Files
siftlode/frontend/src/components/ChannelStatsPopover.dom.test.tsx
T
peter 5838cf1c56 test(channels): cover ActionMenu keyboard-skip + stats ETA value; drop redundant disabledIndices
Deep-review follow-up on R13 S3.

- Add a roving-keyboard-nav test proving a disabled ActionMenu item is
  skipped by arrow keys, and assert the stats "left" row renders the
  formatEta value (formatEta had no coverage anywhere).
- Remove the disabledIndices plumbing from AnchoredPopover + ActionMenu:
  it was redundant. useListNavigation already skips items via their DOM
  disabled/aria-disabled attribute, and PopoverItem renders a native
  disabled button — verified against @floating-ui/react 0.27
  isListIndexDisabled and by a mutation test. Behaviour is unchanged and
  now guarded by the new test.
2026-08-15 04:45:32 +02:00

99 lines
3.9 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 } 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((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");
});
});