"""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'', body, re.S).group(0) out = {} for f in re.findall(r']*>.*?', film, re.S): rel = re.search(r'data-rel="([^"]*)"', f).group(1) out[rel] = "".join(re.findall(r'([AB]+)', 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".*?', body, re.S).group(0) label_b = re.search(r'data-region="label-b".*?', 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": ""} 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" c = _client(tmp_path) assert c.get("/b/g/compare?a=zz-link.png&b=p.png").status_code == 404 assert c.get("/b/g/compare?a=p.png&b=zz-link.png").status_code == 404 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 , 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'', 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" # ---- 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']*data-step="%s"[^>]*href="([^"]+)"' % which, body) href = href or re.search(r']*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=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']*(?: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' tuple[str, str]: href = re.search(r']*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']*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