From a2d2512d6a05f3fd009c44bd7e6cd3900d873bbb Mon Sep 17 00:00:00 2001 From: Patrick Rho Date: Fri, 25 Sep 2026 14:01:09 +0900 Subject: [PATCH] feat(usage): one unified MagicGrid board for all quota cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator direction after the sectioned layout: single-account providers should not own a row — every card belongs on ONE grid regardless of provider or account count, with provider identity on the card itself. - QuotaCardGrid rebuilt as a flat MagicGrid board: uniform fixed-width cards (280px desktop / 260px compact, full-width on phones so MagicGrid computes a single column), shortest-column-first placement, provider icon + label + plan badge on every card. Per-provider
sections and grouping helpers are gone; the page header's PROVIDER filter remains the way to narrow the board. - MagicGrid wiring: static:true + our own ResizeObserver / MutationObserver / window-resize triggers, all disconnected on unmount. listen() is never called (it registers an unremovable window resize listener — leak). - Guard tests: new quota-unified-magic-grid.test.ts pins the architecture (no section markup, mobile w-full -> 1 column, density at 1200px, listener hygiene, compact track < full track). #7072/#6815/horizontal- layout guards superseded and removed (their guarantees re-pinned here); #8916 compact guard updated for the new mechanism. - Found en route: ts.forEachChild STOPS on a truthy visitor return — the AST collectors must use void-returning callbacks (the concise-arrow version silently collected only the first child). --- package-lock.json | 7 + package.json | 3 +- .../ProviderLimits/QuotaCardGrid.tsx | 277 +++++++----------- ...uota-card-grid-compact-layout-8916.test.ts | 16 +- .../unit/quota-card-grid-density-6815.test.ts | 192 ------------ .../quota-card-grid-horizontal-layout.test.ts | 164 ----------- .../unit/quota-card-grid-mobile-7072.test.ts | 141 --------- tests/unit/quota-unified-magic-grid.test.ts | 122 ++++++++ 8 files changed, 242 insertions(+), 680 deletions(-) delete mode 100644 tests/unit/quota-card-grid-density-6815.test.ts delete mode 100644 tests/unit/quota-card-grid-horizontal-layout.test.ts delete mode 100644 tests/unit/quota-card-grid-mobile-7072.test.ts create mode 100644 tests/unit/quota-unified-magic-grid.test.ts diff --git a/package-lock.json b/package-lock.json index 6636a338d58..51d4a41a0cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52,6 +52,7 @@ "jsonc-parser": "^3.3.1", "lowdb": "^7.0.1", "lucide-react": "^1.33.0", + "magic-grid": "^3.4.7", "marked": "^18.0.11", "marked-terminal": "^7.3.0", "material-symbols": "^0.47.0", @@ -28122,6 +28123,12 @@ "lz-string": "bin/bin.js" } }, + "node_modules/magic-grid": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/magic-grid/-/magic-grid-3.4.7.tgz", + "integrity": "sha512-SPy2dmzAzBr8AysPXH6gT9bE9PYIFX/ZQCf/XRojXFnuyFeHkITV2gp6j/7msrcqSDt6Tu+XUtLfQF79Ugc6/g==", + "license": "MIT" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", diff --git a/package.json b/package.json index 579c4a6f4e0..a253ca2f979 100644 --- a/package.json +++ b/package.json @@ -335,6 +335,7 @@ "jsonc-parser": "^3.3.1", "lowdb": "^7.0.1", "lucide-react": "^1.33.0", + "magic-grid": "^3.4.7", "marked": "^18.0.11", "marked-terminal": "^7.3.0", "material-symbols": "^0.47.0", @@ -439,8 +440,8 @@ "lockfile-lint": "^5.0.1", "node-loader": "^2.1.0", "opencode-ai": "1.18.30", - "pretendard": "^1.3.9", "playwright-ctrf-json-reporter": "^0.0.29", + "pretendard": "^1.3.9", "prettier": "^3.9.6", "promptfoo": "^0.123.0", "size-limit": "^13.0.3", diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCardGrid.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCardGrid.tsx index d1057798b20..bbc4ac34f95 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCardGrid.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCardGrid.tsx @@ -1,11 +1,26 @@ "use client"; -import type { ReactNode } from "react"; -import { useState } from "react"; -import { PROVIDER_ORDER } from "./constants"; +/** + * QuotaCardGrid — ONE unified MagicGrid board of quota cards. + * + * Every connection — any provider, any account count — renders as the same + * fixed-width card and flows into the same grid (shortest-column-first), so + * single-account providers sit right next to multi-account ones instead of + * stretching across the whole row or hiding in a per-provider section. + * Provider identity lives on the card itself (icon + label + plan badge). + * + * Layout engine: magic-grid (MagicGrid). Cards keep their CSS width + * (`w-full` on phones → 1 column; `sm:w-[280px]` → as many 280px columns as + * fit, capped by maxColumns). MagicGrid only computes positions; we drive + * `positionItems()` from our own ResizeObserver / MutationObserver / window + * resize listeners so every listener is disconnectable on unmount (the + * library's `listen()` leaks a window listener and has no destroy). + */ + +import { useEffect, useRef } from "react"; +import MagicGrid from "magic-grid"; import QuotaCard from "./QuotaCard"; -import { worstStatus, type CardStatus, compareProviderGroups } from "./utils"; -import { compareTr } from "@/shared/utils/turkishText"; +import { worstStatus, type CardStatus } from "./utils"; interface Props { connections: any[]; @@ -15,7 +30,7 @@ interface Props { lastRefreshedAt: Record; emailsVisible: boolean; providerLabels: Record; - renderInlineQuotaSummary?: (quota: any) => ReactNode; + renderInlineQuotaSummary?: (quota: any) => React.ReactNode; onRefresh: (id: string, provider: string) => void; onOpenCutoff: (connection: any) => void; onOpenResetCredits?: (id: string, provider: string) => void; @@ -27,6 +42,7 @@ interface Props { quotaVisibility?: Record; onHideQuota?: (provider: string, quota: any) => void; onShowQuota?: (provider: string, quota: any) => void; + /** Compact home-widget density: narrower cards on the same board. */ compact?: boolean; } @@ -115,112 +131,11 @@ export function sortProviderConnectionsByPriority( }); } -function buildProviderGroups( - connections: any[], - quotaData: Record, - providerLabels: Record -) { - const groups = new Map(); - for (const conn of connections) { - const list = groups.get(conn.provider) ?? []; - list.push(conn); - groups.set(conn.provider, list); - } - - return [...groups.entries()] - .map(([provider, conns]) => ({ - provider, - connections: sortProviderConnectionsByPriority(conns, quotaData), - })) - .sort((a, b) => - compareProviderGroups(a.provider, b.provider, { - providerOrder: PROVIDER_ORDER, - providerLabels, - compare: compareTr, - }) - ); -} - -interface ProviderQuotaSectionProps extends Omit { - provider: string; - connections: any[]; - defaultOpen?: boolean; -} - -function ProviderQuotaSection({ - provider, - connections, - defaultOpen = true, - quotaData, - loading, - errors, - lastRefreshedAt, - emailsVisible, - providerLabels, - onRefresh, - onOpenCutoff, - onOpenResetCredits, - onToggleActive, - togglingActiveId, - redeemingResetCreditId = null, - loadingResetCreditsId = null, - quotaVisibility, - onHideQuota, - onShowQuota, -}: ProviderQuotaSectionProps) { - const [open, setOpen] = useState(defaultOpen); - const activeCount = connections.filter((conn) => conn.isActive ?? true).length; - const providerLabel = providerLabels[provider] || provider; - - return ( -
setOpen((event.target as HTMLDetailsElement).open)} - className="rounded-lg border border-border bg-surface overflow-hidden" - > - - - {open ? "expand_less" : "expand_more"} - -
-

- {providerLabel} -

-

- {activeCount} active / {connections.length} account - {connections.length !== 1 ? "s" : ""} -

-
-
-
-
- {connections.map((conn) => ( - onRefresh(conn.id, conn.provider)} - onOpenCutoff={() => onOpenCutoff(conn)} - onOpenResetCredits={() => onOpenResetCredits?.(conn.id, conn.provider)} - onToggleActive={(nextActive) => onToggleActive(conn.id, nextActive)} - togglingActive={togglingActiveId === conn.id} - redeemingResetCredit={redeemingResetCreditId === conn.id} - loadingResetCredits={loadingResetCreditsId === conn.id} - quotaVisibility={quotaVisibility} - onHideQuota={onHideQuota ? (q) => onHideQuota(conn.provider, q) : undefined} - onShowQuota={onShowQuota ? (q) => onShowQuota(conn.provider, q) : undefined} - /> - ))} -
-
-
- ); -} +/** Card width per density mode — phones go full-width (single MagicGrid column). */ +const CARD_WIDTH_CLASS = { + full: "w-full sm:w-[280px]", + compact: "w-full sm:w-[260px]", +} as const; export default function QuotaCardGrid({ connections, @@ -243,71 +158,85 @@ export default function QuotaCardGrid({ onShowQuota, compact = false, }: Props) { - if (connections.length === 0) return null; - - const renderCard = (conn: (typeof connections)[number]) => ( - onRefresh(conn.id, conn.provider)} - onOpenCutoff={() => onOpenCutoff(conn)} - onOpenResetCredits={() => onOpenResetCredits?.(conn.id, conn.provider)} - onToggleActive={(nextActive) => onToggleActive(conn.id, nextActive)} - togglingActive={togglingActiveId === conn.id} - redeemingResetCredit={redeemingResetCreditId === conn.id} - loadingResetCredits={loadingResetCreditsId === conn.id} - quotaVisibility={quotaVisibility} - onHideQuota={onHideQuota ? (q) => onHideQuota(conn.provider, q) : undefined} - onShowQuota={onShowQuota ? (q) => onShowQuota(conn.provider, q) : undefined} - /> - ); + const containerRef = useRef(null); + + const sorted = sortProviderConnectionsByPriority(connections, quotaData); + + // MagicGrid lifecycle: one instance per mount; `static: true` bypasses the + // item-count gate so we can position partial loads, and we NEVER call + // listen() (it registers an unremovable window resize listener). All + // reposition triggers are ours and cleaned up below. + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const grid = new MagicGrid({ + container, + static: true, + gutter: 12, + maxColumns: 8, + useMin: true, + animate: true, + }); + + let frame = 0; + const reposition = () => { + if (frame) return; + frame = requestAnimationFrame(() => { + frame = 0; + grid.positionItems(); + }); + }; + + // Card heights change without React state changes: "Show 5 more" + // expanders, loading → data swaps, refreshed countdown text. + const mutation = new MutationObserver(reposition); + mutation.observe(container, { childList: true, subtree: true, characterData: true }); + + const resize = new ResizeObserver(reposition); + resize.observe(container); + + window.addEventListener("resize", reposition); + reposition(); + + return () => { + if (frame) cancelAnimationFrame(frame); + mutation.disconnect(); + resize.disconnect(); + window.removeEventListener("resize", reposition); + }; + // Instance is per-mount; repositioning is observer-driven. + }, []); - // Compact mode: flat 3-column card grid, across all connections. - if (compact) { - return ( -
- {connections.map(renderCard)} -
- ); - } - - // Default layout: expandable provider sections. Provider groups are ordered - // deterministically (PROVIDER_ORDER rank → label → key) so the group order - // never shuffles between quota refreshes, and connections are - // priority-sorted within each provider. - const groups = buildProviderGroups(connections, quotaData, providerLabels); + if (connections.length === 0) return null; return ( -
- {groups.map(({ provider, connections: conns }) => ( - +
+ {sorted.map((conn) => ( +
+ onRefresh(conn.id, conn.provider)} + onOpenCutoff={() => onOpenCutoff(conn)} + onOpenResetCredits={() => onOpenResetCredits?.(conn.id, conn.provider)} + onToggleActive={(nextActive) => onToggleActive(conn.id, nextActive)} + togglingActive={togglingActiveId === conn.id} + redeemingResetCredit={redeemingResetCreditId === conn.id} + loadingResetCredits={loadingResetCreditsId === conn.id} + quotaVisibility={quotaVisibility} + onHideQuota={onHideQuota ? (q) => onHideQuota(conn.provider, q) : undefined} + onShowQuota={onShowQuota ? (q) => onShowQuota(conn.provider, q) : undefined} + /> +
))}
); diff --git a/tests/unit/quota-card-grid-compact-layout-8916.test.ts b/tests/unit/quota-card-grid-compact-layout-8916.test.ts index bfb445b0f86..5e78c9d0cc7 100644 --- a/tests/unit/quota-card-grid-compact-layout-8916.test.ts +++ b/tests/unit/quota-card-grid-compact-layout-8916.test.ts @@ -136,14 +136,14 @@ test("QuotaCardGrid exports a compact prop on its Props interface", () => { ); }); -test("QuotaCardGrid compact mode renders an auto-fill minmax grid", () => { - const classNames = collectDivClassNames(COMPONENTS.quotaCardGrid); - const compactGrid = classNames.find((cn) => - cn.includes("grid-cols-[repeat(auto-fill,minmax(17rem,1fr))]") - ); - assert.ok( - compactGrid, - "compact mode must render grid-cols-[repeat(auto-fill,minmax(17rem,1fr))]" +test("QuotaCardGrid compact mode renders the narrower MagicGrid card track", () => { + // 2026-09 redesign: the compact layout is the same unified MagicGrid board + // with a narrower per-card track (see quota-unified-magic-grid.test.ts). + const code = fs.readFileSync(COMPONENTS.quotaCardGrid, "utf8"); + assert.match( + code, + /compact:\s*"w-full sm:w-\[\d+px\]"/, + "compact mode must keep a dedicated narrower card track in CARD_WIDTH_CLASS" ); }); diff --git a/tests/unit/quota-card-grid-density-6815.test.ts b/tests/unit/quota-card-grid-density-6815.test.ts deleted file mode 100644 index 1893db33947..00000000000 --- a/tests/unit/quota-card-grid-density-6815.test.ts +++ /dev/null @@ -1,192 +0,0 @@ -// #6815 — Provider Quota page horizontal density. -// -// #6815 changed QuotaCardGrid.tsx's per-group card grid from a -// single-column-only layout (`flex flex-col`) to one that packs multiple -// QuotaCards side by side on wide screens, instead of stacking every card -// vertically no matter how much horizontal space is available. -// -// That guarantee was only ever asserted *incidentally*, by two other guards -// that pinned the literal Tailwind token the #6815 implementation happened -// to use at the time (`sm:grid-cols-2` in -// tests/unit/quota-card-grid-mobile-7072.test.ts and -// tests/unit/quota-card-grid-horizontal-layout.test.ts). When PR #7027 -// migrated the component from a fixed breakpoint ladder -// (`grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4`) to a -// container-driven auto-fit template -// (`grid-cols-[repeat(auto-fit,minmax(min(100%,280px),1fr))]`), that literal -// token disappeared from the source and both guards were edited to stop -// asserting it — silently deleting the only coverage #6815 had. -// -// This guard re-establishes dedicated coverage for the #6815 density -// guarantee itself, decoupled from *how* the component achieves it. Instead -// of matching a specific class-name token, it simulates, from the shipped -// className(s), how many columns the per-group card grid would actually -// render at a wide container width — supporting both mechanisms seen in this -// component's history (a Tailwind breakpoint ladder, and a CSS auto-fit -// `minmax()` template) — and asserts that count is >1. Reverting to a single -// unconditional column (`grid-cols-1` with no responsive/auto-fit variants, -// or dropping the grid entirely for `flex flex-col`) must fail this guard, -// regardless of which mechanism produced the regression. - -import { test } from "node:test"; -import assert from "node:assert/strict"; -import fs from "node:fs"; -import path from "node:path"; -import ts from "typescript"; - -const COMPONENT_PATH = path.resolve( - import.meta.dirname, - "../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCardGrid.tsx" -); - -/** - * Extract the string literal passed to `className={...}` (or `className="..."`) - * for every JSX `
` opening element in the component's source, in source - * order, via the TypeScript compiler API (not a hand-rolled regex — tracks - * the real AST so it can't be fooled by comments/whitespace). - */ -function extractDivClassNames(sourcePath: string): string[] { - const sourceText = fs.readFileSync(sourcePath, "utf8"); - const sourceFile = ts.createSourceFile( - sourcePath, - sourceText, - ts.ScriptTarget.Latest, - true, - ts.ScriptKind.TSX - ); - const classNames: string[] = []; - - function visit(node: ts.Node) { - if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) { - const tagName = node.tagName.getText(sourceFile); - if (tagName === "div") { - for (const attr of node.attributes.properties) { - if (ts.isJsxAttribute(attr) && attr.name.getText(sourceFile) === "className") { - const init = attr.initializer; - if (init && ts.isStringLiteral(init)) { - classNames.push(init.text); - } else if ( - init && - ts.isJsxExpression(init) && - init.expression && - ts.isStringLiteral(init.expression) - ) { - classNames.push(init.expression.text); - } - } - } - } - } - ts.forEachChild(node, visit); - } - visit(sourceFile); - return classNames; -} - -// Tailwind's default min-width breakpoints (px). Unprefixed utilities apply -// at every width (breakpoint 0) and later/larger breakpoints win the cascade -// once their min-width is met, mirroring Tailwind's mobile-first source order. -const TAILWIND_BREAKPOINTS: Record = { - sm: 640, - md: 768, - lg: 1024, - xl: 1280, - "2xl": 1536, -}; - -type ColumnRule = - | { breakpoint: number; kind: "fixed"; columns: number } - | { breakpoint: number; kind: "autofit"; trackPx: number }; - -/** - * Parse every `grid-cols-*` utility (optionally breakpoint-prefixed) found in - * a className string into a column rule, supporting both mechanisms this - * component has shipped with: - * - a fixed count, e.g. `grid-cols-2`, `md:grid-cols-3` - * - a CSS auto-fit template, e.g. - * `grid-cols-[repeat(auto-fit,minmax(min(100%,280px),1fr))]`, from which - * the minimum track width (in px) is extracted. - */ -function parseColumnRules(className: string): ColumnRule[] { - const rules: ColumnRule[] = []; - for (const token of className.split(/\s+/).filter(Boolean)) { - const prefixMatch = token.match(/^(?:([a-zA-Z0-9]+):)?grid-cols-(.+)$/); - if (!prefixMatch) continue; - const [, prefix, rest] = prefixMatch; - const breakpoint = prefix ? (TAILWIND_BREAKPOINTS[prefix] ?? 0) : 0; - - if (/^\d+$/.test(rest)) { - rules.push({ breakpoint, kind: "fixed", columns: parseInt(rest, 10) }); - continue; - } - - const autoFitMatch = rest.match( - /^\[repeat\((?:auto-fill|auto-fit),\s*minmax\((.+),\s*1fr\)\)\]$/ - ); - if (autoFitMatch) { - const trackPxMatches = [...autoFitMatch[1].matchAll(/(\d+)px/g)]; - if (trackPxMatches.length > 0) { - const trackPx = parseInt(trackPxMatches[trackPxMatches.length - 1][1], 10); - rules.push({ breakpoint, kind: "autofit", trackPx }); - } - } - } - return rules; -} - -/** - * Given a className string, estimate how many columns the grid renders at a - * given container/viewport width, by picking the widest matching breakpoint - * rule (Tailwind cascade) and resolving fixed vs. auto-fit tracks. Returns 1 - * (single column) when no `grid-cols-*` rule is present at all — e.g. a - * `flex flex-col` layout. - */ -function estimateColumnsAtWidth(className: string, widthPx: number): number { - const rules = parseColumnRules(className).filter((r) => r.breakpoint <= widthPx); - if (rules.length === 0) return 1; - const active = rules.reduce((best, r) => (r.breakpoint >= best.breakpoint ? r : best)); - if (active.kind === "fixed") return active.columns; - return Math.max(1, Math.floor(widthPx / active.trackPx)); -} - -// --- Self-test of the estimator against known-good and known-bad shapes --- -// (independent of the real component, so the simulation logic itself is -// pinned before it's trusted to judge the shipped source below). - -test("#6815 density estimator — breakpoint ladder resolves to multiple columns on a wide viewport", () => { - const className = "grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-3"; - assert.equal(estimateColumnsAtWidth(className, 1200), 3); - assert.equal(estimateColumnsAtWidth(className, 375), 1); -}); - -test("#6815 density estimator — auto-fit template resolves to multiple columns on a wide container", () => { - const className = "grid grid-cols-[repeat(auto-fit,minmax(min(100%,280px),1fr))] gap-3"; - assert.ok(estimateColumnsAtWidth(className, 1200) >= 2); - assert.equal(estimateColumnsAtWidth(className, 200), 1); -}); - -test("#6815 density estimator — single unconditional column stays at 1 column regardless of width", () => { - assert.equal(estimateColumnsAtWidth("grid grid-cols-1 gap-3", 1920), 1); -}); - -test("#6815 density estimator — flex column stack (no grid-cols) resolves to 1 column", () => { - assert.equal(estimateColumnsAtWidth("flex flex-col gap-3", 1920), 1); -}); - -// --- The actual regression guard, reading the shipped component source --- - -test("QuotaCardGrid (#6815) — per-group card grid renders multiple columns on a wide container", () => { - const classNames = extractDivClassNames(COMPONENT_PATH); - const cardGridClassName = classNames.find((c) => /\bgrid\b/.test(c) && /grid-cols-/.test(c)); - assert.ok( - cardGridClassName, - "expected to find a grid-based per-group card grid className (not a single-column flex stack)" - ); - - const columnsOnWideContainer = estimateColumnsAtWidth(cardGridClassName!, 1200); - assert.ok( - columnsOnWideContainer > 1, - `expected the per-group card grid to render more than 1 column at 1200px, got ${columnsOnWideContainer} ` + - `from className="${cardGridClassName}" — this is the #6815 density regression` - ); -}); diff --git a/tests/unit/quota-card-grid-horizontal-layout.test.ts b/tests/unit/quota-card-grid-horizontal-layout.test.ts deleted file mode 100644 index e43dfad628c..00000000000 --- a/tests/unit/quota-card-grid-horizontal-layout.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -// Provider Quota page should be easy to scan provider-by-provider. -// -// QuotaCardGrid used to flow provider groups into CSS columns. That made -// high-account operators scan left/right across unrelated providers and made -// Codex/Claude quota priority hard to follow. This regression guard asserts -// the shipped JSX structure and grouping logic directly: -// 1. Grouping still produces one header per distinct provider with the -// correct account count ("N account(s)"). -// 2. Each provider group's cards auto-fit into as many 280px columns as that -// group's actual width supports, while a card can shrink to the group's -// width when its container is narrower than 280px. -// 3. Provider groups themselves are one-column expandable sections, not a -// two-column masonry flow. -// -// Note: QuotaCardGrid's sibling QuotaCard pulls in next/image + provider-icon -// resolution that only works inside the real Next.js runtime, so this file -// exercises the two testable seams directly instead of full SSR-rendering the -// tree: (a) the pure grouping function extracted below, mirroring the -// component's own grouping logic, and (b) the literal className contract of -// the component's JSX (static string literals, not derived at runtime), -// parsed from source via the TypeScript compiler API so the assertions track -// the real shipped markup rather than a hand-copied string. - -import { test } from "node:test"; -import assert from "node:assert/strict"; -import fs from "node:fs"; -import path from "node:path"; -import ts from "typescript"; - -const COMPONENT_PATH = path.resolve( - import.meta.dirname, - "../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCardGrid.tsx" -); - -function groupByProvider(connections: T[]): Map { - const groups = new Map(); - for (const conn of connections) { - const list = groups.get(conn.provider) ?? []; - list.push(conn); - groups.set(conn.provider, list); - } - return groups; -} - -test("QuotaCardGrid (#3520) — groups connections by provider with correct counts", () => { - const groups = groupByProvider([ - { id: "conn-a1", provider: "openai" }, - { id: "conn-a2", provider: "openai" }, - { id: "conn-b1", provider: "anthropic" }, - ]); - assert.deepEqual([...groups.keys()], ["openai", "anthropic"]); - assert.equal(groups.get("openai")!.length, 2); - assert.equal(groups.get("anthropic")!.length, 1); -}); - -/** - * Extract the string literal passed to `className={...}` (or `className="..."`) - * for every JSX `
` opening element in the component's `return (...)` JSX, - * in source order, via the TypeScript compiler API (not a hand-rolled regex — - * tracks the real AST so it can't be fooled by comments/whitespace). - */ -function extractDivClassNames(sourcePath: string): string[] { - const sourceText = fs.readFileSync(sourcePath, "utf8"); - const sourceFile = ts.createSourceFile( - sourcePath, - sourceText, - ts.ScriptTarget.Latest, - true, - ts.ScriptKind.TSX - ); - const classNames: string[] = []; - - function visit(node: ts.Node) { - if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) { - const tagName = node.tagName.getText(sourceFile); - if (tagName === "div") { - for (const attr of node.attributes.properties) { - if (ts.isJsxAttribute(attr) && attr.name.getText(sourceFile) === "className") { - const init = attr.initializer; - if (init && ts.isStringLiteral(init)) { - classNames.push(init.text); - } else if ( - init && - ts.isJsxExpression(init) && - init.expression && - ts.isStringLiteral(init.expression) - ) { - classNames.push(init.expression.text); - } - } - } - } - } - ts.forEachChild(node, visit); - } - visit(sourceFile); - return classNames; -} - -test("QuotaCardGrid — outer provider sections stay in one vertical reading order", () => { - const classNames = extractDivClassNames(COMPONENT_PATH); - const outerClassName = classNames.find((className) => /\bspace-y-\d+\b/.test(className)); - assert.ok( - outerClassName, - "expected the component to render a vertical provider-section container" - ); - assert.doesNotMatch( - outerClassName!, - /\bcolumns-/, - "provider sections should not flow into two CSS columns" - ); - assert.match( - outerClassName, - /(?:^|\s)(?:space-y-\d+|flex)(?:\s|$)/, - "provider sections should render as one vertical list" - ); -}); - -test("QuotaCardGrid — provider groups are expandable details sections", () => { - const sourceText = fs.readFileSync(COMPONENT_PATH, "utf8"); - assert.match(sourceText, /"); - assert.match(sourceText, / { - const sourceText = fs.readFileSync(COMPONENT_PATH, "utf8"); - assert.match( - sourceText, - /\bPROVIDER_ORDER\b/, - "expected provider sections to use provider order" - ); - assert.match( - sourceText, - /\bsortProviderConnectionsByPriority\b/, - "expected accounts inside a provider to use a dedicated priority sort" - ); - assert.match(sourceText, /\bisActive\b/, "expected active accounts to sort before inactive ones"); - assert.match( - sourceText, - /\bgetSoonestResetMs\b/, - "expected account priority to consider the next quota reset" - ); -}); - -test("QuotaCardGrid (#3520) — cards follow actual group width with a narrow-container fallback", () => { - const classNames = extractDivClassNames(COMPONENT_PATH); - const cardGridClassName = classNames.find((c) => /\bgrid\b/.test(c) && /grid-cols-/.test(c)); - assert.ok(cardGridClassName, "expected to find the actual-width per-group card grid's className"); - assert.match( - cardGridClassName, - /(?:^|\s)grid-cols-\[repeat\(auto-fit,minmax\(min\(100%,280px\),1fr\)\)\](?:\s|$)/, - "expected 280px auto-fit columns that can shrink to 100% in a narrower group container" - ); - assert.doesNotMatch( - cardGridClassName, - /(?:^|\s)(?:grid-cols-2|md:grid-cols-3|xl:grid-cols-4)(?:\s|$)/ - ); -}); - -test("QuotaCardGrid (#3520) — early-returns null when there are no connections", () => { - const sourceText = fs.readFileSync(COMPONENT_PATH, "utf8"); - assert.match(sourceText, /if\s*\(\s*connections\.length\s*===\s*0\s*\)\s*return\s*null;/); -}); diff --git a/tests/unit/quota-card-grid-mobile-7072.test.ts b/tests/unit/quota-card-grid-mobile-7072.test.ts deleted file mode 100644 index a8e31c7ac5e..00000000000 --- a/tests/unit/quota-card-grid-mobile-7072.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -// #7072 — Provider Quota page card grid clipped on mobile. -// -// PR #6815 changed QuotaCardGrid.tsx's per-group card grid from -// `grid-cols-1 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4` to -// `grid-cols-2 md:grid-cols-3 xl:grid-cols-4`, dropping the mobile (<768px) -// single-column fallback that every other card-grid in the dashboard still -// has (ProviderQuotaWidget.tsx, EvalsTab.tsx, MediaPageClient.tsx, -// SystemStorageTab.tsx). Forcing 2 columns even on phone widths squeezes -// each QuotaCard, and since QuotaCard's outer Card uses `overflow-hidden`, -// the overflowing button/label text is clipped instead of wrapping. -// -// This regression guard parses QuotaCardGrid.tsx's JSX via the TypeScript -// compiler API and asserts the per-group card grid's className guarantees a -// single column on mobile-width viewports — the BEHAVIOR that fixes #7072 — -// rather than one specific token. Two implementations satisfy this: -// 1. Breakpoint-driven (#7194): an unprefixed `grid-cols-1` token forces a -// single column below `sm:`, widening at larger breakpoints. -// 2. Container-driven (#7027): an arbitrary-value -// `grid-cols-[repeat(auto-fit,minmax(min(100%,Npx),1fr))]` template -// where the minimum track width is wide enough that a second track -// cannot fit on any realistic phone viewport. -// Either way, reverting to the pre-#7072 forced `grid-cols-2` (no -// unprefixed 1-column fallback, no auto-fit) must fail this guard. - -import { test } from "node:test"; -import assert from "node:assert/strict"; -import fs from "node:fs"; -import path from "node:path"; -import ts from "typescript"; - -const COMPONENT_PATH = path.resolve( - import.meta.dirname, - "../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCardGrid.tsx" -); - -function extractDivClassNames(sourcePath: string): string[] { - const sourceText = fs.readFileSync(sourcePath, "utf8"); - const sourceFile = ts.createSourceFile( - sourcePath, - sourceText, - ts.ScriptTarget.Latest, - true, - ts.ScriptKind.TSX - ); - const classNames: string[] = []; - - function visit(node: ts.Node) { - if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) { - const tagName = node.tagName.getText(sourceFile); - if (tagName === "div") { - for (const attr of node.attributes.properties) { - if (ts.isJsxAttribute(attr) && attr.name.getText(sourceFile) === "className") { - const init = attr.initializer; - if (init && ts.isStringLiteral(init)) { - classNames.push(init.text); - } else if ( - init && - ts.isJsxExpression(init) && - init.expression && - ts.isStringLiteral(init.expression) - ) { - classNames.push(init.expression.text); - } - } - } - } - } - ts.forEachChild(node, visit); - } - visit(sourceFile); - return classNames; -} - -// Minimum track width (px) below which two auto-fit columns could plausibly -// fit side by side on a real phone viewport (narrowest common width ~320px). -// Requiring the min track to clear this bar keeps auto-fit implementations -// honest about the same guarantee the breakpoint model gives explicitly. -const MIN_MOBILE_SAFE_TRACK_PX = 200; - -function assertSingleColumnMobileFallback(cardGridClassName: string): void { - const tokens = cardGridClassName.split(/\s+/); - - // Breakpoint-driven layout (#7194 style): an unprefixed `grid-cols-N` - // token controls the base (mobile-first, <640px) column count directly. - const unprefixedGridCols = tokens.find((t) => /^grid-cols-\d+$/.test(t)); - if (unprefixedGridCols) { - assert.equal( - unprefixedGridCols, - "grid-cols-1", - `breakpoint-driven grid must keep an unprefixed grid-cols-1 mobile fallback, got className="${cardGridClassName}"` - ); - return; - } - - // Container-driven layout (#7027 style): an arbitrary-value auto-fill/auto-fit - // grid template computes column count from available width, not a breakpoint. - // auto-fill is the required shape since the 2026-09 consistency redesign: - // auto-fit collapses empty tracks, stretching a provider's lone card - // full-width while multi-account providers show narrow cards. - const autoFitToken = tokens.find( - (t) => - t.startsWith("grid-cols-[") && - (t.includes("auto-fill") || t.includes("auto-fit")) && - t.includes("minmax(") - ); - assert.ok( - autoFitToken, - `expected either an unprefixed grid-cols-1 mobile fallback or a repeat(auto-fill, minmax(...)) ` + - `container grid, got className="${cardGridClassName}"` - ); - assert.ok( - autoFitToken!.includes("auto-fill"), - "per-group card grid must use auto-fill (not auto-fit): auto-fit stretches a lone card full-width" - ); - - const minTrackMatch = - autoFitToken!.match(/minmax\(min\(100%,(\d+)px\)/) ?? autoFitToken!.match(/minmax\((\d+)px/); - assert.ok( - minTrackMatch, - `expected the auto-fit grid's minmax() to specify a numeric minimum track width, got "${autoFitToken}"` - ); - const minTrackPx = Number(minTrackMatch![1]); - - // A second card-wide column can only appear once the container is at - // least 2x the minimum track width. Requiring the track minimum to clear - // MIN_MOBILE_SAFE_TRACK_PX guards against reintroducing #7072's forced - // 2-column-on-mobile clipping via an undersized auto-fit track. - assert.ok( - minTrackPx >= MIN_MOBILE_SAFE_TRACK_PX, - `auto-fit min track width too small (${minTrackPx}px) — two columns could fit on a mobile ` + - `viewport, reintroducing the #7072 clipping regression` - ); -} - -test("QuotaCardGrid (#7072) — per-group card grid keeps a single-column mobile fallback", () => { - const classNames = extractDivClassNames(COMPONENT_PATH); - const cardGridClassName = classNames.find((c) => /\bgrid\b/.test(c) && /grid-cols-/.test(c)); - assert.ok(cardGridClassName, "expected to find the per-group card grid's className"); - - assertSingleColumnMobileFallback(cardGridClassName!); -}); diff --git a/tests/unit/quota-unified-magic-grid.test.ts b/tests/unit/quota-unified-magic-grid.test.ts new file mode 100644 index 00000000000..284a6802efd --- /dev/null +++ b/tests/unit/quota-unified-magic-grid.test.ts @@ -0,0 +1,122 @@ +// Unified MagicGrid quota board (2026-09 redesign). +// +// Operator direction: stop grouping cards into per-provider expandable +// sections — ONE flat board where every connection (any provider, any +// account count) is the same fixed-width card, laid out by magic-grid +// (https://github.com/e-oj/Magic-Grid) shortest-column-first. Provider +// identity lives on the card (icon + label + plan badge), so nothing is +// lost by dropping the section headers. +// +// These guards pin the new architecture at the three seams that matter: +// +// 1. NO per-provider grouping markup survives in QuotaCardGrid (no +//
/ sections, no group headers) — the whole point of +// the redesign. +// 2. Mobile (#7072 guarantee, new mechanism): card wrappers are `w-full` +// at the base breakpoint, which makes MagicGrid compute exactly one +// column on phones (colWidth = container width), and widen to a fixed +// sm: track >= 200px so two never fit on a phone. +// 3. Density (#6815 guarantee, new mechanism): with the sm: track width and +// the configured gutter, a 1200px container yields >= 2 columns. +// 4. MagicGrid wiring: `static: true` + own observers, and listen() is +// never called (it leaks an unremovable window resize listener). + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import ts from "typescript"; + +const COMPONENT_PATH = path.resolve( + import.meta.dirname, + "../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCardGrid.tsx" +); + +function parseSource(sourcePath: string): ts.SourceFile { + return ts.createSourceFile(sourcePath, fs.readFileSync(sourcePath, "utf8"), ts.ScriptTarget.Latest, true); +} + +function collectStringLiterals(node: ts.Node, out: string[] = []): string[] { + if (ts.isStringLiteral(node)) out.push(node.text); + // forEachChild STOPS on a truthy visitor return — the callback must be void. + ts.forEachChild(node, (child) => { + collectStringLiterals(child, out); + }); + return out; +} + +const source = parseSource(COMPONENT_PATH); +const allLiterals = collectStringLiterals(source); + + +test("unified board — no per-provider section markup survives in QuotaCardGrid", () => { + assert.ok( + !allLiterals.includes("details") && !allLiterals.includes("summary"), + "QuotaCardGrid must not render per-provider
/ sections — cards flow on one unified board" + ); + const code = fs.readFileSync(COMPONENT_PATH, "utf8"); + assert.ok( + !/buildProviderGroups|ProviderQuotaSection/.test(code), + "grouping helpers must be gone — sortProviderConnectionsByPriority + one flat list is the model now" + ); +}); + +test("unified board — card width: full-width on phones, fixed >= 260px track from sm up", () => { + const widthLiterals = allLiterals.filter((c) => c.includes("w-full") && c.includes("sm:w-[")); + assert.ok( + widthLiterals.length >= 1, + `expected the MagicGrid item wrapper to carry "w-full sm:w-[Npx]" (found none); got ${JSON.stringify(allLiterals)}` + ); + for (const cls of widthLiterals) { + assert.match(cls, /\bw-full\b/, "base (mobile) width must be full — MagicGrid then computes 1 column"); + const match = cls.match(/sm:w-\[(\d+)px\]/); + assert.ok(match, `expected a fixed sm: track width in "${cls}"`); + assert.ok( + Number(match![1]) >= 200, + `sm: track ${match![1]}px is narrow enough for 2 columns on a phone (#7072 regression)` + ); + } +}); + +test("unified board — density: configured track + gutter yield >= 2 columns at 1200px", () => { + const widths = allLiterals + .map((c) => c.match(/sm:w-\[(\d+)px\]/)?.[1]) + .filter((n): n is string => Boolean(n)) + .map(Number); + assert.ok(widths.length >= 1, "expected a fixed sm: card width literal"); + const track = Math.min(...widths); + // MagicGrid: numCols = floor(containerWidth / (track + gutter)); gutter 12 in the config test below. + const columnsAt1200 = Math.floor(1200 / (track + 12)); + assert.ok( + columnsAt1200 >= 2, + `track ${track}px + gutter 12 yields only ${columnsAt1200} column(s) at 1200px (#6815 density regression)` + ); +}); + +test("unified board — MagicGrid wiring: static config, shortest-column-first, no listen()", () => { + const code = fs.readFileSync(COMPONENT_PATH, "utf8"); + assert.match(code, /new MagicGrid\(/, "QuotaCardGrid must drive a MagicGrid instance"); + assert.match(code, /static:\s*true/, "MagicGrid must be configured static (partial loads position immediately)"); + assert.match(code, /useMin:\s*true/, "useMin (shortest-column-first) is the masonry behavior the board needs"); + assert.match(code, /maxColumns:\s*\d+/, "maxColumns must be capped so ultrawide containers stay scannable"); + assert.ok( + !/\.listen\(\)/.test(code), + "listen() must NOT be called — it registers an unremovable window resize listener (leak on unmount)" + ); + // Reposition triggers must be ours and cleaned up. + assert.match(code, /new MutationObserver\(/, "card height changes (expander, loading→data) must reposition"); + assert.match(code, /new ResizeObserver\(/, "container resize must reposition"); + assert.match(code, /mutation\.disconnect\(\)/, "MutationObserver must be disconnected on unmount"); + assert.match(code, /resize\.disconnect\(\)/, "ResizeObserver must be disconnected on unmount"); +}); + +test("unified board — compact density keeps its own narrower track", () => { + const code = fs.readFileSync(COMPONENT_PATH, "utf8"); + const full = code.match(/full:\s*"w-full sm:w-\[(\d+)px\]"/); + const compact = code.match(/compact:\s*"w-full sm:w-\[(\d+)px\]"/); + assert.ok(full && compact, "expected a CARD_WIDTH_CLASS map with full + compact tracks"); + assert.ok( + Number(compact![1]) < Number(full![1]), + `compact track (${compact![1]}px) should be narrower than full (${full![1]}px)` + ); +});