Navigation was built from the review ring while the compare GET also demands containment, so an outside symlink (which stays in the ring) was offered by the strip, the steps, the review's Compare control and the flag landing, and 404ed on arrival. Every one is now built from the compare ring (the review ring filtered by the same conjunction, _in_booth). Two pre-existing gaps compare inherits, fixed at the source: resolve_booth caught only OSError, so a NUL in the booth segment was a 500; record_view opened its marker blocking, so a planted FIFO hung every look. Plus: the page treats %73ide=a as side=a, and the subgrid engine floor is stated. Two findings refuted (a chorded click mid-drag never fires pointerup, measured; booth_items never yields an unquotable rel). r3.toml: 57 rows.
369 lines
17 KiB
Python
369 lines
17 KiB
Python
"""R3 — compare: two picked items of a booth side by side.
|
|
|
|
Contract: docs/contracts/r3_compare.contract.md. The server half: the route,
|
|
the pair, the step and strip links, the regions, and the JS-off flag landing.
|
|
The browser half is tests/test_compare_browser.py.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pathlib
|
|
import re
|
|
import sys
|
|
from urllib.parse import parse_qs, urlsplit
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
|
|
|
|
from booth.app import create_app # noqa: E402
|
|
|
|
PNG = b"\x89PNG\r\n\x1a\n"
|
|
|
|
|
|
def _booth(root: pathlib.Path, name: str, files: dict[str, bytes]) -> pathlib.Path:
|
|
b = root / name
|
|
b.mkdir()
|
|
for rel, data in files.items():
|
|
p = b / rel
|
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
p.write_bytes(data)
|
|
return b
|
|
|
|
|
|
def _client(root: pathlib.Path) -> TestClient:
|
|
return TestClient(create_app(root, ttl_hours=24, start_sweeper=False),
|
|
follow_redirects=False)
|
|
|
|
|
|
def _frames(body: str) -> dict[str, str]:
|
|
"""rel -> the A/B marks its filmstrip frame carries ('' for none), in
|
|
strip order."""
|
|
film = re.search(r'<nav class="film"[^>]*data-region="film".*?</nav>', body, re.S).group(0)
|
|
out = {}
|
|
for f in re.findall(r'<a class="film-f[^"]*"[^>]*>.*?</a>', film, re.S):
|
|
rel = re.search(r'data-rel="([^"]*)"', f).group(1)
|
|
out[rel] = "".join(re.findall(r'<span class="film-ab">([AB]+)</span>', f))
|
|
return out
|
|
|
|
|
|
# ---- C1: the route and the pair ----------------------------------------------
|
|
|
|
def test_compare_renders_the_pair(tmp_path):
|
|
"""The tracer: four pictures, #1 against #3. Both names and both ordinals
|
|
are printed, and the filmstrip marks #1 A and #3 B."""
|
|
_booth(tmp_path, "g", {f"{n}.png": PNG for n in ("p", "q", "r", "s")})
|
|
r = _client(tmp_path).get("/b/g/compare?a=p.png&b=r.png")
|
|
assert r.status_code == 200
|
|
body = r.text
|
|
label_a = re.search(r'data-region="label-a".*?</div>', body, re.S).group(0)
|
|
label_b = re.search(r'data-region="label-b".*?</div>', body, re.S).group(0)
|
|
assert "p.png" in label_a and "#1" in label_a, label_a
|
|
assert "r.png" in label_b and "#3" in label_b, label_b
|
|
assert _frames(body) == {"p.png": "A", "q.png": "", "r.png": "B", "s.png": ""}
|
|
assert list(_frames(body)) == ["p.png", "q.png", "r.png", "s.png"], "the strip is in RING order"
|
|
# each side's flag form names its OWN item
|
|
for key, rel in (("a", "p.png"), ("b", "r.png")):
|
|
form = re.search(r'data-region="flag-%s".*?</form>' % key, body, re.S).group(0)
|
|
assert f'name="target" value="{rel}"' in form, (key, form)
|
|
|
|
|
|
def _four(root: pathlib.Path) -> pathlib.Path:
|
|
"""Four pictures, a doc, a caption sidecar and a dotfile: every kind of
|
|
thing a side can name that is not a side."""
|
|
return _booth(root, "g", {"p.png": PNG, "q.png": PNG, "r.png": PNG, "s.png": PNG,
|
|
"notes.md": b"# n", "p.png.txt": b"a caption",
|
|
".hidden.png": PNG})
|
|
|
|
|
|
def test_a_bad_side_is_a_404(tmp_path):
|
|
"""Missing, traversal, a NUL, a dotfile, a doc item, a non-item file: a 404
|
|
each, on either side, never a 500."""
|
|
_four(tmp_path)
|
|
c = _client(tmp_path)
|
|
bad = ["", "../g/p.png/..", "../../etc/passwd", "p.png\x00", ".hidden.png",
|
|
"notes.md", "p.png.txt", "gone.png", "sub/"]
|
|
for rel in bad:
|
|
for q in ({"a": rel, "b": "q.png"}, {"a": "q.png", "b": rel}):
|
|
r = c.get("/b/g/compare", params=q)
|
|
assert r.status_code == 404, (q, r.status_code)
|
|
for q in ({"b": "q.png"}, {"a": "q.png"}, {}):
|
|
assert c.get("/b/g/compare", params=q).status_code == 404, q
|
|
|
|
|
|
def test_a_missing_param_is_404_not_422(tmp_path):
|
|
"""The review declares `f: str` and answers 422 without it; compare
|
|
declares both sides with a default and answers 404."""
|
|
_four(tmp_path)
|
|
r = _client(tmp_path).get("/b/g/compare?a=p.png")
|
|
assert r.status_code == 404
|
|
|
|
|
|
def test_an_outside_symlink_in_the_ring_is_404(tmp_path):
|
|
"""`booth_items` follows symlinks, so a link pointing OUTSIDE the booth is
|
|
in the review ring; only the containment check refuses it."""
|
|
from booth.items import booth_items, review_chain
|
|
b = _four(tmp_path)
|
|
outside = tmp_path / "elsewhere.png"
|
|
outside.write_bytes(PNG)
|
|
(b / "zz-link.png").symlink_to(outside)
|
|
assert "zz-link.png" in review_chain(booth_items(b)), "the fixture must put it in the ring"
|
|
# a SIBLING whose name shares the booth's prefix is outside too (the
|
|
# containment check compares with the separator, never a bare prefix)
|
|
sib = tmp_path / "g-extra"
|
|
sib.mkdir()
|
|
(sib / "x.png").write_bytes(PNG)
|
|
(b / "zz-sib.png").symlink_to(sib / "x.png")
|
|
c = _client(tmp_path)
|
|
for rel in ("zz-link.png", "zz-sib.png"):
|
|
assert c.get(f"/b/g/compare?a={rel}&b=p.png").status_code == 404, rel
|
|
assert c.get(f"/b/g/compare?a=p.png&b={rel}").status_code == 404, rel
|
|
|
|
|
|
def test_no_navigation_offers_a_pair_that_404s(tmp_path):
|
|
"""An outside symlink stays in the review ring, and compare 404s it. So no
|
|
compare link may offer it: not the strip, not a step, not the review's
|
|
Compare control, not the JS-off flag landing (heid bug hunt, 3 of 4)."""
|
|
b = _booth(tmp_path, "g", {"a.png": PNG, "c.png": PNG})
|
|
outside = tmp_path / "elsewhere.png"
|
|
outside.write_bytes(PNG)
|
|
(b / "b-link.png").symlink_to(outside)
|
|
c = _client(tmp_path)
|
|
body = c.get("/b/g/compare?a=a.png&b=c.png").text
|
|
assert list(_frames(body)) == ["a.png", "c.png"], _frames(body)
|
|
for h in _compare_links(body):
|
|
q = parse_qs(urlsplit(h).query)
|
|
assert "b-link.png" not in (q["a"][0], q["b"][0]), h
|
|
assert _step(body, "a-next") == ("c.png", "c.png") # steps over it
|
|
assert _compare_href(c.get("/b/g/view?f=a.png").text) == ("a.png", "c.png")
|
|
r = c.post("/b/g/flag", data={"target": "a.png", "on": "1", "back": "compare",
|
|
"a": "a.png", "b": "b-link.png"})
|
|
assert r.headers["location"] == "/b/g/#item-a.png", r.headers["location"]
|
|
|
|
|
|
def test_hostile_booth_names_are_404_not_500(tmp_path):
|
|
"""A NUL in the booth segment makes Path.resolve raise ValueError, which
|
|
is not an OSError: it must still be a 404 (heid bug hunt, hulda)."""
|
|
_four(tmp_path)
|
|
c = _client(tmp_path)
|
|
for path in ("/b/g%00/compare?a=p.png&b=q.png", "/b/g%00/view?f=p.png", "/b/g%00/"):
|
|
assert c.get(path).status_code == 404, path
|
|
|
|
|
|
def test_a_planted_fifo_marker_cannot_hang_a_look(tmp_path):
|
|
"""Recording a look never costs the page: a FIFO planted at `.viewed` must
|
|
not block the open that touches it (heid bug hunt, hulda)."""
|
|
import os
|
|
import threading
|
|
b = _four(tmp_path)
|
|
os.mkfifo(b / ".viewed")
|
|
got = []
|
|
t = threading.Thread(target=lambda: got.append(
|
|
_client(tmp_path).get("/b/g/compare?a=p.png&b=q.png").status_code), daemon=True)
|
|
t.start()
|
|
t.join(10)
|
|
assert got == [200], "a planted FIFO held the look open"
|
|
|
|
|
|
def test_a_look_records_both_seen(tmp_path):
|
|
"""A compare GET is a look at both sides; a 404 records nothing."""
|
|
import json
|
|
b = _four(tmp_path)
|
|
c = _client(tmp_path)
|
|
assert c.get("/b/g/compare?a=p.png&b=gone.png").status_code == 404
|
|
assert not (b / ".seen").exists() and not (b / ".viewed").exists()
|
|
assert c.get("/b/g/compare?a=q.png&b=s.png").status_code == 200
|
|
assert set(json.loads((b / ".seen").read_text())) == {"q.png", "s.png"}
|
|
assert (b / ".viewed").exists()
|
|
|
|
|
|
def test_compare_carries_data_booth(tmp_path):
|
|
"""Reveal all's script and the head script's reveal restore both read
|
|
`data-booth` off <html>, and bail without it."""
|
|
_four(tmp_path)
|
|
body = _client(tmp_path).get("/b/g/compare?a=p.png&b=q.png").text
|
|
assert re.search(r'<html lang="en" data-booth="g">', body)
|
|
|
|
|
|
def test_no_data_region_repeats(tmp_path):
|
|
"""The swap keeps the FIRST fresh node per id and copies it over EVERY live
|
|
node with that id, so a shared id would turn B's flag into A's. Unique,
|
|
keyed by side — including when a == b."""
|
|
_four(tmp_path)
|
|
c = _client(tmp_path)
|
|
for q in ("a=p.png&b=r.png", "a=q.png&b=q.png"):
|
|
# attributes only: base.html's script names `[data-region="status"]`
|
|
ids = re.findall(r'\sdata-region="([^"]+)"', c.get(f"/b/g/compare?{q}").text)
|
|
assert len(ids) == len(set(ids)), (q, ids)
|
|
assert {"flag-a", "flag-b", "label-a", "label-b", "film"} <= set(ids), ids
|
|
assert not [i for i in ids if i.startswith("item-")], ids
|
|
body = c.get("/b/g/compare?a=q.png&b=q.png").text
|
|
assert _frames(body)["q.png"] == "AB", "a == b marks the one frame both ways"
|
|
|
|
|
|
def test_a_video_or_track_plays_in_its_own_stage_and_two_get_no_toggle(tmp_path):
|
|
"""C4: video and audio play in their own stage; the Fit | 1:1 toggle is
|
|
bound only when a side is a picture, so two videos get none."""
|
|
_booth(tmp_path, "g", {"a.webm": b"\x1aE\xdf\xa3", "b.mp3": b"ID3", "c.png": PNG})
|
|
c = _client(tmp_path)
|
|
body = c.get("/b/g/compare?a=a.webm&b=b.mp3").text
|
|
assert re.search(r'<video class="cmp-media"[^>]*src="a.webm"', body)
|
|
assert re.search(r'<audio class="cmp-media"[^>]*src="b.mp3"', body)
|
|
assert 'id="vtoggle"' not in body
|
|
assert 'id="vtoggle"' in c.get("/b/g/compare?a=a.webm&b=c.png").text
|
|
|
|
|
|
# ---- C3: stepping --------------------------------------------------------------
|
|
|
|
def _ring6(root: pathlib.Path) -> pathlib.Path:
|
|
"""Six media with a DOC between them: 03-notes.md takes ordinal 3, so an
|
|
ordinal is not a ring position."""
|
|
return _booth(root, "g", {"01.png": PNG, "02.png": PNG, "03-notes.md": b"# n",
|
|
"04.png": PNG, "05.png": PNG, "06.png": PNG, "07.png": PNG})
|
|
|
|
|
|
def _step(body: str, which: str) -> tuple[str, str]:
|
|
"""The (a, b) rels a step link targets."""
|
|
href = re.search(r'<a [^>]*data-step="%s"[^>]*href="([^"]+)"' % which, body)
|
|
href = href or re.search(r'<a [^>]*href="([^"]+)"[^>]*data-step="%s"' % which, body)
|
|
q = parse_qs(urlsplit(href.group(1).replace("&", "&")).query)
|
|
return q["a"][0], q["b"][0]
|
|
|
|
|
|
def test_linked_steps_keep_the_distance_and_wrap(tmp_path):
|
|
"""Ring positions 2 and 5 step forward to (3, 6), then (4, 1) — wrapped —
|
|
and back from (1, 4) to (6, 3). Named by rel, never by ordinal."""
|
|
_ring6(tmp_path)
|
|
c = _client(tmp_path)
|
|
body = c.get("/b/g/compare?a=02.png&b=06.png").text
|
|
assert _step(body, "both-next") == ("04.png", "07.png")
|
|
body = c.get("/b/g/compare?a=04.png&b=07.png").text
|
|
assert _step(body, "both-next") == ("05.png", "01.png")
|
|
body = c.get("/b/g/compare?a=07.png&b=02.png").text
|
|
assert _step(body, "both-next") == ("01.png", "04.png"), "A wraps as B does"
|
|
body = c.get("/b/g/compare?a=01.png&b=05.png").text
|
|
assert _step(body, "both-prev") == ("07.png", "04.png")
|
|
# each side on its own moves only itself, and wraps the same way
|
|
assert _step(body, "a-prev") == ("07.png", "05.png")
|
|
assert _step(body, "a-next") == ("02.png", "05.png")
|
|
assert _step(body, "b-prev") == ("01.png", "04.png")
|
|
assert _step(body, "b-next") == ("01.png", "06.png")
|
|
|
|
|
|
def _compare_links(body: str) -> list[str]:
|
|
"""Every step and filmstrip href on the page, entity-decoded."""
|
|
hrefs = re.findall(r'<a [^>]*(?:data-step="[^"]+"[^>]*href|class="film-f[^"]*"[^>]*href)="([^"]+)"', body)
|
|
return [h.replace("&", "&") for h in hrefs]
|
|
|
|
|
|
def test_the_urls_are_keyed_by_rel(tmp_path):
|
|
"""Every step and strip link names both sides by rel, url-quoted; none
|
|
carries an ordinal or any key beyond the pair and the view state."""
|
|
_booth(tmp_path, "g", {"a b.png": PNG, "sub dir/c#d.png": PNG, "e&f.png": PNG})
|
|
body = _client(tmp_path).get("/b/g/compare", params={"a": "a b.png", "b": "sub dir/c#d.png"}).text
|
|
links = _compare_links(body)
|
|
assert len(links) == 6 + 3, links # six steps, three frames
|
|
for h in links:
|
|
u = urlsplit(h)
|
|
assert u.path == "/b/g/compare", h
|
|
assert " " not in h and "#" not in u.query.replace("%23", ""), h
|
|
q = parse_qs(u.query)
|
|
assert set(q) == {"a", "b"}, h
|
|
assert all(v in ("a b.png", "sub dir/c#d.png", "e&f.png") for v in (q["a"][0], q["b"][0])), h
|
|
|
|
|
|
def test_view_state_rides_the_links(tmp_path):
|
|
"""`side=a&link=0` rides every step and strip link; an unknown value reads
|
|
as the default (B active, linked) and is never an error."""
|
|
_four(tmp_path)
|
|
c = _client(tmp_path)
|
|
body = c.get("/b/g/compare?a=p.png&b=r.png&side=a&link=0").text
|
|
links = _compare_links(body)
|
|
assert links and all(h.endswith("&side=a&link=0") for h in links), links
|
|
assert 'class="cmp-side is-active" data-side="a"' in body
|
|
# with A active a frame replaces A, keeping B
|
|
frame = re.search(r'<a class="film-f[^"]*"\s+href="([^"]+)" data-rel="q.png"', body).group(1)
|
|
assert parse_qs(urlsplit(frame.replace("&", "&")).query) == {
|
|
"a": ["q.png"], "b": ["r.png"], "side": ["a"], "link": ["0"]}
|
|
links = _compare_links(c.get("/b/g/compare?a=p.png&b=r.png&link=0").text)
|
|
assert links and all(h.endswith("&link=0") and "side=" not in h for h in links), links
|
|
for odd in ("side=z&link=maybe", "side=A&link=00", "side=&link="):
|
|
r = c.get(f"/b/g/compare?a=p.png&b=r.png&{odd}")
|
|
assert r.status_code == 200, odd
|
|
links = _compare_links(r.text)
|
|
assert all(set(parse_qs(urlsplit(h).query)) == {"a", "b"} for h in links), (odd, links)
|
|
assert 'class="cmp-side is-active" data-side="b"' in r.text, odd
|
|
assert 'data-linked="1"' in r.text, odd
|
|
|
|
|
|
# ---- C5: judging without JS -----------------------------------------------------
|
|
|
|
def _flag(c: TestClient, form: dict, accept: str | None = None):
|
|
headers = {"accept": accept} if accept else {}
|
|
r = c.post("/b/g/flag", data={"target": "q.png", "on": "1", **form}, headers=headers)
|
|
assert r.status_code == (204 if accept else 303), (form, r.status_code)
|
|
return r
|
|
|
|
|
|
def test_a_flag_without_js_lands_on_the_same_pair(tmp_path):
|
|
"""`back=compare` lands on exactly the pair, built from the checked rels,
|
|
with the view state mapped from a closed set and never echoed, and no
|
|
fragment. Anything outside the ring takes the no-`back` landing; the
|
|
in-place answer is the 204 it always was."""
|
|
_booth(tmp_path, "g", {"p.png": PNG, "q.png": PNG, "sub dir/r s.png": PNG, "n.md": b"# n"})
|
|
c = _client(tmp_path)
|
|
pair = {"back": "compare", "a": "p.png", "b": "sub dir/r s.png"}
|
|
want = "/b/g/compare?a=p.png&b=sub%20dir/r%20s.png"
|
|
r = _flag(c, pair)
|
|
assert r.status_code == 303 and r.headers["location"] == want
|
|
assert _flag(c, {**pair, "side": "a", "link": "0"}).headers["location"] == want + "&side=a&link=0"
|
|
assert _flag(c, {**pair, "link": "0"}).headers["location"] == want + "&link=0"
|
|
for odd in ({"side": "A"}, {"link": "00"}, {"side": "b", "link": "1"},
|
|
{"side": "a#x", "link": "0&side=a"}):
|
|
assert _flag(c, {**pair, **odd}).headers["location"] == want, odd
|
|
no_back = _flag(c, {"target": "q.png"}).headers["location"]
|
|
assert no_back == "/b/g/#item-q.png"
|
|
for bad in ({"a": "n.md"}, {"b": "gone.png"}, {"a": "../g/p.png"}, {"b": ""}):
|
|
assert _flag(c, {**pair, **bad}).headers["location"] == no_back, bad
|
|
r = _flag(c, pair, accept="application/json")
|
|
assert r.status_code == 204 and "location" not in r.headers
|
|
|
|
|
|
def test_every_other_landing_is_byte_identical(tmp_path):
|
|
"""R2 INV-4: `back=view`, `back=marks` and no `back` land exactly where they
|
|
did before compare existed."""
|
|
_booth(tmp_path, "g", {"p.png": PNG, "q.png": PNG})
|
|
c = _client(tmp_path)
|
|
assert _flag(c, {}).headers["location"] == "/b/g/#item-q.png"
|
|
assert _flag(c, {"back": "marks"}).headers["location"] == "/b/g/marks#item-q.png"
|
|
assert _flag(c, {"back": "view", "f": "p.png"}).headers["location"] == "/b/g/view?f=p.png#rail"
|
|
assert _flag(c, {"back": "view", "f": "gone.png"}).headers["location"] == "/b/g/#item-q.png"
|
|
assert _flag(c, {"back": "Compare", "a": "p.png", "b": "q.png"}).headers["location"] == "/b/g/#item-q.png"
|
|
|
|
|
|
# ---- C2: picking from the review ------------------------------------------------
|
|
|
|
def _compare_href(body: str) -> tuple[str, str]:
|
|
href = re.search(r'<a class="vbtn vcompare"[^>]*href="([^"]+)"', body).group(1)
|
|
u = urlsplit(href.replace("&", "&"))
|
|
assert u.path == "/b/g/compare", href
|
|
q = parse_qs(u.query)
|
|
assert set(q) == {"a", "b"}, href
|
|
return q["a"][0], q["b"][0]
|
|
|
|
|
|
def test_the_review_offers_compare_with_the_next_item(tmp_path):
|
|
"""The review's Compare control opens this item against the NEXT media item
|
|
in the ring — skipping the doc — and the last wraps to the first. A ring of
|
|
one compares the item with itself."""
|
|
_ring6(tmp_path)
|
|
c = _client(tmp_path)
|
|
assert _compare_href(c.get("/b/g/view?f=02.png").text) == ("02.png", "04.png")
|
|
assert _compare_href(c.get("/b/g/view?f=07.png").text) == ("07.png", "01.png")
|
|
_booth(tmp_path, "one", {"only.png": PNG, "n.md": b"# n"})
|
|
body = c.get("/b/one/view?f=only.png").text
|
|
href = re.search(r'<a class="vbtn vcompare"[^>]*href="([^"]+)"', body).group(1)
|
|
assert href.replace("&", "&") == "/b/one/compare?a=only.png&b=only.png"
|
|
# a doc's own page is not a review, and offers no compare
|
|
assert 'class="vbtn vcompare"' not in c.get("/b/g/view?f=03-notes.md").text
|