`n.toLocaleString()` groups in the BROWSER's locale, so an English UI in a
Hungarian browser printed "1 234 567" while a Hungarian UI in an English
browser printed "1,234,567" — the number half of the date defect fixed in
618ef812. One `formatNumber` in lib/format.ts (a per-language cached
Intl.NumberFormat, like the relative-time formatters beside it) now serves
every grouped figure: Stats, Scheduler, the channel columns and the stats
popover, VideoCard's view tooltip, the ItemCount pill, and formatTotalHours.
ReleaseNotes' `toLocaleDateString(undefined, …)` was the one DATE site the
previous sweep missed; it moves onto formatDate.
The new cases assert both languages and that the two differ, instead of
comparing against the runtime's own default: that default is Hungarian on the
dev box and English on a CI image, so a one-language assertion would pass
under the old bug on half the machines. Verified in Chromium on :5173 with the
browser forced to hu-HU and the UI in English — Stats, Scheduler and the
channel table all keep comma grouping.
99 lines
4.0 KiB
TypeScript
99 lines
4.0 KiB
TypeScript
// @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<typeof import("react-i18next")>();
|
|
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
|
});
|
|
|
|
import ChannelStatsPopover from "./ChannelStatsPopover";
|
|
import { type MyStatus } from "../lib/api";
|
|
import { formatEta, formatNumber } 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> = {}): 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(
|
|
<FloatingTree>
|
|
<ChannelStatsPopover status={s} />
|
|
</FloatingTree>,
|
|
);
|
|
fireEvent.focus(screen.getByRole("button", { name: "channels.stats.label" }));
|
|
};
|
|
|
|
describe("ChannelStatsPopover", () => {
|
|
it("renders a compact Stats trigger, panel closed until opened", () => {
|
|
render(
|
|
<FloatingTree>
|
|
<ChannelStatsPopover status={status()} />
|
|
</FloatingTree>,
|
|
);
|
|
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(formatNumber(1234))).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");
|
|
});
|
|
});
|