/**
* Tests for ToastContext's post-unmount safety guards.
*
* Regression: a login response handler calling showToast AFTER the provider
* had already been unmounted by Vitest's afterEach scheduled a 3s setTimeout
* that fired during test teardown. The callback's setToasts then tried to
* schedule a React update against a torn-down jsdom, producing
* "window is not defined" as an uncaught exception.
*
* The provider now gates every setToasts call on an isMountedRef and
* re-checks inside the auto-dismiss setTimeout callback so stale async
* paths no-op instead of crashing.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { act, render, renderHook } from '@testing-library/react';
import { type ReactNode } from 'react';
import { ToastProvider, useToast } from '../../contexts/ToastContext';
function Wrapper({ children }: { children: ReactNode }) {
return {children};
}
describe('ToastContext post-unmount safety', () => {
beforeEach(() => {
vi.useRealTimers();
});
it('does not crash when showToast is called after unmount', () => {
const { result, unmount } = renderHook(() => useToast(), { wrapper: Wrapper });
// Capture the callbacks BEFORE unmount — a real stale-closure scenario.
// (Async handlers that kicked off before unmount keep their captured
// context value and will invoke this function after we tear down.)
const { showToast } = result.current;
unmount();
// Post-unmount invocation is now a no-op; must not throw.
expect(() => showToast('delayed error message', 'error')).not.toThrow();
});
it('does not invoke setToasts when the auto-dismiss timer fires after unmount', async () => {
vi.useFakeTimers();
const { result, unmount } = renderHook(() => useToast(), { wrapper: Wrapper });
act(() => {
result.current.showToast('will outlive the provider', 'error');
});
// Unmount BEFORE the 3s timer fires — the unmount effect clears pending
// timers, but a belt-and-braces check inside the timer callback (for
// cases where the timer was scheduled post-unmount) must also hold.
unmount();
// Advance past the 3s auto-dismiss window. If the guard isn't in place
// this would throw "window is not defined" in a torn-down jsdom; we
// simulate by asserting no error propagates.
expect(() => {
vi.advanceTimersByTime(5000);
}).not.toThrow();
vi.useRealTimers();
});
it('post-unmount showPersistentToast and dismissToast are no-ops', () => {
const { result, unmount } = renderHook(() => useToast(), { wrapper: Wrapper });
const { showPersistentToast, dismissToast } = result.current;
unmount();
// Both must short-circuit rather than attempt setState on a dead tree.
expect(() => showPersistentToast('orphan', 'still here', 'info')).not.toThrow();
expect(() => dismissToast('orphan')).not.toThrow();
});
it('normal showToast flow still displays and auto-dismisses while mounted', () => {
vi.useFakeTimers();
const { result } = renderHook(() => useToast(), { wrapper: Wrapper });
act(() => {
result.current.showToast('mounted path works', 'success');
});
// No easy way to read toast DOM from the hook alone; assert the timer
// ran without throwing — that proves the isMountedRef guard didn't
// incorrectly short-circuit the mounted path.
expect(() => {
act(() => {
vi.advanceTimersByTime(3500);
});
}).not.toThrow();
vi.useRealTimers();
});
});
describe('ToastContext background dispatch — upload-done UX', () => {
// Small fast files reach 100% upload before the printer's MQTT confirmation
// arrives, leaving the bar parked at 100% for what feels like "stuck". When
// status is still 'processing' but uploadProgressPct >= 99.9 the byte-count
// line should switch to "Awaiting printer..." and the bar gets a pulse.
function dispatchBackgroundEvent(detail: Record) {
window.dispatchEvent(new CustomEvent('background-dispatch', { detail }));
}
it('shows "Awaiting printer..." once upload is complete but printer has not confirmed', () => {
const { container } = render(
);
act(() => {
dispatchBackgroundEvent({
total: 1,
dispatched: 0,
processing: 1,
completed: 0,
failed: 0,
active_jobs: [
{
job_id: 42,
printer_name: 'X1C-2',
source_name: 'Benchy.3mf',
upload_bytes: 102400,
upload_total_bytes: 102400,
upload_progress_pct: 100.0,
},
],
});
});
// The byte-count line should be replaced with the awaiting-printer text.
expect(container.textContent).toContain('Awaiting printer');
// And the original bytes-progressed format must not be visible at the
// same time — that is the "stuck at 100%" symptom we are fixing.
expect(container.textContent).not.toContain('100.0%');
// Bar gets the pulse class when in this state.
const bar = container.querySelector('.animate-pulse');
expect(bar).not.toBeNull();
});
it('still shows the byte/percent counter while upload is mid-flight', () => {
const { container } = render(
);
act(() => {
dispatchBackgroundEvent({
total: 1,
dispatched: 0,
processing: 1,
completed: 0,
failed: 0,
active_jobs: [
{
job_id: 7,
printer_name: 'X1C-2',
source_name: 'Benchy.3mf',
upload_bytes: 51200,
upload_total_bytes: 102400,
upload_progress_pct: 50.0,
},
],
});
});
expect(container.textContent).toContain('50.0%');
expect(container.textContent).not.toContain('Awaiting printer');
expect(container.querySelector('.animate-pulse')).toBeNull();
});
});
describe('ToastContext viewport suppression', () => {
// The kiosk layout flips setViewportSuppressed(true) on mount so the
// SpoolBuddy display stays free of main-app toasts (background dispatch
// progress, login flows, etc.). Verify the gate hides the visible viewport
// without affecting the underlying state machine.
function ViewportProbe() {
const { showToast, setViewportSuppressed } = useToast();
return (
<>