diff --git a/docs/superpowers/contracts/python-pkg-five-packages-2026-08-16.json b/docs/superpowers/contracts/python-pkg-five-packages-2026-08-16.json new file mode 100644 index 00000000..7631705f --- /dev/null +++ b/docs/superpowers/contracts/python-pkg-five-packages-2026-08-16.json @@ -0,0 +1,19 @@ +{ + "title": "Clear five python_pkg packages under the 250-line cap", + "objective": "Bring token_audit, focus_policy, random_jpg, app_icons and android_ui entirely under the repo-wide 250-line cap, leaving wsg_grabber as the only python_pkg package with violations.", + "acceptance_criteria": [ + "bash ~/utils/scripts/check_file_length.sh --all lists no file under any of the five packages.", + "Each package reports its exact baseline pass count: token_audit 81, focus_policy 137, random_jpg 14, app_icons 44, android_ui 57.", + "Every package keeps 100% branch coverage of its source modules.", + "AndroidUi stays defined in driver.py, since that is the only name any android_ui test patches on it.", + "The android_ui class split uses the mixin shape from docs/python-split-recipes.md -- ABC with abstract methods for what the concrete class supplies -- because tests call the moved methods as bound attributes of a real driver.", + "No assertion is changed and no test is deleted, skipped or weakened.", + "No suppression is added and no coverage exclusion is introduced." + ], + "out_of_scope": [ + "Changing what any test asserts, or any adb command the driver issues.", + "wsg_grabber's six test modules, which are a separate commit.", + "Changing the public re-exports in android_ui/__init__.py or app_icons' glyph names." + ], + "verifier": "timeout 120 env PYTHONPATH=. python3 -m pytest python_pkg//tests -q for each of the five (expects 81, 137, 14, 44, 57), plus bash ~/utils/scripts/check_file_length.sh --all showing none of them, plus pre-commit run --files on the touched files." +} diff --git a/docs/superpowers/evidence/python-pkg-five-packages-2026-08-16.json b/docs/superpowers/evidence/python-pkg-five-packages-2026-08-16.json new file mode 100644 index 00000000..31d822f3 --- /dev/null +++ b/docs/superpowers/evidence/python-pkg-five-packages-2026-08-16.json @@ -0,0 +1,48 @@ +{ + "intent": "Clear the five smaller python_pkg packages under the 250-line cap, leaving only wsg_grabber's test modules.", + "scope": [ + "python_pkg/android_ui/driver.py (411 -> 205) + _elements.py, _text_entry.py (new)", + "python_pkg/android_ui/tests/test_driver.py (377 -> 211) + test_text_entry.py, test_shell_helpers.py (new)", + "python_pkg/app_icons/glyphs.py (291 -> 93) + _glyph_art.py (new)", + "python_pkg/token_audit/tests/test_parse.py (271 -> 209) + test_parse_events.py (new)", + "python_pkg/focus_policy/tests/test_model.py (269 -> 148), test_export.py (255 -> 161) + two new modules", + "python_pkg/random_jpg/tests/test_generate_jpeg.py (251 -> 138) + test_generate_jpeg_io.py (new)", + "Non-goal: no assertion changed, no test deleted, skipped or weakened" + ], + "changes": [ + "android_ui/driver.py needed two cuts and a class split. The UiElement dataclass, the three error types and the XML parser moved to _elements.py; the text-entry and keyboard methods became a TextEntryMixin in _text_entry.py. The mixin shape is the one docs/python-split-recipes.md settled on: an ABC declaring _run, dump and wait_for as abstract, because test_driver.py calls the moved methods as bound attributes of a real driver, so module-level functions would not have worked.", + "AndroidUi stays in driver.py and driver re-imports the moved names, so android_ui/__init__.py's public re-exports are untouched.", + "app_icons/glyphs.py: the twelve SVG path fragments moved to _glyph_art.py, leaving the dataclass, registry and lookup. Pure data with no patch targets.", + "The four remaining test modules were partitioned at topic boundaries with a splitter that copies the whole preamble - docstring, imports and local fixture builders - and lets ruff prune what each half does not use." + ], + "verification": [ + { + "command": "timeout 120 env PYTHONPATH=. python3 -m pytest python_pkg//tests -q, for each of the five", + "result": "pass", + "evidence": "token_audit 81, focus_policy 137, random_jpg 14, app_icons 44, android_ui 57 - each identical to its baseline measured before any edit." + }, + { + "command": "timeout 120 env PYTHONPATH=. python3 -m pytest python_pkg/android_ui/tests -q (intermediate runs)", + "result": "fail then pass", + "evidence": "Three intermediate failures, all module-level globals that travelled with their code and were not in my hand-written header: _logger (used by the moved XML parser), then re and _SAME_FIELD_X_TOLERANCE_PX (used by the mixin). Same class of miss as _make_verifier and _QUESTIONS earlier - a def or constant between sections, which ruff cannot flag." + }, + { + "command": "bash ~/utils/scripts/check_file_length.sh --all | grep -E 'android_ui|app_icons|focus_policy|token_audit|random_jpg'", + "result": "pass", + "evidence": "No output - all five packages are entirely under the cap." + }, + { + "command": "pre-commit run --files ", + "result": "fail then pass", + "evidence": "ruff E402 and pylint C0413 on glyphs.py: I had placed the new import after the dataclass rather than in the import block. Moved; the gate then passed with ruff, ruff format, mypy, pylint, bandit and pytest-with-coverage all clean." + } + ], + "risks": [ + "TextEntryMixin declares wait_for with the concrete class's exact signature (timeout default 15.0). A future change to AndroidUi.wait_for that does not update the abstract declaration would pass tests but mislead type checkers and readers.", + "driver.py imports from both _elements and _text_entry, and _text_entry imports from _elements. That order must hold; _elements must never import from driver." + ], + "rollback": [ + "git revert the commit; the five packages return to their previous file sizes and the six new modules disappear.", + "After rollback, re-run each package's suite and confirm the same five counts." + ] +} diff --git a/python_pkg/android_ui/_elements.py b/python_pkg/android_ui/_elements.py new file mode 100644 index 00000000..cca853d6 --- /dev/null +++ b/python_pkg/android_ui/_elements.py @@ -0,0 +1,128 @@ +"""The element and error types the UI driver works in, and the XML parser. + +Split out of :mod:`python_pkg.android_ui.driver` to keep it under the 250-line +cap. ``AndroidUi`` stays there because that is the name the tests patch; +everything here is re-imported by ``driver`` so the package's public re-exports +in ``__init__`` are unchanged. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import logging +import re + +from defusedxml.ElementTree import ParseError, fromstring + +_logger = logging.getLogger(__name__) + +_BOUNDS_RE = re.compile(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]") + + +class UiAutomationError(RuntimeError): + """Base class for every failure this package reports.""" + + +class ElementNotFoundError(UiAutomationError): + """No element matched the query.""" + + +class AmbiguousElementError(UiAutomationError): + """More than one element matched, so acting would be a coin flip.""" + + +@dataclass(frozen=True) +class UiElement: + """One node from the accessibility tree.""" + + text: str + content_desc: str + resource_id: str + class_name: str + bounds: tuple[int, int, int, int] + enabled: bool + focused: bool + + @property + def label(self) -> str: + """The best human-facing name for this element.""" + return self.text or self.content_desc or self.resource_id + + @property + def center(self) -> tuple[int, int]: + """Tap point: the centre of the element's CURRENT bounds.""" + left, top, right, bottom = self.bounds + return ((left + right) // 2, (top + bottom) // 2) + + def matches(self, query: str, *, exact: bool = False) -> bool: + """Return True if ``query`` names this element.""" + haystacks = (self.text, self.content_desc, self.resource_id) + if exact: + return any(h == query for h in haystacks) + return any(query.lower() in h.lower() for h in haystacks if h) + + def __str__(self) -> str: + """Return a one-line description naming the element and its centre.""" + cls = self.class_name.rsplit(".", 1)[-1] + x, y = self.center + return f"{self.label!r} <{cls}> at ({x},{y})" + + +def _escape(text: str) -> str: + r"""Escape text for ``adb shell input text``. + + Backslash is escaped FIRST and excluded from the loop below. Escaping it + inside the loop double-escapes every backslash the loop itself just added + (``&`` -> ``\\&`` -> ``\\\\&``), which silently types the wrong password. + """ + out = text.replace("\\", "\\\\") + out = out.replace("%", "%%").replace(" ", "%s") + for char in "()<>|;&*~\"'`$": + out = out.replace(char, "\\" + char) + return out + + +def _parse_tree(xml: str) -> list[UiElement]: + """Return every labelled node in an accessibility-tree dump.""" + try: + root = fromstring(xml) + except ParseError as exc: + _logger.warning( + "UI dump is not valid XML (%s) — treating it as an empty screen; " + "this is usually a snapshot taken mid-animation", + exc, + ) + return [] + + elements: list[UiElement] = [] + for node in root.iter("node"): + bounds = _BOUNDS_RE.match(node.get("bounds", "")) + if bounds is None: + continue + text = node.get("text", "") + desc = node.get("content-desc", "") + res = node.get("resource-id", "") + cls = node.get("class", "") + # An EMPTY text field has no text, content-desc or resource-id, so a + # "must be labelled" filter drops it -- and an empty field is exactly + # the thing a caller needs to find in order to type into it. Keep every + # editable node regardless of label. + if not (text or desc or res or cls.endswith("EditText")): + continue + elements.append( + UiElement( + text=text, + content_desc=desc, + resource_id=res, + class_name=cls, + bounds=( + int(bounds.group(1)), + int(bounds.group(2)), + int(bounds.group(3)), + int(bounds.group(4)), + ), + enabled=node.get("enabled") == "true", + focused=node.get("focused") == "true", + ) + ) + return elements diff --git a/python_pkg/android_ui/_text_entry.py b/python_pkg/android_ui/_text_entry.py new file mode 100644 index 00000000..fa49c233 --- /dev/null +++ b/python_pkg/android_ui/_text_entry.py @@ -0,0 +1,145 @@ +"""Typing into fields and driving the on-screen keyboard. + +Split out of :mod:`python_pkg.android_ui.driver` to keep it under the 250-line +cap. A mixin rather than module-level functions because +``tests/test_driver.py`` calls these as bound attributes of a real driver, and +because they need ``_run``/``dump``/``wait_for`` from the concrete class. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +import re +import time + +from python_pkg.android_ui._elements import ( + ElementNotFoundError, + UiAutomationError, + UiElement, + _escape, +) + +# Horizontal slack when re-identifying a field after typing: the keyboard can +# shift a widget vertically, but not sideways, so the left edge stays put. +_SAME_FIELD_X_TOLERANCE_PX = 50 + + +class TextEntryMixin(ABC): + """The text-entry half of :class:`~python_pkg.android_ui.driver.AndroidUi`.""" + + _settle: float + + @abstractmethod + def _run(self, *args: str, timeout: float = 30.0) -> str: + """Supplied by the concrete driver: run one adb command.""" + + @abstractmethod + def dump(self, *, retries: int = 4) -> list[UiElement]: + """Supplied by the concrete driver: read the current UI tree.""" + + @abstractmethod + def wait_for( + self, query: str, *, timeout: float = 15.0, exact: bool = False + ) -> UiElement: + """Supplied by the concrete driver: block until an element appears.""" + + def _clear_focused_field(self, length: int) -> None: + """Empty the focused field: select-all, then delete.""" + if length == 0: + return + # KEYCODE_MOVE_END (123) then a run of deletes is more reliable across + # IMEs than CTRL+A, which not every keyboard honours. + self._run("shell", "input", "keyevent", "123") + for _ in range(length + 2): + self._run("shell", "input", "keyevent", "67") + time.sleep(self._settle) + + def editable_fields(self) -> list[UiElement]: + """Return every editable field on screen, in top-to-bottom order. + + An EMPTY text field carries no text, content-desc or resource-id, so + there is nothing to name it by — yet it is precisely the element a + caller needs to address in order to fill it in. Ordering by position + gives a stable handle ("the second field in the sync form") that does + not depend on a label the widget never had. + """ + fields = [e for e in self.dump() if e.class_name.endswith("EditText")] + return sorted(fields, key=lambda e: (e.bounds[1], e.bounds[0])) + + def type_into_field(self, index: int, text: str) -> None: + """Type ``text`` into the ``index``-th editable field on screen. + + Verifies the field changed, exactly like :meth:`type_into`. + """ + fields = self.editable_fields() + if index >= len(fields): + msg = f"asked for editable field #{index} but the screen has {len(fields)}" + raise ElementNotFoundError(msg) + target = fields[index] + before = target.text + x, y = target.center + self._run("shell", "input", "tap", str(x), str(y)) + time.sleep(max(self._settle, 0.8)) + # REPLACE, don't append. `input text` inserts at the cursor, so typing + # into a field that already holds something silently concatenates -- + # producing e.g. "old@example.comnew@example.com", which is accepted by + # the widget, passes a "did the text change?" check, and is wrong. + self._clear_focused_field(len(before)) + self._run("shell", "input", "text", _escape(text)) + time.sleep(self._settle) + after = self.editable_fields() + changed = any( + f.text != before + and abs(f.bounds[0] - target.bounds[0]) < _SAME_FIELD_X_TOLERANCE_PX + for f in after + ) + if not changed: + msg = ( + f"typed {len(text)} character(s) into editable field #{index} " + f"but its contents did not change — the tap did not focus it" + ) + raise UiAutomationError(msg) + + def dismiss_keyboard(self) -> None: + """Close the soft keyboard WITHOUT popping the current route. + + ``KEYCODE_BACK`` is the obvious way and the wrong one: Flutter treats + it as a route pop, so it navigates out of the screen and discards + anything typed. ``KEYCODE_ESCAPE`` leaves the route alone but does not + close every IME (Gboard ignores it), and a keyboard that stays up hides + the button you are about to tap -- so the tap lands on a key instead, + silently doing nothing useful. + + So: ask the IME to hide, verify with ``dumpsys input_method``, and only + then report success. Raises if the keyboard is still up, because + "tapped a letter key" is indistinguishable from "tapped the button" + unless somebody checks. + """ + for keyevent in ("111", "4"): + if not self.keyboard_is_up(): + return + self._run("shell", "input", "keyevent", keyevent) + time.sleep(max(self._settle, 0.6)) + if self.keyboard_is_up(): + msg = ( + "the soft keyboard is still covering the screen after ESCAPE " + "and BACK — any tap below it will hit a key, not your target" + ) + raise UiAutomationError(msg) + + def keyboard_is_up(self) -> bool: + """Return True while the soft keyboard is shown. + + Without this, a caller cannot tell "the button is absent" from "the + button is behind the keyboard", and the accessibility tree reports the + button's laid-out position either way. + """ + out = self._run("shell", "dumpsys", "input_method") + match = re.search(r"mInputShown=(\w+)", out) + return match is not None and match.group(1) == "true" + + def current_focus(self) -> str: + """Return the focused window, for asserting which screen is up.""" + out = self._run("shell", "dumpsys", "window") + match = re.search(r"mCurrentFocus=\S+ \S+ (\S+)}", out) + return match.group(1) if match else "" diff --git a/python_pkg/android_ui/driver.py b/python_pkg/android_ui/driver.py index c241f734..2f038d53 100644 --- a/python_pkg/android_ui/driver.py +++ b/python_pkg/android_ui/driver.py @@ -2,19 +2,24 @@ from __future__ import annotations -from dataclasses import dataclass import logging from pathlib import Path -import re import subprocess import tempfile import time -from defusedxml.ElementTree import ParseError, fromstring +from python_pkg.android_ui._elements import ( + AmbiguousElementError, + ElementNotFoundError, + UiAutomationError, + UiElement, + _escape, + _parse_tree, +) +from python_pkg.android_ui._text_entry import TextEntryMixin _logger = logging.getLogger(__name__) -_BOUNDS_RE = re.compile(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]") _REMOTE_DUMP = "/sdcard/window_dump.xml" # A tree this small is a partial render, not a real screen. Flutter reports @@ -23,61 +28,8 @@ _REMOTE_DUMP = "/sdcard/window_dump.xml" # the node count is taken into account. _SUSPICIOUSLY_SMALL_TREE = 4 -# Horizontal slack when re-identifying a field after typing: the keyboard can -# shift a widget vertically, but not sideways, so the left edge stays put. -_SAME_FIELD_X_TOLERANCE_PX = 50 - -class UiAutomationError(RuntimeError): - """Base class for every failure this package reports.""" - - -class ElementNotFoundError(UiAutomationError): - """No element matched the query.""" - - -class AmbiguousElementError(UiAutomationError): - """More than one element matched, so acting would be a coin flip.""" - - -@dataclass(frozen=True) -class UiElement: - """One node from the accessibility tree.""" - - text: str - content_desc: str - resource_id: str - class_name: str - bounds: tuple[int, int, int, int] - enabled: bool - focused: bool - - @property - def label(self) -> str: - """The best human-facing name for this element.""" - return self.text or self.content_desc or self.resource_id - - @property - def center(self) -> tuple[int, int]: - """Tap point: the centre of the element's CURRENT bounds.""" - left, top, right, bottom = self.bounds - return ((left + right) // 2, (top + bottom) // 2) - - def matches(self, query: str, *, exact: bool = False) -> bool: - """Return True if ``query`` names this element.""" - haystacks = (self.text, self.content_desc, self.resource_id) - if exact: - return any(h == query for h in haystacks) - return any(query.lower() in h.lower() for h in haystacks if h) - - def __str__(self) -> str: - """Return a one-line description naming the element and its centre.""" - cls = self.class_name.rsplit(".", 1)[-1] - x, y = self.center - return f"{self.label!r} <{cls}> at ({x},{y})" - - -class AndroidUi: +class AndroidUi(TextEntryMixin): """Drives one device. Every action re-reads the tree before acting.""" def __init__( @@ -180,63 +132,6 @@ class AndroidUi: raise AmbiguousElementError(msg) return matches[0] - def _clear_focused_field(self, length: int) -> None: - """Empty the focused field: select-all, then delete.""" - if length == 0: - return - # KEYCODE_MOVE_END (123) then a run of deletes is more reliable across - # IMEs than CTRL+A, which not every keyboard honours. - self._run("shell", "input", "keyevent", "123") - for _ in range(length + 2): - self._run("shell", "input", "keyevent", "67") - time.sleep(self._settle) - - def editable_fields(self) -> list[UiElement]: - """Return every editable field on screen, in top-to-bottom order. - - An EMPTY text field carries no text, content-desc or resource-id, so - there is nothing to name it by — yet it is precisely the element a - caller needs to address in order to fill it in. Ordering by position - gives a stable handle ("the second field in the sync form") that does - not depend on a label the widget never had. - """ - fields = [e for e in self.dump() if e.class_name.endswith("EditText")] - return sorted(fields, key=lambda e: (e.bounds[1], e.bounds[0])) - - def type_into_field(self, index: int, text: str) -> None: - """Type ``text`` into the ``index``-th editable field on screen. - - Verifies the field changed, exactly like :meth:`type_into`. - """ - fields = self.editable_fields() - if index >= len(fields): - msg = f"asked for editable field #{index} but the screen has {len(fields)}" - raise ElementNotFoundError(msg) - target = fields[index] - before = target.text - x, y = target.center - self._run("shell", "input", "tap", str(x), str(y)) - time.sleep(max(self._settle, 0.8)) - # REPLACE, don't append. `input text` inserts at the cursor, so typing - # into a field that already holds something silently concatenates -- - # producing e.g. "old@example.comnew@example.com", which is accepted by - # the widget, passes a "did the text change?" check, and is wrong. - self._clear_focused_field(len(before)) - self._run("shell", "input", "text", _escape(text)) - time.sleep(self._settle) - after = self.editable_fields() - changed = any( - f.text != before - and abs(f.bounds[0] - target.bounds[0]) < _SAME_FIELD_X_TOLERANCE_PX - for f in after - ) - if not changed: - msg = ( - f"typed {len(text)} character(s) into editable field #{index} " - f"but its contents did not change — the tap did not focus it" - ) - raise UiAutomationError(msg) - def wait_for( self, query: str, *, timeout: float = 15.0, exact: bool = False ) -> UiElement: @@ -305,107 +200,3 @@ class AndroidUi: f"did not change — the tap probably did not focus the field" ) raise UiAutomationError(msg) - - def dismiss_keyboard(self) -> None: - """Close the soft keyboard WITHOUT popping the current route. - - ``KEYCODE_BACK`` is the obvious way and the wrong one: Flutter treats - it as a route pop, so it navigates out of the screen and discards - anything typed. ``KEYCODE_ESCAPE`` leaves the route alone but does not - close every IME (Gboard ignores it), and a keyboard that stays up hides - the button you are about to tap -- so the tap lands on a key instead, - silently doing nothing useful. - - So: ask the IME to hide, verify with ``dumpsys input_method``, and only - then report success. Raises if the keyboard is still up, because - "tapped a letter key" is indistinguishable from "tapped the button" - unless somebody checks. - """ - for keyevent in ("111", "4"): - if not self.keyboard_is_up(): - return - self._run("shell", "input", "keyevent", keyevent) - time.sleep(max(self._settle, 0.6)) - if self.keyboard_is_up(): - msg = ( - "the soft keyboard is still covering the screen after ESCAPE " - "and BACK — any tap below it will hit a key, not your target" - ) - raise UiAutomationError(msg) - - def keyboard_is_up(self) -> bool: - """Return True while the soft keyboard is shown. - - Without this, a caller cannot tell "the button is absent" from "the - button is behind the keyboard", and the accessibility tree reports the - button's laid-out position either way. - """ - out = self._run("shell", "dumpsys", "input_method") - match = re.search(r"mInputShown=(\w+)", out) - return match is not None and match.group(1) == "true" - - def current_focus(self) -> str: - """Return the focused window, for asserting which screen is up.""" - out = self._run("shell", "dumpsys", "window") - match = re.search(r"mCurrentFocus=\S+ \S+ (\S+)}", out) - return match.group(1) if match else "" - - -def _escape(text: str) -> str: - r"""Escape text for ``adb shell input text``. - - Backslash is escaped FIRST and excluded from the loop below. Escaping it - inside the loop double-escapes every backslash the loop itself just added - (``&`` -> ``\\&`` -> ``\\\\&``), which silently types the wrong password. - """ - out = text.replace("\\", "\\\\") - out = out.replace("%", "%%").replace(" ", "%s") - for char in "()<>|;&*~\"'`$": - out = out.replace(char, "\\" + char) - return out - - -def _parse_tree(xml: str) -> list[UiElement]: - """Return every labelled node in an accessibility-tree dump.""" - try: - root = fromstring(xml) - except ParseError as exc: - _logger.warning( - "UI dump is not valid XML (%s) — treating it as an empty screen; " - "this is usually a snapshot taken mid-animation", - exc, - ) - return [] - - elements: list[UiElement] = [] - for node in root.iter("node"): - bounds = _BOUNDS_RE.match(node.get("bounds", "")) - if bounds is None: - continue - text = node.get("text", "") - desc = node.get("content-desc", "") - res = node.get("resource-id", "") - cls = node.get("class", "") - # An EMPTY text field has no text, content-desc or resource-id, so a - # "must be labelled" filter drops it -- and an empty field is exactly - # the thing a caller needs to find in order to type into it. Keep every - # editable node regardless of label. - if not (text or desc or res or cls.endswith("EditText")): - continue - elements.append( - UiElement( - text=text, - content_desc=desc, - resource_id=res, - class_name=cls, - bounds=( - int(bounds.group(1)), - int(bounds.group(2)), - int(bounds.group(3)), - int(bounds.group(4)), - ), - enabled=node.get("enabled") == "true", - focused=node.get("focused") == "true", - ) - ) - return elements diff --git a/python_pkg/android_ui/tests/test_driver.py b/python_pkg/android_ui/tests/test_driver.py index 2cc4f1d3..52b5648b 100644 --- a/python_pkg/android_ui/tests/test_driver.py +++ b/python_pkg/android_ui/tests/test_driver.py @@ -209,169 +209,3 @@ class TestTap: c for c in ui.device.calls if c[:3] == ("shell", "input", "keyevent") ] assert keyevents, "expected the keyboard to be dismissed first" - - -class TestKeyboard: - def test_reports_whether_the_keyboard_is_up(self, ui: Harness) -> None: - ui.device.keyboard_shown = True - assert ui.ui.keyboard_is_up() - ui.device.keyboard_shown = False - assert not ui.ui.keyboard_is_up() - - def test_dismiss_is_a_no_op_when_already_closed(self, ui: Harness) -> None: - ui.ui.dismiss_keyboard() - assert not [ - c for c in ui.device.calls if c[:3] == ("shell", "input", "keyevent") - ] - - def test_closes_on_the_second_keyevent_when_escape_is_ignored( - self, ui: Harness, monkeypatch: pytest.MonkeyPatch - ) -> None: - # Gboard ignores ESCAPE; BACK is the fallback. Exercises the loop - # running to completion rather than returning on the first check. - states = iter([True, True, False]) - monkeypatch.setattr(ui.ui, "keyboard_is_up", lambda: next(states, False)) - ui.ui.dismiss_keyboard() - sent = [ - c[3] for c in ui.device.calls if c[:3] == ("shell", "input", "keyevent") - ] - assert sent == ["111", "4"] - - def test_raises_when_the_keyboard_will_not_close( - self, ui: Harness, monkeypatch: pytest.MonkeyPatch - ) -> None: - # A keyboard that stays up hides the target; silently continuing would - # tap a letter key and report success. - monkeypatch.setattr(ui.ui, "keyboard_is_up", lambda: True) - with pytest.raises(drv.UiAutomationError, match="still covering"): - ui.ui.dismiss_keyboard() - - -class TestTyping: - def test_verifies_the_field_actually_changed(self, ui: Harness) -> None: - empty = _node(cls="android.widget.EditText", bounds="[0,0][100,50]") - filled = _node( - "typed@example.com", - cls="android.widget.EditText", - bounds="[0,0][100,50]", - ) - ui.device.trees = [_tree(empty), _tree(filled)] - ui.ui.type_into_field(0, "typed@example.com") - - def test_raises_when_the_tap_did_not_focus_the_field(self, ui: Harness) -> None: - # The observed failure: keystrokes go nowhere and NOTHING errors. - empty = _tree(_node(cls="android.widget.EditText", bounds="[0,0][100,50]")) - ui.device.trees = [empty] - with pytest.raises(drv.UiAutomationError, match="did not change"): - ui.ui.type_into_field(0, "typed@example.com") - - def test_clears_a_filled_field_before_typing(self, ui: Harness) -> None: - # `input text` inserts at the cursor: typing over "old" would produce - # "oldnew", which passes a naive "did it change?" check and is wrong. - before = _node("old", cls="android.widget.EditText", bounds="[0,0][100,50]") - after = _node("new", cls="android.widget.EditText", bounds="[0,0][100,50]") - ui.device.trees = [_tree(before), _tree(after)] - ui.ui.type_into_field(0, "new") - deletes = [ - c - for c in ui.device.calls - if c[:3] == ("shell", "input", "keyevent") and c[3] == "67" - ] - assert deletes, "expected the field to be cleared first" - - def test_refuses_an_index_the_screen_does_not_have(self, ui: Harness) -> None: - ui.device.trees = [_tree(_node(cls="android.widget.EditText"))] - with pytest.raises(drv.ElementNotFoundError, match="has 1"): - ui.ui.type_into_field(4, "x") - - def test_editable_fields_are_ordered_top_to_bottom(self, ui: Harness) -> None: - lower = _node(cls="android.widget.EditText", bounds="[0,300][100,350]") - upper = _node(cls="android.widget.EditText", bounds="[0,100][100,150]") - ui.device.trees = [_tree(lower, upper)] - assert [f.bounds[1] for f in ui.ui.editable_fields()] == [100, 300] - - def test_type_into_named_field_verifies_too(self, ui: Harness) -> None: - before = _node( - desc="Email", cls="android.widget.EditText", bounds="[0,0][100,50]" - ) - after = _node( - "x@y.z", desc="Email", cls="android.widget.EditText", bounds="[0,0][100,50]" - ) - ui.device.trees = [_tree(before), _tree(before), _tree(after)] - ui.ui.type_into("Email", "x@y.z") - - def test_type_into_named_field_raises_when_unchanged(self, ui: Harness) -> None: - node = _node(desc="Email", cls="android.widget.EditText") - ui.device.trees = [_tree(node)] - with pytest.raises(drv.UiAutomationError, match="did not change"): - ui.ui.type_into("Email", "x@y.z") - - -class TestEscape: - def test_spaces_become_percent_s(self) -> None: - assert drv._escape("a b") == "a%sb" - - def test_shell_metacharacters_are_escaped(self) -> None: - assert drv._escape("a&b") == r"a\&b" - - def test_percent_is_escaped_first(self) -> None: - assert drv._escape("100%") == "100%%" - - -class TestCurrentFocus: - def test_returns_the_focused_activity(self, ui: Harness) -> None: - assert ui.ui.current_focus() == "com.example/com.example.Main" - - def test_empty_when_nothing_matches( - self, ui: Harness, monkeypatch: pytest.MonkeyPatch - ) -> None: - monkeypatch.setattr(ui.ui, "_run", lambda *a, **k: "no focus here") - assert ui.ui.current_focus() == "" - - -class TestRun: - def test_serial_is_passed_through(self, monkeypatch: pytest.MonkeyPatch) -> None: - seen: dict[str, list[str]] = {} - - class _Done: - returncode = 0 - stdout = "ok" - stderr = "" - - def _fake_run(cmd: list[str], **_kwargs: object) -> _Done: - seen["cmd"] = cmd - return _Done() - - monkeypatch.setattr(f"{MOD}.subprocess.run", _fake_run) - drv.AndroidUi(serial="ABC123")._run("devices") - assert seen["cmd"][:3] == ["adb", "-s", "ABC123"] - - def test_nonzero_exit_raises_with_stderr( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - class _Done: - returncode = 1 - stdout = "" - stderr = "device offline" - - monkeypatch.setattr(f"{MOD}.subprocess.run", lambda *a, **k: _Done()) - with pytest.raises(drv.UiAutomationError, match="device offline"): - drv.AndroidUi()._run("devices") - - def test_timeout_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: - def _boom(*_a: object, **_k: object) -> None: - raise drv.subprocess.TimeoutExpired(cmd="adb", timeout=1) - - monkeypatch.setattr(f"{MOD}.subprocess.run", _boom) - with pytest.raises(drv.UiAutomationError, match="timed out"): - drv.AndroidUi()._run("devices") - - def test_unreadable_dump_raises( - self, ui: Harness, monkeypatch: pytest.MonkeyPatch - ) -> None: - def _no_write(*_a: object, **_k: object) -> str: - return "" - - monkeypatch.setattr(ui.ui, "_run", _no_write) - with pytest.raises(drv.UiAutomationError, match="could not read"): - ui.ui._dump_once() diff --git a/python_pkg/android_ui/tests/test_shell_helpers.py b/python_pkg/android_ui/tests/test_shell_helpers.py new file mode 100644 index 00000000..c1012835 --- /dev/null +++ b/python_pkg/android_ui/tests/test_shell_helpers.py @@ -0,0 +1,158 @@ +"""Tests for shell escaping, focus reads and the adb runner.""" + +from __future__ import annotations + +from pathlib import Path +from typing import NamedTuple + +import pytest + +from python_pkg.android_ui import driver as drv + +MOD = "python_pkg.android_ui.driver" + + +def _node(text: str = "", **attrs: str) -> str: + """Render one ```` element for a fake dump. + + ``attrs`` accepts ``desc``, ``res``, ``cls``, ``bounds``, ``enabled`` and + ``focused``; each falls back to a representative default. + """ + return ( + f'' + ) + + +def _tree(*nodes: str) -> str: + """Wrap ``nodes`` in a hierarchy document.""" + return f"{''.join(nodes)}" + + +class FakeDevice: + """Scripted stand-in for adb, recording every command it is given.""" + + def __init__(self, trees: list[str] | None = None) -> None: + self.trees = trees or [_tree(_node("Connect"))] + self.calls: list[tuple[str, ...]] = [] + self.keyboard_shown = False + self.pulled = 0 + + def run(self, *args: str, timeout: float = 30.0) -> str: + """Answer an adb invocation.""" + del timeout + self.calls.append(args) + if args[:2] == ("shell", "dumpsys") and args[2] == "input_method": + return f"mInputShown={'true' if self.keyboard_shown else 'false'}" + if args[:2] == ("shell", "dumpsys") and args[2] == "window": + return "mCurrentFocus=Window{ab12 u0 com.example/com.example.Main}" + if args[0] == "pull": + index = min(self.pulled, len(self.trees) - 1) + self.pulled += 1 + Path(args[2]).write_text(self.trees[index], encoding="utf-8") + if args[:3] == ("shell", "input", "keyevent") and args[3] in {"111", "4"}: + self.keyboard_shown = False + return "" + + def taps(self) -> list[tuple[int, int]]: + """Return every tap coordinate, in order.""" + return [ + (int(c[3]), int(c[4])) + for c in self.calls + if c[:3] == ("shell", "input", "tap") + ] + + +class Harness(NamedTuple): + """A driver plus the fake device backing it. + + Returned as a pair rather than by bolting the device onto the driver, so + the test needs no attribute that production code does not have. + """ + + ui: drv.AndroidUi + device: FakeDevice + + +@pytest.fixture +def ui(monkeypatch: pytest.MonkeyPatch) -> Harness: + """An :class:`AndroidUi` wired to a :class:`FakeDevice`.""" + device = FakeDevice() + instance = drv.AndroidUi(settle_seconds=0.0) + monkeypatch.setattr(instance, "_run", device.run) + monkeypatch.setattr(f"{MOD}.time.sleep", lambda _s: None) + return Harness(ui=instance, device=device) + + +class TestEscape: + def test_spaces_become_percent_s(self) -> None: + assert drv._escape("a b") == "a%sb" + + def test_shell_metacharacters_are_escaped(self) -> None: + assert drv._escape("a&b") == r"a\&b" + + def test_percent_is_escaped_first(self) -> None: + assert drv._escape("100%") == "100%%" + + +class TestCurrentFocus: + def test_returns_the_focused_activity(self, ui: Harness) -> None: + assert ui.ui.current_focus() == "com.example/com.example.Main" + + def test_empty_when_nothing_matches( + self, ui: Harness, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(ui.ui, "_run", lambda *a, **k: "no focus here") + assert ui.ui.current_focus() == "" + + +class TestRun: + def test_serial_is_passed_through(self, monkeypatch: pytest.MonkeyPatch) -> None: + seen: dict[str, list[str]] = {} + + class _Done: + returncode = 0 + stdout = "ok" + stderr = "" + + def _fake_run(cmd: list[str], **_kwargs: object) -> _Done: + seen["cmd"] = cmd + return _Done() + + monkeypatch.setattr(f"{MOD}.subprocess.run", _fake_run) + drv.AndroidUi(serial="ABC123")._run("devices") + assert seen["cmd"][:3] == ["adb", "-s", "ABC123"] + + def test_nonzero_exit_raises_with_stderr( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + class _Done: + returncode = 1 + stdout = "" + stderr = "device offline" + + monkeypatch.setattr(f"{MOD}.subprocess.run", lambda *a, **k: _Done()) + with pytest.raises(drv.UiAutomationError, match="device offline"): + drv.AndroidUi()._run("devices") + + def test_timeout_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: + def _boom(*_a: object, **_k: object) -> None: + raise drv.subprocess.TimeoutExpired(cmd="adb", timeout=1) + + monkeypatch.setattr(f"{MOD}.subprocess.run", _boom) + with pytest.raises(drv.UiAutomationError, match="timed out"): + drv.AndroidUi()._run("devices") + + def test_unreadable_dump_raises( + self, ui: Harness, monkeypatch: pytest.MonkeyPatch + ) -> None: + def _no_write(*_a: object, **_k: object) -> str: + return "" + + monkeypatch.setattr(ui.ui, "_run", _no_write) + with pytest.raises(drv.UiAutomationError, match="could not read"): + ui.ui._dump_once() diff --git a/python_pkg/android_ui/tests/test_text_entry.py b/python_pkg/android_ui/tests/test_text_entry.py new file mode 100644 index 00000000..85944b65 --- /dev/null +++ b/python_pkg/android_ui/tests/test_text_entry.py @@ -0,0 +1,184 @@ +"""Tests for the keyboard and text-entry half of the driver.""" + +from __future__ import annotations + +from pathlib import Path +from typing import NamedTuple + +import pytest + +from python_pkg.android_ui import driver as drv + +MOD = "python_pkg.android_ui.driver" + + +def _node(text: str = "", **attrs: str) -> str: + """Render one ```` element for a fake dump. + + ``attrs`` accepts ``desc``, ``res``, ``cls``, ``bounds``, ``enabled`` and + ``focused``; each falls back to a representative default. + """ + return ( + f'' + ) + + +def _tree(*nodes: str) -> str: + """Wrap ``nodes`` in a hierarchy document.""" + return f"{''.join(nodes)}" + + +class FakeDevice: + """Scripted stand-in for adb, recording every command it is given.""" + + def __init__(self, trees: list[str] | None = None) -> None: + self.trees = trees or [_tree(_node("Connect"))] + self.calls: list[tuple[str, ...]] = [] + self.keyboard_shown = False + self.pulled = 0 + + def run(self, *args: str, timeout: float = 30.0) -> str: + """Answer an adb invocation.""" + del timeout + self.calls.append(args) + if args[:2] == ("shell", "dumpsys") and args[2] == "input_method": + return f"mInputShown={'true' if self.keyboard_shown else 'false'}" + if args[:2] == ("shell", "dumpsys") and args[2] == "window": + return "mCurrentFocus=Window{ab12 u0 com.example/com.example.Main}" + if args[0] == "pull": + index = min(self.pulled, len(self.trees) - 1) + self.pulled += 1 + Path(args[2]).write_text(self.trees[index], encoding="utf-8") + if args[:3] == ("shell", "input", "keyevent") and args[3] in {"111", "4"}: + self.keyboard_shown = False + return "" + + def taps(self) -> list[tuple[int, int]]: + """Return every tap coordinate, in order.""" + return [ + (int(c[3]), int(c[4])) + for c in self.calls + if c[:3] == ("shell", "input", "tap") + ] + + +class Harness(NamedTuple): + """A driver plus the fake device backing it. + + Returned as a pair rather than by bolting the device onto the driver, so + the test needs no attribute that production code does not have. + """ + + ui: drv.AndroidUi + device: FakeDevice + + +@pytest.fixture +def ui(monkeypatch: pytest.MonkeyPatch) -> Harness: + """An :class:`AndroidUi` wired to a :class:`FakeDevice`.""" + device = FakeDevice() + instance = drv.AndroidUi(settle_seconds=0.0) + monkeypatch.setattr(instance, "_run", device.run) + monkeypatch.setattr(f"{MOD}.time.sleep", lambda _s: None) + return Harness(ui=instance, device=device) + + +class TestKeyboard: + def test_reports_whether_the_keyboard_is_up(self, ui: Harness) -> None: + ui.device.keyboard_shown = True + assert ui.ui.keyboard_is_up() + ui.device.keyboard_shown = False + assert not ui.ui.keyboard_is_up() + + def test_dismiss_is_a_no_op_when_already_closed(self, ui: Harness) -> None: + ui.ui.dismiss_keyboard() + assert not [ + c for c in ui.device.calls if c[:3] == ("shell", "input", "keyevent") + ] + + def test_closes_on_the_second_keyevent_when_escape_is_ignored( + self, ui: Harness, monkeypatch: pytest.MonkeyPatch + ) -> None: + # Gboard ignores ESCAPE; BACK is the fallback. Exercises the loop + # running to completion rather than returning on the first check. + states = iter([True, True, False]) + monkeypatch.setattr(ui.ui, "keyboard_is_up", lambda: next(states, False)) + ui.ui.dismiss_keyboard() + sent = [ + c[3] for c in ui.device.calls if c[:3] == ("shell", "input", "keyevent") + ] + assert sent == ["111", "4"] + + def test_raises_when_the_keyboard_will_not_close( + self, ui: Harness, monkeypatch: pytest.MonkeyPatch + ) -> None: + # A keyboard that stays up hides the target; silently continuing would + # tap a letter key and report success. + monkeypatch.setattr(ui.ui, "keyboard_is_up", lambda: True) + with pytest.raises(drv.UiAutomationError, match="still covering"): + ui.ui.dismiss_keyboard() + + +class TestTyping: + def test_verifies_the_field_actually_changed(self, ui: Harness) -> None: + empty = _node(cls="android.widget.EditText", bounds="[0,0][100,50]") + filled = _node( + "typed@example.com", + cls="android.widget.EditText", + bounds="[0,0][100,50]", + ) + ui.device.trees = [_tree(empty), _tree(filled)] + ui.ui.type_into_field(0, "typed@example.com") + + def test_raises_when_the_tap_did_not_focus_the_field(self, ui: Harness) -> None: + # The observed failure: keystrokes go nowhere and NOTHING errors. + empty = _tree(_node(cls="android.widget.EditText", bounds="[0,0][100,50]")) + ui.device.trees = [empty] + with pytest.raises(drv.UiAutomationError, match="did not change"): + ui.ui.type_into_field(0, "typed@example.com") + + def test_clears_a_filled_field_before_typing(self, ui: Harness) -> None: + # `input text` inserts at the cursor: typing over "old" would produce + # "oldnew", which passes a naive "did it change?" check and is wrong. + before = _node("old", cls="android.widget.EditText", bounds="[0,0][100,50]") + after = _node("new", cls="android.widget.EditText", bounds="[0,0][100,50]") + ui.device.trees = [_tree(before), _tree(after)] + ui.ui.type_into_field(0, "new") + deletes = [ + c + for c in ui.device.calls + if c[:3] == ("shell", "input", "keyevent") and c[3] == "67" + ] + assert deletes, "expected the field to be cleared first" + + def test_refuses_an_index_the_screen_does_not_have(self, ui: Harness) -> None: + ui.device.trees = [_tree(_node(cls="android.widget.EditText"))] + with pytest.raises(drv.ElementNotFoundError, match="has 1"): + ui.ui.type_into_field(4, "x") + + def test_editable_fields_are_ordered_top_to_bottom(self, ui: Harness) -> None: + lower = _node(cls="android.widget.EditText", bounds="[0,300][100,350]") + upper = _node(cls="android.widget.EditText", bounds="[0,100][100,150]") + ui.device.trees = [_tree(lower, upper)] + assert [f.bounds[1] for f in ui.ui.editable_fields()] == [100, 300] + + def test_type_into_named_field_verifies_too(self, ui: Harness) -> None: + before = _node( + desc="Email", cls="android.widget.EditText", bounds="[0,0][100,50]" + ) + after = _node( + "x@y.z", desc="Email", cls="android.widget.EditText", bounds="[0,0][100,50]" + ) + ui.device.trees = [_tree(before), _tree(before), _tree(after)] + ui.ui.type_into("Email", "x@y.z") + + def test_type_into_named_field_raises_when_unchanged(self, ui: Harness) -> None: + node = _node(desc="Email", cls="android.widget.EditText") + ui.device.trees = [_tree(node)] + with pytest.raises(drv.UiAutomationError, match="did not change"): + ui.ui.type_into("Email", "x@y.z") diff --git a/python_pkg/app_icons/_glyph_art.py b/python_pkg/app_icons/_glyph_art.py new file mode 100644 index 00000000..f504e40f --- /dev/null +++ b/python_pkg/app_icons/_glyph_art.py @@ -0,0 +1,220 @@ +"""The SVG path fragments each glyph is drawn from. + +Split out of :mod:`python_pkg.app_icons.glyphs` to keep it under the 250-line +cap. Pure data: :mod:`glyphs` keeps the Glyph dataclass, the registry and the +lookup, so no caller or test needs to know these moved. +""" + +from __future__ import annotations + +_CLOUD_DOWN = """\ + + + + + """ + +# Bar, two plates and two collars. Plate spacing is well above the minimum +# negative space so the bar does not fill in at launcher size. +_BARBELL = """\ + + + + + """ + +# Plain clock face. Deliberately no bell "ears": at launcher size they read as +# noise rather than as an alarm clock. +_CLOCK = """\ + + + """ + +# Shield outline guarding filled cutlery. The fork is paired with a knife on +# purpose: a lone three-tine fork reads as a trident at small sizes. +_SHIELD_CUTLERY = """\ + + + + + """ + +# Two ticked-off list rows. More specific to a notes/todo app than a lone +# checkmark, and still legible at 48dp. +_CHECKLIST = """\ + + + + """ + +# A branching decision tree: one node splitting into two. Reads as "decompose +# a problem into parts", the move every one of the 25 thinking tools makes in +# some form -- an issue tree most literally, but also a fishbone's ribs and a +# 2x2's quadrants. +# +# Nodes are hollow circles rather than filled dots: at 48dp a filled dot and +# the 72px stroke merge into a blob, while the ring keeps visible negative +# space (r=78 leaves 156-72=84px of interior). +# +# Two levels of branching were tried first (7 nodes) and rejected: the +# generator's safe-box check flagged it as overflowing by ~170px, and at icon +# size the doubled connectors closed up into a solid mass. One clean split +# survives the launcher mask and still reads as a tree rather than a Y-fork, +# because the square shoulders and vertical drops keep the hierarchy legible. +_DECISION_TREE = """\ + + + + + + + """ + +# Two interlocking stadium (pill-ring) outlines, both rotated -45 deg and +# offset along the anti-diagonal so their strokes overlap in the middle -- +# reads as both "habit-stacking" (linked items) and "don't break the chain". +# Ring height is tall enough that the 72px stroke leaves a genuinely hollow +# interior (height - 2*stroke-width well above MIN_NEGATIVE_SPACE); a +# shorter ring's stroke fills the interior solid and reads as a blob. +_CHAIN_LINK = """\ + + + + + + """ + + +# An isometric storage carton: hexagonal silhouette plus the three edges that +# meet at the near-top corner (one down the front, two up the top face). +# +# A front-on rectangle with a lid seam was tried first and rejected — a seam +# plus a divider reads as a browser window or a table header, not a box. The +# isometric form has no such collision anywhere else in the family, and it is +# the shape people already recognise as "a package of stuff". +# +# Geometry: a regular hexagon, centre (512,512), circumradius 240, pointy top +# and bottom. Opposite faces sit 208px apart, leaving 136px of hollow once the +# 72px stroke is drawn — comfortably past MIN_NEGATIVE_SPACE, so no face fills +# in at 48dp. Rendered ink is 488x552, inside SAFE_BOX on both axes. +_STORAGE_BOX = """\ + + + + """ + + +# A till receipt with a torn bottom edge and a perforation down the middle: +# one bill, two shares. +# +# Filled rather than stroked, which is what makes the tear possible at all. A +# stroked receipt cannot have a fine ragged edge: at the family's 72px stroke, +# a zigzag needs ~200px pitch to keep adjacent up-strokes MIN_NEGATIVE_SPACE +# apart, and two teeth that coarse read as a ribbon or a pair of bookmarks, +# not as a torn receipt. A filled silhouette is not bound by that rule, so the +# teeth can be small enough to read as a real tear — the "prefer a filled +# silhouette when a stroked outline reads ambiguously" case. +# +# The split is a perforation, not a divider. A full-height divider cuts the +# silhouette into two separate shapes and the receipt read is lost (the same +# bookmarks failure); a dotted tear line says "split here" while leaving one +# intact receipt. Drawn with fill-rule="evenodd" so the dots are true holes, +# not charcoal-painted ones — icon_foreground and icon_monochrome are rendered +# on transparency, where a painted hole would show as a blob and, on the +# monochrome layer, in the wrong colour entirely. +# +# Geometry: body x 312..712, y 282..752, six teeth 46px deep off a root at +# y=706. Four holes of r=30 on the centreline at y=350/446/542/638. Every gap +# is at the family budget: 36px of accent between holes (96px pitch, 60px +# diameter), 38px above the first and below the last. Ink is 400x470, inside +# SAFE_BOX on both axes. +_RECEIPT_SPLIT = """\ + """ + + +# A dip-pen nib: the character sheet is a thing you write on, and the game is +# set in the Romantic era, so the nib carries both the app and the period. +# +# Filled rather than stroked. A stroked almond outline plus a stroked vent hole +# leaves only ~20px of charcoal between the two strokes at the family's 72px +# weight — under MIN_NEGATIVE_SPACE, so the nib fills in solid at 48dp. Filled, +# the vent and the slit become true holes and the budget is spent on gaps +# instead of on stroke. +# +# A d20 was the obvious alternative and was rejected: this system rolls k6, and +# a 20-sided outline is a mess of short strokes at launcher size. A front-on +# die with pips collides with `storage-box` (both read as "boxy thing with +# marks"). Nothing else in the family is a tall pointed shape. +# +# The top edge is flat, not pointed. A symmetric almond — pointed at both ends +# — with a round hole in the upper half is a map pin, which is exactly what the +# first draft rendered as. The flat shoulder is where a real nib clips into the +# holder, and it is what stops the map-pin read. +# +# Geometry: shoulder (420,312)-(604,312), waist bowing out to ~x=400/624 around +# y=456, converging to the tip at (512,812) — ink 224x500, inside SAFE_BOX on +# both axes. Vent circle r=40 at (512,470) clears the outline by ~61px; the +# slit stops at y=700, leaving ~41px to the outline near the tip and 70px to +# the vent above. Every gap is over the 36px budget. fill-rule="evenodd" keeps +# both holes true holes — icon_foreground and icon_monochrome render on +# transparency, where a painted hole would show as a blob and in the wrong +# colour. +_QUILL_NIB = """\ + """ + + +# A blacksmith's anvil: OctoForge is a GitHub client, and the forge is the +# name. Filled rather than stroked, for the same reason as the nib — a stroked +# anvil outline is a long thin horn plus a narrow waist, and at the family's +# 72px weight the waist closes up solid at 48dp. +# +# The obvious alternatives were rejected: an octopus (the Octocat) is both +# GitHub trade dress and a tangle of thin curves that blurs at launcher size, +# and a hammer alone collides with nothing in the family but reads as a generic +# tool rather than a forge. The anvil silhouette is unmistakable and shares no +# shape language with the other nine glyphs. +# +# Geometry: the body is a face slab (x=336..768, y=316..420), a waist +# (x=444..596, y=420..592) and a flared foot (x=304..716, y=592..708) — total +# ink 512x392 including the horn, inside SAFE_BOX once the renderer scales it. +# +# The horn is what makes this an anvil rather than an I-beam, so it is a +# separate wedge projecting left off the face, not a rounded corner on it: it +# leaves the slab at full 104px depth (x=336) and tapers to a 40px blunt tip +# at x=256, dropping the underside from y=420 to y=392 on the way. A sharp +# point would disappear at 48dp; 40px is above MIN_NEGATIVE_SPACE so it +# survives. The first two drafts folded the taper into the slab's left end and +# both read as a bump. +_ANVIL = """\ + """ + +# Three rising bars with a plotted point above the tallest -- a day-by-day log +# that adds up to a trend, which is what a life tracker is. The bars are +# strokes rather than filled rects so the glyph keeps one stroke weight +# throughout; at a 168px pitch the 72px strokes leave 96px gaps, well above +# MIN_NEGATIVE_SPACE. The marker sits on its own row clear of the bar caps, +# and is hollow so it reads as a data point rather than a full stop. +_TRACK_BARS = """\ + + + + """ diff --git a/python_pkg/app_icons/glyphs.py b/python_pkg/app_icons/glyphs.py index 065d40c9..1d7e6c87 100644 --- a/python_pkg/app_icons/glyphs.py +++ b/python_pkg/app_icons/glyphs.py @@ -16,6 +16,21 @@ from __future__ import annotations from dataclasses import dataclass from typing import Final +from python_pkg.app_icons._glyph_art import ( + _ANVIL, + _BARBELL, + _CHAIN_LINK, + _CHECKLIST, + _CLOCK, + _CLOUD_DOWN, + _DECISION_TREE, + _QUILL_NIB, + _RECEIPT_SPLIT, + _SHIELD_CUTLERY, + _STORAGE_BOX, + _TRACK_BARS, +) + @dataclass(frozen=True) class Glyph: @@ -34,218 +49,6 @@ class Glyph: # A cloud silhouette outline with a download arrow underneath: a client that # pulls files off a remote dufs server. -_CLOUD_DOWN = """\ - - - - - """ - -# Bar, two plates and two collars. Plate spacing is well above the minimum -# negative space so the bar does not fill in at launcher size. -_BARBELL = """\ - - - - - """ - -# Plain clock face. Deliberately no bell "ears": at launcher size they read as -# noise rather than as an alarm clock. -_CLOCK = """\ - - - """ - -# Shield outline guarding filled cutlery. The fork is paired with a knife on -# purpose: a lone three-tine fork reads as a trident at small sizes. -_SHIELD_CUTLERY = """\ - - - - - """ - -# Two ticked-off list rows. More specific to a notes/todo app than a lone -# checkmark, and still legible at 48dp. -_CHECKLIST = """\ - - - - """ - -# A branching decision tree: one node splitting into two. Reads as "decompose -# a problem into parts", the move every one of the 25 thinking tools makes in -# some form -- an issue tree most literally, but also a fishbone's ribs and a -# 2x2's quadrants. -# -# Nodes are hollow circles rather than filled dots: at 48dp a filled dot and -# the 72px stroke merge into a blob, while the ring keeps visible negative -# space (r=78 leaves 156-72=84px of interior). -# -# Two levels of branching were tried first (7 nodes) and rejected: the -# generator's safe-box check flagged it as overflowing by ~170px, and at icon -# size the doubled connectors closed up into a solid mass. One clean split -# survives the launcher mask and still reads as a tree rather than a Y-fork, -# because the square shoulders and vertical drops keep the hierarchy legible. -_DECISION_TREE = """\ - - - - - - - """ - -# Two interlocking stadium (pill-ring) outlines, both rotated -45 deg and -# offset along the anti-diagonal so their strokes overlap in the middle -- -# reads as both "habit-stacking" (linked items) and "don't break the chain". -# Ring height is tall enough that the 72px stroke leaves a genuinely hollow -# interior (height - 2*stroke-width well above MIN_NEGATIVE_SPACE); a -# shorter ring's stroke fills the interior solid and reads as a blob. -_CHAIN_LINK = """\ - - - - - - """ - - -# An isometric storage carton: hexagonal silhouette plus the three edges that -# meet at the near-top corner (one down the front, two up the top face). -# -# A front-on rectangle with a lid seam was tried first and rejected — a seam -# plus a divider reads as a browser window or a table header, not a box. The -# isometric form has no such collision anywhere else in the family, and it is -# the shape people already recognise as "a package of stuff". -# -# Geometry: a regular hexagon, centre (512,512), circumradius 240, pointy top -# and bottom. Opposite faces sit 208px apart, leaving 136px of hollow once the -# 72px stroke is drawn — comfortably past MIN_NEGATIVE_SPACE, so no face fills -# in at 48dp. Rendered ink is 488x552, inside SAFE_BOX on both axes. -_STORAGE_BOX = """\ - - - - """ - - -# A till receipt with a torn bottom edge and a perforation down the middle: -# one bill, two shares. -# -# Filled rather than stroked, which is what makes the tear possible at all. A -# stroked receipt cannot have a fine ragged edge: at the family's 72px stroke, -# a zigzag needs ~200px pitch to keep adjacent up-strokes MIN_NEGATIVE_SPACE -# apart, and two teeth that coarse read as a ribbon or a pair of bookmarks, -# not as a torn receipt. A filled silhouette is not bound by that rule, so the -# teeth can be small enough to read as a real tear — the "prefer a filled -# silhouette when a stroked outline reads ambiguously" case. -# -# The split is a perforation, not a divider. A full-height divider cuts the -# silhouette into two separate shapes and the receipt read is lost (the same -# bookmarks failure); a dotted tear line says "split here" while leaving one -# intact receipt. Drawn with fill-rule="evenodd" so the dots are true holes, -# not charcoal-painted ones — icon_foreground and icon_monochrome are rendered -# on transparency, where a painted hole would show as a blob and, on the -# monochrome layer, in the wrong colour entirely. -# -# Geometry: body x 312..712, y 282..752, six teeth 46px deep off a root at -# y=706. Four holes of r=30 on the centreline at y=350/446/542/638. Every gap -# is at the family budget: 36px of accent between holes (96px pitch, 60px -# diameter), 38px above the first and below the last. Ink is 400x470, inside -# SAFE_BOX on both axes. -_RECEIPT_SPLIT = """\ - """ - - -# A dip-pen nib: the character sheet is a thing you write on, and the game is -# set in the Romantic era, so the nib carries both the app and the period. -# -# Filled rather than stroked. A stroked almond outline plus a stroked vent hole -# leaves only ~20px of charcoal between the two strokes at the family's 72px -# weight — under MIN_NEGATIVE_SPACE, so the nib fills in solid at 48dp. Filled, -# the vent and the slit become true holes and the budget is spent on gaps -# instead of on stroke. -# -# A d20 was the obvious alternative and was rejected: this system rolls k6, and -# a 20-sided outline is a mess of short strokes at launcher size. A front-on -# die with pips collides with `storage-box` (both read as "boxy thing with -# marks"). Nothing else in the family is a tall pointed shape. -# -# The top edge is flat, not pointed. A symmetric almond — pointed at both ends -# — with a round hole in the upper half is a map pin, which is exactly what the -# first draft rendered as. The flat shoulder is where a real nib clips into the -# holder, and it is what stops the map-pin read. -# -# Geometry: shoulder (420,312)-(604,312), waist bowing out to ~x=400/624 around -# y=456, converging to the tip at (512,812) — ink 224x500, inside SAFE_BOX on -# both axes. Vent circle r=40 at (512,470) clears the outline by ~61px; the -# slit stops at y=700, leaving ~41px to the outline near the tip and 70px to -# the vent above. Every gap is over the 36px budget. fill-rule="evenodd" keeps -# both holes true holes — icon_foreground and icon_monochrome render on -# transparency, where a painted hole would show as a blob and in the wrong -# colour. -_QUILL_NIB = """\ - """ - - -# A blacksmith's anvil: OctoForge is a GitHub client, and the forge is the -# name. Filled rather than stroked, for the same reason as the nib — a stroked -# anvil outline is a long thin horn plus a narrow waist, and at the family's -# 72px weight the waist closes up solid at 48dp. -# -# The obvious alternatives were rejected: an octopus (the Octocat) is both -# GitHub trade dress and a tangle of thin curves that blurs at launcher size, -# and a hammer alone collides with nothing in the family but reads as a generic -# tool rather than a forge. The anvil silhouette is unmistakable and shares no -# shape language with the other nine glyphs. -# -# Geometry: the body is a face slab (x=336..768, y=316..420), a waist -# (x=444..596, y=420..592) and a flared foot (x=304..716, y=592..708) — total -# ink 512x392 including the horn, inside SAFE_BOX once the renderer scales it. -# -# The horn is what makes this an anvil rather than an I-beam, so it is a -# separate wedge projecting left off the face, not a rounded corner on it: it -# leaves the slab at full 104px depth (x=336) and tapers to a 40px blunt tip -# at x=256, dropping the underside from y=420 to y=392 on the way. A sharp -# point would disappear at 48dp; 40px is above MIN_NEGATIVE_SPACE so it -# survives. The first two drafts folded the taper into the slab's left end and -# both read as a bump. -_ANVIL = """\ - """ - -# Three rising bars with a plotted point above the tallest -- a day-by-day log -# that adds up to a trend, which is what a life tracker is. The bars are -# strokes rather than filled rects so the glyph keeps one stroke weight -# throughout; at a 168px pitch the 72px strokes leave 96px gaps, well above -# MIN_NEGATIVE_SPACE. The marker sits on its own row clear of the bar caps, -# and is hollow so it reads as a data point rather than a full stop. -_TRACK_BARS = """\ - - - - """ - GLYPHS: Final[dict[str, Glyph]] = { glyph.name: glyph diff --git a/python_pkg/focus_policy/tests/test_export.py b/python_pkg/focus_policy/tests/test_export.py index 2ffb72cf..bb46ecff 100644 --- a/python_pkg/focus_policy/tests/test_export.py +++ b/python_pkg/focus_policy/tests/test_export.py @@ -12,7 +12,6 @@ import datetime as dt from python_pkg.focus_policy.export import ( ENFORCER_PACKAGE, policy_to_dict, - policy_to_json, ) from python_pkg.focus_policy.model import CurfewWindow, FocusPolicy, HomeLocation @@ -159,97 +158,3 @@ def test_private_dns_host_is_exported_for_pinning() -> None: # (PRIVATE_DNS_SET_ERROR_HOST_NOT_SERVING). assert payload["private_dns_host"] == "" assert payload["vpn_lockdown"] is True - - -def test_always_blocked_is_a_subset_of_blockable() -> None: - """An always-blocked system app the sweep cannot see would silently no-op. - - The runner filters FLAG_SYSTEM packages out of ``installedPackages`` - unless they are opted in, so the decision layer would never receive them. - """ - payload = policy_to_dict(_policy()) - - assert set(payload["always_blocked_packages"]) <= set( - payload["blockable_system_packages"], - ) - - -def test_always_blocked_never_contradicts_the_allowlist() -> None: - """Allowing a package wins over always-blocking it. - - The asset must not state both at once; the subtraction keeps the two - fields consistent no matter what config.sh says. - """ - payload = policy_to_dict( - _policy( - allowed_packages=frozenset( - {"pl.mbank", "com.launcher", "com.android.chrome"}, - ), - ), - ) - - assert "com.android.chrome" not in payload["always_blocked_packages"] - assert "com.android.chrome" in payload["allowed_packages"] - - -def test_a_prefix_allowance_also_wins_over_always_blocking() -> None: - """Allowance is no longer exact-only, so the check must not be either. - - Subtracting just ``allowed_packages`` left a package allowed by prefix - still emitted as always-blocked, so the asset stated both at once and the - runner's behaviour depended on which check it applied first. - """ - payload = policy_to_dict( - _policy( - allowed_packages=frozenset({"pl.mbank", "com.launcher"}), - allowed_prefixes=("com.android.chrome",), - ), - ) - - assert "com.android.chrome" not in payload["always_blocked_packages"] - - -def test_the_real_distraction_apps_stay_always_blocked() -> None: - """The filter must not quietly un-block what the app exists to block.""" - payload = policy_to_dict(_policy()) - - assert "com.google.android.youtube" in payload["always_blocked_packages"] - assert "com.android.chrome" in payload["always_blocked_packages"] - - -def test_lists_are_sorted_for_stable_diffs() -> None: - """Sorted output keeps a committed asset's diff meaningful.""" - payload = policy_to_dict(_policy()) - - for key in ( - "allowed_packages", - "night_allowed_packages", - "never_disable_prefixes", - "workout_unblock_domains", - "browser_packages", - ): - assert payload[key] == sorted(payload[key]) - - -def test_curfew_is_rendered_as_wall_clock_strings() -> None: - """Kotlin parses `HH:MM`, not an ISO time.""" - assert policy_to_dict(_policy())["curfew"] == {"start": "23:00", "end": "05:00"} - - -def test_absent_curfew_renders_as_null() -> None: - """A policy with no curfew must not fabricate one.""" - assert policy_to_dict(_policy(curfew=None))["curfew"] is None - - -def test_redact_home_blanks_coordinates_but_keeps_geometry() -> None: - """A committed asset discloses the radius, never the location.""" - payload = policy_to_json(_policy(), redact_home=True) - - assert '"latitude": null' in payload - assert '"longitude": null' in payload - assert '"radius_m": 150.0' in payload - - -def test_coordinates_survive_when_not_redacted() -> None: - """The provisioning path still needs the real values.""" - assert '"latitude": 52.2297' in policy_to_json(_policy()) diff --git a/python_pkg/focus_policy/tests/test_export_invariants.py b/python_pkg/focus_policy/tests/test_export_invariants.py new file mode 100644 index 00000000..8043fc71 --- /dev/null +++ b/python_pkg/focus_policy/tests/test_export_invariants.py @@ -0,0 +1,135 @@ +"""Cross-checks between the always-blocked set and the allowlist.""" + +from __future__ import annotations + +import datetime as dt + +from python_pkg.focus_policy.export import ( + policy_to_dict, + policy_to_json, +) +from python_pkg.focus_policy.model import CurfewWindow, FocusPolicy, HomeLocation + +_DEFAULT_CURFEW = CurfewWindow(start=dt.time(23, 0), end=dt.time(5, 0)) + + +def _policy( + *, + # The launcher must be allowed or the model rejects the policy outright. + allowed_packages: frozenset[str] = frozenset({"pl.mbank", "com.launcher"}), + night_allowed_packages: frozenset[str] = frozenset({"pl.mbank", "com.launcher"}), + curfew: CurfewWindow | None = _DEFAULT_CURFEW, + never_disable_prefixes: tuple[str, ...] = ("com.android.",), + allowed_prefixes: tuple[str, ...] = (), +) -> FocusPolicy: + """Build a policy with the enforcer deliberately absent from both lists.""" + return FocusPolicy( + home=HomeLocation( + latitude=52.2297, + longitude=21.0122, + radius_m=150.0, + hysteresis_m=30.0, + ), + curfew=curfew, + launcher_package="com.launcher", + allowed_packages=allowed_packages, + allowed_prefixes=allowed_prefixes, + night_allowed_packages=night_allowed_packages, + never_disable_prefixes=never_disable_prefixes, + workout_unblock_domains=frozenset({"youtube.com"}), + browser_packages=frozenset({"org.mozilla.firefox"}), + ) + + +def test_always_blocked_is_a_subset_of_blockable() -> None: + """An always-blocked system app the sweep cannot see would silently no-op. + + The runner filters FLAG_SYSTEM packages out of ``installedPackages`` + unless they are opted in, so the decision layer would never receive them. + """ + payload = policy_to_dict(_policy()) + + assert set(payload["always_blocked_packages"]) <= set( + payload["blockable_system_packages"], + ) + + +def test_always_blocked_never_contradicts_the_allowlist() -> None: + """Allowing a package wins over always-blocking it. + + The asset must not state both at once; the subtraction keeps the two + fields consistent no matter what config.sh says. + """ + payload = policy_to_dict( + _policy( + allowed_packages=frozenset( + {"pl.mbank", "com.launcher", "com.android.chrome"}, + ), + ), + ) + + assert "com.android.chrome" not in payload["always_blocked_packages"] + assert "com.android.chrome" in payload["allowed_packages"] + + +def test_a_prefix_allowance_also_wins_over_always_blocking() -> None: + """Allowance is no longer exact-only, so the check must not be either. + + Subtracting just ``allowed_packages`` left a package allowed by prefix + still emitted as always-blocked, so the asset stated both at once and the + runner's behaviour depended on which check it applied first. + """ + payload = policy_to_dict( + _policy( + allowed_packages=frozenset({"pl.mbank", "com.launcher"}), + allowed_prefixes=("com.android.chrome",), + ), + ) + + assert "com.android.chrome" not in payload["always_blocked_packages"] + + +def test_the_real_distraction_apps_stay_always_blocked() -> None: + """The filter must not quietly un-block what the app exists to block.""" + payload = policy_to_dict(_policy()) + + assert "com.google.android.youtube" in payload["always_blocked_packages"] + assert "com.android.chrome" in payload["always_blocked_packages"] + + +def test_lists_are_sorted_for_stable_diffs() -> None: + """Sorted output keeps a committed asset's diff meaningful.""" + payload = policy_to_dict(_policy()) + + for key in ( + "allowed_packages", + "night_allowed_packages", + "never_disable_prefixes", + "workout_unblock_domains", + "browser_packages", + ): + assert payload[key] == sorted(payload[key]) + + +def test_curfew_is_rendered_as_wall_clock_strings() -> None: + """Kotlin parses `HH:MM`, not an ISO time.""" + assert policy_to_dict(_policy())["curfew"] == {"start": "23:00", "end": "05:00"} + + +def test_absent_curfew_renders_as_null() -> None: + """A policy with no curfew must not fabricate one.""" + assert policy_to_dict(_policy(curfew=None))["curfew"] is None + + +def test_redact_home_blanks_coordinates_but_keeps_geometry() -> None: + """A committed asset discloses the radius, never the location.""" + payload = policy_to_json(_policy(), redact_home=True) + + assert '"latitude": null' in payload + assert '"longitude": null' in payload + assert '"radius_m": 150.0' in payload + + +def test_coordinates_survive_when_not_redacted() -> None: + """The provisioning path still needs the real values.""" + assert '"latitude": 52.2297' in policy_to_json(_policy()) diff --git a/python_pkg/focus_policy/tests/test_model.py b/python_pkg/focus_policy/tests/test_model.py index 859ea677..59b456ab 100644 --- a/python_pkg/focus_policy/tests/test_model.py +++ b/python_pkg/focus_policy/tests/test_model.py @@ -146,124 +146,3 @@ class TestCurfewWindow: """A non-wrapping window is a plain half-open interval.""" window = CurfewWindow(start=time(9, 0), end=time(17, 0)) assert window.contains(moment) is expected - - -class TestFocusPolicy: - """Allow/deny decisions and the invariants that keep the phone usable.""" - - def test_rejects_launcher_outside_allowlist(self) -> None: - """A policy that blocks its own launcher would leave no home screen.""" - with pytest.raises(PolicyError, match="no home screen"): - _policy(launcher_package="com.missing") - - def test_accepts_launcher_inside_allowlist(self) -> None: - """The guard permits a launcher that is actually allowed.""" - assert _policy(launcher_package="com.launcher").launcher_package - - def test_rejects_night_packages_absent_from_day_list(self) -> None: - """A night allowance for a day-blocked package is contradictory.""" - with pytest.raises(PolicyError, match="subset"): - _policy(night_allowed_packages=frozenset({"com.unknown"})) - - @pytest.mark.parametrize( - ("package", "expected"), - [ - ("com.android.settings", True), - ("com.android.providers", True), - ("com.android.providers.telephony", True), - ("com.android.providersomething", False), - ("com.good", False), - ], - ) - def test_protection_matches_whole_labels_only( - self, - package: str, - *, - expected: bool, - ) -> None: - """Prefix protection covers subpackages but not lookalike names. - - ``com.android.providers.telephony`` must be protected; the unrelated - ``com.android.providersomething`` must not be swept in by a bare - string prefix test. - """ - assert _policy().is_protected(package) is expected - - def test_protected_packages_are_allowed_even_during_curfew(self) -> None: - """Core system packages survive the strictest state.""" - policy = _policy() - assert policy.is_allowed("com.android.settings", during_curfew=True) - - def test_curfew_narrows_the_allowlist(self) -> None: - """Day-allowed apps can still be blocked at night.""" - policy = _policy() - assert policy.is_allowed("com.good") - assert not policy.is_allowed("com.good", during_curfew=True) - assert policy.is_allowed("pl.mbank", during_curfew=True) - - def test_unknown_package_is_blocked(self) -> None: - """Anything not explicitly allowed is denied.""" - assert not _policy().is_allowed("com.evil") - - def test_allowed_prefix_covers_a_family_of_packages(self) -> None: - """Tachiyomi ships every source as its own apk. - - An exact list goes stale the moment a new extension is installed, which - looks exactly like a bug from the phone. - """ - policy = _policy(allowed_prefixes=("eu.kanade.tachiyomi",)) - assert policy.is_allowed("eu.kanade.tachiyomi") - assert policy.is_allowed("eu.kanade.tachiyomi.sy") - assert policy.is_allowed("eu.kanade.tachiyomi.extension.all.mangadex") - - def test_allowed_prefix_matches_whole_labels_only(self) -> None: - """A bare string prefix would allow an unrelated package.""" - policy = _policy(allowed_prefixes=("eu.kanade.tachiyomi",)) - assert not policy.is_allowed("eu.kanade.tachiyomisomething") - - def test_a_day_only_prefix_is_blocked_during_curfew(self) -> None: - """The night list narrows the prefixes too, not just the packages.""" - policy = _policy(allowed_prefixes=("eu.kanade.tachiyomi",)) - assert policy.is_allowed("eu.kanade.tachiyomi.sy") - assert not policy.is_allowed("eu.kanade.tachiyomi.sy", during_curfew=True) - - def test_a_night_prefix_survives_the_curfew(self) -> None: - """Manga is deliberately available at night (chosen 2026-08-14).""" - policy = _policy( - allowed_prefixes=("eu.kanade.tachiyomi",), - night_allowed_prefixes=("eu.kanade.tachiyomi",), - ) - assert policy.is_allowed("eu.kanade.tachiyomi.sy", during_curfew=True) - assert policy.is_allowed( - "eu.kanade.tachiyomi.extension.all.mangadex", - during_curfew=True, - ) - - def test_night_prefixes_must_be_a_subset_of_day_prefixes(self) -> None: - """The curfew tightens the day policy; it must never widen it.""" - with pytest.raises(PolicyError, match="night_allowed_prefixes"): - _policy(night_allowed_prefixes=("eu.kanade.tachiyomi",)) - - def test_is_curfew_active_without_window_is_false(self) -> None: - """A policy with no curfew is never in curfew.""" - assert not _policy().is_curfew_active(time(23, 30)) - - def test_is_curfew_active_with_window(self) -> None: - """A configured window drives the curfew state.""" - policy = _policy(curfew=CurfewWindow(start=time(23, 0), end=time(5, 0))) - assert policy.is_curfew_active(time(23, 30)) - assert not policy.is_curfew_active(time(12, 0)) - - def test_packages_to_block_ignores_uninstalled(self) -> None: - """Only packages present on the device can be blocked.""" - policy = _policy() - installed = frozenset({"com.good", "com.evil", "com.android.settings"}) - assert policy.packages_to_block(installed) == frozenset({"com.evil"}) - - def test_packages_to_block_during_curfew(self) -> None: - """The curfew list is applied when the curfew flag is set.""" - policy = _policy() - installed = frozenset({"com.good", "pl.mbank"}) - assert policy.packages_to_block(installed, during_curfew=True) == frozenset( - {"com.good"}, - ) diff --git a/python_pkg/focus_policy/tests/test_model_policy.py b/python_pkg/focus_policy/tests/test_model_policy.py new file mode 100644 index 00000000..bf83bfa1 --- /dev/null +++ b/python_pkg/focus_policy/tests/test_model_policy.py @@ -0,0 +1,168 @@ +"""Tests for the FocusPolicy aggregate.""" + +from __future__ import annotations + +from datetime import time + +import pytest + +from python_pkg.focus_policy.model import ( + CurfewWindow, + FocusPolicy, + HomeLocation, + PolicyError, +) + +HOME = HomeLocation(latitude=52.2297, longitude=21.0122) +METRES_PER_DEGREE_LAT = 111_320.0 + + +def _policy( + *, + allowed_packages: frozenset[str] | None = None, + night_allowed_packages: frozenset[str] | None = None, + curfew: CurfewWindow | None = None, + launcher_package: str | None = None, + allowed_prefixes: tuple[str, ...] = (), + night_allowed_prefixes: tuple[str, ...] = (), +) -> FocusPolicy: + """Build a small policy, overriding individual fields per test.""" + return FocusPolicy( + home=HOME, + allowed_packages=( + frozenset({"com.good", "pl.mbank", "com.launcher"}) + if allowed_packages is None + else allowed_packages + ), + night_allowed_packages=( + frozenset({"pl.mbank"}) + if night_allowed_packages is None + else night_allowed_packages + ), + never_disable_prefixes=("com.android.providers", "com.android.settings"), + curfew=curfew, + launcher_package=launcher_package, + allowed_prefixes=allowed_prefixes, + night_allowed_prefixes=night_allowed_prefixes, + ) + + +class TestFocusPolicy: + """Allow/deny decisions and the invariants that keep the phone usable.""" + + def test_rejects_launcher_outside_allowlist(self) -> None: + """A policy that blocks its own launcher would leave no home screen.""" + with pytest.raises(PolicyError, match="no home screen"): + _policy(launcher_package="com.missing") + + def test_accepts_launcher_inside_allowlist(self) -> None: + """The guard permits a launcher that is actually allowed.""" + assert _policy(launcher_package="com.launcher").launcher_package + + def test_rejects_night_packages_absent_from_day_list(self) -> None: + """A night allowance for a day-blocked package is contradictory.""" + with pytest.raises(PolicyError, match="subset"): + _policy(night_allowed_packages=frozenset({"com.unknown"})) + + @pytest.mark.parametrize( + ("package", "expected"), + [ + ("com.android.settings", True), + ("com.android.providers", True), + ("com.android.providers.telephony", True), + ("com.android.providersomething", False), + ("com.good", False), + ], + ) + def test_protection_matches_whole_labels_only( + self, + package: str, + *, + expected: bool, + ) -> None: + """Prefix protection covers subpackages but not lookalike names. + + ``com.android.providers.telephony`` must be protected; the unrelated + ``com.android.providersomething`` must not be swept in by a bare + string prefix test. + """ + assert _policy().is_protected(package) is expected + + def test_protected_packages_are_allowed_even_during_curfew(self) -> None: + """Core system packages survive the strictest state.""" + policy = _policy() + assert policy.is_allowed("com.android.settings", during_curfew=True) + + def test_curfew_narrows_the_allowlist(self) -> None: + """Day-allowed apps can still be blocked at night.""" + policy = _policy() + assert policy.is_allowed("com.good") + assert not policy.is_allowed("com.good", during_curfew=True) + assert policy.is_allowed("pl.mbank", during_curfew=True) + + def test_unknown_package_is_blocked(self) -> None: + """Anything not explicitly allowed is denied.""" + assert not _policy().is_allowed("com.evil") + + def test_allowed_prefix_covers_a_family_of_packages(self) -> None: + """Tachiyomi ships every source as its own apk. + + An exact list goes stale the moment a new extension is installed, which + looks exactly like a bug from the phone. + """ + policy = _policy(allowed_prefixes=("eu.kanade.tachiyomi",)) + assert policy.is_allowed("eu.kanade.tachiyomi") + assert policy.is_allowed("eu.kanade.tachiyomi.sy") + assert policy.is_allowed("eu.kanade.tachiyomi.extension.all.mangadex") + + def test_allowed_prefix_matches_whole_labels_only(self) -> None: + """A bare string prefix would allow an unrelated package.""" + policy = _policy(allowed_prefixes=("eu.kanade.tachiyomi",)) + assert not policy.is_allowed("eu.kanade.tachiyomisomething") + + def test_a_day_only_prefix_is_blocked_during_curfew(self) -> None: + """The night list narrows the prefixes too, not just the packages.""" + policy = _policy(allowed_prefixes=("eu.kanade.tachiyomi",)) + assert policy.is_allowed("eu.kanade.tachiyomi.sy") + assert not policy.is_allowed("eu.kanade.tachiyomi.sy", during_curfew=True) + + def test_a_night_prefix_survives_the_curfew(self) -> None: + """Manga is deliberately available at night (chosen 2026-08-14).""" + policy = _policy( + allowed_prefixes=("eu.kanade.tachiyomi",), + night_allowed_prefixes=("eu.kanade.tachiyomi",), + ) + assert policy.is_allowed("eu.kanade.tachiyomi.sy", during_curfew=True) + assert policy.is_allowed( + "eu.kanade.tachiyomi.extension.all.mangadex", + during_curfew=True, + ) + + def test_night_prefixes_must_be_a_subset_of_day_prefixes(self) -> None: + """The curfew tightens the day policy; it must never widen it.""" + with pytest.raises(PolicyError, match="night_allowed_prefixes"): + _policy(night_allowed_prefixes=("eu.kanade.tachiyomi",)) + + def test_is_curfew_active_without_window_is_false(self) -> None: + """A policy with no curfew is never in curfew.""" + assert not _policy().is_curfew_active(time(23, 30)) + + def test_is_curfew_active_with_window(self) -> None: + """A configured window drives the curfew state.""" + policy = _policy(curfew=CurfewWindow(start=time(23, 0), end=time(5, 0))) + assert policy.is_curfew_active(time(23, 30)) + assert not policy.is_curfew_active(time(12, 0)) + + def test_packages_to_block_ignores_uninstalled(self) -> None: + """Only packages present on the device can be blocked.""" + policy = _policy() + installed = frozenset({"com.good", "com.evil", "com.android.settings"}) + assert policy.packages_to_block(installed) == frozenset({"com.evil"}) + + def test_packages_to_block_during_curfew(self) -> None: + """The curfew list is applied when the curfew flag is set.""" + policy = _policy() + installed = frozenset({"com.good", "pl.mbank"}) + assert policy.packages_to_block(installed, during_curfew=True) == frozenset( + {"com.good"}, + ) diff --git a/python_pkg/random_jpg/tests/test_generate_jpeg.py b/python_pkg/random_jpg/tests/test_generate_jpeg.py index 9d7226f9..b988e125 100644 --- a/python_pkg/random_jpg/tests/test_generate_jpeg.py +++ b/python_pkg/random_jpg/tests/test_generate_jpeg.py @@ -2,7 +2,6 @@ from pathlib import Path import tempfile -from unittest.mock import patch from PIL import Image import pytest @@ -11,9 +10,7 @@ from python_pkg.random_jpg.generate_jpeg import ( MAX_IMAGE_SIZE, ImageConfig, _create_random_image, - _save_image, generate_bloated_jpeg, - main, ) @@ -136,116 +133,3 @@ class TestCreateRandomImage: for x in range(20): for y in range(20): assert pixels[x, y] == (255, 0, 0) - - -class TestSaveImage: - """Tests for _save_image function.""" - - def test_creates_output_folder(self) -> None: - """Test that output folder is created if it doesn't exist.""" - with tempfile.TemporaryDirectory() as tmp_dir: - new_folder = Path(tmp_dir) / "new_subfolder" - image = Image.new("RGB", (10, 10)) - config = ImageConfig( - size=10, - color_list=["#FF0000"], - block_size=10, - output_path="image.jpeg", - quality=90, - ) - - _save_image(image, config, 1, str(new_folder)) - - assert new_folder.exists() - - def test_saves_with_correct_quality(self) -> None: - """Test image is saved with specified quality.""" - with tempfile.TemporaryDirectory() as tmp_dir: - image = Image.new("RGB", (10, 10), color=(255, 0, 0)) - config = ImageConfig( - size=10, - color_list=["#FF0000"], - block_size=10, - output_path="image.jpeg", - quality=50, - ) - - result_path = _save_image(image, config, 1, tmp_dir) - - assert Path(result_path).exists() - - -class TestMain: - """Tests for main CLI function.""" - - def test_main_generates_image_with_defaults(self) -> None: - """Test main generates image with default arguments.""" - with ( - tempfile.TemporaryDirectory() as tmp_dir, - patch("sys.argv", ["generate_jpeg.py"]), - patch( - "python_pkg.random_jpg.generate_jpeg.generate_bloated_jpeg" - ) as mock_gen, - ): - mock_gen.return_value = f"{tmp_dir}/test.jpeg" - main() - - mock_gen.assert_called_once() - call_args = mock_gen.call_args - config = call_args[0][0] - assert config.size == 1000 - assert config.block_size == 4 - assert config.quality == 100 - - def test_main_respects_num_images_argument(self) -> None: - """Test main generates multiple images when specified.""" - with ( - tempfile.TemporaryDirectory() as tmp_dir, - patch("sys.argv", ["generate_jpeg.py", "-n", "3"]), - patch( - "python_pkg.random_jpg.generate_jpeg.generate_bloated_jpeg" - ) as mock_gen, - ): - mock_gen.return_value = f"{tmp_dir}/test.jpeg" - main() - - assert mock_gen.call_count == 3 - - def test_main_uses_custom_size(self) -> None: - """Test main respects custom size argument.""" - with ( - tempfile.TemporaryDirectory() as tmp_dir, - patch("sys.argv", ["generate_jpeg.py", "-s", "500", "-b", "5"]), - patch( - "python_pkg.random_jpg.generate_jpeg.generate_bloated_jpeg" - ) as mock_gen, - ): - mock_gen.return_value = f"{tmp_dir}/test.jpeg" - main() - - config = mock_gen.call_args[0][0] - assert config.size == 500 - assert config.block_size == 5 - - def test_main_uses_custom_colors(self) -> None: - """Test main respects custom color list.""" - with ( - tempfile.TemporaryDirectory() as tmp_dir, - patch("sys.argv", ["generate_jpeg.py", "-c", "#AABBCC", "#112233"]), - patch( - "python_pkg.random_jpg.generate_jpeg.generate_bloated_jpeg" - ) as mock_gen, - ): - mock_gen.return_value = f"{tmp_dir}/test.jpeg" - main() - - config = mock_gen.call_args[0][0] - assert config.color_list == ["#AABBCC", "#112233"] - - -class TestConstants: - """Tests for module constants.""" - - def test_max_image_size(self) -> None: - """Test MAX_IMAGE_SIZE constant value.""" - assert MAX_IMAGE_SIZE == 1000 diff --git a/python_pkg/random_jpg/tests/test_generate_jpeg_io.py b/python_pkg/random_jpg/tests/test_generate_jpeg_io.py new file mode 100644 index 00000000..57f646ca --- /dev/null +++ b/python_pkg/random_jpg/tests/test_generate_jpeg_io.py @@ -0,0 +1,127 @@ +"""Tests for saving generated images and the CLI entry point.""" + +from pathlib import Path +import tempfile +from unittest.mock import patch + +from PIL import Image + +from python_pkg.random_jpg.generate_jpeg import ( + MAX_IMAGE_SIZE, + ImageConfig, + _save_image, + main, +) + + +class TestSaveImage: + """Tests for _save_image function.""" + + def test_creates_output_folder(self) -> None: + """Test that output folder is created if it doesn't exist.""" + with tempfile.TemporaryDirectory() as tmp_dir: + new_folder = Path(tmp_dir) / "new_subfolder" + image = Image.new("RGB", (10, 10)) + config = ImageConfig( + size=10, + color_list=["#FF0000"], + block_size=10, + output_path="image.jpeg", + quality=90, + ) + + _save_image(image, config, 1, str(new_folder)) + + assert new_folder.exists() + + def test_saves_with_correct_quality(self) -> None: + """Test image is saved with specified quality.""" + with tempfile.TemporaryDirectory() as tmp_dir: + image = Image.new("RGB", (10, 10), color=(255, 0, 0)) + config = ImageConfig( + size=10, + color_list=["#FF0000"], + block_size=10, + output_path="image.jpeg", + quality=50, + ) + + result_path = _save_image(image, config, 1, tmp_dir) + + assert Path(result_path).exists() + + +class TestMain: + """Tests for main CLI function.""" + + def test_main_generates_image_with_defaults(self) -> None: + """Test main generates image with default arguments.""" + with ( + tempfile.TemporaryDirectory() as tmp_dir, + patch("sys.argv", ["generate_jpeg.py"]), + patch( + "python_pkg.random_jpg.generate_jpeg.generate_bloated_jpeg" + ) as mock_gen, + ): + mock_gen.return_value = f"{tmp_dir}/test.jpeg" + main() + + mock_gen.assert_called_once() + call_args = mock_gen.call_args + config = call_args[0][0] + assert config.size == 1000 + assert config.block_size == 4 + assert config.quality == 100 + + def test_main_respects_num_images_argument(self) -> None: + """Test main generates multiple images when specified.""" + with ( + tempfile.TemporaryDirectory() as tmp_dir, + patch("sys.argv", ["generate_jpeg.py", "-n", "3"]), + patch( + "python_pkg.random_jpg.generate_jpeg.generate_bloated_jpeg" + ) as mock_gen, + ): + mock_gen.return_value = f"{tmp_dir}/test.jpeg" + main() + + assert mock_gen.call_count == 3 + + def test_main_uses_custom_size(self) -> None: + """Test main respects custom size argument.""" + with ( + tempfile.TemporaryDirectory() as tmp_dir, + patch("sys.argv", ["generate_jpeg.py", "-s", "500", "-b", "5"]), + patch( + "python_pkg.random_jpg.generate_jpeg.generate_bloated_jpeg" + ) as mock_gen, + ): + mock_gen.return_value = f"{tmp_dir}/test.jpeg" + main() + + config = mock_gen.call_args[0][0] + assert config.size == 500 + assert config.block_size == 5 + + def test_main_uses_custom_colors(self) -> None: + """Test main respects custom color list.""" + with ( + tempfile.TemporaryDirectory() as tmp_dir, + patch("sys.argv", ["generate_jpeg.py", "-c", "#AABBCC", "#112233"]), + patch( + "python_pkg.random_jpg.generate_jpeg.generate_bloated_jpeg" + ) as mock_gen, + ): + mock_gen.return_value = f"{tmp_dir}/test.jpeg" + main() + + config = mock_gen.call_args[0][0] + assert config.color_list == ["#AABBCC", "#112233"] + + +class TestConstants: + """Tests for module constants.""" + + def test_max_image_size(self) -> None: + """Test MAX_IMAGE_SIZE constant value.""" + assert MAX_IMAGE_SIZE == 1000 diff --git a/python_pkg/token_audit/tests/test_parse.py b/python_pkg/token_audit/tests/test_parse.py index f428f294..8ddabf20 100644 --- a/python_pkg/token_audit/tests/test_parse.py +++ b/python_pkg/token_audit/tests/test_parse.py @@ -4,14 +4,13 @@ import json from pathlib import Path from typing import TYPE_CHECKING -import pytest - from python_pkg.token_audit import parse -from python_pkg.token_audit.model import ToolCall, Turn if TYPE_CHECKING: from pathlib import Path + import pytest + def _assistant( usage: dict[str, int] | None = None, @@ -207,65 +206,3 @@ def test_usage_keeps_only_integer_fields(tmp_path: Path) -> None: def test_sidechain_flag_is_preserved(tmp_path: Path) -> None: path = _write(tmp_path, [_assistant(usage={"output_tokens": 1}, sidechain=True)]) assert parse.load_session(path).turns[0].is_sidechain - - -def test_iter_events_preserves_order(tmp_path: Path) -> None: - records = [ - _assistant( - content=[ - { - "type": "tool_use", - "id": "a", - "name": "Read", - "input": {"file_path": "/i.png"}, - }, - ], - ), - { - "message": { - "content": [ - {"type": "tool_result", "tool_use_id": "a", "content": "xxxx"} - ] - } - }, - _assistant(usage={"output_tokens": 1}), - ] - kinds = [k for k, _ in parse.iter_events(_write(tmp_path, records))] - assert kinds == ["tool", "turn"] - - -def test_events_carry_expected_types(tmp_path: Path) -> None: - records = [ - _assistant( - content=[ - {"type": "tool_use", "id": "a", "name": "Read", "input": {}}, - ], - ), - {"message": {"content": [{"type": "tool_result", "tool_use_id": "a"}]}}, - _assistant(usage={"output_tokens": 1}), - ] - events = dict(parse.iter_events(_write(tmp_path, records))) - assert isinstance(events["tool"], ToolCall) - assert isinstance(events["turn"], Turn) - - -def test_find_transcripts_filters_by_window(tmp_path: Path) -> None: - project = tmp_path / "proj" - project.mkdir() - old = project / "old.jsonl" - new = project / "new.jsonl" - old.write_text("{}", encoding="utf-8") - new.write_text("{}", encoding="utf-8") - import os - - os.utime(old, (1000, 1000)) - os.utime(new, (50_000, 50_000)) - - assert parse.find_transcripts(tmp_path, since=10_000) == [new] - assert parse.find_transcripts(tmp_path, since=0, until=2000) == [old] - assert len(parse.find_transcripts(tmp_path, since=0)) == 2 - - -@pytest.mark.parametrize("payload", [None, "abcdefgh"]) -def test_result_tokens_handles_missing_content(payload: str | None) -> None: - assert parse._result_tokens({"content": payload}) >= 0 diff --git a/python_pkg/token_audit/tests/test_parse_events.py b/python_pkg/token_audit/tests/test_parse_events.py new file mode 100644 index 00000000..7d8205a2 --- /dev/null +++ b/python_pkg/token_audit/tests/test_parse_events.py @@ -0,0 +1,106 @@ +"""Tests for token_audit event iteration and transcript discovery.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest + +from python_pkg.token_audit import parse +from python_pkg.token_audit.model import ToolCall, Turn + +if TYPE_CHECKING: + from pathlib import Path + + +def _assistant( + usage: dict[str, int] | None = None, + content: list[dict[str, object]] | None = None, + cwd: str = "/home/kuhy/demo", + *, + sidechain: bool = False, +) -> dict[str, object]: + message: dict[str, object] = {"model": "claude-opus-5"} + if usage is not None: + message["usage"] = usage + if content is not None: + message["content"] = content + return { + "type": "assistant", + "cwd": cwd, + "isSidechain": sidechain, + "message": message, + } + + +def _write(tmp_path: Path, records: list[object], name: str = "sess.jsonl") -> Path: + path = tmp_path / name + path.write_text( + "\n".join(json.dumps(r) if isinstance(r, dict) else r for r in records), + encoding="utf-8", + ) + return path + + +def test_iter_events_preserves_order(tmp_path: Path) -> None: + records = [ + _assistant( + content=[ + { + "type": "tool_use", + "id": "a", + "name": "Read", + "input": {"file_path": "/i.png"}, + }, + ], + ), + { + "message": { + "content": [ + {"type": "tool_result", "tool_use_id": "a", "content": "xxxx"} + ] + } + }, + _assistant(usage={"output_tokens": 1}), + ] + kinds = [k for k, _ in parse.iter_events(_write(tmp_path, records))] + assert kinds == ["tool", "turn"] + + +def test_events_carry_expected_types(tmp_path: Path) -> None: + records = [ + _assistant( + content=[ + {"type": "tool_use", "id": "a", "name": "Read", "input": {}}, + ], + ), + {"message": {"content": [{"type": "tool_result", "tool_use_id": "a"}]}}, + _assistant(usage={"output_tokens": 1}), + ] + events = dict(parse.iter_events(_write(tmp_path, records))) + assert isinstance(events["tool"], ToolCall) + assert isinstance(events["turn"], Turn) + + +def test_find_transcripts_filters_by_window(tmp_path: Path) -> None: + project = tmp_path / "proj" + project.mkdir() + old = project / "old.jsonl" + new = project / "new.jsonl" + old.write_text("{}", encoding="utf-8") + new.write_text("{}", encoding="utf-8") + import os + + os.utime(old, (1000, 1000)) + os.utime(new, (50_000, 50_000)) + + assert parse.find_transcripts(tmp_path, since=10_000) == [new] + assert parse.find_transcripts(tmp_path, since=0, until=2000) == [old] + assert len(parse.find_transcripts(tmp_path, since=0)) == 2 + + +@pytest.mark.parametrize("payload", [None, "abcdefgh"]) +def test_result_tokens_handles_missing_content(payload: str | None) -> None: + assert parse._result_tokens({"content": payload}) >= 0