From a968a18a11a6dd53c0298be93611cf40f1af6aab Mon Sep 17 00:00:00 2001 From: SquarePots <46488165+squarepots@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:03:52 +0800 Subject: [PATCH] Fix quota reset countdown after refresh --- src/App.test.tsx | 99 +++++++++++++++++++++++++++++++++++++++++++++++- src/App.tsx | 5 ++- src/i18n.test.ts | 11 ++++++ 3 files changed, 112 insertions(+), 3 deletions(-) diff --git a/src/App.test.tsx b/src/App.test.tsx index 68aa9d5..b1930b8 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -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"; @@ -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((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; + await act(async () => { view = render(); }); + const card = screen.getByRole("heading", { name: "person@example.com" }).closest(".account-card")!; + const resetTime = card.querySelector("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(); }); + const card = screen.getByRole("heading", { name: "person@example.com" }).closest(".account-card")!; + const resetTime = card.querySelector("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" }); diff --git a/src/App.tsx b/src/App.tsx index e887260..feedfe5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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; diff --git a/src/i18n.test.ts b/src/i18n.test.ts index 11b2070..f849110 100644 --- a/src/i18n.test.ts +++ b/src/i18n.test.ts @@ -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"); + }); });