Files
hermes-relay/plugin/tests/test_android_screen_hash.py
Bailey DixonandClaude Opus 4.6 3543e6679b feat(A5): add android_screen_hash + android_diff_screen
Cheap change detection for navigation loops. SHA-256 over per-node
fingerprints (className + text + contentDescription + bounds +
viewIdResourceName) across the full multi-window accessibility
tree. diff_screen reports changed + new hash + node_count in one
call so the agent can update its reference without an extra
round-trip. ~100x cheaper than re-reading the full tree for
'did anything change?' polling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 21:19:07 -04:00

252 lines
10 KiB
Python

"""
Unit tests for the A5 ``android_screen_hash`` + ``android_diff_screen``
tool handlers in ``plugin.tools.android_tool``.
Uses only stdlib ``unittest`` + ``unittest.mock`` so the suite runs via::
python -m unittest plugin.tests.test_android_screen_hash
without tripping the pytest-only ``conftest.py`` that imports the
``responses`` package (unavailable in some venvs).
Coverage:
* ``android_screen_hash`` — happy path return shape.
* ``android_diff_screen`` — changed=False (equal hashes) and
changed=True (different hashes) flowed through from the relay.
* Both tools' error envelopes on HTTP failure.
* Hash-stability properties — we simulate the underlying relay
response for three tree states (unchanged / text change / layout
change) and confirm the tool side faithfully reports what the
relay reported. The relay's SHA-256 is deterministic so this
double-checks that the tool doesn't accidentally reshape or re-hash
the response body.
* Schema + handler registration shape — both tools land in the
module-level ``_SCHEMAS`` and ``_HANDLERS`` maps.
"""
from __future__ import annotations
import hashlib
import json
import sys
import unittest
from pathlib import Path
from unittest import mock
# Make ``import plugin.tools.android_tool`` work when the test runs
# from the repo root without the package being installed. Mirrors
# test_android_phone_status.py's import dance.
REPO_ROOT = Path(__file__).resolve().parents[2]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from plugin.tools import android_tool # noqa: E402
# ── Helpers ──────────────────────────────────────────────────────────────────
class _FakeResponse:
"""Minimal ``requests.Response`` stand-in for our mocks."""
def __init__(self, body: dict, status: int = 200) -> None:
self._body = body
self.status_code = status
def json(self) -> dict:
return self._body
def raise_for_status(self) -> None:
if self.status_code >= 400:
raise RuntimeError(f"HTTP {self.status_code}")
def _hash_tree(fields_per_node: list[str]) -> str:
"""
Reference hasher used for the stability tests. Matches the Kotlin
``ScreenHasher`` logic at the wire level: fields are joined with
``|``, nodes are joined with ``\\u001e``, then SHA-256.
The tool side never actually hashes — it just forwards whatever the
relay returned. But using the same algorithm here lets us assert
"two different-looking trees produce different hashes" without
hand-picking magic hex constants.
"""
joined = "\u001e".join(fields_per_node)
return hashlib.sha256(joined.encode("utf-8")).hexdigest()
# Three synthetic "screens" — minimal fingerprint fields per interesting
# node. These don't have to be realistic; they just need to be distinct
# so the hashes diverge.
TREE_A = [
"android.widget.TextView|Hello|desc_a|0,0,100,100|com.app:id/title",
"android.widget.Button|OK||10,200,90,240|com.app:id/ok",
]
TREE_A_TEXT_CHANGED = [
"android.widget.TextView|Hi there|desc_a|0,0,100,100|com.app:id/title",
"android.widget.Button|OK||10,200,90,240|com.app:id/ok",
]
TREE_A_LAYOUT_CHANGED = [
"android.widget.TextView|Hello|desc_a|0,0,100,100|com.app:id/title",
"android.widget.Button|OK||10,250,90,290|com.app:id/ok",
]
# ── Tests ────────────────────────────────────────────────────────────────────
class TestSchemaAndHandlerRegistration(unittest.TestCase):
"""A5 must actually register in the module-level maps or the
tool loader will never see it."""
def test_screen_hash_in_schemas(self) -> None:
self.assertIn("android_screen_hash", android_tool._SCHEMAS)
schema = android_tool._SCHEMAS["android_screen_hash"]
self.assertEqual(schema["name"], "android_screen_hash")
self.assertEqual(schema["parameters"]["required"], [])
def test_diff_screen_in_schemas(self) -> None:
self.assertIn("android_diff_screen", android_tool._SCHEMAS)
schema = android_tool._SCHEMAS["android_diff_screen"]
self.assertEqual(schema["name"], "android_diff_screen")
self.assertEqual(schema["parameters"]["required"], ["previous_hash"])
self.assertIn(
"previous_hash", schema["parameters"]["properties"]
)
def test_both_in_handlers(self) -> None:
self.assertIn("android_screen_hash", android_tool._HANDLERS)
self.assertIn("android_diff_screen", android_tool._HANDLERS)
def test_schema_handler_parity(self) -> None:
"""Every schema must have a handler and vice-versa."""
self.assertEqual(
set(android_tool._SCHEMAS.keys()),
set(android_tool._HANDLERS.keys()),
)
class TestAndroidScreenHashTool(unittest.TestCase):
def test_happy_path_return_shape(self) -> None:
expected_hash = _hash_tree(TREE_A)
fake = _FakeResponse(
{"hash": expected_hash, "node_count": 2, "truncated": False}
)
with mock.patch.object(android_tool.requests, "get", return_value=fake):
raw = android_tool.android_screen_hash()
parsed = json.loads(raw)
self.assertEqual(parsed["hash"], expected_hash)
self.assertEqual(parsed["node_count"], 2)
self.assertIs(parsed["truncated"], False)
def test_forwards_truncated_flag(self) -> None:
fake = _FakeResponse(
{"hash": "a" * 64, "node_count": 512, "truncated": True}
)
with mock.patch.object(android_tool.requests, "get", return_value=fake):
parsed = json.loads(android_tool.android_screen_hash())
self.assertIs(parsed["truncated"], True)
self.assertEqual(parsed["node_count"], 512)
def test_http_error_returns_error_envelope(self) -> None:
def boom(*_args, **_kwargs): # type: ignore[no-untyped-def]
raise RuntimeError("connection refused")
with mock.patch.object(android_tool.requests, "get", side_effect=boom):
parsed = json.loads(android_tool.android_screen_hash())
self.assertIn("error", parsed)
self.assertIn("connection refused", parsed["error"])
def test_stability_unchanged_tree(self) -> None:
"""
Two calls over an unchanged tree must return the same hash.
Mocked twice with the identical body to simulate the relay
re-hashing the same tree and getting the same answer.
"""
h = _hash_tree(TREE_A)
fake = _FakeResponse({"hash": h, "node_count": 2, "truncated": False})
with mock.patch.object(android_tool.requests, "get", return_value=fake):
first = json.loads(android_tool.android_screen_hash())
second = json.loads(android_tool.android_screen_hash())
self.assertEqual(first["hash"], second["hash"])
class TestAndroidDiffScreenTool(unittest.TestCase):
def test_changed_false_on_identical_hash(self) -> None:
h = _hash_tree(TREE_A)
fake = _FakeResponse(
{"changed": False, "hash": h, "node_count": 2, "truncated": False}
)
with mock.patch.object(android_tool.requests, "post", return_value=fake) as post:
parsed = json.loads(android_tool.android_diff_screen(h))
self.assertIs(parsed["changed"], False)
self.assertEqual(parsed["hash"], h)
# Tool side must forward the prior hash in the body.
_, kwargs = post.call_args
self.assertEqual(kwargs["json"], {"previous_hash": h})
def test_changed_true_on_text_change(self) -> None:
old = _hash_tree(TREE_A)
new = _hash_tree(TREE_A_TEXT_CHANGED)
self.assertNotEqual(old, new)
fake = _FakeResponse(
{"changed": True, "hash": new, "node_count": 2, "truncated": False}
)
with mock.patch.object(android_tool.requests, "post", return_value=fake):
parsed = json.loads(android_tool.android_diff_screen(old))
self.assertIs(parsed["changed"], True)
self.assertEqual(parsed["hash"], new)
def test_changed_true_on_layout_change(self) -> None:
old = _hash_tree(TREE_A)
new = _hash_tree(TREE_A_LAYOUT_CHANGED)
self.assertNotEqual(old, new)
fake = _FakeResponse(
{"changed": True, "hash": new, "node_count": 2, "truncated": False}
)
with mock.patch.object(android_tool.requests, "post", return_value=fake):
parsed = json.loads(android_tool.android_diff_screen(old))
self.assertIs(parsed["changed"], True)
self.assertEqual(parsed["hash"], new)
self.assertEqual(parsed["node_count"], 2)
def test_http_error_returns_error_envelope(self) -> None:
def boom(*_args, **_kwargs): # type: ignore[no-untyped-def]
raise RuntimeError("relay down")
with mock.patch.object(android_tool.requests, "post", side_effect=boom):
parsed = json.loads(android_tool.android_diff_screen("deadbeef"))
self.assertIn("error", parsed)
self.assertIn("relay down", parsed["error"])
class TestHashReferenceStability(unittest.TestCase):
"""
These tests don't touch the tool at all — they just assert that
the reference hasher in _hash_tree produces the expected stability
/ change signals. Protects the test-bed itself from silently
drifting, and documents the invariant the Kotlin side must match.
"""
def test_identical_trees_hash_equal(self) -> None:
self.assertEqual(_hash_tree(TREE_A), _hash_tree(list(TREE_A)))
def test_text_change_produces_different_hash(self) -> None:
self.assertNotEqual(_hash_tree(TREE_A), _hash_tree(TREE_A_TEXT_CHANGED))
def test_layout_change_produces_different_hash(self) -> None:
self.assertNotEqual(
_hash_tree(TREE_A), _hash_tree(TREE_A_LAYOUT_CHANGED)
)
def test_hash_is_64_hex_chars(self) -> None:
h = _hash_tree(TREE_A)
self.assertEqual(len(h), 64)
int(h, 16) # must parse as hex — raises ValueError otherwise
if __name__ == "__main__":
unittest.main()