-
Notifications
You must be signed in to change notification settings - Fork 0
Add "Play test sound" and "Send test push" to the alarm settings #424
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
nedtwigg
wants to merge
1
commit into
settings-remote-control
Choose a base branch
from
alarm-test-buttons
base: settings-remote-control
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,144 @@ | ||
| /** | ||
| * @vitest-environment jsdom | ||
| */ | ||
| import { act } from 'react'; | ||
| import { createRoot, type Root } from 'react-dom/client'; | ||
| import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; | ||
|
|
||
| let platform: { remoteHost?: unknown } = {}; | ||
|
|
||
| vi.mock('../lib/platform', () => ({ | ||
| IS_MAC: false, | ||
| getPlatform: () => platform, | ||
| })); | ||
|
|
||
| import { PushTestButton, SpeakTestButton } from './AlarmTestButtons'; | ||
|
|
||
| globalThis.IS_REACT_ACT_ENVIRONMENT = true; | ||
|
|
||
| let container: HTMLDivElement; | ||
| let root: Root; | ||
|
|
||
| function text(): string { | ||
| return container.textContent ?? ''; | ||
| } | ||
|
|
||
| function button(): HTMLButtonElement { | ||
| const found = container.querySelector('button'); | ||
| if (!found) throw new Error('no button rendered'); | ||
| return found; | ||
| } | ||
|
|
||
| beforeEach(() => { | ||
| container = document.createElement('div'); | ||
| document.body.appendChild(container); | ||
| root = createRoot(container); | ||
| }); | ||
|
|
||
| afterEach(async () => { | ||
| await act(async () => root.unmount()); | ||
| container.remove(); | ||
| platform = {}; | ||
| vi.unstubAllGlobals(); | ||
| vi.clearAllMocks(); | ||
| }); | ||
|
|
||
| describe('SpeakTestButton', () => { | ||
| it('speaks and says so when the webview has a speech engine', async () => { | ||
| const speak = vi.fn(); | ||
| vi.stubGlobal('speechSynthesis', { speak, cancel: vi.fn() }); | ||
| vi.stubGlobal( | ||
| 'SpeechSynthesisUtterance', | ||
| class { | ||
| constructor(public text: string) {} | ||
| }, | ||
| ); | ||
|
|
||
| await act(async () => root.render(<SpeakTestButton />)); | ||
| await act(async () => button().click()); | ||
|
|
||
| expect(speak).toHaveBeenCalledTimes(1); | ||
| expect(text()).toContain('Speaking now'); | ||
| }); | ||
|
|
||
| it('says there is no speech engine rather than looking like it worked', async () => { | ||
| // A webview with no backend and one with the volume down produce the same | ||
| // observation, so silence has to be reported. | ||
| vi.stubGlobal('speechSynthesis', undefined); | ||
|
|
||
| await act(async () => root.render(<SpeakTestButton />)); | ||
| await act(async () => button().click()); | ||
|
|
||
| expect(text()).toContain('no speech engine'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('PushTestButton', () => { | ||
| it('renders nothing where there is no Host service', async () => { | ||
| platform = {}; | ||
| await act(async () => root.render(<PushTestButton />)); | ||
| expect(container.innerHTML).toBe(''); | ||
| }); | ||
|
|
||
| it('reports a delivered push', async () => { | ||
| platform = { | ||
| remoteHost: { | ||
| command: vi.fn(async () => ({ targeted: 2, delivered: 2, failed: 0 })), | ||
| on: () => () => {}, | ||
| respond: () => {}, | ||
| notify: () => {}, | ||
| }, | ||
| }; | ||
| await act(async () => root.render(<PushTestButton />)); | ||
| await act(async () => button().click()); | ||
|
|
||
| expect(text()).toContain('Sent to 2 devices'); | ||
| }); | ||
|
|
||
| it('distinguishes "nowhere to send it" from a failure', async () => { | ||
| platform = { | ||
| remoteHost: { | ||
| command: vi.fn(async () => ({ targeted: 0, delivered: 0, failed: 0 })), | ||
| on: () => () => {}, | ||
| respond: () => {}, | ||
| notify: () => {}, | ||
| }, | ||
| }; | ||
| await act(async () => root.render(<PushTestButton />)); | ||
| await act(async () => button().click()); | ||
|
|
||
| expect(text()).toContain('No paired phone has enabled alerts yet'); | ||
| }); | ||
|
|
||
| it('reports a fan-out that reached nobody', async () => { | ||
| platform = { | ||
| remoteHost: { | ||
| command: vi.fn(async () => ({ targeted: 2, delivered: 0, failed: 2 })), | ||
| on: () => () => {}, | ||
| respond: () => {}, | ||
| notify: () => {}, | ||
| }, | ||
| }; | ||
| await act(async () => root.render(<PushTestButton />)); | ||
| await act(async () => button().click()); | ||
|
|
||
| expect(text()).toContain('No device accepted the push'); | ||
| }); | ||
|
|
||
| it('surfaces the service error', async () => { | ||
| platform = { | ||
| remoteHost: { | ||
| command: vi.fn(async () => { | ||
| throw new Error('This machine is not connected to a Dormouse server.'); | ||
| }), | ||
| on: () => () => {}, | ||
| respond: () => {}, | ||
| notify: () => {}, | ||
| }, | ||
| }; | ||
| await act(async () => root.render(<PushTestButton />)); | ||
| await act(async () => button().click()); | ||
|
|
||
| expect(text()).toContain('not connected to a Dormouse server'); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,130 @@ | ||
| import { useCallback, useEffect, useRef, useState } from 'react'; | ||
| import { modalActionButton } from './design'; | ||
| import { speakTestUtterance } from '../lib/alert-speech'; | ||
| import { getPlatform } from '../lib/platform'; | ||
| import { sendTestPush } from '../remote/host/host-status-store'; | ||
|
|
||
| /** | ||
| * "Try it now" controls for the two alarm sinks | ||
| * (`docs/specs/alert.md` -> Alarm settings). | ||
| * | ||
| * Both answer the same question — will this actually reach me? — which is | ||
| * otherwise unanswerable until an alarm fires at 3am. Each reports its own | ||
| * outcome inline rather than relying on the effect being observable: a silent | ||
| * webview and a working one look identical, and a push that reached nobody | ||
| * looks exactly like one that did. | ||
| * | ||
| * The result line clears itself, so the dialog does not accumulate stale | ||
| * verdicts from earlier presses. | ||
| */ | ||
|
|
||
| /** How long a result line stays before the button returns to its resting state. */ | ||
| const RESULT_LINGER_MS = 6000; | ||
|
|
||
| function useTransientResult() { | ||
| const [result, setResult] = useState<{ text: string; tone: 'ok' | 'bad' } | null>(null); | ||
| const timer = useRef<ReturnType<typeof setTimeout> | null>(null); | ||
|
|
||
| const show = useCallback((text: string, tone: 'ok' | 'bad') => { | ||
| if (timer.current) clearTimeout(timer.current); | ||
| setResult({ text, tone }); | ||
| timer.current = setTimeout(() => setResult(null), RESULT_LINGER_MS); | ||
| }, []); | ||
|
|
||
| // A dialog closed while a result is showing must not leave a timer holding a | ||
| // setState on an unmounted tree. | ||
| useEffect(() => () => void (timer.current && clearTimeout(timer.current)), []); | ||
|
|
||
| return [result, show] as const; | ||
| } | ||
|
|
||
| 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'}`}> | ||
| {result.text} | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Speak a fixed phrase now. Synchronous and local — there is no server in this | ||
| * path — so the only failure worth reporting is a webview with no speech | ||
| * backend at all, which would otherwise be indistinguishable from a working one | ||
| * with the volume down. | ||
| */ | ||
| export function SpeakTestButton() { | ||
| const [result, show] = useTransientResult(); | ||
|
|
||
| return ( | ||
| <div> | ||
| <button | ||
| type="button" | ||
| className={modalActionButton()} | ||
| onClick={() => { | ||
| if (speakTestUtterance()) show('Speaking now.', 'ok'); | ||
| else show('This app has no speech engine available.', 'bad'); | ||
| }} | ||
| > | ||
| Play test sound | ||
| </button> | ||
| <ResultLine result={result} /> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Send a real push through the real path — same Host, same ACL, same server — | ||
| * so what it proves is what the alarm will do. | ||
| * | ||
| * Hidden entirely where no Host service exists, matching the Remote control | ||
| * section: there is nothing to test and nothing the user could do about it. | ||
| */ | ||
| export function PushTestButton() { | ||
| const [result, show] = useTransientResult(); | ||
| const [busy, setBusy] = useState(false); | ||
|
|
||
| let hasService = false; | ||
| try { | ||
| hasService = !!getPlatform().remoteHost; | ||
| } catch { | ||
| hasService = false; | ||
| } | ||
| if (!hasService) return null; | ||
|
|
||
| return ( | ||
| <div> | ||
| <button | ||
| type="button" | ||
| disabled={busy} | ||
| className={modalActionButton()} | ||
| onClick={() => { | ||
| setBusy(true); | ||
| void sendTestPush() | ||
| .then((outcome) => { | ||
| if (outcome.targeted === 0) { | ||
| // Not a failure: the Host is fine, nothing has opted in yet. | ||
| show('No paired phone has enabled alerts yet, so there was nowhere to send it.', 'bad'); | ||
| } else if (outcome.delivered === 0) { | ||
| show(`No device accepted the push (${outcome.failed} failed).`, 'bad'); | ||
| } else if (outcome.failed > 0) { | ||
| show(`Sent to ${outcome.delivered}; ${outcome.failed} failed.`, 'bad'); | ||
| } else { | ||
| show( | ||
| `Sent to ${outcome.delivered} ${outcome.delivered === 1 ? 'device' : 'devices'}.`, | ||
| 'ok', | ||
| ); | ||
| } | ||
| }) | ||
| .catch((error: unknown) => { | ||
| show(error instanceof Error ? error.message : String(error), 'bad'); | ||
| }) | ||
| .finally(() => setBusy(false)); | ||
| }} | ||
| > | ||
| {busy ? 'Sending…' : 'Send test push'} | ||
| </button> | ||
| <ResultLine result={result} /> | ||
| </div> | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.tsxusesrole="status"+aria-live="polite"for the same job.