pyrtan / tests / test_examples.py
7,701 bytes · last changed 2026-10-27 in Chat: strangers and bots, Omegle-style, on a hub and across a follow; release 0.10.0
| 1 | """The example trees, end to end, against recorded responses.""" |
| 2 | |
| 3 | import pytest |
| 4 | |
| 5 | from pyrtan.tree import check |
| 6 | |
| 7 | |
| 8 | def test_examples_check_clean(examples): |
| 9 | for tree in examples.trees(): |
| 10 | errors = [p for p in check(tree) if p.level == "error"] |
| 11 | assert not errors, (tree.name, errors) |
| 12 | |
| 13 | |
| 14 | def test_top_story(runner): |
| 15 | out = runner.run_intent("What's the top story on Hacker News?") |
| 16 | assert out.status == "done" |
| 17 | assert out.say == "Top of Hacker News: Pyrtan 0.1 — https://example.org/p" |
| 18 | assert out.output["stories"][0]["by"] == "alice" |
| 19 | |
| 20 | |
| 21 | def test_top_n_stories_fills_in_self_post_url(runner): |
| 22 | out = runner.run_intent("top 3 stories on hacker news") |
| 23 | assert out.status == "done" |
| 24 | stories = out.output["stories"] |
| 25 | assert stories[1]["url"] == "https://news.ycombinator.com/item?id=102" |
| 26 | assert stories[2] is None # a comment is not a story: that item failed, the run did not |
| 27 | assert any(s.node == "not-a-story" for s in out.trace.steps) |
| 28 | |
| 29 | |
| 30 | def test_count_is_bounded(runner): |
| 31 | out = runner.run_intent("top 99 stories on hacker news") |
| 32 | assert out.status == "error" and out.reason == "bad-input" |
| 33 | |
| 34 | |
| 35 | def test_item_missing(runner): |
| 36 | out = runner.run_intent("hacker news item 999") |
| 37 | assert out.status == "failed" and out.reason == "no-such-item" |
| 38 | |
| 39 | |
| 40 | def test_small_talk_is_one_decide_on_what_the_trigger_fixed(runner): |
| 41 | for sentence, topic, starts in [("how are you doing?", "how", "Deterministic"), ("Are you human", "who", "A bot"), ("what can you do", "what", "Only what"), ("see ya", "bye", "Bye")]: |
| 42 | out = runner.run_intent(sentence) |
| 43 | assert out.status == "done" and out.tree == "small-talk" and out.output == {"topic": topic} and out.say.startswith(starts), sentence |
| 44 | assert [s.node for s in out.trace.steps if s.kind != "intent"] == ["topic", topic] |
| 45 | # A greeting alone is filler: it reads as nothing, and no tree answers it. |
| 46 | assert runner.run_intent("hello").reason == "no-match" |
| 47 | |
| 48 | |
| 49 | def test_weather_two_hops(runner): |
| 50 | out = runner.run_intent("weather in Oslo") |
| 51 | assert out.status == "done" |
| 52 | assert out.say == "Oslo, Norway: 4.2°C, raining, humidity 80%, wind 12.3 km/h." |
| 53 | assert [s.chosen for s in out.trace.steps if s.kind == "decide"] == ["exists True", "type 'number'", "between [51, 67]"] |
| 54 | |
| 55 | |
| 56 | def test_weather_units_input(runner): |
| 57 | out = runner.run_tree("weather", {"place": "Oslo", "units": "fahrenheit"}) |
| 58 | assert out.status == "done" and "39.6°F, clear" in out.say |
| 59 | out = runner.run_tree("weather", {"place": "Oslo", "units": "kelvin"}) |
| 60 | assert out.status == "error" and out.reason == "bad-input" |
| 61 | |
| 62 | |
| 63 | def test_weather_unknown_place(runner): |
| 64 | out = runner.run_intent("how cold is it in Nowhereville") |
| 65 | assert out.status == "failed" and out.reason == "unknown-place" |
| 66 | |
| 67 | |
| 68 | def test_site_status_branches(runner): |
| 69 | assert runner.run_intent("is Example.com up").status == "done" |
| 70 | down = runner.run_intent("is broken.example up") |
| 71 | assert down.status == "failed" and down.reason == "server-error" |
| 72 | odd = runner.run_intent("check status of teapot.example") |
| 73 | assert odd.status == "done" and "refuses" in odd.say |
| 74 | |
| 75 | |
| 76 | def test_site_status_rejects_bad_host(runner): |
| 77 | # The {host:host} slot refuses what is not a host name, so the trigger |
| 78 | # does not fire at all, and the reading says which trigger refused. |
| 79 | out = runner.run_intent("is http://x up") |
| 80 | assert out.status == "error" and out.reason == "no-match" |
| 81 | assert out.output["reading"]["considered"] == ["is {host:host} up"] |
| 82 | out = runner.run_tree("site-status", {"host": "http://x"}) |
| 83 | assert out.status == "error" and out.reason == "bad-input" # the input's own pattern still guards a direct run |
| 84 | |
| 85 | |
| 86 | def test_github_release(runner): |
| 87 | ok = runner.run_intent("latest release of b-i-t-8/pyrtan") |
| 88 | assert ok.status == "done" and ok.output["tag"] == "v0.1.0" |
| 89 | none = runner.run_intent("latest b-i-t-8/nothing release") |
| 90 | assert none.status == "failed" and none.reason == "no-release" |
| 91 | busy = runner.run_intent("latest release of b-i-t-8/busy") |
| 92 | assert busy.reason == "rate-limited" |
| 93 | |
| 94 | |
| 95 | def test_fixture_fails_loudly_on_unforeseen_request(runner): |
| 96 | out = runner.run_intent("weather in Paris") |
| 97 | assert out.status == "error" and out.reason == "transport" |
| 98 | |
| 99 | |
| 100 | def test_disk_space_over_a_shell(runner): |
| 101 | out = runner.run_intent("how full is the disk") |
| 102 | assert out.status == "done" and out.say == "/ is 71% full." and out.output == {"mounted": "/", "percent": 71} |
| 103 | assert out.trace.steps[1].summary == "shell df -P / -> exit 0; percent=71, mounted=/" |
| 104 | assert runner.run_intent("disk space on /data").reason == "disk-full" |
| 105 | failed = runner.run_intent("disk space on /nowhere") |
| 106 | assert failed.status == "failed" and failed.reason == "df-failed" and "No such file" in failed.say |
| 107 | |
| 108 | |
| 109 | def test_log_errors_from_a_file(runner): |
| 110 | out = runner.run_intent("errors in the log") |
| 111 | assert out.status == "done" and out.say.startswith("2 error line(s) in the last 5 of /var/log/syslog; latest: ") and out.output["errors"] == 2 |
| 112 | assert runner.run_intent("errors in /var/log/quiet.log").say == "No error lines in the last 1 of /var/log/quiet.log." |
| 113 | assert runner.run_intent("errors in /var/log/nothing.log").reason == "no-such-file" |
| 114 | # The path from the sentence is checked against the tree's paths before anything is read. |
| 115 | out = runner.run_intent("errors in /etc/passwd") |
| 116 | assert out.status == "error" and out.reason == "path-not-allowed" and [s.kind for s in out.trace.steps] == ["intent"] |
| 117 | |
| 118 | |
| 119 | def test_room_temperature_from_a_queue(runner): |
| 120 | assert runner.run_intent("temperature in the kitchen").say == "kitchen: 21.5°C." |
| 121 | assert runner.run_intent("how warm is the attic").say == "attic: 31°C, warm." |
| 122 | assert runner.run_intent("temperature in the garage").reason == "no-reading" |
| 123 | gap = runner.run_intent("temperature in the hall") # a payload that is not JSON: a gap, not a guess |
| 124 | assert gap.status == "error" and gap.reason == "gap" and gap.trace.gaps[0]["node"] == "reading" |
| 125 | assert runner.run_tree("room-temperature", {"room": "a-b"}).status == "error" # fixture has nothing for it |
| 126 | |
| 127 | |
| 128 | def test_rendered_headline_through_a_browser(runner): |
| 129 | out = runner.run_intent("headline of https://app.example/dashboard") |
| 130 | assert out.status == "done" and out.say == "Dashboard — Welcome back, Barry" and out.output["heading"] == "Welcome back, Barry" |
| 131 | assert out.trace.steps[1].summary == "browser https://app.example/dashboard -> 200; heading=Welcome back, Barry" |
| 132 | assert runner.run_intent("what does https://app.example/empty say").reason == "no-heading" |
| 133 | assert runner.run_intent("headline of https://app.example/gone").reason == "unexpected-response" |
| 134 | |
| 135 | |
| 136 | def test_modem_check_over_serial(runner): |
| 137 | assert runner.run_intent("is the modem up").say == "The modem on /dev/ttyUSB0 is there." |
| 138 | silent = runner.run_intent("is the modem on /dev/ttyUSB1 up") |
| 139 | assert silent.status == "error" and silent.reason == "transport" and "no answer within 3 s" in silent.say |
| 140 | assert runner.run_intent("is the modem on /dev/ttyACM0 up").reason == "modem-error" |
| 141 | assert runner.run_intent("is the modem on /dev/ttyS0 up").reason == "device-not-allowed" |
| 142 | |
| 143 | |
| 144 | def test_capabilities_name_the_sources_and_lists(examples): |
| 145 | caps = {t["name"]: t for t in examples.capabilities()} |
| 146 | assert caps["disk-space"]["sources"] == ["shell"] and caps["disk-space"]["commands"] == ["df"] and "paths" not in caps["disk-space"] |
| 147 | assert caps["room-temperature"]["sources"] == ["queue"] and caps["room-temperature"]["topics"] == ["home/+/temperature"] |
| 148 | assert caps["weather"]["sources"] == ["http"] and caps["modem-check"]["devices"] == ["/dev/ttyUSB*", "/dev/ttyACM*"] |