feat(statistics): persist ranges and align history columns
Persist per-day download volume, outcomes, active transfer time, and provider results so today, seven-day, and 30-day views use real partial-window data. Preserve existing all-time counters while extending totals with newly recorded result metrics. Rebuild the history table around one resizable persisted grid shared by headers and rows, with synchronized overflow and consistent alignment across window sizes.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildBackupPayload, planBackupImport } from "../src/main/backup-payload";
|
||||
import type { AppSettings, SessionState, HistoryEntry } from "../src/shared/types";
|
||||
import type { AppSettings, SessionState, HistoryEntry, StatisticsLedger } from "../src/shared/types";
|
||||
|
||||
function settings(overrides: Partial<AppSettings> = {}): AppSettings {
|
||||
return { backupIncludeDownloads: false, token: "secret", outputDir: "C:\\dl" } as unknown as AppSettings;
|
||||
@@ -11,16 +11,18 @@ const session: SessionState = {
|
||||
runStartedAt: 0, totalDownloadedBytes: 0, summaryText: "", reconnectUntil: 0,
|
||||
reconnectReason: "", paused: false, running: true, updatedAt: 0
|
||||
};
|
||||
const history: HistoryEntry[] = [{ id: "h1" } as unknown as HistoryEntry];
|
||||
|
||||
const baseInput = { appVersion: "1.7.183", exportedAt: "2026-06-07T00:00:00Z", session, history };
|
||||
const history: HistoryEntry[] = [{ id: "h1" } as unknown as HistoryEntry];
|
||||
const statistics: StatisticsLedger = { version: 1, startedAt: 1, days: [] };
|
||||
|
||||
const baseInput = { appVersion: "1.7.183", exportedAt: "2026-06-07T00:00:00Z", session, history, statistics };
|
||||
|
||||
describe("buildBackupPayload — default is settings-only", () => {
|
||||
it("omits session AND history when backupIncludeDownloads is false (default)", () => {
|
||||
const p = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: false } as AppSettings });
|
||||
expect(p.kind).toBe("settings-only");
|
||||
expect(p.session).toBeUndefined();
|
||||
expect(p.history).toBeUndefined();
|
||||
expect(p.history).toBeUndefined();
|
||||
expect(p.statistics).toBeUndefined();
|
||||
expect(p.settings).toBeDefined();
|
||||
});
|
||||
|
||||
@@ -28,7 +30,8 @@ describe("buildBackupPayload — default is settings-only", () => {
|
||||
const p = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: true } as AppSettings });
|
||||
expect(p.kind).toBe("full");
|
||||
expect(p.session).toBe(session);
|
||||
expect(p.history).toBe(history);
|
||||
expect(p.history).toBe(history);
|
||||
expect(p.statistics).toBe(statistics);
|
||||
});
|
||||
|
||||
it("treats a missing flag as settings-only (safe default)", () => {
|
||||
|
||||
+37
-11
@@ -6,11 +6,15 @@ import type { HistoryEntry } from "../src/shared/types";
|
||||
import {
|
||||
buildHistoryViewModel,
|
||||
deriveHistoryHoster,
|
||||
deriveHistoryStartAt,
|
||||
filterHistoryRows,
|
||||
deriveHistoryStartAt,
|
||||
filterHistoryRows,
|
||||
createHistoryTableColumnWidths,
|
||||
getHistoryTableGridTemplate,
|
||||
getHistoryTableMinWidth,
|
||||
HISTORY_PAGE_SIZE,
|
||||
paginateHistoryRows,
|
||||
pruneHistoryIds,
|
||||
pruneHistoryIds,
|
||||
resizeHistoryTableColumn,
|
||||
selectVisibleHistoryIds,
|
||||
type HistoryFilter,
|
||||
type HistoryViewEntry
|
||||
@@ -234,6 +238,28 @@ describe("history model", () => {
|
||||
});
|
||||
|
||||
describe("HistoryView", () => {
|
||||
it("uses bounded persistent widths and one exact grid for history headers and rows", () => {
|
||||
const defaults = createHistoryTableColumnWidths();
|
||||
const resized = resizeHistoryTableColumn(defaults, "status", 80);
|
||||
const clamped = createHistoryTableColumnWidths({ ...defaults, name: -500, completed: 9000 });
|
||||
|
||||
expect(resized.status).toBe(defaults.status + 80);
|
||||
expect(clamped.name).toBeGreaterThan(0);
|
||||
expect(clamped.completed).toBeLessThan(9000);
|
||||
expect(getHistoryTableGridTemplate(resized)).toContain(`${resized.status}px`);
|
||||
expect(getHistoryTableMinWidth(resized)).toBeGreaterThan(getHistoryTableMinWidth(defaults));
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<HistoryView
|
||||
actions={createActions()}
|
||||
model={buildHistoryViewModel(entries.slice(0, 2), "all", "", [], [], false, "", now)}
|
||||
/>
|
||||
);
|
||||
const template = getHistoryTableGridTemplate(defaults).replaceAll(" ", " ");
|
||||
expect(html.match(new RegExp(`grid-template-columns:${template.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "g"))).toHaveLength(3);
|
||||
expect(html.match(/Spaltenbreite ändern/g)).toHaveLength(6);
|
||||
});
|
||||
|
||||
it("builds the complete page status as one localizable text value", () => {
|
||||
expect(historyPageStatusLabel({ page: 2, pageSize: 100, rangeLabel: "101–200 von 250", rows: [], totalItems: 250, totalPages: 3 }))
|
||||
.toBe("Seite 2 von 3");
|
||||
@@ -320,7 +346,7 @@ describe("HistoryView", () => {
|
||||
expect(html.match(/data-sliding-selection-active="true"/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps the header and rows inside the same internal horizontal scroll context", () => {
|
||||
it("keeps one clipped header synchronized with the scrollable history rows", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<HistoryView
|
||||
actions={createActions()}
|
||||
@@ -335,10 +361,9 @@ describe("HistoryView", () => {
|
||||
expect(tableStart).toBeGreaterThan(-1);
|
||||
expect(headerStart).toBeGreaterThan(tableStart);
|
||||
expect(bodyStart).toBeGreaterThan(headerStart);
|
||||
expect(css).toMatch(/\.history-content \.history-table\s*\{[^}]*overflow:\s*auto;/s);
|
||||
expect(css).toMatch(/\.history-table > \.history-table-header\s*\{[^}]*position:\s*sticky;[^}]*top:\s*0;/s);
|
||||
expect(css).toMatch(/\.history-table > \.history-table-body\s*\{[^}]*overflow:\s*visible;/s);
|
||||
expect(css).not.toMatch(/\.history-table > \.history-table-body\s*\{[^}]*overflow:\s*auto;/s);
|
||||
expect(css).toMatch(/\.history-content \.history-table\s*\{[^}]*overflow:\s*hidden;/s);
|
||||
expect(css).toMatch(/\.history-table > \.history-table-header\s*\{[^}]*overflow:\s*hidden;/s);
|
||||
expect(css).toMatch(/\.history-table > \.history-table-body\s*\{[^}]*overflow:\s*auto;/s);
|
||||
});
|
||||
|
||||
it("keeps every real AppShell history surface non-selectable while allowing text selection only for detail values", () => {
|
||||
@@ -393,7 +418,7 @@ describe("HistoryView", () => {
|
||||
expect(html).not.toMatch(/>Start<|>Pause<|>Stop<|Priorität/);
|
||||
});
|
||||
|
||||
it("matches the download action control and centers every header except package and file", () => {
|
||||
it("matches the download action control and aligns every header with its data column", () => {
|
||||
const model = buildHistoryViewModel(entries.slice(0, 1), "all", "", [], [], false, "", now);
|
||||
const content = HistoryContentPage({
|
||||
actions: createActions(),
|
||||
@@ -406,8 +431,9 @@ describe("HistoryView", () => {
|
||||
|
||||
expect(actionCell.props.children.props.children).toBe("⋮");
|
||||
expect(styles).toMatch(/\.history-row-action button\s*\{[^}]*background:\s*var\(--ui-input\);[^}]*border:\s*1px solid var\(--ui-border\);[^}]*height:\s*30px;[^}]*width:\s*30px;/s);
|
||||
expect(styles).toMatch(/\.history-table-header-row > span\s*\{[^}]*text-align:\s*center;/s);
|
||||
expect(styles).toMatch(/\.history-table-header-row > span:nth-child\(2\)\s*\{[^}]*text-align:\s*left;/s);
|
||||
expect(styles).toMatch(/\.history-table-header-row > span,\s*\.history-row > span\s*\{[^}]*text-align:\s*left;/s);
|
||||
expect(styles).toMatch(/\.history-table-header-row > span:nth-child\(4\),\s*\.history-row > span:nth-child\(4\)\s*\{[^}]*text-align:\s*right;/s);
|
||||
expect(styles).toMatch(/\.history-row-action\s*\{[^}]*place-items:\s*center;/s);
|
||||
});
|
||||
|
||||
it("renders each visual marker once, occupied main rows separately from closed detail rows and an honest footer", () => {
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { defaultSettings } from "../src/main/constants";
|
||||
import { DownloadManager } from "../src/main/download-manager";
|
||||
import { createStoragePaths, emptySession } from "../src/main/storage";
|
||||
import {
|
||||
aggregateStatisticsRange,
|
||||
createStatisticsLedger,
|
||||
loadStatisticsLedger,
|
||||
recordStatisticsActiveInterval,
|
||||
recordStatisticsBytes,
|
||||
recordStatisticsOutcome,
|
||||
saveStatisticsLedger
|
||||
} from "../src/main/statistics-ledger";
|
||||
|
||||
const roots: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function localTime(day: number, hour = 12): number {
|
||||
return new Date(2026, 7, day, hour, 0, 0, 0).getTime();
|
||||
}
|
||||
|
||||
describe("statistics ledger", () => {
|
||||
it("aggregates every available day inside a rolling seven-day window without requiring seven complete days", () => {
|
||||
let ledger = createStatisticsLedger(localTime(10));
|
||||
ledger = recordStatisticsBytes(ledger, "realdebrid", 100, localTime(7));
|
||||
ledger = recordStatisticsOutcome(ledger, "realdebrid", "completed", localTime(7));
|
||||
ledger = recordStatisticsBytes(ledger, "debridlink", 200, localTime(8));
|
||||
ledger = recordStatisticsOutcome(ledger, "debridlink", "failed", localTime(8));
|
||||
ledger = recordStatisticsBytes(ledger, "realdebrid", 300, localTime(10));
|
||||
ledger = recordStatisticsOutcome(ledger, "realdebrid", "completed", localTime(10));
|
||||
|
||||
const aggregate = aggregateStatisticsRange(ledger, 7, localTime(10));
|
||||
|
||||
expect(aggregate).toMatchObject({
|
||||
downloadedBytes: 600,
|
||||
completedFiles: 2,
|
||||
failedFiles: 1,
|
||||
coveredDays: 3
|
||||
});
|
||||
expect(aggregate.providers).toEqual({
|
||||
debridlink: { bytes: 200, completed: 0, failed: 1 },
|
||||
realdebrid: { bytes: 400, completed: 2, failed: 0 }
|
||||
});
|
||||
});
|
||||
|
||||
it("excludes data outside the requested calendar window and computes average speed from measured active time", () => {
|
||||
let ledger = createStatisticsLedger(localTime(10));
|
||||
ledger = recordStatisticsBytes(ledger, "realdebrid", 8_000, localTime(10));
|
||||
ledger = recordStatisticsActiveInterval(ledger, localTime(10, 10), localTime(10, 10) + 2_000);
|
||||
ledger = recordStatisticsBytes(ledger, "debridlink", 99_000, localTime(3));
|
||||
ledger = recordStatisticsActiveInterval(ledger, localTime(3, 10), localTime(3, 10) + 1_000);
|
||||
|
||||
const today = aggregateStatisticsRange(ledger, 1, localTime(10));
|
||||
const week = aggregateStatisticsRange(ledger, 7, localTime(10));
|
||||
const month = aggregateStatisticsRange(ledger, 30, localTime(10));
|
||||
|
||||
expect(today).toMatchObject({ downloadedBytes: 8_000, activeDownloadMs: 2_000, averageSpeedBps: 4_000 });
|
||||
expect(week.downloadedBytes).toBe(8_000);
|
||||
expect(month).toMatchObject({ downloadedBytes: 107_000, activeDownloadMs: 3_000 });
|
||||
});
|
||||
|
||||
it("splits active intervals across local calendar days", () => {
|
||||
const start = new Date(2026, 7, 9, 23, 59, 59, 500).getTime();
|
||||
const end = new Date(2026, 7, 10, 0, 0, 0, 500).getTime();
|
||||
const ledger = recordStatisticsActiveInterval(createStatisticsLedger(start), start, end);
|
||||
|
||||
expect(aggregateStatisticsRange(ledger, 1, localTime(10)).activeDownloadMs).toBe(500);
|
||||
expect(aggregateStatisticsRange(ledger, 1, localTime(9)).activeDownloadMs).toBe(500);
|
||||
});
|
||||
|
||||
it("persists normalized statistics and recovers safely from malformed files", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-statistics-ledger-"));
|
||||
roots.push(root);
|
||||
const filePath = path.join(root, "rd_statistics.json");
|
||||
const ledger = recordStatisticsOutcome(
|
||||
recordStatisticsBytes(createStatisticsLedger(localTime(10)), "realdebrid", 4_096, localTime(10)),
|
||||
"realdebrid",
|
||||
"completed",
|
||||
localTime(10)
|
||||
);
|
||||
|
||||
saveStatisticsLedger(filePath, ledger);
|
||||
expect(loadStatisticsLedger(filePath, localTime(10))).toEqual(ledger);
|
||||
|
||||
fs.writeFileSync(filePath, "{broken", "utf8");
|
||||
expect(loadStatisticsLedger(filePath, localTime(11))).toEqual(createStatisticsLedger(localTime(11)));
|
||||
});
|
||||
|
||||
it("retries transient Windows rename failures while preserving the statistics file", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-statistics-rename-"));
|
||||
roots.push(root);
|
||||
const filePath = path.join(root, "rd_statistics.json");
|
||||
const ledger = recordStatisticsBytes(createStatisticsLedger(localTime(10)), "realdebrid", 8_192, localTime(10));
|
||||
const rename = fs.renameSync.bind(fs);
|
||||
let attempts = 0;
|
||||
const spy = vi.spyOn(fs, "renameSync").mockImplementation((source, target) => {
|
||||
attempts += 1;
|
||||
if (attempts < 3) {
|
||||
throw Object.assign(new Error("busy"), { code: "EPERM" });
|
||||
}
|
||||
return rename(source, target);
|
||||
});
|
||||
|
||||
saveStatisticsLedger(filePath, ledger);
|
||||
|
||||
expect(attempts).toBe(3);
|
||||
expect(loadStatisticsLedger(filePath, localTime(10))).toEqual(ledger);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("records provider bytes and terminal outcomes through the download manager and restores them after restart", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-statistics-manager-"));
|
||||
roots.push(root);
|
||||
const paths = createStoragePaths(root);
|
||||
const session = emptySession();
|
||||
session.items.item = {
|
||||
id: "item",
|
||||
packageId: "package",
|
||||
url: "https://example.test/file",
|
||||
provider: "realdebrid",
|
||||
providerLabel: "Real-Debrid",
|
||||
status: "completed",
|
||||
retries: 0,
|
||||
speedBps: 0,
|
||||
downloadedBytes: 4_096,
|
||||
totalBytes: 4_096,
|
||||
progressPercent: 100,
|
||||
fileName: "file.bin",
|
||||
targetPath: path.join(root, "file.bin"),
|
||||
resumable: true,
|
||||
attempts: 1,
|
||||
lastError: "",
|
||||
fullStatus: "Fertig",
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
};
|
||||
const manager = new DownloadManager(defaultSettings(), session, paths);
|
||||
|
||||
(manager as any).runItemIds.add("item");
|
||||
(manager as any).recordProviderDownloadedBytes("realdebrid", 4_096);
|
||||
(manager as any).recordRunOutcome("item", "completed");
|
||||
manager.persistNowSync();
|
||||
|
||||
const current = aggregateStatisticsRange(manager.getStats().statistics, 1);
|
||||
expect(current).toMatchObject({ downloadedBytes: 4_096, completedFiles: 1, failedFiles: 0 });
|
||||
expect(current.providers.realdebrid).toEqual({ bytes: 4_096, completed: 1, failed: 0 });
|
||||
expect(fs.existsSync(paths.statisticsFile)).toBe(true);
|
||||
|
||||
const restored = new DownloadManager(defaultSettings(), emptySession(), paths);
|
||||
expect(aggregateStatisticsRange(restored.getStats().statistics, 1)).toMatchObject({
|
||||
downloadedBytes: 4_096,
|
||||
completedFiles: 1
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,9 +5,8 @@ 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,
|
||||
type StatisticsRange
|
||||
buildStatisticsViewModel,
|
||||
type StatisticsMetric
|
||||
} from "../src/renderer/views/statistics/statistics-model";
|
||||
import {
|
||||
StatisticsContent,
|
||||
@@ -15,7 +14,13 @@ import {
|
||||
StatisticsView,
|
||||
type StatisticsViewActions
|
||||
} from "../src/renderer/views/statistics/StatisticsView";
|
||||
import { createVisualFixture } from "./visual/fixtures";
|
||||
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();
|
||||
|
||||
@@ -183,23 +188,28 @@ describe("statistics model", () => {
|
||||
expect(model.providers.some((row) => row.id.includes("host"))).toBe(false);
|
||||
});
|
||||
|
||||
it("uses daily provider usage only for the matching local day", () => {
|
||||
const snapshot = createSnapshot();
|
||||
snapshot.stats.totalDownloaded = 999_999;
|
||||
snapshot.settings.providerDailyUsageDay = "2026-08-10";
|
||||
snapshot.settings.providerDailyUsageBytes = { realdebrid: 500, alldebrid: 1_500 };
|
||||
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.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]
|
||||
]);
|
||||
expectUnavailable(model.metrics.files);
|
||||
expectUnavailable(model.metrics.successRate);
|
||||
expectUnavailable(model.metrics.errors);
|
||||
expectUnavailable(model.metrics.averageSpeedBps);
|
||||
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", () => {
|
||||
@@ -213,28 +223,42 @@ describe("statistics model", () => {
|
||||
expect(model.providers).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(["week", "month"] satisfies StatisticsRange[])("keeps %s unavailable without inventing historical buckets", (range) => {
|
||||
const snapshot = createSnapshot();
|
||||
snapshot.stats.totalDownloaded = 5_000;
|
||||
snapshot.stats.totalDownloadedAllTime = 50_000;
|
||||
snapshot.settings.providerDailyUsageDay = "2026-08-10";
|
||||
snapshot.settings.providerDailyUsageBytes = { realdebrid: 4_000 };
|
||||
snapshot.settings.providerTotalUsageBytes = { realdebrid: 40_000 };
|
||||
|
||||
const model = buildStatisticsViewModel(snapshot, range, now);
|
||||
|
||||
expect(model.coverage).toBe("unavailable");
|
||||
expect(model.message).toBe("Für diesen Zeitraum werden noch keine historischen Daten gespeichert.");
|
||||
expect(model.providers).toEqual([]);
|
||||
expect(model.providerScope).toBeNull();
|
||||
Object.values(model.metrics).forEach(expectUnavailable);
|
||||
});
|
||||
|
||||
it("uses all-time counters and provider totals without inventing historical outcomes", () => {
|
||||
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 };
|
||||
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,
|
||||
@@ -253,10 +277,11 @@ describe("statistics model", () => {
|
||||
["debridlink", 20_000],
|
||||
["realdebrid", 5_000]
|
||||
]);
|
||||
expect(model.providers.every((row) => row.completed === null && row.failed === null)).toBe(true);
|
||||
expectUnavailable(model.metrics.successRate);
|
||||
expectUnavailable(model.metrics.errors);
|
||||
expectUnavailable(model.metrics.averageSpeedBps);
|
||||
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", () => {
|
||||
|
||||
@@ -458,8 +458,38 @@ function createDenseSnapshot(): UiSnapshot {
|
||||
sessionStartedAt: 1786309200000,
|
||||
appSessionStartedAt: 1786309200000,
|
||||
sessionRuntimeMs: 3600000,
|
||||
totalRuntimeMs: 172800000,
|
||||
runtimeMeasuredAt: 1786312800000
|
||||
totalRuntimeMs: 172800000,
|
||||
runtimeMeasuredAt: 1786312800000,
|
||||
statistics: {
|
||||
version: 1,
|
||||
startedAt: 1786053600000,
|
||||
days: [
|
||||
{
|
||||
day: "2026-08-08",
|
||||
downloadedBytes: 182536110080,
|
||||
measuredBytes: 182536110080,
|
||||
completedFiles: 124,
|
||||
failedFiles: 3,
|
||||
activeDownloadMs: 21600000,
|
||||
providers: {
|
||||
realdebrid: { bytes: 123480309760, completed: 86, failed: 1 },
|
||||
debridlink: { bytes: 59055800320, completed: 38, failed: 2 }
|
||||
}
|
||||
},
|
||||
{
|
||||
day: "2026-08-10",
|
||||
downloadedBytes: 541165879488,
|
||||
measuredBytes: 541165879488,
|
||||
completedFiles: 310,
|
||||
failedFiles: 2,
|
||||
activeDownloadMs: 32400000,
|
||||
providers: {
|
||||
realdebrid: { bytes: 328565653504, completed: 192, failed: 1 },
|
||||
debridlink: { bytes: 212600225984, completed: 118, failed: 1 }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
snapshot.speedText = "12,0 MB/s";
|
||||
snapshot.etaText = "00:17:24";
|
||||
|
||||
Reference in New Issue
Block a user