import { act, cleanup, renderHook } from "@testing-library/react"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; import type { ScadaReplay } from "../api/scada-replay-client"; import { useScadaPlayback } from "./use-scada-playback"; const start = Date.parse("2026-07-16T07:30:00+08:00"); const end = start + 3600_000; function receipt(time: number, status = "paused") { return { virtual_now: new Date(time).toISOString(), status, step_pending: false } as ScadaReplay; } function setup(advance = vi.fn<() => Promise>()) { const options = { active: true, enabled: true, busy: false, start, end, now: start, intervalSeconds: 900, restart: false, advance }; return { ...renderHook((props) => useScadaPlayback(props), { initialProps: options }), options, advance }; } beforeEach(() => vi.useFakeTimers()); afterEach(() => { cleanup(); vi.useRealTimers(); }); it("advances the display first and monitors once at each project boundary until the end", async () => { let count = 0; const advance = vi.fn(async () => { count += 1; return receipt(start + count * 900_000, count === 4 ? "completed" : "paused"); }); const { result } = setup(advance); act(() => result.current.play()); await act(() => vi.advanceTimersByTimeAsync(2500)); expect(result.current.time).toBe(start + 450_000); expect(advance).not.toHaveBeenCalled(); await act(() => vi.advanceTimersByTimeAsync(17500)); expect(advance).toHaveBeenCalledTimes(4); expect(result.current.time).toBe(end); expect(result.current.playing).toBe(false); await act(() => vi.advanceTimersByTimeAsync(10000)); expect(advance).toHaveBeenCalledTimes(4); }); it("waits for a slow diagnosis without overlapping requests or jumping ahead", async () => { let resolve!: (value: ScadaReplay) => void; const advance = vi.fn(() => new Promise((done) => { resolve = done; })); const { result } = setup(advance); act(() => result.current.play()); await act(() => vi.advanceTimersByTimeAsync(20000)); expect(advance).toHaveBeenCalledTimes(1); expect(result.current.time).toBe(start + 900_000); act(() => result.current.pause()); await act(async () => resolve(receipt(start + 900_000))); await act(() => vi.advanceTimersByTimeAsync(10000)); expect(advance).toHaveBeenCalledTimes(1); expect(result.current.playing).toBe(false); }); it("pauses when the panel becomes inactive and resumes from the same display time", async () => { const { result, rerender, options, advance } = setup(); act(() => result.current.play()); await act(() => vi.advanceTimersByTimeAsync(2000)); expect(result.current.newRound).toBe(false); const pausedAt = result.current.time; rerender({ ...options, active: false }); await act(() => vi.advanceTimersByTimeAsync(10000)); expect(result.current.time).toBe(pausedAt); expect(result.current.playing).toBe(false); expect(advance).not.toHaveBeenCalled(); rerender(options); act(() => result.current.play()); await act(() => vi.advanceTimersByTimeAsync(1000)); expect(result.current.time).toBe(pausedAt + 180_000); }); it("pauses on a failed command and does not retry automatically", async () => { const { result, advance } = setup(vi.fn(async () => null)); act(() => result.current.play()); await act(() => vi.advanceTimersByTimeAsync(15000)); expect(advance).toHaveBeenCalledTimes(1); expect(result.current.playing).toBe(false); }); it("uses the configured monitoring interval independently of the dial's five-minute ticks", async () => { const { result, rerender, options, advance } = setup(vi.fn(async () => receipt(start + 300_000))); rerender({ ...options, intervalSeconds: 300 }); act(() => result.current.play()); await act(() => vi.advanceTimersByTimeAsync(1700)); expect(advance).toHaveBeenCalledTimes(1); expect(result.current.time).toBe(start + 300_000); }); it("pauses for a hidden page and cleans up its automatic monitor on unmount", async () => { const { result, unmount, advance } = setup(); act(() => result.current.play()); const visibility = vi.spyOn(document, "visibilityState", "get").mockReturnValue("hidden"); act(() => document.dispatchEvent(new Event("visibilitychange"))); await act(() => vi.advanceTimersByTimeAsync(10000)); expect(result.current.playing).toBe(false); visibility.mockRestore(); act(() => result.current.play()); unmount(); await vi.advanceTimersByTimeAsync(10000); expect(advance).not.toHaveBeenCalled(); }); it("resumes a restarted round before its first request without resetting the clock again", async () => { const { result, rerender, options, advance } = setup(); rerender({ ...options, now: end, restart: true }); act(() => result.current.play()); await act(() => vi.advanceTimersByTimeAsync(2000)); expect(result.current.newRound).toBe(true); const pausedAt = result.current.time; act(() => result.current.pause()); act(() => result.current.play()); expect(result.current.time).toBe(pausedAt); await act(() => vi.advanceTimersByTimeAsync(1000)); expect(result.current.time).toBe(pausedAt + 180_000); expect(advance).not.toHaveBeenCalled(); }); it("keeps playing when the refreshed snapshot arrives before the command promise resolves", async () => { let resolve!: (value: ScadaReplay) => void; const { result, rerender, options } = setup(vi.fn(() => new Promise((done) => { resolve = done; }))); act(() => result.current.play()); await act(() => vi.advanceTimersByTimeAsync(5000)); rerender({ ...options, now: start + 900_000 }); await act(async () => resolve(receipt(start + 900_000))); expect(result.current.playing).toBe(true); await act(() => vi.advanceTimersByTimeAsync(1000)); expect(result.current.time).toBe(start + 1080_000); }); it("resets to a paused beginning without processing until play is pressed again", async () => { const { result, rerender, options, advance } = setup(vi.fn(async () => receipt(start + 900_000))); act(() => result.current.play()); await act(() => vi.advanceTimersByTimeAsync(2000)); expect(result.current.time).toBe(start + 360_000); act(() => result.current.reset()); expect(result.current.time).toBe(start); expect(result.current.playing).toBe(false); expect(result.current.started).toBe(false); expect(result.current.newRound).toBe(false); rerender(options); await act(() => vi.advanceTimersByTimeAsync(10000)); expect(advance).not.toHaveBeenCalled(); act(() => result.current.play()); await act(() => vi.advanceTimersByTimeAsync(5000)); expect(advance).toHaveBeenCalledTimes(1); });