Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 98 additions & 1 deletion src/App.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { StrictMode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { open } from "@tauri-apps/plugin-dialog";

Expand Down Expand Up @@ -898,6 +899,102 @@ describe("GSwitch account workspace", () => {
expect(screen.getByRole("button", { name: "唤醒 person@example.com" })).toBeEnabled();
});

describe("quota countdown updates", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2026, 9, 5, 12, 10));
Object.defineProperty(window.navigator, "language", { configurable: true, value: "zh-CN" });
mocks.listAccounts.mockResolvedValue([chatAccount]);
});

afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.useRealTimers();
});

function quotaAt(resetsAt: number): QuotaView {
return {
account_id: chatAccount.id,
status: "fresh",
snapshot: {
fetched_at_unix_ms: Date.now(),
buckets: [{
limit_id: "codex",
kind: "codex",
windows: [{ kind: "five_hour", window_duration_mins: 300, remaining_percent: 100, used_percent: 0, resets_at: resetsAt }],
}],
},
};
}

it.each(["refresh", "wake"] as const)("synchronizes a new reset time after %s while preserving focus and one timer", async (action) => {
const setInterval = vi.spyOn(window, "setInterval");
const clearInterval = vi.spyOn(window, "clearInterval");
const activeCountdownTimers = () => setInterval.mock.calls.flatMap(([, delay], index) => {
const id = setInterval.mock.results[index]!.value as number;
return delay === 60_000 && !clearInterval.mock.calls.some(([cleared]) => cleared === id) ? [id] : [];
});
mocks.accountQuota.mockResolvedValue(quotaAt(Date.now() / 1000 + 30 * 60));
let resolveRefresh!: (quota: QuotaView) => void;
mocks.refreshAccountQuota.mockImplementation(() => new Promise<QuotaView>((resolve) => { resolveRefresh = resolve; }));
mocks.wakeOperation.mockResolvedValue({
id: "wake-1",
status: "completed",
results: [{ account_id: chatAccount.id, result: "reply_received", request_state: "sent" }],
});
let view!: ReturnType<typeof render>;
await act(async () => { view = render(<StrictMode><App /></StrictMode>); });
const card = screen.getByRole("heading", { name: "person@example.com" }).closest(".account-card")!;
const resetTime = card.querySelector<HTMLTimeElement>("time.quota-reset-time")!;
expect(resetTime).toHaveTextContent(/^30分 · /);
expect(activeCountdownTimers()).toHaveLength(1);

await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: `${action === "wake" ? "唤醒" : "刷新"} person@example.com` }));
});
if (action === "wake") {
await act(async () => { await vi.advanceTimersByTimeAsync(500); });
const dialog = screen.getByRole("dialog", { name: "唤醒" });
await act(async () => { fireEvent.click(within(dialog).getByRole("button", { name: "完成" })); });
}
expect(mocks.refreshAccountQuota).toHaveBeenCalledTimes(1);
resetTime.focus();
const resetAt = Math.floor(Date.now() / 1000) + 5 * 60 * 60;
await act(async () => { resolveRefresh(quotaAt(resetAt)); });

expect(card.querySelector("time.quota-reset-time")).toBe(resetTime);
expect(resetTime).toHaveFocus();
expect(resetTime).toHaveTextContent(/^5小时 · /);
expect(resetTime).toHaveAttribute("datetime", new Date(resetAt * 1000).toISOString());
expect(activeCountdownTimers()).toHaveLength(1);

await act(async () => { await vi.advanceTimersByTimeAsync(60_000); });
expect(resetTime).toHaveTextContent(/^4小时59分 · /);
expect(resetTime).toHaveFocus();
view.unmount();
expect(activeCountdownTimers()).toHaveLength(0);
expect(vi.getTimerCount()).toBe(0);
});

it("immediately marks a replacement reset time that has already passed", async () => {
mocks.accountQuota.mockResolvedValue(quotaAt(Date.now() / 1000 + 30 * 60));
await act(async () => { render(<App />); });
const card = screen.getByRole("heading", { name: "person@example.com" }).closest(".account-card")!;
const resetTime = card.querySelector<HTMLTimeElement>("time.quota-reset-time")!;
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
const resetAt = Date.now() / 1000 - 1;
mocks.refreshAccountQuota.mockResolvedValue(quotaAt(resetAt));
await act(async () => { fireEvent.click(screen.getByRole("button", { name: "刷新 person@example.com" })); });

expect(card.querySelector("time.quota-reset-time")).toBe(resetTime);
expect(resetTime).toHaveTextContent("待刷新");
expect(resetTime).toHaveAttribute("datetime", new Date(resetAt * 1000).toISOString());
expect(resetTime).toHaveAttribute("aria-label", expect.stringContaining("待刷新"));
});
});

it("allows the same account to Wake again after an earlier completed request", async () => {
mocks.listAccounts.mockResolvedValue([chatAccount]);
mocks.accountQuota.mockResolvedValue({ ...staleQuota, status: "fresh" });
Expand Down
5 changes: 3 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -653,10 +653,11 @@ function QuotaResetTime({ timestamp, t, formatLocale }: {
formatLocale: string;
}) {
const [nowMs, setNowMs] = useState(Date.now);
useEffect(() => {
useLayoutEffect(() => {
setNowMs(Date.now());
const interval = window.setInterval(() => setNowMs(Date.now()), 60_000);
return () => window.clearInterval(interval);
}, []);
}, [timestamp]);

const fullTime = formatDateTime(timestamp, formatLocale);
const passed = timestamp * 1000 <= nowMs;
Expand Down
11 changes: 11 additions & 0 deletions src/i18n.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,15 @@ describe("GSwitch language settings", () => {
expect(formatQuotaResetTime(now / 1000 + 1, "en-US", now)).toContain("1m · ");
expect(formatQuotaResetTime(now / 1000 - 1, "zh-CN", now)).toBe("待刷新");
});

it("rounds quota countdowns up without capping a real duration longer than five hours", () => {
const now = new Date(2026, 9, 5, 12, 10).getTime();
const fiveHours = now / 1000 + 5 * 60 * 60;
expect(formatQuotaResetTime(fiveHours, "zh-CN", now)).toBe("5小时 · 10/05 17:10");
expect(formatQuotaResetTime(fiveHours, "en-US", now)).toBe("5h · 10/05 17:10");
expect(formatQuotaResetTime(fiveHours + 30, "zh-CN", now)).toBe("5小时1分 · 10/05 17:10");
expect(formatQuotaResetTime(fiveHours + 30, "en-US", now)).toBe("5h 1m · 10/05 17:10");
expect(formatQuotaResetTime(fiveHours - 30, "zh-CN", now)).toBe("5小时 · 10/05 17:09");
expect(formatQuotaResetTime(fiveHours - 30, "en-US", now)).toBe("5h · 10/05 17:09");
});
});
Loading