diff --git a/frontend/src/components/lesson/InteractiveDiffViewer.tsx b/frontend/src/components/lesson/InteractiveDiffViewer.tsx new file mode 100644 index 00000000..f6d3f7c7 --- /dev/null +++ b/frontend/src/components/lesson/InteractiveDiffViewer.tsx @@ -0,0 +1,302 @@ +'use client'; + +import dynamic from 'next/dynamic'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Check, Columns2, CopyPlus, GitCompare, Loader2, Rows2 } from 'lucide-react'; +import { useDiffWorker } from '@/hooks/useDiffWorker'; +import type { DiffChunk } from '@/lib/diff/diffTypes'; + +/** + * InteractiveDiffViewer + * + * Side-by-side Monaco diff comparison for students to compare their working + * contract code against model solutions. Features: + * + * - **Inline / side-by-side toggles** via Monaco's `renderSideBySide`. + * - **Character-level highlighting** — Monaco's inline diff mode highlights + * changed characters, and the chunk list renders add/remove segments. + * - **Apply Diff Chunk** — each hunk gets a one-click control that merges the + * solution's lines for that hunk into the student buffer. + * - **Worker-backed diffing** — chunk computation runs in a Web Worker so + * large multi-file diffs never stutter the UI thread. + * - **Themes** — dark, light, and OLED Monaco themes (OLED defined on mount). + * + * The `DiffEditor` is pulled in via `next/dynamic` (`ssr: false`) to keep the + * server bundle lean, matching `LessonCodeEditor`. + */ + +const DiffEditor = dynamic(() => import('@monaco-editor/react').then((m) => m.DiffEditor), { + ssr: false, + loading: () => ( +
+
+
+

Loading diff editor…

+
+
+ ), +}); + +export type DiffTheme = 'dark' | 'light' | 'oled'; +export type DiffViewMode = 'side-by-side' | 'inline'; + +export interface InteractiveDiffViewerProps { + /** The student's current code (left / original side). */ + original: string; + /** The model solution (right / modified side). */ + modified: string; + /** Monaco language id (defaults to Rust for Soroban contracts). */ + language?: string; + /** Filename shown in the header. */ + filename?: string; + /** Active Monaco theme family. */ + theme?: DiffTheme; + /** Initial view mode. */ + defaultViewMode?: DiffViewMode; + /** Called with the merged buffer whenever the student applies a chunk. */ + onApplyChunk?: (chunk: DiffChunk, mergedCode: string) => void; +} + +const THEME_TO_MONACO: Record = { + dark: 'vs-dark', + light: 'light', + oled: 'oled-diff-theme', +}; + +function themeSurface(theme: DiffTheme): string { + if (theme === 'oled') return 'bg-black'; + if (theme === 'light') return 'bg-zinc-100'; + return 'bg-[#09090b]'; +} + +function themeBorder(theme: DiffTheme): string { + if (theme === 'light') return 'border-zinc-300'; + return 'border-white/10'; +} + +function themeText(theme: DiffTheme): string { + if (theme === 'light') return 'text-zinc-600'; + return 'text-gray-400'; +} + +export function InteractiveDiffViewer({ + original, + modified, + language = 'rust', + filename = 'lib.rs', + theme = 'dark', + defaultViewMode = 'side-by-side', + onApplyChunk, +}: InteractiveDiffViewerProps) { + const computeDiff = useDiffWorker(); + const [viewMode, setViewMode] = useState(defaultViewMode); + const [chunks, setChunks] = useState([]); + const [computing, setComputing] = useState(false); + const [appliedIds, setAppliedIds] = useState>(new Set()); + const [error, setError] = useState(null); + const [buffer, setBuffer] = useState(original); + + useEffect(() => { + setBuffer(original); + setAppliedIds(new Set()); + setError(null); + }, [original]); + + useEffect(() => { + let cancelled = false; + setComputing(true); + setError(null); + + // Run chunk computation off the main thread; Monaco renders independently. + computeDiff(original, modified) + .then((result) => { + if (cancelled) return; + setChunks(result.chunks); + }) + .catch(() => { + if (!cancelled) setError('Could not compute the diff.'); + }) + .finally(() => { + if (!cancelled) setComputing(false); + }); + + return () => { + cancelled = true; + }; + }, [original, modified, computeDiff]); + + const monacoTheme = THEME_TO_MONACO[theme]; + + const handleEditorMount = useCallback( + (_editor: unknown, monaco: typeof import('@monaco-editor/react') extends never ? never : any) => { + // Define the OLED theme once: pure black background, dim gray foreground. + if (!monaco.editor.getTheme || !monaco.editor.defineTheme) return; + if (!monaco.editor.getTheme('oled-diff-theme')) { + monaco.editor.defineTheme('oled-diff-theme', { + base: 'vs-dark', + inherit: true, + rules: [{ token: '', foreground: '9ca3af' }], + colors: { + 'editor.background': '#000000', + 'editor.foreground': '#9ca3af', + 'diffEditor.insertedTextBackground': '#052e16', + 'diffEditor.removedTextBackground': '#450a0a', + 'editor.lineHighlightBackground': '#000000', + }, + }); + } + }, + [] + ); + + const handleApplyChunk = useCallback( + (chunk: DiffChunk) => { + if (appliedIds.has(chunk.id)) return; + const nextApplied = new Set(appliedIds); + nextApplied.add(chunk.id); + setAppliedIds(nextApplied); + + // Replace the chunk's original lines with the solution lines in the buffer. + const originalLines = buffer.split('\n'); + const start = chunk.startLineOriginal - 1; + const end = start + chunk.originalLines.length; + const replacement = chunk.modifiedLines.map((line) => line.replace(/\n$/, '')); + const merged = [...originalLines.slice(0, start), ...replacement, ...originalLines.slice(end)].join('\n'); + setBuffer(merged); + onApplyChunk?.(chunk, merged); + }, + [appliedIds, buffer, onApplyChunk] + ); + + const remainingCount = useMemo( + () => chunks.filter((c) => !appliedIds.has(c.id)).length, + [chunks, appliedIds] + ); + + return ( +
+ {/* Toolbar */} +
+
+
+ +
+ {/* View mode toggle */} +
+ + +
+ + + {remainingCount === 0 ? 'All changes applied' : `${remainingCount} change${remainingCount === 1 ? '' : 's'} left`} + +
+
+ + {error && ( +
+ {error} +
+ )} + + {/* Monaco diff */} +
+ +
+ + {/* Apply-chunk controls */} + {chunks.length > 0 && ( +
+
+
+
    + {chunks.map((chunk) => { + const applied = appliedIds.has(chunk.id); + return ( +
  • + +
  • + ); + })} +
+
+ )} +
+ ); +} + +export default InteractiveDiffViewer; diff --git a/frontend/src/components/lesson/__tests__/InteractiveDiffViewer.test.tsx b/frontend/src/components/lesson/__tests__/InteractiveDiffViewer.test.tsx new file mode 100644 index 00000000..c3912f9d --- /dev/null +++ b/frontend/src/components/lesson/__tests__/InteractiveDiffViewer.test.tsx @@ -0,0 +1,120 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { cleanup, render, screen, fireEvent, waitFor } from '@testing-library/react'; +import React from 'react'; + +// Replace the dynamically-imported Monaco DiffEditor with a lightweight stand-in. +vi.mock('next/dynamic', () => ({ + default: () => { + const MockDiffEditor = (props: any) => ( +
+ +
+ ); + MockDiffEditor.displayName = 'MockDiffEditor'; + return MockDiffEditor; + }, +})); + +vi.mock('@/hooks/useDiffWorker', () => ({ + useDiffWorker: () => vi.fn(), +})); + +import { InteractiveDiffViewer, type DiffChunk } from '../InteractiveDiffViewer'; +import { useDiffWorker } from '@/hooks/useDiffWorker'; + +const chunks: DiffChunk[] = [ + { + id: 'chunk-0', + kind: 'replace', + startLineOriginal: 2, + startLineModified: 2, + originalLines: [' let a = 1;\n'], + modifiedLines: [' let a = 2;\n'], + segments: [{ text: 'let a =', kind: 'same' }, { text: '2', kind: 'add' }], + }, +]; + +const mockedComputeDiff = vi.mocked(vi.fn()); + +beforeEach(() => { + vi.clearAllMocks(); + (useDiffWorker as ReturnType).mockReturnValue(mockedComputeDiff); + mockedComputeDiff.mockResolvedValue({ chunks, identical: false }); +}); + +afterEach(() => cleanup()); + +describe('InteractiveDiffViewer', () => { + it('renders the Monaco diff with the requested language and theme', () => { + render( + + ); + const diff = screen.getByTestId('monaco-diff'); + expect(diff).toHaveAttribute('data-language', 'rust'); + expect(diff).toHaveAttribute('data-theme', 'vs-dark'); + expect(diff).toHaveAttribute('data-side-by-side', 'true'); + }); + + it('toggles between side-by-side and inline views', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /inline/i })); + expect(screen.getByTestId('monaco-diff')).toHaveAttribute('data-side-by-side', 'false'); + fireEvent.click(screen.getByRole('button', { name: /side-by-side/i })); + expect(screen.getByTestId('monaco-diff')).toHaveAttribute('data-side-by-side', 'true'); + }); + + it('maps the OLED theme to the custom Monaco theme', () => { + render(); + expect(screen.getByTestId('monaco-diff')).toHaveAttribute('data-theme', 'oled-diff-theme'); + }); + + it('computes chunks via the diff worker', async () => { + render(); + await waitFor(() => { + expect(mockedComputeDiff).toHaveBeenCalledWith('let a = 1;', 'let a = 2;'); + }); + expect(await screen.findByRole('button', { name: /apply chunk at line 2/i })).toBeInTheDocument(); + }); + + it('applies a chunk and merges solution lines into the buffer', async () => { + const onApplyChunk = vi.fn(); + render( + + ); + + const applyButton = await screen.findByRole('button', { name: /apply chunk at line 2/i }); + fireEvent.click(applyButton); + + await waitFor(() => { + expect(onApplyChunk).toHaveBeenCalled(); + const [, merged] = onApplyChunk.mock.calls[0] as [DiffChunk, string]; + expect(merged).toContain('let a = 2;'); + }); + // Button flips to the applied state. + expect(screen.getByRole('button', { name: /applied/i })).toBeInTheDocument(); + }); + + it('marks the diff as fully applied when every chunk is applied', async () => { + render(); + const applyButton = await screen.findByRole('button', { name: /apply chunk at line 2/i }); + fireEvent.click(applyButton); + expect(await screen.findByText(/all changes applied/i)).toBeInTheDocument(); + }); + + it('shows an error banner when the worker fails', async () => { + mockedComputeDiff.mockRejectedValue(new Error('boom')); + render(); + expect(await screen.findByRole('alert')).toHaveTextContent(/could not compute the diff/i); + }); +}); diff --git a/frontend/src/components/quiz/QuizEngine.tsx b/frontend/src/components/quiz/QuizEngine.tsx index 21f37544..fa96853d 100644 --- a/frontend/src/components/quiz/QuizEngine.tsx +++ b/frontend/src/components/quiz/QuizEngine.tsx @@ -83,6 +83,23 @@ export default function QuizEngine() { event.preventDefault(); }; + const handleReorderKey = ( + event: React.KeyboardEvent, + index: number + ) => { + const { dragOrder } = current.context; + if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return; + + const targetIndex = + event.key === 'ArrowUp' ? index - 1 : index + 1; + if (targetIndex < 0 || targetIndex >= dragOrder.length) return; + + event.preventDefault(); + const nextOrder = [...dragOrder]; + [nextOrder[index], nextOrder[targetIndex]] = [nextOrder[targetIndex], nextOrder[index]]; + send({ type: 'UPDATE_ORDER', order: nextOrder }); + }; + return (
@@ -212,7 +229,9 @@ export default function QuizEngine() { onDragStart={(event) => handleDragStart(event, index)} onDragOver={handleDragOver} onDrop={(event) => handleDrop(event, index)} - className="rounded-3xl border border-white/10 bg-white/5 px-5 py-4 text-left text-base text-gray-200 transition hover:border-red-500/40 hover:bg-white/10" + onKeyDown={(event) => handleReorderKey(event, index)} + aria-label={`${segment}. Step ${index + 1} of ${current.context.dragOrder.length}. Use Arrow Up or Arrow Down to reorder.`} + className="rounded-3xl border border-white/10 bg-white/5 px-5 py-4 text-left text-base text-gray-200 transition hover:border-red-500/40 hover:bg-white/10 focus:border-red-500 focus:outline-none" > Step {index + 1} @@ -315,13 +334,26 @@ export default function QuizEngine() {

Time Remaining

-
+
-

{timeLeft}s

+

+ {timeLeft}s +

diff --git a/frontend/src/components/simulator/BlockchainExplorer.tsx b/frontend/src/components/simulator/BlockchainExplorer.tsx index 3f3ecf22..b465db1b 100644 --- a/frontend/src/components/simulator/BlockchainExplorer.tsx +++ b/frontend/src/components/simulator/BlockchainExplorer.tsx @@ -52,13 +52,22 @@ function ConnectionDot({ status }: { status: string }) { function TxRow({ tx }: { tx: ExplorerTransaction }) { const [expanded, setExpanded] = useState(false); + const toggleExpanded = () => setExpanded((v) => !v); + return ( <> setExpanded((v) => !v)} + onClick={toggleExpanded} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggleExpanded(); + } + }} + tabIndex={0} aria-expanded={expanded} - aria-label={`Transaction ${tx.hash}, status ${tx.status}`} + aria-label={`Transaction ${tx.hash}, status ${tx.status}. Activate to ${expanded ? 'collapse' : 'expand'} details.`} > {tx.hash.slice(0, 8)}… {tx.operation} diff --git a/frontend/src/components/simulator/NodeDetailPanel.tsx b/frontend/src/components/simulator/NodeDetailPanel.tsx index fa95e6f3..cffff909 100644 --- a/frontend/src/components/simulator/NodeDetailPanel.tsx +++ b/frontend/src/components/simulator/NodeDetailPanel.tsx @@ -1,6 +1,6 @@ import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'; import { Activity, ExternalLink, Shield, Wallet, X } from 'lucide-react'; -import React from 'react'; +import React, { useEffect, useRef } from 'react'; import { NetworkNode } from '../../lib/visualization/ForceSimulation'; interface NodeDetailPanelProps { @@ -8,12 +8,62 @@ interface NodeDetailPanelProps { onClose: () => void; } +const FOCUSABLE_SELECTOR = + 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; + export const NodeDetailPanel: React.FC = ({ node, onClose }) => { const shouldReduceMotion = useReducedMotion(); + const panelRef = useRef(null); + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; + + // Keyboard operability: move focus into the panel on open, trap Tab + // inside it, restore focus on close, and let Escape dismiss the dialog. + useEffect(() => { + const panel = panelRef.current; + if (!panel) return; + + const previouslyFocused = document.activeElement as HTMLElement | null; + const closeButton = panel.querySelector('button[aria-label^="Close"]'); + closeButton?.focus(); + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + onCloseRef.current(); + return; + } + + if (event.key !== 'Tab') return; + + const focusable = Array.from(panel.querySelectorAll(FOCUSABLE_SELECTOR)); + if (focusable.length === 0) return; + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + const active = document.activeElement; + + if (event.shiftKey && active === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && active === last) { + event.preventDefault(); + first.focus(); + } + }; + + panel.addEventListener('keydown', handleKeyDown); + + return () => { + panel.removeEventListener('keydown', handleKeyDown); + previouslyFocused?.focus(); + }; + }, []); return ( ; + return ( + <> + + {/* Visually hidden live region so screen readers announce value changes */} + + {`${label}: ${Math.round(value)}${unit}`} + + + ); } diff --git a/frontend/src/components/stellar-scp/SCPVisualizer.tsx b/frontend/src/components/stellar-scp/SCPVisualizer.tsx index f589b583..9e4500ae 100644 --- a/frontend/src/components/stellar-scp/SCPVisualizer.tsx +++ b/frontend/src/components/stellar-scp/SCPVisualizer.tsx @@ -366,11 +366,28 @@ export const SCPVisualizer: React.FC = () => { scpState.nodes.filter((n) => !n.failed).length < Math.ceil(scpState.nodes.length * 0.66); + const handleToggleNodeFailureKey = ( + event: React.KeyboardEvent, + nodeId: string + ) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleToggleNodeFailure(nodeId); + } + }; + return (
+ {/* Screen reader announcements for simulation state changes */} +
+ {`${scpState.phase} phase, round ${scpState.round}, step ${scpState.step}.`} + {consensusReached ? ' Consensus reached.' : ''} + {consensusFailed ? ' Consensus failed.' : ''} +
+ {/* Header */}

@@ -456,10 +473,14 @@ export const SCPVisualizer: React.FC = () => { {/* Speed Control */}
-
{/* Node Status */} @@ -480,8 +503,13 @@ export const SCPVisualizer: React.FC = () => { {scpState.nodes.map((node) => (
handleToggleNodeFailure(node.id)} + onKeyDown={(event) => handleToggleNodeFailureKey(event, node.id)} + aria-pressed={node.failed} + aria-label={`${node.label}. Currently ${node.failed ? 'failed' : node.state}. Activate to toggle failure state.`} title={`Click to toggle failure state. Currently: ${node.failed ? 'FAILED' : node.state.toUpperCase()}`} >
Promise { + const workerRef = useRef(null); + const pendingRef = useRef(new Map void>()); + const requestIdRef = useRef(0); + + const getWorker = useCallback((): Worker => { + if (workerRef.current) return workerRef.current; + const worker = new Worker(new URL('../lib/diff/diff.worker.ts', import.meta.url), { + type: 'module', + }); + worker.addEventListener('message', (event: MessageEvent) => { + const msg = event.data; + if (msg.type === 'diff-result') { + const resolve = pendingRef.current.get(msg.requestId); + if (resolve) { + pendingRef.current.delete(msg.requestId); + resolve(msg.result); + } + } else if (msg.type === 'diff-error') { + const resolve = pendingRef.current.get(msg.requestId); + if (resolve) { + pendingRef.current.delete(msg.requestId); + resolve({ chunks: [], identical: true }); + } + } + }); + workerRef.current = worker; + return worker; + }, []); + + // Terminate the worker on unmount so no work leaks into the next page. + useEffect(() => { + const worker = workerRef.current; + return () => { + worker?.terminate(); + pendingRef.current.clear(); + }; + }, []); + + return useCallback( + (original: string, modified: string): Promise => { + if (original === modified) { + return Promise.resolve({ chunks: [], identical: true }); + } + const worker = getWorker(); + const requestId = `diff-${++requestIdRef.current}`; + return new Promise((resolve) => { + pendingRef.current.set(requestId, resolve); + const payload: DiffWorkerRequest = { type: 'diff', requestId, original, modified }; + worker.postMessage(payload); + }); + }, + [getWorker] + ); +} diff --git a/frontend/src/lib/diff/diff.worker.ts b/frontend/src/lib/diff/diff.worker.ts new file mode 100644 index 00000000..ced53022 --- /dev/null +++ b/frontend/src/lib/diff/diff.worker.ts @@ -0,0 +1,132 @@ +/// + +import { diff_match_patch as DiffMatchPatch } from 'diff-match-patch'; +import type { + DiffChunk, + DiffSegment, + DiffWorkerRequest, + DiffWorkerResponse, +} from './diffTypes'; + +type WorkerSelf = typeof self & { postMessage(message: DiffWorkerResponse): void }; +const workerSelf = self as WorkerSelf; + +const dmp = new DiffMatchPatch(); + +/** Split a diff op's text into lines, keeping trailing newlines on each line. */ +function splitLines(text: string): string[] { + if (text.length === 0) return []; + return text.split(/(?<=\n)/); +} + +/** + * Compute character-level segments for a chunk by re-diffing its removed and + * added text. Kept separate so Monaco decorations and the chunk list can both + * render inline add/remove highlights. + */ +function charSegments(originalText: string, modifiedText: string): DiffSegment[] { + const ops = dmp.diff_main(originalText, modifiedText); + const segments: DiffSegment[] = []; + for (const [op, text] of ops) { + if (!text) continue; + if (op === 0) segments.push({ text, kind: 'same' }); + else if (op === 1) segments.push({ text, kind: 'add' }); + else segments.push({ text, kind: 'remove' }); + } + return segments; +} + +/** + * Compute a line-level chunk model from two code strings. + * + * Uses `diff_linesToChars` (O(ND) on lines) so large multi-file comparisons + * stay fast, then maps the line tokens back to concrete strings. Equal + * (context) regions advance both line counters; contiguous deletions and + * insertions between context regions merge into a single `replace` chunk, + * which is what the "apply chunk" control treats as a unit. + */ +export function computeDiff(original: string, modified: string): { + chunks: DiffChunk[]; + identical: boolean; +} { + if (original === modified) { + return { chunks: [], identical: true }; + } + + const chars = dmp.diff_linesToChars(original, modified); + const lineOps = dmp.diff_main(chars.chars1, chars.chars2, false); + dmp.diff_charsToLines(lineOps, chars.lineArray); + + const chunks: DiffChunk[] = []; + let originalLine = 1; + let modifiedLine = 1; + + let regionStartOriginal = 1; + let regionStartModified = 1; + let pendingOriginal: string[] = []; + let pendingModified: string[] = []; + + const flush = (): void => { + if (pendingOriginal.length === 0 && pendingModified.length === 0) return; + const kind = + pendingOriginal.length > 0 && pendingModified.length > 0 + ? 'replace' + : pendingModified.length > 0 + ? 'add' + : 'remove'; + chunks.push({ + id: `chunk-${chunks.length}`, + kind, + startLineOriginal: regionStartOriginal, + startLineModified: regionStartModified, + originalLines: pendingOriginal, + modifiedLines: pendingModified, + segments: charSegments(pendingOriginal.join(''), pendingModified.join('')), + }); + pendingOriginal = []; + pendingModified = []; + }; + + for (const [op, text] of lineOps) { + const lines = splitLines(text); + if (op === 0) { + // Context region: flush any pending changes, then advance both counters. + flush(); + originalLine += lines.length; + modifiedLine += lines.length; + continue; + } + if (pendingOriginal.length === 0 && pendingModified.length === 0) { + regionStartOriginal = originalLine; + regionStartModified = modifiedLine; + } + if (op === 1) { + pendingModified.push(...lines); + modifiedLine += lines.length; + } else { + pendingOriginal.push(...lines); + originalLine += lines.length; + } + } + flush(); + + return { chunks, identical: false }; +} + +workerSelf.addEventListener('message', (event: MessageEvent) => { + const msg = event.data; + if (msg.type !== 'diff') return; + const { requestId, original, modified } = msg; + try { + const result = computeDiff(original, modified); + workerSelf.postMessage({ type: 'diff-result', requestId, result }); + } catch (err) { + workerSelf.postMessage({ + type: 'diff-error', + requestId, + message: err instanceof Error ? err.message : 'Diff computation failed.', + }); + } +}); + +export {}; diff --git a/frontend/src/lib/diff/diffTypes.ts b/frontend/src/lib/diff/diffTypes.ts new file mode 100644 index 00000000..ba3e8a39 --- /dev/null +++ b/frontend/src/lib/diff/diffTypes.ts @@ -0,0 +1,52 @@ +/** + * Shared message and result types for the interactive diff viewer. + * + * Diff computation is offloaded to a Web Worker (`diff.worker.ts`) so large + * multi-file comparisons never block the UI thread. The worker returns line + * chunks with character-level segments; Monaco renders the visual diff while + * the chunk list powers the one-click "Apply Diff Chunk" controls. + */ + +/** Character-level segment inside a chunk (used for inline highlighting). */ +export interface DiffSegment { + text: string; + kind: 'same' | 'add' | 'remove'; +} + +/** A contiguous hunk of changed lines between original and modified code. */ +export interface DiffChunk { + id: string; + /** `replace` when the hunk has both removed and added lines. */ + kind: 'add' | 'remove' | 'replace'; + /** 1-based start line in the original document. */ + startLineOriginal: number; + /** 1-based start line in the modified document. */ + startLineModified: number; + /** Lines removed from the original (empty for pure adds). */ + originalLines: string[]; + /** Lines added in the modified document (empty for pure removes). */ + modifiedLines: string[]; + /** Character-level segments spanning the hunk. */ + segments: DiffSegment[]; +} + +/** Result of a diff computation. */ +export interface DiffResult { + /** Line hunks, in document order. */ + chunks: DiffChunk[]; + /** Whether the two inputs are identical. */ + identical: boolean; +} + +/** Inbound request sent to the diff worker. */ +export interface DiffWorkerRequest { + type: 'diff'; + requestId: string; + original: string; + modified: string; +} + +/** Outbound response posted by the diff worker. */ +export type DiffWorkerResponse = + | { type: 'diff-result'; requestId: string; result: DiffResult } + | { type: 'diff-error'; requestId: string; message: string }; diff --git a/frontend/src/test/simulator-accessibility.test.tsx b/frontend/src/test/simulator-accessibility.test.tsx new file mode 100644 index 00000000..eda98556 --- /dev/null +++ b/frontend/src/test/simulator-accessibility.test.tsx @@ -0,0 +1,145 @@ +/** + * Accessibility tests for the interactive simulators. + * + * Covers the WCAG 2.1 AA requirements from the simulator accessibility + * milestone: axe-core scans for critical/serious violations, aria-live + * announcements for real-time updates, and keyboard operability (focus + * management + Escape dismissal) for the slide-out node detail dialog. + * + * Run: npm test + */ +import { fireEvent, render, screen } from "@testing-library/react"; +import { axe, toHaveNoViolations } from "jest-axe"; +import React from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +expect.extend(toHaveNoViolations); + +// --------------------------------------------------------------------------- +// Module mocks – must appear before any component imports +// --------------------------------------------------------------------------- + +vi.mock("framer-motion", async () => { + const actual = await vi.importActual("framer-motion"); + return { + ...actual, + AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}, + // jsdom has no matchMedia, so useReducedMotion would throw – stub it. + useReducedMotion: () => false, + motion: { + div: ({ children, ...props }: any) =>
{children}
, + }, + }; +}); + +// --------------------------------------------------------------------------- +// Import components after mocks are set up +// --------------------------------------------------------------------------- +import { BlockchainExplorer } from "@/components/simulator/BlockchainExplorer"; +import { NodeDetailPanel } from "@/components/simulator/NodeDetailPanel"; +import { ResourceGauge } from "@/components/simulator/ResourceGauge"; +import type { NetworkNode } from "@/lib/visualization/ForceSimulation"; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const mockNode: NetworkNode = { + id: "GBRPYHIL2CI3FYQMWVUGE62KMGOBQKLCYJ3HLKBUBIW5VZH4S4MNOWT", + type: "account", + label: "Test Account", +}; + +// --------------------------------------------------------------------------- +// 1. BlockchainExplorer – live region + axe scan +// --------------------------------------------------------------------------- +describe("BlockchainExplorer – live updates and automated scan", () => { + it("announces ledger updates via an aria-live region", () => { + render(); + const table = screen.getByRole("table", { name: /transaction list/i }); + expect(table).toHaveAttribute("aria-live", "polite"); + }); + + it("exposes the connection status to assistive technology", () => { + render(); + const status = screen.getByRole("status"); + expect(status).toHaveAttribute("aria-live", "polite"); + }); + + it("has no axe violations", async () => { + const { container } = render(); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); +}); + +// --------------------------------------------------------------------------- +// 2. ResourceGauge – live value announcement + axe scan +// --------------------------------------------------------------------------- +describe("ResourceGauge – live value announcements and automated scan", () => { + it("announces value changes through a polite live region", () => { + render(); + expect(screen.getByText("CPU: 62%")).toBeInTheDocument(); + expect(screen.getByRole("status")).toHaveAttribute("aria-live", "polite"); + }); + + it("labels the canvas as a graphical object", () => { + render(); + expect(screen.getByRole("img", { name: /cpu resource gauge/i })).toBeInTheDocument(); + }); + + it("has no axe violations", async () => { + const { container } = render( + + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); +}); + +// --------------------------------------------------------------------------- +// 3. NodeDetailPanel – dialog focus management + Escape dismissal +// --------------------------------------------------------------------------- +describe("NodeDetailPanel – keyboard operability", () => { + const onClose = vi.fn(); + + beforeEach(() => { + onClose.mockClear(); + }); + + it("moves focus to the close button when the dialog opens", () => { + render(); + expect(screen.getByRole("button", { name: /close account details panel/i })).toHaveFocus(); + }); + + it("closes the dialog when Escape is pressed", () => { + render(); + fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape", code: "Escape" }); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("traps Tab focus within the dialog", () => { + render(); + const dialog = screen.getByRole("dialog"); + + // Focus the last focusable element (View on Explorer)… + const viewButton = screen.getByRole("button", { name: /view account .* on stellar explorer/i }); + viewButton.focus(); + expect(viewButton).toHaveFocus(); + + // …and Tab should wrap back to the first focusable element (close). + fireEvent.keyDown(dialog, { key: "Tab", code: "Tab" }); + expect(screen.getByRole("button", { name: /close account details panel/i })).toHaveFocus(); + }); + + it("marks the dialog as modal for assistive technology", () => { + render(); + expect(screen.getByRole("dialog")).toHaveAttribute("aria-modal", "true"); + }); + + it("has no axe violations", async () => { + const { container } = render(); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); +});