Publish tests/ from marimo
Browse files- tests/test_brain.py +155 -0
- tests/test_cli.py +39 -0
- tests/test_desktop.py +94 -0
- tests/test_grid_fallback.py +86 -0
- tests/test_loop.py +48 -1
- tests/test_perception.py +119 -0
tests/test_brain.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
|
| 3 |
+
from tiny_computer_agent import (
|
| 4 |
+
Action, BrainError, DecisionMenu, Element, GoalUnreachable, ModelBrain,
|
| 5 |
+
Observation, extract_json_object, extract_json_objects, parse_decision,
|
| 6 |
+
)
|
| 7 |
+
|
| 8 |
+
FRAME = Observation(elements=(
|
| 9 |
+
Element("Files", x=10, y=5, width=40, height=20),
|
| 10 |
+
Element("Save", x=200, y=260, width=60, height=20),
|
| 11 |
+
Element("Cancel", x=300, y=260, width=60, height=20),
|
| 12 |
+
), text="untitled document")
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def test_menu_is_numbered_in_reading_order_and_capped():
|
| 16 |
+
observation = Observation(elements=tuple(
|
| 17 |
+
Element(f"b{i}", x=(i % 2) * 100, y=(i // 2) * 50) for i in range(6)
|
| 18 |
+
))
|
| 19 |
+
menu = DecisionMenu(observation, max_items=3)
|
| 20 |
+
assert [item.index for item in menu] == [1, 2, 3]
|
| 21 |
+
assert len(menu) == 3 and bool(menu)
|
| 22 |
+
assert "1." in menu.render()
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def test_menu_lookup_tolerates_what_models_send():
|
| 26 |
+
menu = DecisionMenu(FRAME)
|
| 27 |
+
assert menu.item(1).label == "Files"
|
| 28 |
+
assert menu.item("2").label == "Save"
|
| 29 |
+
assert menu.item(2.0).label == "Save"
|
| 30 |
+
assert menu.item(0) is None and menu.item(99) is None and menu.item("x") is None
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def test_menu_resolves_labels_through_normalisation():
|
| 34 |
+
menu = DecisionMenu(Observation(elements=(Element("Save ."),)))
|
| 35 |
+
assert menu.find("save").label == "Save ."
|
| 36 |
+
assert menu.find("nothing").__class__ is type(None)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def test_json_objects_are_extracted_in_order_and_the_last_wins():
|
| 40 |
+
raw = 'First {"kind": "click", "index": 1} then {"kind": "done", "reason": "ok"}'
|
| 41 |
+
assert [o["kind"] for o in extract_json_objects(raw)] == ["click", "done"]
|
| 42 |
+
# a model that echoes its prompt puts its own answer last
|
| 43 |
+
assert extract_json_object(raw)["kind"] == "done"
|
| 44 |
+
with pytest.raises(BrainError, match="no JSON object"):
|
| 45 |
+
extract_json_object("no json here")
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def test_parse_decision_json_index():
|
| 49 |
+
action = parse_decision('{"kind": "click", "index": 2, "reason": "saves"}', FRAME)
|
| 50 |
+
assert action.kind == "click" and action.label == "Save"
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def test_parse_decision_echo_of_the_prompt_is_rejected():
|
| 54 |
+
echoed = '{"kind": "click", "index": 1} {"kind": "click", "index": 2}'
|
| 55 |
+
assert parse_decision(echoed, FRAME).label == "Save" # last object wins
|
| 56 |
+
with pytest.raises(BrainError, match="echo"):
|
| 57 |
+
parse_decision(DecisionMenu(FRAME).render(), FRAME) # 3+ labels mentioned
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def test_parse_decision_bare_number_and_prose_label():
|
| 61 |
+
assert parse_decision("2", FRAME).label == "Save"
|
| 62 |
+
assert parse_decision('Click on "Save" in the dialogue.', FRAME).label == "Save"
|
| 63 |
+
assert parse_decision("The goal is already done.", FRAME).kind == "done"
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def test_parse_decision_rejects_an_index_outside_the_menu():
|
| 67 |
+
with pytest.raises(BrainError, match="not in the"):
|
| 68 |
+
parse_decision('{"kind": "click", "index": 99}', FRAME)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def test_parse_decision_rejects_a_label_that_is_not_on_screen():
|
| 72 |
+
with pytest.raises(BrainError, match="not on screen"):
|
| 73 |
+
parse_decision('{"kind": "click", "label": "Format disk"}', FRAME)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def test_parse_decision_tolerates_a_garbled_verb_when_a_label_is_present():
|
| 77 |
+
# 256M models really do emit {"kind": "file", "label": "Save"}
|
| 78 |
+
action = parse_decision('{"kind": "file", "label": "Save"}', FRAME)
|
| 79 |
+
assert action.kind == "click" and action.label == "Save"
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def test_parse_decision_empty_and_useless_replies():
|
| 83 |
+
for raw in ["", " ", "I am not sure what to do, sorry."]:
|
| 84 |
+
with pytest.raises(BrainError):
|
| 85 |
+
parse_decision(raw, FRAME)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
class _ScriptedModel(ModelBrain):
|
| 89 |
+
name = "scripted"
|
| 90 |
+
|
| 91 |
+
def __init__(self, replies, **kwargs):
|
| 92 |
+
super().__init__("fake/model", **kwargs)
|
| 93 |
+
self._replies = list(replies)
|
| 94 |
+
self.prompts = []
|
| 95 |
+
|
| 96 |
+
def _generate(self, prompt, observation):
|
| 97 |
+
self.prompts.append(prompt)
|
| 98 |
+
return self._replies.pop(0)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def test_model_brain_tells_the_model_what_it_already_tried():
|
| 102 |
+
brain = _ScriptedModel(['{"kind": "click", "index": 2}', '{"kind": "done", "reason": "ok"}'])
|
| 103 |
+
first = brain.decide("save", FRAME)
|
| 104 |
+
assert first.label == "Save"
|
| 105 |
+
assert "Already attempted" not in brain.prompts[0] # nothing tried yet
|
| 106 |
+
|
| 107 |
+
second = brain.decide("save", FRAME) # same screen -> no progress
|
| 108 |
+
prompt = brain.prompts[1]
|
| 109 |
+
assert "Already attempted" in prompt
|
| 110 |
+
assert "screen did NOT change" in prompt
|
| 111 |
+
assert "Choose a DIFFERENT action" in prompt
|
| 112 |
+
assert second.kind == "done"
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def test_model_brain_history_is_bounded():
|
| 116 |
+
brain = _ScriptedModel(['{"kind": "wait", "seconds": 1}'] * 6)
|
| 117 |
+
for _ in range(6):
|
| 118 |
+
brain.decide("wait", FRAME)
|
| 119 |
+
assert len(brain.history) == 3
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def test_prompt_carries_the_goal_the_menu_and_the_done_option():
|
| 123 |
+
brain = _ScriptedModel(['{"kind": "done"}'])
|
| 124 |
+
brain.decide("save the document", FRAME)
|
| 125 |
+
prompt = brain.prompts[0]
|
| 126 |
+
assert "save the document" in prompt
|
| 127 |
+
assert "2. Save (at" in prompt
|
| 128 |
+
assert '"kind": "done"' in prompt
|
| 129 |
+
assert "<number from the list>" in prompt # placeholders, not worked examples
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def test_text_only_brains_stop_when_there_is_nothing_to_click():
|
| 133 |
+
class NeedsIndex(_ScriptedModel):
|
| 134 |
+
requires_index = True
|
| 135 |
+
|
| 136 |
+
with pytest.raises(GoalUnreachable, match="no clickable elements"):
|
| 137 |
+
NeedsIndex(['{"kind": "done"}']).decide("save", Observation(elements=()))
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def test_a_right_answer_in_the_wrong_field_is_recovered():
|
| 141 |
+
"""Real 0.5B output: named the correct element but put it in "reason"."""
|
| 142 |
+
action = parse_decision('{"kind": "click", "index": 0, "reason": "Save"}', FRAME)
|
| 143 |
+
assert action.kind == "click" and action.label == "Save"
|
| 144 |
+
assert "recovered" in action.reason
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def test_recovery_still_refuses_an_unresolvable_index():
|
| 148 |
+
with pytest.raises(BrainError, match="not in the"):
|
| 149 |
+
parse_decision('{"kind": "click", "index": 42, "reason": "no idea"}', FRAME)
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def test_recovery_is_not_ambiguous():
|
| 153 |
+
# two labels named and no usable index -> refuse rather than guess
|
| 154 |
+
with pytest.raises(BrainError):
|
| 155 |
+
parse_decision('{"kind": "click", "index": 0, "reason": "Save or Cancel"}', FRAME)
|
tests/test_cli.py
CHANGED
|
@@ -31,3 +31,42 @@ def test_failure_exit_code(capsys):
|
|
| 31 |
# the planner gives up when the target never appears, so the CLI exits non-zero
|
| 32 |
assert cli.main(["click 'Nonexistent button'"]) == 1
|
| 33 |
assert "gave up" in capsys.readouterr().out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
# the planner gives up when the target never appears, so the CLI exits non-zero
|
| 32 |
assert cli.main(["click 'Nonexistent button'"]) == 1
|
| 33 |
assert "gave up" in capsys.readouterr().out
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def test_doctor_exits_with_a_status(capsys):
|
| 37 |
+
assert cli.main(["--doctor"]) in (0, 1)
|
| 38 |
+
assert "preflight" in capsys.readouterr().out
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def test_live_is_refused_without_a_display(capsys, monkeypatch):
|
| 42 |
+
from tiny_computer_agent.desktop import display_available
|
| 43 |
+
|
| 44 |
+
if display_available()[0]:
|
| 45 |
+
pytest.skip("this machine has a display, so --live would really run")
|
| 46 |
+
assert cli.main(["--live"]) == 2
|
| 47 |
+
captured = capsys.readouterr()
|
| 48 |
+
assert "refusing to run live" in captured.err
|
| 49 |
+
assert "no DISPLAY" in captured.out
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def test_screenshot_replay_needs_no_display(capsys):
|
| 53 |
+
"""The whole pipeline - OCR index, menu, action - on a still image."""
|
| 54 |
+
from tiny_computer_agent.perception import detector_available
|
| 55 |
+
|
| 56 |
+
if not detector_available()[0]:
|
| 57 |
+
pytest.skip("no OCR engine installed")
|
| 58 |
+
assert cli.main(["--image", "example", "--brain", "planner"]) == 0
|
| 59 |
+
out = capsys.readouterr().out
|
| 60 |
+
assert "screenshot replay" in out
|
| 61 |
+
assert "elements detected" in out
|
| 62 |
+
assert "would click" in out
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def test_a_missing_image_is_reported_not_crashed(capsys):
|
| 66 |
+
assert cli.main(["--image", "/nonexistent/shot.png"]) == 2
|
| 67 |
+
assert "could not start" in capsys.readouterr().err
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def test_vlm_brain_requires_a_live_screen(capsys):
|
| 71 |
+
assert cli.main(["--brain", "vlm"]) == 2
|
| 72 |
+
assert "needs --live" in capsys.readouterr().err
|
tests/test_desktop.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
|
| 3 |
+
import platform
|
| 4 |
+
|
| 5 |
+
from tiny_computer_agent import (
|
| 6 |
+
AgentLoop, DryRunExecutor, Element, FakeScreen, MockBrain, PlannerBrain,
|
| 7 |
+
PyAutoGuiExecutor,
|
| 8 |
+
)
|
| 9 |
+
from tiny_computer_agent.desktop import (
|
| 10 |
+
DEFAULT_KILL_SWITCH, Check, build_agent, display_available, doctor, run_checks,
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
FRAME = FakeScreen([(Element("Save"),)])
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class StubDetector:
|
| 17 |
+
name = "stub"
|
| 18 |
+
|
| 19 |
+
def detect(self, image):
|
| 20 |
+
return ()
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def test_display_detection_follows_the_environment(monkeypatch):
|
| 24 |
+
monkeypatch.delenv("DISPLAY", raising=False)
|
| 25 |
+
monkeypatch.delenv("WAYLAND_DISPLAY", raising=False)
|
| 26 |
+
ok, detail = display_available()
|
| 27 |
+
if platform.system() in ("Linux", "FreeBSD"):
|
| 28 |
+
assert ok is False and "DISPLAY" in detail
|
| 29 |
+
monkeypatch.setenv("DISPLAY", ":0")
|
| 30 |
+
assert display_available()[0] is True
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def test_checks_cover_every_piece_the_harness_needs():
|
| 34 |
+
names = [c.name for c in run_checks(brain="planner", detector="ocr")]
|
| 35 |
+
for expected in ("display server", "pillow", "element detector (ocr)",
|
| 36 |
+
"execution mode", "kill switch"):
|
| 37 |
+
assert expected in names
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def test_pyautogui_is_only_required_for_real_input():
|
| 41 |
+
dry = {c.name: c for c in run_checks(real_input=False)}
|
| 42 |
+
live = {c.name: c for c in run_checks(real_input=True)}
|
| 43 |
+
assert dry["input backend (pyautogui)"].required is False
|
| 44 |
+
assert live["input backend (pyautogui)"].required is True
|
| 45 |
+
assert live["execution mode"].detail.startswith("REAL")
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def test_the_planner_needs_no_model_runtime():
|
| 49 |
+
checks = {c.name: c for c in run_checks(brain="planner")}
|
| 50 |
+
assert checks["model runtime"].ok
|
| 51 |
+
assert "no weights" in checks["model runtime"].detail
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def test_a_model_brain_reports_its_runtime():
|
| 55 |
+
checks = {c.name: c for c in run_checks(brain="text-planner")}
|
| 56 |
+
assert "model runtime" in checks
|
| 57 |
+
assert "transformers" in checks["model runtime"].detail
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def test_doctor_gives_a_verdict_and_a_fix(monkeypatch):
|
| 61 |
+
monkeypatch.delenv("DISPLAY", raising=False)
|
| 62 |
+
monkeypatch.delenv("WAYLAND_DISPLAY", raising=False)
|
| 63 |
+
report = doctor(brain="planner")
|
| 64 |
+
assert "preflight" in report
|
| 65 |
+
assert "fix:" in report or "READY" in report
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def test_a_check_renders_its_fix_hint():
|
| 69 |
+
rendered = Check("display server", False, "no display", fix="run on the desktop").render()
|
| 70 |
+
assert "[FAIL]" in rendered and "fix: run on the desktop" in rendered
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def test_build_agent_defaults_to_dry_run_and_the_planner():
|
| 74 |
+
loop = build_agent(screen=FRAME, detector=StubDetector(), brain="planner")
|
| 75 |
+
assert isinstance(loop, AgentLoop)
|
| 76 |
+
assert isinstance(loop.brain, PlannerBrain)
|
| 77 |
+
assert isinstance(loop.executor, DryRunExecutor)
|
| 78 |
+
assert loop.policy.dry_run is True
|
| 79 |
+
assert loop.policy.step_budget == 10
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def test_build_agent_real_input_swaps_the_executor():
|
| 83 |
+
loop = build_agent(screen=FRAME, detector=StubDetector(), brain=MockBrain([]),
|
| 84 |
+
dry_run=False, max_steps=3)
|
| 85 |
+
assert isinstance(loop.executor, PyAutoGuiExecutor)
|
| 86 |
+
assert loop.policy.dry_run is False
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def test_build_agent_wires_the_kill_switch(tmp_path):
|
| 90 |
+
marker = tmp_path / "STOP"
|
| 91 |
+
loop = build_agent(screen=FRAME, detector=StubDetector(), brain=MockBrain([]),
|
| 92 |
+
kill_switch_path=str(marker))
|
| 93 |
+
assert loop.policy.kill_switch_path == str(marker)
|
| 94 |
+
assert DEFAULT_KILL_SWITCH.endswith("tiny-computer-agent.STOP")
|
tests/test_grid_fallback.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The grid fallback: an action space that needs nothing, and its guard rails."""
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
from PIL import Image
|
| 5 |
+
|
| 6 |
+
from tiny_computer_agent import Action, Element, Observation, Policy, PolicyViolation
|
| 7 |
+
from tiny_computer_agent.perception import (
|
| 8 |
+
AutoDetector, GridDetector, OcrDetector, detector_available, make_detector,
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
CELL = Element("cell 12", kind="grid", x=600, y=0, width=60, height=57)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class FixedOcr:
|
| 15 |
+
name = "fixed"
|
| 16 |
+
|
| 17 |
+
def __init__(self, elements=()):
|
| 18 |
+
self.elements = tuple(elements)
|
| 19 |
+
|
| 20 |
+
def detect(self, image):
|
| 21 |
+
return self.elements
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def test_grid_detector_needs_nothing_at_all():
|
| 25 |
+
elements = GridDetector(rows=2, cols=2).detect(Image.new("RGB", (100, 80)))
|
| 26 |
+
assert [e.label for e in elements] == ["cell 1", "cell 2", "cell 3", "cell 4"]
|
| 27 |
+
assert all(e.kind == "grid" for e in elements)
|
| 28 |
+
assert elements[0].center == (25, 20)
|
| 29 |
+
assert elements[3].center == (75, 60)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def test_grid_detector_rejects_a_useless_shape():
|
| 33 |
+
with pytest.raises(ValueError):
|
| 34 |
+
GridDetector(rows=0)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def test_grid_detector_is_always_available_and_default_off():
|
| 38 |
+
assert detector_available("grid")[0] is True
|
| 39 |
+
assert isinstance(make_detector("grid"), GridDetector)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def test_auto_uses_ocr_when_it_finds_text():
|
| 43 |
+
detector = AutoDetector(ocr=FixedOcr((Element("Save"),)), grid=GridDetector(rows=1, cols=1))
|
| 44 |
+
assert [e.label for e in detector.detect(Image.new("RGB", (10, 10)))] == ["Save"]
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def test_auto_falls_back_to_the_grid_when_ocr_finds_nothing():
|
| 48 |
+
detector = AutoDetector(ocr=FixedOcr(()), grid=GridDetector(rows=1, cols=2))
|
| 49 |
+
assert [e.label for e in detector.detect(Image.new("RGB", (10, 10)))] == ["cell 1", "cell 2"]
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def test_a_grid_click_is_fine_while_planning():
|
| 53 |
+
Policy(dry_run=True).check(Action("click", label="cell 12"), Observation(elements=(CELL,)))
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def test_a_grid_click_is_refused_with_real_input():
|
| 57 |
+
"""A blind click on a real desktop must be a deliberate choice, not a default."""
|
| 58 |
+
with pytest.raises(PolicyViolation, match="blind click"):
|
| 59 |
+
Policy(dry_run=False).check(Action("click", label="cell 12"), Observation(elements=(CELL,)))
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def test_a_grid_click_is_refused_even_when_the_index_is_stale():
|
| 63 |
+
with pytest.raises(PolicyViolation, match="blind click"):
|
| 64 |
+
Policy(dry_run=False).check(Action("click", label="cell 47"), Observation())
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def test_a_grid_click_is_allowed_when_asked_for():
|
| 68 |
+
Policy(dry_run=False, allow_grid_clicks=True).check(
|
| 69 |
+
Action("click", label="cell 12"), Observation(elements=(CELL,))
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def test_an_ordinary_click_is_unaffected_by_the_grid_guard():
|
| 74 |
+
Policy(dry_run=False).check(
|
| 75 |
+
Action("click", label="Save"),
|
| 76 |
+
Observation(elements=(Element("Save", x=1, y=2, width=3, height=4),)),
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
@pytest.mark.skipif(not detector_available("ocr")[0], reason="no OCR engine installed")
|
| 81 |
+
def test_real_ocr_beats_the_grid_on_a_real_screenshot():
|
| 82 |
+
from tiny_computer_agent.demo import fake_desktop
|
| 83 |
+
|
| 84 |
+
elements = AutoDetector().detect(fake_desktop())
|
| 85 |
+
assert elements and any(e.kind != "grid" for e in elements)
|
| 86 |
+
assert isinstance(OcrDetector(), OcrDetector)
|
tests/test_loop.py
CHANGED
|
@@ -46,7 +46,8 @@ def test_screen_is_reread_after_every_executed_action():
|
|
| 46 |
|
| 47 |
def test_step_budget_is_enforced():
|
| 48 |
screen = FakeScreen([(Element("Save"),)])
|
| 49 |
-
|
|
|
|
| 50 |
episode = AgentLoop(
|
| 51 |
screen=screen, brain=brain, policy=Policy(step_budget=3)
|
| 52 |
).run("click Save")
|
|
@@ -56,6 +57,52 @@ def test_step_budget_is_enforced():
|
|
| 56 |
assert "budget of 3" in episode.stop_reason
|
| 57 |
|
| 58 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
def test_policy_refusal_stops_the_run_and_nothing_executes():
|
| 60 |
executor = DryRunExecutor()
|
| 61 |
episode = AgentLoop(
|
|
|
|
| 46 |
|
| 47 |
def test_step_budget_is_enforced():
|
| 48 |
screen = FakeScreen([(Element("Save"),)])
|
| 49 |
+
# distinct actions, so the budget is what stops it rather than the no-progress guard
|
| 50 |
+
brain = MockBrain([{"kind": "wait", "seconds": 0.5 + i} for i in range(10)])
|
| 51 |
episode = AgentLoop(
|
| 52 |
screen=screen, brain=brain, policy=Policy(step_budget=3)
|
| 53 |
).run("click Save")
|
|
|
|
| 57 |
assert "budget of 3" in episode.stop_reason
|
| 58 |
|
| 59 |
|
| 60 |
+
def test_a_flailing_agent_is_stopped_instead_of_burning_the_budget():
|
| 61 |
+
"""Repeating one action against an unchanged screen is stuck, not persistent."""
|
| 62 |
+
brain = MockBrain([{"kind": "click", "label": "Save"}] * 10)
|
| 63 |
+
episode = AgentLoop(
|
| 64 |
+
screen=FakeScreen([(Element("Save"),)]), brain=brain,
|
| 65 |
+
policy=Policy(step_budget=10),
|
| 66 |
+
).run("click Save")
|
| 67 |
+
|
| 68 |
+
assert episode.success is False
|
| 69 |
+
assert "no progress" in episode.stop_reason
|
| 70 |
+
assert len(episode.steps) < 10
|
| 71 |
+
assert "unchanged screen" in episode.steps[-1].error
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def test_progress_resets_the_repeat_counter():
|
| 75 |
+
"""Two different actions on an unchanged screen is not flailing."""
|
| 76 |
+
brain = MockBrain([
|
| 77 |
+
{"kind": "wait", "seconds": 0.5},
|
| 78 |
+
{"kind": "click", "label": "Save"},
|
| 79 |
+
{"kind": "wait", "seconds": 0.5},
|
| 80 |
+
{"kind": "click", "label": "Save"},
|
| 81 |
+
{"kind": "done", "reason": "assumed saved"},
|
| 82 |
+
])
|
| 83 |
+
episode = AgentLoop(
|
| 84 |
+
screen=FakeScreen([(Element("Save"),)]), brain=brain,
|
| 85 |
+
policy=Policy(step_budget=10),
|
| 86 |
+
).run("save")
|
| 87 |
+
|
| 88 |
+
assert episode.success
|
| 89 |
+
assert len(episode.steps) == 5
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def test_a_dead_screen_is_reported_as_an_episode_not_a_traceback():
|
| 93 |
+
class Blind:
|
| 94 |
+
name = "blind"
|
| 95 |
+
|
| 96 |
+
def capture(self):
|
| 97 |
+
raise RuntimeError("X connection failed")
|
| 98 |
+
|
| 99 |
+
episode = AgentLoop(screen=Blind(), brain=MockBrain([{"kind": "done"}])).run("anything")
|
| 100 |
+
|
| 101 |
+
assert episode.success is False
|
| 102 |
+
assert episode.stop_reason.startswith("capture failed: RuntimeError")
|
| 103 |
+
assert episode.steps == []
|
| 104 |
+
|
| 105 |
+
|
| 106 |
def test_policy_refusal_stops_the_run_and_nothing_executes():
|
| 107 |
executor = DryRunExecutor()
|
| 108 |
episode = AgentLoop(
|
tests/test_perception.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
|
| 3 |
+
from tiny_computer_agent import Element, FakeScreen, Observation
|
| 4 |
+
from tiny_computer_agent.demo import fake_desktop, write_fake_desktop
|
| 5 |
+
from tiny_computer_agent.observe import ImageScreen
|
| 6 |
+
from tiny_computer_agent.perception import (
|
| 7 |
+
DetectorUnavailable, IndexedScreen, OcrDetector, detector_available, make_detector,
|
| 8 |
+
)
|
| 9 |
+
|
| 10 |
+
BOX = [[10, 10], [80, 10], [80, 30], [10, 30]]
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class FakeDetector:
|
| 14 |
+
name = "fake"
|
| 15 |
+
|
| 16 |
+
def __init__(self, elements=(), error=None):
|
| 17 |
+
self.elements = tuple(elements)
|
| 18 |
+
self.error = error
|
| 19 |
+
|
| 20 |
+
def detect(self, image):
|
| 21 |
+
if self.error is not None:
|
| 22 |
+
raise self.error
|
| 23 |
+
return self.elements
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def test_low_confidence_boxes_are_dropped():
|
| 27 |
+
assert OcrDetector(min_score=0.9)._to_element(BOX, "Save", 0.5) is None
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def test_prose_fragments_and_punctuation_are_not_widgets():
|
| 31 |
+
detector = OcrDetector(max_label_chars=20, min_label_chars=2)
|
| 32 |
+
assert detector._to_element(BOX, "a long sentence that is not a widget", 1.0) is None
|
| 33 |
+
assert detector._to_element(BOX, "S", 1.0) is None
|
| 34 |
+
assert detector._to_element(BOX, " ... ", 1.0) is None
|
| 35 |
+
assert detector._to_element(BOX, " first ", 1.0).label == "first"
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def test_a_box_becomes_a_clickable_element():
|
| 39 |
+
element = OcrDetector()._to_element(BOX, "Save", 0.99)
|
| 40 |
+
assert (element.x, element.y, element.width, element.height) == (10, 10, 70, 20)
|
| 41 |
+
assert element.center == (45, 20)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def test_indexed_screen_replaces_elements_with_detected_ones():
|
| 45 |
+
screen = IndexedScreen(
|
| 46 |
+
FakeScreen([(Element("stale"),)]), FakeDetector((Element("Save"), Element("Cancel")))
|
| 47 |
+
)
|
| 48 |
+
observation = screen.capture()
|
| 49 |
+
assert [e.label for e in observation.elements] == ["Save", "Cancel"]
|
| 50 |
+
assert observation.text == "Save Cancel" # text filled from the index
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def test_indexed_screen_keeps_text_from_the_source():
|
| 54 |
+
screen = IndexedScreen(FakeScreen([(Element("x"),)], text="hello"),
|
| 55 |
+
FakeDetector((Element("Save"),)))
|
| 56 |
+
assert screen.capture().text == "hello"
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def test_a_perception_error_does_not_kill_the_run():
|
| 60 |
+
screen = IndexedScreen(FakeScreen([(Element("x"),)]), FakeDetector(error=ValueError("boom")))
|
| 61 |
+
observation = screen.capture()
|
| 62 |
+
assert observation.elements == ()
|
| 63 |
+
assert "perception error" in observation.text
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def test_a_missing_engine_is_reported_loudly():
|
| 67 |
+
screen = IndexedScreen(FakeScreen([(Element("x"),)]),
|
| 68 |
+
FakeDetector(error=DetectorUnavailable("no engine")))
|
| 69 |
+
with pytest.raises(DetectorUnavailable):
|
| 70 |
+
screen.capture()
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def test_unknown_detector_names_are_reported():
|
| 74 |
+
available, detail = detector_available("nope")
|
| 75 |
+
assert available is False and "unknown detector" in detail
|
| 76 |
+
with pytest.raises(DetectorUnavailable):
|
| 77 |
+
make_detector("nope")
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def test_image_screen_replays_a_still_and_holds_it(tmp_path):
|
| 81 |
+
path = write_fake_desktop(tmp_path / "screen.png")
|
| 82 |
+
screen = ImageScreen(str(path))
|
| 83 |
+
assert screen.capture().frame.size == screen.capture().frame.size
|
| 84 |
+
assert isinstance(screen.capture().text, str)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def test_image_screen_rejects_something_empty():
|
| 88 |
+
with pytest.raises(ValueError):
|
| 89 |
+
ImageScreen([])
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def test_the_synthetic_desktop_looks_like_the_intended_screen():
|
| 93 |
+
image = fake_desktop()
|
| 94 |
+
assert image.size == (720, 460)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
@pytest.mark.skipif(not detector_available()[0], reason="no OCR engine installed")
|
| 98 |
+
def test_real_ocr_reads_a_real_screenshot():
|
| 99 |
+
"""The whole point: pixels in, labelled elements with usable coordinates out."""
|
| 100 |
+
elements = OcrDetector().detect(fake_desktop())
|
| 101 |
+
labels = [e.label for e in elements]
|
| 102 |
+
assert len(elements) >= 3, labels
|
| 103 |
+
assert any(e.matches("Save") for e in elements), labels
|
| 104 |
+
|
| 105 |
+
save = next(e for e in elements if e.matches("Save"))
|
| 106 |
+
# the Save button is drawn at (200..380, 265..300)
|
| 107 |
+
assert 200 <= save.center[0] <= 380
|
| 108 |
+
assert 260 <= save.center[1] <= 310
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
@pytest.mark.skipif(not detector_available()[0], reason="no OCR engine installed")
|
| 112 |
+
def test_ocr_index_plugs_straight_into_a_click():
|
| 113 |
+
"""Label -> pixel resolution on coordinates the detector actually produced."""
|
| 114 |
+
from tiny_computer_agent import Action, resolve_point
|
| 115 |
+
|
| 116 |
+
observation = Observation(elements=OcrDetector().detect(fake_desktop()))
|
| 117 |
+
point = resolve_point(Action("click", label="Save"), observation)
|
| 118 |
+
assert point is not None
|
| 119 |
+
assert 200 <= point[0] <= 380
|