Compare's stages load their pictures through the catch-all file route, which
caught only OSError around resolve(); an embedded NUL raises ValueError. Same
class as resolve_booth's fix in f8d136a (heid bug hunt on the race fix,
hulda). The upload route's NUL-in-filename 500 is the same class and is left
to booth-dev: it is not on compare's path.
427 lines
20 KiB
Python
427 lines
20 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_nul_in_a_file_path_is_404_not_500(tmp_path):
|
|
"""Compare's stages load their pictures through the raw file route. A NUL
|
|
in that path segment raises ValueError from resolve(), which is not an
|
|
OSError: still a 404 (heid bug hunt on the race fix, hulda)."""
|
|
_four(tmp_path)
|
|
c = _client(tmp_path)
|
|
for path in ("/b/g/p%00.png", "/b/g/p.png%00?thumb=1", "/b/g/sub%00/p.png?dl=1"):
|
|
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 _vanish_after_scan(monkeypatch, name: str, grace: int) -> None:
|
|
"""Make `name` stop being a file partway through a request: once
|
|
booth_items has scanned the booth, the first `grace` is_file checks of it
|
|
still pass and every later one fails — a file deleted or relinked outside
|
|
the booth mid-request, between two resolves of the same rel."""
|
|
import pathlib as _pl
|
|
import booth.app as app_mod
|
|
state = {"armed": False, "calls": 0}
|
|
real_items, real_is_file = app_mod.booth_items, _pl.Path.is_file
|
|
|
|
def items(booth):
|
|
out = real_items(booth)
|
|
state["armed"] = True
|
|
return out
|
|
|
|
def is_file(self):
|
|
if state["armed"] and self.name == name:
|
|
state["calls"] += 1
|
|
if state["calls"] > grace:
|
|
return False
|
|
return real_is_file(self)
|
|
|
|
monkeypatch.setattr(app_mod, "booth_items", items)
|
|
monkeypatch.setattr(_pl.Path, "is_file", is_file)
|
|
|
|
|
|
def test_a_side_that_vanishes_mid_request_never_500s(tmp_path, monkeypatch):
|
|
"""booth-dev's race: each rel must be judged ONCE per request. A side that
|
|
passes its check and then vanishes before a second resolve must not reach
|
|
a `.index()` that raises — a damaged file costs its own tile, never the
|
|
page."""
|
|
_booth(tmp_path, "g", {"p.png": PNG, "q.png": PNG, "r.png": PNG})
|
|
_vanish_after_scan(monkeypatch, "p.png", grace=1)
|
|
r = _client(tmp_path).get("/b/g/compare?a=p.png&b=q.png")
|
|
assert r.status_code in (200, 404), r.status_code
|
|
|
|
|
|
def test_the_review_hides_compare_when_its_item_vanishes_mid_request(tmp_path, monkeypatch):
|
|
"""The review checked its item, then the item vanished before the compare
|
|
ring was built: the page still renders, without a Compare control (a
|
|
compare of it would 404) — never a 500."""
|
|
_booth(tmp_path, "g", {"p.png": PNG, "q.png": PNG})
|
|
_vanish_after_scan(monkeypatch, "p.png", grace=0)
|
|
r = _client(tmp_path).get("/b/g/view?f=p.png")
|
|
assert r.status_code == 200, r.status_code
|
|
assert 'class="vbtn vcompare"' not in r.text
|
|
|
|
|
|
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
|