Files
siftlode/frontend/src/components/LibraryPlayer.dom.test.tsx
T
peter f41c81eb56 fix(downloads): media-library UI review fixes (E7)
Deep-review remediation on the frontend, all findings adversarially verified:

- LibraryPlayer picks audio vs video from the canonical media_kind (single source), not a
  hardcoded container whitelist, so an uploaded audio file with an uncommon/absent container
  renders in <audio> instead of a broken video box (legacy null media_kind keeps the heuristic).
- UploadDialog aborts the in-flight upload when the dialog is closed by X/ESC/backdrop/Back
  (not only the footer Cancel); drag highlight uses an enter/leave depth counter to stop the
  flicker; the progress bar gets a stable role=progressbar aria-label with the value on aria-valuenow.
- DownloadCenter's Shared tab renders honest loading/error states instead of a blank panel, and
  the library/shared row-action gating moves to a single tested helper (lib/downloadActions).

Tests: downloadActions gating matrix, the uploadReq XHR path (abort/401/422-localize/onerror),
and the media_kind-driven audio/video branch.

i18n: downloads.upload.uploadProgress (HU + EN).
2026-08-29 21:52:13 +02:00

126 lines
3.8 KiB
TypeScript

// @vitest-environment jsdom
//
// E7·S4b — the in-app library player streams the completed item's LOCAL file into a native media
// element. video/audio is chosen from the stored media_kind (the single source), NOT a container
// list — so an audio upload with an uncommon/absent container still renders <audio src=/file>. i18n
// is key-passthrough here.
import { describe, it, expect, afterEach, vi } from "vitest";
import { render, 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 LibraryPlayer from "./LibraryPlayer";
import type { DownloadJob } from "../lib/api";
const baseJob: DownloadJob = {
id: 7,
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 = (over: Partial<NonNullable<DownloadJob["asset"]>>): DownloadJob => ({
...baseJob,
asset: {
id: 10,
status: "ready",
title: "Clip",
uploader: null,
uploader_url: null,
upload_date: null,
duration_s: 12,
size_bytes: 1000,
width: null,
height: null,
container: null,
media_kind: "video",
thumbnail_url: null,
expires_at: null,
...over,
},
});
afterEach(cleanup);
describe("LibraryPlayer", () => {
it("plays a video asset in a <video> pointed at the range-aware /file endpoint", () => {
render(
<LibraryPlayer
job={withAsset({ width: 1280, height: 720, container: "mp4" })}
onClose={() => {}}
/>,
);
const video = document.body.querySelector("video");
expect(video).not.toBeNull();
expect(video!.getAttribute("src")).toBe("/api/downloads/7/file");
// No <audio> element for a video asset.
expect(document.body.querySelector("audio")).toBeNull();
});
it("plays an audio-only asset (media_kind='audio') in an <audio> element", () => {
render(
<LibraryPlayer
job={withAsset({ media_kind: "audio", container: "m4a" })}
onClose={() => {}}
/>,
);
const audio = document.body.querySelector("audio");
expect(audio).not.toBeNull();
expect(audio!.getAttribute("src")).toBe("/api/downloads/7/file");
expect(document.body.querySelector("video")).toBeNull();
});
it("uses media_kind — an audio upload with an uncommon container still renders <audio>", () => {
// The exact bug the fix targets: the old container whitelist (m4a/mp3/…) would drop an .m4b/.weba
// audio upload into a <video> box. media_kind='audio' is authoritative regardless of container.
render(
<LibraryPlayer
job={withAsset({ media_kind: "audio", container: "m4b" })}
onClose={() => {}}
/>,
);
expect(document.body.querySelector("audio")).not.toBeNull();
expect(document.body.querySelector("video")).toBeNull();
});
it("falls back to the container heuristic for a legacy null media_kind", () => {
// A pre-discriminator asset (media_kind null) still resolves audio vs video from dims+container.
render(
<LibraryPlayer job={withAsset({ media_kind: null, container: "mp3" })} onClose={() => {}} />,
);
expect(document.body.querySelector("audio")).not.toBeNull();
});
});