feat: notification center toast coordination (PR-4)

- EventToastBridge checks notification_toast_level and notification_mute_categories
- toast-rules.ts: event-to-category/severity mapping functions
- Settings page: notification preferences section (toast level dropdown, mute checkboxes)
- Settings API types extended with notification preference fields
- 17 frontend tests (toast-rules + bridge)
- Preference hierarchy: mute categories → toast level → show/hide

Quality gates: vitest 17 passed, tsc clean, eslint clean
This commit is contained in:
2026-05-29 13:45:17 +02:00
parent ceaed9af66
commit 19242b4152
8 changed files with 808 additions and 281 deletions
+60 -139
View File
@@ -1,148 +1,69 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { handleEventToast, clearToastDedup } from "./toast-rules";
import { describe, it, expect } from "vitest";
import { mapEventToCategory, mapEventToSeverity } from "./toast-rules";
import type { InstanceEventPayload } from "../types/events";
const mockToastInfo = vi.fn();
const mockToastSuccess = vi.fn();
const mockToastWarning = vi.fn();
const mockToastError = vi.fn();
function makeEvent(
event: string,
overrides?: Partial<InstanceEventPayload>,
): InstanceEventPayload {
return {
event,
instance_id: "i-1",
status: undefined,
message: undefined,
metadata: {},
timestamp: "2026-05-29T10:00:00Z",
correlation_id: "c1",
...overrides,
};
}
vi.mock("../state/toast", () => ({
toast: {
info: (...args: unknown[]) => mockToastInfo(...args),
success: (...args: unknown[]) => mockToastSuccess(...args),
warning: (...args: unknown[]) => mockToastWarning(...args),
error: (...args: unknown[]) => mockToastError(...args),
},
}));
describe("toast-rules", () => {
beforeEach(() => {
clearToastDedup();
mockToastInfo.mockClear();
mockToastSuccess.mockClear();
mockToastWarning.mockClear();
mockToastError.mockClear();
describe("mapEventToCategory", () => {
it('returns "instance" for instance.* events', () => {
expect(mapEventToCategory(makeEvent("instance.started"))).toBe("instance");
expect(mapEventToCategory(makeEvent("instance.error"))).toBe("instance");
});
it("maps instance.started to info toast", () => {
const event: InstanceEventPayload = {
event: "instance.started",
instance_id: "inst-1",
status: "starting",
message: "Container starting...",
metadata: {},
timestamp: "2026-05-28T12:00:00Z",
correlation_id: "corr-1",
};
handleEventToast(event);
expect(mockToastInfo).toHaveBeenCalledWith("Container starting...", {
duration: 3000,
});
it('returns "health" for health.* events', () => {
expect(mapEventToCategory(makeEvent("health.error"))).toBe("health");
});
it("maps health_changed to running to success toast", () => {
const event: InstanceEventPayload = {
event: "instance.health_changed",
instance_id: "inst-1",
status: "running",
message: "Container is running",
metadata: { previous_status: "starting" },
timestamp: "2026-05-28T12:00:00Z",
correlation_id: "corr-1",
};
handleEventToast(event);
expect(mockToastSuccess).toHaveBeenCalledWith("Container running", {
duration: 3000,
});
});
it("maps health_changed to unhealthy to warning toast", () => {
const event: InstanceEventPayload = {
event: "instance.health_changed",
instance_id: "inst-1",
status: "unhealthy",
message: "Container is unhealthy",
metadata: { previous_status: "running" },
timestamp: "2026-05-28T12:00:00Z",
correlation_id: "corr-1",
};
handleEventToast(event);
expect(mockToastWarning).toHaveBeenCalledWith("Container unhealthy", {
duration: 5000,
});
});
it("maps instance.error to error toast with exit code", () => {
const event: InstanceEventPayload = {
event: "instance.error",
instance_id: "inst-1",
status: "error",
message: "Container crashed",
metadata: { exit_code: 137 },
timestamp: "2026-05-28T12:00:00Z",
correlation_id: "corr-1",
};
handleEventToast(event);
expect(mockToastError).toHaveBeenCalledWith(
"Container crashed (exit code: 137)",
{ duration: 10000 },
);
});
it("maps instance.error to error toast without exit code", () => {
const event: InstanceEventPayload = {
event: "instance.error",
instance_id: "inst-1",
status: "error",
message: "Build failed",
metadata: {},
timestamp: "2026-05-28T12:00:00Z",
correlation_id: "corr-1",
};
handleEventToast(event);
expect(mockToastError).toHaveBeenCalledWith("Build failed", {
duration: 10000,
});
});
it("deduplicates within one second", () => {
const event: InstanceEventPayload = {
event: "instance.started",
instance_id: "inst-1",
status: "starting",
message: "Container starting...",
metadata: {},
timestamp: "2026-05-28T12:00:00Z",
correlation_id: "corr-1",
};
handleEventToast(event);
handleEventToast(event);
expect(mockToastInfo).toHaveBeenCalledTimes(1);
});
it("allows duplicate after one second", () => {
vi.useFakeTimers();
const event: InstanceEventPayload = {
event: "instance.started",
instance_id: "inst-1",
status: "starting",
message: "Container starting...",
metadata: {},
timestamp: "2026-05-28T12:00:00Z",
correlation_id: "corr-1",
};
handleEventToast(event);
vi.advanceTimersByTime(1100);
handleEventToast(event);
expect(mockToastInfo).toHaveBeenCalledTimes(2);
vi.useRealTimers();
it('returns "system" for unknown events', () => {
expect(mapEventToCategory(makeEvent("system.announcement"))).toBe("system");
});
});
describe("mapEventToSeverity", () => {
it("returns error for instance.error and health.error", () => {
expect(mapEventToSeverity(makeEvent("instance.error"))).toBe("error");
expect(mapEventToSeverity(makeEvent("health.error"))).toBe("error");
});
it("returns warning for unhealthy health changes", () => {
expect(
mapEventToSeverity(
makeEvent("instance.health_changed", { status: "unhealthy" }),
),
).toBe("warning");
});
it("returns success for recovery to running", () => {
expect(
mapEventToSeverity(
makeEvent("instance.health_changed", { status: "running" }),
),
).toBe("success");
});
it("returns info for lifecycle events", () => {
expect(mapEventToSeverity(makeEvent("instance.created"))).toBe("info");
expect(mapEventToSeverity(makeEvent("instance.started"))).toBe("info");
expect(mapEventToSeverity(makeEvent("instance.stopped"))).toBe("info");
expect(mapEventToSeverity(makeEvent("instance.restarted"))).toBe("info");
expect(mapEventToSeverity(makeEvent("instance.deleted"))).toBe("info");
});
it("returns info for unmapped events", () => {
expect(mapEventToSeverity(makeEvent("unknown.event"))).toBe("info");
});
});