Files
cc-switch/tests/hooks/useUsageAutoRefresh.test.tsx
Jason 7a8087f688 feat(usage): scan session logs at the selected interval while the usage page is open
The usage page's auto-refresh interval only re-read the database, so
session usage still waited for the fixed 60-second background scan, and
routed usage already updated in real time through usage-log-recorded.
The interval now controls how often session logs are scanned:

- The background scan drops to every 10 minutes as a safety net against
  CLIs pruning old session files.
- While the usage page is visible it scans right away, again whenever
  the window becomes visible, and then at the selected interval
  (5/10/30/60s, default 60s). The first scan waits until settings load,
  so it never runs when automatic scanning is off.
- The "Off" option is removed (a saved 0 falls back to 60s), and the
  interval menu is hidden when automatic scanning is off.
- Statistics no longer poll. Imports refresh them through the
  usage-log-recorded event, which is always on again, and time-relative
  ranges re-run once a minute so "Today" rolls over at midnight and
  rolling windows drop old records without new usage.

Copy is updated in zh, zh-TW, en, ja and tr, and in the zh/en/ja manual.
2026-10-14 17:42:42 +08:00

254 lines
7.6 KiB
TypeScript

import type { PropsWithChildren } from "react";
import { act, cleanup, renderHook } from "@testing-library/react";
import {
focusManager,
QueryClient,
QueryClientProvider,
} from "@tanstack/react-query";
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi,
type MockInstance,
} from "vitest";
import { useUsageAutoRefresh } from "@/hooks/useUsageAutoRefresh";
import { usageApi } from "@/lib/api/usage";
import { usageKeys } from "@/lib/query/usage";
import type { SessionSyncResult } from "@/types/usage";
vi.mock("@/lib/api/usage", () => ({
usageApi: { syncSessionUsage: vi.fn() },
}));
const syncResult: SessionSyncResult = {
imported: 1,
skipped: 0,
filesScanned: 1,
suspectedDuplicates: 0,
deferredFiles: 0,
errors: [],
};
const lastSyncKey = [...usageKeys.all, "session-last-sync"];
const syncMock = vi.mocked(usageApi.syncSessionUsage);
const advance = (ms: number) => act(() => vi.advanceTimersByTimeAsync(ms));
const flush = () => act(() => vi.advanceTimersByTimeAsync(0));
describe("useUsageAutoRefresh", () => {
let client: QueryClient;
let invalidate: MockInstance<QueryClient["invalidateQueries"]>;
function wrapper({ children }: PropsWithChildren) {
return (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);
}
beforeEach(() => {
vi.useFakeTimers();
focusManager.setFocused(true);
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
invalidate = vi.spyOn(client, "invalidateQueries");
syncMock.mockReset().mockResolvedValue(syncResult);
});
afterEach(() => {
cleanup();
client.clear();
focusManager.setFocused(undefined);
vi.restoreAllMocks();
vi.useRealTimers();
});
it.each([5000, 10000, 30000, 60000])(
"scans on open, then every %i ms",
async (refreshIntervalMs) => {
renderHook(
() =>
useUsageAutoRefresh({
refreshIntervalMs,
sessionAutoSyncEnabled: true,
}),
{ wrapper },
);
await flush();
expect(syncMock).toHaveBeenCalledTimes(1);
// 新用量由后端事件刷新,这里只刷新「上次同步」时间
expect(invalidate).toHaveBeenCalledTimes(1);
expect(invalidate).toHaveBeenCalledWith({ queryKey: lastSyncKey });
await advance(refreshIntervalMs - 1);
expect(syncMock).toHaveBeenCalledTimes(1);
await advance(1);
expect(syncMock).toHaveBeenCalledTimes(2);
await advance(refreshIntervalMs);
expect(syncMock).toHaveBeenCalledTimes(3);
},
);
it("scans right away and reschedules when the interval changes", async () => {
const { rerender } = renderHook(
({ interval }) =>
useUsageAutoRefresh({
refreshIntervalMs: interval,
sessionAutoSyncEnabled: true,
}),
{ wrapper, initialProps: { interval: 60000 } },
);
await flush();
expect(syncMock).toHaveBeenCalledTimes(1);
await advance(1000);
rerender({ interval: 5000 });
await flush();
expect(syncMock).toHaveBeenCalledTimes(2);
await advance(5000);
expect(syncMock).toHaveBeenCalledTimes(3);
});
it("never scans while session auto-sync is off", async () => {
const { rerender } = renderHook(
({ enabled }) =>
useUsageAutoRefresh({
refreshIntervalMs: 5000,
sessionAutoSyncEnabled: enabled,
}),
{ wrapper, initialProps: { enabled: false } },
);
await advance(20000);
expect(syncMock).not.toHaveBeenCalled();
expect(invalidate).not.toHaveBeenCalled();
rerender({ enabled: true });
await flush();
expect(syncMock).toHaveBeenCalledTimes(1);
rerender({ enabled: false });
await advance(20000);
expect(syncMock).toHaveBeenCalledTimes(1);
});
it("waits for a slow scan and shares it with manual sync without queueing more scans", async () => {
let finish!: (result: SessionSyncResult) => void;
syncMock.mockReturnValueOnce(
new Promise((resolve) => {
finish = resolve;
}),
);
const { result } = renderHook(
() =>
useUsageAutoRefresh({
refreshIntervalMs: 5000,
sessionAutoSyncEnabled: true,
}),
{ wrapper },
);
await flush();
const manualSync = result.current.syncSessionUsage();
await advance(20000);
expect(syncMock).toHaveBeenCalledTimes(1);
expect(invalidate).not.toHaveBeenCalled();
await act(async () => {
finish(syncResult);
await manualSync;
});
expect(invalidate).toHaveBeenCalledTimes(1);
await advance(5000);
expect(syncMock).toHaveBeenCalledTimes(2);
});
it("skips hidden windows, rescans once visible, and pauses during a rebuild", async () => {
focusManager.setFocused(false);
const { rerender } = renderHook(
({ paused }) =>
useUsageAutoRefresh({
refreshIntervalMs: 5000,
sessionAutoSyncEnabled: true,
paused,
}),
{ wrapper, initialProps: { paused: false } },
);
await advance(10000);
expect(syncMock).not.toHaveBeenCalled();
act(() => focusManager.setFocused(true));
await flush();
expect(syncMock).toHaveBeenCalledTimes(1);
rerender({ paused: true });
await advance(10000);
expect(syncMock).toHaveBeenCalledTimes(1);
rerender({ paused: false });
await flush();
expect(syncMock).toHaveBeenCalledTimes(2);
});
it.each(["disable", "unmount"] as const)(
"ignores a pending scan after %s",
async (action) => {
let finish!: (result: SessionSyncResult) => void;
syncMock.mockReturnValueOnce(
new Promise((resolve) => {
finish = resolve;
}),
);
const { rerender, unmount } = renderHook(
({ enabled }) =>
useUsageAutoRefresh({
refreshIntervalMs: 5000,
sessionAutoSyncEnabled: enabled,
}),
{ wrapper, initialProps: { enabled: true } },
);
await flush();
if (action === "disable") rerender({ enabled: false });
else unmount();
await act(async () => {
finish(syncResult);
});
await advance(60000);
expect(invalidate).not.toHaveBeenCalled();
expect(syncMock).toHaveBeenCalledTimes(1);
},
);
it("re-runs usage queries every minute for rolling ranges, even with scanning off", async () => {
const { rerender } = renderHook(
({ rolling }) =>
useUsageAutoRefresh({
refreshIntervalMs: 5000,
sessionAutoSyncEnabled: false,
rollingRange: rolling,
}),
{ wrapper, initialProps: { rolling: true } },
);
await advance(59_999);
expect(invalidate).not.toHaveBeenCalled();
await advance(1);
expect(invalidate).toHaveBeenCalledWith({ queryKey: usageKeys.all });
focusManager.setFocused(false);
await advance(60_000);
expect(invalidate).toHaveBeenCalledTimes(1);
focusManager.setFocused(true);
rerender({ rolling: false });
await advance(120_000);
expect(invalidate).toHaveBeenCalledTimes(1);
expect(syncMock).not.toHaveBeenCalled();
});
it("still updates the last sync time after a scan error and retries next time", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
syncMock.mockRejectedValueOnce(new Error("scan failed"));
renderHook(
() =>
useUsageAutoRefresh({
refreshIntervalMs: 5000,
sessionAutoSyncEnabled: true,
}),
{ wrapper },
);
await flush();
expect(warn).toHaveBeenCalledTimes(1);
expect(invalidate).toHaveBeenCalledTimes(1);
await advance(5000);
expect(syncMock).toHaveBeenCalledTimes(2);
expect(invalidate).toHaveBeenCalledTimes(2);
});
});