import { readFileSync } from "node:fs"; import { isValidElement, type ReactElement, type ReactNode } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; import { buildCollectorTransferPackages, buildCollectorWorkspaceViewModel, mergeCollectorEnrichment, mergeCollectorPackages, removeCollectorLinks, selectCollectorPackageLinks, type CollectorPackage } from "../src/renderer/views/collector/collector-model"; import { CollectorContent, CollectorInputDialog, MemoizedCollectorContent, CollectorSidebar, CollectorToolbar, CollectorView, toggleAllCollectorPackageIds, type CollectorViewActions } from "../src/renderer/views/collector/CollectorView"; function visitElements(node: ReactNode, visit: (element: ReactElement) => void): void { if (Array.isArray(node)) { node.forEach((child) => visitElements(child, visit)); return; } if (!isValidElement(node)) return; visit(node); visitElements(node.props.children, visit); visitElements(node.props.actions, visit); } function findElement(node: ReactNode, predicate: (element: ReactElement) => boolean): ReactElement { let result: ReactElement | null = null; visitElements(node, (element) => { if (!result && predicate(element)) result = element; }); if (!result) throw new Error("Element not found"); return result; } function findButton(node: ReactNode, label: string): ReactElement { return findElement(node, (element) => element.type === "button" && element.props.children === label); } function createActions(overrides: Partial = {}): CollectorViewActions { return { onFilterChange: () => {}, onOpenInput: () => {}, onImportDlc: () => {}, onImportFile: () => {}, onSubmitSelected: () => {}, onSubmitAll: () => {}, onQueryChange: () => {}, onLinkSelectionChange: () => {}, onPackageSelectionChange: () => {}, onPackageCollapseChange: () => {}, onToggleAllPackages: () => {}, onRemoveSelected: () => {}, ...overrides }; } const packages: CollectorPackage[] = [{ id: "package-sbs", name: "SBS14HD", nameSource: "inferred", addedAt: 1_000, links: [ { id: "link-1", url: "https://1fichier.com/?one11111", fileName: "SBS14HD.part01.rar", fileSizeBytes: 471_859_200, hoster: "1fichier", availability: "online", status: "ready", addedAt: 1_000 }, { id: "link-2", url: "https://1fichier.com/?two22222", fileName: "SBS14HD.part02.rar", fileSizeBytes: 471_859_200, hoster: "1fichier", availability: "online", status: "ready", addedAt: 1_000 } ] }, { id: "package-mixed", name: "Mixed", nameSource: "inferred", addedAt: 2_000, links: [ { id: "link-3", url: "https://example.test/unknown", fileName: "download.bin", fileSizeBytes: null, hoster: "example", availability: "unknown", status: "unknown", addedAt: 2_000 }, { id: "link-4", url: "https://example.test/offline", fileName: "offline.bin", fileSizeBytes: null, hoster: "example", availability: "offline", status: "offline", addedAt: 2_000 } ] }]; describe("collector workspace model", () => { it("merges late enrichment by URL without duplicates and moves the link into its resolved package", () => { const initial: CollectorPackage[] = [{ id: "pending", name: "Unsortiert", nameSource: "inferred", addedAt: 1_000, links: [{ id: "stable-link", url: "https://1fichier.com/?one11111", fileName: "download.bin", fileSizeBytes: null, hoster: "1fichier", availability: "unknown", status: "unknown", addedAt: 1_000 }] }]; const enriched: CollectorPackage[] = [{ id: "resolved", name: "SBS14HD", nameSource: "inferred", addedAt: 2_000, links: [{ id: "replacement-id", url: "https://1fichier.com/?one11111", fileName: "SBS14HD.part01.rar", fileSizeBytes: 471_859_200, hoster: "1fichier", availability: "online", status: "ready", addedAt: 2_000 }] }]; const result = mergeCollectorPackages(initial, enriched); expect(result).toEqual(expect.objectContaining({ addedLinks: 0, duplicateLinks: 0, enrichedLinks: 1 })); expect(result.packages).toHaveLength(1); expect(result.packages[0].name).toBe("SBS14HD"); expect(result.packages[0].links).toEqual([expect.objectContaining({ id: "stable-link", fileName: "SBS14HD.part01.rar", fileSizeBytes: 471_859_200, availability: "online", status: "ready", addedAt: 1_000 })]); }); it("deduplicates repeated incoming URLs while preserving distinct links", () => { const incoming: CollectorPackage[] = [{ id: "incoming", name: "SBS14HD", nameSource: "inferred", addedAt: 3_000, links: [ { ...packages[0].links[0], id: "duplicate" }, { ...packages[0].links[0], id: "duplicate-again" }, { id: "link-5", url: "https://1fichier.com/?three333", fileName: "SBS14HD.part03.rar", fileSizeBytes: 10, hoster: "1fichier", availability: "online", status: "ready", addedAt: 3_000 } ] }]; const result = mergeCollectorPackages(packages, incoming); expect(result.addedLinks).toBe(1); expect(result.duplicateLinks).toBe(2); expect(result.packages[0].links.map((link) => link.id)).toEqual(["link-1", "link-2", "link-5"]); }); it("does not restore links removed while background enrichment is running", () => { const current = [{ ...packages[0], links: [packages[0].links[0]] }]; const incoming = [{ ...packages[0], links: packages[0].links.map((link) => ({ ...link, status: "ready" as const })) }]; expect(mergeCollectorEnrichment(current, incoming).packages[0].links.map((link) => link.id)).toEqual(["link-1"]); expect(mergeCollectorEnrichment([], incoming).packages).toEqual([]); }); it("does not replace known metadata with a repeated unknown skeleton", () => { const current = [{ ...packages[0], links: [packages[0].links[0]] }]; const incoming: CollectorPackage[] = [{ id: "pending", name: "1Fichier", nameSource: "inferred", addedAt: 4_000, links: [{ ...packages[0].links[0], id: "replacement", fileName: "download.bin", fileSizeBytes: null, availability: "unknown", status: "unknown", addedAt: 4_000 }] }]; const result = mergeCollectorPackages(current, incoming); expect(result.packages).toHaveLength(1); expect(result.packages[0].name).toBe("SBS14HD"); expect(result.packages[0].links[0]).toEqual(expect.objectContaining({ id: "link-1", fileName: "SBS14HD.part01.rar", fileSizeBytes: 471_859_200, availability: "online", status: "ready", addedAt: 1_000 })); }); it("applies a resolved package name even when link metadata was already complete", () => { const current: CollectorPackage[] = [{ id: "pending", name: "Unsortiert", nameSource: "inferred", addedAt: 1_000, links: [{ ...packages[0].links[0], id: "stable-link" }] }]; const incoming: CollectorPackage[] = [{ id: "resolved", name: "SBS14HD", nameSource: "inferred", addedAt: 2_000, links: [{ ...packages[0].links[0], id: "replacement-link" }] }]; const result = mergeCollectorPackages(current, incoming); expect(result.enrichedLinks).toBe(1); expect(result.duplicateLinks).toBe(0); expect(result.packages.map((pkg) => [pkg.name, pkg.links.map((link) => link.id)])).toEqual([ ["SBS14HD", ["stable-link"]] ]); }); it("keeps explicit package names authoritative over inferred enrichment", () => { const current: CollectorPackage[] = [{ id: "explicit", name: "Meine Staffel", nameSource: "explicit", addedAt: 1_000, links: [{ ...packages[0].links[0], id: "stable-link", availability: "unknown", status: "unknown" }] }]; const incoming: CollectorPackage[] = [{ id: "inferred", name: "SBS14HD", nameSource: "inferred", addedAt: 2_000, links: [{ ...packages[0].links[0], id: "replacement-link" }] }]; const result = mergeCollectorPackages(current, incoming); expect(result.packages).toHaveLength(1); expect(result.packages[0]).toEqual(expect.objectContaining({ name: "Meine Staffel", nameSource: "explicit" })); expect(result.packages[0].links[0]).toEqual(expect.objectContaining({ id: "stable-link", availability: "online", status: "ready" })); }); it("upgrades an inferred package identity when explicit metadata arrives", () => { const current: CollectorPackage[] = [{ id: "inferred", name: "SBS14HD", nameSource: "inferred", addedAt: 1_000, links: [{ ...packages[0].links[0], id: "stable-link", availability: "unknown", status: "unknown" }] }]; const incoming: CollectorPackage[] = [{ id: "explicit", name: "SBS14HD", nameSource: "explicit", addedAt: 2_000, links: [{ ...packages[0].links[0], id: "replacement-link" }] }]; const result = mergeCollectorPackages(current, incoming); expect(result.packages[0].nameSource).toBe("explicit"); }); it("supports whole-package selection, partial transfers and partial removal", () => { const selected = selectCollectorPackageLinks(new Set(["link-3"]), packages[0], true); expect([...selected].sort()).toEqual(["link-1", "link-2", "link-3"]); expect(buildCollectorTransferPackages(packages, new Set(["link-2", "link-3"])).map((pkg) => [pkg.name, pkg.links.map((link) => link.id)])).toEqual([ ["SBS14HD", ["link-2"]], ["Mixed", ["link-3"]] ]); expect(removeCollectorLinks(packages, new Set(["link-2", "link-3"])).map((pkg) => [pkg.name, pkg.links.map((link) => link.id)])).toEqual([ ["SBS14HD", ["link-1"]], ["Mixed", ["link-4"]] ]); }); it("derives aggregates, filters and search once for the view", () => { const model = buildCollectorWorkspaceViewModel(packages, "online", "part02", false, ["link-2"], ["package-mixed"], "", true); expect(model.packages).toHaveLength(1); expect(model.packages[0]).toEqual(expect.objectContaining({ totalBytes: 943_718_400, unknownSizeCount: 0, onlineCount: 2, totalCount: 2, selectedCount: 1, collapsed: false })); expect(model.packages[0].links.map((link) => link.id)).toEqual(["link-2"]); expect(model.filters).toEqual([ { id: "all", label: "Alle", count: 4 }, { id: "online", label: "Online", count: 2 }, { id: "unknown", label: "Ungeprüft", count: 1 }, { id: "offline", label: "Offline", count: 1 } ]); }); it("keeps initially unknown links visible while background analysis runs", () => { const model = buildCollectorWorkspaceViewModel([packages[1]], "all", "", true, [], [], "", true); expect(model.analyzing).toBe(true); expect(model.empty).toBe(false); expect(model.packages[0].links.map((link) => link.fileName)).toContain("download.bin"); }); it("toggles all package identities independent of active filters", () => { const packageIds = packages.map((pkg) => pkg.id); expect([...toggleAllCollectorPackageIds(packageIds, new Set())].sort()).toEqual(["package-mixed", "package-sbs"]); expect([...toggleAllCollectorPackageIds(packageIds, new Set(["package-sbs"]))].sort()).toEqual(["package-mixed", "package-sbs"]); expect([...toggleAllCollectorPackageIds(packageIds, new Set(packageIds))]).toEqual([]); }); }); describe("CollectorView", () => { it("renders expandable package and file rows with preview columns", () => { const html = renderToStaticMarkup(); for (const heading of ["Name", "Größe", "Hoster", "Status", "Verfügbarkeit", "Hinzugefügt"]) expect(html).toContain(`>${heading}<`); expect(html).toContain("SBS14HD"); expect(html).toContain("SBS14HD.part01.rar"); expect(html).toContain("SBS14HD.part02.rar"); expect(html).toContain("2/2 online"); expect(html).toContain("aria-label=\"SBS14HD einklappen\""); expect(html).not.toContain("URL oder Rohzeile"); expect(html).not.toContain(">Zeile<"); }); it("keeps rows and actions available during background analysis", () => { const model = buildCollectorWorkspaceViewModel(packages, "all", "", true, ["link-1"], [], "", true); const html = renderToStaticMarkup(); const toolbar = CollectorToolbar({ actions: createActions(), model }); expect(html).toContain("Analyse läuft im Hintergrund"); expect(html).toContain("SBS14HD.part01.rar"); expect(findButton(toolbar, "Auswahl übergeben (1)").props.disabled).toBe(false); expect(findButton(toolbar, "Alle übergeben (4)").props.disabled).toBe(false); expect(findButton(toolbar, "Auswahl entfernen").props.disabled).toBe(false); }); it("renders known hosters as icons with their full name as tooltip", () => { const html = renderToStaticMarkup(); expect(html).toContain('class="collector-hoster-label" title="1Fichier"'); expect(html).toContain('class="collector-hoster-icon" data-hoster="1fichier" src="./provider-icons/onefichier.png"'); }); it("renders collapsed packages without child rows when animations are disabled", () => { const html = renderToStaticMarkup(); expect(html).toContain("aria-label=\"SBS14HD ausklappen\""); expect(html).not.toContain("SBS14HD.part01.rar"); }); it("removes collapsed child rows from the DOM even when animations are enabled", () => { const html = renderToStaticMarkup(); expect(html).not.toContain("collector-package-items-frame"); expect(html).not.toContain("SBS14HD.part01.rar"); }); it("reuses the collector content when app snapshots keep the same collector model", () => { const model = buildCollectorWorkspaceViewModel(packages, "all", "", false, [], [], "", true); const compare = (MemoizedCollectorContent as unknown as { compare: (previous: { model: typeof model; actions: CollectorViewActions }, next: { model: typeof model; actions: CollectorViewActions }) => boolean; }).compare; expect(compare({ model, actions: createActions() }, { model, actions: createActions() })).toBe(true); expect(compare({ model, actions: createActions() }, { model: { ...model, query: "neu" }, actions: createActions() })).toBe(false); }); it("offers selected and all transfer actions", () => { let selected = 0; let all = 0; const toolbar = CollectorToolbar({ actions: createActions({ onSubmitSelected: () => { selected += 1; }, onSubmitAll: () => { all += 1; } }), model: buildCollectorWorkspaceViewModel(packages, "all", "", false, ["link-1"], [], "", true) }); findButton(toolbar, "Auswahl übergeben (1)").props.onClick(); findButton(toolbar, "Alle übergeben (4)").props.onClick(); expect(selected).toBe(1); expect(all).toBe(1); }); it("renders accessible mixed package selection", () => { const html = renderToStaticMarkup(); expect(html).toContain("aria-label=\"Paket SBS14HD auswählen\""); expect(html).toContain("aria-checked=\"mixed\""); }); it("routes status filters and search independently", () => { let filter = ""; let query = ""; const model = buildCollectorWorkspaceViewModel(packages, "online", "", false, [], [], "", true); const actions = createActions({ onFilterChange: (value) => { filter = value; }, onQueryChange: (value) => { query = value; } }); const sidebar = CollectorSidebar({ actions, model }); findElement(sidebar, (element) => element.type === "button" && element.props["aria-current"] === "page").props.onClick(); const toolbar = CollectorToolbar({ actions, model }); findElement(toolbar, (element) => element.props.label === "Links durchsuchen").props.onChange({ target: { value: "part02" } }); expect(filter).toBe("online"); expect(query).toBe("part02"); }); it("keeps errors visible without replacing existing packages", () => { const html = renderToStaticMarkup(); expect(html).toContain("Ein Link konnte nicht geprüft werden"); expect(html).toContain("SBS14HD.part01.rar"); }); it("uses an analysis dialog instead of a raw tab editor", () => { let value = ""; let commits = 0; const dialog = CollectorInputDialog({ open: true, value, onChange: (next) => { value = next; }, onClose: () => {}, onCommit: () => { commits += 1; } }); const html = renderToStaticMarkup(dialog); expect(html).toContain("Links erscheinen sofort und werden anschließend im Hintergrund geprüft."); expect(html).toContain("Hinzufügen"); findElement(dialog, (element) => element.type === "textarea").props.onChange({ target: { value: "https://1fichier.com/?abc" } }); findButton(dialog, "Hinzufügen").props.onClick(); expect(value).toBe("https://1fichier.com/?abc"); expect(commits).toBe(1); }); it("uses aligned responsive package grids and content visibility", () => { const css = readFileSync(new URL("../src/renderer/views/collector/collector.css", import.meta.url), "utf8"); expect(css).toMatch(/\.collector-table-header-row,\s*\.collector-package-row,\s*\.collector-file-row\s*\{[^}]*grid-template-columns:/s); expect(css).toMatch(/\.collector-package-group\s*\{[^}]*content-visibility:\s*auto;/s); expect(css).toMatch(/@media \(max-width: 1120px\)[\s\S]*\.collector-toolbar\s*\{[^}]*flex-wrap:\s*wrap;/s); }); it("uses one consistent gap across collector toolbar groups", () => { const css = readFileSync(new URL("../src/renderer/views/collector/collector.css", import.meta.url), "utf8"); const toolbarGap = css.match(/\.collector-toolbar\s*\{[^}]*gap:\s*([^;]+);/s)?.[1]?.trim(); const groupGap = css.match(/\.collector-toolbar \.ui-toolbar-group\s*\{[^}]*gap:\s*([^;]+);/s)?.[1]?.trim(); expect(toolbarGap).toBeTruthy(); expect(groupGap).toBe(toolbarGap); }); });