3,981 bytes · last changed 2026-10-25 in Read sentences with rules: rewrites, typed slots, synonyms, typos, pyrtan match
| 1 | """An unanswered sentence becomes a proposed tree, a scaffold that checks, |
| 2 | and an entry in the unanswered log.""" |
| 3 | |
| 4 | import pytest |
| 5 | |
| 6 | from pyrtan.grow import Proposal, Unanswered, no_match_outcome, propose |
| 7 | from pyrtan.intent import Trigger |
| 8 | from pyrtan.tree import check, load_tree_text |
| 9 | |
| 10 | |
| 11 | @pytest.mark.parametrize( |
| 12 | "text, name, trigger, inputs", |
| 13 | [ |
| 14 | ("top 3 stories on hacker news", "top-stories-hacker-news", "top {count:int} stories on hacker news", {"count": 3}), |
| 15 | ("is example.com up", "host-up", "is {host:host} up", {"host": "example.com"}), |
| 16 | ("what's the latest release of astral-sh/uv", "latest-release", "what's the latest release of {target:word}", {"target": "astral-sh/uv"}), |
| 17 | ("convert 12.5 km to miles", "convert-km-miles", "convert {value:float} km to miles", {"value": 12.5}), |
| 18 | ("turn on the lights", "turn-lights", "turn on the lights", {}), |
| 19 | ("Weather in Oslo?", "weather-oslo", "Weather in Oslo", {}), |
| 20 | ("ping 1 then 2", "ping-then", "ping {count:int} then {count2:int}", {"count": 1, "count2": 2}), |
| 21 | ], |
| 22 | ) |
| 23 | def test_propose_names_slots_and_matches_the_sentence(text, name, trigger, inputs): |
| 24 | p = propose(text) |
| 25 | assert (p.name, p.trigger) == (name, trigger) |
| 26 | assert Trigger(p.trigger).match(text) == inputs |
| 27 | assert set(p.inputs) == set(inputs) |
| 28 | |
| 29 | |
| 30 | def test_propose_survives_trigger_syntax_and_bare_numbers(): |
| 31 | p = propose("{weird} [tokens] | here") |
| 32 | Trigger(p.trigger) # compiles |
| 33 | assert "{" not in p.trigger.replace("{count", "") |
| 34 | p = propose("42") |
| 35 | assert p.trigger == "42" and p.inputs == {} |
| 36 | assert propose("").name == "new-tree" |
| 37 | |
| 38 | |
| 39 | def test_given_name_wins(): |
| 40 | assert propose("my thing", name="custom").name == "custom" |
| 41 | |
| 42 | |
| 43 | @pytest.mark.parametrize("text", ["top 3 stories on hacker news", "is example.com up", "what's the latest release of astral-sh/uv", 'say "hi" to {everyone}', "turn on the lights"]) |
| 44 | def test_scaffold_loads_and_checks_clean(text): |
| 45 | p = propose(text) |
| 46 | tree = load_tree_text(p.scaffold()) |
| 47 | assert tree.name == p.name |
| 48 | assert [t.pattern for t in tree.triggers] == [p.trigger] |
| 49 | assert [x for x in check(tree) if x.level == "error"] == [] |
| 50 | assert set(tree.inputs) == set(p.inputs) |
| 51 | |
| 52 | |
| 53 | def test_host_slot_opens_hosts_and_builds_the_url(): |
| 54 | tree = load_tree_text(propose("is example.com up").scaffold()) |
| 55 | assert tree.hosts == ["*"] |
| 56 | assert "inputs.host" in tree.node("fetch").get("http")["url"] |
| 57 | |
| 58 | |
| 59 | def test_command_quotes_for_a_shell(): |
| 60 | assert propose("top 3 stories on hacker news").command() == 'pyrtan new top-stories-hacker-news --from "top 3 stories on hacker news"' |
| 61 | assert propose("what's up").command("trees") == 'pyrtan new up --from "what\'s up" --dir trees' |
| 62 | assert propose("costs $5").command().endswith("--from 'costs $5'") |
| 63 | |
| 64 | |
| 65 | def test_no_match_outcome_points_at_the_next_tree(): |
| 66 | out = no_match_outcome("tell me a joke", "Nothing here") |
| 67 | assert out["say"].startswith("Nothing here answers 'tell me a joke'. To write the tree that would: pyrtan new joke --from") |
| 68 | assert out["output"]["unanswered"] == "tell me a joke" |
| 69 | assert out["output"]["proposal"]["name"] == "joke" |
| 70 | |
| 71 | |
| 72 | def test_unanswered_counts_and_bounds(): |
| 73 | log = Unanswered(limit=2) |
| 74 | first = log.record("Tell me a joke") |
| 75 | assert first["count"] == 1 and first["proposal"]["name"] == "joke" |
| 76 | assert log.record("tell me a joke!")["count"] == 2 # same sentence, normalised |
| 77 | log.record("second") |
| 78 | log.record("third") |
| 79 | texts = [e["text"] for e in log.to_list()] |
| 80 | assert texts == ["third", "second"] # the least recently seen went |
| 81 | log = Unanswered() |
| 82 | log.record("rare") |
| 83 | log.record("common") |
| 84 | log.record("common") |
| 85 | assert [e["count"] for e in log.to_list()] == [2, 1] # most seen first |
| 86 | |
| 87 | |
| 88 | def test_proposal_round_trips_through_its_dict(): |
| 89 | p = propose("is example.com up") |
| 90 | d = p.to_dict() |
| 91 | again = Proposal(d["text"], d["name"], d["trigger"], d["inputs"]) |
| 92 | assert again.scaffold() == p.scaffold() |