Files
UmbraAudio/tests/test_speaker_edit_submit.py
B.D. Murphy 9328159ec1 Fix re-voice never firing, by driving it from the event that carries the form
Reported: Save & Re-voice still gave no response after the previous fix.

That fix bound hx-on::after-request to the form. It never fires. htmx
reports a different detail.elt for each phase of this request - measured in
a real browser:

  beforeRequest  FORM
  beforeSwap     TR
  afterRequest   TBODY

Row swaps get re-parented into a table context, so afterRequest is
dispatched on the enclosing TBODY. A listener on the form never sees it,
because the event does not bubble downwards. An earlier probe suggested the
attribute worked, but that probe used a form outside a table, where elt
stays the FORM - it did not reproduce the real page.

So the profile was being saved (the PUT fired and swapped) while the
re-voice never ran and nothing on screen confirmed anything. That is
exactly "no response".

- Capture the row index at beforeRequest, where detail.elt is still the
  form, and match it to the completing request by path at afterRequest so
  a different response can never trigger a re-voice for the wrong speaker.
- Saving now shows a confirmation toast. The button previously looked dead
  even when the edit did save.
- Tests now guard the mechanism, including that hx-on::after-request is not
  reintroduced on this form.

Verified by clicking the button in a real browser on Deal Hunter's Dealer:
POST /api/voices/queue with {"speaker_name":"Dealer","row_index":14} and
the confirmation shown. Suite: 690 passed, 8 pre-existing.
2026-10-10 20:20:43 -07:00

117 lines
4.7 KiB
Python

"""Guards on the speaker edit form's submit behaviour.
Regression: the voice-queue work emptied ``queueVoiceRegenOnSave`` but left
``onsubmit="return queueVoiceRegenOnSave(...)"`` in the edit form. A handler
that falls off the end returns ``undefined``, which is falsy, so the browser
cancelled the submit and every speaker edit was silently discarded - the save
never reached the server, so nothing looked broken and nothing errored.
An ``onsubmit`` that returns a value must return a boolean deliberately.
"""
from __future__ import annotations
import re
from pathlib import Path
TEMPLATES = Path(__file__).resolve().parents[1] / "umbraaudio" / "web" / "templates"
# onsubmit="return someFn(...)" - the cancelling shape
ONSUBMIT_RETURN = re.compile(r'onsubmit="return\s+([A-Za-z_$][\w$]*)\s*\(')
FUNC_DEF = re.compile(
r"function\s+([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*\{", re.MULTILINE
)
# Comments quote these patterns when documenting the hazard, so strip them
# first or the scanner reports the documentation as if it were live markup.
COMMENTS = re.compile(r"\{#.*?#\}|<!--.*?-->", re.DOTALL)
def _strip_comments(text: str) -> str:
return COMMENTS.sub("", text)
def _all_template_text() -> str:
return _strip_comments(
"\n".join(p.read_text() for p in TEMPLATES.rglob("*.html"))
)
def _function_bodies(text: str) -> dict[str, str]:
"""Map function name -> body text, brace-matched."""
bodies: dict[str, str] = {}
for m in FUNC_DEF.finditer(text):
start = m.end()
depth = 1
i = start
while i < len(text) and depth:
if text[i] == "{":
depth += 1
elif text[i] == "}":
depth -= 1
i += 1
bodies[m.group(1)] = text[start : i - 1]
return bodies
def test_no_onsubmit_handler_can_cancel_the_speaker_edit() -> None:
"""The edit form must never gate its own submission on a JS return value."""
row = _strip_comments((TEMPLATES / "_speaker_row.html").read_text())
assert 'onsubmit="return' not in row, (
"_speaker_row.html uses onsubmit=\"return ...\"; if that handler ever "
"returns a falsy value the browser drops the edit without any error"
)
def test_every_onsubmit_return_handler_returns_a_boolean() -> None:
"""Repo-wide: any onsubmit returning a value must decide explicitly."""
text = _all_template_text()
bodies = _function_bodies(text)
for name in sorted(set(ONSUBMIT_RETURN.findall(text))):
assert name in bodies, f"onsubmit returns {name}() but it is not defined"
body = bodies[name]
returns_bool = re.search(r"return\s+(true|false)\b", body)
assert returns_bool, (
f"onsubmit=\"return {name}(...)\" but {name}() never returns an "
"explicit true/false - falling off the end yields undefined, which "
"cancels the submit and silently discards the user's edit"
)
def test_voice_regen_on_save_is_not_empty() -> None:
"""The specific handler that was gutted in the voice-queue commit."""
bodies = _function_bodies((TEMPLATES / "speakers.html").read_text())
assert "onSpeakerSaved" in bodies, "handler is missing entirely"
body = bodies["onSpeakerSaved"].strip()
assert body, "onSpeakerSaved has an empty body"
assert "enqueueVoice" in body, "it must actually queue a regeneration"
assert "notify" in body, "a save must tell the reviewer it happened"
def test_edit_form_carries_the_regen_marker() -> None:
row = _strip_comments((TEMPLATES / "_speaker_row.html").read_text())
assert "data-regen-on-save" in row, (
"the form must be identifiable so a save can trigger a re-voice"
)
assert "hx-on::after-request" not in row, (
"htmx dispatches afterRequest on the enclosing TBODY for a row swap, "
"so a listener bound to the form never fires - verified in a real browser"
)
def test_regen_trigger_uses_the_event_that_carries_the_form() -> None:
"""detail.elt is the FORM at beforeRequest but the TBODY at afterRequest."""
js = _strip_comments((TEMPLATES / "speakers.html").read_text())
assert "htmx:beforeRequest" in js, (
"the index must be captured at beforeRequest, where detail.elt is still "
"the form; reading it at afterRequest yields a TBODY and finds nothing"
)
assert "htmx:afterRequest" in js
assert "onSpeakerSaved" in js
def test_saving_tells_the_reviewer() -> None:
"""A save with no visible response reads as a broken button."""
bodies = _function_bodies(_strip_comments((TEMPLATES / "speakers.html").read_text()))
body = bodies["onSpeakerSaved"]
assert "enqueueVoice" in body, "it must actually queue a regeneration"
assert "notify" in body, "it must confirm to the reviewer"