19242b4152
- 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
221 lines
6.2 KiB
TypeScript
221 lines
6.2 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { render, act } from "@testing-library/react";
|
|
import { EventToastBridge } from "./event-toast-bridge";
|
|
import { useEventContext } from "../state/events";
|
|
import { getUserConfig } from "../api/settings";
|
|
import { handleEventToast } from "./toast-rules";
|
|
import type { InstanceEventPayload } from "../types/events";
|
|
|
|
vi.mock("../state/events", () => ({
|
|
useEventContext: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../api/settings", () => ({
|
|
getUserConfig: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("./toast-rules", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("./toast-rules")>();
|
|
return {
|
|
...actual,
|
|
handleEventToast: vi.fn(),
|
|
clearToastDedup: vi.fn(),
|
|
};
|
|
});
|
|
|
|
const mockedUseEventContext = vi.mocked(useEventContext);
|
|
const mockedGetUserConfig = vi.mocked(getUserConfig);
|
|
const mockedHandleEventToast = vi.mocked(handleEventToast);
|
|
|
|
function makeEvent(
|
|
eventType: string,
|
|
overrides?: Partial<InstanceEventPayload>,
|
|
): InstanceEventPayload {
|
|
return {
|
|
event: eventType,
|
|
instance_id: "i-1",
|
|
status: undefined,
|
|
message: undefined,
|
|
metadata: {},
|
|
timestamp: "2026-05-29T10:00:00Z",
|
|
correlation_id: "c1",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
async function flushPromises() {
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
});
|
|
}
|
|
|
|
describe("EventToastBridge preference checks", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockedUseEventContext.mockReturnValue({
|
|
events: [],
|
|
connected: false,
|
|
reconnectCount: 0,
|
|
});
|
|
mockedGetUserConfig.mockResolvedValue({
|
|
theme: "system",
|
|
default_editor: null,
|
|
git_user_name: null,
|
|
git_user_email: null,
|
|
last_session_id: null,
|
|
notification_toast_level: "all",
|
|
notification_mute_categories: [],
|
|
} as unknown as Awaited<ReturnType<typeof getUserConfig>>);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("shows toast when level is all and category not muted", async () => {
|
|
const event = makeEvent("instance.started");
|
|
mockedUseEventContext.mockReturnValue({
|
|
events: [event],
|
|
connected: false,
|
|
reconnectCount: 0,
|
|
});
|
|
render(<EventToastBridge />);
|
|
await flushPromises();
|
|
expect(mockedHandleEventToast).toHaveBeenCalledWith(event);
|
|
});
|
|
|
|
it("suppresses toast when level is none", async () => {
|
|
mockedGetUserConfig.mockResolvedValue({
|
|
notification_toast_level: "none",
|
|
notification_mute_categories: [],
|
|
} as unknown as Awaited<ReturnType<typeof getUserConfig>>);
|
|
const event = makeEvent("instance.started");
|
|
mockedUseEventContext.mockReturnValue({
|
|
events: [event],
|
|
connected: false,
|
|
reconnectCount: 0,
|
|
});
|
|
render(<EventToastBridge />);
|
|
await flushPromises();
|
|
expect(mockedHandleEventToast).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("suppresses info toast when level is errors", async () => {
|
|
mockedGetUserConfig.mockResolvedValue({
|
|
notification_toast_level: "errors",
|
|
notification_mute_categories: [],
|
|
} as unknown as Awaited<ReturnType<typeof getUserConfig>>);
|
|
const event = makeEvent("instance.started");
|
|
mockedUseEventContext.mockReturnValue({
|
|
events: [event],
|
|
connected: false,
|
|
reconnectCount: 0,
|
|
});
|
|
render(<EventToastBridge />);
|
|
await flushPromises();
|
|
expect(mockedHandleEventToast).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("shows error toast when level is errors", async () => {
|
|
mockedGetUserConfig.mockResolvedValue({
|
|
notification_toast_level: "errors",
|
|
notification_mute_categories: [],
|
|
} as unknown as Awaited<ReturnType<typeof getUserConfig>>);
|
|
const event = makeEvent("instance.error");
|
|
mockedUseEventContext.mockReturnValue({
|
|
events: [event],
|
|
connected: false,
|
|
reconnectCount: 0,
|
|
});
|
|
render(<EventToastBridge />);
|
|
await flushPromises();
|
|
expect(mockedHandleEventToast).toHaveBeenCalledWith(event);
|
|
});
|
|
|
|
it("suppresses toast when category is muted", async () => {
|
|
mockedGetUserConfig.mockResolvedValue({
|
|
notification_toast_level: "all",
|
|
notification_mute_categories: ["instance"],
|
|
} as unknown as Awaited<ReturnType<typeof getUserConfig>>);
|
|
const event = makeEvent("instance.started");
|
|
mockedUseEventContext.mockReturnValue({
|
|
events: [event],
|
|
connected: false,
|
|
reconnectCount: 0,
|
|
});
|
|
render(<EventToastBridge />);
|
|
await flushPromises();
|
|
expect(mockedHandleEventToast).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("applies preference change immediately via custom event", async () => {
|
|
const event1 = makeEvent("instance.started");
|
|
mockedUseEventContext.mockReturnValue({
|
|
events: [event1],
|
|
connected: false,
|
|
reconnectCount: 0,
|
|
});
|
|
const { rerender } = render(<EventToastBridge />);
|
|
await flushPromises();
|
|
expect(mockedHandleEventToast).toHaveBeenCalledTimes(1);
|
|
|
|
act(() => {
|
|
window.dispatchEvent(
|
|
new CustomEvent("userconfig:updated", {
|
|
detail: { notification_toast_level: "none" },
|
|
}),
|
|
);
|
|
});
|
|
|
|
const event2 = makeEvent("instance.started");
|
|
mockedUseEventContext.mockReturnValue({
|
|
events: [event1, event2],
|
|
connected: false,
|
|
reconnectCount: 0,
|
|
});
|
|
rerender(<EventToastBridge />);
|
|
await flushPromises();
|
|
expect(mockedHandleEventToast).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("muted category overrides all level", async () => {
|
|
mockedGetUserConfig.mockResolvedValue({
|
|
notification_toast_level: "all",
|
|
notification_mute_categories: ["instance"],
|
|
} as unknown as Awaited<ReturnType<typeof getUserConfig>>);
|
|
const event = makeEvent("instance.error");
|
|
mockedUseEventContext.mockReturnValue({
|
|
events: [event],
|
|
connected: false,
|
|
reconnectCount: 0,
|
|
});
|
|
render(<EventToastBridge />);
|
|
await flushPromises();
|
|
expect(mockedHandleEventToast).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("deduplication still works with preferences", async () => {
|
|
const event = makeEvent("instance.started");
|
|
mockedUseEventContext.mockReturnValue({
|
|
events: [event, event],
|
|
connected: false,
|
|
reconnectCount: 0,
|
|
});
|
|
render(<EventToastBridge />);
|
|
await flushPromises();
|
|
expect(mockedHandleEventToast).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("unmapped event defaults to system/info and shows when level is all", async () => {
|
|
const event = makeEvent("system.announcement");
|
|
mockedUseEventContext.mockReturnValue({
|
|
events: [event],
|
|
connected: false,
|
|
reconnectCount: 0,
|
|
});
|
|
render(<EventToastBridge />);
|
|
await flushPromises();
|
|
expect(mockedHandleEventToast).toHaveBeenCalledWith(event);
|
|
});
|
|
});
|