feat(settings): configure notification center

This commit is contained in:
Sucukdeluxe
2026-08-22 07:51:01 +02:00
parent b3f60eada8
commit 16679e73b5
9 changed files with 270 additions and 14 deletions
+16
View File
@@ -16,6 +16,22 @@ describe("renderer localization", () => {
expect(translateUiText("Animations", "de")).toBe("Animationen");
});
it.each([
["Erfolgsmeldungen senden", "Send success notifications"],
["Gesammelt (alle 2 Minuten)", "Grouped (every 2 minutes)"],
["Jedes Paket einzeln", "Each package individually"],
["Melden, wenn der gesamte Lauf fertig ist", "Notify when the entire run completes"],
["Melden, wenn die Restmenge unterschritten wird", "Notify when the remaining amount falls below the threshold"],
["Restmengenschwelle (GB)", "Remaining amount threshold (GB)"],
["Melden, wenn Downloads stillstehen", "Notify when downloads stall"],
["Stillstand bestätigen nach (Sek.)", "Confirm stall after (sec.)"],
["Frühestens erneut melden nach (Min.)", "Notify again after at least (min.)"],
["Melden, wenn Downloads wieder laufen", "Notify when downloads resume"]
])("translates notification center setting %s in both directions", (german, english) => {
expect(translateUiText(german, "en")).toBe(english);
expect(translateUiText(english, "de")).toBe(german);
});
it("translates dynamic update and pagination text", () => {
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.");
+113 -1
View File
@@ -4,7 +4,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { defaultSettings } from "../src/main/constants";
import { createRendererSettings, createRendererState } from "../src/main/renderer-state";
import { buildAccountAddFields, createAccountDialogState } from "../src/renderer/App";
import { buildAccountAddFields, createAccountDialogState, createSettingsDraft } from "../src/renderer/App";
import { buildAccountReplaceCommand, createAccountEditState, type AccountEditTarget } from "../src/renderer/account-edit";
import {
buildScopedAccountEnabledState,
@@ -23,6 +23,7 @@ import {
filterAccountAddOptions,
getSettingsSaveLabel,
getSettingsSelectNavigationIndex,
normalizeNotificationNumberField,
projectAccountRows,
pruneAccountSelection,
reconcileAccountAddDraft,
@@ -579,6 +580,108 @@ describe("settings views", () => {
});
});
it("projects every notification control in the required order with exact bounds", () => {
const form = buildSettingsFormViewModel({
settings: {
...createRendererSettings(defaultSettings()),
archivePasswordList: "",
notifyUrl: "https://discord.com/api/webhooks/example",
notifyOnPackageCompleted: true,
notifyOnRemainingBelow: true,
notifyOnDownloadStall: true
},
section: "allgemein",
speedLimitInput: "0",
scheduleSpeedInputs: {}
});
const fields = form.groups.find((group) => group.id === "general-notifications")?.fields ?? [];
expect(fields.map((field) => field.id)).toEqual([
"notifyUrl",
"notifyMention",
"notifyOnPackageCompleted",
"notifyOnPackageFailed",
"notifyPackageSuccessMode",
"notifyOnRunFinished",
"notifyOnRemainingBelow",
"notifyRemainingThresholdGb",
"notifyOnDownloadStall",
"notifyStallAfterSeconds",
"notifyStallCooldownMinutes",
"notifyOnDownloadRecovery"
]);
expect(fields.find((field) => field.id === "notifyPackageSuccessMode")).toEqual({
id: "notifyPackageSuccessMode",
kind: "select",
label: "Erfolgsmeldungen senden",
value: "digest",
disabled: false,
options: [
{ value: "digest", label: "Gesammelt (alle 2 Minuten)" },
{ value: "individual", label: "Jedes Paket einzeln" }
]
});
expect(fields.find((field) => field.id === "notifyRemainingThresholdGb")).toEqual({
id: "notifyRemainingThresholdGb",
kind: "number",
label: "Restmengenschwelle (GB)",
value: "50",
min: 1,
max: 100000,
disabled: false
});
expect(fields.find((field) => field.id === "notifyStallAfterSeconds")).toEqual({
id: "notifyStallAfterSeconds",
kind: "number",
label: "Stillstand bestätigen nach (Sek.)",
value: "90",
min: 60,
max: 3600,
disabled: false
});
expect(fields.find((field) => field.id === "notifyStallCooldownMinutes")).toEqual({
id: "notifyStallCooldownMinutes",
kind: "number",
label: "Frühestens erneut melden nach (Min.)",
value: "10",
min: 5,
max: 1440,
disabled: false
});
});
it("disables notification controls that depend on an inactive switch", () => {
const form = buildSettingsFormViewModel({
settings: { ...createRendererSettings(defaultSettings()), archivePasswordList: "", notifyUrl: "" },
section: "allgemein",
speedLimitInput: "0",
scheduleSpeedInputs: {}
});
const fields = form.groups.find((group) => group.id === "general-notifications")?.fields ?? [];
const disabled = Object.fromEntries(fields.map((field) => [field.id, Boolean(field.disabled)]));
expect(disabled).toMatchObject({
notifyPackageSuccessMode: true,
notifyRemainingThresholdGb: true,
notifyStallAfterSeconds: true,
notifyStallCooldownMinutes: true,
notifyOnDownloadRecovery: true
});
});
it("clamps notification number fields and restores their exact defaults", () => {
expect(normalizeNotificationNumberField("notifyRemainingThresholdGb", "0")).toBe(1);
expect(normalizeNotificationNumberField("notifyRemainingThresholdGb", "100001")).toBe(100000);
expect(normalizeNotificationNumberField("notifyRemainingThresholdGb", "invalid")).toBe(50);
expect(normalizeNotificationNumberField("notifyStallAfterSeconds", "59")).toBe(60);
expect(normalizeNotificationNumberField("notifyStallAfterSeconds", "3601")).toBe(3600);
expect(normalizeNotificationNumberField("notifyStallAfterSeconds", "invalid")).toBe(90);
expect(normalizeNotificationNumberField("notifyStallCooldownMinutes", "4")).toBe(5);
expect(normalizeNotificationNumberField("notifyStallCooldownMinutes", "1441")).toBe(1440);
expect(normalizeNotificationNumberField("notifyStallCooldownMinutes", "invalid")).toBe(10);
expect(normalizeNotificationNumberField("maxParallel", "8")).toBeUndefined();
});
it("supports keyboard navigation in animated settings selects", () => {
expect(getSettingsSelectNavigationIndex(1, 3, "ArrowDown")).toBe(2);
expect(getSettingsSelectNavigationIndex(2, 3, "ArrowDown")).toBe(0);
@@ -1165,6 +1268,15 @@ describe("account workspace", () => {
});
describe("settings App integration", () => {
it("preserves the write-only webhook during live snapshots and clears it for backup reseeding", () => {
const safe = createRendererSettings({ ...defaultSettings(), notifyUrl: "https://private.example.test/hook" });
const current = { ...safe, archivePasswordList: "loaded-password", notifyUrl: "https://private.example.test/hook" };
expect(createSettingsDraft(safe, current).notifyUrl).toBe("https://private.example.test/hook");
expect(createSettingsDraft(safe).notifyUrl).toBe("");
expect(appSource).toContain("applyPersistedSettings(fresh.settings, false)");
});
it("loads and preserves the stored archive password list in the extraction section", () => {
const revealBlock = sourceBlock(appSource, "const showToast", "const clearImportQueueFocusListener");
const applyBlock = sourceBlock(appSource, "const applyPersistedSettings", "const syncLiveProviderUsageSettings");
+37 -1
View File
@@ -4,13 +4,49 @@ import path from "node:path";
import AdmZip from "adm-zip";
import { describe, expect, it } from "vitest";
import { defaultSettings } from "../src/main/constants";
import { buildAccountSummary, buildStatsPayload } from "../src/main/support-data";
import { buildAccountSummary, buildNotificationSupportPayload, buildStatsPayload } from "../src/main/support-data";
import { buildSupportBundle } from "../src/main/support-bundle";
import { createStoragePaths } from "../src/main/storage";
import { serializeRealDebridApiAccounts } from "../src/shared/real-debrid-accounts";
import { createVisualFixture } from "./visual/fixtures";
describe("Real-Debrid support summary", () => {
it("projects only safe notification delivery and incident aggregates", () => {
const payload = buildNotificationSupportPayload(
{
queued: 7,
lastSuccessAt: 1_700_000_000_000,
lastFailureAt: 1_700_000_010_000,
events: [{ payload: { url: "https://private.example.test/hook", mention: "@private" } }]
} as Parameters<typeof buildNotificationSupportPayload>[0],
{
status: "alerted",
incidentType: "no_data",
incidentStartedAt: 1_700_000_020_000,
runFingerprint: "private-run",
url: "https://private.example.test/hook",
mention: "@private"
} as Parameters<typeof buildNotificationSupportPayload>[1],
1_700_000_050_000
);
expect(payload).toEqual({
queued: 7,
lastSuccessAt: 1_700_000_000_000,
incidentType: "no_data",
incidentAgeMs: 30_000
});
expect(JSON.stringify(payload)).not.toMatch(/payload|https?:|mention|private|lastFailure/i);
});
it("reports no active incident when the health state has no incident", () => {
expect(buildNotificationSupportPayload(
{ queued: 0, lastSuccessAt: 0 },
{ incidentType: null, incidentStartedAt: 0 },
1_700_000_050_000
)).toEqual({ queued: 0, lastSuccessAt: null, incidentType: null, incidentAgeMs: null });
});
it("reports pool counts without exposing account IDs or credentials", () => {
const summary = buildAccountSummary({
...defaultSettings(),
+20
View File
@@ -93,6 +93,26 @@ describe("visual fixtures", () => {
expect(createVisualFixture("dense")).toEqual(dense);
});
it("keeps the complete notification center state deterministic without exposing its webhook", () => {
const settings = createVisualFixture("dense").snapshot.settings;
expect(settings).toEqual(expect.objectContaining({
notifyUrlConfigured: true,
notifyMention: "@visual",
notifyOnPackageCompleted: true,
notifyOnPackageFailed: true,
notifyPackageSuccessMode: "digest",
notifyOnRunFinished: true,
notifyOnRemainingBelow: true,
notifyRemainingThresholdGb: 75,
notifyOnDownloadStall: true,
notifyStallAfterSeconds: 120,
notifyStallCooldownMinutes: 15,
notifyOnDownloadRecovery: true
}));
expect(settings).not.toHaveProperty("notifyUrl");
});
it("freezes runtime and recurring chart timers across visual frames", async () => {
const dense = createVisualFixture("dense");
const originalDateNow = Date.now;
+5 -5
View File
@@ -164,11 +164,11 @@ function createSettings(): AppSettings {
notifyOnPackageFailed: true,
notifyOnRunFinished: true,
notifyPackageSuccessMode: "digest",
notifyOnRemainingBelow: false,
notifyRemainingThresholdGb: 50,
notifyOnDownloadStall: false,
notifyStallAfterSeconds: 90,
notifyStallCooldownMinutes: 10,
notifyOnRemainingBelow: true,
notifyRemainingThresholdGb: 75,
notifyOnDownloadStall: true,
notifyStallAfterSeconds: 120,
notifyStallCooldownMinutes: 15,
notifyOnDownloadRecovery: true,
totalDownloadedAllTime: 987654321000,
totalCompletedFilesAllTime: 842,