// @vitest-environment jsdom // // Library-row metadata (E7·S1): the completed row surfaces the concrete container/format alongside the // already-shown size. The format chip reads the raw `asset.container` (uppercased in the UI), so it must // render when present and be absent when the asset has no container yet (queued/running before the worker // fills it). i18n is key-passthrough here. import { describe, it, expect, afterEach, vi } from "vitest"; import { render, screen, cleanup } from "@testing-library/react"; vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; }); import { DownloadRow, IconBtn } from "./downloadRow"; import type { DownloadJob } from "../lib/api"; const baseJob: DownloadJob = { id: 1, status: "done", progress: 100, speed_bps: null, eta_s: null, phase: null, error: null, error_code: null, error_detail: null, queue_pos: 0, created_at: null, source_kind: "youtube", source_ref: "abc", source_url: null, poster_url: null, profile_id: null, spec: { mode: "av", max_height: null, container: null, audio_format: null, vcodec: null, embed_subs: false, embed_chapters: false, embed_thumbnail: false, sponsorblock: false, }, display_name: "Clip", display_uploader: null, display_uploader_url: null, extra_links: [], can_download: true, expired: false, asset: null, }; const withAsset = ( container: string | null, over: Partial> = {}, ): DownloadJob => ({ ...baseJob, asset: { id: 10, status: "ready", title: "Clip", uploader: null, uploader_url: null, upload_date: null, duration_s: 90, size_bytes: 148_800_000, width: 1920, height: 1080, container, media_kind: "video", thumbnail_url: null, expires_at: null, ...over, }, }); afterEach(cleanup); describe("DownloadRow format chip", () => { it("shows the uppercased container next to the size when ready", () => { render(); // Raw container is stored lowercase; the row uppercases it via CSS, so the text node stays "mp4". expect(screen.getByText(/·\s*mp4/i)).toBeTruthy(); expect(screen.getByText(/142 MB/)).toBeTruthy(); }); it("omits the format chip when the asset has no container yet", () => { const { container } = render(); expect(container.textContent).not.toMatch(/·\s*(mp4|m4a|webm)/i); }); }); // E7·S5b — a generic uploaded file (media_kind='file') has no video frame and no timeline: the row // shows a neutral file glyph instead of a thumbnail , and no duration chip (it stays media info // like the container/size). Media rows keep their thumbnail path (an , or empty until a poster). describe("DownloadRow file kind", () => { it("renders a file glyph, not a thumbnail , for a generic file", () => { const { container } = render( , ); // The file glyph is an inline svg; no element is emitted for a file. expect(container.querySelector("img")).toBeNull(); expect(container.querySelector("svg")).not.toBeNull(); // File info still reads (container + size), but there is no duration chip. expect(screen.getByText(/·\s*pdf/i)).toBeTruthy(); expect(screen.getByText(/2\.3 MB/)).toBeTruthy(); }); it("keeps the thumbnail path (an when a poster exists) for a media asset", () => { const { container } = render( , ); expect(container.querySelector("img")).not.toBeNull(); }); }); describe("DownloadRow · keyboard", () => { it("is a list item only when it is given a position", () => { const { container, rerender } = render(); expect(container.firstElementChild?.getAttribute("data-nav-index")).toBe("3"); rerender(); expect(container.firstElementChild?.hasAttribute("data-nav-index")).toBe(false); }); it("marks the action that opens the row, and only that one", () => { render( {}} title="play"> p {}} title="delete"> d , ); expect(screen.getByTitle("play").hasAttribute("data-nav-open")).toBe(true); expect(screen.getByTitle("delete").hasAttribute("data-nav-open")).toBe(false); }); });