// @vitest-environment jsdom // // Library-row metadata: 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, fireEvent } from "@testing-library/react"; vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useTranslation: () => ({ i18n: { language: "en" }, 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); }); }); // 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); }); // The row once resized its actions with a `[&_button]:size-8` override, which a save LINK in the // same row escaped (28px beside 32px buttons). The size is the primitive's own rung, set by IconBtn. it("sizes its actions through the primitive's rung, not a descendant override", () => { const { container } = render( {}} title="play"> p , ); expect(screen.getByTitle("play").className).toContain("size-8"); expect(container.innerHTML).not.toContain("[&_button]"); }); }); // The row answers to its own width (a container query) and its meta line never wraps — on // the iPad, zoomed in, its "18.9 / MB" pieces broke into a jumble (the owner, 2026-09-27). jsdom lays // nothing out, so this holds the structure the layout rests on; the widths were measured in a browser. describe("DownloadRow · narrow", () => { it("keeps the meta line one cut line of inline text, not a flex row that wraps its pieces", () => { const { container } = render( , ); const meta = container.querySelector("[data-dl-meta]")!; expect(meta.className.split(/\s+/)).toContain("truncate"); expect(meta.className).not.toMatch(/(^|\s)(inline-)?flex(\s|$)/); for (const el of meta.querySelectorAll("*")) expect(el.className.toString()).not.toMatch(/(^|\s)(inline-)?flex(\s|$)/); }); it("is its own size container and stacks below 40rem", () => { const { container } = render(); const row = container.firstElementChild as HTMLElement; expect(row.className).toContain("@container/dlrow"); const grid = row.firstElementChild as HTMLElement; expect(grid.className).toContain( "@max-[40rem]/dlrow:[grid-template-areas:'thumb_head''meta_meta''foot_foot''prog_prog']", ); }); it("stacked, puts the source link and the actions on ONE wrapping line, not a line each", () => { const { container } = render( {}} title="play"> x , ); const link = container.querySelector("a[href='https://example.com/v']")!; const foot = screen.getByTitle("play").closest(".contents")!; expect(foot.contains(link)).toBe(true); for (const c of ["@max-[40rem]/dlrow:[grid-area:foot]", "@max-[40rem]/dlrow:flex-wrap"]) expect(foot.className).toContain(c); }); it("gives the failure link a real 24px line on a mouse, not the text's 20 (DESIGN B1)", () => { render( {}} />, ); const link = screen.getByTitle("downloads.errors.fail.title"); const cls = link.className.split(/\s+/); expect(cls).toEqual(expect.arrayContaining(["inline-block", "min-h-6", "leading-6"])); }); it("draws nothing for an empty link list (a bare 0 once leaked into the row)", () => { const { container } = render(); const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT); const texts: string[] = []; while (walker.nextNode()) texts.push(walker.currentNode.textContent ?? ""); expect(texts).not.toContain("0"); }); }); // A link queued (or failed) before its details: the title, the channel line and the link line all // fell back to the same URL (the owner, 2026-09-27). Now the title IS the short link, once. describe("DownloadRow · a link with no details yet", () => { const bare: DownloadJob = { ...baseJob, status: "error", source_kind: "url", source_ref: "https://www.facebook.com/watch?v=12345", source_url: "https://www.facebook.com/watch?v=12345", display_name: null, asset: null, }; it("shows the short link once, as the title's link, with no channel line", () => { const { container } = render(); const links = [...container.querySelectorAll("a")].filter((a) => a.href === bare.source_url); expect(links.map((a) => a.textContent)).toEqual(["www.facebook.com/watch?v=12345"]); expect(container.textContent).not.toContain("https://"); }); // Measured at 21px on the Downloads page: every link in the row stands on a 24px line on // a mouse (DESIGN B1), as the link line and the failure link already did. it("gives the title link and the channel link a 24px line", () => { const { container } = render( , ); const title = [...container.querySelectorAll("a")].find((a) => a.href === bare.source_url)!; const channel = container.querySelector("a[href='https://example.com/c']")!; // inline-block: an inline link's box is the font's height (21px) whatever its line height. for (const a of [title, channel]) expect(a.className.split(/\s+/)).toEqual( expect.arrayContaining(["inline-block", "leading-6"]), ); }); it("keeps the named row as it was: title text, and the link on its own line", () => { const named = { ...bare, display_name: "A clip" }; const { container } = render(); expect(screen.getByText("A clip").closest("a")).toBeNull(); const links = [...container.querySelectorAll("a")].filter((a) => a.href === bare.source_url); expect(links.map((a) => a.textContent)).toEqual(["www.facebook.com/watch?v=12345"]); }); }); // No picture at all: a quiet glyph of what it is instead of an empty box (the owner, 2026-09-27). describe("DownloadRow · no picture", () => { it("draws a film glyph for a video, a note for audio", () => { const { container, unmount } = render(); expect(container.querySelector("img")).toBeNull(); expect(container.querySelector("svg.lucide-film")).not.toBeNull(); unmount(); const { container: audio } = render( , ); expect(audio.querySelector("svg.lucide-music")).not.toBeNull(); }); // The remote thumbnail was the last resort with no fallback of its own — when it // failed too (a dead CDN link), the row drew the browser's broken-image mark. it("falls back to the glyph when the remote picture fails too", () => { const { container } = render( , ); const img = container.querySelector("img")!; expect(img.getAttribute("src")).toBe("https://cdn.example/dead.jpg"); fireEvent.error(img); expect(container.querySelector("img")).toBeNull(); expect(container.querySelector("svg.lucide-film")).not.toBeNull(); }); }); // The phone-safe conversion reports ffmpeg's real progress, so it gets the % bar a download has; // a yt-dlp post-step reports none and keeps the pulse. describe("DownloadRow · a running conversion", () => { const running = (phase: string): DownloadJob => ({ ...baseJob, status: "running", progress: 40, phase, }); it("shows its phase and its %", () => { const { container } = render(); expect(screen.getByText("downloads.phase.converting")).toBeTruthy(); expect(screen.getByText("40%")).toBeTruthy(); expect(container.querySelector(".animate-pulse")).toBeNull(); }); it("a post-step with no % keeps the pulse", () => { const { container } = render(); expect(screen.queryByText("40%")).toBeNull(); expect(container.querySelector(".animate-pulse")).not.toBeNull(); }); });