diff --git a/CHANGELOG.md b/CHANGELOG.md index 93da029..45daec9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,29 @@ All notable changes to Multi-Debrid Downloader are documented in this file. +## [2.0.16] - 2026-08-10 + +### Downloads and telemetry + +- Started bandwidth-history collection with the active download session instead of waiting for the Statistics view to be opened. +- Kept the latest 60 seconds of speed samples available when Statistics is opened later. +- Updated the sidebar link counter immediately as individual files finish, fail, or leave the active queue. +- Added locale-aware thousands separators to package, link, and hoster counters. + +### Interface fixes + +- Kept nested application-menu entries visible in narrow windows without introducing a horizontal scrollbar. +- Opened nested right-side menus toward the available left side of the application frame. +- Changed enabled settings switches to the semantic success color. +- Changed online file indicators to the semantic success color. +- Removed overlapping light text fragments from the dark-on-green progress labels and strengthened their weight. +- Made service and status labels react to their actual column width, with compact labels and complete accessible names and tooltips. +- Shortened Mega-Debrid service labels in narrow columns while preserving the full account description as a tooltip. + +### Reliability and testing + +- Added regression coverage for background bandwidth sampling, 60-second history trimming, nested menu overflow, enabled switch colors, progress-label clipping, responsive service/status cells, immediate queue counts, and localized sidebar counters. + ## [2.0.15] - 2026-08-10 ### Highlights diff --git a/package-lock.json b/package-lock.json index bc006b9..82421f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "real-debrid-downloader", - "version": "2.0.15", + "version": "2.0.16", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "real-debrid-downloader", - "version": "2.0.15", + "version": "2.0.16", "license": "MIT", "dependencies": { "adm-zip": "0.6.0", diff --git a/package.json b/package.json index e779cfa..1cb013b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "real-debrid-downloader", - "version": "2.0.15", + "version": "2.0.16", "description": "Desktop downloader", "main": "build/main/main/main.js", "author": "Sucukdeluxe", @@ -74,14 +74,14 @@ "from": "LICENSE", "to": "LICENSE" }, - { - "from": "THIRD_PARTY_NOTICES.md", - "to": "THIRD_PARTY_NOTICES.md" - }, - { - "from": "assets/app_icon.ico", - "to": "assets/app_icon.ico" - } + { + "from": "THIRD_PARTY_NOTICES.md", + "to": "THIRD_PARTY_NOTICES.md" + }, + { + "from": "assets/app_icon.ico", + "to": "assets/app_icon.ico" + } ], "asarUnpack": [ "resources/extractor-jvm/**/*" diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 3caf19a..0078be1 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -90,7 +90,7 @@ import { StatisticsSidebarStatus, type StatisticsViewActions } from "./views/statistics/StatisticsView"; -import { buildDownloadsViewModel, getDownloadQueueTotalBytes, getDownloadSpeedBps, type DownloadDisplayMode, type DownloadSidebarFilter } from "./views/downloads/downloads-model"; +import { buildDownloadsViewModel, getDownloadQueueTotalBytes, getDownloadSpeedBps, getPendingDownloadItemCount, type DownloadDisplayMode, type DownloadSidebarFilter } from "./views/downloads/downloads-model"; import { downloadColumnDefinitions, type DownloadSortColumn } from "./views/downloads/DownloadsTable"; import { beginDownloadColumnDrag, clearDownloadColumnDrag, settleDownloadColumnDrag, updateDownloadColumnDrag, type DownloadColumnDragSession } from "./views/downloads/column-drag"; import { @@ -1316,17 +1316,26 @@ export function readBandwidthChartPalette( }; } +export function appendBandwidthSample( + history: { time: number; speed: number }[], + speed: number, + now = Date.now() +): { time: number; speed: number }[] { + const next = [...history, { time: now, speed: Number.isFinite(speed) ? Math.max(0, speed) : 0 }]; + const cutoff = now - 60000; + const firstVisible = next.findIndex((point) => point.time >= cutoff); + return firstVisible > 0 ? next.slice(firstVisible) : next; +} + interface BandwidthChartProps { - items: Record; - running: boolean; + running: boolean; paused: boolean; speedHistoryRef: React.MutableRefObject<{ time: number; speed: number }[]>; } -const BandwidthChart = memo(function BandwidthChart({ items, running, paused, speedHistoryRef }: BandwidthChartProps): ReactElement { - const canvasRef = useRef(null); - const containerRef = useRef(null); - const lastUpdateRef = useRef(0); +const BandwidthChart = memo(function BandwidthChart({ running, paused, speedHistoryRef }: BandwidthChartProps): ReactElement { + const canvasRef = useRef(null); + const containerRef = useRef(null); const animationFrameRef = useRef(0); @@ -1463,30 +1472,6 @@ const BandwidthChart = memo(function BandwidthChart({ items, running, paused, sp return () => clearInterval(interval); }, [drawChart, running, paused]); - useEffect(() => { - if (!running || paused) return; - - const now = Date.now(); - const activeItems = Object.values(items).filter((item) => item.status === "downloading"); - if (activeItems.length === 0) return; - - const totalSpeed = activeItems.reduce((sum, item) => sum + (item.speedBps || 0), 0); - - const history = speedHistoryRef.current; - history.push({ time: now, speed: totalSpeed }); - - const cutoff = now - 60000; - let trimIndex = 0; - while (trimIndex < history.length && history[trimIndex].time < cutoff) { - trimIndex += 1; - } - if (trimIndex > 0) { - speedHistoryRef.current = history.slice(trimIndex); - } - - lastUpdateRef.current = now; - }, [items, paused, running]); - useEffect(() => { const handleResize = () => { if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current); @@ -1504,7 +1489,7 @@ const BandwidthChart = memo(function BandwidthChart({ items, running, paused, sp useEffect(() => { drawChart(); - }, [drawChart, items, paused]); + }, [drawChart, paused]); return (
@@ -4946,6 +4931,10 @@ export function App(): ReactElement { const downloadPackageSpeeds = useMemo(() => Object.fromEntries(packageSpeedMap), [packageSpeedMap]); const liveDownloadSpeedBps = useMemo(() => getDownloadSpeedBps(snapshot.packageSpeedBps), [snapshot.packageSpeedBps]); + useEffect(() => { + if (!snapshot.session.running || snapshot.session.paused) return; + speedHistoryRef.current = appendBandwidthSample(speedHistoryRef.current, liveDownloadSpeedBps); + }, [liveDownloadSpeedBps, snapshot.packageSpeedBps, snapshot.session.paused, snapshot.session.running]); const downloadQueueTotalBytes = useMemo(() => getDownloadQueueTotalBytes(Object.values(snapshot.session.items)), [snapshot.session.items]); const downloadsViewModel = useMemo(() => ({ ...downloadsViewCore, @@ -4971,7 +4960,7 @@ export function App(): ReactElement { sortDirection: downloadsSortDescending ? "desc" : "asc", status: { packages: snapshot.stats.totalPackages, - links: Object.keys(snapshot.session.items).length, + links: getPendingDownloadItemCount(Object.values(snapshot.session.items)), session: humanSize(snapshot.stats.totalDownloaded), sessionBytes: snapshot.stats.totalDownloaded, total: humanSize(downloadQueueTotalBytes), @@ -5963,7 +5952,7 @@ export function App(): ReactElement { {tab === "statistics" && ( } + chart={} model={statisticsViewModel} /> )} diff --git a/src/renderer/download-format.ts b/src/renderer/download-format.ts index 76eb303..52dda18 100644 --- a/src/renderer/download-format.ts +++ b/src/renderer/download-format.ts @@ -30,7 +30,11 @@ export function compactProviderLabels(labels: string[]): string { } export function normalizeDownloadServiceLabel(label: string): string { - return label.replace(/\s+(Web|API)\s+\(\1 Account\)$/i, " $1"); + return [...new Set(label.split(",").map((entry) => entry.trim().replace(/\s+(Web|API)\s+\(\1 Account\)$/i, " $1")).filter(Boolean))].join(", "); +} + +export function compactDownloadServiceLabel(label: string): string { + return [...new Set(normalizeDownloadServiceLabel(label).split(",").map((entry) => entry.trim().replace(/\s+(Web|API)$/i, "")).filter(Boolean))].join(", "); } export function formatDateTime(timestamp: number): string { diff --git a/src/renderer/shell/shell.css b/src/renderer/shell/shell.css index 7d80e74..c469352 100644 --- a/src/renderer/shell/shell.css +++ b/src/renderer/shell/shell.css @@ -601,13 +601,18 @@ .md-application-menu-tree :where(.menu-dropdown, .menu-submenu-dropdown) { z-index: var(--md-layer-menu); max-height: calc(100vh - 16px); - overflow-y: auto; + overflow: visible; } .md-application-menu-tree > .menu-bar-item > .menu-dropdown { right: 0; left: auto; } + +.md-application-menu-tree .menu-submenu-dropdown { + right: 100%; + left: auto; +} .md-overlay-host { position: static; diff --git a/src/renderer/views/downloads/DownloadsTable.tsx b/src/renderer/views/downloads/DownloadsTable.tsx index 9cfd364..565cdcc 100644 --- a/src/renderer/views/downloads/DownloadsTable.tsx +++ b/src/renderer/views/downloads/DownloadsTable.tsx @@ -2,6 +2,7 @@ import { memo, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, import type { DownloadItem } from "../../../shared/types"; import { compactProviderLabels, + compactDownloadServiceLabel, extractHoster, formatAudioStripSummary, formatDateTime, @@ -147,6 +148,17 @@ function DownloadStatusCell({ status, title }: { status: string; title?: string ); } +function DownloadServiceCell({ label }: { label: string }): ReactElement { + const full = normalizeDownloadServiceLabel(label); + const compact = compactDownloadServiceLabel(label); + return ( + + + + + ); +} + function itemCell(item: DownloadItem, column: string, sessionRunning: boolean): ReactElement | null { const displayStatus = displayedStatus(item, sessionRunning); const retrySuffix = item.retries > 0 ? ` (R${item.retries})` : ""; @@ -174,7 +186,7 @@ function itemCell(item: DownloadItem, column: string, sessionRunning: boolean): } if (column === "account") { const full = item.providerLabel || (item.provider ? providerLabels[item.provider] : ""); - return {normalizeDownloadServiceLabel(full)}; + return ; } if (column === "prio") return ; if (column === "status") return ; @@ -393,7 +405,7 @@ function packageCell(row: DownloadPackageRow, column: string, packageSpeedBps: n } if (column === "account") { const full = compactProviderLabels(row.items.map((item) => item.providerLabel || (item.provider ? providerLabels[item.provider] : "")).filter(Boolean)); - return {normalizeDownloadServiceLabel(full)}; + return ; } if (column === "prio") return {entry.priority === "high" ? "Hoch" : entry.priority === "low" ? "Niedrig" : ""}; if (column === "status") { diff --git a/src/renderer/views/downloads/DownloadsView.tsx b/src/renderer/views/downloads/DownloadsView.tsx index 51573c0..200e89c 100644 --- a/src/renderer/views/downloads/DownloadsView.tsx +++ b/src/renderer/views/downloads/DownloadsView.tsx @@ -9,7 +9,9 @@ import { type DownloadSortColumn, type DownloadsTableActions } from "./DownloadsTable"; -import "./downloads.css"; +import "./downloads.css"; + +const integerFormatter = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 0 }); export interface DownloadsStatusModel { packages: number; @@ -104,11 +106,11 @@ export function DownloadsSidebarStatus({ model }: { model: DownloadsViewModel }) const speed = model.status.speed.replace(/^Geschwindigkeit:\s*/i, ""); const eta = model.status.eta.replace(/^ETA:\s*/i, ""); const entries = [ - { label: "Pakete", metric: "packages", numericValue: model.status.packages, value: String(model.status.packages) }, - { label: "Links", metric: "links", numericValue: model.status.links, value: String(model.status.links) }, + { label: "Pakete", metric: "packages", numericValue: model.status.packages, value: integerFormatter.format(model.status.packages) }, + { label: "Links", metric: "links", numericValue: model.status.links, value: integerFormatter.format(model.status.links) }, { label: "Sitzung", metric: "session", numericValue: model.status.sessionBytes, value: model.status.session }, { label: "Gesamt", metric: "total", numericValue: model.status.totalBytes, value: model.status.total }, - { label: "Hoster", metric: "hosters", numericValue: model.status.hosters, value: String(model.status.hosters) } + { label: "Hoster", metric: "hosters", numericValue: model.status.hosters, value: integerFormatter.format(model.status.hosters) } ]; return
{entries.map((entry) =>
{entry.label}
)}
Geschwindigkeit{speed}
ETA{eta}
; } diff --git a/src/renderer/views/downloads/downloads-model.ts b/src/renderer/views/downloads/downloads-model.ts index 6359ac3..c028f45 100644 --- a/src/renderer/views/downloads/downloads-model.ts +++ b/src/renderer/views/downloads/downloads-model.ts @@ -83,6 +83,14 @@ export function getDownloadQueueTotalBytes(items: Iterable): numbe return total; } +export function getPendingDownloadItemCount(items: Iterable): number { + let count = 0; + for (const item of items) { + if (item.status !== "completed" && item.status !== "cancelled" && item.status !== "failed") count += 1; + } + return count; +} + export function getDownloadSpeedBps(packageSpeeds: Record): number { let total = 0; for (const speed of Object.values(packageSpeeds)) { diff --git a/src/renderer/views/downloads/downloads.css b/src/renderer/views/downloads/downloads.css index f9e333c..15bf657 100644 --- a/src/renderer/views/downloads/downloads.css +++ b/src/renderer/views/downloads/downloads.css @@ -545,7 +545,7 @@ } .downloads-link-state.online { - background: var(--ui-primary); + background: var(--ui-success); } .downloads-link-state.offline { @@ -637,10 +637,11 @@ justify-content: center; height: 100%; font-size: 11px; - font-weight: 600; + font-weight: 700; } .downloads-meter-label.is-track { + clip-path: inset(0 0 0 var(--downloads-progress)); color: var(--ui-progress-track-text, #fff); } @@ -656,7 +657,16 @@ color: var(--ui-progress-fill-text, #171a1f); } -.downloads-status-compact { +.downloads-status-cell { + container-type: inline-size; +} + +.downloads-service-cell { + container-type: inline-size; +} + +.downloads-status-compact, +.downloads-service-compact { display: none; } @@ -665,14 +675,22 @@ --downloads-status-min: 90px; } -.md-shell.is-compact .downloads-status-full, -.md-shell.is-minimum .downloads-status-full { - display: none; -} +@container (max-width: 150px) { + .downloads-status-full { + display: none; + } -.md-shell.is-compact .downloads-status-compact, -.md-shell.is-minimum .downloads-status-compact { - display: inline; + .downloads-status-compact { + display: inline; + } + + .downloads-service-full { + display: none; + } + + .downloads-service-compact { + display: inline; + } } .downloads-empty-state, diff --git a/src/renderer/views/settings/settings.css b/src/renderer/views/settings/settings.css index 73cb610..7ffba98 100644 --- a/src/renderer/views/settings/settings.css +++ b/src/renderer/views/settings/settings.css @@ -296,8 +296,8 @@ } .settings-switch.is-on { - border-color: var(--ui-accent); - background: var(--ui-accent); + border-color: var(--ui-success); + background: var(--ui-success); } .settings-switch.is-on > span { diff --git a/tests/app-shell.test.tsx b/tests/app-shell.test.tsx index 523d81c..b1219f2 100644 --- a/tests/app-shell.test.tsx +++ b/tests/app-shell.test.tsx @@ -30,6 +30,13 @@ describe("desktop shell", () => { expect(shellCss).toMatch(/\.md-application-menu-tree > \.menu-bar-item > \.menu-dropdown\s*\{[^}]*right:\s*0;[^}]*left:\s*auto;/s); }); + it("keeps application submenus visible inside narrow right-aligned windows", () => { + const shellCss = readFileSync(new URL("../src/renderer/shell/shell.css", import.meta.url), "utf8"); + + expect(shellCss).toMatch(/\.md-application-menu-tree :where\(\.menu-dropdown, \.menu-submenu-dropdown\)\s*\{[^}]*overflow:\s*visible;/s); + expect(shellCss).toMatch(/\.md-application-menu-tree \.menu-submenu-dropdown\s*\{[^}]*right:\s*100%;[^}]*left:\s*auto;/s); + }); + it("uses the product asset in the header brand", () => { const html = renderToStaticMarkup( {}} />); expect(html).toContain("Multi-Debrid Downloader"); diff --git a/tests/downloads-view.test.tsx b/tests/downloads-view.test.tsx index 6434966..b45826b 100644 --- a/tests/downloads-view.test.tsx +++ b/tests/downloads-view.test.tsx @@ -10,6 +10,7 @@ import { buildDownloadsViewModel, classifyDownloadStatus, getDownloadQueueTotalBytes, + getPendingDownloadItemCount, getDownloadSpeedBps, type DownloadSidebarFilter, type DownloadsModelInput @@ -33,7 +34,7 @@ import { downloadColumnDefinitions, getAvailabilitySummary } from "../src/renderer/views/downloads/DownloadsTable"; -import { normalizeDownloadServiceLabel } from "../src/renderer/download-format"; +import { compactDownloadServiceLabel, normalizeDownloadServiceLabel } from "../src/renderer/download-format"; import { getRollingMetricDirection } from "../src/renderer/ui/RollingMetricValue"; const now = new Date(2026, 7, 10, 12, 0, 0, 0).getTime(); @@ -125,6 +126,28 @@ describe("Download-Gesamtgröße", () => { }); }); +describe("laufender Queue-Linkzähler", () => { + it("sinkt sofort, sobald eine Unterdatei abgeschlossen ist", () => { + const items = [ + item("queued", "package-a", "queued"), + item("active", "package-a", "downloading"), + item("done", "package-a", "completed"), + item("failed", "package-a", "failed") + ]; + + expect(getPendingDownloadItemCount(items)).toBe(2); + }); + + it("formatiert große Sidebar-Zähler mit deutschen Tausenderpunkten", () => { + const model = withRuntime(createInput(), { + status: { ...withRuntime(createInput()).status, packages: 130, links: 2484 } + }); + const html = renderToStaticMarkup(); + + expect(html).toContain(">2.484<"); + }); +}); + describe("responsive Downloadstatus und Servicebezeichnungen", () => { it("keeps full status details while providing compact table text", () => { expect(compactDownloadStatus("Link wird umgewandelt")).toBe("Umwandeln"); @@ -137,6 +160,8 @@ describe("responsive Downloadstatus und Servicebezeichnungen", () => { expect(normalizeDownloadServiceLabel("Mega-Debrid Web (Web Account)")).toBe("Mega-Debrid Web"); expect(normalizeDownloadServiceLabel("Mega-Debrid API (API Account)")).toBe("Mega-Debrid API"); expect(normalizeDownloadServiceLabel("Real-Debrid (Web Account)")).toBe("Real-Debrid (Web Account)"); + expect(normalizeDownloadServiceLabel("Mega-Debrid Web (Web Account), Mega-Debrid API (API Account)")).toBe("Mega-Debrid Web, Mega-Debrid API"); + expect(compactDownloadServiceLabel("Mega-Debrid Web (Web Account), Mega-Debrid API (API Account)")).toBe("Mega-Debrid"); }); }); @@ -622,7 +647,14 @@ describe("downloads view", () => { expect(css).toMatch(/\.downloads-package-items\s*\{[^}]*height:\s*auto;[^}]*overflow:\s*hidden;/s); expect(css).toMatch(/\.downloads-package-items\.is-collapsed\s*\{[^}]*height:\s*0;[^}]*opacity:\s*0;[^}]*pointer-events:\s*none;/s); expect(css).toMatch(/\.downloads-meter-label\.is-track\s*\{[^}]*color:\s*var\(--ui-progress-track-text/s); + expect(css).toMatch(/\.downloads-meter-label\s*\{[^}]*font-weight:\s*700;/s); + expect(css).toMatch(/\.downloads-meter-label\.is-track\s*\{[^}]*clip-path:\s*inset\(0 0 0 var\(--downloads-progress\)\);/s); expect(css).toMatch(/\.downloads-meter-label\.is-filled\s*\{[^}]*color:\s*var\(--ui-progress-fill-text/s); + expect(css).toMatch(/\.downloads-link-state\.online\s*\{[^}]*background:\s*var\(--ui-success\);/s); + expect(css).toMatch(/\.downloads-status-cell\s*\{[^}]*container-type:\s*inline-size;/s); + expect(css).toMatch(/\.downloads-service-cell\s*\{[^}]*container-type:\s*inline-size;/s); + expect(css).toMatch(/@container\s*\(max-width:\s*150px\)[\s\S]*\.downloads-status-full[^{]*\{[^}]*display:\s*none;[\s\S]*\.downloads-status-compact[^{]*\{[^}]*display:\s*inline;/s); + expect(css).toMatch(/@container\s*\(max-width:\s*150px\)[\s\S]*\.downloads-service-full[^{]*\{[^}]*display:\s*none;[\s\S]*\.downloads-service-compact[^{]*\{[^}]*display:\s*inline;/s); expect(readFileSync(new URL("../src/renderer/views/downloads/DownloadsTable.tsx", import.meta.url), "utf8")).toMatch(/\.animate\(\[\{ height: "0px", opacity: 0 \}, \{ height: `\$\{targetHeight\}px`, opacity: 1 \}\]/); expect(css).toMatch(/\.downloads-footer\s*\{[^}]*height:\s*60px;[^}]*padding:\s*0 12px 0 60px;/s); expect(css).toMatch(/\.downloads-package-card\s*\{[^}]*border:\s*0;[^}]*border-bottom:\s*1px solid color-mix\(in srgb, var\(--ui-border\) 72%, transparent\);[^}]*padding:\s*0;/s); @@ -800,7 +832,8 @@ describe("download table row contracts", () => { expect(html).toContain('class="downloads-status-compact"'); expect(html).toContain("DL läuft"); expect(html).toContain('title="Mega-Debrid Web (Web Account)"'); - expect(html).toContain(">Mega-Debrid Web
"); + expect(html).toContain('class="downloads-service-full">Mega-Debrid Web'); + expect(html).toContain('class="downloads-service-compact">Mega-Debrid'); }); it("sets the whole visible selection atomically from the header checkbox", () => { diff --git a/tests/settings-view.test.tsx b/tests/settings-view.test.tsx index cd45b94..944cd43 100644 --- a/tests/settings-view.test.tsx +++ b/tests/settings-view.test.tsx @@ -697,6 +697,7 @@ describe("settings geometry", () => { expect(css).toMatch(/\.settings-form-column\s*{[^}]*width:\s*500px;[^}]*max-width:\s*100%;/s); expect(css).toMatch(/\.settings-control\s*{[^}]*height:\s*44px;[^}]*border-radius:\s*6px;/s); expect(css).toMatch(/\.settings-switch\s*{[^}]*width:\s*40px;[^}]*height:\s*20px;/s); + expect(css).toMatch(/\.settings-switch\.is-on\s*{[^}]*border-color:\s*var\(--ui-success\);[^}]*background:\s*var\(--ui-success\);/s); expect(css).toMatch(/\.settings-account-table-header\s*{[^}]*height:\s*41px;/s); expect(css).toMatch(/\.settings-account-table-header\s*{[^}]*overflow:\s*hidden;/s); expect(css).toMatch(/\.settings-account-table-grid\s*{[^}]*color:\s*var\(--ui-text\);/s); diff --git a/tests/statistics-view.test.tsx b/tests/statistics-view.test.tsx index 41d18ef..092d32c 100644 --- a/tests/statistics-view.test.tsx +++ b/tests/statistics-view.test.tsx @@ -3,7 +3,7 @@ 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 { readBandwidthChartPalette } from "../src/renderer/App"; +import { appendBandwidthSample, readBandwidthChartPalette } from "../src/renderer/App"; import { buildStatisticsViewModel, type StatisticsMetric, @@ -17,7 +17,22 @@ import { } from "../src/renderer/views/statistics/StatisticsView"; import { createVisualFixture } from "./visual/fixtures"; -const now = new Date(2026, 7, 10, 12, 0, 0, 0).getTime(); +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); @@ -385,6 +400,15 @@ describe("statistics view", () => { }); 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];