fix(as-S5b): focus survives a swap, a status line you can see, drafts that ask before they go
The in-place client half of the anti-slop interaction work (guidelines G1, G2, G4, G13). It still never re-POSTs, still serializes saves, and a batch still never reloads. - Focus: the focused element is recorded by identity (its region, its key, which same-key element it was) and the fresh one is focused without scrolling. If an answered pick's form folds into a closed <details>, focus goes to its summary; if nothing is left, to the region (tabindex=-1, set by the script). Focus outside the swapped regions is not touched. - One status line per page (_status.html). It floats at the bottom centre, above the fixed review stage, so it moves nothing and is in view wherever the reader is. Wider than 900px, the letterhead and footer the review covers leave the Tab order (visibility:hidden, CSS only). - The line is never hidden: empty, it takes no space and stays displayed. "Saving…" at the press, "Still saving…" on a repeat press, "Saved." when the swap lands (cleared after 2s if still the same write), and warnings with data-tone="warn". Every write sets or clears the tone. The form in flight carries aria-busy until its save settles. - The client never reloads over a draft: both of its reloads run only when every in-place form is clean except the one just sent, unchanged since its press, asked again at the reload beat; otherwise it says so and stays. A beforeunload guard asks when an in-place form is dirty (its own reload does not ask). The embed asks when one of our answers is unsent, and skips the pressed form on its own one-form submit. - Six booth-dev browser tests read the line's hidden state; they read its words and tone instead. Two r2_submit_all.toml rows are re-anchored to the same failure in the moved code. Folded from the heid bug-hunt (panel 4/4, thread 01M3MRTNTWEPJHTN4APRR81KH4): - aria-busy mirrors which forms are in flight on the LIVE page. It is set at the press and re-synced whenever a save settles, so it ends on every path (a stale tile the swap never replaced included), and a queued form replaced by an earlier swap is marked busy again. - A press inside the reload beat cancels the reload. - A failure that stayed is said again after an unrelated save, rather than buried under "Saved.". - A 204 followed by a failed page GET is "Saved.", never "could not save". - An edit made while its save flew is said to be unsaved. - A focused <summary> has a key. - The queue settles on rejection. - The embed's skip covers the one navigation its submit starts; a cancelled submit, or one that leaves the page in place, is guarded again. - Pinned: no in-place form holds a control dirty() cannot read, and no region nests in another. Folded from this slice's gate: the status line floats (fixed, bottom centre, above the review stage) instead of sitting at the top of <main> or under the viewer's bar. In the flow, every save's "Saving…" moved the page; booth-dev's test_a_flag_lands_in_place_and_every_region_catches_up caught a 50px jump. The viewers' grids are back as they were. Contract: as_antislop S5b (heid contract review and bug-hunt folded). Falsifiers: antislop.toml S5b sections.
This commit is contained in:
@@ -766,3 +766,347 @@ file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_browser.py::test_withdraw_buttons_are_big_enough_to_hit"
|
||||
old = ''' .mark-x{min-width:24px;min-height:24px}'''
|
||||
new = ''' .mark-x{min-height:24px}'''
|
||||
|
||||
# ---- S5b: the in-place client (focus, the status line, the unsent-draft guard)
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b no focus restore after a swap"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_focus_returns_to_the_pressed_control"
|
||||
old = ''' focusRestore(rec, placed);
|
||||
'''
|
||||
new = ''''''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b the first same-key match, not the same one"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_focus_picks_the_same_one_of_two"
|
||||
old = ''' var el = rec.key && rec.n >= 0 && keyed(region, rec.key)[rec.n];'''
|
||||
new = ''' var el = rec.key && rec.n >= 0 && keyed(region, rec.key)[0];'''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b no region fallback (focus falls to body)"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_focus_lands_on_the_region_when_the_control_is_gone"
|
||||
old = ''' region.setAttribute('tabindex', '-1');
|
||||
region.focus({preventScroll: true});'''
|
||||
new = ''''''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b a closed details' summary is not tried"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_focus_returns_to_the_pressed_control"
|
||||
old = ''' if (sum) { sum.focus({preventScroll: true}); if (document.activeElement === sum) return; }'''
|
||||
new = ''''''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b the pressed control is restored, not the focused one"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_focus_elsewhere_is_left_alone"
|
||||
old = ''' var ae = document.activeElement;'''
|
||||
new = ''' var ae = document.querySelector('form[aria-busy="true"] button') || document.activeElement;'''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b restoring focus scrolls the page"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_focus_restore_does_not_scroll"
|
||||
old = ''' if (el) el.focus({preventScroll: true});'''
|
||||
new = ''' if (el) el.focus();'''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b the covered footer stays in the tab order"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_the_covered_letterhead_leaves_the_tab_order"
|
||||
old = ''' @media (min-width:901px){body.page-stage .topbar,body.page-stage .foot{visibility:hidden}}'''
|
||||
new = ''' @media (min-width:901px){body.page-stage .topbar{visibility:hidden}}'''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b the covered letterhead stays in the tab order"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_the_covered_letterhead_leaves_the_tab_order"
|
||||
old = ''' @media (min-width:901px){body.page-stage .topbar,body.page-stage .foot{visibility:hidden}}'''
|
||||
new = ''' @media (min-width:901px){body.page-stage .foot{visibility:hidden}}'''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b the line is hidden again"
|
||||
file = "booth/templates/_status.html"
|
||||
test = "tests/test_antislop.py::test_one_status_line_per_page"
|
||||
old = '''aria-live="polite"></p>'''
|
||||
new = '''aria-live="polite" hidden></p>'''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b an empty line keeps its padding"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_an_empty_status_line_takes_no_space"
|
||||
old = ''' .status:empty{padding:0;margin:0;border:0;box-shadow:none}'''
|
||||
new = ''' .status:empty{}'''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b an empty line is display:none (out of the accessibility tree)"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_the_status_line_is_always_displayed"
|
||||
old = ''' .status:empty{padding:0;margin:0;border:0;box-shadow:none}'''
|
||||
new = ''' .status:empty{display:none}'''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b no Saving… at the press"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_a_save_says_saving_then_saved"
|
||||
old = ''' say('Saving…');
|
||||
markBusy();'''
|
||||
new = ''' markBusy();'''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b Saved. never clears"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_a_save_says_saving_then_saved"
|
||||
old = ''' setTimeout(function () { if (said === n) say(''); }, 2000);'''
|
||||
new = ''''''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b the old timer clears a newer message"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_a_new_save_is_not_cleared_by_the_last_ones_timer"
|
||||
old = '''if (said === n) say('');'''
|
||||
new = '''say('');'''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b a queued save's Saving… is replaced by Saved."
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_a_queued_save_keeps_saying_saving"
|
||||
old = ''' if (others) { say('Saving…'); return; }'''
|
||||
new = ''''''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b the tone outlives its words"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_a_failure_then_an_edit_then_a_save"
|
||||
old = '''if (tone) st.setAttribute('data-tone', tone); else st.removeAttribute('data-tone');'''
|
||||
new = '''if (tone) st.setAttribute('data-tone', tone);'''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b a repeat press says nothing"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_a_repeat_press_says_still_saving"
|
||||
old = ''' if (pending[flightKey(form)]) say('Still saving…');
|
||||
'''
|
||||
new = ''''''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b a clean press during a batch says nothing"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_a_batch_speaks_too"
|
||||
old = '''if (!batch.length) { say('Still saving…'); return; }'''
|
||||
new = '''if (!batch.length) return;'''
|
||||
|
||||
[[mutation]]
|
||||
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"
|
||||
old = '''
|
||||
|
||||
else landed(forms.map(flightKey));'''
|
||||
new = ''''''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b a batch never says Saving…"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_a_batch_speaks_too"
|
||||
old = ''' say('Saving…');
|
||||
var keys = batch.map(flightKey);'''
|
||||
new = ''' var keys = batch.map(flightKey);'''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b the form in flight is not busy"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_the_form_in_flight_is_busy"
|
||||
old = ''' say('Saving…');
|
||||
markBusy();'''
|
||||
new = ''' say('Saving…');'''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b busy never ends on a failure that stays"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_the_form_in_flight_is_busy"
|
||||
old = ''' var settle = function () { delete pending[key]; markBusy(); };'''
|
||||
new = ''' var settle = function () { delete pending[key]; };'''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b a failure is not a warning"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_a_failed_save_keeps_the_other_drafts"
|
||||
old = ''' function warn(text) { return say(text, 'warn'); }'''
|
||||
new = ''' function warn(text) { return say(text); }'''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b a failure reloads over other drafts"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_a_failed_save_keeps_the_other_drafts"
|
||||
old = ''' return !dirty(f) || (flightKey(f) === key && serial(f) === snap);'''
|
||||
new = ''' return true;'''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b text typed while the save flew does not block the reload"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_a_failed_save_keeps_text_typed_while_it_flew"
|
||||
old = '''(flightKey(f) === key && serial(f) === snap)'''
|
||||
new = '''(flightKey(f) === key)'''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b a changed page reloads over other drafts"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_a_changed_page_keeps_the_other_drafts"
|
||||
old = ''' if (mayReload(key, snap)) { reload(); return; }'''
|
||||
new = ''' { reload(); return; }'''
|
||||
|
||||
[[mutation]]
|
||||
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);'''
|
||||
new = ''' beat = setTimeout(reload, 900);'''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b no unload guard"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_leaving_with_a_draft_asks"
|
||||
old = ''' ev.preventDefault();
|
||||
ev.returnValue = '';'''
|
||||
new = ''''''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b the client's own reload asks"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_its_own_reload_does_not_ask"
|
||||
old = ''' if (leaving) return;
|
||||
'''
|
||||
new = ''''''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b a clean page asks"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_leaving_a_clean_page_does_not_ask"
|
||||
old = ''' if ([].some.call(document.querySelectorAll('form[data-inplace]'), dirty)) {'''
|
||||
new = ''' if (true) {'''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b a saved draft still asks"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_a_saved_draft_no_longer_asks"
|
||||
old = ''' if ([].some.call(document.querySelectorAll('form[data-inplace]'), dirty)) {'''
|
||||
new = ''' if (true) {'''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b the embed has no unload guard"
|
||||
file = "booth/static/embed.js"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_embed_leaving_with_a_draft_asks"
|
||||
old = ''' if (forms[i] !== skip && dirty(forms[i])) { ev.preventDefault(); ev.returnValue = ""; return; }'''
|
||||
new = ''''''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b the embed's own submit asks"
|
||||
file = "booth/static/embed.js"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_embed_submit_does_not_ask"
|
||||
old = ''' nativeSent = form;
|
||||
'''
|
||||
new = ''''''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b the line sits in the page flow (a save moves the page)"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_a_save_does_not_move_the_page"
|
||||
old = ''' .status{position:fixed;left:50%;bottom:16px;transform:translateX(-50%);z-index:70;'''
|
||||
new = ''' .status{'''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b the fixed stage covers the line"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_the_status_line_is_visible_on_the_review"
|
||||
old = ''' .status{position:fixed;left:50%;bottom:16px;transform:translateX(-50%);z-index:70;'''
|
||||
new = ''' .status{position:fixed;left:50%;bottom:16px;transform:translateX(-50%);z-index:10;'''
|
||||
|
||||
# ---- S5b, folded from the heid bug-hunt (HNITTA, thread 01M3MRTNTWEPJHTN4APRR81KH4)
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b a settled save leaves its stale tile busy"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_a_stale_tile_is_not_left_busy"
|
||||
old = ''' var settle = function () { delete pending[key]; markBusy(); };'''
|
||||
new = ''' var settle = function () { delete pending[key]; };'''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b a queued form's live copy is not re-marked busy when a save settles"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_a_queued_form_is_busy_on_the_live_page"
|
||||
old = ''' var settle = function () { delete pending[key]; markBusy(); };'''
|
||||
new = ''' var settle = function () { delete pending[key]; };'''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b a press inside the beat does not cancel the reload"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_a_resubmit_in_the_beat_is_not_reloaded_away"
|
||||
old = ''' if (beat) { clearTimeout(beat); beat = null; }
|
||||
'''
|
||||
new = ''''''
|
||||
|
||||
[[mutation]]
|
||||
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; }
|
||||
'''
|
||||
new = ''''''
|
||||
|
||||
[[mutation]]
|
||||
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.');'''
|
||||
new = ''' else fail(form, key, snap);'''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b a focused summary has no key"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_focus_on_a_summary_survives_the_next_swap"
|
||||
old = ''' if (t === 'SUMMARY') {'''
|
||||
new = ''' if (false) {'''
|
||||
|
||||
[[mutation]]
|
||||
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; }
|
||||
'''
|
||||
new = ''''''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b a cancelled native submit stays exempt"
|
||||
file = "booth/static/embed.js"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_embed_a_cancelled_submit_is_guarded_again"
|
||||
old = ''' setTimeout(function () { if (ev.defaultPrevented && nativeSent === form) nativeSent = null; }, 0);
|
||||
'''
|
||||
new = ''''''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b the skip covers every leave, not one"
|
||||
file = "booth/static/embed.js"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_embed_the_skip_covers_one_leave"
|
||||
old = ''' var skip = nativeSent;
|
||||
nativeSent = null;
|
||||
'''
|
||||
new = ''' var skip = nativeSent;
|
||||
'''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b an in-place form holds a control dirty() cannot read"
|
||||
file = "booth/templates/_marks.html"
|
||||
test = "tests/test_antislop.py::test_the_in_place_client_can_read_every_page"
|
||||
old = ''' <textarea name="text" rows="2" aria-label="a note on this booth, for the session that posted it"'''
|
||||
new = ''' <select name="x"></select><textarea name="text" rows="2" aria-label="a note on this booth, for the session that posted it"'''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b a region nests inside another"
|
||||
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">'''
|
||||
|
||||
@@ -78,8 +78,9 @@ label = "a new save does not clear the last one's words"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_flow_browser.py::test_a_later_save_clears_a_stale_not_saved_line"
|
||||
old = '''
|
||||
if (st) { st.textContent = ''; st.hidden = true; }'''
|
||||
new = ''''''
|
||||
st.textContent = text;'''
|
||||
new = '''
|
||||
if (!st.textContent) st.textContent = text;'''
|
||||
|
||||
[[mutation]]
|
||||
label = "a batch whose refresh fails reloads (every unsent draft with it)"
|
||||
@@ -88,7 +89,7 @@ test = "tests/test_flow_browser.py::test_a_batch_whose_refresh_fails_keeps_the_p
|
||||
old = '''
|
||||
var shown = saved.length ? refresh(saved, true) : Promise.resolve(true);'''
|
||||
new = '''
|
||||
var shown = saved.length ? refresh(saved, true).catch(function (e) { fail(); throw e; }) : Promise.resolve(true);'''
|
||||
var shown = saved.length ? refresh(saved, true).catch(function (e) { reload(); throw e; }) : Promise.resolve(true);'''
|
||||
|
||||
[[mutation]]
|
||||
label = "a sent form counts as sent even when it changed after the press"
|
||||
|
||||
@@ -551,3 +551,83 @@ def test_human_dur_never_raises():
|
||||
from booth.app import human_dur
|
||||
for bad in (float("nan"), float("inf"), float("-inf")):
|
||||
assert isinstance(human_dur(bad), str), bad
|
||||
|
||||
|
||||
# ---- S5b: one status line per page, never hidden --------------------------------
|
||||
|
||||
def test_one_status_line_per_page(client):
|
||||
"""Exactly one `data-region="status"` on every kind of page, never `hidden`,
|
||||
and inside no other `data-region` (a swap would replace it mid-message)."""
|
||||
from html.parser import HTMLParser
|
||||
|
||||
from booth.links import LINKS_FILE
|
||||
c, data = client
|
||||
_s5_booth(data)
|
||||
void = {"input", "img", "br", "meta", "link", "hr", "source", "wbr", "col", "area", "base", "embed", "track"}
|
||||
|
||||
class Lines(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.stack, self.found = [], []
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
a = dict(attrs)
|
||||
if a.get("data-region") == "status":
|
||||
self.found.append((tag, a, [r for r in self.stack if r]))
|
||||
if tag not in void:
|
||||
self.stack.append(a.get("data-region"))
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if tag not in void and self.stack:
|
||||
self.stack.pop()
|
||||
|
||||
assert (data / "links" / LINKS_FILE).exists()
|
||||
for url, page in _pages(c).items():
|
||||
p = Lines()
|
||||
p.feed(page)
|
||||
assert len(p.found) == 1, (url, len(p.found))
|
||||
tag, attrs, outer = p.found[0]
|
||||
assert "hidden" not in attrs, url
|
||||
assert attrs.get("role") == "status" and attrs.get("aria-live") == "polite", (url, attrs)
|
||||
assert not outer, (url, outer)
|
||||
|
||||
|
||||
def test_the_in_place_client_can_read_every_page(client):
|
||||
"""What the in-place client assumes about the pages it swaps (heid bug-hunt
|
||||
R7, R11, accepted as true today and pinned here): no in-place form holds a
|
||||
control `dirty()` cannot read (a <select>, or an input that is not text,
|
||||
radio, checkbox, hidden or submit), and no `data-region` sits inside another
|
||||
(a nested region is replaced inside a detached tree)."""
|
||||
from html.parser import HTMLParser
|
||||
c, data = client
|
||||
_s5_booth(data)
|
||||
void = {"input", "img", "br", "meta", "link", "hr", "source", "wbr", "col", "area", "base", "embed", "track"}
|
||||
|
||||
class Pages(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.stack, self.inplace, self.odd, self.nested = [], 0, [], []
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
a = dict(attrs)
|
||||
if tag == "form" and "data-inplace" in a:
|
||||
self.inplace += 1
|
||||
if self.inplace and (tag == "select" or (tag == "input" and a.get("type", "text")
|
||||
not in ("hidden", "radio", "checkbox", "text", "submit"))):
|
||||
self.odd.append((tag, a.get("type")))
|
||||
if a.get("data-region") and any(self.stack):
|
||||
self.nested.append(a["data-region"])
|
||||
if tag not in void:
|
||||
self.stack.append(a.get("data-region"))
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if tag == "form" and self.inplace:
|
||||
self.inplace -= 1
|
||||
if tag not in void and self.stack:
|
||||
self.stack.pop()
|
||||
|
||||
for url, page in _pages(c).items():
|
||||
p = Pages()
|
||||
p.feed(page)
|
||||
assert not p.odd, (url, p.odd)
|
||||
assert not p.nested, (url, p.nested)
|
||||
|
||||
@@ -0,0 +1,808 @@
|
||||
"""S5b of the anti-slop fix slices (docs/contracts/as_antislop.contract.md):
|
||||
the in-place client's focus, its status line, and the unsent-draft guard.
|
||||
|
||||
Measured in the same real uvicorn + Chromium harness as test_flow_browser.
|
||||
Skips, never fails, when no usable Chromium exists. No test here hears a
|
||||
screen reader: what is held is the precondition (a displayed live region whose
|
||||
text changes), not the announcement.
|
||||
"""
|
||||
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
|
||||
from test_flow_browser import PNG, _picks, _set, browser, live # noqa: E402,F401 (fixtures)
|
||||
|
||||
WIDE = {"width": 1400, "height": 900}
|
||||
|
||||
# Every POST waits for the test: window.__release() lets the oldest one through
|
||||
# to the server, window.__release('fail') answers it 500 without sending it.
|
||||
HOLD = """
|
||||
(function () {
|
||||
var real = window.fetch;
|
||||
window.__held = [];
|
||||
window.fetch = function (u, o) {
|
||||
if (!(o && o.method === 'POST')) return real.apply(this, arguments);
|
||||
var self = this, args = arguments;
|
||||
return new Promise(function (res, rej) {
|
||||
window.__held.push(function (how) {
|
||||
if (how === 'fail') res(new Response('', {status: 500}));
|
||||
else real.apply(self, args).then(res, rej);
|
||||
});
|
||||
});
|
||||
};
|
||||
window.__release = function (how) { var f = window.__held.shift(); if (f) f(how); return !!f; };
|
||||
})();
|
||||
"""
|
||||
|
||||
# Counts swaps, and marks the document so a reload is visible.
|
||||
COUNT = """
|
||||
document.addEventListener('booth:swapped', function () { window.__swaps = (window.__swaps || 0) + 1; });
|
||||
"""
|
||||
|
||||
LINE = '[data-region="status"]'
|
||||
|
||||
|
||||
def _page(browser, base, path, hold=False, viewport=WIDE):
|
||||
page = browser.new_page(viewport=viewport)
|
||||
page.add_init_script(COUNT)
|
||||
if hold:
|
||||
page.add_init_script(HOLD)
|
||||
page.goto(f"{base}{path}", wait_until="networkidle")
|
||||
page.evaluate("window.__same = 1")
|
||||
return page
|
||||
|
||||
|
||||
def _said(page):
|
||||
return page.evaluate("""() => { const s = document.querySelector('[data-region="status"]');
|
||||
return {text: s.textContent, tone: s.getAttribute('data-tone')}; }""")
|
||||
|
||||
|
||||
def _wait_said(page, needle, timeout=10000):
|
||||
page.wait_for_function(
|
||||
"n => document.querySelector('[data-region=\"status\"]').textContent.includes(n)", arg=needle,
|
||||
timeout=timeout)
|
||||
|
||||
|
||||
def _wait_swaps(page, n, timeout=10000):
|
||||
page.wait_for_function("n => (window.__swaps || 0) >= n", arg=n, timeout=timeout)
|
||||
|
||||
|
||||
def _wait_held(page, n=1, timeout=5000):
|
||||
page.wait_for_function("n => window.__held.length >= n", arg=n, timeout=timeout)
|
||||
|
||||
|
||||
def _focused(page, selector, index=0):
|
||||
return page.evaluate(
|
||||
"([s, i]) => document.activeElement === document.querySelectorAll(s)[i]", [selector, index])
|
||||
|
||||
|
||||
# ---- G1: focus survives a swap -------------------------------------------------------
|
||||
|
||||
def test_focus_returns_to_the_pressed_control(browser, live):
|
||||
base, root = live
|
||||
b = _set(root, 3)
|
||||
page = _page(browser, base, "/b/g/")
|
||||
flag = 'figure.item[data-item="02.png"] .flagtoggle button'
|
||||
page.locator(flag).focus()
|
||||
y = page.evaluate("scrollY")
|
||||
page.keyboard.press("Enter")
|
||||
page.wait_for_selector('figure.item.is-flagged[data-item="02.png"]', timeout=10000)
|
||||
_wait_swaps(page, 1)
|
||||
assert _focused(page, flag), page.evaluate("document.activeElement.outerHTML.slice(0, 120)")
|
||||
assert abs(page.evaluate("scrollY") - y) <= 1
|
||||
|
||||
page.locator(".verdict .mark-add textarea").fill("a note")
|
||||
page.locator(".verdict .mark-add button").focus()
|
||||
page.keyboard.press("Enter")
|
||||
_wait_swaps(page, 2)
|
||||
assert _focused(page, ".verdict .mark-add button")
|
||||
page.close()
|
||||
|
||||
_picks(b, ("a1",))
|
||||
page = _page(browser, base, "/b/g/marks")
|
||||
page.check('#mark-a1 input[type=radio][value="yes"]')
|
||||
page.locator("#mark-a1 .mark-submit").focus()
|
||||
page.keyboard.press("Enter")
|
||||
page.wait_for_function("document.querySelectorAll('.mark-pick.is-answered').length === 1", timeout=10000)
|
||||
_wait_swaps(page, 1)
|
||||
# answered, the pick's form folds into a closed <details>: focus goes to the
|
||||
# summary that opens it again, never to <body>
|
||||
assert page.locator("#mark-a1 details.mark-formwrap").get_attribute("open") is None
|
||||
assert _focused(page, "#mark-a1 details.mark-formwrap > summary"), \
|
||||
page.evaluate("document.activeElement.outerHTML.slice(0, 120)")
|
||||
page.close()
|
||||
|
||||
|
||||
def test_focus_picks_the_same_one_of_two(browser, live):
|
||||
from booth.marks import set_flag, write_note
|
||||
base, root = live
|
||||
b = _set(root, 3)
|
||||
set_flag(b, "01.png", True)
|
||||
write_note(b, "01.png", "one")
|
||||
write_note(b, "01.png", "two")
|
||||
page = _page(browser, base, "/b/g/")
|
||||
same = '[data-region="verdict"] a[href="view?f=01.png"]'
|
||||
assert page.locator(same).count() >= 2, "positive control: one region, two links with one key"
|
||||
page.evaluate("s => document.querySelectorAll(s)[1].focus()", same)
|
||||
page.evaluate("document.querySelector('figure.item[data-item=\"03.png\"] form.flagtoggle').requestSubmit()")
|
||||
_wait_swaps(page, 1)
|
||||
assert _focused(page, same, 1), page.evaluate("document.activeElement.outerHTML.slice(0, 120)")
|
||||
page.close()
|
||||
|
||||
|
||||
def test_focus_lands_on_the_region_when_the_control_is_gone(browser, live):
|
||||
from booth.marks import write_note
|
||||
base, root = live
|
||||
b = _set(root, 3)
|
||||
write_note(b, "01.png", "gone soon")
|
||||
page = _page(browser, base, "/b/g/")
|
||||
page.locator('figure.item[data-item="01.png"] .item-note .mark-x').focus()
|
||||
page.keyboard.press("Enter")
|
||||
page.wait_for_function(
|
||||
"document.querySelectorAll('figure.item[data-item=\"01.png\"] .item-note').length === 0", timeout=10000)
|
||||
_wait_swaps(page, 1)
|
||||
region = '[data-region="item-01.png"]'
|
||||
assert _focused(page, region), page.evaluate("document.activeElement.tagName")
|
||||
page.evaluate("document.querySelector('figure.item[data-item=\"02.png\"] form.flagtoggle').requestSubmit()")
|
||||
_wait_swaps(page, 2)
|
||||
assert _focused(page, region), "a second swap dropped focus from the region"
|
||||
assert page.evaluate("document.activeElement !== document.body")
|
||||
page.close()
|
||||
|
||||
|
||||
def test_focus_restore_does_not_scroll(browser, live):
|
||||
base, root = live
|
||||
_set(root, 30)
|
||||
page = _page(browser, base, "/b/g/", hold=True)
|
||||
flag = 'figure.item[data-item="01.png"] .flagtoggle button'
|
||||
page.locator(flag).focus()
|
||||
page.keyboard.press("Enter")
|
||||
_wait_held(page)
|
||||
page.evaluate("window.scrollTo(0, document.documentElement.scrollHeight)")
|
||||
y = page.evaluate("scrollY")
|
||||
assert y > 400, "positive control: the page is long enough to scroll away"
|
||||
page.evaluate("window.__release()")
|
||||
_wait_swaps(page, 1)
|
||||
assert _focused(page, flag)
|
||||
assert abs(page.evaluate("scrollY") - y) <= 1, "restoring focus scrolled the page"
|
||||
page.close()
|
||||
|
||||
|
||||
def test_focus_elsewhere_is_left_alone(browser, live):
|
||||
base, root = live
|
||||
_set(root, 3)
|
||||
page = _page(browser, base, "/b/g/", hold=True)
|
||||
page.locator('figure.item[data-item="02.png"] .flagtoggle button').focus()
|
||||
page.keyboard.press("Enter")
|
||||
_wait_held(page)
|
||||
away = '.theme button[data-theme-choice="system"]'
|
||||
page.locator(away).focus()
|
||||
page.evaluate("window.__release()")
|
||||
_wait_swaps(page, 1)
|
||||
assert _focused(page, away)
|
||||
page.close()
|
||||
|
||||
|
||||
# ---- G2: one status line, where it can be seen -------------------------------------------
|
||||
|
||||
def _rows_left(page, stage):
|
||||
return page.evaluate("""s => {
|
||||
const v = document.querySelector('.viewer'), st = document.querySelector(s);
|
||||
const others = [...v.children].filter(c => c !== st && getComputedStyle(c).display !== 'none'
|
||||
&& getComputedStyle(c).position !== 'absolute'
|
||||
&& getComputedStyle(c).position !== 'fixed');
|
||||
const hs = others.map(c => c.getBoundingClientRect().height);
|
||||
const vh = v.getBoundingClientRect().height;
|
||||
return {stage: st.getBoundingClientRect().height, left: vh - hs.reduce((a, h) => a + h, 0),
|
||||
tallest_other: Math.max(...hs), viewer: vh};
|
||||
}""", stage)
|
||||
|
||||
|
||||
def test_the_status_line_is_visible_on_the_review(browser, live):
|
||||
base, root = live
|
||||
_set(root, 3)
|
||||
for path, stage, press in (("/b/g/view?f=01.png", ".review-body", "#rail form.vflag button"),
|
||||
("/b/g/compare?a=01.png&b=02.png", ".cmp-body", '[data-region="flag-a"] button')):
|
||||
page = browser.new_page(viewport={"width": 1280, "height": 800})
|
||||
page.route("**/b/g/flag", lambda route: route.fulfill(status=500, body=""))
|
||||
page.goto(f"{base}{path}", wait_until="networkidle")
|
||||
rows = _rows_left(page, stage)
|
||||
# the stage keeps the 1fr row: it takes the rest, and no other row grew
|
||||
assert abs(rows["stage"] - rows["left"]) <= 1 and rows["tallest_other"] < rows["viewer"] / 4, (path, rows)
|
||||
page.locator(press).first.click()
|
||||
_wait_said(page, "Could not save in place", timeout=5000)
|
||||
hit = page.evaluate("""() => { const s = document.querySelector('[data-region="status"]');
|
||||
const r = s.getBoundingClientRect();
|
||||
const e = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
|
||||
return e === s || s.contains(e); }""")
|
||||
assert hit, f"{path}: the stage covers the line"
|
||||
page.close()
|
||||
|
||||
|
||||
def test_a_save_does_not_move_the_page(browser, live):
|
||||
"""Every save now says something. A line that took space in the page flow
|
||||
moved the page under the reader (booth-dev's
|
||||
test_a_flag_lands_in_place_and_every_region_catches_up caught 50px). The
|
||||
words must be in view where the reader is, and move nothing."""
|
||||
base, root = live
|
||||
_set(root, 30)
|
||||
page = _page(browser, base, "/b/g/", hold=True, viewport={"width": 1400, "height": 800})
|
||||
tile = 'figure.item[data-item="15.png"]'
|
||||
page.locator(tile).scroll_into_view_if_needed()
|
||||
top = lambda: page.locator(tile).evaluate("e => e.getBoundingClientRect().top") # noqa: E731
|
||||
before = top()
|
||||
page.locator(f"{tile} .flagtoggle button").click()
|
||||
_wait_held(page)
|
||||
during = top()
|
||||
seen = page.evaluate("""() => { const r = document.querySelector('[data-region="status"]').getBoundingClientRect();
|
||||
return r.top >= 0 && r.bottom <= innerHeight && r.height > 0; }""")
|
||||
page.evaluate("window.__release()")
|
||||
_wait_said(page, "Saved.")
|
||||
after = top()
|
||||
page.close()
|
||||
assert abs(during - before) <= 1 and abs(after - before) <= 1, (before, during, after)
|
||||
assert seen, "the words are out of view where the reader is"
|
||||
|
||||
|
||||
def test_the_covered_letterhead_leaves_the_tab_order(browser, live):
|
||||
base, root = live
|
||||
_set(root, 3)
|
||||
for path in ("/b/g/view?f=01.png", "/b/g/compare?a=01.png&b=02.png"):
|
||||
for vp, want in (({"width": 1280, "height": 800}, "hidden"), ({"width": 390, "height": 844}, "visible")):
|
||||
page = browser.new_page(viewport=vp)
|
||||
page.goto(f"{base}{path}", wait_until="networkidle")
|
||||
got = page.evaluate("""() => ['header.topbar', 'footer.foot'].map(
|
||||
s => getComputedStyle(document.querySelector(s)).visibility)""")
|
||||
assert got == [want, want], (path, vp, got)
|
||||
page.close()
|
||||
|
||||
|
||||
# ---- G4: the line speaks in time ------------------------------------------------------------
|
||||
|
||||
def test_the_status_line_is_always_displayed(browser, live):
|
||||
base, root = live
|
||||
_set(root, 2)
|
||||
for path in ("/b/g/", "/b/g/view?f=01.png"):
|
||||
page = _page(browser, base, path, hold=True)
|
||||
shown = "() => { const c = getComputedStyle(document.querySelector('[data-region=\"status\"]')); return [c.display, c.visibility]; }"
|
||||
empty = page.evaluate(shown)
|
||||
page.locator("form.flagtoggle button, #rail form.vflag button").first.click()
|
||||
_wait_said(page, "Saving")
|
||||
full = page.evaluate(shown)
|
||||
page.close()
|
||||
for d, v in (empty, full):
|
||||
assert d != "none" and v == "visible", (path, empty, full)
|
||||
|
||||
|
||||
def test_an_empty_status_line_takes_no_space(browser, live):
|
||||
base, root = live
|
||||
_set(root, 2)
|
||||
for path in ("/b/g/", "/b/g/view?f=01.png", "/b/g/compare?a=01.png&b=02.png"):
|
||||
page = _page(browser, base, path)
|
||||
h = page.evaluate("document.querySelector('[data-region=\"status\"]').getBoundingClientRect().height")
|
||||
page.close()
|
||||
assert h == 0, (path, h)
|
||||
|
||||
|
||||
def test_a_save_says_saving_then_saved(browser, live):
|
||||
base, root = live
|
||||
_set(root, 2)
|
||||
page = _page(browser, base, "/b/g/", hold=True)
|
||||
page.locator('figure.item[data-item="01.png"] .flagtoggle button').click()
|
||||
_wait_held(page)
|
||||
assert _said(page) == {"text": "Saving…", "tone": None}
|
||||
page.evaluate("window.__release()")
|
||||
_wait_said(page, "Saved.")
|
||||
assert _said(page) == {"text": "Saved.", "tone": None}
|
||||
page.wait_for_timeout(2500)
|
||||
assert _said(page) == {"text": "", "tone": None}
|
||||
page.close()
|
||||
|
||||
|
||||
def test_a_new_save_is_not_cleared_by_the_last_ones_timer(browser, live):
|
||||
base, root = live
|
||||
_set(root, 3)
|
||||
page = _page(browser, base, "/b/g/", hold=True)
|
||||
page.locator('figure.item[data-item="01.png"] .flagtoggle button').click()
|
||||
_wait_held(page)
|
||||
page.evaluate("window.__release()")
|
||||
_wait_said(page, "Saved.")
|
||||
page.locator('figure.item[data-item="02.png"] .flagtoggle button').click()
|
||||
_wait_held(page)
|
||||
page.wait_for_timeout(2500)
|
||||
assert _said(page)["text"] == "Saving…", "the last save's timer cleared the new one's words"
|
||||
page.evaluate("window.__release()")
|
||||
_wait_said(page, "Saved.")
|
||||
page.close()
|
||||
|
||||
|
||||
def test_a_queued_save_keeps_saying_saving(browser, live):
|
||||
base, root = live
|
||||
_set(root, 3)
|
||||
page = _page(browser, base, "/b/g/", hold=True)
|
||||
page.locator('figure.item[data-item="01.png"] .flagtoggle button').click()
|
||||
page.locator('figure.item[data-item="02.png"] .flagtoggle button').click()
|
||||
_wait_held(page)
|
||||
page.evaluate("window.__release()")
|
||||
_wait_swaps(page, 1)
|
||||
assert _said(page)["text"] == "Saving…", "the first save said Saved. while the second was queued"
|
||||
_wait_held(page)
|
||||
page.evaluate("window.__release()")
|
||||
_wait_said(page, "Saved.")
|
||||
page.close()
|
||||
|
||||
|
||||
def test_a_batch_speaks_too(browser, live):
|
||||
base, root = live
|
||||
b = _set(root, 1)
|
||||
_picks(b)
|
||||
page = _page(browser, base, "/b/g/marks", hold=True)
|
||||
page.check('#mark-a1 input[type=radio][value="yes"]')
|
||||
page.check('#mark-a3 input[type=radio][value="no"]')
|
||||
page.locator("#mark-a1 .mark-submit").click()
|
||||
_wait_held(page)
|
||||
assert _said(page) == {"text": "Saving…", "tone": None}
|
||||
page.locator("#mark-a2 .mark-submit").click()
|
||||
assert _said(page)["text"] == "Still saving…"
|
||||
page.evaluate("window.__release()")
|
||||
_wait_held(page)
|
||||
page.evaluate("window.__release()")
|
||||
_wait_said(page, "Saved.")
|
||||
assert _said(page)["tone"] is None
|
||||
page.close()
|
||||
|
||||
|
||||
def test_a_repeat_press_says_still_saving(browser, live):
|
||||
base, root = live
|
||||
_set(root, 2)
|
||||
page = _page(browser, base, "/b/g/", hold=True)
|
||||
btn = 'figure.item[data-item="01.png"] .flagtoggle button'
|
||||
page.locator(btn).click()
|
||||
_wait_held(page)
|
||||
page.locator(btn).click()
|
||||
assert _said(page)["text"] == "Still saving…"
|
||||
page.wait_for_timeout(2500)
|
||||
assert _said(page)["text"] == "Still saving…", "it cleared while the save was still held"
|
||||
assert page.evaluate("window.__held.length") == 1, "the repeat press sent a second POST"
|
||||
page.evaluate("window.__release()")
|
||||
_wait_said(page, "Saved.")
|
||||
page.close()
|
||||
|
||||
|
||||
def test_the_form_in_flight_is_busy(browser, live):
|
||||
base, root = live
|
||||
_set(root, 2)
|
||||
page = _page(browser, base, "/b/g/", hold=True)
|
||||
form = 'figure.item[data-item="01.png"] form.flagtoggle'
|
||||
page.locator(f"{form} button").click()
|
||||
_wait_held(page)
|
||||
assert page.locator(form).get_attribute("aria-busy") == "true"
|
||||
page.evaluate("window.__release()")
|
||||
_wait_swaps(page, 1)
|
||||
assert page.locator(form).get_attribute("aria-busy") is None
|
||||
# the failure path that stays (another draft is on the page): busy ends too
|
||||
page.locator(".verdict .mark-add textarea").fill("another draft")
|
||||
page.locator(f"{form} button").click()
|
||||
_wait_held(page)
|
||||
page.evaluate("window.__release('fail')")
|
||||
_wait_said(page, "Could not save in place")
|
||||
assert page.locator(form).get_attribute("aria-busy") is None
|
||||
page.close()
|
||||
|
||||
|
||||
def test_a_failure_then_an_edit_then_a_save(browser, live):
|
||||
"""A request trace: a failure that stays, an edit, a second press."""
|
||||
from booth.marks import marks_for
|
||||
base, root = live
|
||||
b = _set(root, 2)
|
||||
page = _page(browser, base, "/b/g/", hold=True)
|
||||
page.locator(".verdict .mark-add textarea").fill("keep me")
|
||||
tile = 'figure.item[data-item="01.png"]'
|
||||
page.locator(f"{tile} .item-addnote summary").click()
|
||||
page.locator(f"{tile} .item-addnote textarea").fill("first")
|
||||
page.locator(f"{tile} .item-addnote button").click()
|
||||
_wait_held(page)
|
||||
page.evaluate("window.__release('fail')")
|
||||
_wait_said(page, "Could not save in place. Reload to see what was saved")
|
||||
page.wait_for_timeout(2500)
|
||||
said = _said(page)
|
||||
assert said["tone"] == "warn" and "Could not save" in said["text"], said
|
||||
page.locator(f"{tile} .item-addnote textarea").fill("first, edited")
|
||||
page.locator(f"{tile} .item-addnote button").click()
|
||||
_wait_held(page)
|
||||
assert _said(page) == {"text": "Saving…", "tone": None}
|
||||
page.evaluate("window.__release()")
|
||||
_wait_said(page, "Saved.")
|
||||
same = page.evaluate("window.__same === 1")
|
||||
kept = page.locator(".verdict .mark-add textarea").input_value()
|
||||
page.close()
|
||||
assert same and kept == "keep me", (same, kept)
|
||||
assert [m.text for m in marks_for(b) if m.shape == "note"] == ["first, edited"]
|
||||
|
||||
|
||||
# ---- G13: leaving with an unsent draft asks first ----------------------------------------------
|
||||
|
||||
def _dialogs(page):
|
||||
seen = []
|
||||
page.on("dialog", lambda d: (seen.append(d.type), d.dismiss()))
|
||||
return seen
|
||||
|
||||
|
||||
def test_leaving_with_a_draft_asks(browser, live):
|
||||
base, root = live
|
||||
_set(root, 3)
|
||||
page = _page(browser, base, "/b/g/view?f=01.png")
|
||||
seen = _dialogs(page)
|
||||
page.locator("#vnote-text").fill("draft")
|
||||
page.locator('.film a.film-f[href="?f=02.png"]').click(no_wait_after=True)
|
||||
page.wait_for_timeout(600)
|
||||
assert seen == ["beforeunload"], seen
|
||||
assert "f=01.png" in page.url and page.locator("#vnote-text").input_value() == "draft"
|
||||
page.evaluate("document.activeElement.blur()")
|
||||
page.keyboard.press("ArrowRight")
|
||||
page.wait_for_timeout(600)
|
||||
assert seen == ["beforeunload", "beforeunload"], seen
|
||||
assert "f=01.png" in page.url
|
||||
page.close()
|
||||
|
||||
|
||||
def test_leaving_a_clean_page_does_not_ask(browser, live):
|
||||
base, root = live
|
||||
_set(root, 3)
|
||||
page = _page(browser, base, "/b/g/view?f=01.png")
|
||||
seen = _dialogs(page)
|
||||
page.locator('.film a.film-f[href="?f=02.png"]').click()
|
||||
page.wait_for_url("**f=02.png", timeout=5000)
|
||||
page.close()
|
||||
assert seen == []
|
||||
|
||||
|
||||
def test_a_saved_draft_no_longer_asks(browser, live):
|
||||
base, root = live
|
||||
_set(root, 3)
|
||||
page = _page(browser, base, "/b/g/view?f=01.png")
|
||||
seen = _dialogs(page)
|
||||
page.locator("#vnote-text").fill("saved")
|
||||
page.locator("form:has(#vnote-text) button[type=submit]").click()
|
||||
_wait_swaps(page, 1)
|
||||
page.locator('.film a.film-f[href="?f=02.png"]').click()
|
||||
page.wait_for_url("**f=02.png", timeout=5000)
|
||||
page.close()
|
||||
assert seen == []
|
||||
|
||||
|
||||
def test_a_failed_save_keeps_the_other_drafts(browser, live):
|
||||
base, root = live
|
||||
_set(root, 2)
|
||||
page = _page(browser, base, "/b/g/")
|
||||
page.route("**/b/g/flag", lambda route: route.fulfill(status=500, body=""))
|
||||
page.locator(".verdict .mark-add textarea").fill("other")
|
||||
page.locator('figure.item[data-item="01.png"] .flagtoggle button').click()
|
||||
_wait_said(page, "Could not save in place. Reload to see what was saved; your other entries are still here.")
|
||||
page.wait_for_timeout(1500)
|
||||
said = _said(page)
|
||||
same = page.evaluate("window.__same === 1")
|
||||
kept = page.locator(".verdict .mark-add textarea").input_value()
|
||||
page.close()
|
||||
assert same and kept == "other" and said["tone"] == "warn", (same, kept, said)
|
||||
|
||||
|
||||
def test_a_failed_save_keeps_text_typed_while_it_flew(browser, live):
|
||||
base, root = live
|
||||
_set(root, 2)
|
||||
page = _page(browser, base, "/b/g/", hold=True)
|
||||
box = ".verdict .mark-add textarea"
|
||||
page.locator(box).fill("first")
|
||||
page.locator(".verdict .mark-add button").click()
|
||||
_wait_held(page)
|
||||
page.locator(box).fill("first and more")
|
||||
page.evaluate("window.__release('fail')")
|
||||
_wait_said(page, "Could not save in place")
|
||||
page.wait_for_timeout(1500)
|
||||
same = page.evaluate("window.__same === 1")
|
||||
kept = page.locator(box).input_value()
|
||||
page.close()
|
||||
assert same and kept == "first and more", (same, kept)
|
||||
|
||||
|
||||
def test_a_changed_page_keeps_the_other_drafts(browser, live):
|
||||
base, root = live
|
||||
b = _set(root, 2)
|
||||
page = _page(browser, base, "/b/g/", hold=True)
|
||||
page.locator(".verdict .mark-add textarea").fill("other")
|
||||
page.locator('figure.item[data-item="01.png"] .flagtoggle button').click()
|
||||
_wait_held(page)
|
||||
(b / "03.png").write_bytes(PNG) # a region the live page does not have
|
||||
page.evaluate("window.__release()")
|
||||
_wait_said(page, "Saved. The page changed meanwhile; reload to see it.")
|
||||
page.wait_for_timeout(1500)
|
||||
busy = page.locator('figure.item[data-item="01.png"] form.flagtoggle').get_attribute("aria-busy")
|
||||
assert busy is None, "a settled save left its form busy"
|
||||
said = _said(page)
|
||||
same = page.evaluate("window.__same === 1")
|
||||
kept = page.locator(".verdict .mark-add textarea").input_value()
|
||||
page.close()
|
||||
assert same and kept == "other" and said["tone"] == "warn", (same, kept, said)
|
||||
|
||||
|
||||
def test_a_draft_typed_during_the_beat_stays(browser, live):
|
||||
base, root = live
|
||||
_set(root, 2)
|
||||
page = _page(browser, base, "/b/g/")
|
||||
page.route("**/b/g/flag", lambda route: route.fulfill(status=500, body=""))
|
||||
page.locator('figure.item[data-item="01.png"] .flagtoggle button').click()
|
||||
_wait_said(page, "reloading to show what was saved")
|
||||
page.locator(".verdict .mark-add textarea").fill("typed in the beat")
|
||||
page.wait_for_timeout(1500)
|
||||
same = page.evaluate("window.__same === 1")
|
||||
kept = page.locator(".verdict .mark-add textarea").input_value()
|
||||
said = _said(page)
|
||||
page.close()
|
||||
assert same and kept == "typed in the beat", (same, kept)
|
||||
assert "your other entries are still here" in said["text"], said
|
||||
|
||||
|
||||
def test_its_own_reload_does_not_ask(browser, live):
|
||||
base, root = live
|
||||
_set(root, 2)
|
||||
page = _page(browser, base, "/b/g/")
|
||||
seen = _dialogs(page)
|
||||
page.route("**/b/g/note", lambda route: route.fulfill(status=500, body=""))
|
||||
page.locator(".verdict .mark-add textarea").fill("sent, and failed")
|
||||
page.locator(".verdict .mark-add button").click()
|
||||
_wait_said(page, "reloading to show what was saved")
|
||||
page.wait_for_function("window.__same !== 1", timeout=5000)
|
||||
page.close()
|
||||
assert seen == [], seen
|
||||
|
||||
|
||||
def test_embed_submit_does_not_ask(browser, live):
|
||||
from booth.marks import declare_pick, marks_for
|
||||
base, root = live
|
||||
b = root / "r"
|
||||
b.mkdir()
|
||||
declare_pick(b, "winner", {"prompt": "Which render wins?", "options": ["A", "B"]})
|
||||
(b / "index.html").write_text(
|
||||
'<!doctype html><html><head><script src="/_booth/embed.js" defer></script></head>'
|
||||
'<body><h1>Report</h1><div data-booth-ask="winner"></div></body></html>')
|
||||
page = browser.new_page()
|
||||
page.goto(f"{base}/b/r/", wait_until="networkidle")
|
||||
page.wait_for_selector(".bk-ask-opt input[type=radio]", timeout=10000)
|
||||
seen = _dialogs(page)
|
||||
page.locator(".bk-ask-opt input[type=radio]").first.check()
|
||||
with page.expect_navigation(timeout=10000):
|
||||
page.locator(".bk-ask-go").click()
|
||||
page.close()
|
||||
assert seen == []
|
||||
assert [m.answer for m in marks_for(b)][0], "the plain submit did not land"
|
||||
|
||||
|
||||
def test_embed_leaving_with_a_draft_asks(browser, live):
|
||||
"""The positive control for the embed's guard: a changed answer, then the
|
||||
home chip, asks."""
|
||||
from booth.marks import declare_pick
|
||||
base, root = live
|
||||
b = root / "r"
|
||||
b.mkdir()
|
||||
declare_pick(b, "winner", {"prompt": "Which render wins?", "options": ["A", "B"]})
|
||||
(b / "index.html").write_text(
|
||||
'<!doctype html><html><head><script src="/_booth/embed.js" defer></script></head>'
|
||||
'<body><h1>Report</h1><div data-booth-ask="winner"></div></body></html>')
|
||||
page = browser.new_page()
|
||||
page.goto(f"{base}/b/r/", wait_until="networkidle")
|
||||
page.wait_for_selector(".bk-ask-opt input[type=radio]", timeout=10000)
|
||||
seen = _dialogs(page)
|
||||
page.locator(".bk-ask-opt input[type=radio]").first.check()
|
||||
page.locator(".booth-nav-home").click(no_wait_after=True)
|
||||
page.wait_for_timeout(600)
|
||||
page.close()
|
||||
assert seen == ["beforeunload"], seen
|
||||
|
||||
|
||||
# ---- folded from the heid bug-hunt (HNITTA, thread 01M3MRTNTWEPJHTN4APRR81KH4) -------------
|
||||
|
||||
def test_a_stale_tile_is_not_left_busy(browser, live):
|
||||
"""R1: a save whose own tile survives the swap as a stale tile (un-flagged
|
||||
under the flagged filter) settles like any other: its form is not busy."""
|
||||
from booth.marks import set_flag
|
||||
base, root = live
|
||||
b = _set(root, 2)
|
||||
set_flag(b, "01.png", True)
|
||||
set_flag(b, "02.png", True)
|
||||
page = _page(browser, base, "/b/g/?filter=flagged")
|
||||
form = 'figure.item[data-item="01.png"] form.flagtoggle'
|
||||
page.locator(f"{form} button").click()
|
||||
page.wait_for_selector('figure.item.is-stale[data-item="01.png"]', timeout=10000)
|
||||
page.wait_for_timeout(300)
|
||||
assert page.locator(form).get_attribute("aria-busy") is None
|
||||
page.close()
|
||||
|
||||
|
||||
def test_a_queued_form_is_busy_on_the_live_page(browser, live):
|
||||
"""R1's twin: an earlier save's swap replaces a queued form's node, and the
|
||||
live copy is the one that must say it is busy."""
|
||||
base, root = live
|
||||
_set(root, 2)
|
||||
page = _page(browser, base, "/b/g/", hold=True)
|
||||
page.locator('figure.item[data-item="01.png"] .flagtoggle button').click()
|
||||
_wait_held(page)
|
||||
page.locator(".verdict .mark-add textarea").fill("queued")
|
||||
page.locator(".verdict .mark-add button").click()
|
||||
page.evaluate("window.__release()")
|
||||
_wait_swaps(page, 1)
|
||||
assert page.locator(".verdict form.mark-add").get_attribute("aria-busy") == "true"
|
||||
_wait_held(page)
|
||||
page.evaluate("window.__release()")
|
||||
_wait_swaps(page, 2)
|
||||
page.wait_for_timeout(200)
|
||||
assert page.locator(".verdict form.mark-add").get_attribute("aria-busy") is None
|
||||
page.close()
|
||||
|
||||
|
||||
def test_a_resubmit_in_the_beat_is_not_reloaded_away(browser, live):
|
||||
"""R2: the failure's reload is due, and the operator presses again inside
|
||||
the beat. The new save owns the page; the old reload must not abort it."""
|
||||
from booth.marks import marks_for
|
||||
base, root = live
|
||||
b = _set(root, 2)
|
||||
page = _page(browser, base, "/b/g/")
|
||||
calls = {"n": 0}
|
||||
|
||||
def first_fails(route):
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
route.fulfill(status=500, body="")
|
||||
else:
|
||||
route.continue_()
|
||||
page.route("**/b/g/note", first_fails)
|
||||
page.locator(".verdict .mark-add textarea").fill("again")
|
||||
page.locator(".verdict .mark-add button").click()
|
||||
_wait_said(page, "reloading to show what was saved")
|
||||
page.locator(".verdict .mark-add button").click()
|
||||
_wait_said(page, "Saved.")
|
||||
page.wait_for_timeout(1500)
|
||||
same = page.evaluate("window.__same === 1")
|
||||
page.close()
|
||||
assert same, "the old failure's reload fired over the new save"
|
||||
assert [m.text for m in marks_for(b) if m.shape == "note"] == ["again"]
|
||||
|
||||
|
||||
def test_an_unrelated_save_does_not_bury_a_failure(browser, live):
|
||||
"""R4: a note fails and stays; a different save then lands. The line keeps
|
||||
saying the note is not saved, rather than "Saved."."""
|
||||
base, root = live
|
||||
_set(root, 2)
|
||||
page = _page(browser, base, "/b/g/", hold=True)
|
||||
page.locator(".verdict .mark-add textarea").fill("another draft") # so the failure 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_held(page)
|
||||
page.evaluate("window.__release('fail')")
|
||||
_wait_said(page, "Could not save in place. Reload to see what was saved")
|
||||
page.locator('figure.item[data-item="02.png"] .flagtoggle button').click()
|
||||
_wait_held(page)
|
||||
page.evaluate("window.__release()")
|
||||
_wait_swaps(page, 1)
|
||||
page.wait_for_timeout(300)
|
||||
said = _said(page)
|
||||
page.close()
|
||||
assert said["tone"] == "warn" and "Could not save in place" in said["text"], said
|
||||
|
||||
|
||||
def test_a_save_whose_page_would_not_refresh_says_saved(browser, live):
|
||||
"""R8: the POST lands (204) and the page GET fails. That is a save, and the
|
||||
line must not say it could not save (a second press would write it twice)."""
|
||||
from booth.marks import marks_for
|
||||
base, root = live
|
||||
b = _set(root, 2)
|
||||
page = _page(browser, base, "/b/g/")
|
||||
gate = {"on": False}
|
||||
page.route("**/b/g/", lambda route: route.fulfill(status=500, body="")
|
||||
if gate["on"] and route.request.method == "GET" else route.continue_())
|
||||
page.locator(".verdict .mark-add textarea").fill("landed")
|
||||
tile = 'figure.item[data-item="01.png"]'
|
||||
page.locator(f"{tile} .item-addnote summary").click()
|
||||
page.locator(f"{tile} .item-addnote textarea").fill("another draft")
|
||||
gate["on"] = True
|
||||
page.locator(".verdict .mark-add button").click()
|
||||
_wait_said(page, "Saved. Could not refresh the page; reload to see it.")
|
||||
page.wait_for_timeout(1500)
|
||||
said = _said(page)
|
||||
same = page.evaluate("window.__same === 1")
|
||||
page.close()
|
||||
assert said["tone"] == "warn" and same, (said, same)
|
||||
assert [m.text for m in marks_for(b) if m.shape == "note"] == ["landed"]
|
||||
|
||||
|
||||
def test_focus_on_a_summary_survives_the_next_swap(browser, live):
|
||||
"""R10: focus that was restored to an answered pick's summary stays there
|
||||
through the next swap."""
|
||||
base, root = live
|
||||
b = _set(root, 1)
|
||||
_picks(b, ("a1", "a2"))
|
||||
page = _page(browser, base, "/b/g/marks")
|
||||
page.check('#mark-a1 input[type=radio][value="yes"]')
|
||||
page.locator("#mark-a1 .mark-submit").focus()
|
||||
page.keyboard.press("Enter")
|
||||
_wait_swaps(page, 1)
|
||||
summary = "#mark-a1 details.mark-formwrap > summary"
|
||||
assert _focused(page, summary)
|
||||
page.evaluate("document.querySelector('#mark-a2 input[type=radio][value=\"no\"]').checked = true;"
|
||||
"document.querySelector('#mark-a2 form.mark-form').requestSubmit()")
|
||||
_wait_swaps(page, 2)
|
||||
assert _focused(page, summary), page.evaluate("document.activeElement.outerHTML.slice(0, 120)")
|
||||
page.close()
|
||||
|
||||
|
||||
def test_an_edit_made_while_saving_is_not_called_saved(browser, live):
|
||||
"""R12: text added to a note while it was saving is on screen and not on
|
||||
the server. The line says so, instead of "Saved."."""
|
||||
base, root = live
|
||||
_set(root, 2)
|
||||
page = _page(browser, base, "/b/g/", hold=True)
|
||||
box = ".verdict .mark-add textarea"
|
||||
page.locator(box).fill("hello")
|
||||
page.locator(".verdict .mark-add button").click()
|
||||
_wait_held(page)
|
||||
page.locator(box).fill("hello!")
|
||||
page.evaluate("window.__release()")
|
||||
_wait_swaps(page, 1)
|
||||
page.wait_for_timeout(300)
|
||||
said = _said(page)
|
||||
kept = page.locator(box).input_value()
|
||||
page.close()
|
||||
assert kept == "hello!", kept
|
||||
assert said["tone"] == "warn" and "not saved yet" in said["text"], said
|
||||
|
||||
|
||||
def _embed_page(browser, base, root, host_script=""):
|
||||
from booth.marks import declare_pick
|
||||
b = root / "r"
|
||||
b.mkdir()
|
||||
declare_pick(b, "winner", {"prompt": "Which render wins?", "options": ["A", "B"]})
|
||||
(b / "index.html").write_text(
|
||||
'<!doctype html><html><head><script src="/_booth/embed.js" defer></script></head>'
|
||||
f'<body><h1>Report</h1><div data-booth-ask="winner"></div>{host_script}</body></html>')
|
||||
page = browser.new_page()
|
||||
page.goto(f"{base}/b/r/", wait_until="networkidle")
|
||||
page.wait_for_selector(".bk-ask-opt input[type=radio]", timeout=10000)
|
||||
return page
|
||||
|
||||
|
||||
def test_embed_a_cancelled_submit_is_guarded_again(browser, live):
|
||||
"""R3: a host handler cancels our native submit. Nothing was sent, so the
|
||||
answer is an unsent draft again, and leaving asks."""
|
||||
base, root = live
|
||||
host = ("<script>document.addEventListener('DOMContentLoaded', function () {"
|
||||
" document.addEventListener('submit', function (e) { e.preventDefault(); }); });</script>")
|
||||
page = _embed_page(browser, base, root, host)
|
||||
seen = _dialogs(page)
|
||||
page.locator(".bk-ask-opt input[type=radio]").first.check()
|
||||
page.locator(".bk-ask-go").click()
|
||||
page.wait_for_timeout(300)
|
||||
assert "/b/r/" in page.url and seen == [], "positive control: the host cancelled it"
|
||||
page.locator(".booth-nav-home").click(no_wait_after=True)
|
||||
page.wait_for_timeout(600)
|
||||
page.close()
|
||||
assert seen == ["beforeunload"], seen
|
||||
|
||||
|
||||
def test_embed_the_skip_covers_one_leave(browser, live):
|
||||
"""R3: the skip for our own submit covers the one navigation it started. A
|
||||
submit whose response does not replace the page (a 204 here; a stop or Esc
|
||||
in life) leaves the answer unsent on screen, and the next leave asks."""
|
||||
base, root = live
|
||||
page = _embed_page(browser, base, root)
|
||||
page.route("**/b/r/answer", lambda route: route.fulfill(status=204, body=""))
|
||||
seen = _dialogs(page)
|
||||
page.locator(".bk-ask-opt input[type=radio]").first.check()
|
||||
page.locator(".bk-ask-go").click()
|
||||
page.wait_for_timeout(500)
|
||||
assert "/b/r/" in page.url and seen == [], "positive control: the page stayed, and our submit did not ask"
|
||||
page.locator(".booth-nav-home").click(no_wait_after=True)
|
||||
page.wait_for_timeout(600)
|
||||
page.close()
|
||||
assert seen == ["beforeunload"], seen
|
||||
@@ -144,7 +144,9 @@ def test_a_failed_save_says_so_reloads_and_never_re_posts(browser, live):
|
||||
(b / ".marks.json").write_text("{damaged")
|
||||
page.locator('figure.item[data-item="01.png"] .flagtoggle button').click()
|
||||
# the reader is TOLD before the page goes (Wren, hulda: nothing asserted it)
|
||||
page.wait_for_selector('[data-region="status"]:not([hidden])', timeout=5000)
|
||||
page.wait_for_function( # as S5b: the line is never hidden; wait for its words
|
||||
"n => document.querySelector('[data-region=\"status\"]').textContent.includes(n)",
|
||||
arg="Could not save in place", timeout=5000)
|
||||
said = page.locator('[data-region="status"]').inner_text()
|
||||
page.wait_for_load_state("networkidle")
|
||||
page.wait_for_timeout(1500) # past the reload
|
||||
@@ -173,7 +175,9 @@ def test_a_lost_response_after_a_landed_write_is_never_retried(browser, live):
|
||||
page.route("**/b/g/note", drop_after_write)
|
||||
page.locator(".verdict .mark-add textarea").fill("exactly once")
|
||||
page.locator(".verdict .mark-add button").click()
|
||||
page.wait_for_selector('[data-region="status"]:not([hidden])', timeout=5000)
|
||||
page.wait_for_function( # as S5b: the line is never hidden; wait for its words
|
||||
"n => document.querySelector('[data-region=\"status\"]').textContent.includes(n)",
|
||||
arg="Could not save in place", timeout=5000)
|
||||
page.wait_for_timeout(1500)
|
||||
page.close()
|
||||
notes = [m for m in marks_for(b) if m.shape == "note" and m.text == "exactly once"]
|
||||
@@ -1588,7 +1592,9 @@ def test_a_refused_pick_in_a_batch_costs_only_itself_and_clears_nothing(browser,
|
||||
page.check(f'#mark-{mid} input[type=radio][value="yes"]')
|
||||
page.locator(".mark-add textarea").fill("half a thought")
|
||||
page.locator("#mark-a1 .mark-submit").click()
|
||||
page.wait_for_selector('[data-region="status"]:not([hidden])', timeout=10000)
|
||||
page.wait_for_function( # as S5b: the line is never hidden; wait for its words
|
||||
"n => document.querySelector('[data-region=\"status\"]').textContent.includes(n)",
|
||||
arg="Not saved", timeout=10000)
|
||||
page.wait_for_function(
|
||||
"document.querySelectorAll('.mark-pick.is-answered').length === 2", timeout=10000)
|
||||
said = page.locator('[data-region="status"]').inner_text()
|
||||
@@ -1644,11 +1650,13 @@ def test_pressing_a_clean_pick_during_a_batch_sends_nothing(browser, live):
|
||||
page.wait_for_function(
|
||||
"document.querySelectorAll('.mark-pick.is-answered').length === 2", timeout=10000)
|
||||
page.wait_for_timeout(1200)
|
||||
status_hidden = page.evaluate("document.querySelector('[data-region=\"status\"]').hidden")
|
||||
# as S5b: the line is never hidden and says "Saved."; what this held is that
|
||||
# it carries no failure (no warn tone)
|
||||
status_quiet = page.evaluate("!document.querySelector('[data-region=\"status\"]').hasAttribute('data-tone')")
|
||||
same = page.evaluate("window.__same === 1")
|
||||
page.close()
|
||||
assert [p.split("&")[0] for p in posts] == ["ask=a1", "ask=a3"], posts
|
||||
assert status_hidden and same, (status_hidden, same)
|
||||
assert status_quiet and same, (status_quiet, same)
|
||||
|
||||
|
||||
# The first page refresh after a save held 1s, so a batch queues behind a flag
|
||||
@@ -1698,16 +1706,19 @@ def test_a_later_save_clears_a_stale_not_saved_line(browser, live):
|
||||
page.check('#mark-a1 input[type=radio][value="yes"]')
|
||||
page.check('#mark-a2 input[type=radio][value="no"]')
|
||||
page.locator("#mark-a1 .mark-submit").click()
|
||||
page.wait_for_selector('[data-region="status"]:not([hidden])', timeout=10000)
|
||||
page.wait_for_function( # as S5b: the line is never hidden; wait for its words
|
||||
"n => document.querySelector('[data-region=\"status\"]').textContent.includes(n)",
|
||||
arg="Not saved", timeout=10000)
|
||||
page.wait_for_function(
|
||||
"document.querySelectorAll('.mark-pick.is-answered').length === 1", timeout=10000)
|
||||
drop["a2"] = False
|
||||
page.locator("#mark-a2 .mark-submit").click()
|
||||
page.wait_for_function(
|
||||
"document.querySelectorAll('.mark-pick.is-answered').length === 2", timeout=10000)
|
||||
hidden = page.evaluate("document.querySelector('[data-region=\"status\"]').hidden")
|
||||
# as S5b: the line is never hidden; what this held is that the stale words go
|
||||
gone = page.evaluate("!document.querySelector('[data-region=\"status\"]').textContent.includes('Not saved')")
|
||||
page.close()
|
||||
assert hidden, "the stale 'not saved' line outlived the save that fixed it"
|
||||
assert gone, "the stale 'not saved' line outlived the save that fixed it"
|
||||
assert _chosen(b) == {"a1": "yes", "a2": "no"}
|
||||
|
||||
|
||||
@@ -1797,7 +1808,9 @@ def test_a_batch_whose_refresh_fails_keeps_the_page(browser, live):
|
||||
page.check('#mark-a2 input[type=radio][value="no"]')
|
||||
page.locator(".mark-add textarea").fill("not sent")
|
||||
page.locator("#mark-a1 .mark-submit").click()
|
||||
page.wait_for_selector('[data-region="status"]:not([hidden])', timeout=10000)
|
||||
page.wait_for_function( # as S5b: the line is never hidden; wait for its words
|
||||
"n => document.querySelector('[data-region=\"status\"]').textContent.includes(n)",
|
||||
arg="Could not refresh", timeout=10000)
|
||||
page.wait_for_timeout(1500)
|
||||
said = page.locator('[data-region="status"]').inner_text()
|
||||
same = page.evaluate("window.__same === 1")
|
||||
|
||||
Reference in New Issue
Block a user