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
16 changes: 12 additions & 4 deletions src/components/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -13,11 +15,17 @@ type Props = {
const App = ({ workerManager, apple1Instance }: Props): JSX.Element => {
return (
<ErrorBoundary>
<DebuggerNavigationProvider>
<AppContent workerManager={workerManager} {...(apple1Instance !== undefined && { apple1Instance })} />
</DebuggerNavigationProvider>
<ToastProvider>
<DebuggerNavigationProvider>
<AppContent
workerManager={workerManager}
{...(apple1Instance !== undefined && { apple1Instance })}
/>
</DebuggerNavigationProvider>
<ToastContainer />
</ToastProvider>
</ErrorBoundary>
);
};

export default App;
export default App;
18 changes: 12 additions & 6 deletions src/components/AppContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -43,6 +44,7 @@ const AppContentInner = ({
const [isSwitchingEngine, setIsSwitchingEngine] = useState<boolean>(false);
const hiddenInputRef = useRef<HTMLInputElement>(null);
const { subscribeToNavigation } = useDebuggerNavigation();
const { showToast } = useToast();
const { safeSetTimeout } = useUnmountSafe();

// Persist debugger view states across tab switches
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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(
Expand All @@ -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 (
Expand Down
49 changes: 49 additions & 0 deletions src/components/ToastContainer.tsx
Original file line number Diff line number Diff line change
@@ -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<ToastVariant, string> = {
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 (
<div className="fixed bottom-md right-md z-50 flex flex-col gap-sm pointer-events-none">
{toasts.map((toast) => (
<div
key={toast.id}
role="status"
aria-live="polite"
data-testid="toast"
data-variant={toast.variant}
className={`pointer-events-auto flex items-center gap-sm rounded-lg border px-md py-sm text-xs font-mono shadow-lg ${VARIANT_CLASSES[toast.variant]}`}
>
<span className="flex-1">{toast.message}</span>
<button
type="button"
aria-label="Dismiss notification"
className="font-bold opacity-70 hover:opacity-100"
onClick={() => dismiss(toast.id)}
>
×
</button>
</div>
))}
</div>
);
};

export default ToastContainer;
117 changes: 117 additions & 0 deletions src/components/__tests__/AppContent-ui-toast-feedback.vitest.test.tsx
Original file line number Diff line number Diff line change
@@ -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: () => <div data-testid="crt-worker" /> }));
vi.mock('../Info', () => ({ __esModule: true, default: () => <div /> }));
vi.mock('../InspectorView', () => ({ __esModule: true, default: () => <div /> }));
vi.mock('../DebuggerLayout', () => ({ __esModule: true, default: () => <div /> }));

// Expose the engine-switch handler through a simple button.
vi.mock('../ExecutionControlsCluster', () => ({
__esModule: true,
default: ({ onEngineSwitch }: { onEngineSwitch: () => void }) => (
<button data-testid="engine-switch-btn" onClick={onEngineSwitch}>
switch-engine
</button>
),
}));

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(
<ToastProvider>
<AppContent workerManager={wm} />
<ToastContainer />
</ToastProvider>,
);

describe('AppContent toast feedback', () => {
let wm: WorkerManager;
let alertSpy: ReturnType<typeof vi.spyOn>;

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();
});
});
4 changes: 4 additions & 0 deletions src/components/__tests__/AppContent.vitest.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<button onClick={() => showToast('first', 'info')}>push-first</button>
<button onClick={() => showToast('second', 'success')}>push-second</button>
{siblingOnClick && <button onClick={siblingOnClick}>sibling</button>}
<ToastContainer />
</div>
);
};

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(
<ToastProvider>
<Harness />
</ToastProvider>,
);

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(
<ToastProvider>
<Harness siblingOnClick={siblingOnClick} />
</ToastProvider>,
);

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);
});
});
Loading