吸收数字员工系统桌面通知
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const notificationMock = vi.hoisted(() => ({
|
||||
instances: [] as Array<{ options: Record<string, unknown> }>,
|
||||
show: vi.fn(),
|
||||
isSupported: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock("electron", () => {
|
||||
class MockNotification {
|
||||
options: Record<string, unknown>;
|
||||
|
||||
constructor(options: Record<string, unknown>) {
|
||||
this.options = options;
|
||||
notificationMock.instances.push(this);
|
||||
}
|
||||
|
||||
show(): void {
|
||||
notificationMock.show();
|
||||
}
|
||||
|
||||
static isSupported(): boolean {
|
||||
return notificationMock.isSupported();
|
||||
}
|
||||
}
|
||||
|
||||
return { Notification: MockNotification };
|
||||
});
|
||||
|
||||
describe("digital employee desktop notifications", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
notificationMock.instances = [];
|
||||
notificationMock.show.mockReset();
|
||||
notificationMock.isSupported.mockReset();
|
||||
notificationMock.isSupported.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it("shows unread action notifications through Electron Notification", async () => {
|
||||
const { showDigitalEmployeeDesktopNotification } = await import("./desktopNotificationService");
|
||||
|
||||
const result = showDigitalEmployeeDesktopNotification({
|
||||
notification_id: "approval:1",
|
||||
title: "待审批动作",
|
||||
body: "客户导出需要主管确认",
|
||||
level: "action",
|
||||
kind: "need_approval",
|
||||
status: "unread",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true, shown: true });
|
||||
expect(notificationMock.instances).toHaveLength(1);
|
||||
expect(notificationMock.instances[0]?.options).toMatchObject({
|
||||
title: "待审批动作",
|
||||
body: "客户导出需要主管确认",
|
||||
});
|
||||
expect(notificationMock.show).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["read", "action"],
|
||||
["dismissed", "action"],
|
||||
["resolved", "action"],
|
||||
["unread", "info"],
|
||||
])("skips %s/%s notifications", async (status, level) => {
|
||||
const { showDigitalEmployeeDesktopNotification } = await import("./desktopNotificationService");
|
||||
|
||||
const result = showDigitalEmployeeDesktopNotification({
|
||||
notification_id: `notification:${status}:${level}`,
|
||||
title: "普通消息",
|
||||
body: "无需系统提醒",
|
||||
status,
|
||||
level,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ ok: true, shown: false });
|
||||
expect(notificationMock.show).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses safe fallback text and truncates long notification content", async () => {
|
||||
const { showDigitalEmployeeDesktopNotification } = await import("./desktopNotificationService");
|
||||
|
||||
const result = showDigitalEmployeeDesktopNotification({
|
||||
notification_id: "warn:1",
|
||||
title: "x".repeat(160),
|
||||
body: "y".repeat(420),
|
||||
level: "warn",
|
||||
status: "unread",
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ ok: true, shown: true });
|
||||
const options = notificationMock.instances[0]?.options ?? {};
|
||||
expect(String(options.title).length).toBeLessThanOrEqual(80);
|
||||
expect(String(options.body).length).toBeLessThanOrEqual(240);
|
||||
|
||||
notificationMock.instances = [];
|
||||
showDigitalEmployeeDesktopNotification({ notification_id: "warn:2", level: "warn", status: "unread" });
|
||||
expect(notificationMock.instances[0]?.options).toMatchObject({
|
||||
title: "数字员工通知",
|
||||
body: "有新的数字员工通知需要处理",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns failure when Electron Notification is unavailable or show fails", async () => {
|
||||
const { showDigitalEmployeeDesktopNotification } = await import("./desktopNotificationService");
|
||||
notificationMock.isSupported.mockReturnValue(false);
|
||||
|
||||
expect(showDigitalEmployeeDesktopNotification({ notification_id: "warn:1", level: "warn", status: "unread" })).toMatchObject({
|
||||
ok: false,
|
||||
shown: false,
|
||||
reason: "notification_unavailable",
|
||||
});
|
||||
|
||||
notificationMock.isSupported.mockReturnValue(true);
|
||||
notificationMock.show.mockImplementationOnce(() => { throw new Error("system denied"); });
|
||||
|
||||
expect(showDigitalEmployeeDesktopNotification({ notification_id: "warn:2", level: "warn", status: "unread" })).toMatchObject({
|
||||
ok: false,
|
||||
shown: false,
|
||||
error: "system denied",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Notification } from "electron";
|
||||
|
||||
const IMPORTANT_LEVELS = new Set(["action", "warn", "error"]);
|
||||
const TITLE_MAX_LENGTH = 80;
|
||||
const BODY_MAX_LENGTH = 240;
|
||||
|
||||
export interface DigitalEmployeeDesktopNotificationInput {
|
||||
notification_id?: string | null;
|
||||
notificationId?: string | null;
|
||||
title?: string | null;
|
||||
body?: string | null;
|
||||
level?: string | null;
|
||||
kind?: string | null;
|
||||
status?: string | null;
|
||||
source?: string | null;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeDesktopNotificationResult {
|
||||
ok: boolean;
|
||||
shown: boolean;
|
||||
reason?: string | null;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export function showDigitalEmployeeDesktopNotification(
|
||||
input: DigitalEmployeeDesktopNotificationInput,
|
||||
): DigitalEmployeeDesktopNotificationResult {
|
||||
const status = stringField(input.status) || "unread";
|
||||
const level = stringField(input.level) || "info";
|
||||
if (status !== "unread") return { ok: true, shown: false, reason: "notification_not_unread" };
|
||||
if (!IMPORTANT_LEVELS.has(level)) return { ok: true, shown: false, reason: "notification_level_not_important" };
|
||||
|
||||
if (!Notification || typeof Notification.isSupported !== "function" || !Notification.isSupported()) {
|
||||
return { ok: false, shown: false, reason: "notification_unavailable" };
|
||||
}
|
||||
|
||||
const title = compactText(stringField(input.title) || "数字员工通知", TITLE_MAX_LENGTH);
|
||||
const body = compactText(stringField(input.body) || "有新的数字员工通知需要处理", BODY_MAX_LENGTH);
|
||||
try {
|
||||
const notification = new Notification({ title, body, silent: false });
|
||||
notification.show();
|
||||
return { ok: true, shown: true };
|
||||
} catch (error) {
|
||||
return { ok: false, shown: false, error: normalizeError(error) };
|
||||
}
|
||||
}
|
||||
|
||||
function stringField(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function compactText(value: string, maxLength: number): string {
|
||||
const compact = value.replace(/\s+/g, " ").trim();
|
||||
if (compact.length <= maxLength) return compact;
|
||||
return `${compact.slice(0, Math.max(0, maxLength - 3))}...`;
|
||||
}
|
||||
|
||||
function normalizeError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -720,6 +720,7 @@ export interface DigitalEmployeeUiState {
|
||||
notificationStateById?: Record<string, unknown>;
|
||||
notificationPreferences?: {
|
||||
enabled: boolean;
|
||||
desktop_enabled: boolean;
|
||||
muted_kinds: string[];
|
||||
muted_levels: string[];
|
||||
updated_at: string | null;
|
||||
@@ -970,6 +971,7 @@ function normalizeRecord(value: unknown): Record<string, unknown> {
|
||||
function defaultNotificationPreferences(): NonNullable<DigitalEmployeeUiState["notificationPreferences"]> {
|
||||
return {
|
||||
enabled: true,
|
||||
desktop_enabled: true,
|
||||
muted_kinds: [],
|
||||
muted_levels: [],
|
||||
updated_at: null,
|
||||
@@ -1041,6 +1043,7 @@ function normalizeNotificationPreferences(
|
||||
: {};
|
||||
return {
|
||||
enabled: record.enabled !== false,
|
||||
desktop_enabled: record.desktop_enabled !== false,
|
||||
muted_kinds: normalizeStringArray(record.muted_kinds),
|
||||
muted_levels: normalizeStringArray(record.muted_levels),
|
||||
updated_at: typeof record.updated_at === "string" ? record.updated_at : null,
|
||||
|
||||
Reference in New Issue
Block a user