2781556ead
* perf(backend): cap cumulative diff bytes and file count during large rebases * perf(web): coalesce cumulative-diff refetches and skip redundant status compares * feat(web): warn when the changes panel hides files from a capped diff * fix(backend): enforce cumulative diff total byte budget strictly * fix(web): keep coalesced refetch timer shared across envKey subscribers * fix(web): drop unsound git-status SHA fast-path that staled live diffs * fix(web): cancel coalesced timer on mid-flight drain to avoid duplicate refetch * refactor(web): move changes-panel banner and helper to a sibling file * docs(backend): clarify cumulative diff truncated-files-count semantics * fix(web): tighten useSelectedFileKey mode param to the all|file union
149 lines
4.6 KiB
TypeScript
149 lines
4.6 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { cleanup, renderHook, act } from "@testing-library/react";
|
|
|
|
const mockRequest = vi.fn();
|
|
|
|
vi.mock("@/lib/ws/connection", () => ({
|
|
getWebSocketClient: () => ({ request: mockRequest }),
|
|
}));
|
|
|
|
let storeState: Record<string, unknown> = {};
|
|
|
|
vi.mock("@/components/state-provider", () => ({
|
|
useAppStore: (selector: (state: Record<string, unknown>) => unknown) => selector(storeState),
|
|
}));
|
|
|
|
import { useCumulativeDiff, invalidateCumulativeDiffCache } from "./use-cumulative-diff";
|
|
|
|
// Mirrors COALESCE_WINDOW_MS in the hook; advance past it to flush the timer.
|
|
const WINDOW = 250;
|
|
|
|
function setStore() {
|
|
// No environment mapping → envKey resolves to the sessionId itself.
|
|
storeState = {
|
|
environmentIdBySessionId: {} as Record<string, string>,
|
|
};
|
|
}
|
|
|
|
describe("useCumulativeDiff invalidation coalescing", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.useFakeTimers();
|
|
setStore();
|
|
mockRequest.mockResolvedValue({ cumulative_diff: { session_id: "x", files: {} } });
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("collapses a burst of invalidations into a single trailing refetch", async () => {
|
|
const sid = "sess-burst";
|
|
renderHook(() => useCumulativeDiff(sid));
|
|
|
|
// Drain the mount fetch.
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
});
|
|
expect(mockRequest).toHaveBeenCalledTimes(1);
|
|
mockRequest.mockClear();
|
|
|
|
// Fire 5 invalidations within the coalesce window.
|
|
act(() => {
|
|
for (let i = 0; i < 5; i += 1) invalidateCumulativeDiffCache(sid);
|
|
});
|
|
// Nothing should have fired yet (trailing edge).
|
|
expect(mockRequest).toHaveBeenCalledTimes(0);
|
|
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(WINDOW);
|
|
});
|
|
expect(mockRequest).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("does not refetch after an unmounted subscriber's coalesced timer fires", async () => {
|
|
const sid = "sess-unmount";
|
|
const { unmount } = renderHook(() => useCumulativeDiff(sid));
|
|
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
});
|
|
mockRequest.mockClear();
|
|
|
|
act(() => {
|
|
invalidateCumulativeDiffCache(sid);
|
|
});
|
|
// Unmount before the window elapses. The timer is NOT cleared (it is shared
|
|
// per envKey), but the listener-subscription cleanup removed this hook's
|
|
// handler, so the timer firing is a no-op for the unmounted subscriber.
|
|
act(() => {
|
|
unmount();
|
|
});
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(WINDOW);
|
|
});
|
|
expect(mockRequest).toHaveBeenCalledTimes(0);
|
|
});
|
|
|
|
it("does not double-fetch when a mid-flight invalidation drains alongside a queued timer", async () => {
|
|
const sid = "sess-drain";
|
|
let resolveFirst: (v: unknown) => void = () => {};
|
|
mockRequest.mockReset();
|
|
mockRequest
|
|
.mockImplementationOnce(
|
|
() =>
|
|
new Promise((r) => {
|
|
resolveFirst = r;
|
|
}),
|
|
)
|
|
.mockResolvedValue({ cumulative_diff: { session_id: "x", files: {} } });
|
|
|
|
renderHook(() => useCumulativeDiff(sid));
|
|
// Mount fetch is in-flight (request #1 pending).
|
|
expect(mockRequest).toHaveBeenCalledTimes(1);
|
|
|
|
// Invalidate while in-flight → sets the pending flag AND queues a timer.
|
|
act(() => {
|
|
invalidateCumulativeDiffCache(sid);
|
|
});
|
|
|
|
// Resolve the in-flight fetch → finally drains the pending flag (immediate
|
|
// follow-up fetch #2) and must cancel the queued timer.
|
|
await act(async () => {
|
|
resolveFirst({ cumulative_diff: { session_id: "x", files: {} } });
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
});
|
|
expect(mockRequest).toHaveBeenCalledTimes(2);
|
|
|
|
// The cancelled timer must NOT fire a third fetch.
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(WINDOW);
|
|
});
|
|
expect(mockRequest).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("uses independent timers per envKey", async () => {
|
|
const sidA = "sess-A";
|
|
const sidB = "sess-B";
|
|
renderHook(() => useCumulativeDiff(sidA));
|
|
renderHook(() => useCumulativeDiff(sidB));
|
|
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
});
|
|
expect(mockRequest).toHaveBeenCalledTimes(2); // one mount fetch each
|
|
mockRequest.mockClear();
|
|
|
|
act(() => {
|
|
invalidateCumulativeDiffCache(sidA);
|
|
invalidateCumulativeDiffCache(sidB);
|
|
});
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(WINDOW);
|
|
});
|
|
// One refetch per envKey — they don't collapse into each other.
|
|
expect(mockRequest).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|