"""U7 — the rail, the filters, the grid keyboard, and the groups. All four components. The fourth — replacing directory sections with filename-derived groups — was a scope DEPARTURE from ROADMAP's U7 row and was ratified by the operator on 2026-09-22; `test_no_group_rail_is_shipped_yet`, the guard that held it back while the ruling was outstanding, was deleted in the commit that built it. A guard that outlives its reason is worse than no guard, because the next reader trusts it. `unanswered` is taken to mean HAS AN OPEN PICK — the U4 hold predicate, which already exists and already has a home. The alternative reading ("has no mark at all") is a real and different question and is the contract's open question. """ from __future__ import annotations import json import pathlib import sys import pytest from fastapi.testclient import TestClient sys.path.insert(0, str(pathlib.Path(__file__).parent.parent)) from booth.app import create_app # noqa: E402 from booth.marks import declare_pick, set_flag, write_note # noqa: E402 PNG = b"\x89PNG\r\n\x1a\n" @pytest.fixture def gallery(tmp_path): """A booth with one of each: flagged, annotated, open pick, and plain.""" b = tmp_path / "g" b.mkdir() for n in ("a.png", "b.png", "c.png", "d.png"): (b / n).write_bytes(PNG) set_flag(b, "a.png", True) write_note(b, "b.png", "a remark") declare_pick(b, "q", {"prompt": "Which?", "options": ["x", "y"]}, target="c.png") app = create_app(tmp_path, ttl_hours=24, start_sweeper=False) return TestClient(app), b def _tiles(body: str) -> list[str]: """The rels the grid actually rendered, in render order.""" import re # `data-item` already exists on every tile (both the doc and media # variants). Reusing it rather than adding a parallel `data-rel` is the # same one-fact-one-place discipline INV-1 states for item facts. return re.findall(r'data-item="([^"]+)"', body) def test_the_rail_counts_every_filter(gallery): c, _ = gallery body = c.get("/b/g/").text assert 'class="rail"' in body for token in ("all", "flagged", "annotated", "unanswered"): assert f'data-filter="{token}"' in body, token @pytest.mark.parametrize("flt,expected", [ ("all", ["a.png", "b.png", "c.png", "d.png"]), ("flagged", ["a.png"]), ("annotated", ["b.png"]), ("unanswered", ["c.png"]), ]) def test_a_filter_narrows_the_grid_server_side(gallery, flt, expected): """INV-4: a filter is a LINK, not a script. Fetched directly, with no JS executed, the server must return the narrowed grid. Defeating change: binding filters to a click handler and returning the full grid for every URL — under which this test gets four tiles every time.""" c, _ = gallery assert _tiles(c.get(f"/b/g/?filter={flt}").text) == expected def test_filtering_never_reorders(gallery): """INV-2, the load-bearing one. Grouping and filtering are VIEWS. The defeating change is sorting the grid by anything derived from the filter — which looks right and silently changes what "the third one" means, the misfiled-judgment failure CLAUDE.md invariant 6 exists to prevent. Asserted as a SUBSEQUENCE rather than a set: order is the property, so a filter that returned the right tiles in the wrong sequence must go red.""" c, _ = gallery # ⚠ THE BASELINE IS COMPUTED INDEPENDENTLY, and that is the whole test. # The first version of this compared each filtered view against the # UNFILTERED RESPONSE — and a mutation that reversed the order reversed # both sides, so it stayed green under the exact change it forbade. Caught # by running the mutation rather than trusting the assertion, which is the # discipline in persistent-memory.d/2026-09-22-vacuous-falsifiers.md and # which this test failed first time out. # # The independent truth is U1 INV-3: the item order IS `sorted(rel)`. So # each filtered view must be sorted, full stop, with no reference to any # other response. for flt in ("all", "flagged", "annotated", "unanswered"): got = _tiles(c.get(f"/b/g/?filter={flt}").text) assert got == sorted(got), f"{flt} rendered out of sorted(rel) order: {got}" # and every filtered view is a SUBSEQUENCE of the true order, not a reshuffle every = sorted(["a.png", "b.png", "c.png", "d.png"]) for flt in ("all", "flagged", "annotated", "unanswered"): got = _tiles(c.get(f"/b/g/?filter={flt}").text) assert got == [r for r in every if r in got], flt def test_an_unknown_filter_falls_back_to_all_and_does_not_500(gallery): """A filter arrives from a URL, which is operator-editable and link-shared. Defeating change: indexing a dict by the raw parameter.""" c, _ = gallery for junk in ("nonsense", "", "../../etc", "flagged;drop"): r = c.get(f"/b/g/?filter={junk}") assert r.status_code == 200, junk assert len(_tiles(r.text)) == 4, junk def test_the_zoom_ring_is_identical_under_every_filter(gallery): """The ring is the item order filtered to images and must not notice the grid's filter — otherwise `next` means something different depending on how the operator arrived, and a flag lands on the wrong artifact. Defeating change: building the ring from the filtered list.""" c, _ = gallery rings = set() for flt in ("all", "flagged", "annotated", "unanswered"): c.get(f"/b/g/?filter={flt}") body = c.get("/b/g/b.png?view=1").text import re rings.add(tuple(re.findall(r'href="([^"]*\.png[^"]*)"', body))) assert len(rings) == 1, f"the ring changed with the filter: {rings}" def test_the_rail_is_absent_on_a_booth_with_no_grid(tmp_path): """INV-5's sibling: a rail over nothing is chrome. The standing link board has no items, so it must not render one.""" b = tmp_path / "links" b.mkdir() (b / "links.md").write_text("- [r](https://x.test/) · a · 2026-09-01 00:00\n") c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False)) assert 'class="rail"' not in c.get("/b/links/").text def test_the_keyboard_is_not_bound_when_there_is_no_grid(tmp_path): """INV-5. Defeating change: binding the handler unconditionally, so `f` on the standing link board swallows the keystroke and flags nothing.""" b = tmp_path / "links" b.mkdir() (b / "links.md").write_text("- [r](https://x.test/) · a · 2026-09-01 00:00\n") c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False)) assert "gridkeys" not in c.get("/b/links/").text def test_the_keyboard_is_bound_when_there_is_one(gallery): c, _ = gallery assert "gridkeys" in c.get("/b/g/").text # --- U7 slice 2: the groups ---------------------------------------------- @pytest.fixture def grouped(tmp_path): """Two groups whose members INTERLEAVE in `sorted(rel)`. `a/x1.png, a/y1.png, b/x2.png, b/y2.png` is the sorted order; group `x` is at positions 0 and 2, group `y` at 1 and 3. That interleaving is the whole point of the fixture — a grid re-sorted by `(group, rel)` to make groups render contiguously would pass every set-based assertion and fail these. """ b = tmp_path / "g" for rel in ("a/x1.png", "a/y1.png", "b/x2.png", "b/y2.png"): p = b / rel p.parent.mkdir(parents=True, exist_ok=True) p.write_bytes(PNG) app = create_app(tmp_path, ttl_hours=24, start_sweeper=False) return TestClient(app), b def _groups(body: str) -> list[str]: """The group keys the rail listed, in render order.""" import re return re.findall(r'data-group="([^"]+)"', body) def test_the_rail_lists_groups_when_grouping_is_informative(grouped): c, _ = grouped body = c.get("/b/g/").text assert 'class="rail-groups"' in body assert _groups(body) == ["x", "y"] def test_group_order_is_the_position_of_the_first_member(tmp_path): """The settled rule (ROADMAP, operator 2026-09-22): groups order by where each group's FIRST member falls in the rendered sequence. ⚠ THIS FIXTURE IS BUILT SO THE THREE PLAUSIBLE RULES ALL DISAGREE. The first version used `w, x, y` — whose positional order happens to BE alphabetical, so it stayed green under the very change it forbade. Caught by running the mutation, not by reading the assertion; the same trap persistent-memory.d/2026-09-22-vacuous-falsifiers.md names and the same one `test_filtering_never_reorders` fell into an hour after it was written. sorted(rel): a/z1 a/z2 b/a1 b/a2 b/a3 c/m1 c/m2 by position: z (0), a (2), m (5) <- the rule alphabetical: a, m, z <- wrong, and differs by count: a(3), z(2), m(2) <- wrong, and differs """ b = tmp_path / "g" for rel in ("a/z1.png", "a/z2.png", "b/a1.png", "b/a2.png", "b/a3.png", "c/m1.png", "c/m2.png"): q = b / rel q.parent.mkdir(parents=True, exist_ok=True) q.write_bytes(PNG) c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False)) assert _groups(c.get("/b/g/").text) == ["z", "a", "m"] def test_grouping_never_reorders_the_grid(grouped): """INV-2, the load-bearing one. The defeating change is sorting the grid by `(group, rel)` so groups render contiguously — which looks right, passes any set comparison, and silently changes what "the third one" means. This fixture interleaves precisely so that change goes red. The baseline is INDEPENDENT (U1 INV-3: the order IS `sorted(rel)`), not a second response — the vacuous-falsifier trap this suite already fell into once.""" c, _ = grouped tiles = _tiles(c.get("/b/g/").text) assert tiles == ["a/x1.png", "a/y1.png", "b/x2.png", "b/y2.png"] assert tiles == sorted(tiles) def test_every_group_anchor_lands_on_a_rendered_tile(grouped): """A jump-to-group link that scrolls nowhere is worse than no link. Every anchor must name an id the page actually carries. Defeating change: anchoring to the group KEY (`#group-x`) while the tiles carry `id="item-"` — which renders, looks right, and does nothing.""" import re c, _ = grouped body = c.get("/b/g/").text hrefs = re.findall(r'class="rail-g"[^>]*href="#([^"]+)"', body) assert hrefs, "the rail rendered no group anchors" for h in hrefs: assert f'id="{h}"' in body, f"anchor #{h} names no element on the page" def test_no_group_rail_when_every_item_is_its_own_group(gallery): """INV-3's real failure mode, and it is NOT the one the contract feared. `a.png b.png c.png d.png` yields four groups of one — a rail that is a second copy of the grid. Measured live: `pewpew-ui-brief` gives 23 groups for 34 items, `dfa-concepts` 13 for 20. The contract only guarded the opposite degeneracy (one group for everything), which is why this test exists. Defeating change: `{% if rail.groups %}`, true for four singletons.""" c, _ = gallery body = c.get("/b/g/").text assert 'class="rail-groups"' not in body assert 'class="rail"' in body, "the filter rail must still be here" def test_no_group_rail_when_there_is_only_one_group(tmp_path): """INV-3 as the contract states it, with the live specimen: `sc-iso-spread` is `DSC0001.jpg` through `DSC0006.jpg` — one group, six images. Defeating change: `{% if rail.groups %}`, true for a single group.""" b = tmp_path / "flat" b.mkdir() for i in range(1, 7): (b / f"DSC{i:04d}.jpg").write_bytes(PNG) c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False)) body = c.get("/b/flat/").text assert 'class="rail-groups"' not in body assert 'class="rail"' in body def test_groups_describe_the_filtered_grid(tmp_path): """The rail describes what is ON SCREEN. An anchor to a group the filter has hidden would scroll nowhere — the same defect as a wrong id, arriving by a different route. Three groups of two; the flag covers `x` and `y` entirely and `z` not at all. Under `?filter=flagged` the rail must list x and y and MUST NOT list z, whose two tiles are not on the page. Defeating change: deriving groups from the full gallery rather than from the rendered list — under which `z` appears and its anchor goes nowhere.""" b = tmp_path / "g" b.mkdir() for n in ("x1.png", "x2.png", "y1.png", "y2.png", "z1.png", "z2.png"): (b / n).write_bytes(PNG) for n in ("x1.png", "x2.png", "y1.png", "y2.png"): set_flag(b, n, True) c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False)) assert _groups(c.get("/b/g/").text) == ["x", "y", "z"] body = c.get("/b/g/?filter=flagged").text assert _groups(body) == ["x", "y"] assert _tiles(body) == ["x1.png", "x2.png", "y1.png", "y2.png"] def test_a_filtered_view_too_small_to_group_drops_the_group_row(grouped): """The informativeness rule binds to the RENDERED list, not to the booth. One flagged tile is one group of one, which cannot navigate — so the group row goes away even though the unfiltered booth has a perfectly good one. The filter rail stays, because that is how the operator gets back.""" c, b = grouped set_flag(b, "a/x1.png", True) assert 'class="rail-groups"' in c.get("/b/g/").text body = c.get("/b/g/?filter=flagged").text assert 'class="rail-groups"' not in body assert 'class="rail"' in body def test_the_zoom_ring_ignores_grouping(grouped): """The ring is `sorted(rel)` filtered to images and must not notice groups any more than it notices filters. THE FIXTURE IS THE FALSIFIER. From `a/x1.png`, sorted order says next is `a/y1.png` — a DIFFERENT group. A ring rebuilt per group would say `b/x2.png`, the next member of group `x`, and `→` would start walking a sequence the operator never saw on the page. That is invariant 6's misfiled-judgment failure exactly: the flag lands on the wrong artifact.""" import re c, _ = grouped body = c.get("/b/g/view?f=a/x1.png").text nxt = re.findall(r'class="vnav vnext" href="\?f=([^"&]+)"', body) assert nxt == ["a/y1.png"], f"the ring followed the group, not sorted(rel): {nxt}" # and the zoom page has no group chrome at all — it is one artifact, not a wall assert "data-group" not in body def test_no_route_body_derives_a_group(gallery): """INV-1, the same assertion U1 makes for `classify` and `render_doc`. Defeating change: a route or template computing a prefix inline — the caption bug in a new field.""" import inspect import booth.app as app_mod src = inspect.getsource(app_mod.create_app) assert "_group_of" not in src, "create_app must read Item.group, not derive it" def test_a_hostile_filename_cannot_break_out_of_the_rail(tmp_path): """Group keys and anchors are AGENT-AUTHORED — they are filenames, and a session makes a booth by making a folder with no validation anywhere in the path. CLAUDE.md names autoescape as load-bearing for exactly this. Defeating change: building the rail markup with `|safe`, or assembling the href by string concatenation outside Jinja. Both render, both look right, and both put attacker-controlled bytes into an attribute.""" b = tmp_path / "g" b.mkdir() for n in ('q"x1.png', 'q"x2.png', "s