diff --git a/src/components/App.tsx b/src/components/App.tsx index 0a689bdd..3218766d 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -2,6 +2,8 @@ import { JSX } from 'react'; import ErrorBoundary from './Error'; import { IInspectableComponent } from '../core/types'; import { DebuggerNavigationProvider } from '../contexts/DebuggerNavigationContext'; +import { ToastProvider } from '../contexts/ToastContext'; +import ToastContainer from './ToastContainer'; import { AppContent } from './AppContent'; import type { WorkerManager } from '../services/WorkerManager'; @@ -13,11 +15,17 @@ type Props = { const App = ({ workerManager, apple1Instance }: Props): JSX.Element => { return ( - - - + + + + + + ); }; -export default App; \ No newline at end of file +export default App; diff --git a/src/components/AppContent.tsx b/src/components/AppContent.tsx index 2162b0ad..f04a87a7 100644 --- a/src/components/AppContent.tsx +++ b/src/components/AppContent.tsx @@ -6,6 +6,7 @@ import CRTWorker from './CRTWorker'; import { CONFIG } from '../config'; import Actions from './Actions'; import { useDebuggerNavigation } from '../contexts/DebuggerNavigationContext'; +import { useToast } from '../contexts/ToastContext'; import { EmulationProvider } from '../contexts/EmulationContext'; import ExecutionControlsCluster from './ExecutionControlsCluster'; import { WorkerDataProvider } from '../contexts/WorkerDataContext'; @@ -43,6 +44,7 @@ const AppContentInner = ({ const [isSwitchingEngine, setIsSwitchingEngine] = useState(false); const hiddenInputRef = useRef(null); const { subscribeToNavigation } = useDebuggerNavigation(); + const { showToast } = useToast(); const { safeSetTimeout } = useUnmountSafe(); // Persist debugger view states across tab switches @@ -99,12 +101,14 @@ const AppContentInner = ({ const status = await workerManager.getEngineStatus(); setEngineStatus(status); loggingService.info('AppContent', `Switched to ${target} engine`); + showToast(`Switched to ${target} engine`, 'success'); } catch (error) { loggingService.error('AppContent', `Failed to switch engine: ${error}`); + showToast(`Failed to switch engine to ${target}.`, 'error'); } finally { setIsSwitchingEngine(false); } - }, [workerManager, engineStatus, isSwitchingEngine]); + }, [workerManager, engineStatus, isSwitchingEngine, showToast]); const handleKeyDown = useCallback( async (e: KeyboardEvent) => { @@ -185,12 +189,13 @@ const AppContentInner = ({ } URL.revokeObjectURL(url); }, 100); + showToast('State saved: apple1_state.json', 'success'); } catch (error) { - console.error('Failed to save state:', error); - window.alert('Failed to save state.'); + loggingService.error('AppContent', `Failed to save state: ${error}`); + showToast('Failed to save state.', 'error'); } }, - [workerManager, safeSetTimeout], + [workerManager, safeSetTimeout, showToast], ); const handleLoadState = useCallback( @@ -204,13 +209,14 @@ const AppContentInner = ({ workerManager.loadState(state); // Reset input so selecting the same file again triggers change e.target.value = ''; + showToast('State loaded', 'success'); } catch { - window.alert('Invalid state file.'); + showToast('Invalid state file.', 'error'); } }; reader.readAsText(file); }, - [workerManager], + [workerManager, showToast], ); return ( diff --git a/src/components/ToastContainer.tsx b/src/components/ToastContainer.tsx new file mode 100644 index 00000000..1aa439c9 --- /dev/null +++ b/src/components/ToastContainer.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { useToast, type ToastVariant } from '../contexts/ToastContext'; + +// Token-backed per-variant styling (mirrors the RunStateBadge variant-map pattern). +const VARIANT_CLASSES: Record = { + success: 'bg-success/20 text-success border-success/40', + error: 'bg-error/20 text-error border-error/40', + info: 'bg-info/20 text-info border-info/40', +}; + +/** + * Fixed-position, non-blocking stack of active toasts (newest first). Each toast is a + * polite status region announced to assistive tech; it never traps focus or blocks the + * rest of the UI. Pure consumer of ToastContext. + */ +const ToastContainer: React.FC = () => { + const { toasts, dismiss } = useToast(); + + if (toasts.length === 0) { + return null; + } + + return ( +
+ {toasts.map((toast) => ( +
+ {toast.message} + +
+ ))} +
+ ); +}; + +export default ToastContainer; diff --git a/src/components/__tests__/AppContent-ui-toast-feedback.vitest.test.tsx b/src/components/__tests__/AppContent-ui-toast-feedback.vitest.test.tsx new file mode 100644 index 00000000..51ceab13 --- /dev/null +++ b/src/components/__tests__/AppContent-ui-toast-feedback.vitest.test.tsx @@ -0,0 +1,117 @@ +import React from 'react'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import '@testing-library/jest-dom/vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { AppContent } from '../AppContent'; +// These imports do not exist yet — that is what makes these tests red (Phase 4). +import { ToastProvider } from '../../contexts/ToastContext'; +import ToastContainer from '../ToastContainer'; +import { createMockWorkerManager } from '../../test-support/mocks/WorkerManager.mock'; +import type { WorkerManager } from '../../services/WorkerManager'; + +// Stub heavy / irrelevant children; keep Actions real so SAVE/LOAD controls exist. +vi.mock('../CRTWorker', () => ({ __esModule: true, default: () =>
})); +vi.mock('../Info', () => ({ __esModule: true, default: () =>
})); +vi.mock('../InspectorView', () => ({ __esModule: true, default: () =>
})); +vi.mock('../DebuggerLayout', () => ({ __esModule: true, default: () =>
})); + +// Expose the engine-switch handler through a simple button. +vi.mock('../ExecutionControlsCluster', () => ({ + __esModule: true, + default: ({ onEngineSwitch }: { onEngineSwitch: () => void }) => ( + + ), +})); + +vi.mock('../../contexts/DebuggerNavigationContext', () => ({ + useDebuggerNavigation: () => ({ subscribeToNavigation: vi.fn(() => () => {}) }), +})); +vi.mock('../../contexts/EmulationContext', () => ({ + EmulationProvider: ({ children }: { children: React.ReactNode }) => children, +})); +vi.mock('../../contexts/WorkerDataContext', () => ({ + WorkerDataProvider: ({ children }: { children: React.ReactNode }) => children, +})); + +const renderApp = (wm: WorkerManager) => + render( + + + + , + ); + +describe('AppContent toast feedback', () => { + let wm: WorkerManager; + let alertSpy: ReturnType; + + beforeEach(() => { + wm = createMockWorkerManager(); + // Make the file-download path work under jsdom. + globalThis.URL.createObjectURL = vi.fn(() => 'blob:mock'); + globalThis.URL.revokeObjectURL = vi.fn(); + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); + alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {}); + vi.mocked(wm.getEngineStatus).mockResolvedValue({ + currentEngine: 'JS', + availableEngines: ['JS', 'WASM'], + switchCount: 0, + lastSwitchTime: 0, + }); + }); + afterEach(() => vi.restoreAllMocks()); + + // AC-1 (RENDER): a successful save surfaces a confirmation message. + it('AC-1 (RENDER): save success shows a confirmation toast', async () => { + renderApp(wm); + fireEvent.click(screen.getByText('SAVE STATE')); + expect(await screen.findByText(/saved/i)).toBeInTheDocument(); + }); + + // AC-2 (RENDER): a failed save surfaces an error message instead of a blocking alert. + it('AC-2 (RENDER): save failure shows an error toast and no window.alert', async () => { + vi.mocked(wm.saveState).mockRejectedValueOnce(new Error('disk full')); + renderApp(wm); + fireEvent.click(screen.getByText('SAVE STATE')); + expect(await screen.findByText(/failed to save/i)).toBeInTheDocument(); + expect(alertSpy).not.toHaveBeenCalled(); + }); + + // AC-3 (RENDER): a successful load surfaces a confirmation message. + it('AC-3 (RENDER): load success shows a confirmation toast', async () => { + renderApp(wm); + const input = document.querySelector('input[aria-label="Load state from file"]') as HTMLInputElement; + const file = new File(['{"valid":true}'], 'state.json', { type: 'application/json' }); + fireEvent.change(input, { target: { files: [file] } }); + expect(await screen.findByText(/loaded/i)).toBeInTheDocument(); + }); + + // AC-4 (RENDER): an invalid load file surfaces an error message, not a blocking alert. + it('AC-4 (RENDER): invalid load file shows an error toast and no window.alert', async () => { + renderApp(wm); + const input = document.querySelector('input[aria-label="Load state from file"]') as HTMLInputElement; + const file = new File(['this is not json'], 'state.json', { type: 'application/json' }); + fireEvent.change(input, { target: { files: [file] } }); + expect(await screen.findByText(/invalid/i)).toBeInTheDocument(); + expect(alertSpy).not.toHaveBeenCalled(); + }); + + // AC-5 (RENDER): a successful engine switch surfaces a confirmation naming the engine. + it('AC-5 (RENDER): engine switch success shows a toast naming the engine', async () => { + renderApp(wm); + await waitFor(() => expect(wm.getEngineStatus).toHaveBeenCalled()); + fireEvent.click(screen.getByTestId('engine-switch-btn')); + expect(await screen.findByText(/WASM/)).toBeInTheDocument(); + }); + + // AC-6 (RENDER): a failed engine switch surfaces an error message. + it('AC-6 (RENDER): engine switch failure shows an error toast', async () => { + vi.mocked(wm.switchEngine).mockRejectedValueOnce(new Error('no wasm')); + renderApp(wm); + await waitFor(() => expect(wm.getEngineStatus).toHaveBeenCalled()); + fireEvent.click(screen.getByTestId('engine-switch-btn')); + expect(await screen.findByText(/failed.*engine|engine.*fail|switch.*fail/i)).toBeInTheDocument(); + }); +}); diff --git a/src/components/__tests__/AppContent.vitest.test.tsx b/src/components/__tests__/AppContent.vitest.test.tsx index 899dd726..d56ff07f 100644 --- a/src/components/__tests__/AppContent.vitest.test.tsx +++ b/src/components/__tests__/AppContent.vitest.test.tsx @@ -55,6 +55,10 @@ vi.mock('../../contexts/WorkerDataContext', () => ({ WorkerDataProvider: ({ children }: { children: React.ReactNode }) => children, })); +vi.mock('../../contexts/ToastContext', () => ({ + useToast: () => ({ toasts: [], showToast: vi.fn(), dismiss: vi.fn() }), +})); + describe('AppContent', () => { let mockWorkerManager: WorkerManager; diff --git a/src/components/__tests__/ToastContainer-ui-toast-feedback.vitest.test.tsx b/src/components/__tests__/ToastContainer-ui-toast-feedback.vitest.test.tsx new file mode 100644 index 00000000..21be0772 --- /dev/null +++ b/src/components/__tests__/ToastContainer-ui-toast-feedback.vitest.test.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { describe, it, expect, afterEach, vi } from 'vitest'; +import '@testing-library/jest-dom/vitest'; +import { render, screen, act, within } from '@testing-library/react'; +// These imports do not exist yet — that is what makes these tests red (Phase 4). +import ToastContainer from '../ToastContainer'; +import { ToastProvider, useToast } from '../../contexts/ToastContext'; + +afterEach(() => vi.clearAllMocks()); + +/** Harness: a control surface to push toasts plus the container under test. */ +const Harness: React.FC<{ siblingOnClick?: () => void }> = ({ siblingOnClick }) => { + const { showToast } = useToast(); + return ( +
+ + + {siblingOnClick && } + +
+ ); +}; + +describe('ToastContainer', () => { + // AC-8 (RENDER): multiple messages are all visible at once, ordered newest-first, and + // each is independently dismissable. + it('AC-8 (RENDER): stacks toasts newest-first and dismisses each independently', () => { + render( + + + , + ); + + act(() => screen.getByText('push-first').click()); + act(() => screen.getByText('push-second').click()); + + const toasts = screen.getAllByTestId('toast'); + expect(toasts).toHaveLength(2); + // newest-first: the most recently pushed ("second") renders first + expect(within(toasts[0]).getByText('second')).toBeInTheDocument(); + expect(within(toasts[1]).getByText('first')).toBeInTheDocument(); + + // dismissing the newest leaves the other intact + act(() => + within(toasts[0]) + .getByRole('button', { name: /dismiss/i }) + .click(), + ); + expect(screen.queryByText('second')).not.toBeInTheDocument(); + expect(screen.getByText('first')).toBeInTheDocument(); + }); + + // AC-9 (RENDER): each message is exposed to assistive tech via a polite status + // announcement and does not block interaction with the rest of the interface. + it('AC-9 (RENDER): toasts are polite status regions and do not block other interaction', () => { + const siblingOnClick = vi.fn(); + render( + + + , + ); + + act(() => screen.getByText('push-first').click()); + + const toast = screen.getByTestId('toast'); + expect(toast).toHaveAttribute('role', 'status'); + expect(toast).toHaveAttribute('aria-live', 'polite'); + // non-blocking: it is not a modal dialog… + expect(toast).not.toHaveAttribute('aria-modal', 'true'); + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); + // …and the rest of the UI stays interactive while the toast is shown + act(() => screen.getByText('sibling').click()); + expect(siblingOnClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/constants/ui.ts b/src/constants/ui.ts index af6ee347..763292cc 100644 --- a/src/constants/ui.ts +++ b/src/constants/ui.ts @@ -1,6 +1,6 @@ /** * UI Update and Refresh Constants - * + * * Standardized timing constants for component updates across the application. * These values balance responsiveness with performance. */ @@ -9,13 +9,13 @@ export const REFRESH_RATES = { /** Fast refresh for real-time components (e.g., CPU registers during execution) */ FAST: 100, - + /** Normal refresh for active debugging components */ NORMAL: 500, - + /** Slow refresh for mostly static components */ SLOW: 1000, - + /** Very slow refresh for background monitoring */ BACKGROUND: 5000, } as const; @@ -24,16 +24,16 @@ export const REFRESH_RATES = { export const DEBUG_REFRESH_RATES = { /** CPU state and registers */ CPU_STATE: REFRESH_RATES.NORMAL, - + /** Memory viewer */ MEMORY_VIEW: REFRESH_RATES.NORMAL, - + /** Disassembler view */ DISASSEMBLER: REFRESH_RATES.NORMAL, - + /** Stack viewer */ STACK_VIEW: REFRESH_RATES.NORMAL, - + /** Component inspector tree */ INSPECTOR: REFRESH_RATES.SLOW, } as const; @@ -42,33 +42,36 @@ export const DEBUG_REFRESH_RATES = { export const UI_TIMINGS = { /** Debounce delay for user input */ DEBOUNCE_DELAY: 300, - + /** Throttle delay for frequent updates */ THROTTLE_DELAY: 100, - + /** Animation duration for transitions */ ANIMATION_DURATION: 200, - + /** Delay before showing loading indicators */ LOADING_DELAY: 1000, - + /** CRT cursor blink rate */ CURSOR_BLINK_RATE: 530, + + /** How long a non-error toast stays on screen before auto-dismissing */ + TOAST_DURATION: 4000, } as const; // Hook update patterns export const UPDATE_PATTERNS = { /** Update only when component is visible */ WHEN_VISIBLE: 'whenVisible', - + /** Update continuously regardless of visibility */ ALWAYS: 'always', - + /** Update only when emulation is paused */ WHEN_PAUSED: 'whenPaused', - + /** Update only when emulation is running */ WHEN_RUNNING: 'whenRunning', } as const; -export type UpdatePattern = typeof UPDATE_PATTERNS[keyof typeof UPDATE_PATTERNS]; \ No newline at end of file +export type UpdatePattern = (typeof UPDATE_PATTERNS)[keyof typeof UPDATE_PATTERNS]; diff --git a/src/contexts/ToastContext.tsx b/src/contexts/ToastContext.tsx new file mode 100644 index 00000000..a54092db --- /dev/null +++ b/src/contexts/ToastContext.tsx @@ -0,0 +1,60 @@ +import React, { createContext, useContext, useState, useCallback } from 'react'; +import { UI_TIMINGS } from '../constants/ui'; +import { useUnmountSafe } from '../hooks/useUnmountSafe'; + +export type ToastVariant = 'success' | 'error' | 'info'; + +export interface Toast { + id: string; + message: string; + variant: ToastVariant; +} + +interface ToastContextValue { + /** Active toasts, newest first. */ + toasts: Toast[]; + /** Enqueue a toast. Non-error variants auto-dismiss; errors persist until dismissed. */ + showToast: (message: string, variant?: ToastVariant) => void; + /** Remove a toast by id. */ + dismiss: (id: string) => void; +} + +const ToastContext = createContext(undefined); + +let nextToastId = 0; + +export const useToast = (): ToastContextValue => { + const context = useContext(ToastContext); + if (!context) { + throw new Error('useToast must be used within a ToastProvider'); + } + return context; +}; + +interface ToastProviderProps { + children: React.ReactNode; +} + +export const ToastProvider: React.FC = ({ children }) => { + const [toasts, setToasts] = useState([]); + const { safeSetTimeout } = useUnmountSafe(); + + const dismiss = useCallback((id: string) => { + setToasts((prev) => prev.filter((toast) => toast.id !== id)); + }, []); + + const showToast = useCallback( + (message: string, variant: ToastVariant = 'info') => { + const id = `toast-${nextToastId++}`; + // newest-first + setToasts((prev) => [{ id, message, variant }, ...prev]); + // Success/info clear themselves; errors stay until the user dismisses them. + if (variant !== 'error') { + safeSetTimeout(() => dismiss(id), UI_TIMINGS.TOAST_DURATION); + } + }, + [safeSetTimeout, dismiss], + ); + + return {children}; +}; diff --git a/src/contexts/__tests__/ToastContext-ui-toast-feedback.vitest.test.tsx b/src/contexts/__tests__/ToastContext-ui-toast-feedback.vitest.test.tsx new file mode 100644 index 00000000..71de4d61 --- /dev/null +++ b/src/contexts/__tests__/ToastContext-ui-toast-feedback.vitest.test.tsx @@ -0,0 +1,101 @@ +import React from 'react'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import '@testing-library/jest-dom/vitest'; +import { render, renderHook, act, screen } from '@testing-library/react'; +// These imports do not exist yet — that is what makes these tests red (Phase 4). +import { ToastProvider, useToast } from '../ToastContext'; + +const wrapper = ({ children }: { children: React.ReactNode }) => {children}; + +describe('ToastContext', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + // AC-10 (none): the notification store enqueues newest-first, auto-removes non-error + // entries after the configured duration, removes any entry on dismiss, and keeps error + // entries until dismissed. + it('AC-10 (none): store enqueues newest-first, auto-expires non-errors, persists errors, dismisses on request', () => { + const { result } = renderHook(() => useToast(), { wrapper }); + + // enqueue prepends newest-first + act(() => { + result.current.showToast('first', 'info'); + result.current.showToast('second', 'info'); + }); + expect(result.current.toasts).toHaveLength(2); + expect(result.current.toasts[0].message).toBe('second'); + + // non-error entries auto-expire after the configured duration + act(() => { + vi.advanceTimersByTime(10_000); + }); + expect(result.current.toasts).toHaveLength(0); + + // error entries persist past the duration + act(() => { + result.current.showToast('boom', 'error'); + }); + act(() => { + vi.advanceTimersByTime(10_000); + }); + expect(result.current.toasts).toHaveLength(1); + expect(result.current.toasts[0].variant).toBe('error'); + + // any entry is removed on explicit dismiss + const id = result.current.toasts[0].id; + act(() => { + result.current.dismiss(id); + }); + expect(result.current.toasts).toHaveLength(0); + }); + + // AC-10 (none): the hook refuses to operate outside its provider. + it('AC-10 (none): useToast throws when used outside a ToastProvider', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + expect(() => renderHook(() => useToast())).toThrow(); + spy.mockRestore(); + }); + + // AC-7 (RENDER): a success/info message disappears on its own after a bounded time, + // while an error message remains until the user dismisses it. + it('AC-7 (RENDER): success/info auto-dismisses on screen, error remains', () => { + const Consumer: React.FC = () => { + const { toasts, showToast } = useToast(); + return ( +
+ + +
    + {toasts.map((t) => ( +
  • {t.message}
  • + ))} +
+
+ ); + }; + render(, { wrapper }); + + act(() => { + screen.getByText('fire-success').click(); + }); + expect(screen.getByText('saved ok')).toBeInTheDocument(); + act(() => { + vi.advanceTimersByTime(10_000); + }); + expect(screen.queryByText('saved ok')).not.toBeInTheDocument(); + + act(() => { + screen.getByText('fire-error').click(); + }); + act(() => { + vi.advanceTimersByTime(10_000); + }); + expect(screen.getByText('save failed')).toBeInTheDocument(); + }); +}); diff --git a/src/version.ts b/src/version.ts index 329a58b1..2eb6a52b 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const APP_VERSION = '4.48.6'; +export const APP_VERSION = '4.49.0';