diff --git a/src/quota.ts b/src/quota.ts index 3420249..cd20726 100644 --- a/src/quota.ts +++ b/src/quota.ts @@ -237,11 +237,7 @@ export class QuotaTracker { ) {} async init() { - try { - await this.resolveCredential(); - } catch { - return; - } + await this.refresh(); } private async resolveCredential(generation = this.generation) { diff --git a/src/tui.tsx b/src/tui.tsx index 479788b..d6e1b90 100644 --- a/src/tui.tsx +++ b/src/tui.tsx @@ -60,9 +60,9 @@ export default Plugin.define({ setRevision((value) => value + 1); }); - const Slot = (props: { sessionID: string }) => { + const Slot = (props: { instance: string }) => { const [status] = createResource( - () => `${props.sessionID}:${revision()}`, + () => `${props.instance}:${revision()}`, () => loadQuotaStatus(() => quota.status({})), ); return ( @@ -83,12 +83,17 @@ export default Plugin.define({ ); }; - const remove = context.ui.slot({ + const removeHome = context.ui.slot({ + append: "home.footer.status", + render: () => , + }); + const removeSidebar = context.ui.slot({ append: "sidebar.footer", - render: (input) => , + render: (input) => , }); return () => { - remove(); + removeHome(); + removeSidebar(); unsubscribe(); clearInterval(retry); }; diff --git a/tests/quota.test.ts b/tests/quota.test.ts index ae536d8..6bd1dab 100644 --- a/tests/quota.test.ts +++ b/tests/quota.test.ts @@ -329,11 +329,11 @@ test("reads old cached snapshots without session estimates and removes excluded ]); expect(status?.windows[0]).not.toHaveProperty("sessionConsumed"); expect(result.stored.get("chatgpt-quota")).not.toHaveProperty("sessions"); - expect(result.calls()).toBe(0); + expect(result.calls()).toBe(1); await result.tracker.dispose(); }); -test("activates only for OAuth and refreshes quota at request start", async () => { +test("activates only for OAuth and refreshes quota at initialization and request start", async () => { const key = fixture({ type: "key", key: "secret" }, [plus]); await key.tracker.init(); await key.tracker.start("root"); @@ -360,10 +360,11 @@ test("activates only for OAuth and refreshes quota at request start", async () = ], ); await oauth.tracker.init(); - await oauth.tracker.start("root"); expect(oauth.calls()).toBe(1); + expect(oauth.tracker.status()?.windows[0]?.remaining).toBe(87.5); + await oauth.tracker.start("root"); + expect(oauth.calls()).toBe(2); expect(oauth.headers[0]?.get("chatgpt-account-id")).toBe("account-one"); - await oauth.tracker.refresh(); const status = oauth.tracker.status(); expect(status?.windows[0]?.remaining).toBe(82.5); expect(status?.windows[0]).not.toHaveProperty("sessionConsumed"); @@ -482,10 +483,10 @@ test("request completion refreshes once across terminal and idle events", async await result.tracker.init(); await result.tracker.start("root"); await result.tracker.complete("root"); - expect(result.tracker.status()?.windows[0]?.remaining).toBe(89); + expect(result.tracker.status()?.windows[0]?.remaining).toBe(70); await result.tracker.complete("root"); - expect(result.calls()).toBe(2); - expect(result.tracker.status()?.windows[0]?.remaining).toBe(89); + expect(result.calls()).toBe(3); + expect(result.tracker.status()?.windows[0]?.remaining).toBe(70); await result.tracker.dispose(); }); @@ -513,8 +514,7 @@ test("an auth switch detaches an in-flight old-account response and immediately }, ], ); - await result.tracker.init(); - const old = result.tracker.start("root"); + const old = result.tracker.init(); while (result.calls() === 0) await Bun.sleep(1); result.switch({ type: "oauth", diff --git a/tests/tui.test.ts b/tests/tui.test.ts index 9d3a26f..323b62b 100644 --- a/tests/tui.test.ts +++ b/tests/tui.test.ts @@ -5,7 +5,8 @@ import { createSignal } from "solid-js"; import { build } from "../scripts/build.js"; import type { QuotaStatus } from "../src/quota.js"; -type SlotRender = (input: { readonly sessionID: string }) => JSX.Element; +type SidebarSlotRender = (input: { readonly sessionID: string }) => JSX.Element; +type HomeSlotRender = (input: Record) => JSX.Element; const status = (plan: string, remaining: number): QuotaStatus => ({ plan, @@ -30,9 +31,13 @@ test("built quota slot stays reactive across pending, empty, failure, update, an ); const { default: plugin } = tui; const pending = Promise.withResolvers(); - const responses: Array<() => Promise> = [() => pending.promise]; + const responses: Array<() => Promise> = [ + async () => status("home", 90), + () => pending.promise, + ]; let statusCalls = 0; - let slotRender: SlotRender | undefined; + let sidebarRender: SidebarSlotRender | undefined; + let homeRender: HomeSlotRender | undefined; let notifyUpdated: (() => void) | undefined; const quota = { @@ -52,8 +57,17 @@ test("built quota slot stays reactive across pending, empty, failure, update, an const contextFixture = { client: { rpc: () => quota }, ui: { - slot(claim: { render: SlotRender }) { - slotRender = claim.render; + slot(claim: { + append: "home.footer.status" | "sidebar.footer"; + render: HomeSlotRender | SidebarSlotRender; + }) { + if (claim.append === "home.footer.status") { + // SAFETY: The home slot renderer receives the empty home slot input. + homeRender = claim.render as HomeSlotRender; + } else { + // SAFETY: The only other fixture path is the session-aware sidebar slot. + sidebarRender = claim.render as SidebarSlotRender; + } return () => undefined; }, }, @@ -63,10 +77,18 @@ test("built quota slot stays reactive across pending, empty, failure, update, an // members implemented by this focused rendering fixture. const context = Object.assign({} as Plugin.Context, contextFixture); const cleanup = await plugin.setup(context); - if (!slotRender || !notifyUpdated) throw new Error("Plugin did not register its slot"); + if (!sidebarRender || !homeRender || !notifyUpdated) + throw new Error("Plugin did not register its slots"); + + const renderHome = homeRender; + const home = await testRender(() => renderHome({}), { width: 70, height: 5 }); + await home.waitForFrame((frame) => frame.includes("ChatGPT quota (home)")); + expect(home.captureCharFrame()).toContain("5h: 90% ยท due"); + expect(statusCalls).toBe(1); + home.renderer.destroy(); const [sessionID, setSessionID] = createSignal("session-one"); - const renderSlot = slotRender; + const renderSlot = sidebarRender; const setup = await testRender( () => renderSlot({ @@ -94,13 +116,13 @@ test("built quota slot stays reactive across pending, empty, failure, update, an throw new Error("disconnected"); }); notifyUpdated(); - await setup.waitFor(() => statusCalls === 3); + await setup.waitFor(() => statusCalls === 4); expect(setup.captureCharFrame()).not.toContain("ChatGPT quota"); responses.push(async () => status("pro", 42)); setSessionID("session-two"); await setup.waitForFrame((frame) => frame.includes("ChatGPT quota (pro)")); - expect(statusCalls).toBe(4); + expect(statusCalls).toBe(5); } finally { setup.renderer.destroy(); if (cleanup) await cleanup();