// @vitest-environment jsdom // // Contract of the catalog-stats hover chip: the compact trigger, the rows it reveals on open (value // formatting + the conditional "left" row), and — the point of the R13-S3 review fix — that each row // still carries its per-stat explanation as a `title`, so the guidance the old inline stats showed is // not lost. i18n is mocked to a key-passthrough so assertions read the stable keys, not translations. // The AnchoredPopover platform is floating-ui based (hover mode), so we stub the observers autoUpdate // wires and wrap in a FloatingTree, and open via focus (hover mode enables useFocus). import { describe, it, expect, afterEach, beforeAll, vi } from "vitest"; import { render, screen, fireEvent, cleanup } from "@testing-library/react"; import { FloatingTree } from "@floating-ui/react"; // Key-passthrough t so assertions read stable keys — but keep the rest of react-i18next intact // (i18n/index.ts calls i18n.use(initReactI18next) at import time). vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; }); import ChannelStatsPopover from "./ChannelStatsPopover"; import { type MyStatus } from "../lib/api"; import { formatEta } from "../lib/format"; beforeAll(() => { class Stub { observe() {} unobserve() {} disconnect() {} takeRecords() { return []; } } globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver; }); afterEach(cleanup); const status = (over: Partial = {}): MyStatus => ({ channels_total: 10, channels_recent_synced: 3, channels_deep_done: 1, channels_deep_requested: 4, deep_pending_count: 0, deep_eta_seconds: 120, my_videos: 1234, quota_remaining_today: 5000, ...over, }) as MyStatus; const open = (s: MyStatus) => { render( , ); fireEvent.focus(screen.getByRole("button", { name: "channels.stats.label" })); }; describe("ChannelStatsPopover", () => { it("renders a compact Stats trigger, panel closed until opened", () => { render( , ); expect(screen.getByRole("button", { name: "channels.stats.label" })).toBeTruthy(); expect(screen.queryByText("channels.stats.channels")).toBeNull(); }); it("reveals one row per stat with formatted values", () => { open(status()); expect(screen.getByText("channels.stats.recentSynced")).toBeTruthy(); expect(screen.getByText("3/10")).toBeTruthy(); // recent-synced ratio expect(screen.getByText("1/4")).toBeTruthy(); // full-history ratio expect(screen.getByText((1234).toLocaleString())).toBeTruthy(); // my_videos, locale-agnostic }); it("shows the 'left' ETA row only while a backfill is pending, with the formatted ETA value", () => { open(status({ deep_pending_count: 0 })); expect(screen.queryByText("channels.stats.left")).toBeNull(); cleanup(); open(status({ deep_pending_count: 2, deep_eta_seconds: 120 })); expect(screen.getByText("channels.stats.left")).toBeTruthy(); // The value is the FORMATTED ETA (via formatEta), not raw seconds — asserting it guards the // formatEta wiring, which is untested anywhere else. Using the same formatEta keeps this // locale/impl-agnostic (expected === what the component renders). expect(screen.getByText(formatEta(120))).toBeTruthy(); expect(screen.queryByText("120")).toBeNull(); // never the raw seconds }); it("keeps each stat's explanation as a per-row title (hint not lost)", () => { open(status()); const row = screen.getByText("channels.stats.channels").closest("div"); expect(row?.getAttribute("title")).toBe("channels.stats.channelsHint"); }); });