Skip to content

Add "Play test sound" and "Send test push" to the alarm settings - #424

Open
nedtwigg wants to merge 1 commit into
settings-remote-controlfrom
alarm-test-buttons
Open

Add "Play test sound" and "Send test push" to the alarm settings#424
nedtwigg wants to merge 1 commit into
settings-remote-controlfrom
alarm-test-buttons

Conversation

@nedtwigg

Copy link
Copy Markdown
Member

Stacked on #423 — review that one first. The base is settings-remote-control, so this diff shows only the alarm-test work. It will retarget to main once #423 merges.

An alarm you cannot observe until it fires unattended is one you cannot trust, and that first firing is exactly when being wrong costs the most. Each alarm sink gets a way to try it now.

Both controls sit outside the switch's dimming and stay enabled while the sink is off: checking that the speakers work, or that the phone buzzes, is most useful before committing to the alarm. Each reports its outcome inline and clears it after a few seconds — because for both sinks, a working path and a broken one produce the same observation: silence.

Play test sound

Speaks a fixed phrase through the same sanitizer a real alarm uses, but deliberately not through speak(). That publishes the transient per-Session speaking / spoken state that Panes and Doors render, and no Session rang — a test that lit up a pane would be claiming some terminal wants attention.

It reports "no speech engine available" rather than degrading silently the way the alarm path correctly does, because a webview with no backend and one with the volume down are indistinguishable otherwise.

Send test push

Goes through the real Host, ACL, and server, so what it proves is what the alarm will do.

That required making the delivery path observable. sendPush now returns a summary instead of void, and a new pushTest service command surfaces failures rather than warning to the console. The ring path's rule that a failed push must never break the alert path is right for an alarm and exactly wrong for a test — it would report success over a fan-out that reached nobody.

Four outcomes are distinguished, because they call for different responses:

Outcome Reads as
targeted: 0 No paired phone has enabled alerts yet — not a failure; the ordinary answer on a freshly enrolled machine
delivered: 0 No device accepted the push
partial Sent to N, M failed
success Sent to N devices

Hidden entirely where no Host service exists, matching the Remote control section.

API change worth flagging

sendPush returns Promise<PushSendSummary> rather than Promise<void>. The ring path ignores the value, so behavior there is unchanged — but it is a signature change.

Verification

typecheck clean; 1689 tests passing; pnpm lint:specs OK. 11 new tests: 4 at the service level (not-connected, nothing-targeted, a real send asserting ACL-derived recipients and the fixed collapse tag, and a refused send), 7 at the component level covering every outcome above plus the missing-speech-engine and no-Host-service cases.

alert.md updated with the behavior and the rationale for each rule.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GDNJHHA95nvRdo4Cv3rAoi

An alarm you cannot observe until it fires unattended is one you cannot trust,
and that first firing is exactly when being wrong costs the most. Give each
alarm sink a way to try it now.

Both sit outside the switch's dimming and stay enabled while the sink is off:
checking that the speakers work, or that the phone buzzes, is most useful
before committing to the alarm. Each reports its outcome inline, because for
both sinks a working path and a broken one produce the same observation —
silence.

Play test sound deliberately does not route through `speak()`: that publishes
the transient per-Session speaking/spoken state that Panes and Doors render, and
no Session rang. A test that lit up a pane would be claiming some terminal wants
attention. It reports a webview with no speech backend rather than degrading
silently the way the alarm path correctly does.

Send test push goes through the real Host, ACL and server, so what it proves is
what the alarm will do. That needed the delivery path to become observable:
`sendPush` now returns a summary instead of `void`, and a new `pushTest` service
command surfaces failures rather than warning to the console. The ring path's
rule that a failed push must never break the alert path is right for an alarm
and exactly wrong for a test — it would report success over a fan-out that
reached nobody. Four outcomes are distinguished: nothing targeted (the ordinary
answer on a freshly enrolled machine, not a failure), nothing delivered, a
partial fan-out, and success.

Note the signature change: `sendPush` returns `Promise<PushSendSummary>` rather
than `Promise<void>`. The ring path ignores the value, so behavior is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GDNJHHA95nvRdo4Cv3rAoi
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying mouseterm with  Cloudflare Pages  Cloudflare Pages

Latest commit: 8a41810
Status: ✅  Deploy successful!
Preview URL: https://cf1d60fa.mouseterm.pages.dev
Branch Preview URL: https://alarm-test-buttons.mouseterm.pages.dev

View logs

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice feature, and the framing is right — routing the push test through the real Host, ACL and server rather than a mock is what makes it worth having, and #pushTest being the deliberate inverse of #push on failure-swallowing is the correct call. Three notes below; only the first is a bug.

Outside the diff: alert-speech.ts's module docstring still claims that "speak() and cancelSpeech() are the only two places this module touches the engine — the seam a future native PlatformAdapter.speak?() would slot into for hosts whose webview has no speech backend (Tauri on Linux/WebKitGTK)". speakTestUtterance is now a third, calling synth.cancel() and synth.speak() directly. That seam now has three sites, and a native-speech implementation written against that docstring would leave the test button broken on exactly the host it exists to diagnose. I can push a commit fixing the docstring if you'd like it.

Also minor: the spec says the button "distinguishes four outcomes", and AlarmTestButtons.test.tsx covers three — the partial fan-out branch (Sent to N; M failed.) is the one with no test.

try {
// Drop anything queued first: repeated presses should say it once more, not
// stack a backlog behind a slow engine.
synth.cancel();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cancel() empties the whole engine queue, not just this function's own utterance, and startAlertSpeech has no way to learn it happened.

Concretely: Pane A is mid-announcement with Pane B's utterance queued behind it, and the user presses Play test sound. Web Speech fires end only for the utterance actually being spoken, so A settles normally but B's is dropped with no callback — startAlertSpeech leaves B sitting in its queued map and never re-dispatches it, so B's alarm is never announced. That is exactly the case interrupt() was written for ("attending one Pane must not silence another Pane's alarm"), and the reason it re-dispatches by hand is stated right there: cancel() is not obliged to fire a callback per dropped utterance.

Narrow window — the user has to be in Settings while another Pane is mid-alarm — but the failure is a real alarm going out silently, which is the costliest thing this subsystem can do. Two ways out: drop the cancel() (two short fixed phrases stacking on a double-press is a smaller problem than a dropped alarm), or give startAlertSpeech a way to hand interrupt() out so the test button can go through it rather than around it.

* than an error. Anything the user could act on differently deserves its own
* answer, and "no devices" and "the server refused" are not the same problem.
*/
export interface PushTestOutcome {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PushTestOutcome is structurally identical to PushSendSummary in push-delivery.ts, and sendTestPush bridges them with an unchecked as PushTestOutcome — so the two can drift with no type error.

service-protocol.ts is the module that exists to prevent precisely this ("Shared by both ends so the contract cannot drift") and already owns PushDevicesResult, AdoptResult and EnrollResult. pushTest's result looks like it belongs there too, with push-delivery.ts importing it instead of declaring a parallel copy.

As written, adding a field to PushSendSummary leaves PushTestOutcome stale and the cast swallows it — the button reads undefined and nothing fails to compile.

function ResultLine({ result }: { result: { text: string; tone: 'ok' | 'bad' } | null }) {
if (!result) return null;
return (
<div className={`mt-1 text-sm leading-relaxed ${result.tone === 'bad' ? 'text-error' : 'text-muted'}`}>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The one thing this control exists to communicate is never announced to a screen reader — the result line just appears. The alert layer's own AlertSpeechIndicator.tsx uses role="status" + aria-live="polite" for the same job.

Suggested change
<div className={`mt-1 text-sm leading-relaxed ${result.tone === 'bad' ? 'text-error' : 'text-muted'}`}>
<div
role="status"
aria-live="polite"
className={`mt-1 text-sm leading-relaxed ${result.tone === 'bad' ? 'text-error' : 'text-muted'}`}
>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants