diff --git a/src/app/(dashboard)/dashboard/compression/studio/useCompressionReplay.ts b/src/app/(dashboard)/dashboard/compression/studio/useCompressionReplay.ts index da8843f12a..1cc805ecbb 100644 --- a/src/app/(dashboard)/dashboard/compression/studio/useCompressionReplay.ts +++ b/src/app/(dashboard)/dashboard/compression/studio/useCompressionReplay.ts @@ -27,28 +27,31 @@ export interface UseCompressionReplayReturn { const BASE_STEP_MS = 400; -function stepMs(speed: ReplaySpeed): number { +/** Interval (ms) between frames for a given speed. Exported as a unit-test seam. */ +export function stepMs(speed: ReplaySpeed): number { return Math.round(BASE_STEP_MS / speed); } // ── Reducer ─────────────────────────────────────────────────────────────── +// `ReplayState`, `ReplayAction`, `INITIAL_STATE` and `replayReducer` are exported +// purely as unit-test seams (the state machine is otherwise driven only via the hook). -interface ReplayState { +export interface ReplayState { frameIndex: number; // -1 = not started isPlaying: boolean; speed: ReplaySpeed; } -type ReplayAction = +export type ReplayAction = | { type: "RESET" } | { type: "PLAY"; frameIndex?: number } | { type: "PAUSE" } | { type: "TICK"; totalFrames: number } | { type: "SET_SPEED"; speed: ReplaySpeed }; -const INITIAL_STATE: ReplayState = { frameIndex: -1, isPlaying: false, speed: 1 }; +export const INITIAL_STATE: ReplayState = { frameIndex: -1, isPlaying: false, speed: 1 }; -function replayReducer(state: ReplayState, action: ReplayAction): ReplayState { +export function replayReducer(state: ReplayState, action: ReplayAction): ReplayState { switch (action.type) { case "RESET": return { ...state, frameIndex: -1, isPlaying: false }; @@ -164,6 +167,10 @@ export function useCompressionReplay( const handleSetSpeed = useCallback( (s: ReplaySpeed) => { + // Update the cadence ref synchronously: startTick() below reads speedRef.current + // immediately, but the syncing effect only runs after render — too late for this + // in-flight restart. Without this, changing speed mid-play kept the old cadence. + speedRef.current = s; dispatch({ type: "SET_SPEED", speed: s }); if (isPlaying) startTick(); }, diff --git a/tests/unit/ui/compression-replay-reducer.test.ts b/tests/unit/ui/compression-replay-reducer.test.ts new file mode 100644 index 0000000000..77fe48eb77 --- /dev/null +++ b/tests/unit/ui/compression-replay-reducer.test.ts @@ -0,0 +1,89 @@ +/** + * tests/unit/ui/compression-replay-reducer.test.ts + * + * F5.1 coverage gap (F3.2): the `useCompressionReplay` state machine was untested + * (only buildReplayFrames was covered). This pins the pure reducer + timing. + */ + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { + replayReducer, + INITIAL_STATE, + stepMs, + type ReplayState, +} from "../../../src/app/(dashboard)/dashboard/compression/studio/useCompressionReplay.ts"; + +describe("replayReducer — compression replay state machine (F3.2)", () => { + it("starts idle: frameIndex -1, not playing, speed 1", () => { + assert.deepEqual(INITIAL_STATE, { frameIndex: -1, isPlaying: false, speed: 1 }); + }); + + it("PLAY without frameIndex starts playing and keeps the current frame", () => { + const s = replayReducer({ frameIndex: 2, isPlaying: false, speed: 1 }, { type: "PLAY" }); + assert.equal(s.isPlaying, true); + assert.equal(s.frameIndex, 2); + }); + + it("PLAY with frameIndex restarts from that frame (used for replay-from-start)", () => { + const s = replayReducer(INITIAL_STATE, { type: "PLAY", frameIndex: 0 }); + assert.equal(s.isPlaying, true); + assert.equal(s.frameIndex, 0); + }); + + it("PAUSE stops playing but keeps the frame", () => { + const s = replayReducer({ frameIndex: 1, isPlaying: true, speed: 1 }, { type: "PAUSE" }); + assert.equal(s.isPlaying, false); + assert.equal(s.frameIndex, 1); + }); + + it("TICK advances one frame while below the last", () => { + const start: ReplayState = { frameIndex: -1, isPlaying: true, speed: 1 }; + const a = replayReducer(start, { type: "TICK", totalFrames: 3 }); + assert.equal(a.frameIndex, 0); + assert.equal(a.isPlaying, true); + const b = replayReducer(a, { type: "TICK", totalFrames: 3 }); + assert.equal(b.frameIndex, 1); + assert.equal(b.isPlaying, true); + }); + + it("TICK stops at the last frame and clears isPlaying (auto-stop at end)", () => { + const atSecondLast: ReplayState = { frameIndex: 1, isPlaying: true, speed: 1 }; + const done = replayReducer(atSecondLast, { type: "TICK", totalFrames: 3 }); + assert.equal(done.frameIndex, 2, "lands on the last frame index (totalFrames-1)"); + assert.equal(done.isPlaying, false, "playback stops once the last frame is reached"); + }); + + it("RESET returns to idle (-1, not playing) but preserves speed", () => { + const s = replayReducer({ frameIndex: 2, isPlaying: true, speed: 3 }, { type: "RESET" }); + assert.equal(s.frameIndex, -1); + assert.equal(s.isPlaying, false); + assert.equal(s.speed, 3, "speed must survive a reset"); + }); + + it("SET_SPEED changes speed without touching frame/play state", () => { + const s = replayReducer({ frameIndex: 1, isPlaying: true, speed: 1 }, { + type: "SET_SPEED", + speed: 3, + }); + assert.equal(s.speed, 3); + assert.equal(s.frameIndex, 1); + assert.equal(s.isPlaying, true); + }); + + it("unknown action is a no-op (returns the same state reference)", () => { + const state: ReplayState = { frameIndex: 0, isPlaying: false, speed: 1 }; + // @ts-expect-error — exercising the default branch with an invalid action + assert.equal(replayReducer(state, { type: "NOPE" }), state); + }); +}); + +describe("stepMs — frame pacing by speed (F3.2)", () => { + it("maps speed to a shorter interval as speed rises", () => { + assert.equal(stepMs(1), 400); + assert.equal(stepMs(3), 133); // round(400/3) + assert.equal(stepMs(0.3), 1333); // round(400/0.3) + assert.ok(stepMs(3) < stepMs(1) && stepMs(1) < stepMs(0.3), "monotonic: faster = shorter"); + }); +}); diff --git a/tests/unit/ui/compression-replay-speed.test.tsx b/tests/unit/ui/compression-replay-speed.test.tsx new file mode 100644 index 0000000000..c7b3136c44 --- /dev/null +++ b/tests/unit/ui/compression-replay-speed.test.tsx @@ -0,0 +1,98 @@ +// @vitest-environment jsdom +/** + * F5.2 finding (HIGH): changing replay speed mid-play was silently ignored — `startTick` + * read `speedRef.current` synchronously but the ref was only synced in a post-render + * effect, so the new interval kept the OLD cadence. This drives the hook with fake timers + * to prove a mid-play speed change actually takes effect. + */ +import React, { act, useMemo, useEffect } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { useCompressionReplay } from "@/app/(dashboard)/dashboard/compression/studio/useCompressionReplay"; +import { compressionEventToModel } from "@/app/(dashboard)/dashboard/compression/studio/compressionFlowModel"; +import type { + CompressionCompletedPayload, +} from "@/lib/events/types"; + +// 3 engine steps → buildReplayFrames yields 3 frames (indices 0,1,2). +const PAYLOAD: CompressionCompletedPayload = { + requestId: "r1", + comboId: "c", + mode: "stacked", + originalTokens: 1000, + compressedTokens: 700, + savingsPercent: 30, + engineBreakdown: [ + { engine: "a", originalTokens: 1000, compressedTokens: 900, savingsPercent: 10, techniquesUsed: [], durationMs: 1 }, + { engine: "b", originalTokens: 900, compressedTokens: 800, savingsPercent: 11, techniquesUsed: [], durationMs: 1 }, + { engine: "c", originalTokens: 800, compressedTokens: 700, savingsPercent: 12, techniquesUsed: [], durationMs: 1 }, + ], + timestamp: 1718000000000, +}; + +let api: ReturnType | null = null; +function Harness() { + // Memoize so the model identity is stable across re-renders (otherwise the + // model-change effect would RESET on every render). + const model = useMemo(() => compressionEventToModel(PAYLOAD), []); + const hook = useCompressionReplay(model); + // Capture in an effect (not during render) so we don't reassign a module-scoped + // variable mid-render. The effect runs after each commit, so `api` tracks the latest. + useEffect(() => { + api = hook; + }); + return null; +} + +const containers: HTMLElement[] = []; +function mount(): void { + const container = document.createElement("div"); + document.body.appendChild(container); + containers.push(container); + const root = createRoot(container); + act(() => { + root.render(); + }); +} + +beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); + while (containers.length > 0) containers.pop()?.remove(); + document.body.innerHTML = ""; + api = null; +}); + +describe("useCompressionReplay — mid-play speed change (F5.2)", () => { + it("applies a new speed to the running ticker (was silently ignored before the fix)", () => { + mount(); + expect(api!.totalFrames).toBe(3); + + // Play at speed 1 (interval 400ms): one tick advances to frame 0. + act(() => { + api!.play(); + }); + act(() => { + vi.advanceTimersByTime(400); + }); + expect(api!.frameIndex).toBe(0); + + // Speed up to 3 (interval ~133ms) WHILE playing, then advance only 133ms. + // With the fix the ticker restarts at the faster cadence and advances; + // without it the interval stayed at 400ms and 133ms would not tick. + act(() => { + api!.setSpeed(3); + }); + act(() => { + vi.advanceTimersByTime(133); + }); + expect(api!.frameIndex).toBe(1); + expect(api!.speed).toBe(3); + }); +}); diff --git a/tests/unit/ui/live-compression-accumulate.test.ts b/tests/unit/ui/live-compression-accumulate.test.ts new file mode 100644 index 0000000000..650994c410 --- /dev/null +++ b/tests/unit/ui/live-compression-accumulate.test.ts @@ -0,0 +1,76 @@ +/** + * tests/unit/ui/live-compression-accumulate.test.ts + * + * F5.1 coverage gap (F3.3): the `useLiveCompression` accumulator (`accumulateRun`) + * — which folds incoming `compression.completed` payloads into the run list — was + * exported "for unit tests" but never tested. This pins ordering + the cap. + */ + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { accumulateRun } from "../../../src/hooks/useLiveCompression.ts"; +import type { CompressionRunModel } from "../../../src/app/(dashboard)/dashboard/compression/studio/compressionFlowModel.ts"; +import type { CompressionCompletedPayload } from "../../../src/lib/events/types.ts"; + +function payload(requestId: string): CompressionCompletedPayload { + return { + requestId, + comboId: "my-combo", + mode: "stacked", + originalTokens: 1000, + compressedTokens: 600, + savingsPercent: 40, + engineBreakdown: [ + { + engine: "rtk", + originalTokens: 1000, + compressedTokens: 600, + savingsPercent: 40, + techniquesUsed: ["tool-output-trim"], + durationMs: 3, + }, + ], + timestamp: 1718000000000, + }; +} + +describe("accumulateRun — live compression run accumulator (F3.3)", () => { + it("adds the first run derived from the payload (requestId carried through)", () => { + const runs = accumulateRun([], payload("r1")); + assert.equal(runs.length, 1); + assert.equal(runs[0].requestId, "r1"); + assert.equal(runs[0].mode, "stacked"); + }); + + it("prepends so the list is most-recent-first", () => { + let runs: CompressionRunModel[] = []; + runs = accumulateRun(runs, payload("r1")); + runs = accumulateRun(runs, payload("r2")); + runs = accumulateRun(runs, payload("r3")); + assert.deepEqual( + runs.map((r) => r.requestId), + ["r3", "r2", "r1"], + ); + }); + + it("caps the list at maxRuns, dropping the oldest", () => { + let runs: CompressionRunModel[] = []; + for (const id of ["a", "b", "c", "d"]) { + runs = accumulateRun(runs, payload(id), /* maxRuns */ 2); + } + assert.equal(runs.length, 2, "never grows beyond maxRuns"); + assert.deepEqual( + runs.map((r) => r.requestId), + ["d", "c"], + "keeps the 2 newest, newest-first; oldest (a, b) dropped", + ); + }); + + it("does not mutate the previous array (returns a new list)", () => { + const prev = accumulateRun([], payload("r1")); + const next = accumulateRun(prev, payload("r2")); + assert.equal(prev.length, 1, "input array is left untouched"); + assert.notEqual(prev, next); + }); +}); diff --git a/tests/unit/ui/status-dot.test.tsx b/tests/unit/ui/status-dot.test.tsx new file mode 100644 index 0000000000..014587c808 --- /dev/null +++ b/tests/unit/ui/status-dot.test.tsx @@ -0,0 +1,68 @@ +// @vitest-environment jsdom +/** + * F5.1 coverage gap (F0.2): the StatusDot U0 sub-component had no dedicated test. + * Pins the error-override + sizeClass behavior. + */ +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { StatusDot } from "@/shared/components/flow/StatusDot"; +import { FLOW_EDGE_COLORS } from "@/shared/components/flow/edgeStyles"; + +const containers: HTMLElement[] = []; + +function mount(ui: React.ReactElement): HTMLElement { + const container = document.createElement("div"); + document.body.appendChild(container); + containers.push(container); + const root = createRoot(container); + act(() => { + root.render(ui); + }); + return container; +} + +/** Normalize a CSS color the way jsdom does (hex → rgb(...)) for stable comparison. */ +function normalizeColor(c: string): string { + const probe = document.createElement("span"); + probe.style.backgroundColor = c; + return probe.style.backgroundColor; +} + +beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(() => { + while (containers.length > 0) containers.pop()?.remove(); + document.body.innerHTML = ""; +}); + +describe("StatusDot (U0 — pulsing presence indicator)", () => { + it("uses the provided color when not errored", () => { + const container = mount(); + const dots = container.querySelectorAll("span[style]"); + expect(dots.length).toBe(2); // ping halo + solid dot + for (const d of dots) expect(d.style.backgroundColor).toBe("rgb(1, 2, 3)"); + }); + + it("overrides color with the error color when error=true", () => { + const container = mount(); + const dots = container.querySelectorAll("span[style]"); + const expected = normalizeColor(FLOW_EDGE_COLORS.error); + for (const d of dots) { + expect(d.style.backgroundColor).toBe(expected); + expect(d.style.backgroundColor).not.toBe("rgb(1, 2, 3)"); + } + }); + + it("applies the sizeClass to the wrapper (defaults to size-1.5)", () => { + const dflt = mount(); + expect(dflt.querySelector(".size-1\\.5")).toBeTruthy(); + + const custom = mount(); + expect(custom.querySelector(".size-3")).toBeTruthy(); + }); +});