Files
Shadowbroker/frontend/src/__tests__/components/AlertToast.test.tsx
T
421682c447 Pause AlertToast auto-dismiss while hovered (#235)
Each alert toast had a 5-second auto-dismiss timer that fired even
while the user was reading the card. This adds pause-on-hover: the
dismiss timer stops while the mouse is over a toast and restarts (full
lifetime) on mouse leave. The progress bar animation pauses with it,
so the visual matches the actual remaining time.

All other behavior is preserved: same cyber/mono styling, same spring
slide-in, same risk-color border + glow, same warning icon, same
LVL X/10 readout, same title/source layout, same click-to-fly + dismiss
on body click, same × dismiss button.

Implementation notes:
- Extract a ToastCard sub-component so each card can own its own
  paused state (useState can't be array-indexed in the parent).
- Move the auto-dismiss timer out of useAlertToasts.ts and into
  ToastCard. The hook previously scheduled the dismiss itself, which
  meant the UI couldn't pause it — only the component knows whether
  the user is interacting.
- Add tests covering: title/source/severity render, auto-dismiss
  fires at 5s, hover pauses indefinitely, mouse-leave restarts the
  full lifetime, × dismisses without flying, body-click flies +
  dismisses.

This implements the genuine UX improvement that PR #234 was reaching
for, without #234's broken syntax, missing-field bug, duplicate
timer logic, or design regression.

Refs: #234

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 00:49:36 -06:00

127 lines
3.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React from 'react';
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import AlertToast from '@/components/AlertToast';
import type { ToastItem } from '@/hooks/useAlertToasts';
function buildToast(partial: Partial<ToastItem> = {}): ToastItem {
return {
id: 'toast-1',
title: 'Embassy evacuation reported',
source: 'Reuters',
risk_score: 9,
lat: 38.9,
lng: -77.0,
timestamp: Date.now(),
...partial,
};
}
describe('AlertToast', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it('renders the toast title, source, and severity label', () => {
const toast = buildToast();
render(
<AlertToast toasts={[toast]} onDismiss={vi.fn()} />,
);
expect(screen.getByText(toast.title)).toBeTruthy();
expect(screen.getByText(toast.source)).toBeTruthy();
// 9/10 -> CRITICAL
expect(screen.getByText(/CRITICAL/)).toBeTruthy();
expect(screen.getByText(/LVL 9\/10/)).toBeTruthy();
});
it('auto-dismisses after 5 seconds', () => {
const onDismiss = vi.fn();
const toast = buildToast();
render(
<AlertToast toasts={[toast]} onDismiss={onDismiss} />,
);
expect(onDismiss).not.toHaveBeenCalled();
act(() => {
vi.advanceTimersByTime(5000);
});
expect(onDismiss).toHaveBeenCalledWith(toast.id);
});
it('pauses auto-dismiss while the card is hovered', () => {
const onDismiss = vi.fn();
const toast = buildToast();
render(
<AlertToast toasts={[toast]} onDismiss={onDismiss} />,
);
// Hover before the timer fires. mouseEnter must be flushed
// (state update + effect cleanup) in its own act() before we
// advance timers — otherwise the original mount-time timer is
// still active when advanceTimersByTime runs.
const card = screen.getByText(toast.title).closest('[class*="cursor-pointer"]')!;
expect(card).toBeTruthy();
act(() => {
fireEvent.mouseEnter(card);
});
act(() => {
vi.advanceTimersByTime(10_000);
});
// Still no dismiss — timer is paused.
expect(onDismiss).not.toHaveBeenCalled();
// Leave: a fresh full-lifetime timer starts.
act(() => {
fireEvent.mouseLeave(card);
});
act(() => {
vi.advanceTimersByTime(4_999);
});
expect(onDismiss).not.toHaveBeenCalled();
act(() => {
vi.advanceTimersByTime(1);
});
expect(onDismiss).toHaveBeenCalledWith(toast.id);
});
it('dismisses on × button click without calling onFlyTo', () => {
const onDismiss = vi.fn();
const onFlyTo = vi.fn();
const toast = buildToast();
render(
<AlertToast toasts={[toast]} onDismiss={onDismiss} onFlyTo={onFlyTo} />,
);
fireEvent.click(screen.getByText('×'));
expect(onDismiss).toHaveBeenCalledWith(toast.id);
expect(onFlyTo).not.toHaveBeenCalled();
});
it('flies to the toast location and dismisses on body click', () => {
const onDismiss = vi.fn();
const onFlyTo = vi.fn();
const toast = buildToast();
render(
<AlertToast toasts={[toast]} onDismiss={onDismiss} onFlyTo={onFlyTo} />,
);
fireEvent.click(screen.getByText(toast.title));
expect(onFlyTo).toHaveBeenCalledWith(toast.lat, toast.lng);
expect(onDismiss).toHaveBeenCalledWith(toast.id);
});
});