Files
Multi-Debrid-Downloader/tests/statistics-view.test.tsx
Sucukdeluxe fe3cb36731 perf(ui): calm large-queue live updates
Throttle visible download snapshots at 500 ms from 250 queue items, align speed visualizations to the same cadence, preserve small-queue responsiveness, and retain the stronger protection for very large queues.
2026-08-17 13:39:53 +02:00

532 lines
22 KiB
TypeScript

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 type { DownloadItem, DownloadStatus, UiSnapshot } from "../src/shared/types";
import { appendBandwidthSample, readBandwidthChartPalette, readDownloadSpeedSparklinePalette } from "../src/renderer/App";
import {
buildStatisticsViewModel,
type StatisticsMetric
} from "../src/renderer/views/statistics/statistics-model";
import {
StatisticsContent,
StatisticsSidebar,
StatisticsView,
type StatisticsViewActions
} from "../src/renderer/views/statistics/StatisticsView";
import { createVisualFixture } from "./visual/fixtures";
import {
createStatisticsLedger,
recordStatisticsActiveInterval,
recordStatisticsBytes,
recordStatisticsOutcome
} from "../src/main/statistics-ledger";
const now = new Date(2026, 7, 10, 12, 0, 0, 0).getTime();
describe("bandwidth sampling", () => {
it("keeps the latest minute and normalizes invalid speeds", () => {
const history = [
{ time: now - 61000, speed: 1 },
{ time: now - 59000, speed: 2 }
];
expect(appendBandwidthSample(history, Number.NaN, now)).toEqual([
{ time: now - 59000, speed: 2 },
{ time: now, speed: 0 }
]);
expect(history).toHaveLength(2);
});
});
function createSnapshot(): UiSnapshot {
return structuredClone(createVisualFixture("empty").snapshot);
}
function item(
id: string,
status: DownloadStatus,
overrides: Partial<DownloadItem> = {}
): DownloadItem {
return {
id,
packageId: "statistics-package",
url: `https://url-host-${id}.example/file`,
provider: "realdebrid",
providerLabel: "Real-Debrid",
status,
retries: 0,
speedBps: 0,
downloadedBytes: 100,
totalBytes: 100,
progressPercent: status === "completed" ? 100 : 50,
fileName: `${id}.bin`,
targetPath: `C:\\Downloads\\${id}.bin`,
resumable: true,
attempts: 1,
lastError: status === "failed" ? "Fehlgeschlagen" : "",
fullStatus: status,
createdAt: now - 1000,
updatedAt: now,
...overrides
};
}
function setItems(snapshot: UiSnapshot, items: DownloadItem[]): void {
snapshot.session.items = Object.fromEntries(items.map((entry) => [entry.id, entry]));
}
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);
}
function findButton(node: ReactNode, label: string): ReactElement {
let result: ReactElement | null = null;
visitElements(node, (element) => {
if (!result && element.type === "button" && element.props.children === label) {
result = element;
}
});
if (!result) {
throw new Error(`Button not found: ${label}`);
}
return result;
}
function createActions(overrides: Partial<StatisticsViewActions> = {}): StatisticsViewActions {
return {
onRangeChange: () => {},
onResetSession: () => {},
onResetAll: () => {},
onResetErrors: () => {},
...overrides
};
}
function expectUnavailable(metric: StatisticsMetric): void {
expect(metric).toMatchObject({ value: null, available: false });
expect(metric.sourceLabel.trim()).not.toBe("");
}
describe("statistics model", () => {
it("uses real snapshot session fields and excludes active or waiting downloads from the success denominator", () => {
const snapshot = createSnapshot();
snapshot.stats.totalDownloaded = 600;
snapshot.stats.totalFilesSession = 3;
snapshot.session.running = true;
setItems(snapshot, [
item("complete-a", "completed"),
item("complete-b", "completed"),
item("complete-c", "completed"),
item("failed", "failed"),
item("active", "downloading"),
item("waiting", "queued")
]);
const model = buildStatisticsViewModel(snapshot, "session", now);
expect(model.metrics.downloadedBytes.value).toBe(600);
expect(model.metrics.files.value).toBe(3);
expect(model.metrics.successRate.value).toBe(75);
expect(model.metrics.errors.value).toBe(1);
});
it("reports no success rate when the current queue has no completed or failed result", () => {
const snapshot = createSnapshot();
snapshot.session.running = true;
setItems(snapshot, [item("active", "downloading"), item("waiting", "queued")]);
const model = buildStatisticsViewModel(snapshot, "session", now);
expectUnavailable(model.metrics.successRate);
expect(model.metrics.errors).toMatchObject({ value: 0, available: true });
});
it("sorts current-queue providers by bytes and then id without deriving them from URL hostnames", () => {
const snapshot = createSnapshot();
snapshot.session.running = true;
setItems(snapshot, [
item("real", "completed", {
url: "https://alldebrid.invalid/wrong-source",
provider: "realdebrid",
providerLabel: "Real-Debrid Konto",
downloadedBytes: 200
}),
item("all", "failed", {
url: "https://realdebrid.invalid/wrong-source",
provider: "alldebrid",
providerLabel: "AllDebrid",
downloadedBytes: 200
}),
item("link", "completed", {
url: "https://realdebrid.invalid/also-wrong",
provider: "debridlink",
providerLabel: "Debrid-Link",
downloadedBytes: 400
}),
item("unknown", "completed", {
url: "https://hoster-only.invalid/not-a-provider",
provider: null,
providerLabel: undefined,
downloadedBytes: 900
})
]);
const model = buildStatisticsViewModel(snapshot, "session", now);
expect(model.providers.map((row) => row.id)).toEqual(["debridlink", "alldebrid", "realdebrid"]);
expect(model.providers.map((row) => row.label)).toEqual(["Debrid-Link", "AllDebrid", "Real-Debrid Konto"]);
expect(model.providers.map((row) => [row.completed, row.failed])).toEqual([[1, 0], [0, 1], [1, 0]]);
expect(model.providers.some((row) => row.id.includes("host"))).toBe(false);
});
it("uses persisted daily bytes, results and active time for every statistic shown today", () => {
const snapshot = createSnapshot();
let ledger = createStatisticsLedger(now);
ledger = recordStatisticsBytes(ledger, "realdebrid", 500, now);
ledger = recordStatisticsOutcome(ledger, "realdebrid", "completed", now);
ledger = recordStatisticsBytes(ledger, "alldebrid", 1_500, now);
ledger = recordStatisticsOutcome(ledger, "alldebrid", "failed", now);
ledger = recordStatisticsActiveInterval(ledger, now - 2_000, now);
snapshot.stats.statistics = ledger;
const model = buildStatisticsViewModel(snapshot, "today", now);
expect(model.metrics.downloadedBytes).toMatchObject({ value: 2_000, available: true });
expect(model.metrics.files).toMatchObject({ value: 1, available: true });
expect(model.metrics.successRate).toMatchObject({ value: 50, available: true });
expect(model.metrics.errors).toMatchObject({ value: 1, available: true });
expect(model.metrics.averageSpeedBps).toMatchObject({ value: 1_000, available: true });
expect(model.providers.map((row) => [row.id, row.bytes])).toEqual([
["alldebrid", 1_500],
["realdebrid", 500]
]);
expect(model.providers.map((row) => [row.completed, row.failed])).toEqual([[0, 1], [1, 0]]);
});
it("treats a stale daily key as a genuine zero today without stale provider rows", () => {
const snapshot = createSnapshot();
snapshot.settings.providerDailyUsageDay = "2026-08-09";
snapshot.settings.providerDailyUsageBytes = { realdebrid: 900 };
const model = buildStatisticsViewModel(snapshot, "today", now);
expect(model.metrics.downloadedBytes).toMatchObject({ value: 0, available: true });
expect(model.providers).toEqual([]);
});
it("sums every available day in seven-day and 30-day windows without waiting for a full period", () => {
const snapshot = createSnapshot();
let ledger = createStatisticsLedger(new Date(2026, 7, 3, 12).getTime());
ledger = recordStatisticsBytes(ledger, "realdebrid", 300, new Date(2026, 7, 3, 12).getTime());
ledger = recordStatisticsOutcome(ledger, "realdebrid", "completed", new Date(2026, 7, 3, 12).getTime());
ledger = recordStatisticsBytes(ledger, "debridlink", 700, new Date(2026, 7, 8, 12).getTime());
ledger = recordStatisticsOutcome(ledger, "debridlink", "failed", new Date(2026, 7, 8, 12).getTime());
ledger = recordStatisticsBytes(ledger, "realdebrid", 500, now);
ledger = recordStatisticsOutcome(ledger, "realdebrid", "completed", now);
snapshot.stats.statistics = ledger;
const week = buildStatisticsViewModel(snapshot, "week", now);
const month = buildStatisticsViewModel(snapshot, "month", now);
expect(week.metrics.downloadedBytes.value).toBe(1_200);
expect(week.metrics.files.value).toBe(1);
expect(week.metrics.errors.value).toBe(1);
expect(week.message).toContain("2 erfasste Tage");
expect(month.metrics.downloadedBytes.value).toBe(1_500);
expect(month.metrics.files.value).toBe(2);
expect(month.metrics.errors.value).toBe(1);
expect(month.message).toContain("3 erfasste Tage");
});
it("combines existing all-time counters with persisted outcomes, provider results and measured average speed", () => {
const snapshot = createSnapshot();
snapshot.stats.totalDownloadedAllTime = 25_000;
snapshot.stats.totalFilesAllTime = 42;
snapshot.settings.providerTotalUsageBytes = { realdebrid: 5_000, debridlink: 20_000 };
let ledger = createStatisticsLedger(now - 10_000);
ledger = recordStatisticsBytes(ledger, "realdebrid", 2_000, now);
ledger = recordStatisticsActiveInterval(ledger, now - 2_000, now);
ledger = recordStatisticsOutcome(ledger, "realdebrid", "completed", now);
ledger = recordStatisticsOutcome(ledger, "realdebrid", "completed", now);
ledger = recordStatisticsOutcome(ledger, "debridlink", "failed", now);
snapshot.stats.statistics = ledger;
snapshot.summary = {
total: 10,
success: 9,
failed: 1,
cancelled: 0,
extracted: 9,
durationSeconds: 10,
averageSpeedBps: 2_500
};
const model = buildStatisticsViewModel(snapshot, "all", now);
expect(model.metrics.downloadedBytes.value).toBe(25_000);
expect(model.metrics.files.value).toBe(42);
expect(model.providers.map((row) => [row.id, row.bytes])).toEqual([
["debridlink", 20_000],
["realdebrid", 5_000]
]);
expect(model.providers.map((row) => [row.completed, row.failed])).toEqual([[0, 1], [2, 0]]);
expect(model.metrics.successRate.available).toBe(true);
expect(model.metrics.successRate.value).toBeCloseTo(200 / 3);
expect(model.metrics.errors).toMatchObject({ value: 1, available: true });
expect(model.metrics.averageSpeedBps).toMatchObject({ value: 1_000, available: true });
});
it("prefers live queue outcomes over an old summary and uses the summary only after the run ends", () => {
const snapshot = createSnapshot();
setItems(snapshot, [
item("complete-a", "completed"),
item("complete-b", "completed"),
item("complete-c", "completed"),
item("failed", "failed")
]);
snapshot.summary = {
total: 4,
success: 1,
failed: 3,
cancelled: 0,
extracted: 1,
durationSeconds: 100,
averageSpeedBps: 500
};
snapshot.session.running = true;
const active = buildStatisticsViewModel(snapshot, "session", now);
snapshot.session.running = false;
const ended = buildStatisticsViewModel(snapshot, "session", now);
expect(active.metrics.successRate.value).toBe(75);
expect(active.metrics.errors.value).toBe(1);
expectUnavailable(active.metrics.averageSpeedBps);
expect(ended.metrics.successRate.value).toBe(25);
expect(ended.metrics.errors.value).toBe(3);
expect(ended.metrics.averageSpeedBps).toMatchObject({ value: 500, available: true });
});
it("models empty, idle, active and paused session states separately", () => {
const empty = createSnapshot();
const idle = createSnapshot();
setItems(idle, [item("idle", "completed")]);
const active = createSnapshot();
active.session.running = true;
setItems(active, [item("active", "downloading")]);
const paused = createSnapshot();
paused.session.running = true;
paused.session.paused = true;
setItems(paused, [item("paused", "paused")]);
expect(buildStatisticsViewModel(empty, "session", now).sessionState).toBe("empty");
expect(buildStatisticsViewModel(idle, "session", now).sessionState).toBe("idle");
expect(buildStatisticsViewModel(active, "session", now).sessionState).toBe("active");
expect(buildStatisticsViewModel(paused, "session", now).sessionState).toBe("paused");
});
});
describe("statistics view", () => {
it("marks statistic ranges for one measured vertical selection indicator", () => {
const model = buildStatisticsViewModel(createSnapshot(), "today", now);
const html = renderToStaticMarkup(<StatisticsSidebar actions={createActions()} model={model} />);
expect(html).toContain("ui-sliding-selection ui-sliding-selection-vertical");
expect(html.match(/data-sliding-selection-item="true"/g)).toHaveLength(5);
expect(html.match(/data-sliding-selection-active="true"/g)).toHaveLength(1);
});
it("renders each statistics marker exactly once, all ranges and no download toolbar or pagination", () => {
const snapshot = createSnapshot();
snapshot.stats.totalDownloaded = 2_048;
snapshot.stats.totalFilesSession = 1;
setItems(snapshot, [item("complete", "completed")]);
const html = renderToStaticMarkup(
<StatisticsView
actions={createActions()}
chart={<div>Bestehender Bandbreitenverlauf</div>}
model={buildStatisticsViewModel(snapshot, "session", now)}
/>
);
for (const marker of ["statistics-sidebar", "statistics-kpis", "statistics-chart"]) {
expect(html.match(new RegExp(`data-visual-region=\\"${marker}\\"`, "g"))).toHaveLength(1);
}
for (const label of ["Sitzung", "Heute", "Sieben Tage", "30 Tage", "Gesamt"]) {
expect(html).toContain(`>${label}<`);
}
expect(html).toContain("Bestehender Bandbreitenverlauf");
expect(html).not.toContain("downloads-toolbar");
expect(html.toLocaleLowerCase("de-DE")).not.toContain("pagination");
});
it("dispatches range and reset controls only through the supplied callbacks", () => {
const snapshot = createSnapshot();
setItems(snapshot, [item("failed", "failed")]);
const model = buildStatisticsViewModel(snapshot, "session", now);
const calls: string[] = [];
const actions = createActions({
onRangeChange: (range) => calls.push(`range:${range}`),
onResetSession: () => calls.push("reset:session"),
onResetAll: () => calls.push("reset:all"),
onResetErrors: () => calls.push("reset:errors")
});
const sidebar = StatisticsSidebar({ actions, model });
const content = StatisticsContent({ actions, chart: <div />, model });
findButton(sidebar, "Heute").props.onClick();
findButton(content, "Sitzung zurücksetzen").props.onClick();
findButton(content, "Gesamt zurücksetzen").props.onClick();
findButton(content, "Fehler zurücksetzen").props.onClick();
expect(calls).toEqual(["range:today", "reset:session", "reset:all", "reset:errors"]);
});
it("enables error reset only for a positive genuine error metric", () => {
const clean = createSnapshot();
const failed = createSnapshot();
setItems(failed, [item("failed", "failed")]);
const cleanContent = StatisticsContent({
actions: createActions(),
chart: <div />,
model: buildStatisticsViewModel(clean, "session", now)
});
const failedContent = StatisticsContent({
actions: createActions(),
chart: <div />,
model: buildStatisticsViewModel(failed, "session", now)
});
expect(findButton(cleanContent, "Fehler zurücksetzen").props.disabled).toBe(true);
expect(findButton(failedContent, "Fehler zurücksetzen").props.disabled).toBe(false);
});
it("keeps the empty provider state inside the ARIA table as a row and spanning cell", () => {
const html = renderToStaticMarkup(
<StatisticsContent
actions={createActions()}
chart={<div />}
model={buildStatisticsViewModel(createSnapshot(), "session", now)}
/>
);
expect(html).toContain('class="statistics-provider-empty" role="row"');
expect(html).toContain('aria-colspan="3" role="cell"');
});
});
describe("bandwidth chart palette", () => {
it("collects bandwidth samples from the mounted application instead of the statistics tab", () => {
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8");
const chartBlock = source.slice(source.indexOf("const BandwidthChart"), source.indexOf("interface DownloadSpeedSparklineProps"));
expect(source).toContain("appendBandwidthSample(speedHistoryRef.current, liveDownloadSpeedBps");
expect(chartBlock).not.toContain("history.push");
expect(chartBlock).not.toContain('item.status === "downloading"');
});
it("defines semantic speed and progress text colors for both themes", () => {
const css = readFileSync(new URL("../src/renderer/theme.css", import.meta.url), "utf8");
const dark = css.match(/:root,\s*:root\[data-theme="dark"\]\s*\{([\s\S]*?)\}/)?.[1];
const light = css.match(/:root\[data-theme="light"\]\s*\{([\s\S]*?)\}/)?.[1];
expect(dark).toContain("--ui-speed-accent: #4ADE80;");
expect(dark).toContain("--ui-primary-text: #181A1F;");
expect(dark).toContain("--ui-focus: #9AB8E8;");
expect(dark).toContain("--ui-success-text: #4ADE80;");
expect(dark).toContain("--ui-warning-text: #F1C786;");
expect(dark).toContain("--ui-danger-text: #F06464;");
expect(dark).toContain("--ui-progress-track-text: #FFFFFF;");
expect(dark).toContain("--ui-progress-fill-text: #181A1F;");
expect(light).toContain("--ui-speed-accent: #1E9E55;");
expect(light).toContain("--ui-primary-text: #FFFFFF;");
expect(light).toContain("--ui-focus: #24558D;");
expect(light).toContain("--ui-success-text: #137A3D;");
expect(light).toContain("--ui-warning-text: #7A4B00;");
expect(light).toContain("--ui-danger-text: #B4232F;");
expect(light).toContain("--ui-progress-track-text: #181A1F;");
expect(light).toContain("--ui-progress-fill-text: #181A1F;");
});
it("uses theme-aware primary text and visible focus colors", () => {
const theme = readFileSync(new URL("../src/renderer/theme.css", import.meta.url), "utf8");
const shell = readFileSync(new URL("../src/renderer/shell/shell.css", import.meta.url), "utf8");
const collector = readFileSync(new URL("../src/renderer/views/collector/collector.css", import.meta.url), "utf8");
expect(theme).toMatch(/:focus-visible\s*\{[^}]*outline:\s*2px solid var\(--ui-focus\);/s);
expect(shell).toContain("color: var(--ui-update-text);");
expect(collector.match(/color:\s*var\(--ui-primary-text\);/g)).toHaveLength(3);
});
it("requests only the semantic UI color properties and keeps the computed font family", () => {
const requested: string[] = [];
const values: Record<string, string> = {
"--ui-border": " rgb(61, 61, 61) ",
"--ui-text-muted": " rgb(145, 145, 145) ",
"--ui-speed-accent": " rgb(242, 148, 45) "
};
const palette = readBandwidthChartPalette((property) => {
requested.push(property);
return values[property];
}, "Inter, Segoe UI, sans-serif");
expect(requested).toEqual(["--ui-border", "--ui-text-muted", "--ui-speed-accent"]);
expect(palette).toEqual({
grid: "rgb(61, 61, 61)",
text: "rgb(145, 145, 145)",
accent: "rgb(242, 148, 45)",
fontFamily: "Inter, Segoe UI, sans-serif"
});
});
it("uses the semantic green speed accent for the header sparkline", () => {
const requested: string[] = [];
const palette = readDownloadSpeedSparklinePalette((property) => {
requested.push(property);
return property === "--ui-speed-accent" ? " rgb(74, 222, 128) " : "";
});
expect(requested).toEqual(["--ui-speed-accent"]);
expect(palette).toEqual({ accent: "rgb(74, 222, 128)" });
});
it("labels the live chart and slows redraws when reduced motion is requested", () => {
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8");
const chartBlock = source.slice(source.indexOf("const BandwidthChart"), source.indexOf("interface DownloadSpeedSparklineProps"));
expect(chartBlock).toContain('role="img"');
expect(chartBlock).toContain('aria-label="Bandbreitenverlauf der letzten 60 Sekunden"');
expect(chartBlock).toContain('window.matchMedia("(prefers-reduced-motion: reduce)")');
expect(chartBlock).toContain("reducedMotion ? 1000 : 500");
});
it("asks for confirmation before deleting all saved download statistics", () => {
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8");
const actions = source.slice(source.indexOf("const statisticsActions"), source.indexOf("const collectorActions"));
expect(actions).toContain("askConfirmPrompt");
expect(actions.indexOf("askConfirmPrompt")).toBeLessThan(actions.indexOf("resetDownloadStats"));
expect(actions).toContain('title: "Gesamtstatistik zurücksetzen"');
});
it("asks for confirmation before resetting session statistics", () => {
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8");
const actions = source.slice(source.indexOf("const statisticsActions"), source.indexOf("const collectorActions"));
const sessionReset = actions.slice(actions.indexOf("onResetSession"), actions.indexOf("onResetAll"));
expect(sessionReset).toContain("askConfirmPrompt");
expect(sessionReset.indexOf("askConfirmPrompt")).toBeLessThan(sessionReset.indexOf("resetSessionStats"));
expect(sessionReset).toContain('title: "Sitzungsstatistik zurücksetzen"');
});
});