fix(as-S5a): fixup from booth-dev's gate — release asks, fail-closed words, ids that cannot collide
From booth-dev's hulda bug-hunt with heid's second voice (BRINGA, thread 01M3MVGQ7QSCCK8WT59TQ4J469): - The booth page's "kept — release" asks by name, as the Desk's does. - WORDS has no prototype: data-confirm="__proto__" or "constructor" is an unknown word, and asks, instead of throwing before preventDefault. - The confirm helper moved into <head>: its capture listener exists before any form, so a click during load is asked too (the inline confirm() it replaced had that property). - shown() also marks U+2028/U+2029 and the zero-width characters. - Derived ids take ':' (bk-ask-<id>-<key>:prompt, bk-ask-<id>:title), which no id or key can contain; '-prompt' and '-title' collided with valid keys. booth-dev's chip test now looks its fragment up by [id=...]. - human_dur says "—" for a value that is not finite, instead of raising. - The tile's copy of a note drops its id (booth-dev: mark-<id> is the panel's article). - Four guards that asserted source patterns now also hold on computed effects: embed rings, rings inside clipping containers, the withdraw × on both axes, and question-level notes fields. Contract: as_antislop S5a (fixup). Falsifiers: antislop.toml 102/102 with r2_flow.toml 24/24 proved; the full gate follows.
This commit is contained in:
@@ -226,6 +226,8 @@ from booth.links import ( # noqa: E402
|
|||||||
|
|
||||||
|
|
||||||
def human_dur(seconds: float) -> str:
|
def human_dur(seconds: float) -> str:
|
||||||
|
if not math.isfinite(seconds): # as S5a fixup: int(nan) raises; say nothing we cannot know
|
||||||
|
return "—"
|
||||||
s = int(seconds)
|
s = int(seconds)
|
||||||
if s <= 0:
|
if s <= 0:
|
||||||
return "expired"
|
return "expired"
|
||||||
|
|||||||
@@ -25,10 +25,10 @@
|
|||||||
{% set skipped = a.answer and not picked %}
|
{% set skipped = a.answer and not picked %}
|
||||||
<div class="bk-ask{% if picked %} bk-done{% elif skipped %} bk-skip{% endif %}" id="bk-ask-{{ a.id }}{% if q.key %}-{{ q.key }}{% endif %}">
|
<div class="bk-ask{% if picked %} bk-done{% elif skipped %} bk-skip{% endif %}" id="bk-ask-{{ a.id }}{% if q.key %}-{{ q.key }}{% endif %}">
|
||||||
<span class="bk-ask-tag">{% if picked %}✓ answered{% elif skipped %}— skipped{% else %}? your pick{% endif %}</span>
|
<span class="bk-ask-tag">{% if picked %}✓ answered{% elif skipped %}— skipped{% else %}? your pick{% endif %}</span>
|
||||||
<p class="bk-ask-prompt" id="bk-ask-{{ a.id }}{% if q.key %}-{{ q.key }}{% endif %}-prompt">{{ q.prompt }}</p>
|
<p class="bk-ask-prompt" id="bk-ask-{{ a.id }}{% if q.key %}-{{ q.key }}{% endif %}:prompt">{{ q.prompt }}</p>
|
||||||
{% if picked %}<p class="bk-ask-was">recorded: <b>{{ qa.label }}</b>{% if qa.notes %} — {{ qa.notes }}{% endif %}</p>
|
{% if picked %}<p class="bk-ask-was">recorded: <b>{{ qa.label }}</b>{% if qa.notes %} — {{ qa.notes }}{% endif %}</p>
|
||||||
{% elif skipped %}<p class="bk-ask-was">left blank — pick one any time, or leave it{% if qa and qa.notes %}; note: {{ qa.notes }}{% endif %}</p>{% endif %}
|
{% elif skipped %}<p class="bk-ask-was">left blank — pick one any time, or leave it{% if qa and qa.notes %}; note: {{ qa.notes }}{% endif %}</p>{% endif %}
|
||||||
<div class="bk-ask-opts" role="radiogroup" aria-labelledby="bk-ask-{{ a.id }}{% if q.key %}-{{ q.key }}{% endif %}-prompt">
|
<div class="bk-ask-opts" role="radiogroup" aria-labelledby="bk-ask-{{ a.id }}{% if q.key %}-{{ q.key }}{% endif %}:prompt">
|
||||||
{% for o in q.options %}
|
{% for o in q.options %}
|
||||||
<label class="bk-ask-opt">
|
<label class="bk-ask-opt">
|
||||||
<input type="radio" name="{{ field }}" value="{{ o.id }}"
|
<input type="radio" name="{{ field }}" value="{{ o.id }}"
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
<div class="bk-ask"><span class="bk-ask-tag">⚠ broken ask</span>
|
<div class="bk-ask"><span class="bk-ask-tag">⚠ broken ask</span>
|
||||||
<p class="bk-ask-err">this question could not be read: {{ a.error }}</p></div>
|
<p class="bk-ask-err">this question could not be read: {{ a.error }}</p></div>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% if a.title %}<p class="bk-ask-title" id="bk-ask-{{ a.id }}-title">{{ a.title }}</p>{% endif %}
|
{% if a.title %}<p class="bk-ask-title" id="bk-ask-{{ a.id }}:title">{{ a.title }}</p>{% endif %}
|
||||||
{% for q in a.questions %}{{ question(a, q, form_id, name_url) }}{% endfor %}
|
{% for q in a.questions %}{{ question(a, q, form_id, name_url) }}{% endfor %}
|
||||||
{{ submit(a, form_id, name_url) }}
|
{{ submit(a, form_id, name_url) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
+46
-40
@@ -30,6 +30,52 @@
|
|||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
<script>
|
||||||
|
/* as S5a: moved here from index.html so every page's destructive forms
|
||||||
|
(the Desk's wipe and release, a booth's own wipe and release) share ONE
|
||||||
|
prompt that names the booth and fails closed on an unknown word. In
|
||||||
|
<head>, so it is listening before any form exists: a click while the page
|
||||||
|
is still loading is asked too (a capture listener on document works before
|
||||||
|
<body> parses). */
|
||||||
|
/* Destructive-action confirmation, delegated and DATA-DRIVEN. The booth name
|
||||||
|
travels as a data attribute, where escaping is escaping, and never reaches
|
||||||
|
a JS string literal: a booth name is agent-authored, and an inline handler
|
||||||
|
carrying one was a live injection path. With JS off the form submits
|
||||||
|
without a prompt. */
|
||||||
|
(function () {
|
||||||
|
/* No prototype: "__proto__", "constructor" or "toString" is an unknown
|
||||||
|
word, which asks, instead of an inherited function that throws before
|
||||||
|
preventDefault and lets the form submit unasked. */
|
||||||
|
var WORDS = Object.create(null);
|
||||||
|
WORDS.release = function (n) {
|
||||||
|
return 'Release “' + n + '”?\n\nIt rejoins the sweep: it will be wiped '
|
||||||
|
+ '{{ ttl_hours|int }}h after its last activity. Nothing is deleted by this step.';
|
||||||
|
};
|
||||||
|
WORDS['wipe-kept'] = function (n) {
|
||||||
|
return 'WIPE the KEPT booth “' + n + '”?\n\nThis deletes it and its files '
|
||||||
|
+ 'immediately. Kept booths are the ones nothing else will clean up, so nobody '
|
||||||
|
+ 'else is going to do this for you — and nothing brings it back.';
|
||||||
|
};
|
||||||
|
WORDS.wipe = function (n) { return 'Wipe booth “' + n + '”?'; };
|
||||||
|
/* A word the page does not know still ASKS: fail closed. Keying the
|
||||||
|
prompt on `word &&` meant a typo'd data-confirm submitted unguarded. */
|
||||||
|
var ASK = function (n) { return 'Really do this to “' + n + '”?'; };
|
||||||
|
/* The name as the operator should READ it. Escaping keeps it out of any
|
||||||
|
script, but a bidi override (U+202E) or a newline in an agent-made name
|
||||||
|
still rewrote the dialog's text — what is read before approving a wipe.
|
||||||
|
Controls, bidi formatting, the line and paragraph separators and the
|
||||||
|
zero-width characters show as U+FFFD, visibly, never silently. */
|
||||||
|
function shown(n) {
|
||||||
|
return n.replace(/[\u0000-\u001f\u007f-\u009f\u061c\u200b-\u200f\u2028\u2029\u202a-\u202e\u2060\u2066-\u2069\ufeff]/g, '\ufffd');
|
||||||
|
}
|
||||||
|
document.addEventListener('submit', function (ev) {
|
||||||
|
var form = ev.target.closest ? ev.target.closest('form[data-confirm]') : null;
|
||||||
|
if (!form) return;
|
||||||
|
var word = WORDS[form.getAttribute('data-confirm')] || ASK;
|
||||||
|
if (!confirm(word(shown(form.getAttribute('data-booth') || '')))) ev.preventDefault();
|
||||||
|
}, true);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
<title>{% block title %}The Booth{% endblock %}</title>
|
<title>{% block title %}The Booth{% endblock %}</title>
|
||||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%2315191d'/%3E%3Cpath d='M7 12V7h5M20 7h5v5M7 20v5h5M25 20v5h-5' fill='none' stroke='%23b2cd12' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3Ccircle cx='16' cy='16' r='3' fill='%23b2cd12'/%3E%3C/svg%3E">
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%2315191d'/%3E%3Cpath d='M7 12V7h5M20 7h5v5M7 20v5h5M25 20v5h-5' fill='none' stroke='%23b2cd12' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3Ccircle cx='16' cy='16' r='3' fill='%23b2cd12'/%3E%3C/svg%3E">
|
||||||
{# The two SVOS voices. display=swap and the system stacks in --font-sans /
|
{# The two SVOS voices. display=swap and the system stacks in --font-sans /
|
||||||
@@ -1496,45 +1542,5 @@
|
|||||||
<footer class="foot">
|
<footer class="foot">
|
||||||
drop a folder into <code>{{ data_dir }}</code>{% if host %} · {{ host }}{% endif %}
|
drop a folder into <code>{{ data_dir }}</code>{% if host %} · {{ host }}{% endif %}
|
||||||
</footer>
|
</footer>
|
||||||
<script>
|
|
||||||
/* as S5a: moved here from index.html so every page's destructive forms
|
|
||||||
(the Desk's wipe and release, a booth's own wipe button) share ONE prompt that names
|
|
||||||
the booth and fails closed on an unknown word. */
|
|
||||||
/* Destructive-action confirmation, delegated and DATA-DRIVEN. The booth name
|
|
||||||
travels as a data attribute, where escaping is escaping, and never reaches
|
|
||||||
a JS string literal: a booth name is agent-authored, and an inline handler
|
|
||||||
carrying one was a live injection path. With JS off the form submits
|
|
||||||
without a prompt. */
|
|
||||||
(function () {
|
|
||||||
var WORDS = {
|
|
||||||
release: function (n) {
|
|
||||||
return 'Release “' + n + '”?\n\nIt rejoins the sweep: it will be wiped '
|
|
||||||
+ '{{ ttl_hours|int }}h after its last activity. Nothing is deleted by this step.';
|
|
||||||
},
|
|
||||||
'wipe-kept': function (n) {
|
|
||||||
return 'WIPE the KEPT booth “' + n + '”?\n\nThis deletes it and its files '
|
|
||||||
+ 'immediately. Kept booths are the ones nothing else will clean up, so nobody '
|
|
||||||
+ 'else is going to do this for you — and nothing brings it back.';
|
|
||||||
},
|
|
||||||
wipe: function (n) { return 'Wipe booth “' + n + '”?'; }
|
|
||||||
};
|
|
||||||
/* A word the page does not know still ASKS: fail closed. Keying the
|
|
||||||
prompt on `word &&` meant a typo'd data-confirm submitted unguarded. */
|
|
||||||
var ASK = function (n) { return 'Really do this to “' + n + '”?'; };
|
|
||||||
/* The name as the operator should READ it. Escaping keeps it out of any
|
|
||||||
script, but a bidi override (U+202E) or a newline in an agent-made name
|
|
||||||
still rewrote the dialog's text — what is read before approving a wipe.
|
|
||||||
Controls and bidi formatting show as U+FFFD, visibly, never silently. */
|
|
||||||
function shown(n) {
|
|
||||||
return n.replace(/[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, '\ufffd');
|
|
||||||
}
|
|
||||||
document.addEventListener('submit', function (ev) {
|
|
||||||
var form = ev.target.closest ? ev.target.closest('form[data-confirm]') : null;
|
|
||||||
if (!form) return;
|
|
||||||
var word = WORDS[form.getAttribute('data-confirm')] || ASK;
|
|
||||||
if (!confirm(word(shown(form.getAttribute('data-booth') || '')))) ev.preventDefault();
|
|
||||||
}, true);
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -44,7 +44,9 @@
|
|||||||
textarea. #}
|
textarea. #}
|
||||||
{% macro marknotes(name_url, it, marks) -%}
|
{% macro marknotes(name_url, it, marks) -%}
|
||||||
{% for m in marks if m.shape == 'note' %}
|
{% for m in marks if m.shape == 'note' %}
|
||||||
<div class="item-note" id="mark-{{ m.id }}">
|
{# no id: `mark-<id>` names the panel's article, which is what the CLI's
|
||||||
|
`#mark-<id>` links mean (booth-dev, 2026-09-28) #}
|
||||||
|
<div class="item-note">
|
||||||
<pre>{{ m.text }}</pre>
|
<pre>{{ m.text }}</pre>
|
||||||
<form method="post" action="/b/{{ name_url }}/unmark" data-inplace>
|
<form method="post" action="/b/{{ name_url }}/unmark" data-inplace>
|
||||||
<input type="hidden" name="mark" value="{{ m.id }}">
|
<input type="hidden" name="mark" value="{{ m.id }}">
|
||||||
@@ -94,7 +96,8 @@
|
|||||||
{# Promote or release without going back to the index. `next` keeps you on
|
{# Promote or release without going back to the index. `next` keeps you on
|
||||||
this page instead of bouncing you to /. #}
|
this page instead of bouncing you to /. #}
|
||||||
{% if kept %}
|
{% if kept %}
|
||||||
<form class="keep-lg" method="post" action="/b/{{ name_url }}/unkeep">
|
<form class="keep-lg" method="post" action="/b/{{ name_url }}/unkeep"
|
||||||
|
data-booth="{{ name }}" data-confirm="release">
|
||||||
<input type="hidden" name="next" value="/b/{{ name_url }}/">
|
<input type="hidden" name="next" value="/b/{{ name_url }}/">
|
||||||
<button title="release — rejoins the TTL sweep">★ kept — release</button>
|
<button title="release — rejoins the TTL sweep">★ kept — release</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -162,6 +162,16 @@ The markup and CSS half of the interaction work. It changes no script behaviour
|
|||||||
- A why truncated with an ellipsis carries its full text in `title`.
|
- A why truncated with an ellipsis carries its full text in `title`.
|
||||||
- A countdown of 48h or more rolls up to days (`6d 23h`, not `167h 12m`).
|
- A countdown of 48h or more rolls up to days (`6d 23h`, not `167h 12m`).
|
||||||
- *Falsifiable:* `test_a_truncated_why_carries_its_full_text`, `test_human_dur_rolls_up_to_days`.
|
- *Falsifiable:* `test_a_truncated_why_carries_its_full_text`, `test_human_dur_rolls_up_to_days`.
|
||||||
|
- **Fixup from booth-dev's gate** (a hulda bug-hunt plus heid's second voice, BRINGA, thread `01M3MVGQ7QSCCK8WT59TQ4J469`):
|
||||||
|
- The booth page's "★ kept — release" asks by name, as the Desk's release does.
|
||||||
|
- `WORDS` has no prototype, so a `data-confirm` of `__proto__` or `constructor` is an unknown word, and it asks.
|
||||||
|
- The confirm helper lives in `<head>`, so its capture listener is registered before any form exists. A click during load is asked too; the inline `confirm()` it replaced had that property.
|
||||||
|
- `shown()` also marks U+2028, U+2029, U+200B–U+200D, U+2060 and U+FEFF.
|
||||||
|
- Derived ids take a `:`, which no ask id or question key can contain: `bk-ask-<id>-<key>:prompt` and `bk-ask-<id>:title`. `-prompt` or `-title` collided with valid keys. The asks chip still jumps to it by URL fragment; booth-dev's `test_the_chip_does_not_jump_to_a_mark_that_merely_shares_a_prefix` now looks the target up by `[id=…]`, since a `#` selector cannot hold a `:`.
|
||||||
|
- `human_dur` returns "—" for a value that is not finite, instead of raising.
|
||||||
|
- The tile's copy of a note drops its `id`, because `mark-<id>` names the panel's article (booth-dev's ruling).
|
||||||
|
- The guards the gate found asserting source patterns now also hold on computed effects: the embed's rings are a solid, opaque 2px line under a host that removes outlines; the rings inside clipping containers compute to `-2px`; the withdraw × is measured on both axes; and question-level notes fields are named.
|
||||||
|
- *Falsifiable:* `test_no_id_repeats_on_any_page`, `test_release_on_the_booth_page_asks_by_name` (and its browser twin), `test_a_prototype_word_still_asks`, `test_the_confirm_helper_is_listening_before_the_body_exists`, `test_the_dialog_shows_hidden_breaks_and_zero_widths_visibly`, `test_human_dur_never_raises`, `test_rings_inside_clipping_containers_are_drawn_inside`, `test_the_embed_draws_visible_rings`, and the rows marked "S5a fixup" in `antislop.toml`.
|
||||||
- **Existing rows this slice edits** (booth-dev's): two `r2_flow.toml` rows for the confirm helper now name `base.html`, where the helper moved. Their anchors are unchanged.
|
- **Existing rows this slice edits** (booth-dev's): two `r2_flow.toml` rows for the confirm helper now name `base.html`, where the helper moved. Their anchors are unchanged.
|
||||||
- **Reported, not changed:** mark ids repeat across a tile and its aside (`mark-note-1`). The CLI prints `#mark-<id>` links to them, so the fix is booth-dev's call.
|
- **Reported, not changed:** mark ids repeat across a tile and its aside (`mark-note-1`). The CLI prints `#mark-<id>` links to them, so the fix is booth-dev's call.
|
||||||
|
|
||||||
|
|||||||
@@ -473,15 +473,15 @@ new = """<div class="bk-ask-opts" aria-labelledby="""
|
|||||||
label = "S5a the group names a prompt id that is not there"
|
label = "S5a the group names a prompt id that is not there"
|
||||||
file = "booth/templates/_ask_inline.html"
|
file = "booth/templates/_ask_inline.html"
|
||||||
test = "tests/test_antislop.py::test_radio_groups_are_named_and_ids_are_unique"
|
test = "tests/test_antislop.py::test_radio_groups_are_named_and_ids_are_unique"
|
||||||
old = """<p class="bk-ask-prompt" id="bk-ask-{{ a.id }}{% if q.key %}-{{ q.key }}{% endif %}-prompt">"""
|
old = '''<p class="bk-ask-prompt" id="bk-ask-{{ a.id }}{% if q.key %}-{{ q.key }}{% endif %}:prompt">'''
|
||||||
new = """<p class="bk-ask-prompt">"""
|
new = '''<p class="bk-ask-prompt">'''
|
||||||
|
|
||||||
[[mutation]]
|
[[mutation]]
|
||||||
label = "S5a a titled ask's title reuses the question's id"
|
label = "S5a a titled ask's title reuses the question's id"
|
||||||
file = "booth/templates/_ask_inline.html"
|
file = "booth/templates/_ask_inline.html"
|
||||||
test = "tests/test_antislop.py::test_radio_groups_are_named_and_ids_are_unique"
|
test = "tests/test_antislop.py::test_radio_groups_are_named_and_ids_are_unique"
|
||||||
old = """<p class="bk-ask-title" id="bk-ask-{{ a.id }}-title">"""
|
old = '''<p class="bk-ask-title" id="bk-ask-{{ a.id }}:title">'''
|
||||||
new = """<p class="bk-ask-title" id="bk-ask-{{ a.id }}">"""
|
new = '''<p class="bk-ask-title" id="bk-ask-{{ a.id }}">'''
|
||||||
|
|
||||||
[[mutation]]
|
[[mutation]]
|
||||||
label = "S5a a single-question fieldset without a legend"
|
label = "S5a a single-question fieldset without a legend"
|
||||||
@@ -643,3 +643,126 @@ file = "booth/templates/base.html"
|
|||||||
test = "tests/test_antislop_browser.py::test_touch_and_scroll_behaviour"
|
test = "tests/test_antislop_browser.py::test_touch_and_scroll_behaviour"
|
||||||
old = """ @media (max-width:600px){.h1-slug{white-space:normal;overflow-wrap:anywhere}}"""
|
old = """ @media (max-width:600px){.h1-slug{white-space:normal;overflow-wrap:anywhere}}"""
|
||||||
new = """ @media (max-width:600px){.h1-slug{}}"""
|
new = """ @media (max-width:600px){.h1-slug{}}"""
|
||||||
|
|
||||||
|
# ---- S5a fixup: booth-dev's gate (hulda + heid BRINGA, thread 01M3MVGQ7QSCCK8WT59TQ4J469)
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S5a fixup the booth page's release does not ask"
|
||||||
|
file = "booth/templates/booth.html"
|
||||||
|
test = "tests/test_antislop.py::test_release_on_the_booth_page_asks_by_name"
|
||||||
|
old = '''
|
||||||
|
|
||||||
|
data-booth="{{ name }}" data-confirm="release">'''
|
||||||
|
new = '''>'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S5a fixup the booth page's release does not ask (browser)"
|
||||||
|
file = "booth/templates/booth.html"
|
||||||
|
test = "tests/test_antislop_browser.py::test_release_on_the_booth_page_asks_in_the_browser"
|
||||||
|
old = '''
|
||||||
|
|
||||||
|
data-booth="{{ name }}" data-confirm="release">'''
|
||||||
|
new = '''>'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S5a fixup WORDS inherits from Object.prototype"
|
||||||
|
file = "booth/templates/base.html"
|
||||||
|
test = "tests/test_antislop_browser.py::test_a_prototype_word_still_asks"
|
||||||
|
old = ''' var WORDS = Object.create(null);'''
|
||||||
|
new = ''' var WORDS = {};'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S5a fixup the confirm helper leaves <head>"
|
||||||
|
file = "booth/templates/base.html"
|
||||||
|
test = "tests/test_antislop.py::test_the_confirm_helper_is_listening_before_the_body_exists"
|
||||||
|
old = '''<script>
|
||||||
|
/* as S5a: moved here from index.html'''
|
||||||
|
new = '''</head>
|
||||||
|
<script>
|
||||||
|
/* as S5a: moved here from index.html'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S5a fixup shown() lets line separators and zero-widths through"
|
||||||
|
file = "booth/templates/base.html"
|
||||||
|
test = "tests/test_antislop_browser.py::test_the_dialog_shows_hidden_breaks_and_zero_widths_visibly"
|
||||||
|
old = ''' return n.replace(/[\u0000-\u001f\u007f-\u009f\u061c\u200b-\u200f\u2028\u2029\u202a-\u202e\u2060\u2066-\u2069\ufeff]/g, '\ufffd');'''
|
||||||
|
new = ''' return n.replace(/[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, '\ufffd');'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S5a fixup a question's prompt id collides with a key ending -prompt"
|
||||||
|
file = "booth/templates/_ask_inline.html"
|
||||||
|
test = "tests/test_antislop.py::test_no_id_repeats_on_any_page"
|
||||||
|
old = '''id="bk-ask-{{ a.id }}{% if q.key %}-{{ q.key }}{% endif %}:prompt"'''
|
||||||
|
new = '''id="bk-ask-{{ a.id }}{% if q.key %}-{{ q.key }}{% endif %}-prompt"'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S5a fixup the title's id collides with a key named title"
|
||||||
|
file = "booth/templates/_ask_inline.html"
|
||||||
|
test = "tests/test_antislop.py::test_no_id_repeats_on_any_page"
|
||||||
|
old = '''id="bk-ask-{{ a.id }}:title"'''
|
||||||
|
new = '''id="bk-ask-{{ a.id }}-title"'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S5a fixup the tile's note repeats the article's id"
|
||||||
|
file = "booth/templates/booth.html"
|
||||||
|
test = "tests/test_antislop.py::test_no_id_repeats_on_any_page"
|
||||||
|
old = ''' <div class="item-note">'''
|
||||||
|
new = ''' <div class="item-note" id="mark-{{ m.id }}">'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S5a fixup human_dur raises on nan"
|
||||||
|
file = "booth/app.py"
|
||||||
|
test = "tests/test_antislop.py::test_human_dur_never_raises"
|
||||||
|
old = ''' if not math.isfinite(seconds): # as S5a fixup: int(nan) raises; say nothing we cannot know
|
||||||
|
return "—"
|
||||||
|
'''
|
||||||
|
new = ''''''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S5a fixup a question's notes field named only by its placeholder"
|
||||||
|
file = "booth/templates/_ask_inline.html"
|
||||||
|
test = "tests/test_antislop.py::test_fields_are_named"
|
||||||
|
old = '''aria-label="notes on this one" placeholder="notes on this one (optional)">'''
|
||||||
|
new = '''placeholder="notes on this one (optional)">'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S5a fixup the marks page's question notes named only by their placeholder"
|
||||||
|
file = "booth/templates/_marks.html"
|
||||||
|
test = "tests/test_antislop.py::test_fields_are_named"
|
||||||
|
old = '''aria-label="notes on this one" placeholder="notes on this one (optional)">'''
|
||||||
|
new = '''placeholder="notes on this one (optional)">'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S5a fixup the embed chip's ring is transparent"
|
||||||
|
file = "booth/static/embed.js"
|
||||||
|
test = "tests/test_antislop_browser.py::test_the_embed_draws_visible_rings"
|
||||||
|
old = '''outline:2px solid #fff;outline-offset:1px;box-shadow:0 0 0 4px #15191d}'''
|
||||||
|
new = '''outline:2px solid transparent;outline-offset:1px;box-shadow:0 0 0 4px #15191d}'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S5a fixup the embed submit's ring is transparent"
|
||||||
|
file = "booth/static/embed.js"
|
||||||
|
test = "tests/test_antislop_browser.py::test_the_embed_draws_visible_rings"
|
||||||
|
old = '''".bk-ask-go:focus-visible{outline:2px solid var(--bk-accent);outline-offset:2px}"'''
|
||||||
|
new = '''".bk-ask-go:focus-visible{outline:2px solid transparent;outline-offset:2px}"'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S5a fixup an image tile's ring is drawn outside its clip"
|
||||||
|
file = "booth/templates/base.html"
|
||||||
|
test = "tests/test_antislop_browser.py::test_rings_inside_clipping_containers_are_drawn_inside"
|
||||||
|
old = '''.theme button:focus-visible,.vtoggle button:focus-visible,.item a:focus-visible,'''
|
||||||
|
new = '''.theme button:focus-visible,.vtoggle button:focus-visible,'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S5a fixup a Desk panel's ring is drawn outside its clip (computed)"
|
||||||
|
file = "booth/templates/base.html"
|
||||||
|
test = "tests/test_antislop_browser.py::test_rings_inside_clipping_containers_are_drawn_inside"
|
||||||
|
old = ''' .desk-panel a:focus-visible{outline-offset:-2px}'''
|
||||||
|
new = ''' .desk-panel a:focus-visible{outline-offset:2px}'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S5a fixup the withdraw × narrower than 24px"
|
||||||
|
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}'''
|
||||||
|
|||||||
+56
-2
@@ -329,6 +329,12 @@ def _s5_booth(data):
|
|||||||
write_note(b, "a.png", "soft edges")
|
write_note(b, "a.png", "soft edges")
|
||||||
write_note(b, None, "about the booth")
|
write_note(b, None, "about the booth")
|
||||||
declare_pick(b, "p1", {"title": "Round one", "prompt": "Which?", "options": ["North", "South"]})
|
declare_pick(b, "p1", {"title": "Round one", "prompt": "Which?", "options": ["North", "South"]})
|
||||||
|
# S5a fixup: a multi-question ask with per-question notes, and keys that end
|
||||||
|
# like the ids S5a derives from them (`-prompt`, `title`)
|
||||||
|
declare_pick(b, "p2", {"title": "Round two", "questions": [
|
||||||
|
{"key": "x-prompt", "prompt": "First?", "options": ["keep", "cut"], "notes": True},
|
||||||
|
{"key": "x", "prompt": "Second?", "options": ["keep", "cut"], "notes": True},
|
||||||
|
{"key": "title", "prompt": "Third?", "options": ["keep", "cut"]}]})
|
||||||
_s5_board(data)
|
_s5_board(data)
|
||||||
return b
|
return b
|
||||||
|
|
||||||
@@ -415,7 +421,7 @@ def test_fields_are_named(client):
|
|||||||
def test_radio_groups_are_named_and_ids_are_unique(client):
|
def test_radio_groups_are_named_and_ids_are_unique(client):
|
||||||
c, data = client
|
c, data = client
|
||||||
_s5_booth(data)
|
_s5_booth(data)
|
||||||
(m,) = c.get("/b/b/embed.json").json()["marks"]
|
(m,) = [m for m in c.get("/b/b/embed.json").json()["marks"] if m["id"] == "p1"]
|
||||||
# The embed places an ask one of two ways: `whole` (title, questions and
|
# The embed places an ask one of two ways: `whole` (title, questions and
|
||||||
# submit in one piece), or its questions one by one plus `submit`. Never both.
|
# submit in one piece), or its questions one by one plus `submit`. Never both.
|
||||||
placements = {"whole": m["whole"], "parts": "".join(q["html"] for q in m["questions"]) + m["submit"]}
|
placements = {"whole": m["whole"], "parts": "".join(q["html"] for q in m["questions"]) + m["submit"]}
|
||||||
@@ -466,7 +472,7 @@ def test_wipe_now_asks_by_name(client):
|
|||||||
page = c.get("/b/b/").text
|
page = c.get("/b/b/").text
|
||||||
form = re.search(r'<form class="wipe wipe-lg"[^>]*>', page, flags=re.S).group(0)
|
form = re.search(r'<form class="wipe wipe-lg"[^>]*>', page, flags=re.S).group(0)
|
||||||
assert 'data-booth="b"' in form and 'data-confirm="wipe"' in form and "onsubmit" not in form, form
|
assert 'data-booth="b"' in form and 'data-confirm="wipe"' in form and "onsubmit" not in form, form
|
||||||
assert "var WORDS = {" in page, "the shared confirm helper is on the booth page"
|
assert "var WORDS = Object.create(null);" in page, "the shared confirm helper is on the booth page"
|
||||||
|
|
||||||
|
|
||||||
def test_human_dur_rolls_up_to_days():
|
def test_human_dur_rolls_up_to_days():
|
||||||
@@ -497,3 +503,51 @@ def test_a_truncated_why_carries_its_full_text(client):
|
|||||||
(b / ".booth.json").write_text('{"handle": "design-dev", "why": "a long reason that the Desk truncates with an ellipsis"}')
|
(b / ".booth.json").write_text('{"handle": "design-dev", "why": "a long reason that the Desk truncates with an ellipsis"}')
|
||||||
page = c.get("/").text
|
page = c.get("/").text
|
||||||
assert re.search(r'<span class="prov-why" title="a long reason that the Desk truncates with an ellipsis">', page)
|
assert re.search(r'<span class="prov-why" title="a long reason that the Desk truncates with an ellipsis">', page)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ---- S5a fixup: booth-dev's gate (hulda bug-hunt + heid BRINGA, thread 01M3MVGQ7QSCCK8WT59TQ4J469) ----
|
||||||
|
|
||||||
|
def test_no_id_repeats_on_any_page(client):
|
||||||
|
"""An id names one element. The tile's copy of a note used to repeat the
|
||||||
|
panel article's `mark-<id>` (booth-dev: the id is the article's); a question
|
||||||
|
key ending in `-prompt`, or named `title`, repeated the ids S5a derived."""
|
||||||
|
c, data = client
|
||||||
|
_s5_booth(data)
|
||||||
|
pages = _pages(c)
|
||||||
|
for m in c.get("/b/b/embed.json").json()["marks"]:
|
||||||
|
pages[f"embed {m['id']} whole"] = m["whole"]
|
||||||
|
pages[f"embed {m['id']} parts"] = "".join(q["html"] for q in m["questions"]) + m["submit"]
|
||||||
|
for url, page in pages.items():
|
||||||
|
ids = re.findall(r'\sid="([^"]+)"', _markup(page))
|
||||||
|
dupes = sorted({i for i in ids if ids.count(i) > 1})
|
||||||
|
assert not dupes, (url, dupes[:5])
|
||||||
|
for ref in re.findall(r'aria-labelledby="([^"]+)"', page):
|
||||||
|
assert f'id="{ref}"' in page, (url, ref)
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_on_the_booth_page_asks_by_name(client):
|
||||||
|
c, data = client
|
||||||
|
b = data / "k"
|
||||||
|
b.mkdir()
|
||||||
|
(b / "x.txt").write_text("x")
|
||||||
|
(b / ".forever").write_text("")
|
||||||
|
page = c.get("/b/k/").text
|
||||||
|
form = re.search(r'<form class="keep-lg"[^>]*>', page).group(0)
|
||||||
|
assert 'data-booth="k"' in form and 'data-confirm="release"' in form, form
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_confirm_helper_is_listening_before_the_body_exists(client):
|
||||||
|
"""A click while the page is still loading must be asked too: the capture
|
||||||
|
listener is registered in <head>, not after the footer."""
|
||||||
|
c, data = client
|
||||||
|
_s5_booth(data)
|
||||||
|
for url in ("/", "/b/b/"):
|
||||||
|
page = c.get(url).text
|
||||||
|
assert page.index("function shown(n)") < page.index("</head>"), url
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
@@ -188,7 +188,8 @@ def test_withdraw_buttons_are_big_enough_to_hit(browser, live):
|
|||||||
ctx = browser.new_context(**ctx_args)
|
ctx = browser.new_context(**ctx_args)
|
||||||
page = ctx.new_page()
|
page = ctx.new_page()
|
||||||
page.goto(f"{base}/b/g/marks", wait_until="load") # visible at both widths
|
page.goto(f"{base}/b/g/marks", wait_until="load") # visible at both widths
|
||||||
box = page.locator(".mark-x").first.evaluate("e => e.getBoundingClientRect().height")
|
box = page.locator(".mark-x").first.evaluate(
|
||||||
|
"e => { const r = e.getBoundingClientRect(); return Math.min(r.width, r.height); }")
|
||||||
assert box >= floor, (ctx_args, box)
|
assert box >= floor, (ctx_args, box)
|
||||||
ctx.close()
|
ctx.close()
|
||||||
|
|
||||||
@@ -231,3 +232,119 @@ def test_touch_and_scroll_behaviour(browser, live):
|
|||||||
"e => ({ws: getComputedStyle(e).whiteSpace, r: e.getBoundingClientRect().right, vw: innerWidth})")
|
"e => ({ws: getComputedStyle(e).whiteSpace, r: e.getBoundingClientRect().right, vw: innerWidth})")
|
||||||
assert slug["ws"] == "normal" and slug["r"] <= slug["vw"], slug
|
assert slug["ws"] == "normal" and slug["r"] <= slug["vw"], slug
|
||||||
page.close()
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ---- S5a fixup: the confirm helper, and the rings on COMPUTED style ------------------------
|
||||||
|
|
||||||
|
def _dialog_texts(page):
|
||||||
|
said = []
|
||||||
|
page.on("dialog", lambda d: (said.append(d.message), d.dismiss()))
|
||||||
|
return said
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_on_the_booth_page_asks_in_the_browser(browser, live):
|
||||||
|
base, root = live
|
||||||
|
b = root / "kept"
|
||||||
|
b.mkdir()
|
||||||
|
(b / "x.txt").write_text("x")
|
||||||
|
(b / ".forever").write_text("")
|
||||||
|
page = browser.new_page(viewport={"width": 1280, "height": 800})
|
||||||
|
page.goto(f"{base}/b/kept/", wait_until="load")
|
||||||
|
said = _dialog_texts(page)
|
||||||
|
page.locator(".keep-lg button").click()
|
||||||
|
page.wait_for_timeout(300)
|
||||||
|
page.close()
|
||||||
|
assert said and "“kept”" in said[0] and "Release" in said[0], said
|
||||||
|
assert (b / ".forever").exists(), "dismissing must not release"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_prototype_word_still_asks(browser, live):
|
||||||
|
"""A `data-confirm` naming a property every object inherits is an unknown
|
||||||
|
word, and an unknown word asks (fail closed)."""
|
||||||
|
base, root = live
|
||||||
|
b = root / "g"
|
||||||
|
b.mkdir()
|
||||||
|
(b / "x.txt").write_text("x")
|
||||||
|
page = browser.new_page()
|
||||||
|
page.goto(f"{base}/b/g/", wait_until="load")
|
||||||
|
said = _dialog_texts(page)
|
||||||
|
words = ["__proto__", "constructor", "toString", "hasOwnProperty", "valueOf"]
|
||||||
|
for w in words:
|
||||||
|
page.evaluate("""w => { const f = document.createElement('form');
|
||||||
|
f.method = 'post'; f.action = '/b/g/delete';
|
||||||
|
f.setAttribute('data-confirm', w); f.setAttribute('data-booth', 'g');
|
||||||
|
document.body.appendChild(f); f.requestSubmit(); f.remove(); }""", w)
|
||||||
|
page.wait_for_timeout(100)
|
||||||
|
page.close()
|
||||||
|
assert len(said) == len(words), said
|
||||||
|
assert (b / "x.txt").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_dialog_shows_hidden_breaks_and_zero_widths_visibly(browser, live):
|
||||||
|
import urllib.parse
|
||||||
|
base, root = live
|
||||||
|
name = "a
b
cde"
|
||||||
|
b = root / name
|
||||||
|
b.mkdir()
|
||||||
|
(b / "x.txt").write_text("x")
|
||||||
|
page = browser.new_page()
|
||||||
|
page.goto(f"{base}/b/{urllib.parse.quote(name)}/", wait_until="load")
|
||||||
|
said = _dialog_texts(page)
|
||||||
|
page.locator(".wipe-lg button").click()
|
||||||
|
page.wait_for_timeout(300)
|
||||||
|
page.close()
|
||||||
|
assert said, "positive control: Wipe now asked"
|
||||||
|
for ch in "
":
|
||||||
|
assert ch not in said[0], (hex(ord(ch)), said[0])
|
||||||
|
assert said[0].count("�") == 5, said[0]
|
||||||
|
|
||||||
|
|
||||||
|
_RING = """e => { const cs = getComputedStyle(e);
|
||||||
|
return {fv: e.matches(':focus-visible'), style: cs.outlineStyle, width: cs.outlineWidth,
|
||||||
|
color: cs.outlineColor, offset: cs.outlineOffset}; }"""
|
||||||
|
|
||||||
|
_ALPHA = """c => { const m = c.match(/rgba?\\(([^)]+)\\)/); const p = m ? m[1].split(',') : [];
|
||||||
|
return p.length > 3 ? parseFloat(p[3]) : 1; }"""
|
||||||
|
|
||||||
|
|
||||||
|
def _keyboard_focus(page, selector):
|
||||||
|
page.keyboard.press("Shift") # keyboard modality: focus() is then :focus-visible
|
||||||
|
loc = page.locator(selector).first
|
||||||
|
loc.focus()
|
||||||
|
return loc.evaluate(_RING)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rings_inside_clipping_containers_are_drawn_inside(browser, live):
|
||||||
|
from booth.benches import upsert_bench
|
||||||
|
base, root = live
|
||||||
|
g = root / "g"
|
||||||
|
g.mkdir()
|
||||||
|
(g / "a.png").write_bytes(_png(64, 48))
|
||||||
|
upsert_bench(root, "http://10.100.10.50:5173/", "peedlar desk", "peedlar-dev") # a Desk panel with a link
|
||||||
|
page = browser.new_page(viewport={"width": 1280, "height": 800})
|
||||||
|
for url, sel in (("/", ".theme button"), ("/", ".desk-panel a"), ("/b/g/", ".item a")):
|
||||||
|
page.goto(base + url, wait_until="load")
|
||||||
|
ring = _keyboard_focus(page, sel)
|
||||||
|
assert ring["fv"] and ring["offset"] == "-2px", (url, sel, ring)
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_embed_draws_visible_rings(browser, live):
|
||||||
|
from booth.marks import declare_pick
|
||||||
|
base, root = live
|
||||||
|
b = root / "r"
|
||||||
|
b.mkdir()
|
||||||
|
declare_pick(b, "winner", {"prompt": "Which?", "options": ["A", "B"]})
|
||||||
|
(b / "index.html").write_text(
|
||||||
|
'<!doctype html><html><head><script src="/_booth/embed.js" defer></script>'
|
||||||
|
'<style>*:focus{outline:none}</style></head>' # a host that removes rings
|
||||||
|
'<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-go", timeout=10000)
|
||||||
|
for sel in (".booth-nav-home", ".bk-ask-go"):
|
||||||
|
ring = _keyboard_focus(page, sel)
|
||||||
|
alpha = page.evaluate(_ALPHA, ring["color"])
|
||||||
|
assert ring["fv"] and ring["style"] == "solid" and ring["width"] == "2px" and alpha == 1, (sel, ring)
|
||||||
|
page.close()
|
||||||
|
|||||||
@@ -489,7 +489,9 @@ def test_the_chip_does_not_jump_to_a_mark_that_merely_shares_a_prefix(browser, l
|
|||||||
target = page.locator(".booth-nav-asks").get_attribute("href")
|
target = page.locator(".booth-nav-asks").get_attribute("href")
|
||||||
assert "batch2" not in target, f"the chip landed on the sibling mark: {target}"
|
assert "batch2" not in target, f"the chip landed on the sibling mark: {target}"
|
||||||
assert target.startswith("#bk-ask-batch")
|
assert target.startswith("#bk-ask-batch")
|
||||||
assert page.locator(target).count() == 1
|
# as S5a fixup: derived ids take a `:` (`bk-ask-batch:title`) so they cannot
|
||||||
|
# collide with a question key; a fragment may hold one, a #selector cannot
|
||||||
|
assert page.locator(f'[id="{target[1:]}"]').count() == 1
|
||||||
page.close()
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user