Clear five python_pkg packages under the cap

android_ui, app_icons, token_audit, focus_policy and random_jpg are now
entirely under 250 lines. Only wsg_grabber's six test modules remain in
python_pkg.

android_ui/driver.py 411 -> 205 took two cuts and a class split: the
element and error types plus the XML parser to _elements.py, and the
text-entry methods to a TextEntryMixin, using the ABC-with-abstract-
methods shape because the tests call them as bound attributes.
app_icons/glyphs.py 291 -> 93 by moving the twelve path fragments out.

Three intermediate failures, all module-level globals that travelled
with their code (_logger, re, _SAME_FIELD_X_TOLERANCE_PX) - the same
class of miss as _make_verifier, which ruff cannot flag.

Counts unchanged: 81, 137, 14, 44, 57.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
This commit is contained in:
Krzysztof Rudnicki 2026-08-16 18:14:36 +02:00
parent e3214c813c
commit b60cb4972a
18 changed files with 1465 additions and 994 deletions

View File

@ -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/<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."
}

View File

@ -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/<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 <the touched files and artifacts>",
"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."
]
}

View File

@ -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

View File

@ -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 ""

View File

@ -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

View File

@ -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()

View File

@ -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 ``<node>`` element for a fake dump.
``attrs`` accepts ``desc``, ``res``, ``cls``, ``bounds``, ``enabled`` and
``focused``; each falls back to a representative default.
"""
return (
f'<node text="{text}" content-desc="{attrs.get("desc", "")}" '
f'resource-id="{attrs.get("res", "")}" '
f'class="{attrs.get("cls", "android.widget.TextView")}" '
f'bounds="{attrs.get("bounds", "[0,0][100,50]")}" '
f'enabled="{attrs.get("enabled", "true")}" '
f'focused="{attrs.get("focused", "false")}"/>'
)
def _tree(*nodes: str) -> str:
"""Wrap ``nodes`` in a hierarchy document."""
return f"<?xml version='1.0'?><hierarchy rotation='0'>{''.join(nodes)}</hierarchy>"
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()

View File

@ -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 ``<node>`` element for a fake dump.
``attrs`` accepts ``desc``, ``res``, ``cls``, ``bounds``, ``enabled`` and
``focused``; each falls back to a representative default.
"""
return (
f'<node text="{text}" content-desc="{attrs.get("desc", "")}" '
f'resource-id="{attrs.get("res", "")}" '
f'class="{attrs.get("cls", "android.widget.TextView")}" '
f'bounds="{attrs.get("bounds", "[0,0][100,50]")}" '
f'enabled="{attrs.get("enabled", "true")}" '
f'focused="{attrs.get("focused", "false")}"/>'
)
def _tree(*nodes: str) -> str:
"""Wrap ``nodes`` in a hierarchy document."""
return f"<?xml version='1.0'?><hierarchy rotation='0'>{''.join(nodes)}</hierarchy>"
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")

View File

@ -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 = """\
<g transform="translate(296,196) scale(18)" stroke-width="4">
<path d="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 \
2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 \
0-2.64-2.05-4.78-4.65-4.96z"/>
</g>
<path d="M 512 628 L 512 744"/>
<path d="M 444 676 L 512 744 L 580 676"/>"""
# 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 = """\
<path d="M 268 512 L 756 512"/>
<path d="M 364 356 L 364 668"/>
<path d="M 660 356 L 660 668"/>
<path d="M 268 432 L 268 592"/>
<path d="M 756 432 L 756 592"/>"""
# Plain clock face. Deliberately no bell "ears": at launcher size they read as
# noise rather than as an alarm clock.
_CLOCK = """\
<circle cx="512" cy="512" r="244"/>
<path d="M 512 512 L 512 350"/>
<path d="M 512 512 L 636 512"/>"""
# 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 = """\
<path d="M 512 275 L 752 366 L 752 544 C 752 655 646 723 512 753 \
C 378 723 272 655 272 544 L 272 366 Z"/>
<g transform="translate(356,362) scale(13)" fill="{{ACCENT}}" stroke="none">
<path d="M11 9H9V2H7v7H5V2H3v7c0 2.12 1.66 3.84 3.75 3.97V22h2.5v-9.03\
C11.34 12.84 13 11.12 13 9V2h-2v7z"/>
<path d="M16 12h2.5v10H21V2c-2.76 0-5 2.24-5 5v5z"/>
</g>"""
# Two ticked-off list rows. More specific to a notes/todo app than a lone
# checkmark, and still legible at 48dp.
_CHECKLIST = """\
<path d="M 272 372 L 346 446 L 478 314"/>
<path d="M 594 400 L 752 400"/>
<path d="M 272 620 L 346 694 L 478 562"/>
<path d="M 594 648 L 752 648"/>"""
# 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 = """\
<circle cx="512" cy="300" r="78"/>
<path d="M 512 378 L 512 452"/>
<path d="M 346 452 L 678 452"/>
<path d="M 346 452 L 346 528"/>
<path d="M 678 452 L 678 528"/>
<circle cx="346" cy="606" r="78"/>
<circle cx="678" cy="606" r="78"/>"""
# 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 = """\
<g transform="translate(467,557) rotate(-45) translate(-150,-110)">
<rect x="0" y="0" width="300" height="220" rx="110" ry="110"/>
</g>
<g transform="translate(557,467) rotate(-45) translate(-150,-110)">
<rect x="0" y="0" width="300" height="220" rx="110" ry="110"/>
</g>"""
# 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 = """\
<path d="M 512 272 L 720 392 L 720 632 L 512 752 L 304 632 L 304 392 Z"/>
<path d="M 512 512 L 512 752"/>
<path d="M 512 512 L 304 392"/>
<path d="M 512 512 L 720 392"/>"""
# 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 = """\
<path fill="{{ACCENT}}" fill-rule="evenodd" stroke="none" d="\
M 312 282 L 712 282 L 712 706 L 678 752 L 645 706 L 612 752 L 578 706 \
L 545 752 L 512 706 L 478 752 L 445 706 L 412 752 L 378 706 L 345 752 \
L 312 706 Z \
M 482 350 A 30 30 0 1 0 542 350 A 30 30 0 1 0 482 350 Z \
M 482 446 A 30 30 0 1 0 542 446 A 30 30 0 1 0 482 446 Z \
M 482 542 A 30 30 0 1 0 542 542 A 30 30 0 1 0 482 542 Z \
M 482 638 A 30 30 0 1 0 542 638 A 30 30 0 1 0 482 638 Z"/>"""
# 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 = """\
<path fill="{{ACCENT}}" fill-rule="evenodd" stroke="none" d="\
M 420 312 L 604 312 Q 626 470 596 570 L 512 812 L 428 570 \
Q 398 470 420 312 Z \
M 472 470 A 40 40 0 1 0 552 470 A 40 40 0 1 0 472 470 Z \
M 512 700 L 492 580 L 532 580 Z"/>"""
# 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 = """\
<path fill="{{ACCENT}}" stroke="none" d="\
M 768 316 L 768 420 L 596 420 \
Q 592 512 592 592 L 716 592 L 716 708 L 304 708 L 304 592 L 428 592 \
Q 428 512 424 420 L 336 420 \
L 256 392 Q 248 376 256 360 L 336 316 Z"/>"""
# 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 = """\
<path d="M 320 700 L 320 556"/>
<path d="M 488 700 L 488 460"/>
<path d="M 656 700 L 656 364"/>
<circle cx="656" cy="252" r="40" fill="none"/>"""

View File

@ -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 = """\
<g transform="translate(296,196) scale(18)" stroke-width="4">
<path d="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 \
2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 \
0-2.64-2.05-4.78-4.65-4.96z"/>
</g>
<path d="M 512 628 L 512 744"/>
<path d="M 444 676 L 512 744 L 580 676"/>"""
# 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 = """\
<path d="M 268 512 L 756 512"/>
<path d="M 364 356 L 364 668"/>
<path d="M 660 356 L 660 668"/>
<path d="M 268 432 L 268 592"/>
<path d="M 756 432 L 756 592"/>"""
# Plain clock face. Deliberately no bell "ears": at launcher size they read as
# noise rather than as an alarm clock.
_CLOCK = """\
<circle cx="512" cy="512" r="244"/>
<path d="M 512 512 L 512 350"/>
<path d="M 512 512 L 636 512"/>"""
# 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 = """\
<path d="M 512 275 L 752 366 L 752 544 C 752 655 646 723 512 753 \
C 378 723 272 655 272 544 L 272 366 Z"/>
<g transform="translate(356,362) scale(13)" fill="{{ACCENT}}" stroke="none">
<path d="M11 9H9V2H7v7H5V2H3v7c0 2.12 1.66 3.84 3.75 3.97V22h2.5v-9.03\
C11.34 12.84 13 11.12 13 9V2h-2v7z"/>
<path d="M16 12h2.5v10H21V2c-2.76 0-5 2.24-5 5v5z"/>
</g>"""
# Two ticked-off list rows. More specific to a notes/todo app than a lone
# checkmark, and still legible at 48dp.
_CHECKLIST = """\
<path d="M 272 372 L 346 446 L 478 314"/>
<path d="M 594 400 L 752 400"/>
<path d="M 272 620 L 346 694 L 478 562"/>
<path d="M 594 648 L 752 648"/>"""
# 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 = """\
<circle cx="512" cy="300" r="78"/>
<path d="M 512 378 L 512 452"/>
<path d="M 346 452 L 678 452"/>
<path d="M 346 452 L 346 528"/>
<path d="M 678 452 L 678 528"/>
<circle cx="346" cy="606" r="78"/>
<circle cx="678" cy="606" r="78"/>"""
# 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 = """\
<g transform="translate(467,557) rotate(-45) translate(-150,-110)">
<rect x="0" y="0" width="300" height="220" rx="110" ry="110"/>
</g>
<g transform="translate(557,467) rotate(-45) translate(-150,-110)">
<rect x="0" y="0" width="300" height="220" rx="110" ry="110"/>
</g>"""
# 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 = """\
<path d="M 512 272 L 720 392 L 720 632 L 512 752 L 304 632 L 304 392 Z"/>
<path d="M 512 512 L 512 752"/>
<path d="M 512 512 L 304 392"/>
<path d="M 512 512 L 720 392"/>"""
# 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 = """\
<path fill="{{ACCENT}}" fill-rule="evenodd" stroke="none" d="\
M 312 282 L 712 282 L 712 706 L 678 752 L 645 706 L 612 752 L 578 706 \
L 545 752 L 512 706 L 478 752 L 445 706 L 412 752 L 378 706 L 345 752 \
L 312 706 Z \
M 482 350 A 30 30 0 1 0 542 350 A 30 30 0 1 0 482 350 Z \
M 482 446 A 30 30 0 1 0 542 446 A 30 30 0 1 0 482 446 Z \
M 482 542 A 30 30 0 1 0 542 542 A 30 30 0 1 0 482 542 Z \
M 482 638 A 30 30 0 1 0 542 638 A 30 30 0 1 0 482 638 Z"/>"""
# 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 = """\
<path fill="{{ACCENT}}" fill-rule="evenodd" stroke="none" d="\
M 420 312 L 604 312 Q 626 470 596 570 L 512 812 L 428 570 \
Q 398 470 420 312 Z \
M 472 470 A 40 40 0 1 0 552 470 A 40 40 0 1 0 472 470 Z \
M 512 700 L 492 580 L 532 580 Z"/>"""
# 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 = """\
<path fill="{{ACCENT}}" stroke="none" d="\
M 768 316 L 768 420 L 596 420 \
Q 592 512 592 592 L 716 592 L 716 708 L 304 708 L 304 592 L 428 592 \
Q 428 512 424 420 L 336 420 \
L 256 392 Q 248 376 256 360 L 336 316 Z"/>"""
# 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 = """\
<path d="M 320 700 L 320 556"/>
<path d="M 488 700 L 488 460"/>
<path d="M 656 700 L 656 364"/>
<circle cx="656" cy="252" r="40" fill="none"/>"""
GLYPHS: Final[dict[str, Glyph]] = {
glyph.name: glyph

View File

@ -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())

View File

@ -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())

View File

@ -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"},
)

View File

@ -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"},
)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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