hkblockchaintechnology commited on
Commit
16e3e7d
·
verified ·
1 Parent(s): b038669

Publish tests/ from marimo

Browse files
tests/conftest.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared test helpers.
2
+
3
+ The OCR guard used to live inline in two files, written two slightly different
4
+ ways - and on a machine with no OCR engine one of them never fired at all. It is
5
+ one function now, and `test_ocr_guard.py` proves it actually skips.
6
+ """
7
+
8
+ import pytest
9
+
10
+
11
+ def require_ocr() -> None:
12
+ """Skip the calling test unless a real OCR engine is installed.
13
+
14
+ Deliberately asks for the **ocr** detector, not the ``auto`` one: ``auto`` is
15
+ available even with nothing installed, because it falls back to the numbered
16
+ grid, so guarding on it silently *never* skips.
17
+ """
18
+ from tiny_computer_agent.perception import detector_available
19
+
20
+ available, detail = detector_available("ocr")
21
+ if not available:
22
+ pytest.skip(f"no OCR engine installed: {detail}")
tests/test_accessibility.py ADDED
@@ -0,0 +1,278 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The accessibility-tree detector: exact elements, and honest failures.
2
+
3
+ Nothing here needs a desktop. The tree is injected through `TreeReader`, so the
4
+ filtering, the geometry and the coordinate contract are all tested for real; the
5
+ one test that drives actual AT-SPI is opt-in, like the live-desktop tests.
6
+ """
7
+
8
+ import os
9
+
10
+ import pytest
11
+ from PIL import Image
12
+
13
+ from tiny_computer_agent import (
14
+ INTERACTIVE_ROLES, TEXT_ROLES, AccessibleNode, AtspiDetector, AtspiReader,
15
+ AutoDetector, Element, IndexedScreen, Observation, detector_available,
16
+ make_detector,
17
+ )
18
+ from tiny_computer_agent.accessibility import accessibility_available
19
+ from tiny_computer_agent.perception import DetectorUnavailable
20
+
21
+
22
+ class FakeReader:
23
+ """A tree, exactly as AT-SPI would report it."""
24
+
25
+ name = "fake"
26
+
27
+ def __init__(self, nodes):
28
+ self.nodes = tuple(nodes)
29
+ self.reads = 0
30
+
31
+ def read(self):
32
+ self.reads += 1
33
+ return self.nodes
34
+
35
+
36
+ FRAME = Image.new("RGB", (1000, 700), (20, 22, 28)) # a frame, for grid fallback
37
+
38
+
39
+ def node(role, name, x=100, y=100, width=100, height=36, depth=2):
40
+ return AccessibleNode(role, name, x, y, width, height, depth)
41
+
42
+
43
+ TREE = [
44
+ node("application", "gtk_dialog.py", 0, 0, 0, 0, 0),
45
+ node("frame", "Accessible desktop", 160, 160, 460, 220, 1),
46
+ node("panel", "", 174, 174, 432, 192, 2),
47
+ node("button", "Files", 174, 201, 100, 36),
48
+ node("button", "Terminal", 282, 201, 100, 36),
49
+ node("button", "Save", 390, 201, 100, 36),
50
+ node("button", "Cancel", 498, 201, 100, 36),
51
+ node("label", "Choose an action", 174, 245, 424, 19),
52
+ node("label", "last action: (none)", 174, 174, 424, 19),
53
+ ]
54
+
55
+
56
+ # -- what the tree gives us --------------------------------------------------
57
+
58
+ def test_the_tree_yields_labelled_widgets_with_real_coordinates():
59
+ elements = AtspiDetector(reader=FakeReader(TREE)).detect()
60
+ assert [(e.label, e.kind) for e in elements] == [
61
+ ("Files", "button"), ("Terminal", "button"), ("Save", "button"), ("Cancel", "button"),
62
+ ]
63
+ save = next(e for e in elements if e.label == "Save")
64
+ assert (save.x, save.y, save.width, save.height) == (390, 201, 100, 36)
65
+ assert save.center == (440, 219)
66
+
67
+
68
+ def test_window_furniture_is_not_offered_as_a_choice():
69
+ """A frame and a panel have names too; giving them to a model is a trap."""
70
+ roles = {e.kind for e in AtspiDetector(reader=FakeReader(TREE)).detect()}
71
+ assert "frame" not in roles and "panel" not in roles and "application" not in roles
72
+ assert roles <= INTERACTIVE_ROLES
73
+
74
+
75
+ def test_labels_can_be_read_without_being_clickable():
76
+ plain = AtspiDetector(reader=FakeReader(TREE))
77
+ with_text = AtspiDetector(reader=FakeReader(TREE), include_text=True)
78
+ assert not any(e.kind == "label" for e in plain.detect())
79
+ assert [e.label for e in with_text.detect() if e.kind == "label"] == [
80
+ "Choose an action", "last action: (none)"]
81
+ assert "Choose an action" in with_text.text()
82
+
83
+
84
+ def test_labels_are_filtered_by_length_and_size():
85
+ tree = [node("button", "x"), node("button", "a" * 80), node("button", "Sneaky", 5, 5, 2, 2)]
86
+
87
+ strict = AtspiDetector(reader=FakeReader(tree), min_label_chars=2)
88
+ assert [e.label for e in strict.detect()] == [] # too short, too long, too small
89
+
90
+ loose = AtspiDetector(reader=FakeReader(tree), min_label_chars=1,
91
+ max_label_chars=6, min_size=1)
92
+ assert [e.label for e in loose.detect()] == ["x", "Sneaky"] # 80 chars still too long
93
+
94
+
95
+ def test_off_screen_and_duplicate_nodes_are_dropped():
96
+ tree = [node("button", "Save"), node("button", "Save"),
97
+ node("button", "Ghost", -400, -400)]
98
+ assert [e.label for e in AtspiDetector(reader=FakeReader(tree)).detect()] == ["Save"]
99
+
100
+
101
+ def test_the_detector_ignores_the_image_it_is_handed():
102
+ reader = FakeReader(TREE)
103
+ detector = AtspiDetector(reader=reader)
104
+ detector.detect(None)
105
+ detector.detect(object()) # any object at all
106
+ assert reader.reads == 2
107
+
108
+
109
+ # -- the coordinate contract -------------------------------------------------
110
+
111
+ class ScaledSource:
112
+ """A source whose frames are downscaled: every element needs re-mapping."""
113
+
114
+ def __init__(self):
115
+ self.origin = (0, 0)
116
+ self.frame_scale = 2.0
117
+
118
+ def capture(self):
119
+ return Observation(index=0, frame=FRAME)
120
+
121
+ def map_to_screen(self, element):
122
+ return Element(label=element.label, kind=element.kind, x=element.x * 2,
123
+ y=element.y * 2, width=element.width * 2, height=element.height * 2)
124
+
125
+
126
+ def test_screen_coordinates_are_not_double_mapped_by_a_downscaled_capture():
127
+ """AT-SPI already speaks screen pixels; a downscale must not scale them again."""
128
+ detector = AtspiDetector(reader=FakeReader([node("button", "Save", 390, 201, 100, 36)]))
129
+ observation = IndexedScreen(ScaledSource(), detector).capture()
130
+ save = observation.elements[0]
131
+ assert (save.x, save.y) == (390, 201), "the accessibility tree was scaled twice"
132
+
133
+
134
+ def test_a_frame_relative_detector_is_still_mapped():
135
+ """The opt-out is per-detector: a grid in a downscaled frame must be mapped."""
136
+ from tiny_computer_agent import GridDetector
137
+ observation = IndexedScreen(ScaledSource(), GridDetector(rows=1, cols=2)).capture()
138
+ assert [e.x for e in observation.elements] == [0, 1000] # 500 * frame_scale of 2
139
+
140
+
141
+ # -- honest failures --------------------------------------------------------
142
+
143
+ def test_a_reader_that_cannot_reach_the_bus_raises_rather_than_lying():
144
+ class Broken:
145
+ name = "broken"
146
+
147
+ def read(self):
148
+ raise DetectorUnavailable("no a11y bus")
149
+
150
+ with pytest.raises(DetectorUnavailable):
151
+ AtspiDetector(reader=Broken()).detect()
152
+
153
+
154
+ def test_ui_automation_is_reported_as_not_implemented():
155
+ ok, detail = accessibility_available("uia")
156
+ assert ok is False and "not implemented" in detail
157
+ assert accessibility_available("telepathy")[0] is False
158
+
159
+
160
+ def test_the_reader_itself_works_without_a_desktop_at_all():
161
+ """AT-SPI is present here, so reading an empty desktop returns no nodes."""
162
+ available, detail = accessibility_available("atspi")
163
+ if not available:
164
+ pytest.skip(f"no AT-SPI here: {detail}")
165
+ assert isinstance(AtspiReader().read(), tuple)
166
+
167
+
168
+ # -- the fallback chain ------------------------------------------------------
169
+
170
+ def test_best_uses_the_tree_when_it_has_content():
171
+ chains = []
172
+
173
+ class RecordingAccessibility:
174
+ name = "a11y"
175
+
176
+ def detect(self, image=None):
177
+ chains.append("accessibility")
178
+ return (Element("Save", kind="button", x=390, y=201, width=100, height=36),)
179
+
180
+ class RecordingOcr:
181
+ name = "ocr"
182
+
183
+ def detect(self, image):
184
+ chains.append("ocr")
185
+ return (Element("Guessed"),)
186
+
187
+ detector = AutoDetector(ocr=RecordingOcr(), accessibility=RecordingAccessibility())
188
+ assert [e.label for e in detector.detect(None)] == ["Save"]
189
+ assert chains == ["accessibility"]
190
+
191
+
192
+ def test_best_falls_through_to_ocr_then_the_grid():
193
+ from tiny_computer_agent import GridDetector
194
+
195
+ class EmptyTree:
196
+ name = "a11y"
197
+
198
+ def detect(self, image=None):
199
+ return ()
200
+
201
+ class BrokenOcr:
202
+ name = "ocr"
203
+
204
+ def detect(self, image):
205
+ raise DetectorUnavailable("no engine")
206
+
207
+ with_ocr = AutoDetector(ocr=type("Ocr", (), {
208
+ "detect": lambda self, image: (Element("From OCR"),)})(), accessibility=EmptyTree())
209
+ assert [e.label for e in with_ocr.detect(FRAME)] == ["From OCR"]
210
+
211
+ without_ocr = AutoDetector(ocr=BrokenOcr(), grid=GridDetector(rows=1, cols=2),
212
+ accessibility=EmptyTree())
213
+ assert [e.label for e in without_ocr.detect(FRAME)] == ["cell 1", "cell 2"]
214
+
215
+
216
+ def test_the_auto_detector_still_defaults_to_ocr_then_grid():
217
+ """The new chain is opt-in: `auto` behaviour must not change."""
218
+ assert AutoDetector().accessibility is None
219
+
220
+
221
+ def test_the_new_detector_names_are_reachable():
222
+ assert type(make_detector("atspi")).__name__ == "AtspiDetector"
223
+ assert type(make_detector("best")).__name__ == "AutoDetector"
224
+ ok, detail = detector_available("atspi")
225
+ assert isinstance(ok, bool) and detail
226
+ assert TEXT_ROLES and "label" in TEXT_ROLES
227
+
228
+
229
+ # -- the one test that drives real AT-SPI ------------------------------------
230
+
231
+ OPT_IN = os.environ.get("TINY_COMPUTER_AGENT_LIVE_TEST") == "1"
232
+
233
+
234
+ @pytest.mark.skipif(not OPT_IN, reason="starts a real GTK window under a session bus")
235
+ def test_atspi_reads_a_real_gtk_button_at_the_right_pixels(tmp_path):
236
+ import pathlib
237
+ import subprocess
238
+ import sys
239
+ import time
240
+
241
+ if not accessibility_available("atspi")[0]:
242
+ pytest.skip("no AT-SPI on this machine")
243
+ if not shutil_which("dbus-daemon"):
244
+ pytest.skip("no dbus-daemon")
245
+
246
+ address = subprocess.run(["dbus-daemon", "--session", "--fork", "--print-address=1"],
247
+ capture_output=True, text=True).stdout.strip()
248
+ os.environ["DBUS_SESSION_BUS_ADDRESS"] = address
249
+ os.environ.setdefault("DISPLAY", ":99")
250
+ os.environ["NO_AT_BRIDGE"] = "0"
251
+
252
+ ready = pathlib.Path(tmp_path) / "gtk.ready"
253
+ app = subprocess.Popen(
254
+ [sys.executable, "showcase_scenes/gtk_dialog.py", str(tmp_path / "marker"), str(ready)])
255
+ try:
256
+ deadline = time.time() + 25
257
+ while time.time() < deadline and not ready.exists():
258
+ if app.poll() is not None:
259
+ pytest.fail("the GTK window exited early")
260
+ time.sleep(0.25)
261
+ assert ready.exists(), "the GTK window never mapped"
262
+ time.sleep(3)
263
+
264
+ elements = AtspiDetector(reader=AtspiReader()).detect()
265
+ labels = [e.label for e in elements]
266
+ assert "Save" in labels, f"the tree had no Save button: {labels}"
267
+ save = next(e for e in elements if e.label == "Save")
268
+ # the window is at +160+160 and the buttons are 100x36, laid out left to right
269
+ assert 160 < save.x < 160 + 460 and 160 < save.y < 160 + 220, save
270
+ assert (save.width, save.height) == (100, 36)
271
+ finally:
272
+ app.terminate()
273
+ app.wait(timeout=10)
274
+
275
+
276
+ def shutil_which(name):
277
+ import shutil
278
+ return shutil.which(name)
tests/test_cli.py CHANGED
@@ -51,13 +51,9 @@ def test_live_is_refused_without_a_display(capsys, monkeypatch):
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
- # detector_available() defaults to "auto", which is True whenever the grid
57
- # fallback exists - so it never skips. Ask for the OCR engine specifically:
58
- # the pipeline below needs it to find the button.
59
- if not detector_available("ocr")[0]:
60
- pytest.skip("no OCR engine installed")
61
  assert cli.main(["--image", "example", "--brain", "planner"]) == 0
62
  out = capsys.readouterr().out
63
  assert "screenshot replay" in out
 
51
 
52
  def test_screenshot_replay_needs_no_display(capsys):
53
  """The whole pipeline - OCR index, menu, action - on a still image."""
54
+ from conftest import require_ocr
55
 
56
+ require_ocr() # one tested guard, shared with the perception tests
 
 
 
 
57
  assert cli.main(["--image", "example", "--brain", "planner"]) == 0
58
  out = capsys.readouterr().out
59
  assert "screenshot replay" in out
tests/test_decoding.py ADDED
@@ -0,0 +1,277 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Constrained decoding: make an echo impossible instead of merely detected."""
2
+
3
+ import pytest
4
+
5
+ from tiny_computer_agent import (
6
+ Action, BrainError, DecisionMenu, Element, GoalUnreachable, ModelBrain, Observation,
7
+ )
8
+ from tiny_computer_agent.decoding import (
9
+ MenuMask, brevity_ladder, choice_token_ids, digit_token_ids, majority_choice, read_choice,
10
+ )
11
+
12
+ FRAME = Observation(elements=(
13
+ Element("Files", x=10, y=6, width=60, height=20),
14
+ Element("Save", x=200, y=270, width=90, height=30),
15
+ Element("Cancel", x=400, y=270, width=90, height=30),
16
+ ), text="Untitled document - unsaved changes")
17
+
18
+
19
+ class FakeTokenizer:
20
+ """A tokenizer where each digit is one token (bare and space-prefixed)."""
21
+
22
+ def __init__(self, expressible=True):
23
+ self.expressible = expressible
24
+ self.table = {}
25
+ if expressible:
26
+ for digit in range(10):
27
+ self.table[str(digit)] = 100 + digit
28
+ self.table[" " + str(digit)] = 200 + digit
29
+ for index, word in enumerate(("yes", "Yes", "no", "No")):
30
+ self.table[word] = 300 + index
31
+ self.table[" " + word] = 320 + index
32
+
33
+ def encode(self, text, add_special_tokens=False):
34
+ if text in self.table:
35
+ return [self.table[text]]
36
+ return [999, 999] # never a single token
37
+
38
+
39
+ # -- token ids ---------------------------------------------------------------
40
+
41
+ def test_digit_ids_cover_every_digit_exactly_once():
42
+ ids = digit_token_ids(FakeTokenizer())
43
+ assert len(ids) == 20 # 0-9, bare and space-prefixed
44
+ assert set(range(100, 110)) <= set(ids)
45
+
46
+
47
+ def test_choice_ids_cover_only_the_legal_answers():
48
+ assert sorted(choice_token_ids(FakeTokenizer(), 3)) == sorted([100, 101, 102, 103, 200, 201, 202, 203])
49
+ assert sorted(choice_token_ids(FakeTokenizer(), 2, allow_done=False)) == sorted([101, 102, 201, 202])
50
+
51
+
52
+ def test_a_tokenizer_that_cannot_spell_a_number_yields_no_ids():
53
+ assert digit_token_ids(FakeTokenizer(expressible=False)) == []
54
+ assert choice_token_ids(FakeTokenizer(expressible=False), 5) == []
55
+
56
+
57
+ # -- the mask ----------------------------------------------------------------
58
+
59
+ def test_the_mask_forbids_everything_that_is_not_a_menu_number():
60
+ torch = pytest.importorskip("torch")
61
+ scores = torch.zeros(1, 8)
62
+ masked = MenuMask([1, 3])(None, scores)
63
+ assert masked[0, 1].item() == 0 and masked[0, 3].item() == 0
64
+ for blocked in (0, 2, 4, 5, 6, 7):
65
+ assert masked[0, blocked].item() == float("-inf")
66
+ assert MenuMask([1, 3]).allowed_ids == (1, 3)
67
+
68
+
69
+ def test_the_mask_keeps_the_end_of_sequence_reachable_after_one_token():
70
+ torch = pytest.importorskip("torch")
71
+ process = MenuMask([1], eos_token_id=7)
72
+ first = process(None, torch.zeros(1, 8))
73
+ assert first[0, 7].item() == float("-inf"), "stopping immediately is not an answer"
74
+ assert first[0, 1].item() == 0
75
+ second = process(None, torch.zeros(1, 8))
76
+ assert second[0, 7].item() == 0 # ...but it can stop after answering
77
+ assert second[0, 2].item() == float("-inf")
78
+
79
+
80
+ def test_the_mask_can_be_told_not_to_force_a_token():
81
+ torch = pytest.importorskip("torch")
82
+ process = MenuMask([1], eos_token_id=7, force_first=False)
83
+ assert process(None, torch.zeros(1, 8))[0, 7].item() == 0
84
+
85
+
86
+ # -- reading an answer -------------------------------------------------------
87
+
88
+ def test_read_choice_reads_only_the_digits():
89
+ assert read_choice("3", 12) == 3
90
+ assert read_choice(" 3 ", 12) == 3
91
+ assert read_choice("3.", 12) == 3
92
+ assert read_choice("12", 12) == 12
93
+ assert read_choice("0", 12) == 0 # 0 is "already done"
94
+ assert read_choice("I would click item 2", 12) == 2
95
+
96
+
97
+ def test_read_choice_refuses_what_is_not_exactly_one_answer():
98
+ assert read_choice("", 12) is None
99
+ assert read_choice("no idea", 12) is None
100
+ assert read_choice("13", 12) is None # outside the menu
101
+ assert read_choice("2 or 3", 12) is None # 23: ambiguous, so refused
102
+ assert read_choice("0", 12, allow_done=False) is None
103
+
104
+
105
+ # -- the ladder and the vote -------------------------------------------------
106
+
107
+ def test_the_brevity_ladder_grows_then_stops():
108
+ assert brevity_ladder(3) == (0, 1, 2)
109
+ assert brevity_ladder(1) == (0,)
110
+ assert brevity_ladder(0) == (0,)
111
+
112
+
113
+ def test_majority_choice_ignores_failures_and_breaks_ties_by_first_seen():
114
+ assert majority_choice([1, 1, 2]) == 1
115
+ assert majority_choice([None, 3, None]) == 3
116
+ assert majority_choice([2, 1]) == 2 # a tie goes to the first seen
117
+ assert majority_choice([]) is None
118
+ assert majority_choice([None, None]) is None
119
+
120
+
121
+ # -- the brain, in constrained mode ------------------------------------------
122
+
123
+ class Scripted(ModelBrain):
124
+ """A backend whose replies are scripted, so the ladder can be tested."""
125
+
126
+ name = "scripted"
127
+ supports_constrained = True
128
+
129
+ def __init__(self, replies, tokenizer=None, **kwargs):
130
+ super().__init__("fake/model", **kwargs)
131
+ self._replies = list(replies)
132
+ self._tokenizer = tokenizer or FakeTokenizer()
133
+ self.prompts = []
134
+
135
+ def _tokenizer_for_choice(self):
136
+ return self._tokenizer
137
+
138
+ def _generate_constrained(self, prompt, observation, allowed_ids, max_new_tokens, *, sample=False):
139
+ self.prompts.append(prompt)
140
+ assert allowed_ids, "the mask must be non-empty"
141
+ return self._replies.pop(0)
142
+
143
+ def _generate(self, prompt, observation):
144
+ self.prompts.append(prompt)
145
+ return self._replies.pop(0)
146
+
147
+
148
+ def test_choice_mode_turns_a_number_into_a_click():
149
+ brain = Scripted(["2"], decision_mode="choice")
150
+ action = brain.decide("save the document", FRAME)
151
+ assert action.kind == "click" and action.label == "Save"
152
+ assert "constrained choice 2" in action.reason
153
+ assert brain.last_choice == 2
154
+
155
+
156
+ def test_choice_mode_reads_zero_as_already_done():
157
+ action = Scripted(["0"], decision_mode="choice").decide("save", FRAME)
158
+ assert action.kind == "done"
159
+
160
+
161
+ def test_the_echoed_prompt_is_not_usable_even_if_the_model_returns_it():
162
+ """The old failure mode, fed straight in: it cannot be mistaken for an answer."""
163
+ brain = Scripted(["2"], decision_mode="choice")
164
+ echo = brain.build_prompt("save", DecisionMenu(FRAME), FRAME, brevity=0)
165
+ assert read_choice(echo, 12) is None, "an echoed menu must not parse as a choice"
166
+ # the prompt contains the menu numbers, spaced apart: 1, 2, 3 -> "123"
167
+ assert read_choice(brain.build_prompt("save", DecisionMenu(FRAME), FRAME), 3) is None
168
+
169
+
170
+ def test_a_useless_reply_shortens_the_prompt_and_tries_again():
171
+ brain = Scripted(["", "3"], decision_mode="choice", retries=3)
172
+ action = brain.decide("discard the change", FRAME)
173
+ assert action.label == "Cancel"
174
+ assert len(brain.prompts) == 2
175
+ assert len(brain.prompts[1]) < len(brain.prompts[0]), "the retry must be shorter"
176
+
177
+
178
+ def test_the_prompt_shrinks_all_the_way_when_nothing_works():
179
+ brain = Scripted([""] * 5, decision_mode="choice", retries=3)
180
+ with pytest.raises(BrainError, match="not a menu number"):
181
+ brain.decide("save", FRAME)
182
+ lengths = [len(prompt) for prompt in brain.prompts]
183
+ assert lengths[0] > lengths[-1] and len(lengths) == 3
184
+ assert "Text on screen" not in brain.prompts[-1]
185
+
186
+
187
+ def test_votes_take_a_majority_over_samples():
188
+ brain = Scripted(["2", "3", "2"], decision_mode="choice", votes=3)
189
+ action = brain.decide("save", FRAME)
190
+ assert action.label == "Save" # 2 wins over 3
191
+ assert len(brain.prompts) == 3 # only one ladder rung used
192
+
193
+
194
+ def test_a_tokenizer_that_cannot_spell_numbers_is_refused_clearly():
195
+ brain = Scripted(["1"], tokenizer=FakeTokenizer(expressible=False), decision_mode="choice")
196
+ with pytest.raises(BrainError, match="cannot express a menu number"):
197
+ brain.decide("save", FRAME)
198
+
199
+
200
+ def test_an_empty_screen_is_a_hard_stop_in_choice_mode():
201
+ brain = Scripted(["1"], decision_mode="choice")
202
+ with pytest.raises(GoalUnreachable):
203
+ brain.decide("save", Observation(elements=()))
204
+
205
+
206
+ class ScanBackend(ModelBrain):
207
+ """Answers yes only for the labels it was told to like."""
208
+
209
+ name = "scan-backend"
210
+ supports_constrained = True
211
+
212
+ def __init__(self, likes, tokenizer=None, **kwargs):
213
+ super().__init__("fake/model", **kwargs)
214
+ self.likes = set(likes)
215
+ self.tokenizer = tokenizer or FakeTokenizer()
216
+ self.prompts = []
217
+
218
+ def _tokenizer_for_choice(self):
219
+ return self.tokenizer
220
+
221
+ def _generate_constrained(self, prompt, observation, allowed_ids, max_new_tokens, *, sample=False):
222
+ self.prompts.append(prompt)
223
+ label = prompt.split("Candidate item: ", 1)[1].splitlines()[0]
224
+ return "yes" if label in self.likes else "no"
225
+
226
+
227
+ def test_scanning_asks_one_question_per_item_and_takes_the_first_yes():
228
+ brain = ScanBackend(["Save"], decision_mode="scan")
229
+ action = brain.decide("click 'Save' to save the document", FRAME)
230
+ assert action.kind == "click" and action.label == "Save"
231
+ assert "said yes to 'Save'" in action.reason
232
+ assert len(brain.prompts) == 2 # Files, Save - then it stopped
233
+ assert all("yes or no" in prompt for prompt in brain.prompts)
234
+ assert all("Candidate item:" in prompt for prompt in brain.prompts)
235
+
236
+
237
+ def test_scanning_refuses_when_every_item_is_rejected():
238
+ brain = ScanBackend([], decision_mode="scan")
239
+ with pytest.raises(BrainError, match="said no to all"):
240
+ brain.decide("save", FRAME)
241
+ assert len(brain.prompts) == 3 # every item was asked about
242
+
243
+
244
+ def test_scanning_refuses_a_reply_that_is_neither_yes_nor_no():
245
+ class Mute(ScanBackend):
246
+ def _generate_constrained(self, prompt, observation, allowed_ids, max_new_tokens, *, sample=False):
247
+ return "maybe"
248
+
249
+ with pytest.raises(BrainError, match="neither yes nor no|masked reply"):
250
+ Mute(["Save"], decision_mode="scan").decide("save", FRAME)
251
+
252
+
253
+ def test_read_verdict_reads_what_the_mask_allows():
254
+ from tiny_computer_agent.decoding import pair_token_ids, read_verdict
255
+ assert read_verdict("yes") == "yes"
256
+ assert read_verdict(" Yes ") == "yes"
257
+ assert read_verdict("no") == "no"
258
+ assert read_verdict("No.") == "no"
259
+ assert read_verdict("perhaps") is None
260
+ assert read_verdict("") is None
261
+ yes_ids, no_ids = pair_token_ids(FakeTokenizer())
262
+ assert yes_ids and no_ids and not (set(yes_ids) & set(no_ids))
263
+
264
+
265
+ def test_json_mode_is_untouched():
266
+ """The default must not change: the existing lenient parsing still applies."""
267
+ brain = Scripted(['{"kind": "click", "index": 2}'])
268
+ assert brain.decision_mode == "json"
269
+ assert brain.decide("save", FRAME).label == "Save"
270
+
271
+
272
+ def test_choice_mode_is_only_entered_when_the_backend_supports_it():
273
+ class Plain(Scripted):
274
+ supports_constrained = False
275
+
276
+ brain = Plain(['{"kind": "click", "index": 2}'], decision_mode="choice")
277
+ assert brain.decide("save", FRAME).label == "Save" # fell back to JSON parsing
tests/test_ocr_guard.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The OCR guard must actually skip when there is no engine.
2
+
3
+ This is a regression test for a real bug: the guard in `test_cli.py` asked
4
+ `detector_available()`, whose default `auto` is `True` even with nothing
5
+ installed - because the grid fallback always exists. So on a machine with no OCR
6
+ engine the test *failed* instead of skipping, and the failure looked like a
7
+ harness defect. The guard now asks for `ocr` specifically, and here is the proof.
8
+ """
9
+
10
+ import pytest
11
+
12
+ from conftest import require_ocr
13
+
14
+ import tiny_computer_agent.perception as perception
15
+
16
+
17
+ def test_the_guard_skips_when_no_engine_is_installed(monkeypatch):
18
+ monkeypatch.setattr(perception, "detector_available",
19
+ lambda name="auto": (False, "simulated: no engine"))
20
+ with pytest.raises(pytest.skip.Exception):
21
+ require_ocr()
22
+
23
+
24
+ def test_the_guard_passes_when_an_engine_is_installed(monkeypatch):
25
+ monkeypatch.setattr(perception, "detector_available",
26
+ lambda name="auto": (True, "simulated: engine present"))
27
+ require_ocr() # must not raise
28
+
29
+
30
+ def test_the_guard_asks_for_ocr_and_not_auto(monkeypatch):
31
+ """`auto` is available with nothing installed, so it would never skip."""
32
+ asked = []
33
+
34
+ def record(name="auto"):
35
+ asked.append(name)
36
+ return True, "recorded"
37
+
38
+ monkeypatch.setattr(perception, "detector_available", record)
39
+ require_ocr()
40
+ assert asked == ["ocr"]
41
+
42
+
43
+ def test_auto_is_available_without_any_engine():
44
+ """The reason the old guard never fired, pinned down as a test."""
45
+ available, detail = perception.detector_available("auto")
46
+ assert available is True, "auto must stay usable with no OCR engine"
tests/test_perception.py CHANGED
@@ -94,7 +94,7 @@ def test_the_synthetic_desktop_looks_like_the_intended_screen():
94
  assert image.size == (720, 460)
95
 
96
 
97
- @pytest.mark.skipif(not detector_available("ocr")[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())
@@ -108,7 +108,7 @@ def test_real_ocr_reads_a_real_screenshot():
108
  assert 260 <= save.center[1] <= 310
109
 
110
 
111
- @pytest.mark.skipif(not detector_available("ocr")[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
 
94
  assert image.size == (720, 460)
95
 
96
 
97
+ @pytest.mark.skipif(not detector_available("ocr")[0], reason="no OCR engine installed") # conftest.require_ocr is the in-test form
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())
 
108
  assert 260 <= save.center[1] <= 310
109
 
110
 
111
+ @pytest.mark.skipif(not detector_available("ocr")[0], reason="no OCR engine installed") # conftest.require_ocr is the in-test form
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
tests/test_stepwise.py ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The stepwise API: a console needs to run the loop one turn at a time.
2
+
3
+ `run()` is the batch form. A live console needs the opposite - the caller owns the
4
+ loop, so a button press advances exactly one step and a pause switch stops between
5
+ them. Both are the same rules; these tests pin that down, including an
6
+ equivalence check so the two forms cannot drift apart.
7
+ """
8
+
9
+ import pytest
10
+
11
+ from tiny_computer_agent import (
12
+ AgentLoop, Element, FakeScreen, MockBrain, PlannerBrain, Policy,
13
+ )
14
+
15
+
16
+ def script():
17
+ return MockBrain([
18
+ {"kind": "wait", "seconds": 0.1},
19
+ {"kind": "click", "label": "Save"},
20
+ {"kind": "key", "keys": "ctrl+s"},
21
+ {"kind": "done", "reason": "saved"},
22
+ ])
23
+
24
+
25
+ def screen():
26
+ return FakeScreen([
27
+ (Element("Files", x=10, y=6, width=60, height=20),),
28
+ (Element("Save", x=320, y=120, width=80, height=24),),
29
+ ])
30
+
31
+
32
+ def shape(episode):
33
+ """Everything about an episode that a user could notice."""
34
+ return (
35
+ episode.stop_reason,
36
+ episode.success,
37
+ [
38
+ (s.index,
39
+ s.action.describe() if s.action else None,
40
+ s.error,
41
+ s.result.summary() if s.result else None)
42
+ for s in episode.steps
43
+ ],
44
+ )
45
+
46
+
47
+ # -- stepping ---------------------------------------------------------------
48
+
49
+ def test_begin_reads_the_screen_once_and_step_once_advances_one_turn():
50
+ screen_ = screen()
51
+ loop = AgentLoop(screen=screen_, brain=script()).begin("save")
52
+ assert screen_.captures == 1 # one read, before the first decision
53
+ assert loop.finished is False
54
+ assert loop.episode.steps == []
55
+
56
+ first = loop.step_once()
57
+ assert first.index == 0 and first.action.kind == "wait"
58
+ assert len(loop.episode.steps) == 1
59
+ assert loop.finished is False # three turns still to go
60
+
61
+ loop.step_once()
62
+ assert [s.action.kind for s in loop.episode.steps] == ["wait", "click"]
63
+
64
+
65
+ def test_stepping_past_the_end_returns_none_instead_of_raising():
66
+ loop = AgentLoop(screen=screen(), brain=script()).begin("save")
67
+ while loop.step_once() is not None:
68
+ pass
69
+ assert loop.finished is True
70
+ assert loop.episode.success is True
71
+ assert loop.step_once() is None
72
+ assert loop.step_once() is None
73
+
74
+
75
+ def test_step_once_before_begin_does_nothing():
76
+ loop = AgentLoop(screen=screen(), brain=script())
77
+ assert loop.step_once() is None
78
+ assert loop.episode is None
79
+
80
+
81
+ def test_the_budget_is_enforced_one_step_at_a_time():
82
+ loop = AgentLoop(
83
+ screen=screen(),
84
+ brain=MockBrain([{"kind": "wait", "seconds": 0.2 + i} for i in range(10)]),
85
+ policy=Policy(step_budget=3),
86
+ ).begin("save")
87
+ taken = 0
88
+ while loop.step_once() is not None:
89
+ taken += 1
90
+ assert taken == 3
91
+ assert "budget of 3" in loop.episode.stop_reason
92
+
93
+
94
+ # -- pause and resume -------------------------------------------------------
95
+
96
+ def test_pause_stops_between_steps_and_leaves_the_episode_open():
97
+ loop = AgentLoop(screen=screen(), brain=script()).begin("save")
98
+ loop.step_once()
99
+ loop.pause()
100
+ episode = loop.run() # returns immediately
101
+ assert len(episode.steps) == 1
102
+ assert loop.finished is False # not finished, only paused
103
+ assert loop.paused is True
104
+
105
+
106
+ def test_resume_continues_the_same_episode_to_the_same_end():
107
+ loop = AgentLoop(screen=screen(), brain=script()).begin("save")
108
+ loop.step_once()
109
+ loop.pause()
110
+ loop.run()
111
+ loop.resume()
112
+ episode = loop.run()
113
+ assert episode.success is True
114
+ assert [s.action.kind for s in episode.steps] == ["wait", "click", "key", "done"]
115
+ assert loop.paused is False
116
+
117
+
118
+ def test_a_paused_run_is_equivalent_to_an_uninterrupted_one():
119
+ straight = AgentLoop(screen=screen(), brain=script()).run("save")
120
+
121
+ interrupted = AgentLoop(screen=screen(), brain=script()).begin("save")
122
+ interrupted.step_once()
123
+ interrupted.pause()
124
+ interrupted.run()
125
+ interrupted.resume()
126
+ interrupted.run()
127
+
128
+ assert shape(interrupted.episode) == shape(straight)
129
+
130
+
131
+ # -- the two forms cannot drift apart ---------------------------------------
132
+
133
+ def test_run_is_exactly_begin_plus_step_once_in_a_loop():
134
+ batch = AgentLoop(screen=screen(), brain=PlannerBrain()).run("click 'Save'")
135
+
136
+ stepwise = AgentLoop(screen=screen(), brain=PlannerBrain()).begin("click 'Save'")
137
+ while stepwise.step_once() is not None:
138
+ pass
139
+
140
+ assert shape(stepwise.episode) == shape(batch)
141
+
142
+
143
+ def test_verify_still_owns_success_when_stepping():
144
+ texts = ["Unsaved changes", "before", "Saved"]
145
+
146
+ class TextScreen:
147
+ def __init__(self):
148
+ self.n = 0
149
+
150
+ def capture(self):
151
+ from tiny_computer_agent import Observation
152
+ text = texts[min(self.n, len(texts) - 1)]
153
+ self.n += 1
154
+ return Observation(index=self.n - 1, elements=(Element("Save"),), text=text)
155
+
156
+ loop = AgentLoop(
157
+ screen=TextScreen(),
158
+ brain=MockBrain([{"kind": "wait", "seconds": 0.1}] * 4),
159
+ verify=lambda observation: "saved" in observation.text.lower(),
160
+ ).begin("save")
161
+ while loop.step_once() is not None:
162
+ pass
163
+ assert loop.episode.success is True
164
+ assert loop.episode.stop_reason == "verified after 2 action(s)"
165
+
166
+
167
+ def test_a_kill_switch_stops_a_stepwise_run_before_the_first_action(tmp_path):
168
+ marker = tmp_path / "STOP"
169
+ marker.write_text("stop")
170
+ loop = AgentLoop(
171
+ screen=screen(),
172
+ brain=MockBrain([{"kind": "click", "label": "Save"}]),
173
+ policy=Policy(kill_switch_path=str(marker)),
174
+ ).begin("save")
175
+ assert loop.step_once() is None
176
+ assert loop.episode.steps == []
177
+ assert loop.episode.stop_reason == "kill switch engaged"
178
+ assert loop.finished is True
179
+
180
+
181
+ def test_a_dead_screen_finishes_the_episode_at_begin():
182
+ class Blind:
183
+ def capture(self):
184
+ raise RuntimeError("X connection failed")
185
+
186
+ loop = AgentLoop(screen=Blind(), brain=script()).begin("anything")
187
+ assert loop.finished is True
188
+ assert loop.episode.steps == []
189
+ assert loop.episode.stop_reason.startswith("capture failed: RuntimeError")
190
+ assert loop.step_once() is None