mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-10-25 17:22:27 +08:00
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.
254 lines
7.6 KiB
TypeScript
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);
|
|
});
|
|
});
|