mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 18:47:04 +01:00
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:
parent
e3214c813c
commit
b60cb4972a
@ -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."
|
||||
}
|
||||
@ -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."
|
||||
]
|
||||
}
|
||||
128
python_pkg/android_ui/_elements.py
Normal file
128
python_pkg/android_ui/_elements.py
Normal 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
|
||||
145
python_pkg/android_ui/_text_entry.py
Normal file
145
python_pkg/android_ui/_text_entry.py
Normal 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 ""
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
158
python_pkg/android_ui/tests/test_shell_helpers.py
Normal file
158
python_pkg/android_ui/tests/test_shell_helpers.py
Normal 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()
|
||||
184
python_pkg/android_ui/tests/test_text_entry.py
Normal file
184
python_pkg/android_ui/tests/test_text_entry.py
Normal 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")
|
||||
220
python_pkg/app_icons/_glyph_art.py
Normal file
220
python_pkg/app_icons/_glyph_art.py
Normal 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"/>"""
|
||||
@ -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
|
||||
|
||||
@ -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())
|
||||
|
||||
135
python_pkg/focus_policy/tests/test_export_invariants.py
Normal file
135
python_pkg/focus_policy/tests/test_export_invariants.py
Normal 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())
|
||||
@ -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"},
|
||||
)
|
||||
|
||||
168
python_pkg/focus_policy/tests/test_model_policy.py
Normal file
168
python_pkg/focus_policy/tests/test_model_policy.py
Normal 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"},
|
||||
)
|
||||
@ -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
|
||||
|
||||
127
python_pkg/random_jpg/tests/test_generate_jpeg_io.py
Normal file
127
python_pkg/random_jpg/tests/test_generate_jpeg_io.py
Normal 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
|
||||
@ -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
|
||||
|
||||
106
python_pkg/token_audit/tests/test_parse_events.py
Normal file
106
python_pkg/token_audit/tests/test_parse_events.py
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user