mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-05 06:42:12 +03:00
test(compression): close F5.1 coverage gaps (replay reducer, live accumulator, StatusDot) (#4192)
Integrated into release/v3.8.29
This commit is contained in:
committed by
GitHub
parent
4f3a113471
commit
cd60eb20a9
@@ -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();
|
||||
},
|
||||
|
||||
89
tests/unit/ui/compression-replay-reducer.test.ts
Normal file
89
tests/unit/ui/compression-replay-reducer.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
98
tests/unit/ui/compression-replay-speed.test.tsx
Normal file
98
tests/unit/ui/compression-replay-speed.test.tsx
Normal file
@@ -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<typeof useCompressionReplay> | 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(<Harness />);
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
76
tests/unit/ui/live-compression-accumulate.test.ts
Normal file
76
tests/unit/ui/live-compression-accumulate.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
68
tests/unit/ui/status-dot.test.tsx
Normal file
68
tests/unit/ui/status-dot.test.tsx
Normal file
@@ -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(<StatusDot color="rgb(1, 2, 3)" />);
|
||||
const dots = container.querySelectorAll<HTMLElement>("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(<StatusDot color="rgb(1, 2, 3)" error />);
|
||||
const dots = container.querySelectorAll<HTMLElement>("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(<StatusDot color="rgb(1, 2, 3)" />);
|
||||
expect(dflt.querySelector(".size-1\\.5")).toBeTruthy();
|
||||
|
||||
const custom = mount(<StatusDot color="rgb(1, 2, 3)" sizeClass="size-3" />);
|
||||
expect(custom.querySelector(".size-3")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user