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
165 changes: 165 additions & 0 deletions frontend/src/components/NotificationsDrawer.alerts.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { render, screen, fireEvent, act, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import NotificationsDrawer from "./NotificationsDrawer";
import { useNotificationStore } from "../stores/notificationStore";
import { useUserPreferencesStore } from "../stores/userPreferencesStore";

const enableSoundMock = vi.fn();
const requestDesktopPermissionMock = vi.fn(async () => {});

let desktopPermission: "granted" | "denied" | "default" = "granted";
let soundUnlocked = true;
let soundSupported = true;

vi.mock("../hooks/useAlertChime", () => ({
useAlertChime: () => ({
supported: soundSupported,
unlocked: soundUnlocked,
unlock: enableSoundMock,
play: vi.fn(),
}),
isWebAudioSupported: () => soundSupported,
}));

vi.mock("../hooks/useDesktopNotifications", () => ({
useDesktopNotifications: () => ({
support: desktopPermission,
requestPermission: requestDesktopPermissionMock,
notifyInBackground: vi.fn(() => false),
}),
getDesktopPermission: () => desktopPermission,
getDesktopNotificationSupport: () => ({}),
}));

function resetStores() {
useNotificationStore.setState(useNotificationStore.getInitialState(), true);
useUserPreferencesStore.setState(useUserPreferencesStore.getInitialState(), true);
}

function renderDrawer() {
return render(
<NotificationsDrawer open drawerId="test-drawer" onClose={vi.fn()} />
);
}

describe("NotificationsDrawer alert controls", () => {
beforeEach(() => {
resetStores();
localStorage.clear();
enableSoundMock.mockClear();
requestDesktopPermissionMock.mockClear();
desktopPermission = "granted";
soundUnlocked = true;
soundSupported = true;
});

it("renders an audible alerts switch", () => {
renderDrawer();
expect(screen.getByRole("switch", { name: /Audible alerts/i })).toBeInTheDocument();
});

it("renders a desktop notifications switch", () => {
renderDrawer();
expect(
screen.getByRole("switch", { name: /Desktop notifications/i })
).toBeInTheDocument();
});

it("reflects the persisted sound preference", () => {
useUserPreferencesStore.setState({ soundEnabled: true });
renderDrawer();
expect(screen.getByRole("switch", { name: /Audible alerts/i })).toHaveAttribute(
"aria-checked",
"true"
);
});

it("toggles the sound preference and arms audio in the same gesture", () => {
useUserPreferencesStore.setState({ soundEnabled: false });
renderDrawer();

fireEvent.click(screen.getByRole("switch", { name: /Audible alerts/i }));

expect(enableSoundMock).toHaveBeenCalled();
expect(useUserPreferencesStore.getState().soundEnabled).toBe(true);
});

it("requests notification permission when enabling desktop alerts", async () => {
useUserPreferencesStore.setState({ notificationsEnabled: false });
renderDrawer();

await act(async () => {
fireEvent.click(screen.getByRole("switch", { name: /Desktop notifications/i }));
});

expect(requestDesktopPermissionMock).toHaveBeenCalled();
expect(useUserPreferencesStore.getState().notificationsEnabled).toBe(true);
});

it("does not re-request permission when disabling desktop alerts", async () => {
useUserPreferencesStore.setState({ notificationsEnabled: true });
renderDrawer();

await act(async () => {
fireEvent.click(screen.getByRole("switch", { name: /Desktop notifications/i }));
});

expect(requestDesktopPermissionMock).not.toHaveBeenCalled();
expect(useUserPreferencesStore.getState().notificationsEnabled).toBe(false);
});

it("explains how to unblock notifications when permission is denied", () => {
desktopPermission = "denied";
renderDrawer();
expect(screen.getByText(/blocked/i)).toBeInTheDocument();
});

it("prompts the operator to arm sound before the first chime", () => {
soundUnlocked = false;
useUserPreferencesStore.setState({ soundEnabled: true });
renderDrawer();
expect(screen.getByText(/block audio until you interact/i)).toBeInTheDocument();
});

it("hides the arm-sound prompt once audio is unlocked", () => {
useUserPreferencesStore.setState({ soundEnabled: true });
renderDrawer();
expect(screen.queryByText(/block audio until you interact/i)).not.toBeInTheDocument();
});

it("explains the missing API when Web Audio is unavailable", () => {
soundSupported = false;
renderDrawer();
expect(screen.getByRole("switch", { name: /Audible alerts/i })).toBeDisabled();
expect(screen.getByText(/Web Audio is unavailable/i)).toBeInTheDocument();
});

it("keeps the existing notification list and actions working", () => {
useNotificationStore.getState().addNotification({
id: "n-1",
type: "system",
priority: "critical",
title: "Circuit breaker tripped",
message: "Reserve mismatch detected",
timestamp: 5,
});

renderDrawer();

expect(screen.getByText("Circuit breaker tripped")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Mark all as read/i })).toBeInTheDocument();
});

it("closes via the escape key without breaking on the new controls", async () => {
const onClose = vi.fn();
render(
<NotificationsDrawer open drawerId="test-drawer" onClose={onClose} />
);

await act(async () => {
fireEvent.keyDown(document, { key: "Escape" });
});

await waitFor(() => expect(onClose).toHaveBeenCalled());
});
});
107 changes: 107 additions & 0 deletions frontend/src/components/NotificationsDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
type Notification,
type NotificationPriority,
} from "../stores/notificationStore";
import { useUserPreferencesStore } from "../stores/userPreferencesStore";
import { useAlertNotifier } from "../hooks/useAlertNotifier";

interface NotificationsDrawerProps {
open: boolean;
Expand Down Expand Up @@ -62,6 +64,17 @@ export default function NotificationsDrawer({
(state) => state.clearReadNotifications
);

const soundEnabled = useUserPreferencesStore((state) => state.soundEnabled);
const desktopNotificationsEnabled = useUserPreferencesStore(
(state) => state.notificationsEnabled
);
const setPreference = useUserPreferencesStore((state) => state.setPreference);

const notifier = useAlertNotifier({
soundEnabled,
desktopEnabled: desktopNotificationsEnabled,
});

const groupedNotifications = useMemo(() => {
const visibleNotifications = notifications.filter((n) => !n.dismissed);
return NOTIFICATION_PRIORITY_ORDER.reduce<Record<NotificationPriority, Notification[]>>(
Expand Down Expand Up @@ -241,6 +254,100 @@ export default function NotificationsDrawer({
Clear read
</button>
</div>

<div className="mt-3 flex flex-col gap-2 border-t border-stellar-border pt-3">
<div className="flex items-center justify-between gap-3">
<label
htmlFor="notifications-sound-toggle"
className="text-xs text-stellar-text-secondary"
>
Audible alerts
</label>
<button
id="notifications-sound-toggle"
type="button"
role="switch"
aria-checked={soundEnabled}
disabled={!notifier.soundSupported}
onClick={() => {
// Arm audio in the same gesture, otherwise the browser keeps
// the AudioContext suspended and the first chime is silent.
notifier.enableSound();
setPreference("soundEnabled", !soundEnabled);
}}
className={`relative h-5 w-9 rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-stellar-blue disabled:cursor-not-allowed disabled:opacity-40 ${
soundEnabled ? "bg-stellar-blue" : "bg-stellar-border"
}`}
>
<span className="sr-only">
{soundEnabled ? "Audible alerts on" : "Audible alerts off"}
</span>
<span
aria-hidden="true"
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white transition-transform ${
soundEnabled ? "translate-x-[1.125rem]" : "translate-x-0.5"
}`}
/>
</button>
</div>

{notifier.soundSupported && soundEnabled && !notifier.soundUnlocked && (
<p className="text-xs text-yellow-400">
Tap the toggle once to arm sound — browsers block audio until you interact.
</p>
)}
{!notifier.soundSupported && (
<p className="text-xs text-stellar-text-secondary">
Web Audio is unavailable in this browser, so audible alerts are disabled.
</p>
)}

<div className="flex items-center justify-between gap-3">
<label
htmlFor="notifications-desktop-toggle"
className="text-xs text-stellar-text-secondary"
>
Desktop notifications
</label>
<button
id="notifications-desktop-toggle"
type="button"
role="switch"
aria-checked={desktopNotificationsEnabled}
disabled={!notifier.desktopSupported}
onClick={() => {
if (!desktopNotificationsEnabled) {
// Only ask on the enabling gesture; browsers reject
// permission prompts that are not user-initiated.
void notifier.requestDesktopPermission();
}
setPreference("notificationsEnabled", !desktopNotificationsEnabled);
}}
className={`relative h-5 w-9 rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-stellar-blue disabled:cursor-not-allowed disabled:opacity-40 ${
desktopNotificationsEnabled ? "bg-stellar-blue" : "bg-stellar-border"
}`}
>
<span className="sr-only">
{desktopNotificationsEnabled
? "Desktop notifications on"
: "Desktop notifications off"}
</span>
<span
aria-hidden="true"
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white transition-transform ${
desktopNotificationsEnabled ? "translate-x-[1.125rem]" : "translate-x-0.5"
}`}
/>
</button>
</div>

{notifier.desktopPermission === "denied" && (
<p className="text-xs text-red-400">
Browser notifications are blocked. Allow them in your browser site settings to
receive alerts while this tab is in the background.
</p>
)}
</div>
</div>

<p ref={liveRegionRef} className="sr-only" role="status" aria-live="polite" aria-atomic="true" />
Expand Down
Loading
Loading