pyrtan / sourcev0.14.0 · main

pyrtan / tests / test_tree.py

5,998 bytes · last changed 2026-10-25 in Read sentences with rules: rewrites, typed slots, synonyms, typos, pyrtan match

1import pytest
2
3from pyrtan.tree import TreeError, check, load_tree_text
4
5MINIMAL = """
6name = "t"
7description = "a test"
8hosts = ["example.com"]
9start = "get"
10
11[nodes.get]
12kind = "observe"
13http = { url = "https://example.com/" }
14into = "r"
15next = "ok"
16
17[nodes.ok]
18kind = "done"
19say = "{{ state.r.status }}"
20"""
21
22
23def test_minimal_loads_clean():
24 tree = load_tree_text(MINIMAL)
25 assert tree.name == "t" and tree.start == "get"
26 assert [p for p in check(tree) if p.level != "info"] == []
27 assert tree.capability()["triggers"] == []
28
29
30def problems(text: str) -> list[str]:
31 try:
32 tree = load_tree_text(text)
33 except TreeError as e:
34 return [str(e)]
35 return [str(p) for p in check(tree)]
36
37
38def test_start_buried_in_table_gets_a_hint():
39 text = MINIMAL.replace('start = "get"\n', "") + '\nstart = "get"\n'
40 with pytest.raises(TreeError, match="before the first"):
41 load_tree_text(text)
42
43
44def test_missing_target():
45 text = MINIMAL.replace('next = "ok"', 'next = "nope"')
46 with pytest.raises(TreeError, match="does not exist"):
47 load_tree_text(text)
48
49
50def test_cycles_are_refused():
51 text = MINIMAL + """
52[nodes.loop-a]
53kind = "set"
54set = { x = 1 }
55next = "loop-b"
56
57[nodes.loop-b]
58kind = "set"
59set = { x = 2 }
60next = "loop-a"
61"""
62 with pytest.raises(TreeError, match="do not loop"):
63 load_tree_text(text)
64
65
66def test_host_not_listed():
67 with pytest.raises(TreeError, match="not in this tree's hosts"):
68 load_tree_text(MINIMAL.replace('hosts = ["example.com"]', 'hosts = ["other.com"]'))
69 with pytest.raises(TreeError, match="must list the hosts"):
70 load_tree_text(MINIMAL.replace('hosts = ["example.com"]', ""))
71
72
73def test_wildcard_hosts():
74 tree = load_tree_text(MINIMAL.replace('hosts = ["example.com"]', 'hosts = ["*.example.com"]').replace("https://example.com/", "https://api.example.com/"))
75 assert tree.host_allowed("https://api.example.com/x")
76 assert tree.host_allowed("https://example.com/")
77 assert not tree.host_allowed("https://example.org/")
78 assert not tree.host_allowed("not a url")
79
80
81def test_observe_with_write_method_is_refused():
82 with pytest.raises(TreeError, match="make this an act"):
83 load_tree_text(MINIMAL.replace('http = { url = "https://example.com/" }', 'http = { method = "POST", url = "https://example.com/" }'))
84
85
86def test_undeclared_input_in_template():
87 with pytest.raises(TreeError, match="inputs.city, which is not declared"):
88 load_tree_text(MINIMAL.replace("https://example.com/", "https://example.com/{{ inputs.city }}"))
89
90
91def test_trigger_slot_must_be_an_input():
92 text = MINIMAL + '\n[[triggers]]\npattern = "get {thing}"\n'
93 with pytest.raises(TreeError, match="not a declared input"):
94 load_tree_text(text)
95
96
97def test_unreachable_node_warns_and_decide_without_else_informs():
98 text = MINIMAL + """
99[nodes.lonely]
100kind = "decide"
101on = "r.status"
102branches = [ { when = { eq = 200 }, then = "ok" } ]
103"""
104 notes = problems(text)
105 assert any("not reachable" in n for n in notes)
106 assert any("no 'else'" in n for n in notes)
107
108
109def test_state_reference_nobody_produces_warns():
110 notes = problems(MINIMAL.replace("{{ state.r.status }}", "{{ state.ghost }}"))
111 assert any("state.ghost, which no node produces" in n for n in notes)
112
113
114def test_bad_input_definitions():
115 with pytest.raises(TreeError, match="unknown type"):
116 load_tree_text(MINIMAL + '\n[inputs.x]\ntype = "money"\n')
117 with pytest.raises(TreeError, match="must be lowercase"):
118 load_tree_text(MINIMAL.replace('name = "t"', 'name = "Not Ok"'))
119
120
121def test_bind_inputs_defaults_coercion_and_limits():
122 tree = load_tree_text(MINIMAL + '\n[inputs.n]\ntype = "int"\ndefault = 1\nmin = 1\nmax = 3\n\n[inputs.flag]\ntype = "bool"\ndefault = false\n\n[inputs.name]\ntype = "str"\n')
123 assert tree.bind_inputs({"name": "x"}) == {"n": 1, "flag": False, "name": "x"}
124 assert tree.bind_inputs({"name": "x", "n": "2", "flag": "yes"}) == {"n": 2, "flag": True, "name": "x"}
125 with pytest.raises(TreeError, match="required"):
126 tree.bind_inputs({})
127 with pytest.raises(TreeError, match="at most 3"):
128 tree.bind_inputs({"name": "x", "n": 4})
129 with pytest.raises(TreeError, match="unknown inputs"):
130 tree.bind_inputs({"name": "x", "zzz": 1})
131 with pytest.raises(TreeError, match="must be int"):
132 tree.bind_inputs({"name": "x", "n": "two"})
133
134
135def test_each_needs_tree_xor_start_and_into():
136 base = MINIMAL + """
137[nodes.walk]
138kind = "each"
139over = "r.json"
140into = "out"
141next = "ok"
142"""
143 with pytest.raises(TreeError, match="exactly one of 'tree'"):
144 load_tree_text(base)
145 with pytest.raises(TreeError, match="exactly one of 'tree'"):
146 load_tree_text(base.replace('over = "r.json"', 'over = "r.json"\ntree = "a"\nstart = "ok"'))
147 notes = problems(base.replace('over = "r.json"', 'over = "r.json"\ntree = "a"'))
148 assert any("no 'limit'" in n for n in notes)
149
150
151def test_slot_kinds_must_fit_their_inputs():
152 base = 'name = "t"\nstart = "s"\n[[triggers]]\npattern = "{pat}"\n[inputs.x]\ntype = "{typ}"\n[nodes.s]\nkind = "done"\nsay = "x"\n'
153 for pat, typ in [("at {x:date}", "int"), ("wait {x:duration}", "bool"), ("n {x:float}", "int"), ("on {x:host}", "list")]:
154 with pytest.raises(TreeError, match="produces a"):
155 load_tree_text(base.format(pat=pat, typ=typ))
156 for pat, typ in [("at {x:date}", "str"), ("wait {x:duration}", "int"), ("wait {x:duration}", "str"), ("n {x:number}", "int"), ("n {x:number}", "float"), ("n {x:int}", "float")]:
157 load_tree_text(base.format(pat=pat, typ=typ))
158 with pytest.raises(TreeError, match="language.synonyms"):
159 load_tree_text('name = "t"\nstart = "s"\n[language]\nsynonyms = "no"\n[nodes.s]\nkind = "done"\nsay = "x"\n')
160 with pytest.raises(TreeError, match="unknown key"):
161 load_tree_text('name = "t"\nstart = "s"\n[language]\nstemming = true\n[nodes.s]\nkind = "done"\nsay = "x"\n')