diff --git a/README.md b/README.md index b81ba15..04ed84e 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ The application is an educational simulator, not a navigation service. Its built - **Story Mode (`/`)** introduces graphs, route objectives, search strategies, source concepts, scenarios, and trace architecture through responsive interactive demonstrations. - **Algorithm Lab (`/lab`)** runs one of seven algorithms with scenario, cost-model, heuristic, language, closure, speed, and timeline controls. It includes metrics, frontier/current-event inspection, node details, and an accessible graph table. -- **Compare Mode (`/compare`)** synchronizes two to four compatible algorithms on the same graph and playback clock (two lanes by default). +- **Compare Mode (`/compare`)** synchronizes two to four compatible algorithms on the same graph, with playback from 0.5× to 100× and a measured real-time option (two lanes by default). - **Code Explorer (`/code`)** presents curated read-only Dijkstra excerpts in six languages with conceptual line highlighting, copy, and download actions. - **Documentation (`/docs`)** provides an in-product guide; the [`docs/`](docs/) directory contains the detailed engineering reference. diff --git a/app/layout.tsx b/app/layout.tsx index 7cea199..1f6b5ef 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -39,7 +39,10 @@ export async function generateMetadata(): Promise { authors: [{ name: "RouteLab" }], creator: "RouteLab", icons: { - icon: "/favicon.ico", + icon: [ + { url: "/favicon.svg", type: "image/svg+xml" }, + { url: "/favicon.ico", sizes: "any" }, + ], shortcut: "/favicon.ico", }, openGraph: { diff --git a/components/CompareWorkspace.module.css b/components/CompareWorkspace.module.css index ccc5c31..4e19070 100644 --- a/components/CompareWorkspace.module.css +++ b/components/CompareWorkspace.module.css @@ -453,7 +453,9 @@ .graphFrame { position: relative; + height: 266px; min-height: 266px; + overflow: hidden; border-block: 1px solid var(--line); background: linear-gradient(var(--bg-elevated) 1px, transparent 1px), @@ -471,15 +473,19 @@ .graphFrame :global(.graph-canvas), .graphFrame :global(.graph-visual) { - height: 266px; + height: 100%; + min-height: 0; } .graphFrame :global(.graph-visual) { position: relative; } -.graphFrame svg { +.graphFrame :global(.graph-visual) > svg { + position: absolute; display: block; + inset: 0; + box-sizing: border-box; width: 100%; height: 100%; padding: 18px; @@ -721,7 +727,7 @@ .timeline { display: grid; - grid-template-columns: auto minmax(220px, 1fr) 90px; + grid-template-columns: auto minmax(220px, 1fr) minmax(128px, auto); gap: 22px; align-items: center; width: min(1540px, calc(100% - 48px)); @@ -809,6 +815,15 @@ font-size: 11px; } +.speedField small { + margin-top: 2px; + color: var(--text-dim); + font-family: var(--font-geist-mono), monospace; + font-size: 7px; + line-height: 1.2; + white-space: nowrap; +} + @media (max-width: 1280px) { .intro, .configurationInner, @@ -1001,9 +1016,7 @@ text-overflow: ellipsis; } - .graphFrame, - .graphFrame :global(.graph-canvas), - .graphFrame :global(.graph-visual) { + .graphFrame { min-height: 235px; height: 235px; } diff --git a/components/CompareWorkspace.tsx b/components/CompareWorkspace.tsx index 9c00341..3e23254 100644 --- a/components/CompareWorkspace.tsx +++ b/components/CompareWorkspace.tsx @@ -1,6 +1,12 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; import { GraphCanvas } from "@/components/GraphCanvas"; import { @@ -15,6 +21,15 @@ import { type HeuristicId, type RunOptions, } from "@/lib/algorithms"; +import { + formatExecutionTime, + getPlaybackDurationMs, + getRealtimeLaneStep, + getTimelineStepAtElapsed, + parsePlaybackMode, + PLAYBACK_SPEEDS, + type PlaybackMode, +} from "@/lib/compare-playback"; import { BUILT_IN_SCENARIOS, SCENARIO_LIMITS, @@ -25,7 +40,6 @@ import { import styles from "./CompareWorkspace.module.css"; const DEFAULT_ALGORITHMS: readonly AlgorithmId[] = ["dijkstra", "astar"]; -const PLAYBACK_SPEEDS = [0.5, 1, 2, 4] as const; const COST_LABELS: Readonly> = { weight: "Edge weight", @@ -48,6 +62,10 @@ const OPTIMALITY_LABELS: Readonly> = { unreachable: "Destination unreachable", }; +const subscribeToHydration = () => () => undefined; +const getHydratedSnapshot = () => true; +const getServerHydratedSnapshot = () => false; + interface ComparisonRun { readonly algorithmId: AlgorithmId; readonly result: AlgorithmResult | null; @@ -216,8 +234,18 @@ export function CompareWorkspace({ resolvedInitialHeuristic, ); const [step, setStep] = useState(0); + const stepRef = useRef(0); const [isPlaying, setIsPlaying] = useState(false); - const [playbackSpeed, setPlaybackSpeed] = useState(1); + const [playbackMode, setPlaybackMode] = useState(1); + const [realtimeElapsedMs, setRealtimeElapsedMs] = useState( + null, + ); + const realtimeElapsedRef = useRef(0); + const hydrated = useSyncExternalStore( + subscribeToHydration, + getHydratedSnapshot, + getServerHydratedSnapshot, + ); const [copyState, setCopyState] = useState<"idle" | "copied" | "address-bar">( "idle", ); @@ -257,8 +285,20 @@ export function CompareWorkspace({ (run): run is ComparisonRun & { result: AlgorithmResult } => run.result !== null, ); + const maxExecutionTimeMs = Math.max( + 0, + ...validRuns.map((run) => run.result.metrics.executionTimeMs), + ); + const resolveLaneStep = (result: AlgorithmResult) => + playbackMode === "realtime" && realtimeElapsedMs !== null + ? getRealtimeLaneStep({ + elapsedMs: realtimeElapsedMs, + executionTimeMs: result.metrics.executionTimeMs, + traceLength: result.trace.length, + }) + : Math.min(step, Math.max(0, result.trace.length - 1)); const completedRunCount = validRuns.filter( - (run) => step >= run.result.trace.length - 1, + (run) => resolveLaneStep(run.result) >= run.result.trace.length - 1, ).length; const sharedComplete = validRuns.length > 0 && step >= maxStep; const successfulRuns = validRuns.filter((run) => run.result.found); @@ -274,28 +314,73 @@ export function CompareWorkspace({ useEffect(() => { if (!isPlaying) return undefined; - const interval = window.setInterval( - () => { - setStep((currentStep) => { - const nextStep = Math.min(maxStep, currentStep + 1); - if (nextStep >= maxStep) { - setIsPlaying(false); - setAnnouncement( - `Comparison finished. ${validRuns.length} traces are complete.`, - ); - } - return nextStep; - }); - }, - Math.max(70, Math.round(680 / playbackSpeed)), - ); + const startStep = Math.min(maxStep, stepRef.current); + const realtimeStartElapsedMs = + playbackMode === "realtime" + ? Math.min(maxExecutionTimeMs, realtimeElapsedRef.current) + : 0; + const durationMs = getPlaybackDurationMs({ + startStep, + maxStep, + mode: playbackMode, + realtimeDurationMs: maxExecutionTimeMs, + realtimeStartElapsedMs, + }); + let frameId = 0; + const startedAt = window.performance.now(); + let renderedStep = startStep; - return () => window.clearInterval(interval); - }, [isPlaying, maxStep, playbackSpeed, validRuns.length]); + const tick = (timestamp: number) => { + const elapsedMs = Math.max(0, timestamp - startedAt); + + if (playbackMode === "realtime") { + const totalElapsedMs = + maxExecutionTimeMs <= 0 + ? 0 + : Math.min(maxExecutionTimeMs, realtimeStartElapsedMs + elapsedMs); + realtimeElapsedRef.current = totalElapsedMs; + setRealtimeElapsedMs(totalElapsedMs); + } + + const nextStep = getTimelineStepAtElapsed({ + startStep, + maxStep, + elapsedMs, + durationMs, + }); + if (nextStep !== renderedStep) { + renderedStep = nextStep; + stepRef.current = nextStep; + setStep(nextStep); + } + + if (elapsedMs >= durationMs) { + stepRef.current = maxStep; + setStep(maxStep); + if (playbackMode === "realtime") { + realtimeElapsedRef.current = maxExecutionTimeMs; + setRealtimeElapsedMs(maxExecutionTimeMs); + } + setIsPlaying(false); + setAnnouncement( + `Comparison finished. ${validRuns.length} traces are complete.`, + ); + return; + } + + frameId = window.requestAnimationFrame(tick); + }; + + frameId = window.requestAnimationFrame(tick); + return () => window.cancelAnimationFrame(frameId); + }, [isPlaying, maxExecutionTimeMs, maxStep, playbackMode, validRuns.length]); function resetTimeline(message: string) { setIsPlaying(false); + stepRef.current = 0; setStep(0); + realtimeElapsedRef.current = 0; + setRealtimeElapsedMs(null); setCopyState("idle"); setAnnouncement(message); } @@ -361,19 +446,66 @@ export function CompareWorkspace({ setAnnouncement(`Playback paused at shared step ${step + 1}.`); return; } - if (sharedComplete) setStep(0); + const startStep = sharedComplete ? 0 : stepRef.current; + if (sharedComplete) { + stepRef.current = 0; + setStep(0); + } + if (playbackMode === "realtime") { + const elapsedMs = sharedComplete + ? 0 + : (realtimeElapsedMs ?? + (maxStep > 0 ? (startStep / maxStep) * maxExecutionTimeMs : 0)); + realtimeElapsedRef.current = elapsedMs; + setRealtimeElapsedMs(elapsedMs); + } setIsPlaying(true); setAnnouncement( - sharedComplete ? "Comparison replay started." : "Playback started.", + playbackMode === "realtime" + ? `Real-time playback started. Slowest measured compute time: ${formatExecutionTime(maxExecutionTimeMs)}.` + : sharedComplete + ? "Comparison replay started." + : `Playback started at ${playbackMode} times speed.`, ); } function moveToStep(nextStep: number, message: string) { + const resolvedStep = Math.min(maxStep, Math.max(0, nextStep)); setIsPlaying(false); - setStep(Math.min(maxStep, Math.max(0, nextStep))); + stepRef.current = resolvedStep; + setStep(resolvedStep); + if (playbackMode === "realtime") { + const elapsedMs = + maxStep > 0 ? (resolvedStep / maxStep) * maxExecutionTimeMs : 0; + realtimeElapsedRef.current = elapsedMs; + setRealtimeElapsedMs(elapsedMs); + } else { + realtimeElapsedRef.current = 0; + setRealtimeElapsedMs(null); + } setAnnouncement(message); } + function handlePlaybackModeChange(value: string) { + const nextMode = parsePlaybackMode(value); + setPlaybackMode(nextMode); + if (nextMode === "realtime") { + const elapsedMs = + maxStep > 0 ? (stepRef.current / maxStep) * maxExecutionTimeMs : 0; + realtimeElapsedRef.current = elapsedMs; + setRealtimeElapsedMs(elapsedMs); + setAnnouncement( + validRuns.length === 0 + ? "Real-time mode selected, but no compatible runs are available." + : `Real-time mode selected. Slowest measured compute time: ${formatExecutionTime(maxExecutionTimeMs)}.`, + ); + } else { + realtimeElapsedRef.current = 0; + setRealtimeElapsedMs(null); + setAnnouncement(`Playback speed changed to ${nextMode} times.`); + } + } + async function copyConfiguration() { const url = new URL(window.location.href); url.searchParams.set("scenario", scenario.id); @@ -579,12 +711,10 @@ export function CompareWorkspace({ {runs.map((run, index) => { const info = ALGORITHM_INFO[run.algorithmId]; const result = run.result; - const laneStep = result - ? Math.min(step, Math.max(0, result.trace.length - 1)) - : 0; + const laneStep = result ? resolveLaneStep(result) : 0; const event = result?.trace[laneStep] ?? null; const laneComplete = result - ? step >= result.trace.length - 1 + ? laneStep >= result.trace.length - 1 : false; return ( @@ -682,7 +812,10 @@ export function CompareWorkspace({ {result ? ( <> -
+
+
+
Max frontier
+
+ {result.metrics.maximumFrontierSize.toLocaleString( + "en-US", + )} +
+
Trace events
@@ -738,6 +879,19 @@ export function CompareWorkspace({ )}
+
+
Compute
+
+ {hydrated + ? formatExecutionTime( + result.metrics.executionTimeMs, + ) + : "—"} +
+
@@ -861,15 +1015,24 @@ export function CompareWorkspace({
diff --git a/docs/animation-system.md b/docs/animation-system.md index 0e9fab8..bbd3050 100644 --- a/docs/animation-system.md +++ b/docs/animation-system.md @@ -76,9 +76,9 @@ No algorithm is rerun during timeline navigation. A new trace is generated only ## Playback controller -Autoplay owns one cancelable browser timer. Each tick advances by one semantic event until the final cursor, then stops. Changing route, scenario, algorithm, cost model, heuristic, closure state, or trace cancels the prior timer. Starting playback at the end restarts from cursor zero only through an explicit replay action. +Autoplay owns one cancelable animation-frame loop. Elapsed wall-clock time determines the semantic cursor, so faster rates can advance several correctness events in one paint without distorting total playback time. Changing route, scenario, algorithm, cost model, heuristic, closure state, or trace cancels the prior loop. Starting playback at the end restarts from cursor zero only through an explicit replay action. -The speed control maps human-readable rates to timer cadence. It does not batch correctness events together. The browser may throttle timers in a background tab; on return, RouteLab continues from the current cursor rather than racing through wall-clock “missed” ticks. +The speed control maps human-readable rates from 0.5× through 100× to elapsed playback time. Real-time mode instead maps each lane's event stream across that algorithm's measured computation duration, while retaining the shared normalized progress controller. The browser may throttle animation frames in a background tab; on return, RouteLab derives the cursor from elapsed time and safely catches up to the correct position. Global keyboard shortcuts are scoped away from editable controls: @@ -92,9 +92,9 @@ Native buttons and the range input remain the authoritative accessible controls ## Compare synchronization -Compare Mode runs two to four distinct compatible algorithms independently against the same immutable graph, start, goal, closures, and cost metric. Each pane owns a trace/result; the controller owns playback, speed, and a shared step. +Compare Mode runs two to four distinct compatible algorithms independently against the same immutable graph, start, goal, closures, and cost metric. Each pane owns a trace/result; the controller owns playback mode and normalized progress. Fixed-speed playback uses one shared semantic step. Real-time playback resolves a lane-specific step from its measured computation duration, so quicker algorithms can visibly finish before slower ones. -At zero-based shared event index `s`, a pane selects `min(s, trace.length - 1)` and replays through that selected event. A shorter trace therefore stays at its completed state while a longer trace continues. Metrics remain the pane's real counters; RouteLab does not stretch or synthesize events to make animations look simultaneous. Reset and scrub apply to every pane atomically. +In fixed-speed playback, at zero-based shared event index `s`, a pane selects `min(s, trace.length - 1)` and replays through that selected event. A shorter trace therefore stays at its completed state while a longer trace continues. In real-time playback, each pane maps elapsed time across its own trace using that algorithm's measured total computation duration. Metrics remain the pane's real counters; RouteLab does not synthesize correctness events to make animations look simultaneous. Reset and scrub apply to every pane atomically. For comparisons with substantially different event counts, normalized-progress visualization can be added later, but it must be labeled because equal percentages do not represent equivalent algorithm decisions. diff --git a/lib/algorithms/index.ts b/lib/algorithms/index.ts index 86ffe95..311673d 100644 --- a/lib/algorithms/index.ts +++ b/lib/algorithms/index.ts @@ -1258,6 +1258,10 @@ interface ResultParts { readonly allPairs?: AllPairsShortestPaths; } +function monotonicNow(): number { + return typeof performance === "undefined" ? Date.now() : performance.now(); +} + function makeResult( options: ResolvedRunOptions, recorder: TraceRecorder, @@ -1268,7 +1272,7 @@ function makeResult( ): AlgorithmResult { const resultMetrics: AlgorithmMetrics = { ...metrics, - executionTimeMs: Math.max(0, Date.now() - startedAt), + executionTimeMs: Math.max(0, monotonicNow() - startedAt), traceEventCount: recorder.trace.length, }; return { @@ -1337,7 +1341,7 @@ function executeBreadthFirstSearch( options: ResolvedRunOptions, warnings: readonly CompatibilityIssue[], ): AlgorithmResult { - const startedAt = Date.now(); + const startedAt = monotonicNow(); const traversal = buildTraversalGraph(graph, options); const recorder = new TraceRecorder( options.algorithmId, @@ -1487,7 +1491,7 @@ function executeBestFirst( warnings: readonly CompatibilityIssue[], mode: BestFirstMode, ): AlgorithmResult { - const startedAt = Date.now(); + const startedAt = monotonicNow(); const traversal = buildTraversalGraph(graph, options); const goal = traversal.nodesById.get(options.goalId); if (!goal) throw new Error("Validated goal node is missing."); @@ -1749,7 +1753,7 @@ function executeBidirectionalDijkstra( options: ResolvedRunOptions, warnings: readonly CompatibilityIssue[], ): AlgorithmResult { - const startedAt = Date.now(); + const startedAt = monotonicNow(); const traversal = buildTraversalGraph(graph, options); const recorder = new TraceRecorder( options.algorithmId, @@ -2077,7 +2081,7 @@ function executeBellmanFord( options: ResolvedRunOptions, warnings: readonly CompatibilityIssue[], ): AlgorithmResult { - const startedAt = Date.now(); + const startedAt = monotonicNow(); const traversal = buildTraversalGraph(graph, options); const recorder = new TraceRecorder( options.algorithmId, @@ -2319,7 +2323,7 @@ function executeFloydWarshall( options: ResolvedRunOptions, warnings: readonly CompatibilityIssue[], ): AlgorithmResult { - const startedAt = Date.now(); + const startedAt = monotonicNow(); const traversal = buildTraversalGraph(graph, options); const recorder = new TraceRecorder( options.algorithmId, diff --git a/lib/compare-playback.ts b/lib/compare-playback.ts new file mode 100644 index 0000000..c30f886 --- /dev/null +++ b/lib/compare-playback.ts @@ -0,0 +1,74 @@ +export const BASE_EVENT_DURATION_MS = 680; +export const PLAYBACK_SPEEDS = [0.5, 1, 2, 4, 10, 25, 50, 100] as const; + +export type PlaybackSpeed = (typeof PLAYBACK_SPEEDS)[number]; +export type PlaybackMode = PlaybackSpeed | "realtime"; + +export function parsePlaybackMode(value: string): PlaybackMode { + if (value === "realtime") return value; + const speed = Number(value) as PlaybackSpeed; + return PLAYBACK_SPEEDS.includes(speed) ? speed : 1; +} + +export function getPlaybackDurationMs({ + startStep, + maxStep, + mode, + realtimeDurationMs, + realtimeStartElapsedMs = 0, +}: { + startStep: number; + maxStep: number; + mode: PlaybackMode; + realtimeDurationMs: number; + realtimeStartElapsedMs?: number; +}): number { + const remainingSteps = Math.max(0, maxStep - startStep); + if (remainingSteps === 0) return 0; + if (mode === "realtime") { + return Math.max(0, realtimeDurationMs - realtimeStartElapsedMs); + } + return (remainingSteps * BASE_EVENT_DURATION_MS) / mode; +} + +export function getTimelineStepAtElapsed({ + startStep, + maxStep, + elapsedMs, + durationMs, +}: { + startStep: number; + maxStep: number; + elapsedMs: number; + durationMs: number; +}): number { + const firstStep = Math.min(maxStep, Math.max(0, startStep)); + const remainingSteps = Math.max(0, maxStep - firstStep); + if (remainingSteps === 0 || durationMs <= 0) return maxStep; + const progress = Math.min(1, Math.max(0, elapsedMs / durationMs)); + return Math.min(maxStep, firstStep + Math.floor(progress * remainingSteps)); +} + +export function getRealtimeLaneStep({ + elapsedMs, + executionTimeMs, + traceLength, +}: { + elapsedMs: number; + executionTimeMs: number; + traceLength: number; +}): number { + const finalStep = Math.max(0, traceLength - 1); + if (finalStep === 0 || executionTimeMs <= 0) return finalStep; + const progress = Math.min(1, Math.max(0, elapsedMs / executionTimeMs)); + return Math.min(finalStep, Math.floor(progress * finalStep)); +} + +export function formatExecutionTime(milliseconds: number): string { + if (!Number.isFinite(milliseconds) || milliseconds < 0) return "—"; + if (milliseconds < 0.01) return "<0.01 ms"; + if (milliseconds < 1) return `${milliseconds.toFixed(2)} ms`; + if (milliseconds < 10) return `${milliseconds.toFixed(2)} ms`; + if (milliseconds < 1000) return `${milliseconds.toFixed(1)} ms`; + return `${(milliseconds / 1000).toFixed(2)} s`; +} diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..00c1b14 Binary files /dev/null and b/public/favicon.ico differ diff --git a/tests/compare-playback.test.ts b/tests/compare-playback.test.ts new file mode 100644 index 0000000..515787e --- /dev/null +++ b/tests/compare-playback.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { + formatExecutionTime, + getPlaybackDurationMs, + getRealtimeLaneStep, + getTimelineStepAtElapsed, + parsePlaybackMode, + PLAYBACK_SPEEDS, +} from "@/lib/compare-playback"; + +describe("Compare Mode playback timing", () => { + it("offers multipliers through 100x and parses real-time mode", () => { + expect(PLAYBACK_SPEEDS).toEqual([0.5, 1, 2, 4, 10, 25, 50, 100]); + expect(parsePlaybackMode("100")).toBe(100); + expect(parsePlaybackMode("realtime")).toBe("realtime"); + expect(parsePlaybackMode("invalid")).toBe(1); + }); + + it("lets high multipliers advance multiple events per rendered frame", () => { + const durationMs = getPlaybackDurationMs({ + startStep: 0, + maxStep: 100, + mode: 100, + realtimeDurationMs: 0, + }); + expect(durationMs).toBe(680); + expect( + getTimelineStepAtElapsed({ + startStep: 0, + maxStep: 100, + elapsedMs: 16, + durationMs, + }), + ).toBe(2); + expect( + getTimelineStepAtElapsed({ + startStep: 0, + maxStep: 100, + elapsedMs: durationMs, + durationMs, + }), + ).toBe(100); + }); + + it("maps each real-time lane to its own measured completion time", () => { + expect( + getRealtimeLaneStep({ + elapsedMs: 3, + executionTimeMs: 6, + traceLength: 101, + }), + ).toBe(50); + expect( + getRealtimeLaneStep({ + elapsedMs: 16, + executionTimeMs: 0, + traceLength: 101, + }), + ).toBe(100); + }); + + it("formats sub-millisecond and visible execution durations honestly", () => { + expect(formatExecutionTime(0)).toBe("<0.01 ms"); + expect(formatExecutionTime(0.125)).toBe("0.13 ms"); + expect(formatExecutionTime(12.34)).toBe("12.3 ms"); + expect(formatExecutionTime(1234)).toBe("1.23 s"); + }); +}); diff --git a/tests/e2e/routelab.spec.ts b/tests/e2e/routelab.spec.ts index 9a78b8f..9c49fe3 100644 --- a/tests/e2e/routelab.spec.ts +++ b/tests/e2e/routelab.spec.ts @@ -131,6 +131,75 @@ test("Compare Mode runs real synchronized traces", async ({ page }) => { await expect(page.getByLabel("Algorithm in lane 2")).toHaveValue("astar"); const sliders = page.getByRole("slider"); await expect(sliders.first()).toBeEnabled(); + + const graphFrames = page.getByTestId("compare-graph-frame"); + await expect(graphFrames).toHaveCount(2); + for (const frame of await graphFrames.all()) { + const geometry = await frame.evaluate((element) => { + const frameRect = element.getBoundingClientRect(); + const visualRect = element + .querySelector(".graph-visual")! + .getBoundingClientRect(); + const svgRect = element.querySelector("svg")!.getBoundingClientRect(); + return { + visualContained: + visualRect.top >= frameRect.top - 1 && + visualRect.bottom <= frameRect.bottom + 1, + svgContained: + svgRect.top >= frameRect.top - 1 && + svgRect.bottom <= frameRect.bottom + 1, + }; + }); + expect(geometry.visualContained).toBe(true); + expect(geometry.svgContained).toBe(true); + } +}); + +test("Compare Mode supports 100x and measured real-time playback", async ({ + page, +}) => { + await page.goto("/compare"); + const timeline = page + .getByRole("region", { name: "Synchronized playback controls" }) + .getByRole("slider"); + const speed = page.getByRole("combobox", { name: "Playback speed" }); + const computeTimes = page.getByTestId("compare-compute-time"); + + await expect(computeTimes.first()).not.toHaveText("—"); + await expect(speed.locator("option")).toHaveText([ + "0.5×", + "1×", + "2×", + "4×", + "10×", + "25×", + "50×", + "100×", + "Real time", + ]); + + const finalStep = await timeline.getAttribute("max"); + expect(finalStep).not.toBeNull(); + await speed.selectOption("100"); + await page.getByRole("button", { name: "Play comparison" }).click(); + await expect(timeline).toHaveValue(finalStep!, { timeout: 6_000 }); + + await speed.selectOption("realtime"); + await expect(speed).toHaveValue("realtime"); + await page.getByRole("button", { name: "Play comparison" }).click(); + await expect(timeline).toHaveValue(finalStep!, { timeout: 2_000 }); + await expect(page.getByText(/measured$/)).toBeVisible(); + + await page.goto("/compare?scenario=all-pairs-matrix&algorithms=bfs,dijkstra"); + await page.waitForLoadState("networkidle"); + const unavailableSpeed = page.getByRole("combobox", { + name: "Playback speed", + }); + await unavailableSpeed.selectOption("realtime"); + await expect(unavailableSpeed).toHaveValue("realtime"); + await expect( + page.getByText("No compatible runs", { exact: true }), + ).toBeVisible(); }); test("malformed imports fail as data without executing labels", async ({ diff --git a/tests/e2e/visual.spec.ts b/tests/e2e/visual.spec.ts index 9929c4e..7fbda13 100644 --- a/tests/e2e/visual.spec.ts +++ b/tests/e2e/visual.spec.ts @@ -61,3 +61,14 @@ test("Compare Mode visual baseline", async ({ page }) => { maxDiffPixelRatio: 0.005, }); }); + +test("Compare graph visual baseline", async ({ page }) => { + await page.goto("/compare"); + const graphFrame = page.getByTestId("compare-graph-frame").first(); + await graphFrame.scrollIntoViewIfNeeded(); + await expect(graphFrame).toHaveScreenshot("compare-graph.png", { + animations: "disabled", + caret: "hide", + maxDiffPixelRatio: 0.005, + }); +}); diff --git a/tests/e2e/visual.spec.ts-snapshots/compare-graph-chromium-win32.png b/tests/e2e/visual.spec.ts-snapshots/compare-graph-chromium-win32.png new file mode 100644 index 0000000..fdb8b97 Binary files /dev/null and b/tests/e2e/visual.spec.ts-snapshots/compare-graph-chromium-win32.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/compare-graph-mobile-chromium-win32.png b/tests/e2e/visual.spec.ts-snapshots/compare-graph-mobile-chromium-win32.png new file mode 100644 index 0000000..b3b1a6c Binary files /dev/null and b/tests/e2e/visual.spec.ts-snapshots/compare-graph-mobile-chromium-win32.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/compare-initial-mobile-chromium-win32.png b/tests/e2e/visual.spec.ts-snapshots/compare-initial-mobile-chromium-win32.png index adb3175..eaf3a2c 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/compare-initial-mobile-chromium-win32.png and b/tests/e2e/visual.spec.ts-snapshots/compare-initial-mobile-chromium-win32.png differ