fix(as-S5c): whose key it is, the doc bar, tile sizes, the rail's shadow, reveal names

The last of the anti-slop interaction work (guidelines G6, G7, G14, G15,
G17), plus booth-dev's note from S5b's gate. Every S5b promise holds: no
re-POST, serialized saves, a batch never reloads, focus survives a swap.
- Keys (G6): one rule in base.html's <head>, BoothKeys.theirs(e), called
  first by the grid, the review and compare. A field or a player owns every
  key but Escape (Esc still goes back from a focused player); a control
  owns Space; a focused 1:1 stage that can pan owns the
  arrows and Space (Chromium puts it in the Tab order); Ctrl/Meta/Alt are
  the browser's. The field check lives on as BoothKeys.isEditable. Before:
  an arrow on a focused video left the review, and Enter on any control
  also opened the grid cursor's tile.
- The grid cursor is real focus: the tile it moves to gets tabindex=-1
  (script-set, one tile at a time) and focus, without a scroll; the cursor
  is an item (its data-item), and a doc closed with its ✕ is skipped; focus that
  lands on a tile (S5b's fallback) makes it the cursor; Enter opens the
  review only from the body, the grid or the tile, by its view?f= link;
  n opens a closed doc's fold; Escape clears the cursor
  and releases the tile's focus. The reticle is its focus mark (no second
  ring).
- The doc bar (G7): the controls leave the <summary>. div.doc-bar holds
  details.doc-fold (its summary is the label only) and div.doc-tools beside
  it; the body and notes follow in div.doc-inline, hidden with a closed
  fold by :has(), scripts on or off. A closed doc keeps its tools. Renders
  pixel-identical to today at 1280 and 390, light and dark.
- Tile sizes (G14): a gallery tile's <img> carries width/height, the
  picture as the browser draws it (EXIF 5-8 swap), read from the header
  only (no decode; PNG getexif is skipped unless the header carried it),
  opened O_NOFOLLOW|O_NONBLOCK, cached by the file's identity (ctime
  included, so cp -p over a file is seen), in a separate
  step (items.image_dims over thumbs.drawn_size) so the Desk never pays it.
  Measured before: a link to tile 30 of 40 landed 44px low (3/3); after, on
  its mark. content-visibility:auto, which the report proposed too, is NOT
  added: a swapped-in tile has no remembered size, and a flag far down moved
  the page 2929px (3/3; 0px without it).
- The rail (G15): html:has(.rail){scroll-padding-top} replaces .item's
  scroll-margin-top (the two add), so a control reached by Tab stops below
  the sticky rail too. Measured before: a Tab-focused flag button at 19.6px,
  under the rail's bottom at 47.6px. The scripts-off fallbacks are the old
  rules' numbers (132px, 217px at <=480), now pinned by a test. The height
  script follows the live rail after every in-place save (it watched the
  replaced node, and read 0px after one flag), and the rail's own controls
  cancel the padding (a Tab between stuck group links scrolled 357px).
- Reveal names (G17): no aria-label on any reveal control; the name is the
  words on it, the glyph in an aria-hidden span, the item's name as
  .sr-only text ("reveal a.png" / "hide a.png"). Reveal all drops
  aria-pressed (its words already say the state; r2b rules them) and its
  "on" look reads the .reveal-all class on <html>. No pixel changes.
- booth-dev's note: a refused batch's forms enter `unsent` with the
  refusal's words, and a later save says every standing failure's words
  (each once, in order) instead of "Saved.", and every warning says the
  other standing failures first, so no failure buries another. Test first:
  test_a_batch_refusal_outlives_an_unrelated_save.
- Rows re-anchored to the same failure: r2b "Space on a focused review
  button", r3 "C3 a held modifier" and both "C3 Space on a focused ..."
  (now in BoothKeys), r2c "the stage reveal shows with scripts off", and
  this contract's S3 doc-bar row and five S5b status-line rows.

Folded from the heid contract review (BEINKA, panel 4/4, thread
01M3NZJNX8D3BEYD48M9K3MV3Q): 24 flags, all prose the tests left open; the
contract states the tile/focus/cursor seam with S5b, the helper's union and
scope, the size's source and every path to none, Reveal all's name, the
refusal sentence's lifetime, and the fallback arithmetic (one test added).

Folded from the heid bug-hunt (HRÖSKVA, panel 4/4, thread
01M3P0ZPRSASFSE5K3PR4NTQP6): R1 closed docs and the cursor as an item, R2
the rail's height after a save, R3 no warning buries another, R5 the view?f=
link, R7 ctime in the size cache, R8 Escape from a player, R9 the rail's own
controls, R10 n on a closed doc. Refuted with reasons: R4 (unreachable: refused
picks re-send together), R6 (Chrome takes the same header's size with or
without the attributes; measured), R11 (by design).

From this slice's own falsifier runs: a "one row wide" row that mutated a
flex basis a non-wrapping bar just shrinks (re-aimed at the bar's flex), and
a Reveal-all "on look" read under the clicking pointer, where :hover draws
the same border (the pointer now leaves first; 3/3 proved).

Contract: as_antislop S5c.
Falsifiers: antislop.toml S5c section.
This commit is contained in:
vh
2026-09-29 01:09:15 -07:00
parent 62c0c9b638
commit 72d6c61629
15 changed files with 1730 additions and 136 deletions
+361 -7
View File
@@ -283,8 +283,9 @@ new = ''' @media (max-width:1px){.board-head{flex-wrap:wrap}.board-title{white-
label = "S3 the doc bar squeezes the name again"
file = "booth/templates/base.html"
test = "tests/test_antislop_browser.py::test_doc_name_keeps_a_readable_line_on_a_phone"
old = ''' @media (max-width:600px){.doc-inline > .doc-bar{flex-wrap:wrap;row-gap:4px}'''
new = ''' @media (max-width:1px){.doc-inline > .doc-bar{flex-wrap:wrap;row-gap:4px}'''
# re-anchored in S5c (G7): the bar is a div holding the fold and the tools
old = ''' @media (max-width:600px){.doc-bar{flex-wrap:wrap;row-gap:4px}'''
new = ''' @media (max-width:1px){.doc-bar{flex-wrap:wrap;row-gap:4px}'''
[[mutation]]
label = "S3 a file tile's link starts under the number again"
@@ -903,9 +904,10 @@ new = '''if (!batch.length) return;'''
label = "S5b a batch that lands says nothing"
file = "booth/templates/base.html"
test = "tests/test_antislop_s5b_browser.py::test_a_batch_speaks_too"
# re-anchored in S5c (heid bug-hunt R3): the batch's keys are named once, as `mine`
old = '''
else landed(forms.map(flightKey));'''
else landed(mine);'''
new = ''''''
[[mutation]]
@@ -963,7 +965,8 @@ new = ''' { reload(); return; }'''
label = "S5b the reload is not asked again at the beat"
file = "booth/templates/base.html"
test = "tests/test_antislop_s5b_browser.py::test_a_draft_typed_during_the_beat_stays"
old = ''' beat = setTimeout(function () { beat = null; if (mayReload(key, snap)) reload(); else warn(STAY); }, 900);'''
# re-anchored in S5c (heid bug-hunt R3): the stay message now says the other standing failures first
old = ''' beat = setTimeout(function () { beat = null; if (mayReload(key, snap)) reload(); else warn(also(STAY, [key])); }, 900);'''
new = ''' beat = setTimeout(reload, 900);'''
[[mutation]]
@@ -1053,7 +1056,8 @@ new = ''''''
label = "S5b an unrelated save buries a failure"
file = "booth/templates/base.html"
test = "tests/test_antislop_s5b_browser.py::test_an_unrelated_save_does_not_bury_a_failure"
old = ''' if (failed.length) { warn(STAY); return; }
# re-anchored in S5c: a standing failure now carries its own words
old = ''' if (failed.length) { warn(stayed(failed)); return; }
'''
new = ''''''
@@ -1061,7 +1065,8 @@ new = ''''''
label = "S5b a refresh failure after a 204 says it could not save"
file = "booth/templates/base.html"
test = "tests/test_antislop_s5b_browser.py::test_a_save_whose_page_would_not_refresh_says_saved"
old = ''' else warn('Saved. Could not refresh the page; reload to see it.');'''
# re-anchored in S5c (heid bug-hunt R3)
old = ''' else warn(also('Saved. Could not refresh the page; reload to see it.', [key]));'''
new = ''' else fail(form, key, snap);'''
[[mutation]]
@@ -1075,7 +1080,8 @@ new = ''' if (false) {'''
label = "S5b an edit made while saving is called Saved."
file = "booth/templates/base.html"
test = "tests/test_antislop_s5b_browser.py::test_an_edit_made_while_saving_is_not_called_saved"
old = ''' if (keys.some(function (k) { return liveForms(k).some(dirty); })) { warn(CHANGED); return; }
# re-anchored in S5c (heid bug-hunt R3)
old = ''' if (keys.some(function (k) { return liveForms(k).some(dirty); })) { warn(also(CHANGED, keys)); return; }
'''
new = ''''''
@@ -1110,3 +1116,351 @@ file = "booth/templates/_marks.html"
test = "tests/test_antislop.py::test_the_in_place_client_can_read_every_page"
old = '''<form class="mark-add" method="post" action="/b/{{ name_url }}/note" data-inplace>'''
new = '''<form class="mark-add" method="post" action="/b/{{ name_url }}/note" data-inplace data-region="nested">'''
# ---- S5c: whose key it is, the doc bar, tile sizes, the rail's shadow, reveal names --------
[[mutation]]
label = "S5c a player gives up its keys to the page"
file = "booth/templates/base.html"
test = "tests/test_antislop_s5c_browser.py::test_a_player_keeps_its_keys"
old = ''' if (BoothKeys.isEditable(t) || (k !== 'Escape' && t.closest('video, audio'))) return true;'''
new = ''' if (BoothKeys.isEditable(t)) return true;'''
[[mutation]]
label = "S5c Enter opens the cursor tile from a control or any focused node"
file = "booth/templates/booth.html"
test = "tests/test_antislop_s5c_browser.py::test_enter_on_a_control_does_not_open_the_cursor_tile"
old = ''' if (e.target === document.body || e.target === grid || tiles().indexOf(e.target) >= 0) {'''
new = ''' if (true) {'''
[[mutation]]
label = "S5c a focused 1:1 stage loses its arrows to the page"
file = "booth/templates/base.html"
test = "tests/test_antislop_s5c_browser.py::test_a_focused_pannable_stage_pans_with_the_arrows"
old = ''' if ((k === ' ' || k.indexOf('Arrow') === 0) && t.closest('.vstage.can-pan')) return true;
'''
new = ''''''
[[mutation]]
label = "S5c the grid cursor is a class only"
file = "booth/templates/booth.html"
test = "tests/test_antislop_s5c_browser.py::test_the_grid_cursor_is_real_focus"
old = ''' t[at].focus({ preventScroll: true });
'''
new = ''''''
[[mutation]]
label = "S5c every tile the cursor visited stays focusable"
file = "booth/templates/booth.html"
test = "tests/test_antislop_s5c_browser.py::test_the_grid_cursor_is_real_focus"
old = ''' if (el !== c) el.removeAttribute('tabindex');
'''
new = ''''''
[[mutation]]
label = "S5c Escape leaves focus on the tile it un-cursors"
file = "booth/templates/booth.html"
test = "tests/test_antislop_s5c_browser.py::test_the_grid_cursor_is_real_focus"
old = ''' if (was && document.activeElement === was) was.blur();
at = -1; cur = null; mark(); break;'''
new = ''' at = -1; cur = null; tiles().forEach(function (x) { x.classList.remove('is-cursor'); }); break;'''
[[mutation]]
label = "S5c the focused cursor tile draws a second ring over the reticle"
file = "booth/templates/base.html"
test = "tests/test_antislop_s5c_browser.py::test_the_grid_cursor_is_real_focus"
old = ''' .item.is-cursor:focus-visible{outline:none}'''
new = ''' .item.is-cursor:focus-visible{}'''
[[mutation]]
label = "S5c focus that lands on a tile does not make it the cursor"
file = "booth/templates/booth.html"
test = "tests/test_antislop_s5c_browser.py::test_focus_returned_to_a_tile_makes_it_the_cursor"
old = ''' if (i >= 0 && i !== at) { at = i; cur = e.target.getAttribute('data-item'); mark(); }'''
new = ''' if (false) { at = i; cur = e.target.getAttribute('data-item'); mark(); }'''
[[mutation]]
label = "S5c a control is back inside the doc's summary"
file = "booth/templates/booth.html"
test = "tests/test_antislop.py::test_the_doc_summary_holds_no_controls"
old = ''' <span class="doc-name">{{ it.name }}</span>
</summary>'''
new = ''' <span class="doc-name">{{ it.name }}</span>
<a class="doc-act" href="view?f={{ it.url }}">⤢</a>
</summary>'''
[[mutation]]
label = "S5c a closed fold leaves the doc's body showing"
file = "booth/templates/base.html"
test = "tests/test_antislop_s5c_browser.py::test_a_closed_doc_keeps_its_tools"
old = ''' .item-doc:has(.doc-fold:not([open])) > .doc-inline{display:none}'''
new = ''' .item-doc:has(.doc-fold:not([open])) > .doc-none{display:none}'''
[[mutation]]
label = "S5c the tools inside the fold, which a closed fold hides"
file = "booth/templates/booth.html"
test = "tests/test_antislop_s5c_browser.py::test_a_closed_doc_keeps_its_tools"
old = ''' </summary>
</details>
<div class="doc-tools">'''
new = ''' </summary>
<div class="doc-tools">'''
[[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 = ''' <button type="button" class="reveal"><span class="rv-glyph" aria-hidden="true">'''
new = ''' <button type="button" class="reveal" aria-label="reveal {{ name }}"><span class="rv-glyph" aria-hidden="true">'''
[[mutation]]
label = "S5c a tile's reveal glyph is read aloud"
file = "booth/templates/booth.html"
test = "tests/test_antislop.py::test_reveal_glyphs_are_hidden_from_the_name"
old = ''' <button type="button" class="reveal"><span class="rv-glyph" aria-hidden="true">'''
new = ''' <button type="button" class="reveal"><span class="rv-glyph">'''
[[mutation]]
label = "S5c a tile's reveal flips its whole text, and its name loses the item"
file = "booth/templates/booth.html"
test = "tests/test_antislop_s5c_browser.py::test_reveal_names_follow_their_words"
old = ''' btn.querySelector('.rv-word').textContent = on ? 'hide' : 'reveal';'''
new = ''' btn.textContent = on ? '🙈 hide' : '👁 reveal';'''
[[mutation]]
label = "S5c the review's reveal flips its whole text"
file = "booth/templates/view.html"
test = "tests/test_antislop_s5c_browser.py::test_reveal_names_follow_their_words"
old = ''' rv.querySelector('.rv-word').textContent = on ? 'hide' : 'reveal';'''
new = ''' rv.textContent = on ? '🙈 hide' : '👁 reveal — blur is cosmetic'; return;'''
[[mutation]]
label = "S5c compare's reveal flips its whole text"
file = "booth/templates/compare.html"
test = "tests/test_antislop_s5c_browser.py::test_reveal_names_follow_their_words"
old = ''' btn.querySelector('.rv-word').textContent = on ? 'hide' : 'reveal';'''
new = ''' btn.textContent = on ? '🙈 hide' : '👁 reveal — blur is cosmetic'; return;'''
[[mutation]]
label = "S5c a doc page's reveal flips its whole text"
file = "booth/templates/doc.html"
test = "tests/test_antislop_s5c_browser.py::test_reveal_names_follow_their_words"
old = ''' rv.querySelector('.rv-word').textContent = on ? 'hide' : 'reveal';'''
new = ''' rv.textContent = on ? '🙈 hide' : '👁 reveal — blur is cosmetic'; return;'''
[[mutation]]
label = "S5c Reveal all claims a pressed state its words already say"
file = "booth/templates/base.html"
test = "tests/test_antislop_s5c_browser.py::test_reveal_names_follow_their_words"
old = ''' btn.querySelector('.ra-note').hidden = on;
}'''
new = ''' btn.querySelector('.ra-note').hidden = on;
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
}'''
[[mutation]]
label = "S5c Reveal all loses its on look with the attribute"
file = "booth/templates/base.html"
test = "tests/test_antislop_s5c_browser.py::test_reveal_names_follow_their_words"
old = ''' .reveal-all .reveal-all-btn{color:var(--text-body);border-color:var(--border-strong)}'''
new = ''''''
[[mutation]]
label = "S5c Reveal all's glyph is read aloud"
file = "booth/templates/view.html"
test = "tests/test_antislop.py::test_reveal_glyphs_are_hidden_from_the_name"
old = '''<span class="ra-glyph" aria-hidden="true">👁</span>'''
new = '''<span class="ra-glyph">👁</span>'''
[[mutation]]
label = "S5c a refused batch's forms never enter unsent (booth-dev's note)"
file = "booth/templates/base.html"
test = "tests/test_antislop_s5c_browser.py::test_a_batch_refusal_outlives_an_unrelated_save"
old = ''' if (!saved.some(function (x) { return x.key === k; })) unsent[k] = told;'''
new = ''''''
[[mutation]]
label = "S5c only the last standing failure is said again"
file = "booth/templates/base.html"
test = "tests/test_antislop_s5c_browser.py::test_two_standing_failures_are_both_said_once_each"
old = ''' return words.join(' ');'''
new = ''' return words[words.length - 1];'''
[[mutation]]
label = "S5c a refusal shared by two forms is said twice"
file = "booth/templates/base.html"
test = "tests/test_antislop_s5c_browser.py::test_two_standing_failures_are_both_said_once_each"
old = ''' keys.forEach(function (k) { if (words.indexOf(unsent[k]) < 0) words.push(unsent[k]); });'''
new = ''' keys.forEach(function (k) { words.push(unsent[k]); });'''
# ---- S5c, folded from the heid bug-hunt (HRÖSKVA, panel 4/4) --------------------------
[[mutation]]
label = "S5c R1 the cursor lands on a doc closed with its ✕"
file = "booth/templates/booth.html"
test = "tests/test_antislop_s5c_browser.py::test_the_cursor_skips_a_closed_doc"
old = '''grid.querySelectorAll('figure.item:not(.is-closed)')'''
new = '''grid.querySelectorAll('figure.item')'''
[[mutation]]
label = "S5c R10 n leaves a closed doc's fold shut over its note field"
file = "booth/templates/booth.html"
test = "tests/test_antislop_s5c_browser.py::test_n_opens_the_note_on_a_closed_doc"
old = ''' if (fold) fold.open = true;'''
new = ''''''
[[mutation]]
label = "S5c R5 Enter follows the first link that merely starts with view"
file = "booth/templates/booth.html"
test = "tests/test_antislop_s5c_browser.py::test_enter_reviews_a_file_named_view"
old = ''' click('a[href^="view?f="]');'''
new = ''' click('a[href^="view"]');'''
[[mutation]]
label = "S5c R2 the observer stays on the first rail"
file = "booth/templates/base.html"
test = "tests/test_antislop_s5c_browser.py::test_the_rail_height_follows_the_live_rail_after_a_save"
old = ''' if (ro && rail !== seen) {'''
new = ''' if (ro && !seen) {'''
[[mutation]]
label = "S5c R9 a Tab between stuck rail links scrolls the page up by the rail"
file = "booth/templates/base.html"
test = "tests/test_antislop_s5c_browser.py::test_tab_between_rail_links_does_not_move_the_page"
old = ''' .rail :is(a,button,input,summary){scroll-margin-top:calc(-1 * var(--rail-pad, 0px))}'''
new = ''''''
[[mutation]]
label = "S5c R3 a later failure buries an earlier one"
file = "booth/templates/base.html"
test = "tests/test_antislop_s5c_browser.py::test_a_later_failure_does_not_bury_an_earlier_one"
old = ''' return before ? before + ' ' + text : text;'''
new = ''' return text;'''
[[mutation]]
label = "S5c R3 a partly refused batch buries an earlier failure"
file = "booth/templates/base.html"
test = "tests/test_antislop_s5c_browser.py::test_two_standing_failures_are_both_said_once_each"
old = ''' else if (told) warn(also(told, mine));'''
new = ''' else if (told) warn(told);'''
[[mutation]]
label = "S5c R8 a focused player swallows Escape"
file = "booth/templates/base.html"
test = "tests/test_antislop_s5c_browser.py::test_a_player_keeps_its_keys"
old = '''(k !== 'Escape' && t.closest('video, audio'))'''
new = '''t.closest('video, audio')'''
[[mutation]]
label = "S5c R7 a file rewritten in place with its mtime restored keeps its old size"
file = "booth/thumbs.py"
test = "tests/test_antislop.py::test_a_file_replaced_in_place_keeps_no_stale_size"
old = '''st.st_mtime_ns, st.st_ctime_ns)'''
new = '''st.st_mtime_ns, 0)'''
+7 -3
View File
@@ -239,10 +239,14 @@ new = '''
[[mutation]]
label = "Space on a focused review button moves to the next item"
file = "booth/templates/view.html"
# design-dev re-anchored this row in S5c: the review's own Space exception moved
# into base.html's BoothKeys, the one rule the grid, the review and compare
# share. Same failure: Space from a focused control reaches the page.
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_space_on_a_focused_review_button_presses_it_and_does_not_move_on"
old = '''e.target !== player && !(e.target.closest && e.target.closest('button, a, summary'))) {'''
new = '''e.target !== player) {'''
old = ''' if (k === ' ' && t.closest('button, a[href], summary')) return true;
'''
new = ''''''
[[mutation]]
label = "the top-bar controls squeeze into multi-line stacks at phone width"
+4 -2
View File
@@ -247,8 +247,10 @@ new = '''
label = "the stage reveal shows with scripts off (and does nothing)"
file = "booth/templates/view.html"
test = "tests/test_flow_browser.py::test_the_stage_reveal_never_shows_without_js_and_keeps_the_fit_shadow"
old = '''aria-label="reveal {{ file }}" hidden>'''
new = '''aria-label="reveal {{ file }}">'''
# design-dev re-anchored in S5c (G17): the stage reveal lost its aria-label (its
# name is now the words on it). Same failure: the button renders unhidden.
old = '''id="vreveal" hidden>'''
new = '''id="vreveal">'''
[[mutation]]
label = "a revealed review picture loses Fit's shadow"
+13 -13
View File
@@ -299,24 +299,24 @@ new = '''
[[mutation]]
label = "C3 a held modifier does not make the keys inert"
file = "booth/templates/compare.html"
# design-dev re-anchored this row and the two Space rows below in S5c: compare's
# guards moved into base.html's BoothKeys, which compare now calls first. Same
# failures, in the one place the rule now lives.
file = "booth/templates/base.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;'''
if (e.metaKey || e.ctrlKey || e.altKey) return true;
var t = e.target, k = e.key;'''
new = '''
if (isEditable(e.target)) return;
var k = e.key;'''
var t = e.target, k = e.key;'''
[[mutation]]
label = "C3 Space on a focused control steps instead of pressing it"
file = "booth/templates/compare.html"
file = "booth/templates/base.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 = '''
old = ''' if (k === ' ' && t.closest('button, a[href], summary')) return true;
'''
new = ''''''
[[mutation]]
label = "INV-6 the review has no C key"
@@ -455,10 +455,10 @@ new = '''
[[mutation]]
label = "C3 Space on a focused player steps the pair"
file = "booth/templates/compare.html"
file = "booth/templates/base.html"
test = "tests/test_compare_browser.py::test_the_keys_keep_the_reviews_guards_and_c_toggles_the_view"
old = '''e.target.closest('button, a, summary, video, audio')'''
new = '''e.target.closest('button, a, summary')'''
old = ''' if (BoothKeys.isEditable(t) || (k !== 'Escape' && t.closest('video, audio'))) return true;'''
new = ''' if (BoothKeys.isEditable(t)) return true;'''
[[mutation]]
label = "C3 there is no back arrow"
+170
View File
@@ -631,3 +631,173 @@ def test_the_in_place_client_can_read_every_page(client):
p.feed(page)
assert not p.odd, (url, p.odd)
assert not p.nested, (url, p.nested)
# ---- S5c -----------------------------------------------------------------------------
def _figure(page, rel):
"""One tile's markup, by its data-item."""
m = re.search(r'<figure\b[^>]*data-item="' + re.escape(rel) + r'".*?</figure>', _markup(page), re.S)
assert m, f"no tile for {rel}"
return m.group(0)
def test_the_doc_summary_holds_no_controls(client):
"""G7: a <summary> is one button to a screen reader, and a <form> is not
valid inside one. The doc's summary holds its label; the controls sit in a
sibling toolbar."""
c, data = client
_s5_booth(data)
tile = _figure(c.get("/b/b/").text, "notes.md")
labels = [s for s in re.findall(r"<summary\b[^>]*>(.*?)</summary>", tile, re.S) if "doc-name" in s]
assert len(labels) == 1, labels
assert not re.search(r"<(a|button|form|input|textarea)\b", labels[0]), labels[0]
tools = re.search(r'<div class="doc-tools">(.*?)</div>\s*</div>', 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"<img\b[^>]*>", 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('<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"/>')
(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'<button\b(?=[^>]*(?:class="[^"]*\breveal(?:-all-btn)?\b|data-reveal-all))([^>]*)>(.*?)</button>', 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'<span\b[^>]*aria-hidden="true"[^>]*>.*?</span>', "", 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))
+610
View File
@@ -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 <summary> ---------------------------------
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