diff --git a/bajutsu/capability_preflight.py b/bajutsu/capability_preflight.py index fe67f49ab..91f412ee7 100644 --- a/bajutsu/capability_preflight.py +++ b/bajutsu/capability_preflight.py @@ -14,6 +14,11 @@ - `selectOption` needs `selectOption` (BE-0191): a web-only action that sets a native ` switch; not supported on iOS / Android)", _select_option_locations, ), + _Requirement( + base.Capability.TEXT_SELECTION, + "select / copy (select-all + clipboard copy; not supported on idb)", + _text_selection_locations, + ), _Requirement( base.Capability.SCREENSHOT, "visual assertion", diff --git a/bajutsu/drivers/adb.py b/bajutsu/drivers/adb.py index b787405e9..a9ff8d4e9 100644 --- a/bajutsu/drivers/adb.py +++ b/bajutsu/drivers/adb.py @@ -547,6 +547,7 @@ def driver_interval(self, kind: str, path: Path) -> intervals.Interval | None: base.Capability.ELEMENTS, base.Capability.SCREENSHOT, base.Capability.MULTI_TOUCH, + base.Capability.TEXT_SELECTION, base.Capability.DC_SET_LOCATION, base.Capability.DC_CLIPBOARD, } diff --git a/bajutsu/drivers/base.py b/bajutsu/drivers/base.py index d0e121777..cb72be858 100644 --- a/bajutsu/drivers/base.py +++ b/bajutsu/drivers/base.py @@ -54,6 +54,11 @@ class Capability: MULTI_TOUCH = "multiTouch" # two-finger gestures (pinch / rotate); idb is single-touch WEBVIEW = "webView" # DOM query/tap inside an embedded WKWebView (BE-0037) SELECT_OPTION = "selectOption" # set a native . + driver = harness.with_screen([]) + driver.tap({"id": FIELD_ID}) + before = len(_field_value(driver)) + driver.type_text("wxyz") + typed = len(_field_value(driver)) + if typed <= before: + pytest.skip("backend does not surface the field value; delete effect not observable") + driver.delete_text(2) + assert len(_field_value(driver)) < typed + + def test_tap_point_focuses_the_field_like_a_semantic_tap( + self, harness: ConformanceHarness + ) -> None: + # tap_point is a raw coordinate tap (the alert-dismissal path, BE-0269); no lane actuated it + # under the contract before (BE-0280). Aimed at the field's center, it must focus the field — + # the same observable effect as a semantic tap on it: typing afterward lands in the field. + driver = harness.with_screen([element(identifier="elsewhere")]) + # First confirm the field surfaces typed text through the known-good semantic tap, so a + # backend that never reports `value` is skipped here rather than at the coordinate-tap + # assertion — otherwise a genuinely broken tap_point (field left unfocused, length unchanged + # from an empty start) would look identical to "value not observable" and be masked. + driver.tap({"id": FIELD_ID}) + driver.type_text("a") + baseline = len(_field_value(driver)) + if baseline == 0: + pytest.skip("backend does not surface the field value; focus effect not observable") + # Blur by tapping elsewhere, then re-focus by a raw coordinate tap at the field's center: the + # coordinate tap must land on the field, so the following character grows it. A tap_point that + # missed would leave the field unfocused and the length unchanged — a failure, not a skip. + driver.tap({"id": "elsewhere"}) + driver.tap_point(_field_center(driver)) + driver.type_text("z") + assert len(_field_value(driver)) > baseline + def test_wait_for_is_single_shot(self, harness: ConformanceHarness) -> None: # wait_for reflects the current screen only; the deadline loop lives in wait_until. present = harness.with_screen([element(identifier="s")]) diff --git a/tests/test_adb.py b/tests/test_adb.py index 5d9eed98b..96c49a76b 100644 --- a/tests/test_adb.py +++ b/tests/test_adb.py @@ -217,6 +217,7 @@ def test_capabilities_lean_end() -> None: # multiTouch is advertised (BE-0232): the two-finger sendevent sweep, so preflight admits # `gestures_multitouch`. The root precondition is enforced at actuation time, not in the set. assert base.Capability.MULTI_TOUCH in caps + assert base.Capability.TEXT_SELECTION in caps # Ctrl+A / Ctrl+C actuate; idb refuses (BE-0280) def test_driver_interval_routes_video_and_devicelog_to_adb_starters( diff --git a/tests/test_capability_preflight.py b/tests/test_capability_preflight.py index 00dee8e9f..a3afcab03 100644 --- a/tests/test_capability_preflight.py +++ b/tests/test_capability_preflight.py @@ -488,6 +488,46 @@ def test_select_option_reason_includes_step_index() -> None: assert "selectOption" in reasons[0] +# --- textSelection capability gating (select / copy; BE-0280) --- + + +_TEXT = _IDB | {base.Capability.TEXT_SELECTION} + + +@pytest.mark.parametrize( + "step", + [{"select": {"into": {"id": "field"}}}, {"copy": {}}], +) +def test_select_and_copy_require_text_selection(step: dict[str, object]) -> None: + # select-all / copy actuate only on a backend that can select natively; idb (coordinate-only, + # no TEXT_SELECTION) is rejected up front rather than left to fail late mid-run (BE-0280). + sc = _sc(steps=[step]) + reasons = capability_preflight.unsupported(sc, _IDB) + assert reasons and any("textSelection" in r for r in reasons) + # A backend advertising TEXT_SELECTION runs it without issue. + assert capability_preflight.unsupported(sc, _TEXT) == [] + + +def test_delete_and_clear_are_not_gated_by_text_selection() -> None: + # Every backend actuates delete_text (a run of backspaces), so delete/clear need no token — idb, + # which lacks TEXT_SELECTION, still runs them (BE-0280). + sc = _sc( + steps=[ + {"delete": {"into": {"id": "field"}, "count": 3}}, + {"clear": {"into": {"id": "field"}}}, + ] + ) + assert capability_preflight.unsupported(sc, _IDB) == [] + + +def test_select_reason_includes_step_index() -> None: + sc = _sc(steps=[{"tap": {"id": "ok"}}, {"select": {"into": {"id": "field"}}}]) + reasons = capability_preflight.unsupported(sc, _IDB) + assert len(reasons) == 1 + assert reasons[0].startswith("step 2: ") + assert "textSelection" in reasons[0] + + # --- CLI doctor --scenario integration (BE-0024) --- diff --git a/tests/test_driver_conformance.py b/tests/test_driver_conformance.py index 06f9ed7f6..2af43602c 100644 --- a/tests/test_driver_conformance.py +++ b/tests/test_driver_conformance.py @@ -8,19 +8,54 @@ from __future__ import annotations import pytest -from driver_conformance import ConformanceHarness, DriverConformanceContract +from driver_conformance import FIELD_ID, ConformanceHarness, DriverConformanceContract, element from bajutsu.drivers import base -from bajutsu.drivers.fake import FakeDriver +from bajutsu.drivers.fake import FakeDriver, React + +# The conformance field's frame on the fake screen: a known, off-origin box so a coordinate tap at +# its center is unambiguous and never coincides with the default (0,0)-origin seeded elements. +_FIELD_FRAME: base.Frame = (0.0, 200.0, 100.0, 40.0) + + +def _text_field_react(field: base.Element) -> React: + """Model the conformance field so the gate observes the real text round-trip, not just a log. + + The on-device and web backends surface a live editable field; `FakeDriver` records actions but + holds no field state, so the round-trip / focus invariants (BE-0280) would be unobservable on + the fast gate. This `react` gives the fake just enough field behavior — focus follows the last + tap, typing appends to the focused field, deleting trims its end — to exercise them for real. + """ + focused = {"on": False} + + def react(_driver: FakeDriver, kind: str, arg: object) -> None: + if kind == "tap": + focused["on"] = isinstance(arg, dict) and arg.get("id") == FIELD_ID + elif kind == "tap_point" and isinstance(arg, tuple): + x, y, w, h = field["frame"] + px, py = arg + focused["on"] = x <= px <= x + w and y <= py <= y + h + elif kind == "type" and focused["on"] and isinstance(arg, str): + field["value"] = (field["value"] or "") + arg + elif kind == "delete_text" and focused["on"] and isinstance(arg, int): + field["value"] = (field["value"] or "")[: -arg or None] + + return react class FakeConformanceHarness: - """Realizes a conformance screen as a `FakeDriver` seeded with those elements.""" + """Realizes a conformance screen as a `FakeDriver` seeded with those elements. + + Every screen also carries the always-present conformance field (BE-0280), wired to a `react` + that models its text state, so the text-editing and `tap_point` invariants are observable on the + fast gate exactly as they are against a real field on-device. + """ backend = "fake" def with_screen(self, elements: list[base.Element]) -> base.Driver: - return FakeDriver(screen=elements) + field = element(identifier=FIELD_ID, value="", frame=_FIELD_FRAME) + return FakeDriver(screen=[*elements, field], react=_text_field_react(field)) class TestFakeDriverConformance(DriverConformanceContract): diff --git a/tests/test_driver_conformance_web.py b/tests/test_driver_conformance_web.py index f25ad9952..5bdc9f6d9 100644 --- a/tests/test_driver_conformance_web.py +++ b/tests/test_driver_conformance_web.py @@ -22,7 +22,7 @@ from typing import Any import pytest -from driver_conformance import ConformanceHarness, DriverConformanceContract +from driver_conformance import FIELD_ID, ConformanceHarness, DriverConformanceContract from bajutsu.drivers import base from bajutsu.drivers.playwright import PlaywrightDriver @@ -36,6 +36,16 @@ pytest.skip("Playwright (the web extra) is not installed", allow_module_level=True) +# The always-present editable field (BE-0280), the web twin of the iOS `ConformanceView` / +# Compose `ConformanceScreen` field. Absolutely positioned at a known, off-flow box so its frame +# stays fixed (the coordinate-tap invariant aims at its center) and it never overlaps the seeded +# nodes above; a real `` so `QUERY_JS` reads its live `value` for the round-trip invariant. +_FIELD_HTML = ( + f'' +) + + def _render(elements: list[base.Element]) -> str: """One HTML page realizing the seeded conformance screen for `QUERY_JS` to read. @@ -44,10 +54,12 @@ def _render(elements: list[base.Element]) -> str: real, clickable point) — the seeded ids come through as the driver's element identifiers. An element seeded with the `button` trait renders as a `