docs(agents): tooling-improvement and architecture-upkeep rules; add tool tests

Researched the sibling project's tooling discipline (tools/README inventory,
tools/tests with stdlib unittest, check_docs gate, clean-worktree rule, paid-API
tools human-triggered) and encoded it for this repository:

AGENTS.md gains two sections.
- 'Python and shell tooling': fix the tool not the artefact; improve over fork;
  one script one job; fail loudly; result files for Unreal commandlets; absolute
  paths into sandboxes; clean worktree for generators (.blend/.umap are
  read-only on checkout); network/paid tools human-triggered with 429 back-off;
  measure don't assert; document every tool in the README inventory; test the
  pure parts.
- 'Architecture documentation': Docs/Architecture is arc42 in Markdown; a table
  mapping each kind of change to the section that must be updated; describe what
  exists, ADRs append-only, keep architecture.md in sync, prettier --check.

Made the referenced scaffolding real rather than aspirational:
- Tools/tests/ (README + __init__) with 12 passing unittest tests covering
  input_emulation's ioctl/key-code encoding and block_height_report's height
  classification.
- requirements-tools.txt (requests).
- Extracted classify_height() out of block_height_report.main() so the roof/
  shelter rule is testable — the rule itself came from the v0.1.2 finding.
- Tools/README: tests section, input-injection rows, and the removed TEMP-QA row
  replaced by the real cvar/self-test description.
- Docs/Architecture 05 + architecture.md: tools table extended (input injection,
  tests) and a link to the tool rules; kept in sync per the new rule.
This commit is contained in:
zwitschi committed 2026-11-08 16:25:26 +01:00
1 parent 420f863999
commit 5be6c84b37
10 files changed
+233 -17

No files matched your search

+26
View File
@@ -0,0 +1,26 @@
# Tooling tests
Standard-library `unittest` tests for the host-side tools in `Tools/`. They never start Blender, Unreal, or the game; anything that needs those processes is verified by the tool itself and recorded in `Docs/release-checks.md` instead.
Run from the repository root:
```bash
python3 -m pip install -r requirements-tools.txt
python3 -m unittest discover -s Tools/tests -t Tools -p 'test_*.py'
```
The `-t Tools` top-level directory is required: the tests import the tool modules by their plain names (`import input_emulation`), which is why `Tools/tests/` carries an `__init__.py`.
Individual test:
```bash
python3 -m unittest Tools.tests.test_input_emulation -v
```
## What belongs here
Test the **pure** parts — argument parsing, key/coordinate encoding, geometry maths, licence gating, report formatting, decision classification. Network, Blender and Unreal calls stay out: a test that needs a paid API, a display, or an engine process is a manual check, not a unit test.
When a tool's logic is buried in `main()`, extract it into a function so it can be tested directly. That is a tooling improvement, not test scaffolding.
Geo (`Geo/tools/`) and Blender (`Art/blender/*/`) builders are tested the same way once their pure parts are extracted; note the module path in the test.
+1
View File
@@ -0,0 +1 @@
"""Package marker so `python3 -m unittest Tools.tests.<module>` resolves."""
+53
View File
@@ -0,0 +1,53 @@
"""Tests for Geo/tools/block_height_report.py::classify_height.
The Geo tools live outside `Tools/`, so the test adds `Geo/tools` to `sys.path`
before importing the module by name.
"""
import os
import sys
import unittest
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
sys.path.insert(0, os.path.join(ROOT, "Geo", "tools"))
import block_height_report as bhr # noqa: E402
class ClassifyHeightTest(unittest.TestCase):
def test_height_tag_wins(self):
height, source = bhr.classify_height({"building": "yes", "height": "18.1"})
self.assertEqual(height, 18.1)
self.assertEqual(source, "height tag")
def test_height_tag_beats_levels(self):
height, _ = bhr.classify_height(
{"building": "yes", "height": "12.5", "building:levels": "4"})
self.assertEqual(height, 12.5)
def test_levels_derive_height(self):
height, source = bhr.classify_height(
{"building": "yes", "building:levels": "4"}, level_height=3.2)
self.assertEqual(height, 12.8)
self.assertIn("levels x", source)
def test_roof_is_not_a_building_volume(self):
# building=roof at layer=1 is an elevated canopy, not a two-storey block.
height, source = bhr.classify_height({"building": "roof", "layer": "1"})
self.assertEqual(height, 0.0)
self.assertIn("not a building volume (roof)", source)
def test_transit_shelter_is_not_a_building_volume(self):
height, source = bhr.classify_height(
{"building": "yes", "amenity": "shelter", "shelter_type": "public_transport"})
self.assertEqual(height, 0.0)
self.assertIn("not a building volume (shelter)", source)
def test_missing_data_falls_back_to_a_flagged_guess(self):
height, source = bhr.classify_height({"building": "yes"})
self.assertEqual(height, 2 * bhr.LEVEL_HEIGHT)
self.assertTrue(source.startswith("GUESS"))
if __name__ == "__main__":
unittest.main()
+57
View File
@@ -0,0 +1,57 @@
"""Tests for Tools/input_emulation.py — the pure ioctl/key-code parts.
The device path itself needs /dev/uinput, so only the encoding helpers are
covered here; the end-to-end injection is verified by running the tool (see
Docs/release-checks.md and MEMORY.md).
"""
import unittest
import input_emulation as ie
class IocEncodingTest(unittest.TestCase):
"""_IOC must match the Linux ioctl bit layout."""
def test_layout_constants(self):
self.assertEqual(ie._IOC_NRSHIFT, 0)
self.assertEqual(ie._IOC_TYPESHIFT, 8)
self.assertEqual(ie._IOC_SIZESHIFT, 16)
self.assertEqual(ie._IOC_DIRSHIFT, 30)
def test_known_values(self):
# UI_SET_EVBIT = _IOW('U', 100, int) -> 0x40045564 on Linux.
self.assertEqual(ie.UI_SET_EVBIT, 0x40045564)
# UI_DEV_CREATE = _IO('U', 1) -> 0x5501.
self.assertEqual(ie.UI_DEV_CREATE, 0x5501)
def test_type_accepts_char_or_int(self):
self.assertEqual(ie._ioc(1, 4, "U", 100), ie._ioc(1, 4, ord("U"), 100))
class KeyCodeTest(unittest.TestCase):
"""KEYCODES must hold the Linux input-event codes the demo uses."""
EXPECTED = {
"w": 17, "s": 31, "a": 30, "d": 32,
"space": 57, "r": 19, "esc": 1,
}
def test_demo_controls(self):
for name, code in self.EXPECTED.items():
self.assertEqual(ie.KEYCODES[name], code, f"{name} code")
def test_lookup_is_case_insensitive(self):
dev = ie.InputDevice.__new__(ie.InputDevice) # no device opened
self.assertEqual(dev._key_code("W"), 17)
self.assertEqual(dev._key_code("SPACE"), 57)
self.assertEqual(dev._key_code(19), 19) # a raw code passes through
def test_unknown_key_raises(self):
dev = ie.InputDevice.__new__(ie.InputDevice)
with self.assertRaises(ie.UInputError):
dev._key_code("not-a-key")
if __name__ == "__main__":
unittest.main()