release: publish v2.0.21 responsive accessibility audit
Refine all primary views across supported desktop widths, preserve compact download actions, strengthen keyboard and assistive semantics, confirm irreversible actions, complete new bilingual UI strings, and unify live speed visualization colors.
This commit is contained in:
@@ -8,6 +8,30 @@ import { buildMainNavigation } from "../src/renderer/shell/shell-model";
|
||||
import { getSnapshotRenderDelay } from "../src/renderer/App";
|
||||
|
||||
describe("desktop shell", () => {
|
||||
it("uses keyboard-focusable controls for every copy target", () => {
|
||||
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8");
|
||||
|
||||
expect(source).not.toMatch(/<span[^>]*className="[^"]*link-popup-click/);
|
||||
expect(source.match(/<button[^>]*className="[^"]*link-popup-click[^>]*type="button"/g)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("confirms before removing a collector tab", () => {
|
||||
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8");
|
||||
const removal = source.slice(source.indexOf("const removeCollectorTab"), source.indexOf("const openCollectorInput"));
|
||||
|
||||
expect(removal).toContain("askConfirmPrompt");
|
||||
expect(removal.indexOf("askConfirmPrompt")).toBeLessThan(removal.indexOf("planCollectorTabRemoval"));
|
||||
});
|
||||
|
||||
it("confirms before removing selected collector links", () => {
|
||||
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8");
|
||||
const removal = source.slice(source.indexOf("const removeSelectedCollectorRows"), source.indexOf("const onPackageStartEdit"));
|
||||
|
||||
expect(removal).toContain("askConfirmPrompt");
|
||||
expect(removal.indexOf("askConfirmPrompt")).toBeLessThan(removal.indexOf("setCollectorTabs"));
|
||||
expect(removal).toContain('title: "Ausgewählte Links löschen"');
|
||||
});
|
||||
|
||||
it("does not stack renderer latency on the manager cadence for large active queues", () => {
|
||||
expect(getSnapshotRenderDelay(2_470, true, "downloads")).toBe(0);
|
||||
expect(getSnapshotRenderDelay(2_470, true, "statistics")).toBe(800);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { isValidElement, type ReactElement, type ReactNode } from "react";
|
||||
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 {
|
||||
@@ -185,7 +186,7 @@ describe("CollectorView", () => {
|
||||
expect(empty).not.toContain("aria-label=\"Seitennavigation\"");
|
||||
});
|
||||
|
||||
it("renders compact occupied rows and removes the empty marker", () => {
|
||||
it("renders compact occupied rows and removes the empty marker", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CollectorView
|
||||
actions={createActions()}
|
||||
@@ -199,10 +200,58 @@ describe("CollectorView", () => {
|
||||
expect(html).toContain("data-visual-region=\"collector-toolbar\"");
|
||||
expect(html).toContain("data-visual-region=\"collector-table-body\"");
|
||||
expect(html).not.toContain("data-visual-region=\"downloads-toolbar\"");
|
||||
expect(html).not.toContain("aria-label=\"Seitennavigation\"");
|
||||
});
|
||||
|
||||
it("separates local input, queue submission, search, selection and local removal callbacks", () => {
|
||||
expect(html).not.toContain("aria-label=\"Seitennavigation\"");
|
||||
});
|
||||
|
||||
it("gives every row checkbox a unique accessible name with its link and collection", () => {
|
||||
const content = CollectorContent({
|
||||
actions: createActions(),
|
||||
model: buildCollectorViewModel(populatedTabs, "tab-a", "", false, [])
|
||||
});
|
||||
const labels: string[] = [];
|
||||
visitElements(content, (element) => {
|
||||
if (element.type === "input" && element.props.type === "checkbox") {
|
||||
labels.push(element.props["aria-label"]);
|
||||
}
|
||||
});
|
||||
|
||||
expect(labels).toEqual([
|
||||
"https://example.test/a aus Sammlung A, Zeile 1 auswählen",
|
||||
"https://example.test/b aus Sammlung A, Zeile 3 auswählen"
|
||||
]);
|
||||
expect(new Set(labels).size).toBe(labels.length);
|
||||
});
|
||||
|
||||
it("uses a high-contrast table heading token in both themes", () => {
|
||||
const css = readFileSync(new URL("../src/renderer/views/collector/collector.css", import.meta.url), "utf8");
|
||||
|
||||
expect(css).toMatch(/\.collector-table-header-row\s*{[^}]*color:\s*var\(--ui-text-secondary\);/s);
|
||||
});
|
||||
|
||||
it("uses the semantic danger text token for the removal action", () => {
|
||||
const css = readFileSync(new URL("../src/renderer/views/collector/collector.css", import.meta.url), "utf8");
|
||||
|
||||
expect(css).toMatch(/\.collector-action-danger:not\(:disabled\)\s*{[^}]*color:\s*var\(--ui-danger-text\);/s);
|
||||
});
|
||||
|
||||
it("disables queue submission only when the active collection has no links", () => {
|
||||
const emptyActive = CollectorToolbar({
|
||||
actions: createActions(),
|
||||
model: buildCollectorViewModel([
|
||||
{ id: "tab-a", name: "Sammlung A", text: "" },
|
||||
{ id: "tab-b", name: "Sammlung B", text: "https://example.test/b" }
|
||||
], "tab-a", "", false, [])
|
||||
});
|
||||
const filteredActive = CollectorToolbar({
|
||||
actions: createActions(),
|
||||
model: buildCollectorViewModel(populatedTabs, "tab-a", "kein-treffer", false, [])
|
||||
});
|
||||
|
||||
expect(findButton(emptyActive, "An Downloads übergeben").props.disabled).toBe(true);
|
||||
expect(findButton(filteredActive, "An Downloads übergeben").props.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("separates local input, queue submission, search, selection and local removal callbacks", () => {
|
||||
let inputOpens = 0;
|
||||
let queueSubmits = 0;
|
||||
let query = "";
|
||||
@@ -235,7 +284,7 @@ describe("CollectorView", () => {
|
||||
search.props.onChange({ target: { value: "release" } });
|
||||
expect(query).toBe("release");
|
||||
|
||||
const checkbox = findElement(content, (element) => element.type === "input" && element.props["aria-label"] === "Link auswählen");
|
||||
const checkbox = findElement(content, (element) => element.type === "input" && element.props.type === "checkbox");
|
||||
checkbox.props.onChange();
|
||||
findButton(toolbar, "Auswahl entfernen").props.onClick();
|
||||
expect(selected).toBe("tab-a:0");
|
||||
@@ -243,7 +292,7 @@ describe("CollectorView", () => {
|
||||
expect(queueSubmits).toBe(1);
|
||||
});
|
||||
|
||||
it("names the input dialog and commits only through the local draft callback", () => {
|
||||
it("names the input dialog and commits only through the local draft callback", () => {
|
||||
let value = "";
|
||||
let commits = 0;
|
||||
const dialog = CollectorInputDialog({
|
||||
@@ -265,6 +314,13 @@ describe("CollectorView", () => {
|
||||
textbox.props.onChange({ target: { value: "https://example.test/new" } });
|
||||
findButton(dialog, "Übernehmen").props.onClick();
|
||||
expect(value).toBe("https://example.test/new");
|
||||
expect(commits).toBe(1);
|
||||
});
|
||||
});
|
||||
expect(commits).toBe(1);
|
||||
});
|
||||
|
||||
it("moves the search field onto a separate compact row instead of overlapping actions", () => {
|
||||
const css = readFileSync(new URL("../src/renderer/views/collector/collector.css", import.meta.url), "utf8");
|
||||
|
||||
expect(css).toMatch(/@media \(max-width: 1120px\)[\s\S]*\.collector-toolbar\s*\{[^}]*flex-wrap:\s*wrap;/s);
|
||||
expect(css).toMatch(/@media \(max-width: 1120px\)[\s\S]*\.collector-toolbar \.ui-toolbar-search\s*\{[^}]*flex:\s*1 0 100%;[^}]*width:\s*100%;/s);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,11 +43,11 @@ const now = new Date(2026, 7, 10, 12, 0, 0, 0).getTime();
|
||||
|
||||
describe("Downloadtabellen-Spalten", () => {
|
||||
it("verteilt die Breite mit ausreichend Platz für vollständige Überschriften", () => {
|
||||
expect(downloadColumnDefinitions.name.width).toBe("minmax(290px, 2.3fr)");
|
||||
expect(downloadColumnDefinitions.progress.width).toBe("minmax(105px, 0.85fr)");
|
||||
expect(downloadColumnDefinitions.prio.width).toBe("minmax(85px, 0.8fr)");
|
||||
expect(downloadColumnDefinitions.speed).toEqual(expect.objectContaining({ label: "Geschwindigkeit", width: "minmax(120px, 1fr)" }));
|
||||
expect(downloadColumnDefinitions.availability).toEqual(expect.objectContaining({ label: "Verfügbarkeit", width: "minmax(110px, 1fr)" }));
|
||||
expect(downloadColumnDefinitions.name.width).toBe("minmax(var(--downloads-name-min, 290px), 2.3fr)");
|
||||
expect(downloadColumnDefinitions.progress.width).toBe("minmax(var(--downloads-progress-min, 105px), 0.85fr)");
|
||||
expect(downloadColumnDefinitions.prio.width).toBe("minmax(var(--downloads-priority-min, 85px), 0.8fr)");
|
||||
expect(downloadColumnDefinitions.speed).toEqual(expect.objectContaining({ label: "Geschwindigkeit", width: "minmax(var(--downloads-speed-min, 120px), 1fr)" }));
|
||||
expect(downloadColumnDefinitions.availability).toEqual(expect.objectContaining({ label: "Verfügbarkeit", width: "minmax(var(--downloads-availability-min, 110px), 1fr)" }));
|
||||
});
|
||||
|
||||
it("uses the normal text color for sortable and static column headers", () => {
|
||||
@@ -484,6 +484,7 @@ describe("downloads view", () => {
|
||||
expect(html).toContain("ui-sliding-selection ui-sliding-selection-vertical");
|
||||
expect(html.match(/data-sliding-selection-item="true"/g)).toHaveLength(6);
|
||||
expect(html.match(/data-sliding-selection-active="true"/g)).toHaveLength(1);
|
||||
expect(html.match(/aria-current="page"/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("disables the service filter until more than one concrete service is available", () => {
|
||||
@@ -668,7 +669,7 @@ describe("downloads view", () => {
|
||||
expect(html).toContain('class="downloads-package-items is-expanded"');
|
||||
expect(html).toContain('class="downloads-package-items-inner"');
|
||||
expect(css).toMatch(/\.downloads-table\s*\{[^}]*overflow-x:\s*auto;[^}]*scrollbar-gutter:\s*stable;/s);
|
||||
expect(css).toMatch(/\.downloads-table-header,\s*\.downloads-table-body\s*\{[^}]*width:\s*100%;[^}]*min-width:\s*1191px;/s);
|
||||
expect(css).toMatch(/\.downloads-table-header,\s*\.downloads-table-body\s*\{[^}]*width:\s*100%;[^}]*min-width:\s*var\(--downloads-table-min-width, 1191px\);/s);
|
||||
expect(css).not.toMatch(/min-width:\s*max-content;/);
|
||||
expect(css).toMatch(/\.downloads-table-header\s*\{[^}]*height:\s*41px;[^}]*position:\s*sticky;/s);
|
||||
expect(css).toMatch(/\.downloads-item-row,\s*\.downloads-package-row\s*\{[^}]*height:\s*40px;/s);
|
||||
@@ -678,6 +679,7 @@ describe("downloads view", () => {
|
||||
expect(css).toMatch(/\.downloads-collapse-button\s*\{[^}]*box-sizing:\s*border-box;[^}]*flex:\s*0 0 30px;[^}]*width:\s*30px;[^}]*min-width:\s*30px;[^}]*max-width:\s*30px;/s);
|
||||
expect(css).toMatch(/\.downloads-cell-slot\s*\{[^}]*display:\s*flex;[^}]*justify-content:\s*center;/s);
|
||||
expect(css).toMatch(/\.downloads-cell-slot\s*>\s*\.downloads-cell\s*\{[^}]*justify-content:\s*center;[^}]*text-align:\s*center;/s);
|
||||
expect(css).toMatch(/\.downloads-column-header\s*\{[^}]*justify-content:\s*center;[^}]*text-align:\s*center;/s);
|
||||
expect(css).toMatch(/\[data-download-column="name"\][^{]*\{[^}]*justify-content:\s*flex-start;[^}]*text-align:\s*left;/s);
|
||||
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);
|
||||
@@ -688,8 +690,10 @@ describe("downloads view", () => {
|
||||
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(css).toMatch(/\.downloads-cell-slot\s*>\s*:is\(\.downloads-status-cell, \.downloads-service-cell\)\s*\{[^}]*justify-content:\s*flex-start;[^}]*text-align:\s*left;/s);
|
||||
expect(css).toMatch(/:is\(\.downloads-status-full, \.downloads-status-compact, \.downloads-service-full, \.downloads-service-compact\)\s*\{[^}]*min-width:\s*0;[^}]*overflow:\s*hidden;[^}]*text-overflow:\s*ellipsis;[^}]*white-space:\s*nowrap;/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*block;/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*block;/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);
|
||||
@@ -720,10 +724,13 @@ describe("downloads view", () => {
|
||||
expect(source.match(/duration:\s*300/g)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps the 1120px layout inside the single downloads table scroll owner", () => {
|
||||
it("keeps the action column visible at 1366px and 1120px through the production wrapper contract", () => {
|
||||
const html = renderToStaticMarkup(<DownloadsContent actions={createActions()} model={withRuntime(createInput())} />);
|
||||
const css = readFileSync(new URL("../src/renderer/views/downloads/downloads.css", import.meta.url), "utf8");
|
||||
|
||||
expect(css).toMatch(/@media \(max-width:\s*1120px\)/);
|
||||
expect(html).toMatch(/^<main class="downloads-content">/);
|
||||
expect(css).toMatch(/\.md-shell\.is-compact \.downloads-content,\s*\.md-shell\.is-minimum \.downloads-content\s*\{[^}]*--downloads-table-min-width:\s*1016px;[^}]*--downloads-name-min:\s*180px;[^}]*--downloads-status-min:\s*90px;/s);
|
||||
expect(css).not.toMatch(/\.md-shell\.is-(?:compact|minimum) \.downloads-view/);
|
||||
expect(css).toMatch(/\.downloads-content\s*\{[^}]*overflow:\s*hidden;/s);
|
||||
expect(css).toMatch(/\.downloads-table\s*\{[^}]*overflow-x:\s*auto;[^}]*overflow-y:\s*auto;/s);
|
||||
});
|
||||
@@ -838,6 +845,9 @@ describe("download table row contracts", () => {
|
||||
expect(css).toMatch(/\.downloads-availability\.has-counts\s*\{[^}]*display:\s*grid;[^}]*grid-template-columns:\s*16px 4ch 1ch 4ch auto;[^}]*font-variant-numeric:\s*tabular-nums;/s);
|
||||
expect(css).toMatch(/\.downloads-availability-count\.is-online-count\s*\{[^}]*text-align:\s*right;/s);
|
||||
expect(css).toMatch(/\.downloads-availability-count\.is-total-count\s*\{[^}]*text-align:\s*left;/s);
|
||||
expect(css).toMatch(/\.downloads-availability\.is-online\s*\{[^}]*color:\s*var\(--ui-success-text\);/s);
|
||||
expect(css).toMatch(/\.downloads-availability\.is-partial\s*\{[^}]*color:\s*var\(--ui-warning-text\);/s);
|
||||
expect(css).toMatch(/\.downloads-availability\.is-offline\s*\{[^}]*color:\s*var\(--ui-danger-text\);/s);
|
||||
});
|
||||
|
||||
it("preserves the package download and extraction phase split", () => {
|
||||
@@ -936,12 +946,53 @@ describe("download table row contracts", () => {
|
||||
visibleIds: ["package-a", "active", "queued"]
|
||||
});
|
||||
const checkbox = findElement(header, (element) => element.type === "input");
|
||||
const input = { indeterminate: false };
|
||||
|
||||
(checkbox as unknown as { ref: (element: typeof input) => void }).ref(input);
|
||||
checkbox.props.onChange({ target: { checked: true } });
|
||||
|
||||
expect(checkbox.props["aria-checked"]).toBe("mixed");
|
||||
expect(input.indeterminate).toBe(true);
|
||||
expect(calls).toEqual([[['package-a', 'active', 'queued'], true]]);
|
||||
});
|
||||
|
||||
it("announces sort state and exposes keyboard-operable column move controls", () => {
|
||||
const calls: Array<[string, string, number]> = [];
|
||||
const header = DownloadsTableHeader({
|
||||
actions: createActions({
|
||||
onColumnPointerDown: (column, event) => calls.push(["down", column, event.clientX]),
|
||||
onColumnPointerMove: (column, event) => calls.push(["move", column, event.clientX]),
|
||||
onColumnPointerUp: (column, event) => calls.push(["up", column, event.clientX])
|
||||
}),
|
||||
columnOrder: ["name", "size", "account"],
|
||||
gridTemplate: "200px 100px 100px",
|
||||
selectedCount: 0,
|
||||
sortColumn: "name",
|
||||
sortDirection: "desc",
|
||||
visibleIds: ["package-a"]
|
||||
});
|
||||
const html = renderToStaticMarkup(header);
|
||||
const moveLeft = findElement(header, (element) => element.type === "button" && element.props["aria-label"] === "Geladen / Größe nach links verschieben");
|
||||
const previous = { getBoundingClientRect: () => ({ left: 100, width: 100 }), matches: () => true };
|
||||
const current = {
|
||||
getBoundingClientRect: () => ({ left: 200, width: 100 }),
|
||||
previousElementSibling: previous,
|
||||
nextElementSibling: null
|
||||
};
|
||||
|
||||
moveLeft.props.onClick({ currentTarget: { closest: () => current }, stopPropagation: () => {} });
|
||||
|
||||
expect(html).toMatch(/aria-sort="descending"[^>]*data-download-column="name"/);
|
||||
expect(html).toMatch(/aria-sort="none"[^>]*data-download-column="size"/);
|
||||
expect(html).not.toMatch(/aria-sort="[^"]+"[^>]*data-download-column="account"/);
|
||||
expect(moveLeft.props.type).toBe("button");
|
||||
expect(calls).toEqual([
|
||||
["down", "size", 250],
|
||||
["move", "size", 149],
|
||||
["up", "size", 149]
|
||||
]);
|
||||
});
|
||||
|
||||
it("includes package selection state in memo equality", () => {
|
||||
const model = withRuntime(createInput());
|
||||
const row = model.packageRows[0];
|
||||
|
||||
+120
-8
@@ -8,6 +8,8 @@ import {
|
||||
deriveHistoryHoster,
|
||||
deriveHistoryStartAt,
|
||||
filterHistoryRows,
|
||||
HISTORY_PAGE_SIZE,
|
||||
paginateHistoryRows,
|
||||
pruneHistoryIds,
|
||||
selectVisibleHistoryIds,
|
||||
type HistoryFilter,
|
||||
@@ -15,11 +17,14 @@ import {
|
||||
} from "../src/renderer/views/history/history-model";
|
||||
import {
|
||||
HistoryContent,
|
||||
HistoryContentPage,
|
||||
HistoryPagination,
|
||||
HistorySidebar,
|
||||
HistoryToolbar,
|
||||
HistoryView,
|
||||
type HistoryViewActions
|
||||
} from "../src/renderer/views/history/HistoryView";
|
||||
HistoryView,
|
||||
historyPageStatusLabel,
|
||||
type HistoryViewActions
|
||||
} from "../src/renderer/views/history/HistoryView";
|
||||
import { createVisualFixture } from "./visual/fixtures";
|
||||
import { createVisualElectronApi } from "./visual/mock-electron-api";
|
||||
|
||||
@@ -179,6 +184,30 @@ describe("history model", () => {
|
||||
expect([...selectVisibleHistoryIds(visibleIds)]).toEqual(["week-edge", "week"]);
|
||||
});
|
||||
|
||||
it("splits large filtered results into stable pages and clamps invalid page requests", () => {
|
||||
const template = filterHistoryRows([entry({ id: "template", name: "Vorlage" })], "all", "", now)[0];
|
||||
const rows = Array.from({ length: 100_005 }, (_, index) => ({
|
||||
...template,
|
||||
id: `row-${index + 1}`,
|
||||
name: `Eintrag ${index + 1}`
|
||||
}));
|
||||
|
||||
const first = paginateHistoryRows(rows, 1);
|
||||
const last = paginateHistoryRows(rows, 2_000);
|
||||
|
||||
expect(HISTORY_PAGE_SIZE).toBe(100);
|
||||
expect(first.rows).toHaveLength(100);
|
||||
expect(first.rows[0].id).toBe("row-1");
|
||||
expect(first.rows[99].id).toBe("row-100");
|
||||
expect(first.page).toBe(1);
|
||||
expect(first.totalPages).toBe(1_001);
|
||||
expect(first.rangeLabel).toBe("1–100 von 100.005");
|
||||
expect(last.rows).toHaveLength(5);
|
||||
expect(last.rows[0].id).toBe("row-100001");
|
||||
expect(last.page).toBe(1_001);
|
||||
expect(last.rangeLabel).toBe("100.001–100.005 von 100.005");
|
||||
});
|
||||
|
||||
it("removes hidden selected ids from the filtered view model and every toolbar action", () => {
|
||||
const model = buildHistoryViewModel(entries, "deleted", "", ["today", "week"], [], false, "", now);
|
||||
const calls: Array<unknown> = [];
|
||||
@@ -205,6 +234,83 @@ describe("history model", () => {
|
||||
});
|
||||
|
||||
describe("HistoryView", () => {
|
||||
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");
|
||||
});
|
||||
|
||||
it("renders only one fixed-size page with accessible previous and next controls", () => {
|
||||
const template = filterHistoryRows([entry({ id: "template", name: "Vorlage" })], "all", "", now)[0];
|
||||
const model = {
|
||||
...buildHistoryViewModel([], "all", "", [], [], false, "", now),
|
||||
rows: Array.from({ length: 205 }, (_, index) => ({
|
||||
...template,
|
||||
id: `visible-${index + 1}`,
|
||||
name: `Sichtbar ${index + 1}`
|
||||
})),
|
||||
totalCount: 205
|
||||
};
|
||||
const html = renderToStaticMarkup(<HistoryView actions={createActions()} model={model} />);
|
||||
|
||||
expect(html.match(/data-history-row-id=/g)).toHaveLength(100);
|
||||
expect(html).toContain("aria-label=\"Verlaufsseiten\"");
|
||||
expect(html).toContain(">Zurück<");
|
||||
expect(html).toContain(">Vor<");
|
||||
expect(html).toContain("100 pro Seite");
|
||||
expect(html).toContain("1–100 von 205");
|
||||
expect(html).toContain("Seite 1 von 3");
|
||||
});
|
||||
|
||||
it("moves through pages with bounded previous and next actions", () => {
|
||||
const template = filterHistoryRows([entry({ id: "template", name: "Vorlage" })], "all", "", now)[0];
|
||||
const rows = Array.from({ length: 205 }, (_, index) => ({
|
||||
...template,
|
||||
id: `visible-${index + 1}`,
|
||||
name: `Sichtbar ${index + 1}`
|
||||
}));
|
||||
const calls: number[] = [];
|
||||
const first = HistoryPagination({ page: paginateHistoryRows(rows, 1), onPageChange: (page) => calls.push(page) });
|
||||
const middle = HistoryPagination({ page: paginateHistoryRows(rows, 2), onPageChange: (page) => calls.push(page) });
|
||||
const last = HistoryPagination({ page: paginateHistoryRows(rows, 3), onPageChange: (page) => calls.push(page) });
|
||||
|
||||
expect(findButton(first, "Zurück").props.disabled).toBe(true);
|
||||
expect(findButton(first, "Vor").props.disabled).toBe(false);
|
||||
findButton(first, "Vor").props.onClick();
|
||||
findButton(middle, "Zurück").props.onClick();
|
||||
findButton(middle, "Vor").props.onClick();
|
||||
expect(findButton(last, "Vor").props.disabled).toBe(true);
|
||||
expect(calls).toEqual([2, 1, 3]);
|
||||
});
|
||||
|
||||
it("keeps a visible page title in the main content when the filter sidebar is unavailable", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<HistoryContent actions={createActions()} model={buildHistoryViewModel(entries, "all", "", [], [], false, "", now)} />
|
||||
);
|
||||
|
||||
expect(html).toContain('<h1 class="history-main-title">Verlauf</h1>');
|
||||
expect(html.indexOf("history-main-title")).toBeLessThan(html.indexOf("history-table"));
|
||||
});
|
||||
|
||||
it("keeps pagination text clear of the shell information button", () => {
|
||||
const css = readFileSync(new URL("../src/renderer/views/history/history.css", import.meta.url), "utf8");
|
||||
|
||||
expect(css).toMatch(/\.history-pagination\s*\{[^}]*padding:\s*10px 14px 10px 60px;/s);
|
||||
});
|
||||
|
||||
it("announces loading politely and errors immediately", () => {
|
||||
const loading = renderToStaticMarkup(
|
||||
<HistoryContent actions={createActions()} model={buildHistoryViewModel([], "all", "", [], [], true, "", now)} />
|
||||
);
|
||||
const error = renderToStaticMarkup(
|
||||
<HistoryContent actions={createActions()} model={buildHistoryViewModel([], "all", "", [], [], false, "Verlauf konnte nicht geladen werden", now)} />
|
||||
);
|
||||
|
||||
expect(loading).toMatch(/role="status"[^>]*aria-live="polite"[^>]*aria-atomic="true"/);
|
||||
expect(loading).toContain("Verlauf wird geladen");
|
||||
expect(error).toMatch(/role="alert"[^>]*aria-live="assertive"[^>]*aria-atomic="true"/);
|
||||
expect(error).toContain("Verlauf konnte nicht geladen werden");
|
||||
});
|
||||
|
||||
it("marks history filters for one measured vertical selection indicator", () => {
|
||||
const model = buildHistoryViewModel(entries, "week", "", [], [], false, "", now);
|
||||
const html = renderToStaticMarkup(<HistorySidebar actions={createActions()} model={model} />);
|
||||
@@ -288,9 +394,12 @@ describe("HistoryView", () => {
|
||||
});
|
||||
|
||||
it("matches the download action control and centers every header except package and file", () => {
|
||||
const content = HistoryContent({
|
||||
const model = buildHistoryViewModel(entries.slice(0, 1), "all", "", [], [], false, "", now);
|
||||
const content = HistoryContentPage({
|
||||
actions: createActions(),
|
||||
model: buildHistoryViewModel(entries.slice(0, 1), "all", "", [], [], false, "", now)
|
||||
model,
|
||||
onPageChange: () => {},
|
||||
page: paginateHistoryRows(model.rows, 1)
|
||||
});
|
||||
const actionCell = findElement(content, (element) => element.props.className === "history-row-action");
|
||||
const styles = readFileSync(new URL("../src/renderer/views/history/history.css", import.meta.url), "utf8").replaceAll("\r\n", "\n");
|
||||
@@ -326,7 +435,7 @@ describe("HistoryView", () => {
|
||||
onContextMenu: (id, x, y) => calls.push(["context", id, x, y])
|
||||
});
|
||||
const model = buildHistoryViewModel(entries.slice(0, 2), "all", "", [], [], false, "", now);
|
||||
const content = HistoryContent({ actions, model });
|
||||
const content = HistoryContentPage({ actions, model, onPageChange: () => {}, page: paginateHistoryRows(model.rows, 1) });
|
||||
|
||||
const selectAll = findElement(content, (element) => element.type === "input" && element.props["aria-label"] === "Alle sichtbaren Einträge auswählen");
|
||||
selectAll.props.onChange();
|
||||
@@ -353,9 +462,12 @@ describe("HistoryView", () => {
|
||||
it("focuses the matching row action before opening a genuine row context menu", () => {
|
||||
const calls: Array<unknown> = [];
|
||||
const focusCalls: Array<unknown> = [];
|
||||
const content = HistoryContent({
|
||||
const model = buildHistoryViewModel(entries.slice(0, 1), "all", "", [], [], false, "", now);
|
||||
const content = HistoryContentPage({
|
||||
actions: createActions({ onContextMenu: (id, x, y) => calls.push([id, x, y]) }),
|
||||
model: buildHistoryViewModel(entries.slice(0, 1), "all", "", [], [], false, "", now)
|
||||
model,
|
||||
onPageChange: () => {},
|
||||
page: paginateHistoryRows(model.rows, 1)
|
||||
});
|
||||
const row = findElement(content, (element) => element.props["data-history-row-id"] === "today");
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ describe("renderer localization", () => {
|
||||
expect(translateUiText("v2.0.14 ist verfügbar. Installierte Version: 2.0.13.", "en"))
|
||||
.toBe("v2.0.14 is available. Installed version: 2.0.13.");
|
||||
expect(translateUiText("1–46 von 46", "en")).toBe("1–46 of 46");
|
||||
expect(translateUiText("100.001–100.005 von 100.005", "en")).toBe("100.001–100.005 of 100.005");
|
||||
expect(translateUiText("100.001–100.005 of 100.005", "de")).toBe("100.001–100.005 von 100.005");
|
||||
});
|
||||
|
||||
it("translates the complete history surface including status values", () => {
|
||||
@@ -52,6 +54,31 @@ describe("renderer localization", () => {
|
||||
expect(translateUiText("2 pro Seite", "en")).toBe("2 per page");
|
||||
expect(translateUiText("Sichtbar: ", "en")).toBe("Visible: ");
|
||||
expect(translateUiText(" pro Seite", "en")).toBe(" per page");
|
||||
expect(translateUiText("Soll die Sammlung Tab 2 mit 5 Link(s) wirklich entfernt werden?", "en"))
|
||||
.toBe("Do you really want to remove collection Tab 2 with 5 link(s)?");
|
||||
expect(translateUiText("Soll die leere Sammlung Tab 2 wirklich entfernt werden?", "en"))
|
||||
.toBe("Do you really want to remove the empty collection Tab 2?");
|
||||
expect(translateUiText("Link kopieren", "en")).toBe("Copy Link");
|
||||
expect(translateUiText("example.test Klicken zum Kopieren", "en")).toBe("Click to copy example.test");
|
||||
expect(translateUiText("Geschwindigkeit verschieben", "en")).toBe("Move Speed");
|
||||
expect(translateUiText("Geschwindigkeit nach links verschieben", "en")).toBe("Move Speed left");
|
||||
expect(translateUiText("Move Speed right", "de")).toBe("Geschwindigkeit nach rechts verschieben");
|
||||
expect(translateUiText("Verlaufsseiten", "en")).toBe("History pages");
|
||||
expect(translateUiText("Vorherige Verlaufsseite", "en")).toBe("Previous history page");
|
||||
expect(translateUiText("Nächste Verlaufsseite", "en")).toBe("Next history page");
|
||||
expect(translateUiText("Zurück", "en")).toBe("Back");
|
||||
expect(translateUiText("Vor", "en")).toBe("Next");
|
||||
expect(translateUiText("Seite 2 von 7", "en")).toBe("Page 2 of 7");
|
||||
expect(translateUiText("Page 2 of 7", "de")).toBe("Seite 2 von 7");
|
||||
expect(translateUiText("Seite 1.000 von 2.500", "en")).toBe("Page 1.000 of 2.500");
|
||||
expect(translateUiText("Accountdaten werden aktualisiert.", "en")).toBe("Account data is being updated.");
|
||||
expect(translateUiText("Keine passenden Dienste oder Zugangstypen gefunden.", "en"))
|
||||
.toBe("No matching services or access types found.");
|
||||
expect(translateUiText("https://example.test/a aus Sammlung A, Zeile 3 auswählen", "en"))
|
||||
.toBe("Select https://example.test/a from Sammlung A, line 3");
|
||||
expect(translateUiText("Select https://example.test/a from Sammlung A, line 3", "de"))
|
||||
.toBe("https://example.test/a aus Sammlung A, Zeile 3 auswählen");
|
||||
expect(translateUiText("abc••••\nKlicken zum Kopieren", "en")).toBe("Click to copy abc••••");
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -59,6 +59,14 @@ describe("responsive shell mode", () => {
|
||||
expect(css).toMatch(/\.md-shell-sidebar\.is-responsive-rail \.md-shell-sidebar-toggle\s*\{[^}]*width:\s*32px;[^}]*height:\s*32px;[^}]*opacity:\s*1;/s);
|
||||
expect(css).not.toMatch(/\.md-shell-sidebar\.is-responsive-rail \.md-shell-sidebar-scroll\s*\{[^}]*visibility:\s*visible;/s);
|
||||
});
|
||||
|
||||
it("keeps the responsive expand control in the header instead of covering view content", () => {
|
||||
const css = readFileSync(new URL("../src/renderer/shell/shell.css", import.meta.url), "utf8");
|
||||
|
||||
expect(css).toMatch(/\.md-shell-sidebar\.is-responsive-rail \.md-shell-sidebar-toggle\s*\{[^}]*top:\s*-48px;/s);
|
||||
expect(css).toMatch(/\.md-shell\.has-collapsed-sidebar:is\(\.is-compact, \.is-minimum\) \.md-shell-navigation\s*\{[^}]*padding-left:\s*44px;/s);
|
||||
expect(css).toMatch(/@media \(max-width: 1120px\)[\s\S]*\.md-shell\.is-minimum \.md-shell-sidebar\.is-responsive-rail \.md-shell-sidebar-toggle\s*\{[^}]*top:\s*-46px;/s);
|
||||
});
|
||||
});
|
||||
|
||||
describe("focus restoration", () => {
|
||||
|
||||
@@ -39,7 +39,11 @@ import {
|
||||
type AccountWorkspaceActions,
|
||||
type AccountWorkspaceViewModel
|
||||
} from "../src/renderer/views/settings/AccountWorkspace";
|
||||
import { SettingsForm } from "../src/renderer/views/settings/SettingsForm";
|
||||
import {
|
||||
SettingsForm,
|
||||
closeSettingsSelectAndRestoreFocus,
|
||||
getSettingsSelectKeyboardAction
|
||||
} from "../src/renderer/views/settings/SettingsForm";
|
||||
import {
|
||||
SettingsContent,
|
||||
SettingsSidebar,
|
||||
@@ -90,6 +94,32 @@ function findElement(node: ReactNode, predicate: (element: ReactElement) => bool
|
||||
return result;
|
||||
}
|
||||
|
||||
function findElements(node: ReactNode, predicate: (element: ReactElement) => boolean): ReactElement[] {
|
||||
const results: ReactElement[] = [];
|
||||
visitElements(node, (element) => {
|
||||
if (predicate(element)) {
|
||||
results.push(element);
|
||||
}
|
||||
});
|
||||
return results;
|
||||
}
|
||||
|
||||
function compositeKeyboardTarget(count: number): {
|
||||
elements: Array<{ closest: () => { querySelectorAll: () => unknown[] }; focus: () => void }>;
|
||||
focused: () => number;
|
||||
} {
|
||||
let focusedIndex = -1;
|
||||
const elements: Array<{ closest: () => { querySelectorAll: () => unknown[] }; focus: () => void }> = [];
|
||||
const container = { querySelectorAll: () => elements };
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
elements.push({
|
||||
closest: () => container,
|
||||
focus: () => { focusedIndex = index; }
|
||||
});
|
||||
}
|
||||
return { elements, focused: () => focusedIndex };
|
||||
}
|
||||
|
||||
function count(haystack: string, needle: string): number {
|
||||
return haystack.split(needle).length - 1;
|
||||
}
|
||||
@@ -419,6 +449,14 @@ describe("settings model", () => {
|
||||
});
|
||||
|
||||
describe("settings views", () => {
|
||||
it("uses a dedicated high-contrast border for form controls", () => {
|
||||
const theme = readFileSync(new URL("../src/renderer/theme.css", import.meta.url), "utf8");
|
||||
const css = readFileSync(new URL("../src/renderer/views/settings/settings.css", import.meta.url), "utf8");
|
||||
|
||||
expect(theme).toContain("--ui-control-border: #707070;");
|
||||
expect(theme).toContain("--ui-control-border: #7B8491;");
|
||||
expect(css.match(/border:\s*1px solid var\(--ui-control-border\);/g)?.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
it("marks settings sections for one measured vertical selection indicator", () => {
|
||||
const html = renderToStaticMarkup(<SettingsSidebar actions={viewActions()} model={viewModel()} />);
|
||||
|
||||
@@ -479,11 +517,25 @@ describe("settings views", () => {
|
||||
|
||||
const source = readFileSync(new URL("../src/renderer/views/settings/SettingsForm.tsx", import.meta.url), "utf8");
|
||||
expect(source).toContain("optionRefs.current[nextIndex]?.focus()");
|
||||
expect(source).toContain('event.key === "Home"');
|
||||
expect(source).toContain('event.key === "End"');
|
||||
expect(getSettingsSelectKeyboardAction("Home", 1, 3)).toEqual({ type: "focus", index: 0 });
|
||||
expect(getSettingsSelectKeyboardAction("End", 1, 3)).toEqual({ type: "focus", index: 2 });
|
||||
expect(source).toContain("onBlur={onBlur}");
|
||||
});
|
||||
|
||||
it("closes settings selects with Escape and restores the trigger focus", () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
expect(getSettingsSelectKeyboardAction("Escape", 1, 3)).toEqual({ type: "close" });
|
||||
expect(getSettingsSelectKeyboardAction("ArrowDown", 1, 3)).toEqual({ type: "focus", index: 2 });
|
||||
expect(getSettingsSelectKeyboardAction("ArrowUp", 0, 3)).toEqual({ type: "focus", index: 2 });
|
||||
closeSettingsSelectAndRestoreFocus(
|
||||
() => calls.push("close"),
|
||||
{ focus: () => calls.push("focus") }
|
||||
);
|
||||
|
||||
expect(calls).toEqual(["close", "focus"]);
|
||||
});
|
||||
|
||||
it("clears a bounded history preset when permanent retention is selected", () => {
|
||||
expect(resolveHistoryRetentionSelection("permanent", 100, "permanent")).toEqual({
|
||||
historyRetentionMode: "permanent",
|
||||
@@ -540,6 +592,40 @@ describe("settings views", () => {
|
||||
switchButton.props.onClick();
|
||||
expect(changed).toBe("autoUpdate");
|
||||
});
|
||||
|
||||
it("uses roving focus and complete arrow navigation for theme radios", () => {
|
||||
const changes: string[] = [];
|
||||
const tree = SettingsForm({
|
||||
model: formModel(),
|
||||
actions: {
|
||||
onChange: (id, value) => changes.push(`${id}:${String(value)}`),
|
||||
onAction: () => {}
|
||||
}
|
||||
});
|
||||
const radios = findElements(tree, (element) => element.props.role === "radio");
|
||||
|
||||
expect(radios.map((radio) => radio.props.tabIndex)).toEqual([-1, 0, -1]);
|
||||
|
||||
for (const [key, sourceIndex, targetIndex, value] of [
|
||||
["ArrowRight", 1, 2, "system"],
|
||||
["ArrowDown", 1, 2, "system"],
|
||||
["ArrowLeft", 1, 0, "light"],
|
||||
["ArrowUp", 1, 0, "light"],
|
||||
["Home", 1, 0, "light"],
|
||||
["End", 1, 2, "system"]
|
||||
] as const) {
|
||||
const target = compositeKeyboardTarget(radios.length);
|
||||
let prevented = false;
|
||||
radios[sourceIndex].props.onKeyDown({
|
||||
key,
|
||||
currentTarget: target.elements[sourceIndex],
|
||||
preventDefault: () => { prevented = true; }
|
||||
});
|
||||
expect(prevented).toBe(true);
|
||||
expect(target.focused()).toBe(targetIndex);
|
||||
expect(changes.at(-1)).toBe(`theme:${value}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("account workspace", () => {
|
||||
@@ -551,6 +637,64 @@ describe("account workspace", () => {
|
||||
expect(html.match(/data-sliding-selection-active="true"/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("uses roving focus and horizontal keyboard navigation for account tabs", () => {
|
||||
const panels: string[] = [];
|
||||
const tree = AccountWorkspace({
|
||||
model: workspaceModel(),
|
||||
actions: workspaceActions({ onPanelChange: (panel) => panels.push(panel) })
|
||||
});
|
||||
const tabs = findElements(tree, (element) => element.props.role === "tab");
|
||||
|
||||
expect(tabs.map((tab) => tab.props.tabIndex)).toEqual([0, -1]);
|
||||
|
||||
for (const [key, sourceIndex, targetIndex, panel] of [
|
||||
["ArrowRight", 0, 1, "rules"],
|
||||
["ArrowLeft", 0, 1, "rules"],
|
||||
["Home", 1, 0, "overview"],
|
||||
["End", 0, 1, "rules"]
|
||||
] as const) {
|
||||
const target = compositeKeyboardTarget(tabs.length);
|
||||
let prevented = false;
|
||||
tabs[sourceIndex].props.onKeyDown({
|
||||
key,
|
||||
currentTarget: target.elements[sourceIndex],
|
||||
preventDefault: () => { prevented = true; }
|
||||
});
|
||||
expect(prevented).toBe(true);
|
||||
expect(target.focused()).toBe(targetIndex);
|
||||
expect(panels.at(-1)).toBe(panel);
|
||||
}
|
||||
|
||||
const rulesTree = AccountWorkspace({
|
||||
model: { ...workspaceModel(), activePanel: "rules" },
|
||||
actions: workspaceActions()
|
||||
});
|
||||
const rulesTabs = findElements(rulesTree, (element) => element.props.role === "tab");
|
||||
expect(rulesTabs.map((tab) => tab.props.tabIndex)).toEqual([-1, 0]);
|
||||
});
|
||||
|
||||
it("announces account loading and errors without hiding the existing table state", () => {
|
||||
const loadingHtml = renderToStaticMarkup(
|
||||
<AccountWorkspace
|
||||
actions={workspaceActions()}
|
||||
model={{ ...workspaceModel(), busy: true, rows: [], selectedIds: [] }}
|
||||
/>
|
||||
);
|
||||
const errorHtml = renderToStaticMarkup(
|
||||
<AccountWorkspace
|
||||
actions={workspaceActions()}
|
||||
model={{ ...workspaceModel(), busy: false, error: "Accounts konnten nicht geladen werden", rows: [], selectedIds: [] }}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(loadingHtml).toContain('aria-busy="true"');
|
||||
expect(loadingHtml).toContain('aria-live="polite"');
|
||||
expect(loadingHtml).toContain('role="status"');
|
||||
expect(loadingHtml).toContain("Accountdaten werden aktualisiert");
|
||||
expect(errorHtml).toContain('role="alert"');
|
||||
expect(errorHtml).toContain("Accounts konnten nicht geladen werden");
|
||||
});
|
||||
|
||||
it("renders the exact columns, one table marker, full usernames and no raw credentials", () => {
|
||||
const html = renderToStaticMarkup(<AccountWorkspace actions={workspaceActions()} model={workspaceModel()} />);
|
||||
const positions = ACCOUNT_COLUMNS.map((column) => html.indexOf(column));
|
||||
@@ -717,6 +861,36 @@ describe("account workspace", () => {
|
||||
expect(selected).toEqual(["debridlink-api"]);
|
||||
});
|
||||
|
||||
it("shows an accessible empty result when account search has no matches", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AccountAddDialog
|
||||
actions={{
|
||||
onQueryChange: () => {},
|
||||
onFilterChange: () => {},
|
||||
onOptionSelect: () => {},
|
||||
onFieldChange: () => {},
|
||||
onClose: () => {},
|
||||
onSubmit: () => {}
|
||||
}}
|
||||
model={{
|
||||
open: true,
|
||||
query: "nicht vorhanden",
|
||||
filter: "all",
|
||||
options: [],
|
||||
selectedOptionId: null,
|
||||
fields: [],
|
||||
error: "",
|
||||
busy: false
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(html).toContain('aria-describedby="settings-account-picker-empty"');
|
||||
expect(html).toContain('aria-live="polite"');
|
||||
expect(html).toContain('role="status"');
|
||||
expect(html).toContain("Keine passenden Dienste oder Zugangstypen gefunden");
|
||||
});
|
||||
|
||||
it("keeps stored usernames separate from provider email addresses", () => {
|
||||
const rows = projectAccountRows(accountSources(), [], NOW);
|
||||
|
||||
@@ -757,6 +931,17 @@ describe("settings App integration", () => {
|
||||
});
|
||||
|
||||
describe("settings geometry", () => {
|
||||
it("uses central focus and semantic status text tokens", () => {
|
||||
expect(settingsCss).toMatch(/\.settings-theme-option:focus-visible,[^{]*\.settings-account-picker-row:focus-visible\s*{[^}]*outline:\s*2px solid var\(--ui-focus\);/s);
|
||||
expect(settingsCss).toMatch(/\.settings-save-state\.is-clean,[^{]*\.settings-save-state\.is-saved\s*{[^}]*color:\s*var\(--ui-success-text\);/s);
|
||||
expect(settingsCss).toMatch(/\.settings-save-state\.is-dirty,[^{]*\.settings-save-state\.is-saving\s*{[^}]*color:\s*var\(--ui-warning-text\);/s);
|
||||
expect(settingsCss).toMatch(/\.settings-save-state\.is-error\s*{[^}]*color:\s*var\(--ui-danger-text\);/s);
|
||||
expect(settingsCss).toMatch(/\.settings-account-status-badge\.is-ok\s*{[^}]*color:\s*var\(--ui-success-text\);/s);
|
||||
expect(settingsCss).toMatch(/\.settings-account-status-badge\.is-free,[^{]*\.settings-account-status-badge\.is-unknown\s*{[^}]*color:\s*var\(--ui-warning-text\);/s);
|
||||
expect(settingsCss).toMatch(/\.settings-account-status-badge\.is-invalid\s*{[^}]*color:\s*var\(--ui-danger-text\);/s);
|
||||
expect(settingsCss).toMatch(/\.settings-account-table-error \.ui-data-table-empty-title,[^{]*\.settings-account-dialog-error\s*{[^}]*color:\s*var\(--ui-danger-text\);/s);
|
||||
});
|
||||
|
||||
it("keeps the specified form, table, switch, overflow and selection geometry", () => {
|
||||
const css = readFileSync(new URL("../src/renderer/views/settings/settings.css", import.meta.url), "utf8");
|
||||
expect(css).toMatch(/\.settings-content\s*{[^}]*padding:\s*24px;/s);
|
||||
|
||||
@@ -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 { appendBandwidthSample, readBandwidthChartPalette } from "../src/renderer/App";
|
||||
import { appendBandwidthSample, readBandwidthChartPalette, readDownloadSpeedSparklinePalette } from "../src/renderer/App";
|
||||
import {
|
||||
buildStatisticsViewModel,
|
||||
type StatisticsMetric,
|
||||
@@ -414,14 +414,34 @@ describe("bandwidth chart palette", () => {
|
||||
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: #F2942D;");
|
||||
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: #C2701A;");
|
||||
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-primary-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> = {
|
||||
@@ -441,6 +461,46 @@ describe("bandwidth chart palette", () => {
|
||||
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 : 250");
|
||||
});
|
||||
|
||||
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"');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user