ROADMAP's U7 row names four components. Three of them -- a sticky rail, filters, and grid keyboard -- are already ratified there and are implemented here. The fourth, replacing directory sections with filename-derived groups, is a scope DEPARTURE the operator has not ruled on and is deliberately not built; test_no_group_rail_is_shipped_yet fails the moment somebody builds it anyway, so it cannot arrive by accident while he is away. Filters are links carrying a query parameter, resolved server-side, so the gallery keeps working with JavaScript off -- U3 already cost the verbatim path its no-JS operation and said so, and the gallery is the surface the operator actually reviews on. An unknown filter falls back to `all` rather than indexing a dict by a value that arrives from an operator-editable URL. `unanswered` means HAS AN OPEN PICK, the U4 hold predicate that already exists. The other reading is a real and different question and stays open on the contract rather than being guessed at. Filtering is a VIEW and never reorders. The grid renders `sorted(rel)` with non-matching items removed, so "the third one" means the same thing with a filter on as with it off, and the zoom ring is untouched by any filter -- a ring that changed with the grid would make `next` depend on how the operator arrived, which is the misfiled-judgment failure invariant 6 exists for. ⚠ The first version of that invariant's test was VACUOUS and the mutation run caught it: it compared each filtered view against the unfiltered RESPONSE, so a reversing mutation reversed both sides and it stayed green under the exact change it forbade. Rewritten against an independent truth -- U1 INV-3 says the order IS sorted(rel) -- and re-verified RED. Written an hour after the entry describing this exact failure class, which is worth recording. 611 -> 623 tests.
171 lines
7.2 KiB
Python
171 lines
7.2 KiB
Python
"""U7 slice 1 — the rail, the filters, the grid keyboard.
|
|
|
|
⚠ SCOPE. This covers ONLY the three components ROADMAP's U7 row already
|
|
ratifies: a sticky rail, filters, and grid keyboard. The fourth — replacing
|
|
directory sections with filename-derived groups — is a scope DEPARTURE proposed
|
|
in `docs/contracts/u7_navigation.contract.md` and is deliberately NOT built
|
|
here, because it is the operator's call and he has not made it. The rail
|
|
therefore carries totals and filter counts and no jump-to-group anchors yet.
|
|
|
|
`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/) <sub>· a · 2026-09-01 00:00</sub>\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/) <sub>· a · 2026-09-01 00:00</sub>\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
|
|
|
|
|
|
def test_no_group_rail_is_shipped_yet(gallery):
|
|
"""⚠ SCOPE GUARD, and it is deliberate. Replacing directory sections with
|
|
filename-derived groups is a scope DEPARTURE from ROADMAP's U7 row that the
|
|
operator has not ruled on. This test fails the moment somebody builds it
|
|
anyway, so the departure cannot arrive by accident while he is away."""
|
|
c, _ = gallery
|
|
body = c.get("/b/g/").text
|
|
assert "data-group" not in body
|
|
assert 'class="rail-groups"' not in body
|
|
assert 'class="rail"' in body, "the approved rail must still be here"
|