diff --git a/booth/app.py b/booth/app.py index 8f37bea..cff67be 100644 --- a/booth/app.py +++ b/booth/app.py @@ -103,6 +103,7 @@ from booth.items import ( # noqa: E402,F401 read_blurred, render_doc, render_doc_body, + image_dims, ) # The per-item blur writer lives with its reader in booth/blur.py, stdlib-only so # `scripts/booth blur` shares both. Re-exported: tests import it from here. @@ -916,6 +917,9 @@ def build_gallery(child: Path) -> list[dict]: # SILENT fallback to the full image — the adapter is exactly # where a new Item field gets dropped without anything failing. "thumb": it.thumb, + # as S5c (G14): the tile's picture size, a separate step like + # the doc body below, so the Desk's `booth_items` never pays it. + "dims": image_dims(child, it), # R2 C1. Same rule: the resolver numbers, this carries. "ordinal": it.ordinal, "caption": it.caption, diff --git a/booth/items.py b/booth/items.py index 5c11fe6..dcba229 100644 --- a/booth/items.py +++ b/booth/items.py @@ -33,7 +33,7 @@ except ImportError: # pragma: no cover from booth.asks import is_answer_file, is_ask_file from booth.blur import BLUR_FILE, read_blurred # noqa: F401 (re-exported) from booth.links import is_safe_href -from booth.thumbs import wants_thumb +from booth.thumbs import drawn_size, wants_thumb # Browser-playable media buckets. Anything else renders as a download link. IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".svg", ".bmp"} @@ -500,6 +500,17 @@ def find_item(items: Sequence[Item], rel: str) -> Item | None: return None +def image_dims(booth: Path, item: Item) -> tuple[int, int] | None: + """(width, height) of an image item as the browser draws it, or None. + + A SEPARATE STEP, like `render_doc_body` below and for its reason: the Desk + calls `booth_items` for every booth, and reading a header per picture there + would be the cost of a fact only the gallery's tiles use (as S5c, G14).""" + if item.kind != "image": + return None + return drawn_size(booth / item.rel) + + def render_doc_body(booth: Path, item: Item) -> tuple[str, bool] | None: """(body, is_html) for a doc item under DOC_MAX_BYTES, else None. diff --git a/booth/templates/base.html b/booth/templates/base.html index df86ea0..8407bb5 100644 --- a/booth/templates/base.html +++ b/booth/templates/base.html @@ -76,6 +76,38 @@ }, true); })(); + {% block title %}The Booth{% endblock %} {# The two SVOS voices. display=swap and the system stacks in --font-sans / @@ -577,7 +609,7 @@ font:600 11px/1.2 var(--font-mono);background:oklch(0.17 0.01 250 / .82);color:oklch(0.91 0.008 216); pointer-events:none} .item.blurred > .ord{top:44px} - .doc-bar .ord{font:600 11px/1 var(--font-mono);color:var(--text-muted)} + .doc-sum .ord{font:600 11px/1 var(--font-mono);color:var(--text-muted)} /* the inline group header: spans the grid, is never a tile */ .grp-head{grid-column:1 / -1;display:flex;align-items:center;gap:8px;margin:8px 0 -4px; font-family:var(--font-mono);font-size:var(--size-micro);letter-spacing:var(--tracking-caps);text-transform:uppercase} @@ -631,8 +663,16 @@ untouched: this sizes boxes, it moves none. */ .gallery{display:grid;gap:var(--space-5);grid-template-columns:repeat(auto-fill,minmax(320px,1fr));align-items:start} .item{position:relative;margin:0;background:var(--surface-card);border:1px solid var(--border-default); - border-radius:var(--radius-xl);overflow:hidden;display:flex;flex-direction:column;box-shadow:var(--shadow-sm); - scroll-margin-top:calc(var(--rail-h, 120px) + 12px)} + border-radius:var(--radius-xl);overflow:hidden;display:flex;flex-direction:column;box-shadow:var(--shadow-sm)} + /* as S5c (G15): the PAGE keeps the rail's height clear, not the tile, so a + control reached by Tab stops below the rail too, as a jump and the cursor + do. It replaces .item's scroll-margin-top: the two add, and a tile would + land two rails down. Only a page with a rail. */ + html:has(.rail){--rail-pad:calc(var(--rail-h, 120px) + 12px);scroll-padding-top:var(--rail-pad)} + /* ...and a control IN the stuck rail is not under it: its own negative + margin cancels the padding, or every Tab between group links scrolled the + page up by the rail (357px measured; heid bug-hunt R9). */ + .rail :is(a,button,input,summary){scroll-margin-top:calc(-1 * var(--rail-pad, 0px))} .item img,.item video{width:100%;height:auto;display:block;background:var(--surface-sunken)} .item audio{width:100%;margin:20px 14px 6px;max-width:calc(100% - 28px)} .item .dl{padding:22px 14px;font-family:var(--font-mono);font-size:var(--size-sm);word-break:break-all} @@ -706,6 +746,9 @@ .mark-opt:has(input:checked)::after{inset:3px;--rl:10px;--rt:1.5px;filter:none} .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)} + /* as S5c (G6): the cursor is real focus, and the reticle and the border + above already mark it, so the focused cursor tile draws no second ring */ + .item.is-cursor:focus-visible{outline:none} /* ⚠ Blur is COSMETIC. The file is still served at its own URL and still in the zip. This hides an item from a glance, nothing more — and nothing here @@ -745,7 +788,9 @@ background:none;color:var(--text-muted);cursor:pointer;-webkit-backdrop-filter:none;backdrop-filter:none} .blur-all button:hover,.reveal-all-btn:hover{color:var(--text-body);border-color:var(--border-strong)} .blur-all.is-on button{color:var(--text-body);border-color:var(--border-strong)} - .reveal-all-btn[aria-pressed="true"]{color:var(--text-body);border-color:var(--border-strong)} + /* as S5c (G17): its "on" look reads the one class on that IS the + state; the words say it to a screen reader, so no aria-pressed */ + .reveal-all .reveal-all-btn{color:var(--text-body);border-color:var(--border-strong)} /* One line each, always: a top bar at phone width squeezed these into four-line stacks. Below 600px the reveal drops its "— blur is cosmetic" tail; the title still says it, as does every per-item reveal. */ @@ -765,26 +810,38 @@ full grid width so prose has a readable measure. */ .item-doc{grid-column:1 / -1} .item-doc.is-closed{display:none} + /* as S5c (G7): the bar is a row holding the fold's summary (its label only) + and the tools beside it, outside the summary, which a screen reader reads + as one button. The fold holds only its summary, so a closed fold keeps the + tools; closing it hides the body and notes by CSS, scripts on or off. */ + /* The tools' padding sets the bar's height, as the old summary's did around + them; the fold stretches to it and its summary fills it, so the whole bar + left of the tools is still the hit area. */ + .doc-bar{display:flex;align-items:stretch;font-family:var(--font-mono);font-size:var(--size-caption); + color:var(--text-muted);background:var(--surface-raised);border-bottom:1px solid var(--border-subtle)} + .doc-fold{flex:1;min-width:0;display:flex} + .doc-sum{flex:1;min-width:0;list-style:none;cursor:pointer;display:flex;align-items:center;gap:8px; + padding:9px 14px;user-select:none} + .doc-sum::-webkit-details-marker{display:none} + .doc-tools{display:flex;align-items:center;gap:8px;padding:9px 14px 9px 0} .doc-inline{display:block} - .doc-inline > .doc-bar{list-style:none;cursor:pointer;display:flex;align-items:center;gap:8px; - padding:9px 14px;font-family:var(--font-mono);font-size:var(--size-caption);color:var(--text-muted); - background:var(--surface-raised);border-bottom:1px solid var(--border-subtle);user-select:none} - .doc-inline > .doc-bar::-webkit-details-marker{display:none} + .item-doc:has(.doc-fold:not([open])) > .doc-inline{display:none} .doc-chevron{color:var(--text-faint);transition:transform var(--dur-1) var(--ease-out);font-size:10px} - .doc-inline[open] > .doc-bar .doc-chevron{transform:rotate(90deg)} + .doc-fold[open] .doc-chevron{transform:rotate(90deg)} .doc-name{color:var(--text-heading);word-break:break-all} /* as S3: at phone width the name takes the bar's full width and breaks only where it must; the actions wrap under it (squeezed, it was one word wide) */ - @media (max-width:600px){.doc-inline > .doc-bar{flex-wrap:wrap;row-gap:4px} - .doc-name{flex:1 1 calc(100% - 3rem);word-break:normal;overflow-wrap:anywhere} - .doc-spacer{display:none}} - .doc-spacer{flex:1} + @media (max-width:600px){.doc-bar{flex-wrap:wrap;row-gap:4px} + .doc-fold{flex:1 1 100%} + .doc-sum{padding-bottom:0} + .doc-tools{padding:0 14px 9px} + .doc-name{flex:1 1 calc(100% - 3rem);word-break:normal;overflow-wrap:anywhere}} .doc-act{color:var(--text-muted);text-decoration:none;padding:2px 6px;border-radius:var(--radius-sm); font-size:13px;line-height:1;background:none;border:0;cursor:pointer;font-family:inherit} .doc-act:hover{color:var(--text-heading);background:var(--surface-sunken);text-decoration:none} .doc-close:hover{color:var(--text-heading)} - .doc-bar .blurtoggle,.doc-bar .flagtoggle{padding:0;background:none} - .doc-bar .blurtoggle{margin-left:4px} + .doc-tools .blurtoggle,.doc-tools .flagtoggle{padding:0;background:none} + .doc-tools .blurtoggle{margin-left:4px} .doc-body{margin:0;border:0;border-radius:0;max-height:32rem;overflow:auto;padding:18px 20px} .doc-body.textview{background:var(--surface-card);border:0;border-radius:0} @@ -1111,7 +1168,7 @@ leaves the tile visible; undershooting hides it. JS on, --rail-h is exact and this changes nothing. */ @media (max-width:480px){ - .item{scroll-margin-top:calc(var(--rail-h, 205px) + 12px)} + html:has(.rail){--rail-pad:calc(var(--rail-h, 205px) + 12px)} } @media (max-width:600px){ main{padding:20px 16px 40px} @@ -1197,18 +1254,36 @@ the line goes back to that one's "Saving…". Otherwise "Saved.", the one message that clears itself, and only if the line still holds that write. */ var CHANGED = 'Saved. You changed it while it was saving, and that change is not saved yet: press again to save it.'; + /* as S5c: every failure that stays, by form, with the words said when it + failed (a one-form failure's STAY, or a batch's refusal), so a later + save says them again instead of burying them under "Saved.". */ var unsent = {}; + function stayed(keys) { + var words = []; + keys.forEach(function (k) { if (words.indexOf(unsent[k]) < 0) words.push(unsent[k]); }); + return words.join(' '); + } + /* as S5c (heid bug-hunt R3): a warning says the failures still standing + first, except those of the forms it is about, so a later failure never + buries an earlier one. (`liveForms` is below.) */ + function also(text, keys) { + var others = Object.keys(unsent).filter(function (k) { + return keys.indexOf(k) < 0 && liveForms(k).length; + }); + var before = stayed(others); + return before ? before + ' ' + text : text; + } function liveForms(key) { return [].filter.call(document.querySelectorAll('form[data-inplace]'), function (f) { return flightKey(f) === key; }); } function landed(keys) { keys.forEach(function (k) { delete unsent[k]; }); - if (keys.some(function (k) { return liveForms(k).some(dirty); })) { warn(CHANGED); return; } + if (keys.some(function (k) { return liveForms(k).some(dirty); })) { warn(also(CHANGED, keys)); return; } var others = Object.keys(pending).some(function (k) { return keys.indexOf(k) < 0; }); if (others) { say('Saving…'); return; } var failed = Object.keys(unsent).filter(function (k) { return liveForms(k).length; }); - if (failed.length) { warn(STAY); return; } + if (failed.length) { warn(stayed(failed)); return; } var n = say('Saved.'); setTimeout(function () { if (said === n) say(''); }, 2000); } @@ -1478,14 +1553,14 @@ var STAY = 'Could not save in place. Reload to see what was saved; your other entries are still here.'; var beat = null; function fail(form, key, snap) { - unsent[key] = true; - if (!mayReload(key, snap)) { warn(STAY); return; } + unsent[key] = STAY; + if (!mayReload(key, snap)) { warn(also(STAY, [key])); return; } /* Said, then reloaded after a beat, so the words are readable rather than a flash before the page goes; asked again at the beat, since a draft typed in the meantime must not go with it. A press inside the beat cancels it: that new save owns the page (heid bug-hunt R2). */ warn('Could not save in place — reloading to show what was saved.'); - beat = setTimeout(function () { beat = null; if (mayReload(key, snap)) reload(); else warn(STAY); }, 900); + beat = setTimeout(function () { beat = null; if (mayReload(key, snap)) reload(); else warn(also(STAY, [key])); }, 900); } /* The POST and the page GET fail differently. A POST that failed did not save. A POST that answered 204 DID save, even if the GET then failed: @@ -1497,10 +1572,10 @@ return refresh([{key: key, snap: snap}]).then(function (placed) { if (placed) { landed([key]); return; } if (mayReload(key, snap)) { reload(); return; } - warn('Saved. The page changed meanwhile; reload to see it.'); + warn(also('Saved. The page changed meanwhile; reload to see it.', [key])); }, function () { if (mayReload(key, snap)) reload(); - else warn('Saved. Could not refresh the page; reload to see it.'); + else warn(also('Saved. Could not refresh the page; reload to see it.', [key])); }); }, function () { fail(form, key, snap); }); } @@ -1573,14 +1648,23 @@ ? 'Saved ' + saved.length + ' of ' + forms.length + '. Not saved: ' + refused.join('; ') + '. Nothing else you entered was cleared.' : ''; + /* as S5c (booth-dev's note): a refused form stays unsent with the + refusal's words, like a one-form failure, so an unrelated save's + "Saved." cannot replace them while it is still on the page. The + refused forms are the ones the server did not take. */ + forms.forEach(function (f) { + var k = flightKey(f); + if (!saved.some(function (x) { return x.key === k; })) unsent[k] = told; + }); var shown = saved.length ? refresh(saved, true) : Promise.resolve(true); + var mine = forms.map(flightKey); return shown.then(function (placed) { - if (!placed) warn((told || 'Saved.') + ' The page changed meanwhile; reload to see it.'); - else if (told) warn(told); - else landed(forms.map(flightKey)); + if (!placed) warn(also((told || 'Saved.') + ' The page changed meanwhile; reload to see it.', mine)); + else if (told) warn(also(told, mine)); + else landed(mine); }, function () { - warn((told || 'Saved ' + saved.length + ' of ' + forms.length + '.') + - ' Could not refresh the page; reload to see what was saved.'); + warn(also((told || 'Saved ' + saved.length + ' of ' + forms.length + '.') + + ' Could not refresh the page; reload to see what was saved.', mine)); }); }); } @@ -1620,18 +1704,31 @@ })(); @@ -591,11 +666,11 @@ to the node it replaced goes with that node. `__bound` keeps a node from being bound twice. - Inline-doc ✕ closes (hides) a rendered doc for the session. The button sits - inside , so without this its click would just toggle the
- open/closed — stopPropagation + preventDefault make ✕ mean "close", not - "collapse". Collapse stays available via the rest of the summary bar. With - JS off the button is inert and collapse via
still works. + Inline-doc ✕ closes (hides) a rendered doc for the session. It sits in the + bar's tools, beside the fold's summary (as S5c, G7), so its click toggles + nothing; stopPropagation + preventDefault are kept as belt and braces. + Collapse is the summary's. With JS off the button is inert and collapse via +
still works. Blur reveal. WARNING: this handler previously sat after the content block's closing tag, which in a child template Jinja DISCARDS — the button rendered @@ -604,10 +679,6 @@ block. Per-viewer and never persisted: a reload re-hides. */ function bindTiles() { function once(el) { if (el.__bound) return false; el.__bound = true; return true; } - /* A form inside would otherwise collapse the doc on submit. */ - document.querySelectorAll('.doc-bar .blurtoggle, .doc-bar .flagtoggle').forEach(function (f) { - if (once(f)) f.addEventListener('click', function (ev) { ev.stopPropagation(); }); - }); document.querySelectorAll('.doc-close').forEach(function (btn) { if (!once(btn)) return; btn.addEventListener('click', function (ev) { @@ -617,16 +688,21 @@ if (item) item.classList.add('is-closed'); }); }); + /* as S5c (G17): the glyph and the word flip in their own spans, so the + name stays the words on screen and the item's .sr-only name stays put */ + function said(btn, on) { + btn.querySelector('.rv-glyph').textContent = on ? '🙈' : '👁'; + btn.querySelector('.rv-word').textContent = on ? 'hide' : 'reveal'; + } document.querySelectorAll('.item.blurred .reveal').forEach(function (btn) { var fig = btn.closest('.item'); - /* a swap carries `revealed` across (base.html); the label follows it */ - btn.textContent = fig.classList.contains('revealed') ? '🙈 hide' : '👁 reveal'; + /* a swap carries `revealed` across (base.html); the words follow it */ + said(btn, fig.classList.contains('revealed')); if (!once(btn)) return; btn.addEventListener('click', function (ev) { ev.preventDefault(); ev.stopPropagation(); - var on = fig.classList.toggle('revealed'); - btn.textContent = on ? '🙈 hide' : '👁 reveal'; + said(btn, fig.classList.toggle('revealed')); }); }); } diff --git a/booth/templates/compare.html b/booth/templates/compare.html index c6f975b..8078b37 100644 --- a/booth/templates/compare.html +++ b/booth/templates/compare.html @@ -31,7 +31,7 @@ {% endif %} - {% if film | selectattr('blurred') | list %}{% endif %} + {% if film | selectattr('blurred') | list %}{% endif %}
@@ -52,7 +52,9 @@
{# Over the stage, never inside its scrolled content; JS-only, so `hidden` until bound (the review's pattern). #} - {% if s.blurred %}{% endif %} + {# as S5c (G17): the name is the words on it, the side's letter as + .sr-only text ("reveal A — blur is cosmetic", then "hide A") #} + {% if s.blurred %}{% endif %}
@@ -216,7 +218,9 @@ btn.hidden = false; btn.addEventListener('click', function () { var on = stage.classList.toggle('revealed'); - btn.textContent = on ? '🙈 hide' : '👁 reveal — blur is cosmetic'; + btn.querySelector('.rv-glyph').textContent = on ? '🙈' : '👁'; + btn.querySelector('.rv-word').textContent = on ? 'hide' : 'reveal'; + btn.querySelector('.rv-note').hidden = on; }); }); @@ -235,29 +239,25 @@ }); 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 || ''))); - } + /* THE KEYS (C3). EVERY key here is left to the focused element when it + uses it, and to the browser whenever Ctrl, Meta or Alt is held: the + review's rule, now one rule for both (as S5c, G6: base.html's + BoothKeys). A focused player keeps every key; a focused 1:1 stage pans. */ 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; + if (BoothKeys.theirs(e)) 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. */ + control (Space presses it) and never from a player on either stage; + BoothKeys has already left it to them. */ else if (k === ' ') { - if (e.target.closest && e.target.closest('button, a, summary, video, audio')) return; e.preventDefault(); step(e.shiftKey ? -1 : 1); } diff --git a/booth/templates/doc.html b/booth/templates/doc.html index f7374c9..8605fb8 100644 --- a/booth/templates/doc.html +++ b/booth/templates/doc.html @@ -9,7 +9,7 @@ {# Reveal all can lift this page's blur, so this page must be able to put it back (r2b, heid bug-hunt). #} - {% if blurred %}{% endif %} + {% if blurred %}{% endif %} ⬇
{# Same record, same reason as the image viewer: the sidecar that says what @@ -24,7 +24,8 @@ doc's own page rendered clear. Its reveal is per-page and JS-only, like the review stage's; Reveal all lifts it by the same class. #}
- {% if blurred %}{% endif %} + {# as S5c (G17): the name is the words on it, the glyph hidden #} + {% if blurred %}{% endif %} {% if is_html %}
{{ body|safe }}
{% else %} @@ -64,7 +65,9 @@ rv.hidden = false; rv.addEventListener('click', function () { var on = document.getElementById('docbody').classList.toggle('revealed'); - rv.textContent = on ? '🙈 hide' : '👁 reveal — blur is cosmetic'; + rv.querySelector('.rv-glyph').textContent = on ? '🙈' : '👁'; + rv.querySelector('.rv-word').textContent = on ? 'hide' : 'reveal'; + rv.querySelector('.rv-note').hidden = on; }); } document.addEventListener('keydown', function (e) { diff --git a/booth/templates/view.html b/booth/templates/view.html index cd5043e..d21410b 100644 --- a/booth/templates/view.html +++ b/booth/templates/view.html @@ -32,7 +32,7 @@ - {% if film | selectattr('blurred') | list %}{% endif %} + {% if film | selectattr('blurred') | list %}{% endif %} ⬇
@@ -61,7 +61,10 @@ outside the stage it can never start a pan. #} {# JS-only, so `hidden` until the script binds it (heid bug-hunt: shown with scripts off, it did nothing) — the toggle's own pattern. #} - {% if blurred %}{% endif %} + {# as S5c (G17): the name is the words on it; the glyph is hidden and the + file rides as .sr-only text ("reveal a.png — blur is cosmetic", then + "hide a.png"). The script flips the spans, never the whole text. #} + {% if blurred %}{% endif %} {% if next_url %}›{% endif %}
+
''' +new = '''
+
''' + +[[mutation]] +label = "S5c the doc's tools wrap under the name on a wide screen" +file = "booth/templates/base.html" +test = "tests/test_antislop_s5c_browser.py::test_the_doc_bar_is_one_row_wide_and_two_on_a_phone" +old = ''' .doc-bar{display:flex;align-items:stretch;''' +new = ''' .doc-bar{display:block;align-items:stretch;''' + +[[mutation]] +label = "S5c a closed fold opens again on the next save" +file = "booth/templates/base.html" +test = "tests/test_antislop_s5c_browser.py::test_a_closed_doc_stays_closed_through_a_save" +old = ''' if (freshDetails[k]) freshDetails[k].open = d.open;''' +new = ''' if (false) freshDetails[k].open = d.open;''' + +[[mutation]] +label = "S5c tile pictures carry no size" +file = "booth/templates/booth.html" +test = "tests/test_antislop.py::test_tile_images_carry_their_drawn_size" +old = '''{% if it.dims %} width="{{ it.dims[0] }}" height="{{ it.dims[1] }}"{% endif %}''' +new = '''''' + +[[mutation]] +label = "S5c tile pictures carry no size, so a link lands off its tile" +file = "booth/templates/booth.html" +test = "tests/test_antislop_s5c_browser.py::test_a_link_to_a_tile_lands_where_it_points" +old = '''{% if it.dims %} width="{{ it.dims[0] }}" height="{{ it.dims[1] }}"{% endif %}''' +new = '''''' + +[[mutation]] +label = "S5c the gallery drops the size on its way to the template" +file = "booth/app.py" +test = "tests/test_antislop.py::test_tile_images_carry_their_drawn_size" +old = ''' "dims": image_dims(child, it),''' +new = ''' "dims": None,''' + +[[mutation]] +label = "S5c the drawn size ignores the EXIF orientation" +file = "booth/thumbs.py" +test = "tests/test_antislop.py::test_tile_images_carry_their_drawn_size" +old = ''' return (h, w) if orientation in (5, 6, 7, 8) else (w, h)''' +new = ''' return (w, h)''' + +[[mutation]] +label = "S5c reading a size decodes the picture (PNG getexif)" +file = "booth/thumbs.py" +test = "tests/test_antislop.py::test_reading_a_size_decodes_nothing" +old = ''' orientation = (im.getexif().get(0x0112, 1) if "exif" in im.info else 1)''' +new = ''' orientation = (im.getexif().get(0x0112, 1))''' + +[[mutation]] +label = "S5c a planted link is read through" +file = "booth/thumbs.py" +test = "tests/test_antislop.py::test_a_planted_fifo_or_link_costs_its_size_and_never_hangs" +old = ''' fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)''' +new = ''' fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK)''' + +[[mutation]] +label = "S5c a planted FIFO hangs the render" +file = "booth/thumbs.py" +test = "tests/test_antislop.py::test_a_planted_fifo_or_link_costs_its_size_and_never_hangs" +old = ''' fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)''' +new = ''' fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)''' + +[[mutation]] +label = "S5c every render reads every header again" +file = "booth/thumbs.py" +test = "tests/test_antislop.py::test_a_size_is_read_once_per_version_of_the_file" +old = '''@functools.lru_cache(maxsize=SIZE_CACHE) +''' +new = '''''' + +[[mutation]] +label = "S5c a replaced file keeps its old size" +file = "booth/thumbs.py" +test = "tests/test_antislop.py::test_a_size_is_read_once_per_version_of_the_file" +old = ''' return _read_size(str(path), st.st_dev, st.st_ino, st.st_size, st.st_mtime_ns, st.st_ctime_ns)''' +new = ''' return _read_size(str(path), st.st_dev, st.st_ino, 0, 0, 0)''' + +[[mutation]] +label = "S5c no scroll padding: a control reached by Tab hides under the rail" +file = "booth/templates/base.html" +test = "tests/test_antislop_s5c_browser.py::test_a_tabbed_control_does_not_hide_under_the_rail" +old = ''' html:has(.rail){--rail-pad:calc(var(--rail-h, 120px) + 12px);scroll-padding-top:var(--rail-pad)}''' +new = ''' html:has(.rail){--rail-pad:calc(var(--rail-h, 120px) + 12px)}''' + +[[mutation]] +label = "S5c the tile keeps its scroll margin too, and a jump lands two rails down" +file = "booth/templates/base.html" +test = "tests/test_antislop_s5c_browser.py::test_a_jump_lands_just_below_the_rail" +old = ''' border-radius:var(--radius-xl);overflow:hidden;display:flex;flex-direction:column;box-shadow:var(--shadow-sm)}''' +new = ''' border-radius:var(--radius-xl);overflow:hidden;display:flex;flex-direction:column;box-shadow:var(--shadow-sm); + scroll-margin-top:calc(var(--rail-h, 120px) + 12px)}''' + +[[mutation]] +label = "S5c with scripts off, a phone falls back to the desktop rail height" +file = "booth/templates/base.html" +test = "tests/test_antislop_s5c_browser.py::test_the_rail_padding_falls_back_with_scripts_off" +old = ''' html:has(.rail){--rail-pad:calc(var(--rail-h, 205px) + 12px)}''' +new = ''' html:has(.rail){--rail-pad:calc(var(--rail-h, 120px) + 12px)}''' + +[[mutation]] +label = "S5c every page gets the rail's scroll padding" +file = "booth/templates/base.html" +test = "tests/test_antislop_s5c_browser.py::test_pages_without_a_rail_have_no_scroll_padding" +old = ''' html:has(.rail){--rail-pad:calc(var(--rail-h, 120px) + 12px);scroll-padding-top:var(--rail-pad)}''' +new = ''' html{--rail-pad:calc(var(--rail-h, 120px) + 12px);scroll-padding-top:var(--rail-pad)}''' + +[[mutation]] +label = "S5c a tile's reveal carries a fixed label again" +file = "booth/templates/booth.html" +test = "tests/test_antislop.py::test_reveal_glyphs_are_hidden_from_the_name" +old = '''
', tile, re.S) + assert tools, "no doc toolbar" + t = tools.group(1) + assert 'href="view?f=notes.md"' in t and 'download' in t, t + assert 'class="blurtoggle' in t and 'class="flagtoggle' in t and "doc-close" in t, t + + +def _image(path, size, exif_orientation=None, fmt="PNG"): + from PIL import Image + im = Image.new("RGB", size, (90, 120, 160)) + if exif_orientation is None: + im.save(path, fmt) + else: + exif = Image.Exif() + exif[0x0112] = exif_orientation + im.save(path, fmt, exif=exif) + + +def _img_size(tile): + img = re.search(r"]*>", tile).group(0) + w, h = re.search(r'\bwidth="(\d+)"', img), re.search(r'\bheight="(\d+)"', img) + return (int(w.group(1)), int(h.group(1))) if w and h else None + + +def test_tile_images_carry_their_drawn_size(client): + """G14: the picture's size as the browser draws it, so a lazy tile reserves + its box before it loads. Orientations 5-8 swap the numbers. Anything whose + header cannot be read safely gets no size, which is today's markup.""" + pytest.importorskip("PIL.Image") + c, data = client + b = data / "g" + b.mkdir() + _image(b / "wide.png", (300, 200)) + _image(b / "turned.jpg", (300, 200), exif_orientation=6, fmt="JPEG") + (b / "broken.png").write_bytes(b"\x89PNG\r\n\x1a\n") + (b / "pic.svg").write_text('') + (b / "link.png").symlink_to(b / "wide.png") + page = c.get("/b/g/").text + got = {r: _img_size(_figure(page, r)) for r in ("wide.png", "turned.jpg", "broken.png", "pic.svg", "link.png")} + assert got == {"wide.png": (300, 200), "turned.jpg": (200, 300), "broken.png": None, + "pic.svg": None, "link.png": None}, got + + +def test_reading_a_size_decodes_nothing(tmp_path, monkeypatch): + """G14: the header only. Pillow's PNG getexif() decodes the whole picture to + look for a late eXIf chunk; a gallery render must not.""" + Image = pytest.importorskip("PIL.Image") + from PIL import ImageFile + from booth import thumbs + _image(tmp_path / "a.png", (300, 200)) + _image(tmp_path / "b.jpg", (300, 200), exif_orientation=6, fmt="JPEG") + + def boom(self, *a, **k): + raise AssertionError("decoded") + monkeypatch.setattr(Image.Image, "load", boom) + monkeypatch.setattr(ImageFile.ImageFile, "load", boom) + thumbs._read_size.cache_clear() + assert thumbs.drawn_size(tmp_path / "a.png") == (300, 200) + assert thumbs.drawn_size(tmp_path / "b.jpg") == (200, 300) + + +_REVEAL = re.compile(r']*(?:class="[^"]*\breveal(?:-all-btn)?\b|data-reveal-all))([^>]*)>(.*?)', re.S) + + +def test_reveal_glyphs_are_hidden_from_the_name(client): + """G17: a reveal control's name is the words on it. No aria-label to + contradict the words after a flip, no aria-pressed on a control whose words + already say its state, and no glyph read aloud.""" + from booth.app import set_blurred + c, data = client + b = _s5_booth(data) + for rel in ("a.png", "c.png", "notes.md"): + set_blurred(b, rel, True) + seen = 0 + for url in ("/b/b/", "/b/b/view?f=a.png", "/b/b/compare?a=a.png&b=c.png", "/b/b/view?f=notes.md"): + for attrs, inner in _REVEAL.findall(_markup(c.get(url).text)): + seen += 1 + assert "aria-label" not in attrs and "aria-pressed" not in attrs, (url, attrs) + bare = re.sub(r']*aria-hidden="true"[^>]*>.*?', "", inner, flags=re.S) + assert not re.search("[\U0001F300-\U0001FAFF]", bare), (url, inner) + assert seen >= 7, seen # tile x3 (two images, the doc), review x2, compare x3, doc page x2 + + +def test_a_planted_fifo_or_link_costs_its_size_and_never_hangs(tmp_path): + """G14: opened O_NONBLOCK, a FIFO with no writer reads as empty instead of + holding the render forever; opened O_NOFOLLOW, a link is refused.""" + import threading + pytest.importorskip("PIL.Image") + from booth.thumbs import drawn_size + _image(tmp_path / "a.png", (300, 200)) + os.mkfifo(tmp_path / "pipe.png") + (tmp_path / "link.png").symlink_to(tmp_path / "a.png") + got = {} + t = threading.Thread(target=lambda: got.update(pipe=drawn_size(tmp_path / "pipe.png")), daemon=True) + t.start() + t.join(3) + assert not t.is_alive() and got == {"pipe": None}, got + assert drawn_size(tmp_path / "link.png") is None + assert drawn_size(tmp_path / "a.png") == (300, 200) # the positive control + + +def test_a_size_is_read_once_per_version_of_the_file(tmp_path, monkeypatch): + """G14: cached by the file's identity, so a gallery render reads each + header once while it is unchanged, and a replaced file is read again.""" + Image = pytest.importorskip("PIL.Image") + from booth import thumbs + _image(tmp_path / "a.png", (300, 200)) + thumbs._read_size.cache_clear() + opened = [] + real = Image.open + monkeypatch.setattr(thumbs._Image, "open", lambda *a, **k: (opened.append(1), real(*a, **k))[1]) + first = [thumbs.drawn_size(tmp_path / "a.png") for _ in range(3)] + _image(tmp_path / "a.png", (120, 480)) + os.utime(tmp_path / "a.png", ns=(1, 1)) + again = thumbs.drawn_size(tmp_path / "a.png") + assert first == [(300, 200)] * 3 and again == (120, 480), (first, again) + assert len(opened) == 2, opened + + +def test_a_file_replaced_in_place_keeps_no_stale_size(tmp_path): + """HRÖSKVA R7: `cp -p` over a file keeps its inode and restores its mtime, + and a same-length replacement kept its size too, so the cache served the + old picture's size. The change time moves on every write.""" + pytest.importorskip("PIL.Image") + from PIL import PngImagePlugin + from booth import thumbs + + def png(size, pad): + info = PngImagePlugin.PngInfo() + info.add_text("pad", "x" * pad) + from PIL import Image + import io + buf = io.BytesIO() + Image.new("RGB", size, (90, 120, 160)).save(buf, "PNG", pnginfo=info) + return buf.getvalue() + a, b = png((300, 200), 0), png((120, 480), 0) + a, b = (png((300, 200), max(0, len(b) - len(a))), png((120, 480), max(0, len(a) - len(b)))) + assert len(a) == len(b), (len(a), len(b)) + f = tmp_path / "a.png" + f.write_bytes(a) + st = os.stat(f) + thumbs._read_size.cache_clear() + first = thumbs.drawn_size(f) + time.sleep(0.01) + with open(f, "r+b") as fh: # in place: same inode, same length + fh.write(b) + os.utime(f, ns=(st.st_atime_ns, st.st_mtime_ns)) + assert os.stat(f).st_ino == st.st_ino and os.stat(f).st_size == st.st_size + assert (first, thumbs.drawn_size(f)) == ((300, 200), (120, 480)) diff --git a/tests/test_antislop_s5c_browser.py b/tests/test_antislop_s5c_browser.py new file mode 100644 index 0000000..ccf957f --- /dev/null +++ b/tests/test_antislop_s5c_browser.py @@ -0,0 +1,610 @@ +"""S5c of the anti-slop fix slices (docs/contracts/as_antislop.contract.md): +whose key it is, the doc bar, tile sizes, the rail's shadow, reveal names, and +a refused batch that an unrelated save must not bury. + +Measured in the same real uvicorn + Chromium harness as test_flow_browser. +Skips, never fails, when no usable Chromium exists. +""" + +import pathlib +import sys + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) +from test_flow_browser import PNG, _picks, _png, _set, browser, live # noqa: E402,F401 (fixtures) +from test_antislop_s5b_browser import COUNT, WIDE, _said, _wait_said, _wait_swaps # noqa: E402 + +PHONE = {"width": 390, "height": 844} + +_RECT = "e => { const b = e.getBoundingClientRect(); return {l: b.left, r: b.right, t: b.top, b: b.bottom}; }" + + +def _page(browser, base, path, viewport=WIDE, js=True): + ctx = browser.new_context(viewport=viewport, java_script_enabled=js) + page = ctx.new_page() + if js: + page.add_init_script(COUNT) + page.goto(f"{base}{path}", wait_until="networkidle") + return page + + +def _active(page): + return page.evaluate("""() => { const a = document.activeElement; + return a === document.body ? 'body' : (a.getAttribute('data-item') || a.id || a.tagName); }""") + + +def _posts(page): + seen = [] + page.on("request", lambda r: seen.append(r.url) if r.method == "POST" else None) + return seen + + +# ---- booth-dev's note: a refused batch is not buried by an unrelated save ------------ + +def test_a_batch_refusal_outlives_an_unrelated_save(browser, live): + """a2's POST is dropped, so the batch says "Not saved: a2" and a2 stays unsent + on the page. An unrelated flag then lands. Its "Saved." must not replace the + refusal's words, which S5b already guarantees for a one-form failure.""" + base, root = live + b = _set(root, 2) + _picks(b, ("a1", "a2")) + page = _page(browser, base, "/b/g/") + page.route("**/b/g/answer", lambda route: route.abort() + if "ask=a2" in (route.request.post_data or "") else route.continue_()) + page.check('.verdict #mark-a1 input[type=radio][value="yes"]') + page.check('.verdict #mark-a2 input[type=radio][value="no"]') + page.locator(".verdict #mark-a1 .mark-submit").click() + _wait_said(page, "Not saved") + page.wait_for_function( + "document.querySelectorAll('.verdict .mark-pick.is-answered').length === 1", timeout=10000) + swaps = page.evaluate("window.__swaps || 0") + page.locator('figure.item[data-item="02.png"] .flagtoggle button').click() + page.wait_for_selector('figure.item.is-flagged[data-item="02.png"]', timeout=10000) + _wait_swaps(page, swaps + 1) + page.wait_for_timeout(2500) # past the 2s beat that clears a "Saved." + said = _said(page) + kept = page.is_checked('.verdict #mark-a2 input[type=radio][value="no"]') + page.close() + assert kept, "the refused pick's choice was lost" + assert said["tone"] == "warn" and "Not saved: a2" in said["text"], said + + +def test_two_standing_failures_are_both_said_once_each(browser, live): + """Each failure that stays is said again after an unrelated save, each + distinct message once, in the order they failed: a note that could not be + saved, then a batch with two refusals.""" + base, root = live + b = _set(root, 3) + _picks(b, ("a1", "a2", "a3")) + page = _page(browser, base, "/b/g/") + fail = {"note": True} + page.route("**/b/g/note", lambda route: route.fulfill(status=500, body="") + if fail["note"] else route.continue_()) + page.route("**/b/g/answer", lambda route: route.abort() + if "ask=a1" not in (route.request.post_data or "") else route.continue_()) + page.locator(".verdict .mark-add textarea").fill("a draft that stays") # so no reload + tile = 'figure.item[data-item="01.png"]' + page.locator(f"{tile} .item-addnote summary").click() + page.locator(f"{tile} .item-addnote textarea").fill("did not go") + page.locator(f"{tile} .item-addnote button").click() + _wait_said(page, "Could not save in place. Reload to see what was saved") + for q, v in (("a1", "yes"), ("a2", "no"), ("a3", "no")): + page.check(f'.verdict #mark-{q} input[type=radio][value="{v}"]') + page.locator(".verdict #mark-a1 .mark-submit").click() + _wait_said(page, "Not saved") + at_refusal = _said(page)["text"] # R3: the note's failure is not buried + swaps = page.evaluate("window.__swaps || 0") + page.locator('figure.item[data-item="02.png"] .flagtoggle button').click() + page.wait_for_selector('figure.item.is-flagged[data-item="02.png"]', timeout=10000) + _wait_swaps(page, swaps + 1) + page.wait_for_timeout(2500) + said = _said(page) + page.close() + assert "Could not save in place" in at_refusal, at_refusal + text = said["text"] + assert said["tone"] == "warn", said + assert text.count("Could not save in place") == 1 and text.count("Not saved: a2") == 1, text + assert text.index("Could not save in place") < text.index("Not saved: a2"), text + assert "a3" in text, text + + +# ---- G6: a page's keys never take a key the focused element uses --------------------- + +def _media_set(root): + b = root / "g" + b.mkdir() + for n in ("01.png", "03.png"): + (b / n).write_bytes(PNG) + (b / "02.webm").write_bytes(b"\x1a\x45\xdf\xa3") + return b + + +def test_a_player_keeps_its_keys(browser, live): + base, root = live + _media_set(root) + # the review: a focused player keeps the arrows, the letters and Escape + page = _page(browser, base, "/b/g/view?f=02.webm") + posts = _posts(page) + page.locator("#vmedia").focus() + assert _active(page) == "vmedia", "precondition: the player takes focus" + for key in ("ArrowRight", "ArrowLeft", "f", "Space"): + page.keyboard.press(key) + page.wait_for_timeout(600) + stayed = page.url.endswith("view?f=02.webm") + # ...but not Escape, which a player has no use for outside fullscreen (heid + # bug-hunt HRÖSKVA R8): Esc still goes back to the grid + with page.expect_navigation(): + page.keyboard.press("Escape") + back = "/b/g/" in page.url and "view?" not in page.url + page.goto(f"{base}/b/g/view?f=02.webm", wait_until="networkidle") + with page.expect_navigation(): # the negative control: from the page it moves + page.keyboard.press("ArrowRight") + moved = page.url.endswith("view?f=03.png") + page.close() + assert stayed and posts == [], (stayed, posts) + assert back, "Escape from a focused player did not go back to the grid" + assert moved + + # the grid: an arrow on a focused tile video is the player's, not the cursor's + page = _page(browser, base, "/b/g/") + page.locator('figure.item[data-item="02.webm"] video').focus() + page.keyboard.press("ArrowRight") + page.wait_for_timeout(300) + none = page.locator("figure.item.is-cursor").count() + page.evaluate("document.activeElement.blur()") + page.keyboard.press("ArrowRight") + page.wait_for_selector("figure.item.is-cursor", timeout=5000) + page.close() + assert none == 0 + + # compare: an arrow on a focused player does not step the pair + page = _page(browser, base, "/b/g/compare?a=01.png&b=02.webm") + page.locator(".cmp-media").focus() + page.keyboard.press("ArrowRight") + page.wait_for_timeout(600) + kept = "b=02.webm" in page.url + page.close() + assert kept + + +def test_enter_on_a_control_does_not_open_the_cursor_tile(browser, live): + base, root = live + _set(root, 3) + page = _page(browser, base, "/b/g/") + page.keyboard.press("ArrowRight") + page.wait_for_selector("figure.item.is-cursor", timeout=5000) + summary = page.locator('figure.item[data-item="03.png"] .item-addnote summary') + summary.focus() + page.keyboard.press("Enter") + page.wait_for_timeout(600) + stayed = page.url.endswith("/b/g/") + opened = page.locator('figure.item[data-item="03.png"] details.item-addnote').get_attribute("open") + # nor from any other focused node that is not the page itself (the rail stands in + # for one, as an S5b fallback region would be) + page.evaluate("() => { const r = document.querySelector('.rail'); r.setAttribute('tabindex', '-1'); r.focus(); }") + page.keyboard.press("Enter") + page.wait_for_timeout(600) + stayed = stayed and page.url.endswith("/b/g/") + # the positive control: Enter on the focused cursor tile opens its review + page.locator('figure.item[data-item="01.png"]').focus() + with page.expect_navigation(): + page.keyboard.press("Enter") + reviewed = page.url.endswith("view?f=01.png") + page.close() + assert stayed, "Enter on a summary opened the cursor tile" + assert opened is not None, "Enter did not open the summary it was pressed on" + assert reviewed + + +def test_a_focused_pannable_stage_pans_with_the_arrows(browser, live): + from test_flow_browser import _stage_set + base, root = live + _stage_set(root, {"a.png": (3000, 2400), "b.png": (400, 300)}) + page = _page(browser, base, "/b/g/view?f=a.png") + page.locator("#btn-one").click() + page.wait_for_selector("#vstage.can-pan", timeout=5000) + page.locator("#vstage").focus() + assert _active(page) == "vstage", "precondition: Chromium puts a scroller in the Tab order" + page.keyboard.press("ArrowRight") + page.wait_for_timeout(600) + stayed = page.url.endswith("view?f=a.png") + panned = page.evaluate("document.getElementById('vstage').scrollLeft") + page.evaluate("document.activeElement.blur()") + with page.expect_navigation(): + page.keyboard.press("ArrowRight") + moved = page.url.endswith("view?f=b.png") + page.close() + assert stayed and panned > 0, (stayed, panned) + assert moved + + +def test_the_grid_cursor_is_real_focus(browser, live): + base, root = live + _set(root, 5) + page = _page(browser, base, "/b/g/") + page.keyboard.press("ArrowRight") + page.wait_for_selector("figure.item.is-cursor", timeout=5000) + first = page.evaluate("""() => { const a = document.activeElement; + return {cursor: a.classList.contains('is-cursor'), item: a.getAttribute('data-item'), + tabbed: [...document.querySelectorAll('figure.item[tabindex]')].map(e => e.getAttribute('data-item')), + outline: getComputedStyle(a).outlineStyle}; }""") + page.keyboard.press("ArrowRight") + second = page.evaluate("""() => { const a = document.activeElement; + return {cursor: a.classList.contains('is-cursor'), item: a.getAttribute('data-item'), + tabbed: [...document.querySelectorAll('figure.item[tabindex]')].map(e => e.getAttribute('data-item'))}; }""") + page.keyboard.press("Escape") + after = {"cursors": page.locator("figure.item.is-cursor").count(), "active": _active(page)} + page.close() + assert first == {"cursor": True, "item": "01.png", "tabbed": ["01.png"], "outline": "none"}, first + assert second == {"cursor": True, "item": "02.png", "tabbed": ["02.png"]}, second + assert after == {"cursors": 0, "active": "body"}, after + + +def test_focus_returned_to_a_tile_makes_it_the_cursor(browser, live): + from booth.marks import write_note + base, root = live + b = _set(root, 8) + write_note(b, "05.png", "withdraw me") + page = _page(browser, base, "/b/g/") + page.locator('figure.item[data-item="05.png"] .item-note .mark-x').focus() + page.keyboard.press("Enter") + page.wait_for_function( + "!document.querySelector('figure.item[data-item=\"05.png\"] .item-note')", timeout=10000) + _wait_swaps(page, 1) + landed = page.evaluate("""() => { const a = document.activeElement; + return {item: a.getAttribute('data-item'), cursor: a.classList.contains('is-cursor')}; }""") + page.keyboard.press("ArrowRight") + moved = _active(page) + page.close() + assert landed == {"item": "05.png", "cursor": True}, landed + assert moved == "06.png", moved + + +# ---- G7: the doc bar's controls leave its --------------------------------- + +def _doc_set(root): + b = root / "g" + b.mkdir() + (b / "notes.md").write_text("# Notes\n\n" + "A line of prose. " * 40 + "\n") + for n in ("01.png", "02.png"): + (b / n).write_bytes(PNG) + return b + + +def test_a_closed_doc_keeps_its_tools(browser, live): + base, root = live + _doc_set(root) + for js in (True, False): + page = _page(browser, base, "/b/g/", js=js) + doc = 'figure.item-doc[data-item="notes.md"]' + page.locator(f"{doc} summary.doc-sum").click() + page.wait_for_timeout(200) + state = page.evaluate("""d => { const f = document.querySelector(d); + const tools = f.querySelector('.doc-tools').getBoundingClientRect(); + return {body: f.querySelector('.doc-body').getClientRects().length > 0, + tools: tools.width > 0 && tools.height > 0}; }""", doc) + assert state == {"body": False, "tools": True}, (js, state) # the body is not rendered + if js: + page.locator(f"{doc} .doc-tools .flagtoggle button").click() + page.wait_for_function( + "d => document.querySelector(d + ' .doc-tools .flagtoggle button').textContent.includes('flagged')", + arg=doc, timeout=10000) + assert page.evaluate("window.__swaps || 0") >= 1, "the flag reloaded instead of landing in place" + page.close() + + +def test_the_doc_bar_is_one_row_wide_and_two_on_a_phone(browser, live): + base, root = live + _doc_set(root) + got = {} + for label, vp in (("wide", {"width": 1280, "height": 900}), ("phone", PHONE)): + page = _page(browser, base, "/b/g/", viewport=vp) + doc = 'figure.item-doc[data-item="notes.md"]' + got[label] = {"sum": page.locator(f"{doc} summary.doc-sum").evaluate(_RECT), + "name": page.locator(f"{doc} .doc-name").evaluate(_RECT), + "tools": page.locator(f"{doc} .doc-tools").evaluate(_RECT)} + page.close() + w, p = got["wide"], got["phone"] + assert w["tools"]["t"] < w["sum"]["b"] and w["tools"]["b"] > w["sum"]["t"], w # one row + assert p["tools"]["t"] >= p["name"]["b"] - 1, p # under the name + + +def test_a_closed_doc_stays_closed_through_a_save(browser, live): + base, root = live + _doc_set(root) + page = _page(browser, base, "/b/g/") + doc = 'figure.item-doc[data-item="notes.md"]' + page.locator(f"{doc} summary.doc-sum").click() + page.locator('figure.item[data-item="01.png"] .flagtoggle button').click() + page.wait_for_selector('figure.item.is-flagged[data-item="01.png"]', timeout=10000) + _wait_swaps(page, 1) + state = page.evaluate("""d => { const f = document.querySelector(d); + return {open: f.querySelector('details.doc-fold').open, + body: f.querySelector('.doc-body').getClientRects().length > 0}; }""", doc) + page.close() + assert state == {"open": False, "body": False}, state + + +# ---- G14: a gallery tile's picture carries its size --------------------------------- + +def _tall_set(root, n=40): + b = root / "g" + b.mkdir() + for i in range(1, n + 1): + (b / f"{i:02d}.png").write_bytes(_png(400, 500 + (i * 37) % 400)) + return b + + +def _landing(page, rel): + return page.evaluate("""r => ({top: document.getElementById('item-' + r).getBoundingClientRect().top, + rail: document.querySelector('.rail').getBoundingClientRect().bottom})""", rel) + + +def test_a_link_to_a_tile_lands_where_it_points(browser, live): + """Measured before the fix: the tile's top sat 44px below its mark (3 of 3).""" + base, root = live + _tall_set(root) + page = _page(browser, base, "/b/g/#item-30.png") + page.wait_for_timeout(600) + got = _landing(page, "30.png") + page.close() + assert abs(got["top"] - (got["rail"] + 12)) <= 2, got + + +# ---- G15: nothing reached by Tab sits under the sticky rail ------------------------- + +def test_a_tabbed_control_does_not_hide_under_the_rail(browser, live): + """Measured before the fix: the Tab-focused flag button sat at 19.6px, under + the rail's bottom at 47.6px.""" + base, root = live + _tall_set(root, 16) + page = _page(browser, base, "/b/g/", viewport={"width": 1400, "height": 700}) + btn = 'figure.item[data-item="10.png"] .flagtoggle button' + page.evaluate("""s => { const all = [...document.querySelectorAll('a[href],button,summary,input,textarea')] + .filter(e => e.offsetParent); + const b = document.querySelector(s); all[all.indexOf(b) - 1].focus({preventScroll: true}); + window.scrollBy(0, b.getBoundingClientRect().top - 20); }""", btn) + page.wait_for_timeout(200) + page.keyboard.press("Tab") + page.wait_for_timeout(400) + got = page.evaluate("""s => ({on: document.activeElement === document.querySelector(s), + top: document.activeElement.getBoundingClientRect().top, + rail: document.querySelector('.rail').getBoundingClientRect().bottom})""", btn) + page.close() + assert got["on"], "precondition: Tab reached the flag button" + assert got["top"] >= got["rail"], got + + +def test_a_jump_lands_just_below_the_rail(browser, live): + """One offset, not two: scroll padding on the page REPLACES the tile's scroll + margin, since the two add.""" + base, root = live + _set(root, 40) + page = _page(browser, base, "/b/g/#item-31.png", viewport={"width": 1400, "height": 700}) + page.wait_for_timeout(400) + got = _landing(page, "31.png") + page.close() + assert abs(got["top"] - (got["rail"] + 12)) <= 2, got + + +def test_pages_without_a_rail_have_no_scroll_padding(browser, live): + base, root = live + _set(root, 3) + got = {} + for path in ("/", "/b/g/view?f=01.png", "/b/g/"): + page = _page(browser, base, path) + got[path] = page.evaluate("getComputedStyle(document.documentElement).scrollPaddingTop") + page.close() + assert got["/"] == "auto" and got["/b/g/view?f=01.png"] == "auto", got + assert got["/b/g/"] != "auto", got # the positive control + + +def test_the_rail_padding_falls_back_with_scripts_off(browser, live): + """With scripts off --rail-h is unset and its fallback stands in: 120px, or + the measured worst case at <=480px, 205px. The 12px is added either way.""" + base, root = live + _set(root, 3) + got = {} + for label, vp in (("wide", {"width": 1400, "height": 900}), ("phone", PHONE)): + page = _page(browser, base, "/b/g/", viewport=vp, js=False) + got[label] = page.evaluate("getComputedStyle(document.documentElement).scrollPaddingTop") + page.close() + assert got == {"wide": "132px", "phone": "217px"}, got + + +# ---- G17: a reveal control's name is the words on it --------------------------------- + +def _named(page, name): + return page.get_by_role("button", name=name, exact=True).count() + + +def test_reveal_names_follow_their_words(browser, live): + from booth.app import set_blurred + base, root = live + b = root / "g" + b.mkdir() + for rel in ("a.png", "b.png"): + (b / rel).write_bytes(_png(40, 30)) + set_blurred(b, rel, True) + (b / "notes.md").write_text("# Notes\n\nprose\n") + set_blurred(b, "notes.md", True) + got = {} + + page = _page(browser, base, "/b/g/") + got["tile"] = [_named(page, "reveal a.png")] + page.get_by_role("button", name="reveal a.png", exact=True).click() + got["tile"] += [_named(page, "hide a.png")] + ra = page.locator("[data-reveal-all]") + page.mouse.move(1, 1) + look = ra.evaluate("e => getComputedStyle(e).borderTopColor") + got["all"] = [_named(page, "reveal all — blur is cosmetic")] + ra.click() + # the pointer leaves first: :hover draws the same border as the "on" look, + # and a read under the pointer measured the hover (a vacuous gate run) + page.mouse.move(1, 1) + page.wait_for_timeout(300) + got["all"] += [_named(page, "blur again"), ra.get_attribute("aria-pressed"), + ra.evaluate("e => getComputedStyle(e).borderTopColor") != look] + page.close() + + page = _page(browser, base, "/b/g/view?f=a.png") + got["review"] = [_named(page, "reveal a.png — blur is cosmetic")] + page.locator("#vreveal").click() + got["review"] += [_named(page, "hide a.png")] + page.close() + + page = _page(browser, base, "/b/g/compare?a=a.png&b=b.png") + got["compare"] = [_named(page, "reveal A — blur is cosmetic")] + page.locator('.cmp-reveal[data-side="a"]').click() + got["compare"] += [_named(page, "hide A")] + page.close() + + page = _page(browser, base, "/b/g/view?f=notes.md") + got["doc"] = [_named(page, "reveal notes.md — blur is cosmetic")] + page.locator("#docreveal").click() + got["doc"] += [_named(page, "hide notes.md")] + page.close() + + assert got == {"tile": [1, 1], "all": [1, 1, None, True], "review": [1, 1], + "compare": [1, 1], "doc": [1, 1]}, got + + +# ---- folded from the heid bug-hunt (HRÖSKVA) ----------------------------------------- + +def _doc_between(root): + b = root / "g" + b.mkdir() + for n in ("01.png", "03.png", "04.png"): + (b / n).write_bytes(PNG) + (b / "02-notes.md").write_text("# Notes\n\nprose\n") + return b + + +def test_the_cursor_skips_a_closed_doc(browser, live): + """R1: a doc closed with its ✕ is display:none, and a cursor that landed on + it could not take focus while f and Enter acted on it blind.""" + base, root = live + _doc_between(root) + page = _page(browser, base, "/b/g/") + page.locator('figure.item-doc[data-item="02-notes.md"] .doc-close').click() + page.evaluate("document.activeElement.blur(); scrollTo(0, 0)") + page.keyboard.press("ArrowRight") + page.wait_for_selector("figure.item.is-cursor", timeout=5000) + page.keyboard.press("ArrowRight") + got = page.evaluate("""() => ({active: document.activeElement.getAttribute('data-item'), + cursor: [...document.querySelectorAll('figure.item.is-cursor')].map(e => e.getAttribute('data-item')), + tabbed: [...document.querySelectorAll('figure.item[tabindex]')].map(e => e.getAttribute('data-item'))})""") + page.close() + assert got == {"active": "03.png", "cursor": ["03.png"], "tabbed": ["03.png"]}, got + + +def test_n_opens_the_note_on_a_closed_doc(browser, live): + """R10: the note field sits in the doc's body, which a closed fold hides.""" + base, root = live + _doc_between(root) + page = _page(browser, base, "/b/g/") + page.locator('figure.item-doc[data-item="02-notes.md"] summary.doc-sum').click() + page.evaluate("document.activeElement.blur(); scrollTo(0, 0)") + page.keyboard.press("ArrowRight") + page.keyboard.press("ArrowRight") + page.wait_for_selector('figure.item-doc.is-cursor[data-item="02-notes.md"]', timeout=5000) + page.keyboard.press("n") + got = page.evaluate("""() => { const f = document.querySelector('figure.item-doc[data-item="02-notes.md"]'); + return {open: f.querySelector('details.doc-fold').open, + typing: document.activeElement === f.querySelector('.item-addnote textarea')}; }""") + page.close() + assert got == {"open": True, "typing": True}, got + + +def test_enter_reviews_a_file_named_view(browser, live): + """R5: Enter followed the first link whose href merely STARTED with "view", + and a media tile's download link comes first: views.webm downloaded. A file + with no review (viewer.zip) is not downloaded by Enter either.""" + base, root = live + b = root / "g" + b.mkdir() + (b / "views.webm").write_bytes(b"\x1a\x45\xdf\xa3") + (b / "viewer.zip").write_bytes(b"PK\x05\x06" + b"\x00" * 18) + page = _page(browser, base, "/b/g/") + downloads = [] + page.on("download", lambda d: downloads.append(d.suggested_filename)) + page.locator('figure.item[data-item="viewer.zip"]').evaluate("e => e.setAttribute('tabindex', '-1')") + page.locator('figure.item[data-item="viewer.zip"]').focus() # the cursor follows focus + page.keyboard.press("Enter") + page.wait_for_timeout(800) + stayed = page.url.endswith("/b/g/") + page.locator('figure.item[data-item="views.webm"]').evaluate("e => e.setAttribute('tabindex', '-1')") + page.locator('figure.item[data-item="views.webm"]').focus() + with page.expect_navigation(): + page.keyboard.press("Enter") + reviewed = page.url.endswith("view?f=views.webm") + page.wait_for_timeout(300) + page.close() + assert stayed and reviewed and downloads == [], (stayed, reviewed, downloads) + + +def test_the_rail_height_follows_the_live_rail_after_a_save(browser, live): + """R2: the rail is a region a save replaces. The height script watched the + node it found first, so after a save it measured a detached rail.""" + base, root = live + _set(root, 6) + page = _page(browser, base, "/b/g/") + page.locator('figure.item[data-item="02.png"] .flagtoggle button').click() + page.wait_for_selector('figure.item.is-flagged[data-item="02.png"]', timeout=10000) + _wait_swaps(page, 1) + page.wait_for_timeout(300) + probe = """() => ({var: getComputedStyle(document.documentElement).getPropertyValue('--rail-h').trim(), + live: document.querySelector('.rail').offsetHeight + 'px'})""" + after_save = page.evaluate(probe) + page.set_viewport_size({"width": 420, "height": 800}) # the rail wraps taller + page.wait_for_timeout(400) + after_resize = page.evaluate(probe) + page.close() + assert after_save["var"] == after_save["live"], after_save + assert after_resize["var"] == after_resize["live"], after_resize + + +def test_tab_between_rail_links_does_not_move_the_page(browser, live): + """R9: the page's scroll padding clears the rail for everything under it, + and a control IN the stuck rail is not under it. Measured before: each Tab + between group links scrolled the page up 357px (3 of 3).""" + base, root = live + b = root / "g" + b.mkdir() + for grp in ("aa", "bb", "cc", "dd", "ee"): + for i in range(1, 13): + (b / f"{grp}{i:02d}.png").write_bytes(PNG) + page = _page(browser, base, "/b/g/", viewport={"width": 1400, "height": 700}) + page.evaluate("scrollTo(0, 2000)") + page.wait_for_timeout(300) + page.evaluate("document.querySelectorAll('.rail-g')[1].focus({preventScroll: true})") + y = page.evaluate("scrollY") + page.keyboard.press("Tab") + page.wait_for_timeout(400) + got = {"on": page.evaluate("document.activeElement.className"), "moved": page.evaluate("scrollY") - y} + page.close() + assert got == {"on": "rail-g", "moved": 0}, got + + +def test_a_later_failure_does_not_bury_an_earlier_one(browser, live): + """R3: every warning says the failures still standing first, so a later + failure never buries an earlier one: here a batch refusal, then a note.""" + base, root = live + b = _set(root, 3) + _picks(b, ("a1", "a2")) + page = _page(browser, base, "/b/g/") + page.route("**/b/g/answer", lambda route: route.abort() + if "ask=a2" in (route.request.post_data or "") else route.continue_()) + page.route("**/b/g/note", lambda route: route.fulfill(status=500, body="")) + page.check('.verdict #mark-a1 input[type=radio][value="yes"]') + page.check('.verdict #mark-a2 input[type=radio][value="no"]') + page.locator(".verdict #mark-a1 .mark-submit").click() + _wait_said(page, "Not saved") + page.locator(".verdict .mark-add textarea").fill("a draft that stays") + tile = 'figure.item[data-item="01.png"]' + page.locator(f"{tile} .item-addnote summary").click() + page.locator(f"{tile} .item-addnote textarea").fill("did not go") + page.locator(f"{tile} .item-addnote button").click() + _wait_said(page, "Could not save in place") + text = _said(page)["text"] + page.close() + assert "Not saved: a2" in text and text.index("Not saved: a2") < text.index("Could not save in place"), text