feat(r3): compare — two picked rels side by side, linked stepping, synced pan, flag the winner

GET /b/{name}/compare with the conjunction 404 (containment AND the review
ring), both sides recorded as seen, view state (side, link) mapped from a
closed set onto every link, side-keyed regions, and back=compare in
_mark_redirect. compare.html: two stages sharing one set of rows, the strip
as picker (the side active now), linked and per-side stepping, X/L/Z/A/B/C
keys under the review's guards, synced pan by fraction with an echo guard,
per-side blur reveals, JS-off parity.

The stage machinery moves out of view.html into _stage_js.html
(BoothMode.bind, BoothStage.attach), shared by the review and compare. The
review gains a Compare control and a C key. At phone width a full top bar
wraps.

Tables: r2c's 15 stage rows re-pointed to _stage_js.html; r2b's phone
top-bar row re-anchored (the wrap made it vacuous alone); new r3.toml. The
contract records the wrap, equal stages and C on the compare page.
This commit is contained in:
vh
2026-09-24 13:26:11 -07:00
parent 5d785fe3c4
commit 8c7fe77841
11 changed files with 1841 additions and 111 deletions
+129
View File
@@ -1538,6 +1538,26 @@ def create_app(
return RedirectResponse(
url=f"/b/{quote(name, safe='')}/view?f={quote(f, safe='/')}#rail",
status_code=303)
elif form.get("back") == "compare":
# R3 C5: a flag made in compare lands back on the same PAIR — only
# when both sides are media of this booth, quoted as the view branch
# quotes. The view state is MAPPED from a closed set (`side` exactly
# `a`, `link` exactly `0`, in that order), never echoed. No fragment:
# the flags sit beside the stages, so there is nothing to scroll to.
a, b = form.get("a"), form.get("b")
if isinstance(a, str) and a and isinstance(b, str) and b:
try:
ring = review_chain(booth_items(resolve_booth(name)))
except HTTPException:
ring = []
if a in ring and b in ring:
url = (f"/b/{quote(name, safe='')}/compare"
f"?a={quote(a, safe='/')}&b={quote(b, safe='/')}")
if form.get("side") == "a":
url += "&side=a"
if form.get("link") == "0":
url += "&link=0"
return RedirectResponse(url=url, status_code=303)
return RedirectResponse(url=f"{base}#{anchor}", status_code=303)
def _mark_done(request: Request, name: str, form, anchor: str) -> Response:
@@ -1965,6 +1985,10 @@ def create_app(
"is_last": pos == len(ring) - 1,
"tray": [x for x in film if x["flagged"]],
"back_url": f"/b/{quote(name, safe='')}/#item-{item.url}",
# R3 C2: this item against the NEXT in the ring (itself in a
# ring of one), keyed by rel like every compare URL
"compare_url": (f"/b/{quote(name, safe='')}/compare?a={quote(f, safe='/')}"
f"&b={quote(ring[(pos + 1) % len(ring)], safe='/')}"),
})
# .md renders, .txt/.log show as text — viewable in-booth, no download
@@ -1983,6 +2007,111 @@ def create_app(
url=f"/b/{quote(name, safe='')}/{quote(f, safe='/')}", status_code=307
)
def _compare_side(booth: Path, ring: list[str], rel) -> str:
"""One side of a compare, or a 404 (R3 C1). A CONJUNCTION: the view
route's resolve / containment / is_file check, AND membership of the
review ring. The ring alone is not enough — `booth_items` follows
symlinks, so a link pointing outside the booth is IN the ring and only
containment refuses it. The view's check alone is not enough — it
renders a doc, and compare takes media only."""
if not isinstance(rel, str) or not rel:
raise HTTPException(status_code=404, detail="no such item")
try:
target = (booth / rel).resolve()
except (OSError, ValueError):
# ValueError: an embedded NUL — hostile input, a 404, never a 500
raise HTTPException(status_code=404, detail="no such item")
if not str(target).startswith(str(booth) + os.sep) or not target.is_file():
raise HTTPException(status_code=404, detail="no such item")
if rel not in ring:
raise HTTPException(status_code=404, detail="no such item")
return rel
# Registered BEFORE the catch-all file route, as view is. Accepted: a booth
# FILE literally named `compare` is unreachable at /b/<name>/compare — the
# same shadowing view, marks, asks and embed.json already cause.
@app.get("/b/{name}/compare", response_class=HTMLResponse)
def booth_compare(request: Request, name: str, a: str = "", b: str = "",
side: str = "", link: str = ""):
"""Two items of the review ring side by side (R3), for judging which is
better and flagging the winner.
The PAIR is two rels in the URL, never ordinals (INV-1): an ordinal is a
position in the set as it is now, and a file added mid-bakeoff would
turn a bookmarked compare into two different pictures. `side` and
`link` are VIEW STATE riding the URL so that every step, a full page
load, keeps them. They are `str`, not an int or a Literal, so an
unknown value reads as the default and never as a 422.
The filmstrip is the review ring in RING ORDER (the item order filtered
to media — the review's `film`).
"""
booth = resolve_booth(name)
items = booth_items(booth)
ring = review_chain(items)
a = _compare_side(booth, ring, a)
b = _compare_side(booth, ring, b)
# A look records both — below the 404s, so only a real pair counts.
record_view(booth)
record_seen(booth, a, items)
record_seen(booth, b, items)
side_a = side == "a"
linked = link != "0"
name_url = quote(name, safe="")
by_rel = {it.rel: it for it in items}
flagged_rels = flagged_targets(marks_for(booth))
n = len(ring)
ia, ib = ring.index(a), ring.index(b)
def url(x: str, y: str) -> str:
"""A compare URL for the pair (x, y), carrying the view state."""
u = f"/b/{name_url}/compare?a={quote(x, safe='/')}&b={quote(y, safe='/')}"
if side_a:
u += "&side=a"
if not linked:
u += "&link=0"
return u
def facts(rel: str) -> dict:
it = by_rel[rel]
return {"rel": rel, "url": it.url, "ordinal": it.ordinal,
"kind": it.kind, "caption": it.caption,
"blurred": it.blurred, "flagged": rel in flagged_rels,
"thumb": it.thumb,
"review": f"/b/{name_url}/view?f={it.url}"}
film = [{"name": r, "url": by_rel[r].url, "thumb": by_rel[r].thumb,
"ordinal": by_rel[r].ordinal, "kind": by_rel[r].kind,
"blurred": by_rel[r].blurred, "flagged": r in flagged_rels,
"is_a": r == a, "is_b": r == b,
# the link a frame is without JS: it replaces the ACTIVE side
"pick": url(r, b) if side_a else url(a, r),
# ...and with JS, the side active at the click
"pick_a": url(r, b), "pick_b": url(a, r)} for r in ring]
steps = {
"both_prev": url(ring[(ia - 1) % n], ring[(ib - 1) % n]),
"both_next": url(ring[(ia + 1) % n], ring[(ib + 1) % n]),
"a_prev": url(ring[(ia - 1) % n], b),
"a_next": url(ring[(ia + 1) % n], b),
"b_prev": url(a, ring[(ib - 1) % n]),
"b_next": url(a, ring[(ib + 1) % n]),
}
return templates.TemplateResponse(
request, "compare.html", {
**base_ctx,
"name": name,
"name_url": name_url,
"sides": {"a": facts(a), "b": facts(b)},
"active": "a" if side_a else "b",
"linked": linked,
"film": film,
"steps": steps,
"back_url": f"/b/{name_url}/view?f={quote(a, safe='/')}",
"ord_width": len(str(len(items))),
"any_image": any(by_rel[r].kind == "image" for r in (a, b)),
})
@app.get("/b/{name}/{filepath:path}")
def booth_file(name: str, filepath: str, dl: int = 0, thumb: int = 0):
booth = resolve_booth(name)
+122
View File
@@ -0,0 +1,122 @@
{# THE STAGE MACHINERY (r2c, shared by R3). One copy, behind a stated
interface, used by the review (one stage) and compare (two). It DEFINES two
things and binds nothing by itself:
BoothMode.bind({toggle, fit, one, onChange}) page level, once
BoothStage.attach(stageEl, {img, onSettle}) once per stage
A page binds BoothMode ONLY when at least one of its stages is an image (the
review's rule): two videos get no toggle. No key is bound here — the review
gains none, and compare's `Z` is compare's own (r3 C4, INV-6). No
ResizeObserver here either: each page owns its own, so the review's stays in
view.html with its mutation row. #}
<script>
/* The mode is ONE class on <html>, `stage-one` (absent = Fit), set by the
head script before any stage existed. This owns the class, the pressed
state, the storage writes and the cross-tab listener. It never raises:
storage that throws costs the memory, never the click. */
var BoothMode = {
bind: function (o) {
var d = document.documentElement;
var toggle = o.toggle, bFit = o.fit, bOne = o.one;
var onChange = o.onChange || function () {};
var show = function () {
var one = d.classList.contains('stage-one');
bFit.classList.toggle('on', !one);
bOne.classList.toggle('on', one);
bFit.setAttribute('aria-pressed', one ? 'false' : 'true');
bOne.setAttribute('aria-pressed', one ? 'true' : 'false');
};
/* The click applies to the page first and is remembered second: storage
that throws costs the memory, never the click. */
var setMode = function (one) {
d.classList.toggle('stage-one', one);
try {
if (one) localStorage.setItem('booth.fit', 'one'); else localStorage.removeItem('booth.fit');
} catch (e) {}
show();
onChange();
};
toggle.hidden = false;
show();
/* A mode chosen in another tab moves this one (the theme's rule). */
window.addEventListener('storage', function (e) {
if (e.key !== 'booth.fit' && e.key !== null) return;
var one = false;
try { one = localStorage.getItem('booth.fit') === 'one'; } catch (x) {}
d.classList.toggle('stage-one', one);
show();
onChange();
});
bFit.addEventListener('click', function () { setMode(false); });
bOne.addEventListener('click', function () { setMode(true); });
return {
setMode: setMode,
flip: function () { setMode(!d.classList.contains('stage-one')); }
};
}
};
/* One stage: whether its picture can pan, and DRAG TO PAN (r2c S4). Returns
{settle, pannable}; `settle` is the stage's own settle, then the page's
`onSettle` (the review places its arrows there). */
var BoothStage = {
attach: function (stage, o) {
var d = document.documentElement;
var img = o.img || null;
var onSettle = o.onSettle || function () {};
function pannable() {
var can = !!img && d.classList.contains('stage-one') &&
(stage.scrollWidth > stage.clientWidth || stage.scrollHeight > stage.clientHeight);
stage.classList.toggle('can-pan', can);
return can;
}
function settle() { pannable(); onSettle(); }
if (!img) return {settle: settle, pannable: pannable};
img.addEventListener('load', settle);
if (img.complete) settle();
/* The picture follows the pointer, a press that moves under 4px is not a
drag, and a press on a control inside the stage keeps its click. The
picture cannot be dragged away. */
stage.addEventListener('dragstart', function (e) { e.preventDefault(); });
var drag = null;
stage.addEventListener('pointerdown', function (e) {
if (e.button !== 0 || !pannable()) return;
/* a press on the stage's own scrollbar is the scrollbar's, not a pan
(heid bug-hunt, groa: the pan fought the thumb, backwards) */
var r = stage.getBoundingClientRect();
if (e.clientX - r.left - stage.clientLeft >= stage.clientWidth ||
e.clientY - r.top - stage.clientTop >= stage.clientHeight) return;
/* nothing interactive lives in a stage today (its reveal sits over
it); this keeps a future control's click its own */
if (e.target.closest && e.target.closest('button, a, input, textarea, select, summary')) return;
drag = {x: e.clientX, y: e.clientY, l: stage.scrollLeft, t: stage.scrollTop, on: false, id: e.pointerId};
});
stage.addEventListener('pointermove', function (e) {
if (!drag || e.pointerId !== drag.id) return;
/* No button held: the press ended where the stage could not hear it
(released outside before the drag began). Never pan on a hover. */
if (!(e.buttons & 1)) { endDrag(); return; }
var dx = e.clientX - drag.x, dy = e.clientY - drag.y;
if (!drag.on) {
if (dx * dx + dy * dy < 16) return; /* under 4px in all: a click */
drag.on = true;
stage.classList.add('is-grabbing');
try { stage.setPointerCapture(drag.id); } catch (x) {}
}
stage.scrollLeft = drag.l - dx;
stage.scrollTop = drag.t - dy;
e.preventDefault();
});
function endDrag() {
if (drag && drag.on) stage.classList.remove('is-grabbing');
drag = null;
}
stage.addEventListener('pointerup', endDrag);
stage.addEventListener('pointercancel', endDrag);
return {settle: settle, pannable: pannable};
}
};
</script>
+74 -2
View File
@@ -618,7 +618,8 @@
pseudo-element, so no markup is added. They sit 4px INSIDE the box: an
overflow:hidden tile clips an outside bracket's entire stroke (the SVOS
foot-gun). A hairline shadow keeps them visible over a bright image. */
.item.is-cursor::after,.item:target::after,.mark-opt:has(input:checked)::after,.film-f.is-current::after{content:"";position:absolute;inset:4px;z-index:3;
.item.is-cursor::after,.item:target::after,.mark-opt:has(input:checked)::after,.film-f.is-current::after,
.cmp-side.is-active>.cmp-stagewrap::after,.film-f.is-active::after{content:"";position:absolute;inset:4px;z-index:3;
pointer-events:none;--rl:16px;--rt:2px;
background:
linear-gradient(var(--accent) 0 0) top left / var(--rl) var(--rt),
@@ -631,7 +632,7 @@
linear-gradient(var(--accent) 0 0) bottom right / var(--rt) var(--rl);
background-repeat:no-repeat;filter:drop-shadow(0 0 1px oklch(0.14 0.01 250 / .7))}
.mark-opt:has(input:checked)::after{inset:3px;--rl:10px;--rt:1.5px;filter:none}
.film-f.is-current::after{inset:2px;--rl:9px;--rt:1.5px}
.film-f.is-current::after,.film-f.is-active::after{inset:2px;--rl:9px;--rt:1.5px}
.item.is-cursor{border-color:color-mix(in oklab,var(--accent) 60%,transparent)}
/* ⚠ Blur is COSMETIC. The file is still served at its own URL and still in
@@ -678,6 +679,13 @@
tail; the title still says it, as does every per-item reveal. */
.blur-all button,.reveal-all-btn{white-space:nowrap}
@media (max-width:600px){.reveal-all-btn .ra-note{display:none}}
/* R3: the review's Compare control keeps only its glyph at phone width; its
title still says what it does (and C does it). */
@media (max-width:600px){.vcompare-l{display:none}}
/* ...and at phone width a top bar that cannot hold its controls WRAPS rather
than scrolling the page sideways. The review's bar was full before R3 (a
fogged booth overflowed it by 3px at 390); compare's bar holds more. */
@media (max-width:600px){.vbar{flex-wrap:wrap;row-gap:6px}}
.badge-blur{color:var(--text-muted)}
/* ---- inline docs ------------------------------------------------------
@@ -842,6 +850,70 @@
.vnext{right:0}
}
/* ---- COMPARE (R3) -------------------------------------------------------
Two stages, one judgment each. The root is `.viewer.review.compare`, so
the review's blur, 1:1 and Reveal-all rules apply unchanged (they are
scoped to `.review`); this overrides the review's four-row grid with bar,
the two sides, the key line and the filmstrip. There is no rail column:
each side carries its own flag under its stage. */
.viewer.review.compare{grid-template-rows:auto minmax(0,1fr) auto auto}
/* The sides share ONE set of rows (subgrid): a caption under A takes its
height from both stages alike, never from A's alone — two stages of
different sizes would draw the same picture at two scales in Fit. */
.cmp-body{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);
grid-template-rows:auto minmax(0,1fr) auto;min-height:0}
.cmp-side{display:grid;grid-row:1 / -1;grid-template-rows:subgrid;min-width:0;min-height:0}
.cmp-side+.cmp-side{border-left:1px solid var(--border-subtle)}
.cmp-head{display:flex;align-items:center;gap:8px;min-width:0;padding:8px 12px;
border-bottom:1px solid var(--border-subtle);background:var(--surface-base)}
.cmp-label{flex:1;min-width:0;font-family:var(--font-mono);font-size:var(--size-caption);color:var(--text-body);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.cmp-letter{display:inline-block;min-width:1.5em;text-align:center;font-weight:700;color:var(--text-heading);
border:1px solid var(--border-strong);border-radius:var(--radius-sm)}
.cmp-side.is-active .cmp-letter{border-color:var(--accent);color:var(--accent-text)}
.cmp-label .ord{font-weight:600;color:var(--text-heading)}
.cmp-flagged{color:var(--success-text);font-weight:600}
.cmp-head .cmp-step{display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;width:28px;height:28px;
border:1px solid var(--border-default);border-radius:var(--radius-md);color:var(--text-body);font-size:18px;line-height:1;
text-decoration:none}
.cmp-head .cmp-step:hover{border-color:var(--border-strong);background:var(--surface-overlay);text-decoration:none}
.cmp-review{flex:0 0 auto;font-family:var(--font-mono);font-size:var(--size-micro);white-space:nowrap}
/* The stage fills its side. The wrapper, never the stage, holds what sits
OVER the stage (the reveal, the reticle), so a 1:1 pan cannot carry them. */
.cmp-stagewrap{position:relative;display:flex;min-height:0;min-width:0}
.cmp-stagewrap>.vstage{flex:1;min-width:0}
.review.compare .vstage video{max-width:100%;max-height:100%}
.cmp-reveal{position:absolute;top:10px;left:10px;z-index:5;cursor:pointer;font-family:var(--font-mono);
font-size:var(--size-micro);padding:6px 9px;border-radius:var(--radius-md);border:1px solid rgb(255 255 255 / .16);
background:oklch(0.17 0.01 250 / .86);color:oklch(0.91 0.008 216)}
.cmp-reveal[hidden]{display:none}
/* Reveal all stands the per-side reveals down BY STYLESHEET, as it does the
review's #vreveal (r3 C6). */
.reveal-all .cmp-reveal{display:none}
.cmp-foot{display:flex;flex-direction:column;gap:6px;padding:8px 12px;border-top:1px solid var(--border-subtle);
background:var(--surface-card)}
.cmp-foot .vflag-btn{width:100%;height:36px;justify-content:center;gap:6px;font-family:var(--font-sans);font-weight:600}
/* each side's caption: the review's .vcap, clamped to 20vh with its own scroll */
.cmp-cap{max-height:20vh;overflow-y:auto;font-size:var(--size-sm);line-height:var(--leading-body);
color:var(--text-body);white-space:pre-wrap}
.cmp-keys{padding:6px 14px;font-family:var(--font-mono);font-size:var(--size-micro);color:var(--text-muted);
border-top:1px solid var(--border-subtle);background:var(--surface-base)}
.cmp-link[aria-pressed="false"]{color:var(--text-muted);border-style:dashed}
.cmp-link[hidden]{display:none} /* .vbtn's inline-flex beats the UA's [hidden] */
/* The strip marks which frame is on which side; the ACTIVE side's frame
wears the reticle (with the active stage, via the list above). */
.film-f.is-a,.film-f.is-b{opacity:1;border-color:var(--border-strong)}
.film-ab{position:absolute;right:3px;bottom:3px;padding:1px 5px;border-radius:var(--radius-sm);
font:700 9.5px/1.2 var(--font-mono);background:var(--accent);color:var(--accent-contrast)}
@media (max-width:900px){
.viewer.review.compare{display:block}
.cmp-body{grid-template-columns:minmax(0,1fr);grid-template-rows:none}
.cmp-side{grid-row:auto;grid-template-rows:auto auto auto}
.cmp-side+.cmp-side{border-left:0;border-top:1px solid var(--border-subtle)}
.review.compare .vstage{height:45vh}
.cmp-keys{display:none}
}
/* ---- the standing link board ------------------------------------------
Rows, not a markdown blob. Dense enough that thirty entries stay
scannable; provenance recedes so the description leads, and the × only
+270
View File
@@ -0,0 +1,270 @@
{% extends "base.html" %}
{% block title %}{{ sides.a.rel }} · {{ sides.b.rel }} · compare · {{ name }} · The Booth{% endblock %}
{# data-booth: without it Reveal all's script and the head script's reveal
restore both bail (r3 C6). #}
{% block html_attrs %} data-booth="{{ name }}"{% endblock %}
{# COMPARE (R3). Two items of the review ring side by side, one judgment each:
flag the winner. The pair is two rels in the URL, always (INV-1); the active
side and the linked stepping ride the URL as view state. Everything a mark
can change is a `data-region` keyed by SIDE, never by rel (`a == b` would
duplicate it) and never `item-` (the swap reads that as a stale tile). THE
STAGES NEVER ARE: swapping one would restart a playing track.
The root carries `review` so the review's blur, 1:1 and Reveal-all rules
apply unchanged; `.compare` overrides its grid. #}
{% macro num(n) -%}#{{ "%0*d"|format(ord_width, n) }}{%- endmacro %}
{% block content %}
<div class="viewer review compare" data-linked="{{ '1' if linked else '0' }}">
<div class="vbar">
<a class="vbtn vx" href="{{ back_url }}" title="back to the review of A (Esc)">✕</a>
<span class="vname">compare <span class="cmp-vs"><span class="ord">{{ num(sides.a.ordinal) }}</span> · <span class="ord">{{ num(sides.b.ordinal) }}</span></span></span>
<span class="vspacer"></span>
<a class="vbtn cmp-step" data-step="both-prev" href="{{ steps.both_prev }}" title="both back (←)" aria-label="both back">‹‹</a>
<a class="vbtn cmp-step" data-step="both-next" href="{{ steps.both_next }}" title="both forward (→)" aria-label="both forward">››</a>
{# JS-only, like the stage toggle: without JS there are no keys to link,
and the per-side and both-sides links above step either way. #}
<button type="button" class="vbtn cmp-link" id="cmp-link" aria-pressed="{{ 'true' if linked else 'false' }}" hidden
title="linked: ← and → move both sides (L)">{{ '⛓ linked' if linked else '⛓ unlinked' }}</button>
{% if any_image %}
<span class="vtoggle" id="vtoggle" hidden>
<button type="button" class="vseg on" id="btn-fit" aria-pressed="true" title="the whole picture, as large as the stage allows (Z)">Fit</button><button type="button" class="vseg" id="btn-one" aria-pressed="false" title="natural pixels — drag to pan; both stages pan together (Z)">1:1</button>
</span>
{% endif %}
{% if film | selectattr('blurred') | list %}<button type="button" class="reveal-all-btn" data-reveal-all hidden title="blur is cosmetic — the files are still served"><span class="ra-label">👁 reveal all</span><span class="ra-note"> — blur is cosmetic</span></button>{% endif %}
</div>
<div class="cmp-body">
{% for key in ('a', 'b') %}{% set s = sides[key] %}{% set L = key | upper %}
<section class="cmp-side{% if active == key %} is-active{% endif %}" data-side="{{ key }}" aria-label="side {{ L }}">
<div class="cmp-head">
<a class="cmp-step" data-step="{{ key }}-prev" href="{{ steps[key ~ '_prev'] }}" title="{{ L }} back" aria-label="{{ L }} back">‹</a>
<div class="cmp-label" data-region="label-{{ key }}"><span class="cmp-letter">{{ L }}</span> <span class="ord">{{ num(s.ordinal) }}</span> <span class="cmp-name" title="{{ s.rel }}">{{ s.rel }}</span>{% if s.flagged %} <span class="cmp-flagged">✔ flagged</span>{% endif %}</div>
<a class="cmp-step" data-step="{{ key }}-next" href="{{ steps[key ~ '_next'] }}" title="{{ L }} forward" aria-label="{{ L }} forward">›</a>
<a class="cmp-review" href="{{ s.review }}" title="the full review of {{ s.rel }}">review {{ L }}</a>
</div>
<div class="cmp-stagewrap">
<div class="vstage{% if s.kind == 'image' %} is-img{% endif %}{% if s.blurred %} is-blurred{% endif %}" data-side="{{ key }}">
{% if s.kind == 'image' %}<img src="{{ s.url }}" alt="{{ s.rel }}" draggable="false">
{% elif s.kind == 'video' %}<video class="cmp-media" controls preload="metadata" src="{{ s.url }}"></video>
{% else %}<audio class="cmp-media" controls preload="metadata" src="{{ s.url }}"></audio>
{% endif %}
</div>
{# Over the stage, never inside its scrolled content; JS-only, so
`hidden` until bound (the review's pattern). #}
{% if s.blurred %}<button type="button" class="reveal cmp-reveal" data-side="{{ key }}" aria-label="reveal {{ L }}" hidden>👁 reveal — blur is cosmetic</button>{% endif %}
</div>
<div class="cmp-foot">
<div class="cmp-flag" data-region="flag-{{ key }}">
<form class="vflag" method="post" action="/b/{{ name_url }}/flag" data-inplace>
<input type="hidden" name="target" value="{{ s.rel }}">
<input type="hidden" name="on" value="{{ '0' if s.flagged else '1' }}">
<input type="hidden" name="back" value="compare">
<input type="hidden" name="a" value="{{ sides.a.rel }}">
<input type="hidden" name="b" value="{{ sides.b.rel }}">
<input type="hidden" name="side" value="{{ active }}">
<input type="hidden" name="link" value="{{ '1' if linked else '0' }}">
<button class="vbtn vflag-btn{% if s.flagged %} is-flagged{% endif %}" id="cmp-flag-{{ key }}"
title="{{ 'un-flag' if s.flagged else 'flag' }} {{ L }} ({{ L }})">{{ '✔ flagged' if s.flagged else '○ flag' }} {{ L }} <kbd>{{ L }}</kbd></button>
</form>
</div>
{% if s.caption %}<div class="cmp-cap">{{ s.caption }}</div>{% endif %}
</div>
</section>
{% endfor %}
</div>
<div class="cmp-keys"><kbd>←</kbd> <kbd>→</kbd> <kbd>Space</kbd> step · <kbd>A</kbd> <kbd>B</kbd> flag · <kbd>X</kbd> side · <kbd>L</kbd> link · <kbd>Z</kbd> Fit/1:1 · <kbd>Esc</kbd> review</div>
{# THE FILMSTRIP IS THE PICKER: the review ring in ring order. Without JS a
frame is a link that replaces the active side from the URL (B by
default); with JS a click replaces the side that is active NOW. #}
<nav class="film" data-region="film" aria-label="pick from the set">
{% for x in film %}
<a class="film-f{% if x.flagged %} is-flagged{% endif %}{% if x.blurred %} is-blurred{% endif %}{% if x.is_a %} is-a{% endif %}{% if x.is_b %} is-b{% endif %}{% if (active == 'a' and x.is_a) or (active == 'b' and x.is_b) %} is-active{% endif %}"
href="{{ x.pick }}" data-rel="{{ x.name }}" data-pick-a="{{ x.pick_a }}" data-pick-b="{{ x.pick_b }}" title="{{ x.name }}">
{%- if x.kind == 'image' %}<img loading="lazy" decoding="async" src="{{ x.thumb or x.url }}" alt="">{% else %}<span class="film-kind">{{ '♪' if x.kind == 'audio' else '▶' }}</span>{% endif -%}
<span class="film-ord">{{ num(x.ordinal) }}</span>
{%- if x.is_a or x.is_b %}<span class="film-ab">{{ 'A' if x.is_a }}{{ 'B' if x.is_b }}</span>{% endif -%}
</a>
{% endfor %}
</nav>
</div>
{% include "_stage_js.html" %}
<script>
(function () {
var BACK = {{ back_url|tojson }};
var d = document.documentElement;
var root = document.querySelector('.viewer.compare');
var sides = {a: root.querySelector('.cmp-side[data-side="a"]'),
b: root.querySelector('.cmp-side[data-side="b"]')};
/* THE VIEW STATE (r3 C2): the active side and the linked stepping, read
from the server's render of THIS URL, and written back into the URL in
place whenever they change, so every step — a full page load — keeps
them. Kept on the sides (never on a region: a save swaps regions). */
var active = sides.a.classList.contains('is-active') ? 'a' : 'b';
var linked = root.getAttribute('data-linked') !== '0';
/* A compare href with THIS page's view state: `side` and `link` dropped
and re-added from the closed set, the pair's own params untouched (their
encoding is the server's, never re-serialised here). */
function withState(href) {
var i = href.indexOf('?');
if (i < 0) return href;
var parts = href.slice(i + 1).split('#')[0].split('&').filter(function (p) {
return p && !/^(side|link)(=|$)/.test(p);
});
if (active === 'a') parts.push('side=a');
if (!linked) parts.push('link=0');
return href.slice(0, i) + '?' + parts.join('&');
}
function go(href) { window.location.href = href; }
/* Every server-built link, the URL and the flag forms' landing fields
follow the state; the strip's markers follow the active side. Run on
every change of state and after a save swaps the regions. */
function restate() {
['a', 'b'].forEach(function (k) { sides[k].classList.toggle('is-active', k === active); });
document.querySelectorAll('.film-f').forEach(function (f) {
f.classList.toggle('is-active', f.classList.contains('is-' + active));
var pick = f.getAttribute('data-pick-' + active);
if (pick) f.setAttribute('href', withState(pick));
});
document.querySelectorAll('a[data-step]').forEach(function (a) {
a.setAttribute('href', withState(a.getAttribute('href')));
});
document.querySelectorAll('.cmp-flag form').forEach(function (f) {
var sd = f.querySelector('input[name="side"]'), ln = f.querySelector('input[name="link"]');
if (sd) sd.value = active;
if (ln) ln.value = linked ? '1' : '0';
});
root.setAttribute('data-linked', linked ? '1' : '0');
try { history.replaceState(history.state, '', withState(location.pathname + location.search)); } catch (e) {}
}
function setActive(k) { if (k !== active) { active = k; restate(); } }
/* THE LINKED TOGGLE (C3): JS-only, because without JS there are no keys to
link; its state is the URL's `link`, and nothing else remembers it. */
var lbtn = document.getElementById('cmp-link');
function showLinked() {
lbtn.setAttribute('aria-pressed', linked ? 'true' : 'false');
lbtn.textContent = linked ? '⛓ linked' : '⛓ unlinked';
}
function setLinked(on) { linked = on; showLinked(); restate(); }
lbtn.hidden = false;
showLinked();
lbtn.addEventListener('click', function () { setLinked(!linked); });
/* THE STAGES (C4): the shared machinery, attached once per stage; one mode
for both, bound once, and only when a side is a picture. This page owns
its own ResizeObserver, because `pannable` changes on resize. */
var stages = ['a', 'b'].map(function (k) {
var el = sides[k].querySelector('.vstage');
return {k: k, el: el, st: BoothStage.attach(el, {img: el.querySelector('img')})};
});
function settleAll() { stages.forEach(function (s) { s.st.settle(); }); }
var mode = null, toggle = document.getElementById('vtoggle');
if (toggle) mode = BoothMode.bind({
toggle: toggle,
fit: document.getElementById('btn-fit'),
one: document.getElementById('btn-one'),
onChange: settleAll
});
if (window.ResizeObserver) {
var ro = new ResizeObserver(settleAll);
stages.forEach(function (s) { ro.observe(s.el); });
} else window.addEventListener('resize', settleAll);
/* A press on a stage makes its side the active one. */
stages.forEach(function (s) {
s.el.addEventListener('pointerdown', function () { setActive(s.k); });
});
/* SYNCED PAN (C4). In 1:1 a scroll of either stage — a drag, a scrollbar,
a wheel — puts the other at the SAME FRACTION of its own scrollable
range, per axis; an axis with nothing to scroll on either side is left
alone. A scroll the sync caused is recognised by where it landed and is
never synced back, so there is no loop and no drift. */
function sync(from, to) {
var fx = from.scrollWidth - from.clientWidth, fy = from.scrollHeight - from.clientHeight;
var tx = to.scrollWidth - to.clientWidth, ty = to.scrollHeight - to.clientHeight;
var l = to.scrollLeft, t = to.scrollTop;
if (fx > 0 && tx > 0) l = from.scrollLeft / fx * tx;
if (fy > 0 && ty > 0) t = from.scrollTop / fy * ty;
var was = [to.scrollLeft, to.scrollTop];
to.scrollTo(l, t);
if (to.scrollLeft !== was[0] || to.scrollTop !== was[1]) to.__synced = {l: to.scrollLeft, t: to.scrollTop};
}
stages.forEach(function (s, i) {
var other = stages[1 - i].el;
s.el.addEventListener('scroll', function () {
var mine = s.el.__synced;
if (mine) {
s.el.__synced = null;
if (Math.abs(s.el.scrollLeft - mine.l) < 1 && Math.abs(s.el.scrollTop - mine.t) < 1) return;
}
if (!d.classList.contains('stage-one') || other === s.el) return;
sync(s.el, other);
}, {passive: true});
});
/* Blur reveal per side — per-viewer, never persisted; cosmetic, and the
button says so. Over the stage, never in its scrolled content. */
root.querySelectorAll('.cmp-reveal').forEach(function (btn) {
var stage = sides[btn.getAttribute('data-side')].querySelector('.vstage');
btn.hidden = false;
btn.addEventListener('click', function () {
var on = stage.classList.toggle('revealed');
btn.textContent = on ? '🙈 hide' : '👁 reveal — blur is cosmetic';
});
});
/* THE PICKER (C2): a click on a frame replaces the side that is active
NOW. Delegated at the document, because a save replaces the frames. A
modified click keeps the browser's own meaning (a new tab), with the
href restate() keeps current. */
document.addEventListener('click', function (e) {
var f = e.target.closest && e.target.closest('.film-f');
if (!f || e.defaultPrevented || e.button !== 0) return;
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
var pick = f.getAttribute('data-pick-' + active);
if (!pick) return;
e.preventDefault();
go(withState(pick));
});
document.addEventListener('booth:swapped', restate);
/* THE KEYS (C3). EVERY key here is ignored while focus is in something
editable and whenever Ctrl, Meta or Alt is held (the review's rule,
applied to all of them). */
function isEditable(el) {
return !!(el && (el.isContentEditable ||
/^(input|textarea|select)$/i.test(el.tagName || '')));
}
function step(dir) {
var which = (linked ? 'both' : active) + (dir < 0 ? '-prev' : '-next');
var a = document.querySelector('a[data-step="' + which + '"]');
if (a) go(withState(a.getAttribute('href')));
}
document.addEventListener('keydown', function (e) {
if (isEditable(e.target)) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
var k = e.key;
if (k === 'Escape' || k === 'c' || k === 'C') go(BACK);
else if (k === 'ArrowLeft') step(-1);
else if (k === 'ArrowRight') step(1);
/* Space steps only from nowhere in particular: never from a focused
control (Space presses it) and never from a player on either stage. */
else if (k === ' ') {
if (e.target.closest && e.target.closest('button, a, summary, video, audio')) return;
e.preventDefault();
step(e.shiftKey ? -1 : 1);
}
else if (k === 'a' || k === 'A' || k === 'b' || k === 'B') {
/* looked up at press time: a save may have replaced the button */
var btn = document.getElementById('cmp-flag-' + k.toLowerCase());
if (btn) { e.preventDefault(); btn.click(); }
}
else if (k === 'x' || k === 'X') setActive(active === 'a' ? 'b' : 'a');
else if (k === 'l' || k === 'L') setLinked(!linked);
else if ((k === 'z' || k === 'Z') && mode) mode.flip();
});
})();
</script>
{% endblock %}
+19 -89
View File
@@ -13,6 +13,8 @@
<a class="vbtn vx" href="{{ back_url }}" title="back to the grid (Esc)">✕</a>
<span class="vname"><span class="ord">{{ num(ordinal) }}</span> {{ file }}</span>
<span class="vspacer"></span>
{# R3: this item against the next one in the ring, side by side (C). #}
<a class="vbtn vcompare" href="{{ compare_url }}" title="compare with the next item (C)"><span aria-hidden="true">⇆</span><span class="vcompare-l"> compare</span></a>
{% if kind == 'image' %}
{# A JS-only VIEWING convenience (INV-3): hidden until the script shows it,
and only ever rendered for a picture. With scripts off the image shows at
@@ -128,7 +130,7 @@
</div>
{% endif %}
<div class="vr-keys"><kbd>←</kbd> <kbd>→</kbd> <kbd>Space</kbd> move · <kbd>F</kbd> flag · <kbd>N</kbd> note · <kbd>Esc</kbd> grid</div>
<div class="vr-keys"><kbd>←</kbd> <kbd>→</kbd> <kbd>Space</kbd> move · <kbd>F</kbd> flag · <kbd>N</kbd> note · <kbd>C</kbd> compare · <kbd>Esc</kbd> grid</div>
</aside>
</div>
@@ -167,16 +169,18 @@
color:var(--text-body);white-space:pre-wrap}
@media print{.vcap{max-height:none;overflow:visible}.vnav{display:none}}
</style>
{% include "_stage_js.html" %}
<script>
(function () {
var BACK = {{ back_url|tojson }};
var PREV = {{ (('?f=' ~ prev_url) if prev_url else '')|tojson }};
var NEXT = {{ (('?f=' ~ next_url) if next_url else '')|tojson }};
var COMPARE = {{ compare_url|tojson }};
/* THE STAGE (r2c). The mode is ONE class on <html>, `stage-one` (absent =
Fit), set by the head script before the stage existed; this binds the
toggle, places the arrows at the drawn picture, and pans a 1:1 picture. */
var d = document.documentElement;
Fit), set by the head script before the stage existed. The shared
machinery binds the toggle and pans a 1:1 picture; this page places the
arrows at the drawn picture. */
var stage = document.getElementById('vstage');
var img = document.getElementById('vimg');
var video = stage.querySelector('video');
@@ -228,91 +232,16 @@
a.style.top = (s.top - o.top + s.height / 2) + 'px';
});
}
function pannable() {
var can = !!img && d.classList.contains('stage-one') &&
(stage.scrollWidth > stage.clientWidth || stage.scrollHeight > stage.clientHeight);
stage.classList.toggle('can-pan', can);
return can;
}
function settle() { place(); pannable(); }
if (img) {
var toggle = document.getElementById('vtoggle');
var bFit = document.getElementById('btn-fit'), bOne = document.getElementById('btn-one');
var show = function () {
var one = d.classList.contains('stage-one');
bFit.classList.toggle('on', !one);
bOne.classList.toggle('on', one);
bFit.setAttribute('aria-pressed', one ? 'false' : 'true');
bOne.setAttribute('aria-pressed', one ? 'true' : 'false');
};
/* The click applies to the page first and is remembered second: storage
that throws costs the memory, never the click. */
var setMode = function (one) {
d.classList.toggle('stage-one', one);
try {
if (one) localStorage.setItem('booth.fit', 'one'); else localStorage.removeItem('booth.fit');
} catch (e) {}
show();
settle();
};
toggle.hidden = false;
show();
/* A mode chosen in another tab moves this one (the theme's rule). */
window.addEventListener('storage', function (e) {
if (e.key !== 'booth.fit' && e.key !== null) return;
var one = false;
try { one = localStorage.getItem('booth.fit') === 'one'; } catch (x) {}
d.classList.toggle('stage-one', one);
show();
settle();
});
bFit.addEventListener('click', function () { setMode(false); });
bOne.addEventListener('click', function () { setMode(true); });
img.addEventListener('load', settle);
if (img.complete) settle();
/* DRAG TO PAN (r2c S4), on the stage only: the picture follows the
pointer, a press that moves under 4px is not a drag, and a press on a
control inside the stage (its reveal) keeps its click. The picture
cannot be dragged away. */
stage.addEventListener('dragstart', function (e) { e.preventDefault(); });
var drag = null;
stage.addEventListener('pointerdown', function (e) {
if (e.button !== 0 || !pannable()) return;
/* a press on the stage's own scrollbar is the scrollbar's, not a pan
(heid bug-hunt, groa: the pan fought the thumb, backwards) */
var r = stage.getBoundingClientRect();
if (e.clientX - r.left - stage.clientLeft >= stage.clientWidth ||
e.clientY - r.top - stage.clientTop >= stage.clientHeight) return;
/* nothing interactive lives in the stage today (its reveal sits over
it); this keeps a future control's click its own */
if (e.target.closest && e.target.closest('button, a, input, textarea, select, summary')) return;
drag = {x: e.clientX, y: e.clientY, l: stage.scrollLeft, t: stage.scrollTop, on: false, id: e.pointerId};
});
stage.addEventListener('pointermove', function (e) {
if (!drag || e.pointerId !== drag.id) return;
/* No button held: the press ended where the stage could not hear it
(released outside before the drag began). Never pan on a hover. */
if (!(e.buttons & 1)) { endDrag(); return; }
var dx = e.clientX - drag.x, dy = e.clientY - drag.y;
if (!drag.on) {
if (dx * dx + dy * dy < 16) return; /* under 4px in all: a click */
drag.on = true;
stage.classList.add('is-grabbing');
try { stage.setPointerCapture(drag.id); } catch (x) {}
}
stage.scrollLeft = drag.l - dx;
stage.scrollTop = drag.t - dy;
e.preventDefault();
});
function endDrag() {
if (drag && drag.on) stage.classList.remove('is-grabbing');
drag = null;
}
stage.addEventListener('pointerup', endDrag);
stage.addEventListener('pointercancel', endDrag);
}
/* The stage's own machinery — Fit/1:1, pannable, drag-to-pan — is the
shared include (_stage_js.html, r3 C4); the arrows stay this page's. */
var st = BoothStage.attach(stage, {img: img, onSettle: place});
var settle = st.settle;
if (img) BoothMode.bind({
toggle: document.getElementById('vtoggle'),
fit: document.getElementById('btn-fit'),
one: document.getElementById('btn-one'),
onChange: settle
});
if (video) video.addEventListener('loadedmetadata', settle);
if (window.ResizeObserver) new ResizeObserver(settle).observe(stage);
else window.addEventListener('resize', settle);
@@ -360,6 +289,7 @@
var b = document.getElementById('vflag-btn'); /* re-read: the rail may have been swapped */
if (b) { e.preventDefault(); b.click(); }
}
else if (e.key === 'c' || e.key === 'C') { e.preventDefault(); window.location.href = COMPARE; }
else if (e.key === 'n' || e.key === 'N') {
var t = document.getElementById('vnote-text');
if (t) { e.preventDefault(); t.focus(); }
+12
View File
@@ -118,6 +118,8 @@ assumptions:
ignored while focus is in something editable, and whenever a modifier
(Ctrl, Meta, Alt) is held: the review's `isEditable` rule, applied to all of
them, not only to Space.
- `C` and `Esc` both return to the review of A. `C` is the view toggle: `C` in
the review opens compare with that item as A, and `C` again goes back to it.
- Without JS, the page renders plain links for "both back", "both forward",
and each side's back and forward, with server-computed targets.
@@ -127,6 +129,15 @@ assumptions:
half the body and labelled `A #09 <name>` / `B #25 <name>`. At 900px and
below they STACK, A above B, each at most 45vh tall. The stack break is the
review's.
- **The two stages are always the same size.** The sides share one set of rows
(subgrid), so a caption under one side takes its height from both stages,
never from that side's alone. Two stages of different sizes would draw the
same picture at two scales in Fit.
- **At phone width (600px and below) a top bar that cannot hold its controls
WRAPS** instead of scrolling the page sideways or crushing a control. This
applies to the review's bar too, which gains the Compare control (only its
glyph below 600px). The review's bar was already full: a fogged booth
overflowed it by 3px at 390px before r3.
- **Each stage is the r2c stage:** Fit fills (up or down, contain, never
cropped), or 1:1 at natural pixels with every pixel reachable. Drag pans a
1:1 picture that overflows. The picture cannot be dragged away. Video and
@@ -295,6 +306,7 @@ Browser (`tests/test_compare_browser.py`):
| tests/mutations/r2c.toml: 21 rows anchor in view.html's script; the 15 on the toggle, the storage listener and drag-pan | `file = view.html`, anchors in the inline script | `file = _stage_js.html`, anchors re-pointed to the parameterised code (e.g. `stage.scrollLeft` becomes the attached stage's name) | the code moved (C4); every re-pointed row must still FALSIFY |
| tests/mutations/r2c.toml: the other 6 of those 21, the arrow placement, including the resize row ("S3 the arrows do not follow a resize") | view.html | unchanged: `place()` and view's ResizeObserver stay in view.html (C4) | — |
| tests/mutations/r2b.toml, the row on Space from a focused button | view.html's keydown | unchanged: the keydown handler stays in view.html | — |
| tests/mutations/r2b.toml, "the top-bar controls squeeze into multi-line stacks at phone width" (declared during the build) | removes the no-wrap rules | removes the no-wrap rules AND the phone-width wrap | a wrapping bar never squeezes, so removing the no-wrap rules alone went vacuous; r3.toml rows the wrap on its own |
| new: tests/mutations/r3.toml | — | rows for: the side-keyed region ids, the conjunction 404, `back=compare`'s ring check, the linked distance, the synced-pan loop guard, the `data-booth` attribute, the `cmp-reveal` Reveal-all rule | the r3 falsifiers |
**The gate for the refactor is the TABLE, not only the suite:**
+14 -2
View File
@@ -248,11 +248,23 @@ new = '''e.target !== player) {'''
label = "the top-bar controls squeeze into multi-line stacks at phone width"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_the_review_and_doc_top_bars_fit_a_phone"
# r3 re-anchored this row. r3 added a Compare control to the review's bar and
# made a full bar WRAP at phone width (a fogged booth already overflowed it by
# 3px at 390). A wrapping bar never squeezes, so removing the no-wrap rules
# alone went VACUOUS: the property is now held by both. The defeating change is
# losing both; r3.toml rows the wrap on its own.
old = '''
.blur-all button,.reveal-all-btn{white-space:nowrap}
@media (max-width:600px){.reveal-all-btn .ra-note{display:none}}'''
@media (max-width:600px){.reveal-all-btn .ra-note{display:none}}
/* R3: the review's Compare control keeps only its glyph at phone width; its
title still says what it does (and C does it). */
@media (max-width:600px){.vcompare-l{display:none}}
/* ...and at phone width a top bar that cannot hold its controls WRAPS rather
than scrolling the page sideways. The review's bar was full before R3 (a
fogged booth overflowed it by 3px at 390); compare's bar holds more. */
@media (max-width:600px){.vbar{flex-wrap:wrap;row-gap:6px}}'''
new = '''
'''
@media (max-width:600px){.vcompare-l{display:none}}'''
[[mutation]]
label = "the Desk strip under another booth's reveal is lifted by a whisker (blur(0px) is not blurred)"
+28 -18
View File
@@ -1,5 +1,12 @@
# R2c — the review stage: every falsifier the contract claims
# (docs/contracts/r2c_review_stage.contract.md), and the change each forbids.
#
# r3 moved the stage machinery (the mode toggle, the storage listener, pannable
# and drag-pan) out of view.html into the shared _stage_js.html (r3 C4). The 15
# rows that anchor there were RE-POINTED, not rewritten: same test, same
# defeating change, in the code's new home, and every one re-proved after the
# move. The arrows (place, drawn, view's ResizeObserver) stayed in view.html,
# and so did their rows.
unit = "the review stage: fit / 1:1, the arrows at the picture, drag-pan"
@@ -23,12 +30,14 @@ new = '''
[[mutation]]
label = "S2 the toggle stays hidden (the per-picture hide is back)"
file = "booth/templates/view.html"
file = "booth/templates/_stage_js.html"
test = "tests/test_flow_browser.py::test_the_toggle_shows_for_every_picture_and_never_without_js"
# r3: BoothMode.bind is page level and holds no picture, so the re-pointed hide
# reads the page's one picture and stage itself.
old = '''
toggle.hidden = false;'''
new = '''
toggle.hidden = img.naturalWidth <= stage.clientWidth;'''
toggle.hidden = document.querySelector('.vstage img').naturalWidth <= document.querySelector('.vstage').clientWidth;'''
[[mutation]]
label = "S2 the toggle shows without JS (display beats [hidden])"
@@ -59,7 +68,7 @@ new = '''
[[mutation]]
label = "S2 a storage write that throws swallows the click"
file = "booth/templates/view.html"
file = "booth/templates/_stage_js.html"
test = "tests/test_flow_browser.py::test_the_mode_persists_across_prev_next_and_never_flashes"
old = '''
var setMode = function (one) {
@@ -112,7 +121,7 @@ new = '''
[[mutation]]
label = "S4 the pan runs backwards (the picture flees the pointer)"
file = "booth/templates/view.html"
file = "booth/templates/_stage_js.html"
test = "tests/test_flow_browser.py::test_in_one_to_one_a_drag_pans_and_the_picture_cannot_be_dragged_away"
old = '''
stage.scrollLeft = drag.l - dx;'''
@@ -121,7 +130,7 @@ new = '''
[[mutation]]
label = "S4 no drag threshold (a jittery click pans)"
file = "booth/templates/view.html"
file = "booth/templates/_stage_js.html"
test = "tests/test_flow_browser.py::test_in_one_to_one_a_drag_pans_and_the_picture_cannot_be_dragged_away"
old = '''
if (dx * dx + dy * dy < 16) return; /* under 4px in all: a click */'''
@@ -166,7 +175,7 @@ new = '''
[[mutation]]
label = "a buttonless hover continues a press released outside the stage"
file = "booth/templates/view.html"
file = "booth/templates/_stage_js.html"
test = "tests/test_flow_browser.py::test_a_pan_holds_past_the_stage_edge_and_never_starts_on_a_hover"
old = '''
if (!(e.buttons & 1)) { endDrag(); return; }'''
@@ -175,7 +184,7 @@ new = '''
[[mutation]]
label = "no pointer capture (a pan dies at the stage's edge)"
file = "booth/templates/view.html"
file = "booth/templates/_stage_js.html"
test = "tests/test_flow_browser.py::test_a_pan_holds_past_the_stage_edge_and_never_starts_on_a_hover"
old = '''
try { stage.setPointerCapture(drag.id); } catch (x) {}'''
@@ -222,7 +231,7 @@ new = '''
[[mutation]]
label = "the drag threshold is per axis, not total (a 3,3 diagonal pans nothing)"
file = "booth/templates/view.html"
file = "booth/templates/_stage_js.html"
test = "tests/test_flow_browser.py::test_in_one_to_one_a_drag_pans_and_the_picture_cannot_be_dragged_away"
old = '''
if (dx * dx + dy * dy < 16) return; /* under 4px in all: a click */'''
@@ -252,7 +261,7 @@ new = '''
[[mutation]]
label = "a stage mode chosen in another tab does not reach this one"
file = "booth/templates/view.html"
file = "booth/templates/_stage_js.html"
test = "tests/test_flow_browser.py::test_a_stage_mode_chosen_in_one_tab_moves_the_others"
old = '''
if (e.key !== 'booth.fit' && e.key !== null) return;'''
@@ -261,7 +270,7 @@ new = '''
[[mutation]]
label = "the drag threshold drops to 3px"
file = "booth/templates/view.html"
file = "booth/templates/_stage_js.html"
test = "tests/test_flow_browser.py::test_in_one_to_one_a_drag_pans_and_the_picture_cannot_be_dragged_away"
old = '''
if (dx * dx + dy * dy < 16) return; /* under 4px in all: a click */'''
@@ -270,7 +279,7 @@ new = '''
[[mutation]]
label = "a storage write that throws cuts the click short (buttons never update)"
file = "booth/templates/view.html"
file = "booth/templates/_stage_js.html"
test = "tests/test_flow_browser.py::test_the_mode_persists_across_prev_next_and_never_flashes"
old = '''
try {
@@ -281,7 +290,7 @@ new = '''
[[mutation]]
label = "the Fit button's pressed state is never drawn"
file = "booth/templates/view.html"
file = "booth/templates/_stage_js.html"
test = "tests/test_flow_browser.py::test_the_mode_persists_across_prev_next_and_never_flashes"
old = '''
bFit.setAttribute('aria-pressed', one ? 'false' : 'true');'''
@@ -302,16 +311,17 @@ new = '''
[[mutation]]
label = "S2 the toggle hides, on load, for a picture larger than the stage"
file = "booth/templates/view.html"
file = "booth/templates/_stage_js.html"
test = "tests/test_flow_browser.py::test_the_toggle_shows_for_every_picture_and_never_without_js"
# r3: `settle` is the stage's own (pannable) then the page's onSettle (place).
old = '''
function settle() { place(); pannable(); }'''
function settle() { pannable(); onSettle(); }'''
new = '''
function settle() { place(); pannable(); if (img) document.getElementById('vtoggle').hidden = img.naturalWidth > stage.clientWidth; }'''
function settle() { pannable(); onSettle(); if (img) document.getElementById('vtoggle').hidden = img.naturalWidth > stage.clientWidth; }'''
[[mutation]]
label = "S2 choosing Fit stores a word instead of forgetting 1:1"
file = "booth/templates/view.html"
file = "booth/templates/_stage_js.html"
test = "tests/test_flow_browser.py::test_the_mode_persists_across_prev_next_and_never_flashes"
old = '''
if (one) localStorage.setItem('booth.fit', 'one'); else localStorage.removeItem('booth.fit');'''
@@ -320,7 +330,7 @@ new = '''
[[mutation]]
label = "S4 pan only when BOTH axes overflow"
file = "booth/templates/view.html"
file = "booth/templates/_stage_js.html"
test = "tests/test_flow_browser.py::test_a_picture_that_overflows_one_axis_pans_along_it"
old = '''
(stage.scrollWidth > stage.clientWidth || stage.scrollHeight > stage.clientHeight);'''
@@ -331,7 +341,7 @@ new = '''
[[mutation]]
label = "a press on the stage's scrollbar starts a pan"
file = "booth/templates/view.html"
file = "booth/templates/_stage_js.html"
test = "tests/test_flow_browser.py::test_a_classic_scrollbar_is_neither_under_an_arrow_nor_a_pan"
old = '''
if (e.clientX - r.left - stage.clientLeft >= stage.clientWidth ||
+429
View File
@@ -0,0 +1,429 @@
# R3 — compare: every falsifier the contract claims
# (docs/contracts/r3_compare.contract.md), and the change each forbids.
unit = "compare: two picked rels side by side, linked stepping, synced pan, flag the winner"
# ---- C1: the route and the pair
[[mutation]]
label = "C1 the conjunction loses containment (an outside symlink in the ring opens)"
file = "booth/app.py"
test = "tests/test_compare.py::test_an_outside_symlink_in_the_ring_is_404"
old = '''
if not str(target).startswith(str(booth) + os.sep) or not target.is_file():
raise HTTPException(status_code=404, detail="no such item")
if rel not in ring:'''
new = '''
if rel not in ring:'''
[[mutation]]
label = "C1 the conjunction loses the ring (a doc or a sidecar opens as a side)"
file = "booth/app.py"
test = "tests/test_compare.py::test_a_bad_side_is_a_404"
old = '''
if rel not in ring:
raise HTTPException(status_code=404, detail="no such item")
return rel'''
new = '''
return rel'''
[[mutation]]
label = "C1 a missing side is FastAPI's 422 (no default)"
file = "booth/app.py"
test = "tests/test_compare.py::test_a_missing_param_is_404_not_422"
old = '''def booth_compare(request: Request, name: str, a: str = "", b: str = "",'''
new = '''def booth_compare(request: Request, name: str, a: str, b: str,'''
[[mutation]]
label = "C1 a look records only A"
file = "booth/app.py"
test = "tests/test_compare.py::test_a_look_records_both_seen"
old = '''
record_seen(booth, b, items)'''
new = '''
'''
[[mutation]]
label = "C1 the look is recorded above the 404s (a bad pair holds a booth open)"
file = "booth/app.py"
test = "tests/test_compare.py::test_a_look_records_both_seen"
old = '''
booth = resolve_booth(name)
items = booth_items(booth)
ring = review_chain(items)
a = _compare_side(booth, ring, a)'''
new = '''
booth = resolve_booth(name)
record_view(booth)
items = booth_items(booth)
ring = review_chain(items)
a = _compare_side(booth, ring, a)'''
[[mutation]]
label = "C6 compare does not carry data-booth (Reveal all and its restore bail)"
file = "booth/templates/compare.html"
test = "tests/test_compare.py::test_compare_carries_data_booth"
old = '''{% block html_attrs %} data-booth="{{ name }}"{% endblock %}'''
new = '''{% block html_attrs %}{% endblock %}'''
# ---- C2/C3: picking and stepping, server-built
[[mutation]]
label = "C3 linked steps do not keep the distance (B lands one after A)"
file = "booth/app.py"
test = "tests/test_compare.py::test_linked_steps_keep_the_distance_and_wrap"
old = ''' "both_next": url(ring[(ia + 1) % n], ring[(ib + 1) % n]),'''
new = ''' "both_next": url(ring[(ia + 1) % n], ring[(ia + 2) % n]),'''
[[mutation]]
label = "C3 linked steps stop at the end instead of wrapping"
file = "booth/app.py"
test = "tests/test_compare.py::test_linked_steps_keep_the_distance_and_wrap"
old = ''' "both_next": url(ring[(ia + 1) % n], ring[(ib + 1) % n]),'''
new = ''' "both_next": url(ring[min(ia + 1, n - 1)], ring[min(ib + 1, n - 1)]),'''
[[mutation]]
label = "INV-1 a link is keyed by ordinal"
file = "booth/app.py"
test = "tests/test_compare.py::test_the_urls_are_keyed_by_rel"
old = ''' u = f"/b/{name_url}/compare?a={quote(x, safe='/')}&b={quote(y, safe='/')}"'''
new = ''' u = f"/b/{name_url}/compare?a={quote(x, safe='/')}&b={quote(y, safe='/')}&i={by_rel[x].ordinal}"'''
[[mutation]]
label = "INV-1 a rel is not url-quoted in a link"
file = "booth/app.py"
test = "tests/test_compare.py::test_the_urls_are_keyed_by_rel"
old = ''' u = f"/b/{name_url}/compare?a={quote(x, safe='/')}&b={quote(y, safe='/')}"'''
new = ''' u = f"/b/{name_url}/compare?a={x}&b={y}"'''
[[mutation]]
label = "C2 the view state does not ride the links"
file = "booth/app.py"
test = "tests/test_compare.py::test_view_state_rides_the_links"
old = '''
if not linked:
u += "&link=0"
return u'''
new = '''
return u'''
[[mutation]]
label = "C2 an unknown side reads as A"
file = "booth/app.py"
test = "tests/test_compare.py::test_view_state_rides_the_links"
old = ''' side_a = side == "a"'''
new = ''' side_a = side not in ("", "b")'''
[[mutation]]
label = "C2 the review's Compare does not wrap (the last item compares with itself)"
file = "booth/app.py"
test = "tests/test_compare.py::test_the_review_offers_compare_with_the_next_item"
old = ''' f"&b={quote(ring[(pos + 1) % len(ring)], safe='/')}"),'''
new = ''' f"&b={quote(ring[min(pos + 1, len(ring) - 1)], safe='/')}"),'''
# ---- C5: the regions and the JS-off flag landing
[[mutation]]
label = "C5 the side regions share one id (B's control becomes A's after a save)"
file = "booth/templates/compare.html"
test = "tests/test_compare.py::test_no_data_region_repeats"
old = '''<div class="cmp-flag" data-region="flag-{{ key }}">'''
new = '''<div class="cmp-flag" data-region="flag">'''
[[mutation]]
label = "C5 back=compare lands on a pair outside the ring (no ring check)"
file = "booth/app.py"
test = "tests/test_compare.py::test_a_flag_without_js_lands_on_the_same_pair"
old = '''
if a in ring and b in ring:'''
new = '''
if True:'''
[[mutation]]
label = "C5 the landing echoes the form's side instead of mapping it"
file = "booth/app.py"
test = "tests/test_compare.py::test_a_flag_without_js_lands_on_the_same_pair"
old = '''
if form.get("side") == "a":
url += "&side=a"'''
new = '''
if form.get("side"):
url += "&side=" + str(form.get("side"))'''
[[mutation]]
label = "C5 the landing carries a fragment"
file = "booth/app.py"
test = "tests/test_compare.py::test_a_flag_without_js_lands_on_the_same_pair"
old = '''
return RedirectResponse(url=url, status_code=303)
return RedirectResponse(url=f"{base}#{anchor}", status_code=303)'''
new = '''
return RedirectResponse(url=url + "#" + anchor, status_code=303)
return RedirectResponse(url=f"{base}#{anchor}", status_code=303)'''
# ---- C4: the stages
[[mutation]]
label = "C4 the sides stack on a wide screen (compare's break is not the review's 900px)"
file = "booth/templates/base.html"
test = "tests/test_compare_browser.py::test_two_stages_side_by_side_wide_and_stacked_narrow"
# Not the column count: each side spans every row (subgrid), so a single
# declared column still auto-places the second side into an implicit column
# beside the first — that row was vacuous. The break is what a slip moves.
old = '''
@media (max-width:900px){
.viewer.review.compare{display:block}'''
new = '''
@media (max-width:1600px){
.viewer.review.compare{display:block}'''
[[mutation]]
label = "C4 a caption takes its height from one stage only (Fit draws the two at two scales)"
file = "booth/templates/base.html"
test = "tests/test_compare_browser.py::test_two_stages_side_by_side_wide_and_stacked_narrow"
old = '''
.cmp-side{display:grid;grid-row:1 / -1;grid-template-rows:subgrid;min-width:0;min-height:0}'''
new = '''
.cmp-side{display:grid;grid-row:1 / -1;grid-template-rows:auto minmax(0,1fr) auto;min-width:0;min-height:0}'''
[[mutation]]
label = "C4 stacked, a stage keeps the review's 60vh"
file = "booth/templates/base.html"
test = "tests/test_compare_browser.py::test_two_stages_side_by_side_wide_and_stacked_narrow"
old = '''
.review.compare .vstage{height:45vh}'''
new = '''
'''
[[mutation]]
label = "C4 Z is not bound (the mode key is missing on compare)"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_one_mode_for_both_and_for_the_review"
old = '''
else if ((k === 'z' || k === 'Z') && mode) mode.flip();'''
new = '''
'''
[[mutation]]
label = "C4 no synced pan"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_synced_pan_lands_on_the_same_crop"
old = '''
sync(s.el, other);'''
new = '''
'''
[[mutation]]
label = "C4 the sync copies the pixel offset, not the fraction"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_synced_pan_by_fraction_for_different_sizes"
old = '''
if (fx > 0 && tx > 0) l = from.scrollLeft / fx * tx;
if (fy > 0 && ty > 0) t = from.scrollTop / fy * ty;'''
new = '''
if (fx > 0 && tx > 0) l = from.scrollLeft;
if (fy > 0 && ty > 0) t = from.scrollTop;'''
[[mutation]]
label = "C4 the synced-pan loop guard is gone (a sync echoes back and walks the side put)"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_synced_pan_by_fraction_for_different_sizes"
old = '''
if (mine) {
s.el.__synced = null;
if (Math.abs(s.el.scrollLeft - mine.l) < 1 && Math.abs(s.el.scrollTop - mine.t) < 1) return;
}'''
new = '''
'''
# ---- C2/C3: the view state and the keys, in the page
[[mutation]]
label = "C3 unlinked, an arrow still moves both sides"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_unlinked_moves_only_the_active_side_and_the_strip_picks_it"
old = '''
var which = (linked ? 'both' : active) + (dir < 0 ? '-prev' : '-next');'''
new = '''
var which = 'both' + (dir < 0 ? '-prev' : '-next');'''
[[mutation]]
label = "C2 the unlinked state does not survive a step (it is not in the next URL)"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_unlinked_moves_only_the_active_side_and_the_strip_picks_it"
old = '''
if (!linked) parts.push('link=0');'''
new = '''
'''
[[mutation]]
label = "C2 X does not swap the active side"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_unlinked_moves_only_the_active_side_and_the_strip_picks_it"
old = '''
else if (k === 'x' || k === 'X') setActive(active === 'a' ? 'b' : 'a');'''
new = '''
'''
[[mutation]]
label = "C2 the active stage does not wear the reticle"
file = "booth/templates/base.html"
test = "tests/test_compare_browser.py::test_unlinked_moves_only_the_active_side_and_the_strip_picks_it"
old = '''
.cmp-side.is-active>.cmp-stagewrap::after,.film-f.is-active::after{content:"";'''
new = '''
.film-f.is-active::after{content:"";'''
[[mutation]]
label = "C2 a strip click always replaces B (not the side active now)"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_a_save_keeps_the_active_side"
old = '''
var pick = f.getAttribute('data-pick-' + active);
if (!pick) return;'''
new = '''
var pick = f.getAttribute('data-pick-b');
if (!pick) return;'''
[[mutation]]
label = "C2 a press on a stage does not make its side active"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_a_save_keeps_the_active_side"
old = '''
s.el.addEventListener('pointerdown', function () { setActive(s.k); });'''
new = '''
'''
[[mutation]]
label = "C2 the active side is not written back into the URL (a reload forgets it)"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_a_save_keeps_the_active_side"
old = '''
try { history.replaceState(history.state, '', withState(location.pathname + location.search)); } catch (e) {}'''
new = '''
'''
[[mutation]]
label = "C3 a held modifier does not make the keys inert"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_the_keys_keep_the_reviews_guards_and_c_toggles_the_view"
old = '''
if (isEditable(e.target)) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
var k = e.key;'''
new = '''
if (isEditable(e.target)) return;
var k = e.key;'''
[[mutation]]
label = "C3 Space on a focused control steps instead of pressing it"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_the_keys_keep_the_reviews_guards_and_c_toggles_the_view"
old = '''
if (e.target.closest && e.target.closest('button, a, summary, video, audio')) return;'''
new = '''
'''
[[mutation]]
label = "INV-6 the review has no C key"
file = "booth/templates/view.html"
test = "tests/test_compare_browser.py::test_the_keys_keep_the_reviews_guards_and_c_toggles_the_view"
old = '''
else if (e.key === 'c' || e.key === 'C') { e.preventDefault(); window.location.href = COMPARE; }'''
new = '''
'''
# ---- C5: judging in place
[[mutation]]
label = "C5 a stage is a region (a save swaps it, and a playing track restarts)"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_a_flags_A_in_place_and_the_stages_survive"
old = '''<div class="vstage{% if s.kind == 'image' %} is-img{% endif %}'''
new = '''<div data-region="stage-{{ key }}" class="vstage{% if s.kind == 'image' %} is-img{% endif %}'''
[[mutation]]
label = "C5 the B key presses the first flag button on the page (A's)"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_a_flags_A_in_place_and_the_stages_survive"
old = '''
var btn = document.getElementById('cmp-flag-' + k.toLowerCase());'''
new = '''
var btn = document.querySelector('.cmp-flag .vflag-btn');'''
# ---- C6: blur
[[mutation]]
label = "C6 Reveal all leaves the per-side reveals standing"
file = "booth/templates/base.html"
test = "tests/test_compare_browser.py::test_blur_is_honest_on_both_sides"
old = '''
.reveal-all .cmp-reveal{display:none}'''
new = '''
'''
[[mutation]]
label = "C6 a side's reveal lifts A's blur whichever side it sits on"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_blur_is_honest_on_both_sides"
old = '''
var stage = sides[btn.getAttribute('data-side')].querySelector('.vstage');'''
new = '''
var stage = sides.a.querySelector('.vstage');'''
[[mutation]]
label = "C6 compare's root is not a .review (the blur rules do not reach its stages)"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_blur_is_honest_on_both_sides"
old = '''<div class="viewer review compare" data-linked'''
new = '''<div class="viewer compare" data-linked'''
# ---- INV-4: without JS
[[mutation]]
label = "INV-4 the JS-only Linked toggle shows without JS (inline-flex beats [hidden])"
file = "booth/templates/base.html"
test = "tests/test_compare_browser.py::test_without_js_every_judgment_and_step_still_works"
old = '''
.cmp-link[hidden]{display:none}'''
new = '''
'''
[[mutation]]
label = "INV-4 without JS a strip frame goes nowhere useful (links the review instead)"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_without_js_every_judgment_and_step_still_works"
old = '''
href="{{ x.pick }}" data-rel'''
new = '''
href="/b/{{ name_url }}/view?f={{ x.url }}" data-rel'''
# ---- the top bar at phone width (the review's, and compare's)
[[mutation]]
label = "at phone width a full top bar scrolls the page sideways instead of wrapping"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_the_review_and_doc_top_bars_fit_a_phone"
old = '''
@media (max-width:600px){.vbar{flex-wrap:wrap;row-gap:6px}}'''
new = '''
'''
[[mutation]]
label = "compare's top bar crushes its controls at phone width (the Fit | 1:1 toggle to 2px)"
file = "booth/templates/base.html"
test = "tests/test_compare_browser.py::test_two_stages_side_by_side_wide_and_stacked_narrow"
old = '''
@media (max-width:600px){.vbar{flex-wrap:wrap;row-gap:6px}}'''
new = '''
'''
# Accepted, not rowed: the mode's `onChange: settleAll` (re-deciding which stage
# can pan) is redundant with compare's ResizeObserver — 1:1 drops the stage's
# padding, so every mode change resizes the stage's content box and the
# observer settles both. Its row stayed green; each alone holds.
#
# Accepted, not rowed: the `booth:swapped` restate is redundant with the URL
# rewrite — the in-place client re-fetches `location.href`, which already
# carries `side`, so the fresh strip renders the active side itself. Each alone
# holds; the save test (a_save_keeps_the_active_side) sees the pair.
+293
View File
@@ -0,0 +1,293 @@
"""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": ""}
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 <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"
# ---- 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("&amp;", "&")).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'<a [^>]*(?:data-step="[^"]+"[^>]*href|class="film-f[^"]*"[^>]*href)="([^"]+)"', body)
return [h.replace("&amp;", "&") 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("&amp;", "&")).query) == {
"a": ["q.png"], "b": ["r.png"], "side": ["a"], "link": ["0"]}
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 {}
return c.post("/b/g/flag", data={"target": "q.png", "on": "1", **form}, headers=headers)
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("&amp;", "&"))
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("&amp;", "&") == "/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
+451
View File
@@ -0,0 +1,451 @@
"""R3 — compare, in a real DOM.
Contract: docs/contracts/r3_compare.contract.md. A TestClient can prove what
the server answers; it cannot prove that two stages sit side by side, that a
pan on one lands the other on the same crop, or that an in-place save keeps
the active side. The harness is test_flow_browser's (a real uvicorn, a real
offline Chromium), and like it this SKIPS, never fails, without a browser.
"""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
from test_flow_browser import _png, browser, live # noqa: E402,F401 (fixtures)
def _pics(root: pathlib.Path, pics: dict, name: str = "g") -> pathlib.Path:
b = root / name
b.mkdir()
for rel, (w, h) in pics.items():
p = b / rel
p.parent.mkdir(parents=True, exist_ok=True)
p.write_bytes(_png(w, h))
return b
def _open(page, url):
"""Load a compare page and wait until every picture on a stage decoded."""
page.goto(url, wait_until="networkidle")
page.wait_for_function("""[...document.querySelectorAll('.cmp-side .vstage img')]
.every(i => i.complete && i.naturalWidth > 0)""")
page.wait_for_timeout(150)
_BOXES = """() => [...document.querySelectorAll('.cmp-side .vstage')].map(s => {
const b = s.getBoundingClientRect();
return {side: s.dataset.side, l: b.left, r: b.right, t: b.top, b: b.bottom, h: b.height};
})"""
def test_two_stages_side_by_side_wide_and_stacked_narrow(browser, live):
"""The tracer. At 1440 A and B share one row, A on the left, each about
half the body. At 390 they stack, A above B, each at most 45vh tall."""
base, root = live
from booth.app import set_blurred
b = _pics(root, {"a.png": (800, 600), "b.png": (800, 600)})
(b / "a.png.txt").write_text("a caption on A only, " * 8)
set_blurred(b, "b.png", True) # the bar carries Reveal all too: its fullest
got = {}
for w, h in ((1440, 900), (390, 844)):
page = browser.new_page(viewport={"width": w, "height": h})
_open(page, f"{base}/b/g/compare?a=a.png&b=b.png")
got[w] = (page.evaluate(_BOXES), h,
page.evaluate("document.documentElement.scrollWidth - document.documentElement.clientWidth"),
# and nothing squeezed: every top-bar control one line, none
# crushed narrower than its own content (a flex item shrinks
# before it overflows — the Fit | 1:1 toggle went to 2px)
page.evaluate("""() => { const c = [...document.querySelectorAll(
'.vbar button, .vbar .vbtn, .vbar .vtoggle')].filter(e => e.offsetParent);
return [Math.max(...c.map(e => e.getBoundingClientRect().height)),
Math.max(...c.map(e => e.scrollWidth - e.clientWidth))]; }"""))
page.close()
(a, b), _, over, tallest = got[1440]
assert (a["side"], b["side"]) == ("a", "b")
assert abs(a["t"] - b["t"]) <= 1 and a["r"] <= b["l"], (a, b)
assert a["r"] - a["l"] > 1440 * 0.4 and b["r"] - b["l"] > 1440 * 0.4, (a, b)
# the SAME stage for both, or Fit draws one smaller: A's caption must not
# take its height from A's stage alone
assert abs(a["h"] - b["h"]) <= 1 and abs(a["b"] - b["b"]) <= 1, (a, b)
assert over <= 0 and tallest[0] <= 40 and tallest[1] <= 1, (over, tallest)
(a, b), vh, over, tallest = got[390]
assert a["b"] <= b["t"], ("A above B", a, b)
assert a["h"] <= 0.45 * vh + 1 and b["h"] <= 0.45 * vh + 1, (a, b)
assert a["h"] > 100 and b["h"] > 100, (a, b)
assert over <= 0, "the compare page scrolls sideways at phone width"
assert tallest[0] <= 40, ("a top-bar control squeezed into a stack", tallest)
assert tallest[1] <= 1, ("a top-bar control crushed narrower than its content", tallest)
_IMGS = """() => [...document.querySelectorAll('.cmp-side .vstage img')].map(i => {
const b = i.getBoundingClientRect();
return [Math.round(b.width), Math.round(b.height), i.naturalWidth, i.naturalHeight];
})"""
def test_one_mode_for_both_and_for_the_review(browser, live):
"""`Z` switches BOTH stages to 1:1 and stores it as the review's own
preference: the review then opens in 1:1, and Z back is Fit for both."""
base, root = live
_pics(root, {"a.png": (1600, 1200), "b.png": (1400, 1000)})
ctx = browser.new_context(viewport={"width": 1440, "height": 900})
page = ctx.new_page()
_open(page, f"{base}/b/g/compare?a=a.png&b=b.png")
fit = page.evaluate(_IMGS)
page.keyboard.press("z")
page.wait_for_timeout(150)
one = page.evaluate(_IMGS)
# both larger than their stages, so both offer the grab at once — not at
# the next resize
grab = page.evaluate("[...document.querySelectorAll('.cmp-side .vstage')].map(s => getComputedStyle(s).cursor)")
stored = page.evaluate("localStorage.getItem('booth.fit')")
pressed = page.locator("#btn-one").get_attribute("aria-pressed")
page.goto(f"{base}/b/g/view?f=a.png", wait_until="networkidle")
review_one = page.evaluate("document.documentElement.classList.contains('stage-one')")
page.goto(f"{base}/b/g/compare?a=a.png&b=b.png", wait_until="networkidle")
page.keyboard.press("Z")
back = page.evaluate("[document.documentElement.classList.contains('stage-one'), localStorage.getItem('booth.fit')]")
ctx.close()
assert all([w, h] != [nw, nh] for w, h, nw, nh in fit), fit
assert all([w, h] == [nw, nh] for w, h, nw, nh in one), one
assert grab == ["grab", "grab"], grab
assert stored == "one" and pressed == "true" and review_one is True, (stored, pressed, review_one)
assert back == [False, None], back
_SCROLLS = """() => [...document.querySelectorAll('.cmp-side .vstage')].map(s => [s.scrollLeft, s.scrollTop])"""
def _one_to_one(page, url):
"""Open a compare in 1:1 (the stored preference, applied before paint)."""
page.add_init_script("try { localStorage.setItem('booth.fit', 'one'); } catch (e) {}")
_open(page, url)
def _center(page, side):
box = page.locator(f'.cmp-side[data-side="{side}"] .vstage').bounding_box()
return box["x"] + box["width"] / 2, box["y"] + box["height"] / 2
def test_synced_pan_lands_on_the_same_crop(browser, live):
"""Two pictures of one size, larger than the stage, in 1:1. A drag on A of
(+80, +60) scrolls BOTH by (-80, -60): the same pixels under the same
point. A wheel on B moves A with it. After a second of idle neither has
moved on its own (no sync loop)."""
base, root = live
_pics(root, {"a.png": (3000, 3000), "b.png": (3000, 3000)})
page = browser.new_page(viewport={"width": 1440, "height": 900})
_one_to_one(page, f"{base}/b/g/compare?a=a.png&b=b.png")
page.evaluate("document.querySelector('.cmp-side[data-side=\"a\"] .vstage').scrollTo(500, 500)")
page.wait_for_timeout(200)
start = page.evaluate(_SCROLLS)
cx, cy = _center(page, "a")
page.mouse.move(cx, cy); page.mouse.down(); page.mouse.move(cx + 80, cy + 60, steps=6); page.mouse.up()
page.wait_for_timeout(200)
dragged = page.evaluate(_SCROLLS)
bx, by = _center(page, "b")
page.mouse.move(bx, by)
page.mouse.wheel(0, 300)
page.wait_for_timeout(600)
wheeled = page.evaluate(_SCROLLS)
page.wait_for_timeout(1000)
idle = page.evaluate(_SCROLLS)
page.close()
assert start == [[500, 500], [500, 500]], start
assert dragged == [[420, 440], [420, 440]], dragged
assert wheeled[1][1] > 440 and wheeled[0] == wheeled[1], wheeled
assert idle == wheeled, (wheeled, idle)
_RANGES = """() => [...document.querySelectorAll('.cmp-side .vstage')].map(s =>
[s.scrollWidth - s.clientWidth, s.scrollHeight - s.clientHeight])"""
# The first y near `from` on B whose trip B -> A -> B does not come back to
# itself when each scroll lands on a whole pixel: where a sync that re-synced
# its own echo would walk B off the spot it was put on.
_LOSSY = """([from, rA, rB]) => {
for (let y = from; y < from + 200; y++)
if (Math.round(Math.round(y / rB * rA) / rA * rB) !== y) return y;
return from;
}"""
def test_synced_pan_by_fraction_for_different_sizes(browser, live):
"""A 2000px and a 3000px picture. One at its middle puts the other at ITS
middle; one at 25% of its range puts the other at 25% of ITS range — not
at the same pixel offset. The equal-size test cannot see a fraction bug
(equal overflow makes offsets and fractions coincide); this one can. And a
side put somewhere STAYS there: the sync never echoes back and walks it."""
base, root = live
_pics(root, {"a.png": (2000, 2000), "b.png": (3000, 3000)})
page = browser.new_page(viewport={"width": 1440, "height": 900})
_one_to_one(page, f"{base}/b/g/compare?a=a.png&b=b.png")
(rax, ray), (rbx, rby) = page.evaluate(_RANGES)
a = '.cmp-side[data-side="a"] .vstage'
b = '.cmp-side[data-side="b"] .vstage'
page.evaluate("([s, x, y]) => document.querySelector(s).scrollTo(x, y)", [a, round(rax / 2), round(ray / 2)])
page.wait_for_timeout(250)
middle = page.evaluate(_SCROLLS)
y = page.evaluate(_LOSSY, [round(rby / 4), ray, rby])
x = page.evaluate(_LOSSY, [round(rbx / 4), rax, rbx])
page.evaluate("([s, x, y]) => document.querySelector(s).scrollTo(x, y)", [b, x, y])
page.wait_for_timeout(250)
quarter = page.evaluate(_SCROLLS)
page.wait_for_timeout(1000)
idle = page.evaluate(_SCROLLS)
page.close()
assert rbx > rax > 0 and rby > ray > 0, (rax, ray, rbx, rby)
assert abs(middle[1][0] - rbx / 2) <= 1 and abs(middle[1][1] - rby / 2) <= 1, (middle, rbx, rby)
assert abs(quarter[0][0] / rax - x / rbx) * rax <= 1, (quarter, x)
assert abs(quarter[0][1] / ray - y / rby) * ray <= 1, (quarter, y)
assert quarter[0][0] < x - 100, "A must sit at ITS 25%, not at B's pixel offset"
assert quarter[1] == [x, y] and idle == quarter, (x, y, quarter, idle)
def test_a_flags_A_in_place_and_the_stages_survive(browser, live):
"""`A` flags A in place: no navigation, A's control and label show the
flag, B's do not, and both stages are the SAME nodes — a save swaps the
regions and never a stage (a playing track would restart)."""
from booth.marks import marks_for
base, root = live
b = _pics(root, {"a.png": (800, 600), "b.png": (800, 600)})
page = browser.new_page(viewport={"width": 1440, "height": 900})
_open(page, f"{base}/b/g/compare?a=a.png&b=b.png")
page.evaluate("""() => { window.__noReload = 1;
window.__stages = [...document.querySelectorAll('.cmp-side .vstage')]; }""")
page.keyboard.press("a")
page.wait_for_selector("#cmp-flag-a.is-flagged", timeout=10000)
got = page.evaluate("""() => ({
reload: window.__noReload !== 1,
same: [...document.querySelectorAll('.cmp-side .vstage')].every((s, i) => s === window.__stages[i]),
a: document.querySelector('[data-region="label-a"]').textContent,
b: document.querySelector('[data-region="label-b"]').textContent,
bflag: document.getElementById('cmp-flag-b').classList.contains('is-flagged'),
})""")
flagged = [m.target for m in marks_for(b) if m.shape == "flag"]
# the save REPLACED the buttons: each key must find the fresh one
page.keyboard.press("b")
page.wait_for_selector("#cmp-flag-b.is-flagged", timeout=10000)
page.keyboard.press("a")
page.wait_for_selector("#cmp-flag-a:not(.is-flagged)", timeout=10000)
after = sorted(m.target for m in marks_for(b) if m.shape == "flag")
reloaded = page.evaluate("window.__noReload !== 1")
page.close()
assert not got["reload"] and got["same"], got
assert "flagged" in got["a"] and "flagged" not in got["b"] and not got["bflag"], got
assert flagged == ["a.png"] and after == ["b.png"] and not reloaded, (flagged, after, reloaded)
def _bakeoff(root: pathlib.Path) -> pathlib.Path:
"""sindra-bakeoff's shape: two lanes, m and r, the same scenes and seeds,
laid out as two parallel runs in sorted order — no pairing rule needed."""
scenes = ("dock-s11", "forge-s23", "marsh-s37", "tower-s42")
return _pics(root, {f"{lane}-{i}-{sc}.png": (400, 300)
for lane in ("m", "r") for i, sc in enumerate(scenes, 1)})
def _pair(page) -> tuple[str, str]:
from urllib.parse import parse_qs, urlsplit
q = parse_qs(urlsplit(page.url).query)
return q["a"][0], q["b"][0]
def _press_and_wait(page, key):
with page.expect_navigation(wait_until="networkidle"):
page.keyboard.press(key)
def test_linked_arrow_walks_a_bakeoff(browser, live):
"""m#1 against r#1, then `→` three times: every pair is the same scene and
seed in the two lanes."""
base, root = live
_bakeoff(root)
page = browser.new_page(viewport={"width": 1440, "height": 900})
_open(page, f"{base}/b/g/compare?a=m-1-dock-s11.png&b=r-1-dock-s11.png")
pairs = [_pair(page)]
for _ in range(3):
_press_and_wait(page, "ArrowRight")
pairs.append(_pair(page))
page.close()
assert [(a[2:], b[2:]) for a, b in pairs] == [(a[2:], a[2:]) for a, _ in pairs], pairs
assert [a[0] + b[0] for a, b in pairs] == ["mr"] * 4, pairs
assert pairs[-1] == ("m-4-tower-s42.png", "r-4-tower-s42.png"), pairs
def test_unlinked_moves_only_the_active_side_and_the_strip_picks_it(browser, live):
"""`L` unlinks: `→` moves only B, and a SECOND `→` still moves only B (the
state survived the navigation). A strip click replaces the active side.
`X` swaps the active side, the reticle follows, and it survives a step."""
base, root = live
_bakeoff(root)
page = browser.new_page(viewport={"width": 1440, "height": 900})
_open(page, f"{base}/b/g/compare?a=m-1-dock-s11.png&b=r-1-dock-s11.png")
page.keyboard.press("l")
linked = page.locator("#cmp-link").get_attribute("aria-pressed")
_press_and_wait(page, "ArrowRight")
one = _pair(page)
_press_and_wait(page, "ArrowRight")
two = _pair(page)
with page.expect_navigation(wait_until="networkidle"):
page.locator('.film-f[data-rel="m-3-marsh-s37.png"]').click()
picked = _pair(page)
page.keyboard.press("x")
active = page.evaluate("""() => [document.querySelector('.cmp-side.is-active').dataset.side,
[...document.querySelectorAll('.film-f.is-active')].map(f => f.dataset.rel)]""")
_press_and_wait(page, "ArrowRight")
stepped = _pair(page)
after = page.evaluate("""() => [document.querySelector('.cmp-side.is-active').dataset.side,
getComputedStyle(document.querySelector('.cmp-side.is-active > .cmp-stagewrap'), '::after').backgroundImage !== 'none',
getComputedStyle(document.querySelector('.cmp-side:not(.is-active) > .cmp-stagewrap'), '::after').backgroundImage !== 'none']""")
page.close()
assert linked == "false", linked
assert one == ("m-1-dock-s11.png", "r-2-forge-s23.png"), one
assert two == ("m-1-dock-s11.png", "r-3-marsh-s37.png"), two
assert picked == ("m-1-dock-s11.png", "m-3-marsh-s37.png"), picked
assert active == ["a", ["m-1-dock-s11.png"]], active
assert stepped == ("m-2-forge-s23.png", "m-3-marsh-s37.png"), stepped
assert after == ["a", True, False], after
_FILTERS = """() => [...document.querySelectorAll('.cmp-side .vstage img')].map(i => getComputedStyle(i).filter)"""
_REVEALS = """() => [...document.querySelectorAll('.cmp-reveal')].map(b => getComputedStyle(b).display !== 'none')"""
def test_blur_is_honest_on_both_sides(browser, live):
"""A blurred side IS blurred — the computed filter, not just a class. Its
own reveal lifts it and leaves the other side blurred. Reveal all lifts
both, and stands both per-side reveals down."""
from booth.app import set_blurred
base, root = live
b = _pics(root, {"a.png": (800, 600), "b.png": (800, 600)})
set_blurred(b, "a.png", True)
set_blurred(b, "b.png", True)
page = browser.new_page(viewport={"width": 1440, "height": 900})
_open(page, f"{base}/b/g/compare?a=a.png&b=b.png")
before = page.evaluate(_FILTERS)
shown = page.evaluate(_REVEALS)
page.locator('.cmp-reveal[data-side="b"]').click()
page.wait_for_timeout(400) # the filter transition
own = page.evaluate(_FILTERS)
page.locator('.cmp-reveal[data-side="b"]').click()
page.locator("[data-reveal-all]").click()
page.wait_for_timeout(400)
everything = page.evaluate(_FILTERS)
stood_down = page.evaluate(_REVEALS)
page.close()
assert all("blur(" in f for f in before), before
assert shown == [True, True], shown
assert "blur(" in own[0] and "blur(" not in own[1], own
assert all("blur(" not in f for f in everything), everything
assert stood_down == [False, False], stood_down
def test_a_save_keeps_the_active_side(browser, live):
"""Make A active (a press on its stage), then flag B in place. The save
swaps the strip and the labels, and A is STILL the active side — on its
stage and on the strip — and a strip click after the swap replaces A."""
base, root = live
_bakeoff(root)
page = browser.new_page(viewport={"width": 1440, "height": 900})
_open(page, f"{base}/b/g/compare?a=m-1-dock-s11.png&b=r-1-dock-s11.png")
ax, ay = _center(page, "a")
page.mouse.click(ax, ay)
page.evaluate("window.__noReload = 1")
page.keyboard.press("b")
page.wait_for_selector("#cmp-flag-b.is-flagged", timeout=10000)
kept = page.evaluate("""() => ({
reload: window.__noReload !== 1,
side: document.querySelector('.cmp-side.is-active').dataset.side,
strip: [...document.querySelectorAll('.film-f.is-active')].map(f => f.dataset.rel),
url: location.search,
})""")
with page.expect_navigation(wait_until="networkidle"):
page.locator('.film-f[data-rel="m-2-forge-s23.png"]').click()
picked = _pair(page)
side = page.evaluate("document.querySelector('.cmp-side.is-active').dataset.side")
# the active side lives in the URL: `X` rewrites it in place, so a reload
# shows the side that was active, not the default
page.keyboard.press("x")
page.reload(wait_until="networkidle")
reloaded = page.evaluate("document.querySelector('.cmp-side.is-active').dataset.side")
page.close()
assert reloaded == "b", reloaded
assert not kept["reload"] and kept["side"] == "a", kept
assert kept["strip"] == ["m-1-dock-s11.png"] and "side=a" in kept["url"], kept
assert picked == ("m-2-forge-s23.png", "r-1-dock-s11.png") and side == "a", (picked, side)
def test_without_js_every_judgment_and_step_still_works(browser, live):
"""INV-4. Scripts off: the pair renders in two Fit stages, the step and
strip links navigate, both flag forms are there and a flag lands back on
the same pair. Nothing judgment-bearing hides behind a script."""
from booth.marks import marks_for
base, root = live
b = _bakeoff(root)
ctx = browser.new_context(java_script_enabled=False, viewport={"width": 1440, "height": 900})
page = ctx.new_page()
page.goto(f"{base}/b/g/compare?a=m-1-dock-s11.png&b=r-1-dock-s11.png", wait_until="networkidle")
fit = page.evaluate("document.documentElement.classList.contains('stage-one')")
hidden = [page.locator(s).is_visible() for s in ("#cmp-link", "#vtoggle")]
forms = page.locator(".cmp-flag form").count()
page.locator('a[data-step="both-next"]').click()
page.wait_for_load_state("networkidle")
both = _pair(page)
page.locator('a[data-step="a-prev"]').click()
page.wait_for_load_state("networkidle")
a_back = _pair(page)
page.locator('.film-f[data-rel="m-4-tower-s42.png"]').click()
page.wait_for_load_state("networkidle")
strip = _pair(page)
page.locator("#cmp-flag-b").click()
page.wait_for_load_state("networkidle")
landed = _pair(page)
shows = page.locator("#cmp-flag-b").get_attribute("class")
ctx.close()
assert fit is False and hidden == [False, False] and forms == 2, (fit, hidden, forms)
assert both == ("m-2-forge-s23.png", "r-2-forge-s23.png"), both
assert a_back == ("m-1-dock-s11.png", "r-2-forge-s23.png"), a_back
assert strip == ("m-1-dock-s11.png", "m-4-tower-s42.png"), strip
assert landed == strip and "is-flagged" in shows, (landed, shows)
assert [m.target for m in marks_for(b) if m.shape == "flag"] == ["m-4-tower-s42.png"]
def test_the_keys_keep_the_reviews_guards_and_c_toggles_the_view(browser, live):
"""C3: a held modifier makes every key inert; Space on a focused control
presses it and never steps, and from nowhere in particular it steps
(Shift+Space back). `C` in the review opens compare against the next
item; `Esc` (or `C`) in compare returns to the review of A."""
base, root = live
_bakeoff(root)
page = browser.new_page(viewport={"width": 1440, "height": 900})
_open(page, f"{base}/b/g/compare?a=m-2-forge-s23.png&b=r-2-forge-s23.png")
start = page.url
for key in ("Control+ArrowRight", "Alt+ArrowRight", "Meta+ArrowRight", "Control+x", "Alt+l"):
page.keyboard.press(key)
page.wait_for_timeout(300)
inert = (page.url == start,
page.evaluate("document.querySelector('.cmp-side.is-active').dataset.side"),
page.locator("#cmp-link").get_attribute("aria-pressed"))
page.locator("#cmp-link").focus()
page.keyboard.press(" ")
page.wait_for_timeout(300)
pressed = (page.url.split("?")[1], page.locator("#cmp-link").get_attribute("aria-pressed"))
page.locator("#cmp-link").press(" ") # linked again
page.evaluate("document.activeElement.blur()")
_press_and_wait(page, " ")
fwd = _pair(page)
_press_and_wait(page, "Shift+ ")
back = _pair(page)
_press_and_wait(page, "Escape")
esc = page.url
_press_and_wait(page, "c")
c_review = _pair(page)
_press_and_wait(page, "C")
c_back = page.url
page.close()
assert inert == (True, "b", "true"), inert
assert pressed == ("a=m-2-forge-s23.png&b=r-2-forge-s23.png&link=0", "false"), pressed
assert fwd == ("m-3-marsh-s37.png", "r-3-marsh-s37.png") and back == ("m-2-forge-s23.png", "r-2-forge-s23.png"), (fwd, back)
assert esc.endswith("/b/g/view?f=m-2-forge-s23.png"), esc
assert c_review == ("m-2-forge-s23.png", "m-3-marsh-s37.png"), c_review
assert c_back.endswith("/b/g/view?f=m-2-forge-s23.png"), c_back