Files
siftlode/frontend/src/components/downloadRow.dom.test.tsx
T
peter b4ad59274b feat(downloads): gate library row actions by media_kind (E7 S5b)
Surface the S5a media_kind discriminator in the library UI. A generic
uploaded file (media_kind='file') is download+share only — no player and
no timeline to trim, so Play/Edit/Edit-details are media-only; it shows a
neutral file glyph instead of an (always-empty) thumbnail and no duration
chip (container/size still read). The "Shared with me" tab's file row is
gated the same way. Re-download is now hidden for edit clips and uploads
too (neither has a re-fetchable source — the backend already rejects both
with 409), removing a latent click-to-409 on edit rows.

FE-only: the serializer already emits media_kind (S5a). Adds media_kind to
the DownloadAsset type and "upload" to the job_kind union; DOM tests cover
the file-row presentation (glyph, no img, no duration).
2026-08-29 18:38:21 +02:00

119 lines
3.8 KiB
TypeScript

// @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<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
import { DownloadRow } 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,
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<NonNullable<DownloadJob["asset"]>> = {},
): 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(<DownloadRow job={withAsset("mp4")} />);
// 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(<DownloadRow job={withAsset(null)} />);
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 <img>, and no duration chip (it stays media info
// like the container/size). Media rows keep their thumbnail path (an <img>, or empty until a poster).
describe("DownloadRow file kind", () => {
it("renders a file glyph, not a thumbnail <img>, for a generic file", () => {
const { container } = render(
<DownloadRow
job={withAsset("pdf", { media_kind: "file", duration_s: null, size_bytes: 2_400_000 })}
/>,
);
// The file glyph is an inline svg; no <img> 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 <img> when a poster exists) for a media asset", () => {
const { container } = render(
<DownloadRow job={{ ...withAsset("mp4"), poster_url: "/api/downloads/1/poster.jpg" }} />,
);
expect(container.querySelector("img")).not.toBeNull();
});
});