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

+18 -1
View File
@@ -57,4 +57,21 @@ Geographic tools live in [`Geo/tools/`](../Geo/README.md) and the Blender-side b
| Command | Purpose |
| --- | --- |
| `UnrealEditor <uproject> /Game/Hollywood/DemoMap -game -qaselftest` | Headless behaviour check: drives forward input for 2 s, then resets and pauses. Logs `GigLine QA: ... PASS/FAIL` lines and a final `RESULT`. Runs only with `-qaselftest`, so normal play is unaffected. |
| `GigLine SIM` / `GigLine GROUND` log lines | Emitted every second by `AGigLineCharacter` (TEMP QA): position, fall state, movement mode, and what the ground probe hits underneath. Remove before release. |
| `-qaselftest` phases | Walk, reset, pause/unpause, a 16-row seam transect (0 holes) and a ground-referenced wall probe (0/35), plus a frame-time capture writing `Saved/Profiling/perf-walk-frametimes.csv` and logging `GigLine PERF:`. |
| `gigline.SetupLogs` cvar | Spawn/input setup logging, **off by default** (`DefaultEngine.ini [ConsoleVariables]`); enable at runtime with `gigline.SetupLogs 1`. |
## Input injection
| Tool | Purpose |
| --- | --- |
| `input_emulation.py` | Pure-stdlib `ctypes`/`fcntl` driver that creates a virtual keyboard+mouse on `/dev/uinput`. The desktop treats it as real hardware, so it proves whether OS input actually reaches the game. Needs `/dev/uinput` writable (ACL on this host). |
| `input_test.py` | Injects a scripted sequence into the focused window: `--sequence move:2,look:3,jump:1,reset:1,pause:1,click`. |
## Tests
```bash
python3 -m pip install -r requirements-tools.txt
python3 -m unittest discover -s Tools/tests -t Tools -p 'test_*.py'
```
Stdlib `unittest`, no Blender/Unreal process. See [`tests/README.md`](tests/README.md): test the pure parts (encoding, classification, maths, parsing); if logic is buried in `main()`, extract it into a function so it can be tested.
+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()