Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/specs/alert.md
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,9 @@ Reached from any of the controls at the far right of the baseboard; placement an
- Lists every watched command with a remove control, and **cannot add one**. WATCHING is keyed on a running command's name, so creating a rule stays a bell click / `a` press in the tab running it; the empty state says so. This dialog and the bell dialog are the two places a rule set on a since-closed Pane can be found and removed — they render the same `WatchedCommandList`, so the list has one implementation.
- Delays are shown in seconds and committed on blur or `Enter`, never per keystroke — typing `3` on the way to `30` must not briefly install a 3-second timer. An out-of-range or empty entry snaps back to whatever the store clamped it to.
- The push group's device line names every device a push would reach, and otherwise states why there is none — no Host enrolled, nothing subscribed yet, or the server could not be asked. A push that silently goes nowhere is indistinguishable from a broken one.
- Each alarm sink carries a **try it now** control — **Play test sound** and **Send test push** — because an alarm is otherwise unobservable until it fires unattended, which is the moment its being wrong costs the most. Source of truth: `lib/src/components/AlarmTestButtons.tsx`. 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 own 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 as a real alarm, but deliberately not through `speak()`: that publishes the transient per-Session `speaking` / `spoken` state Panes and Doors render, and no Session rang. 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. It is the one caller of the push path that must **not** swallow failures — the ring path's rule that a failed push never breaks the alert path would make a test button report success over a fan-out that reached nobody. It distinguishes four outcomes: no devices targeted (the ordinary answer on a freshly enrolled machine, and not a failure), nothing delivered, a partial fan-out, and success. The button is hidden entirely where no Host service exists, matching the Remote control section ([server.md](./server.md)).

## Workspace union

Expand Down Expand Up @@ -362,5 +365,6 @@ Alert-specific robustness requirements: multiple Sessions ring independently; mi
| `lib/src/components/wall/AlertSpeechIndicator.tsx` | Whole-Pane `SPEAKING` / `SPOKEN` treatment |
| `lib/src/components/TodoAlertDialog.tsx` | TODO + WATCHING-rule switches, notification detail, watched-command list |
| `lib/src/components/SettingsDialog.tsx` | App-global Settings dialog: theme row (see [theme.md](./theme.md)), shell row (standalone, see [standalone.md](./standalone.md)), rule list, inactivity timeout, spoken alarms, push notifications, remote control (see [server.md](./server.md)) |
| `lib/src/components/AlarmTestButtons.tsx` | The two alarm sinks' "try it now" controls: Play test sound, Send test push |
| `lib/src/components/WatchedCommandList.tsx` | The WATCHING rule set with per-rule remove, shared by both dialogs |
| `lib/src/components/Door.tsx` | Door bell + TODO display |
13 changes: 13 additions & 0 deletions docs/stories/pairing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,19 @@ Back on the laptop, the section now counts what is paired:

<Canvas of={RemoteControl.ConnectedOneDevice} />

The push alarm's settings, directly above that section, carry the proof of the
whole chain you just built: **Send test push**. Nothing about it is simulated —
the test goes through the same Host, the same ACL, and the same server a real
alarm would, so a phone that buzzes is evidence of everything from §1 to §6 at
once. Its answers are kept deliberately distinct, because they call for
different responses: on a machine that has enrolled (§3) but has no phone with
alerts enabled yet, "nowhere to send it" is the ordinary answer, not a failure —
while a fan-out that no device accepted is one worth chasing. The notification
itself is titled to say that nothing actually needs attention, so a test that
lands hours late, or on another paired phone, cannot masquerade as a real
alarm. (The spoken alarm has a sibling **Play test sound**; both live in
`docs/specs/alert.md` → Alarm settings.)

**Displaced** is the one connection state that needs a person. Another Dormouse
instance enrolled with the same `hostId` took the relay slot, and this one stood
down — terminally, on purpose, because two instances fighting over a slot is
Expand Down
146 changes: 146 additions & 0 deletions lib/src/components/AlarmTestButtons.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/**
* @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');
// The ordinary answer on a freshly enrolled machine — not rendered as an error.
expect(container.querySelector('[role="status"]')?.className).not.toContain('text-error');
});

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');
});
});
134 changes: 134 additions & 0 deletions lib/src/components/AlarmTestButtons.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
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
role="status"
aria-live="polite"
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.', 'ok');
} 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>
);
}
34 changes: 24 additions & 10 deletions lib/src/components/SettingsDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { ThemePicker } from './ThemePicker';
import { ShellPicker } from './ShellPicker';
import { WatchedCommandList } from './WatchedCommandList';
import { RemoteControlSection } from './RemoteControlSection';
import { PushTestButton, SpeakTestButton } from './AlarmTestButtons';
import { getPlatform } from '../lib/platform';
import { getShellsSnapshot, subscribeToShells } from '../lib/shell-store';
import {
Expand Down Expand Up @@ -187,6 +188,7 @@ export function SettingsDialog({ onClose }: { onClose: () => void }) {
delayMs={settings.speakDelayMs}
onToggle={(speakEnabled) => updateAlertSettings({ speakEnabled })}
onCommitDelay={(speakDelayMs) => updateAlertSettings({ speakDelayMs })}
action={<SpeakTestButton />}
/>

<AlarmSinkSection
Expand All @@ -196,6 +198,7 @@ export function SettingsDialog({ onClose }: { onClose: () => void }) {
delayMs={settings.pushDelayMs}
onToggle={(pushEnabled) => updateAlertSettings({ pushEnabled })}
onCommitDelay={(pushDelayMs) => updateAlertSettings({ pushDelayMs })}
action={<PushTestButton />}
>
{describePushTargets(push, hasHostService)}
</AlarmSinkSection>
Expand All @@ -221,6 +224,7 @@ function AlarmSinkSection({
onToggle,
onCommitDelay,
children,
action,
}: {
switchLabel: string;
delayLabel: string;
Expand All @@ -229,20 +233,30 @@ function AlarmSinkSection({
onToggle: (next: boolean) => void;
onCommitDelay: (ms: number) => void;
children?: React.ReactNode;
/**
* A "try it now" control. Rendered *outside* the dimming below, and never
* disabled by the switch: checking that the speakers work — or that the phone
* buzzes — is most useful before committing to the alarm, and an alarm you
* cannot observe until 3am is one you cannot trust.
*/
action?: React.ReactNode;
}) {
return (
<section className={SECTION}>
<SwitchRow label={switchLabel} on={enabled} onChange={onToggle} />
<div className={`mt-2 ${UNDER_SWITCH_INDENT} ${enabled ? '' : 'opacity-50'}`}>
<SecondsField
label={delayLabel}
valueMs={delayMs}
disabled={!enabled}
onCommit={onCommitDelay}
/>
{children ? (
<div className="mt-1 text-sm leading-relaxed text-muted">{children}</div>
) : null}
<div className={UNDER_SWITCH_INDENT}>
<div className={`mt-2 ${enabled ? '' : 'opacity-50'}`}>
<SecondsField
label={delayLabel}
valueMs={delayMs}
disabled={!enabled}
onCommit={onCommitDelay}
/>
{children ? (
<div className="mt-1 text-sm leading-relaxed text-muted">{children}</div>
) : null}
</div>
{action ? <div className="mt-2">{action}</div> : null}
</div>
</section>
);
Expand Down
Loading