Publish tests/ from marimo
Browse files- tests/conftest.py +22 -0
- tests/test_accessibility.py +278 -0
- tests/test_cli.py +2 -6
- tests/test_decoding.py +277 -0
- tests/test_ocr_guard.py +46 -0
- tests/test_perception.py +2 -2
- tests/test_stepwise.py +190 -0
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
|
| 55 |
|
| 56 |
-
|
| 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
|