Improve collector metadata progress and alignment

Stream completed metadata checks into the collector while preserving generation-based stale-result protection. Derive row statuses from verified availability, move analysis feedback into the sidebar, center table values under their headers, and keep the header synchronized during horizontal scrolling.
This commit is contained in:
Sucukdeluxe
2026-08-26 16:05:09 +02:00
parent 0ba87c37ec
commit eed8d1e970
15 changed files with 309 additions and 57 deletions
+20 -5
View File
@@ -5,7 +5,9 @@ import type { ElectronApi } from "../src/shared/preload-api";
const electron = vi.hoisted(() => ({
api: undefined as ElectronApi | undefined,
invoke: vi.fn<(...args: unknown[]) => Promise<unknown>>(async () => undefined),
getPathForFile: vi.fn(() => "C:\\Imports\\dropped.dlc")
getPathForFile: vi.fn(() => "C:\\Imports\\dropped.dlc"),
on: vi.fn(),
removeListener: vi.fn()
}));
vi.mock("electron", () => ({
@@ -16,8 +18,8 @@ vi.mock("electron", () => ({
},
ipcRenderer: {
invoke: electron.invoke,
on: vi.fn(),
removeListener: vi.fn(),
on: electron.on,
removeListener: electron.removeListener,
send: vi.fn()
},
webUtils: { getPathForFile: electron.getPathForFile }
@@ -118,12 +120,12 @@ describe("account preload contract", () => {
await electron.api?.prepareCollectorText(textRequest);
await electron.api?.prepareCollectorContainers(["C:\\Imports\\sample.dlc"], 2345);
await electron.api?.enrichCollectorPackages({ packages });
await electron.api?.enrichCollectorPackages({ requestId: "request-preload", packages });
expect(electron.invoke.mock.calls).toEqual([
[IPC_CHANNELS.PREPARE_COLLECTOR_TEXT, textRequest],
[IPC_CHANNELS.PREPARE_COLLECTOR_CONTAINERS, ["C:\\Imports\\sample.dlc"], 2345],
[IPC_CHANNELS.ENRICH_COLLECTOR_PACKAGES, { packages }]
[IPC_CHANNELS.ENRICH_COLLECTOR_PACKAGES, { requestId: "request-preload", packages }]
]);
});
@@ -134,4 +136,17 @@ describe("account preload contract", () => {
expect(electron.getPathForFile).toHaveBeenCalledWith(file);
expect(electron.invoke).not.toHaveBeenCalled();
});
it("subscribes and unsubscribes collector enrichment progress", () => {
const callback = vi.fn();
const unsubscribe = electron.api?.onCollectorEnrichmentProgress(callback);
const listener = electron.on.mock.calls.find((call) => call[0] === IPC_CHANNELS.COLLECTOR_ENRICHMENT_PROGRESS)?.[1] as ((event: unknown, value: unknown) => void) | undefined;
const progress = { requestId: "request-progress", result: { packages: [], invalidCount: 0, duplicateCount: 0 } };
listener?.({}, progress);
unsubscribe?.();
expect(callback).toHaveBeenCalledWith(progress);
expect(electron.removeListener).toHaveBeenCalledWith(IPC_CHANNELS.COLLECTOR_ENRICHMENT_PROGRESS, listener);
});
});
+50 -4
View File
@@ -10,6 +10,7 @@ import {
validateCollectorEnrichmentRequest,
validateCollectorTextPreparationRequest
} from "../src/shared/collector";
import type { CollectorInspectionResult } from "../src/shared/collector";
describe("collector preparation", () => {
it("returns a stable package skeleton without requesting metadata", () => {
@@ -89,7 +90,7 @@ describe("collector enrichment", () => {
const linkBefore = prepared.packages[0].links[0];
const result = await enrichCollectorPackages(
{ packages: prepared.packages },
{ requestId: "request-one", packages: prepared.packages },
defaultSettings(),
{
checkOneFichier: async () => new Map([[linkBefore.url, {
@@ -124,8 +125,8 @@ describe("collector enrichment", () => {
return new Map();
};
const firstRun = enrichCollectorPackages({ packages: first.packages }, defaultSettings(), { checkOneFichier });
const secondRun = enrichCollectorPackages({ packages: second.packages }, defaultSettings(), { checkOneFichier });
const firstRun = enrichCollectorPackages({ requestId: "request-first", packages: first.packages }, defaultSettings(), { checkOneFichier });
const secondRun = enrichCollectorPackages({ requestId: "request-second", packages: second.packages }, defaultSettings(), { checkOneFichier });
await vi.waitFor(() => expect(started).toHaveLength(2));
resolvers.forEach((resolve) => resolve());
await Promise.all([firstRun, secondRun]);
@@ -136,9 +137,54 @@ describe("collector enrichment", () => {
]);
});
it("reports finished RapidGator links before the complete enrichment resolves", async () => {
const prepared = prepareCollectorText({
rawText: [
"https://rapidgator.net/file/aaaaaaaa/one.bin.html",
"https://rapidgator.net/file/bbbbbbbb/two.bin.html"
].join("\n"),
addedAt: 7_000
});
const resolvers = new Map<string, (value: { online: boolean; fileName: string; fileSizeBytes: number }) => void>();
const progress: CollectorInspectionResult[] = [];
let completed = false;
const run = enrichCollectorPackages(
{ requestId: "request-progress", packages: prepared.packages },
defaultSettings(),
{
checkRapidgator: (url) => new Promise((resolve) => resolvers.set(url, resolve))
},
(result) => progress.push(result)
).then((result) => {
completed = true;
return result;
});
await vi.waitFor(() => expect(resolvers.size).toBe(2));
resolvers.get("https://rapidgator.net/file/aaaaaaaa/one.bin.html")?.({
online: true,
fileName: "one.part01.rar",
fileSizeBytes: 100
});
await vi.waitFor(() => expect(progress.length).toBeGreaterThan(0));
expect(completed).toBe(false);
expect(progress.flatMap((entry) => entry.packages).flatMap((pkg) => pkg.links)).toEqual([
expect.objectContaining({ fileName: "one.part01.rar", fileSizeBytes: 100, availability: "online" })
]);
resolvers.get("https://rapidgator.net/file/bbbbbbbb/two.bin.html")?.({
online: false,
fileName: "two.bin",
fileSizeBytes: 200
});
await run;
});
it("rejects enrichment payloads that do not contain prepared absolute links", () => {
expect(() => validateCollectorEnrichmentRequest({ packages: [] })).toThrow(/ungültig/i);
expect(() => validateCollectorEnrichmentRequest({ requestId: "request-empty", packages: [] })).toThrow(/ungültig/i);
expect(() => validateCollectorEnrichmentRequest({
requestId: "request-invalid",
packages: [{
id: "package",
name: "Paket",
+45 -1
View File
@@ -16,9 +16,11 @@ import {
CollectorInputDialog,
MemoizedCollectorContent,
CollectorSidebar,
CollectorSidebarStatus,
CollectorToolbar,
CollectorView,
collectorFileInteractionAttributes,
collectorHeaderScrollStyle,
collectorPackageIntrinsicBlockSize,
toggleAllCollectorPackageIds,
type CollectorViewActions
@@ -391,6 +393,7 @@ describe("CollectorView", () => {
expect(html).toContain("SBS14HD.part01.rar");
expect(html).toContain("SBS14HD.part02.rar");
expect(html).toContain("2/2 online");
expect(html).toContain(">Online<");
expect(html).toContain("aria-label=\"SBS14HD einklappen\"");
expect(html).not.toContain("URL oder Rohzeile");
expect(html).not.toContain(">Zeile<");
@@ -399,15 +402,50 @@ describe("CollectorView", () => {
it("keeps rows and actions available during background analysis", () => {
const model = buildCollectorWorkspaceViewModel(packages, "all", "", true, ["link-1"], [], "", true);
const html = renderToStaticMarkup(<CollectorContent actions={createActions()} model={model} />);
const sidebarStatus = renderToStaticMarkup(<CollectorSidebarStatus model={model} />);
const toolbar = CollectorToolbar({ actions: createActions(), model });
expect(html).toContain("Analyse läuft im Hintergrund");
expect(html).not.toContain("Analyse läuft im Hintergrund");
expect(sidebarStatus).toContain("Analyse läuft im Hintergrund");
expect(sidebarStatus).toContain('role="status"');
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("derives status exclusively from availability instead of filename readiness", () => {
const unknownReady = [{
...packages[0],
links: [{ ...packages[0].links[0], availability: "unknown" as const, status: "ready" as const }]
}];
const partial = [{
...packages[0],
links: [
{ ...packages[0].links[0], availability: "online" as const },
{ ...packages[0].links[1], availability: "unknown" as const }
]
}];
const offlineUnknown = [{
...packages[0],
links: [
{ ...packages[0].links[0], availability: "offline" as const },
{ ...packages[0].links[1], availability: "unknown" as const }
]
}];
const unknownHtml = renderToStaticMarkup(<CollectorContent actions={createActions()} model={buildCollectorWorkspaceViewModel(unknownReady, "all", "", false, [], [], "", true)} />);
const partialHtml = renderToStaticMarkup(<CollectorContent actions={createActions()} model={buildCollectorWorkspaceViewModel(partial, "all", "", false, [], [], "", true)} />);
const offlineUnknownHtml = renderToStaticMarkup(<CollectorContent actions={createActions()} model={buildCollectorWorkspaceViewModel(offlineUnknown, "all", "", false, [], [], "", true)} />);
expect(unknownHtml).not.toContain(">Bereit<");
expect(unknownHtml).toContain(">Ungeprüft<");
expect(partialHtml).toContain(">Teilweise online<");
expect(partialHtml).toContain("1/2 online");
expect(offlineUnknownHtml).not.toContain(">Teilweise online<");
expect(offlineUnknownHtml).toContain(">Ungeprüft<");
});
it("renders known hosters as icons with their full name as tooltip", () => {
const html = renderToStaticMarkup(<CollectorContent actions={createActions()} model={buildCollectorWorkspaceViewModel(packages, "all", "", false, [], [], "", true)} />);
@@ -497,6 +535,12 @@ describe("CollectorView", () => {
expect(css).toMatch(/@media \(prefers-reduced-motion: reduce\)[\s\S]*\.collector-virtual-spacer\.is-motion-enabled \.collector-virtual-package\s*\{[^}]*transition-duration:\s*300ms, 300ms !important;/s);
expect(css).toMatch(/\.collector-package-items-frame\.is-expanding\s*\{[^}]*animation:\s*collector-items-expand/s);
expect(css).not.toMatch(/\.collector-package-items-frame\.is-animated\s*\{[^}]*animation:\s*collector-items-expand/s);
expect(css).toMatch(/\.collector-table-header-row > span:nth-child\(2\)\s*\{[^}]*padding-left:\s*48px;/s);
expect(css).toMatch(/\.collector-name-cell\.is-file\s*\{[^}]*padding-left:\s*32px;/s);
expect(css).toMatch(/\.collector-table-header,\s*\.collector-table-body\s*\{[^}]*scrollbar-gutter:\s*stable;/s);
expect(css).toMatch(/\.collector-size-cell,\s*\.collector-hoster-cell,\s*\.collector-status-cell,\s*\.collector-availability-cell,\s*\.collector-added-cell\s*\{[^}]*text-align:\s*center;/s);
expect(css).toMatch(/\.collector-table-header-row > span:nth-child\(n\+3\)\s*\{[^}]*justify-content:\s*center;[^}]*text-align:\s*center;/s);
expect(collectorHeaderScrollStyle(37)).toEqual({ transform: "translateX(-37px)" });
});
it("uses one consistent gap across collector toolbar groups", () => {
+1
View File
@@ -62,6 +62,7 @@ export function createVisualElectronApi(
prepareCollectorText: async () => ({ packages: [], invalidCount: 0, duplicateCount: 0 }),
prepareCollectorContainers: async () => ({ packages: [], invalidCount: 0, duplicateCount: 0 }),
enrichCollectorPackages: async (request) => ({ packages: clone(request.packages), invalidCount: 0, duplicateCount: 0 }),
onCollectorEnrichmentProgress: () => () => {},
getPathForDroppedFile: () => "",
getStartConflicts: async () => [],
resolveStartConflict: async (_packageId, policy) => ({