Files
AgentCompany/tests/unit/notification-service.test.ts
T
ZacharyZhang-NY 36bed1b0f1 feat(team): add TeamHeaderSummary strip + delete unreachable orgchart section
Two S-level audit items landed together because they are the same
"first-second orientation" concern on the team page:

S1 — Empty OrgChart was the first thing the board saw; the canvas
     carried no summary data. Now a compact summary strip sits above
     the chart showing Members / Running / Attention / Pending hires /
     This-month budget (spent/cap/%). All five numbers computed from
     the existing snapshot — no extra IPC.
S9 — Activity and budget context previously required visiting three
     separate pages. The summary strip collapses the minimum-useful
     subset into a single glance; the full drill-down paths (Work,
     Approvals, Costs) remain one click away.

S10 — The `orgchart` SectionId was a dead rendering branch:
       - Not in the sidebar (useUIController sidebarGroups)
       - Not in the command palette (paletteOnlyNavDefs)
       - Not reachable from any navigateTo() call in renderer code
     The only callers were an automation post-hire notification and a
     unit test — both pointed at "orgchart" as a navigation target.
     Routed both to "agents" instead (which renders the OrgChart at
     the top anyway, plus the new summary strip and the roster), so
     clicking the "Agent Hired" notification now lands on the team
     page with the new hire highlighted.

Removed:
- `orgchart` from SectionId enum (src/shared/types/enums.ts)
- `section === "orgchart"` render branch (OrganizationSections.tsx)
- `nav.orgchart` i18n key (zh + en)

Added:
- src/renderer/components/organization/TeamHeaderSummary.tsx
- team.summary.* i18n keys (zh + en)

Covers S1 / S9 / S10.
2026-04-17 19:57:58 -04:00

271 lines
8.5 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { BrowserWindow } from "electron";
import type { DesktopEvent } from "@shared/types";
// Track all constructed notification instances
const constructedNotifications: Array<{
options: Record<string, unknown>;
clickHandler?: () => void;
closeHandler?: () => void;
}> = [];
vi.mock("electron", () => {
return {
Notification: class MockNotification {
static isSupported = () => true;
options: Record<string, unknown>;
_clickHandler?: () => void;
_closeHandler?: () => void;
constructor(options: Record<string, unknown>) {
this.options = options;
}
show() {
constructedNotifications.push({
options: this.options,
clickHandler: this._clickHandler,
closeHandler: this._closeHandler,
});
}
on(event: string, handler: () => void) {
if (event === "click") this._clickHandler = handler;
if (event === "close") this._closeHandler = handler;
// Update the last entry if already shown, or store for later
const last = constructedNotifications[constructedNotifications.length - 1];
if (last && event === "click") last.clickHandler = handler;
if (last && event === "close") last.closeHandler = handler;
}
},
app: { focus: vi.fn() },
};
});
// Import after mocking
const { createNotificationService } = await import("@main/notification-service");
describe("notification-service", () => {
let service: ReturnType<typeof createNotificationService>;
let mockGetMainWindow: ReturnType<typeof vi.fn>;
let mockEmitEvent: ReturnType<typeof vi.fn>;
let mockLogger: { info: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> };
beforeEach(() => {
vi.useFakeTimers();
constructedNotifications.length = 0;
mockGetMainWindow = vi.fn(() => null);
mockEmitEvent = vi.fn();
mockLogger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
service = createNotificationService({
getMainWindow: mockGetMainWindow as unknown as () => BrowserWindow | null,
emitEvent: mockEmitEvent as unknown as (event: DesktopEvent) => void,
logger: mockLogger as { info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void; error: (...args: unknown[]) => void },
});
});
afterEach(() => {
service.destroy();
vi.useRealTimers();
});
it("Test 1: fireNotification with urgency 'critical' creates Notification with silent: false", () => {
service.notify({
title: "Budget Exhausted",
body: "Agent paused",
urgency: "critical",
});
expect(constructedNotifications).toHaveLength(1);
expect(constructedNotifications[0]!.options.silent).toBe(false);
});
it("Test 2: fireNotification with urgency 'informational' creates Notification with silent: true", () => {
service.notify({
title: "Agent Hired",
body: "Bob joined",
urgency: "informational",
});
expect(constructedNotifications).toHaveLength(1);
expect(constructedNotifications[0]!.options.silent).toBe(true);
});
it("Test 3: notify without batchKey fires immediately (no batching)", () => {
service.notify({
title: "Update Available",
body: "Version 2.0 is ready",
urgency: "informational",
});
expect(constructedNotifications).toHaveLength(1);
});
it("Test 4: notify with batchKey fires first event immediately, starts batch timer", () => {
service.notify({
title: "Task Failed",
body: "Task A failed",
urgency: "informational",
navigation: { section: "tasks" },
batchKey: "failed_runs",
});
expect(constructedNotifications).toHaveLength(1);
expect(constructedNotifications[0]!.options.title).toBe("Task Failed");
});
it("Test 5: notify with same batchKey within 10s increments count, does NOT fire second notification", () => {
service.notify({
title: "Task Failed",
body: "Task A failed",
urgency: "informational",
navigation: { section: "tasks" },
batchKey: "failed_runs",
});
service.notify({
title: "Task Failed",
body: "Task B failed",
urgency: "informational",
navigation: { section: "tasks" },
batchKey: "failed_runs",
});
// Only the first should have fired
expect(constructedNotifications).toHaveLength(1);
});
it("Test 6: After 10s batch timer expires, fires summary notification with count in title", () => {
service.notify({
title: "Task Failed",
body: "Task A failed",
urgency: "informational",
navigation: { section: "tasks" },
batchKey: "failed_runs",
});
// Add 2 more batched notifications
service.notify({
title: "Task Failed",
body: "Task B failed",
urgency: "informational",
navigation: { section: "tasks" },
batchKey: "failed_runs",
});
service.notify({
title: "Task Failed",
body: "Task C failed",
urgency: "informational",
navigation: { section: "tasks" },
batchKey: "failed_runs",
});
expect(constructedNotifications).toHaveLength(1); // Only first
// Advance timer by 10 seconds
vi.advanceTimersByTime(10_000);
// Summary notification should have fired
expect(constructedNotifications).toHaveLength(2);
const summary = constructedNotifications[1]!;
expect(summary.options.title).toContain("2");
expect(summary.options.title).toContain("tasks failed");
expect(summary.options.body).toBe("Click to view failed runs");
});
it("Test 7: Batched summary notification has no entityId (navigates to section overview)", () => {
service.notify({
title: "Agent Hired",
body: "Alice joined",
urgency: "informational",
navigation: { section: "agents", entityId: "agent-1" },
batchKey: "agent_hired",
});
service.notify({
title: "Agent Hired",
body: "Bob joined",
urgency: "informational",
navigation: { section: "agents", entityId: "agent-2" },
batchKey: "agent_hired",
});
vi.advanceTimersByTime(10_000);
// Summary notification fires -- simulate click
expect(constructedNotifications).toHaveLength(2);
const summaryClickHandler = constructedNotifications[1]!.clickHandler;
expect(summaryClickHandler).toBeInstanceOf(Function);
summaryClickHandler!();
// emitEvent should have been called with section but no entityId
const emitCall = mockEmitEvent.mock.calls.find(
(args: unknown[]) => (args[0] as { type?: string })?.type === "notification-click",
);
expect(emitCall).toBeDefined();
expect(emitCall![0].section).toBe("orgchart");
expect(emitCall![0].entityId).toBeUndefined();
});
it("Test 8: Events in NEVER_BATCH list always fire individually regardless of batchKey", () => {
// pending_approval is in NEVER_BATCH
service.notify({
title: "Approval Required",
body: "First approval",
urgency: "critical",
navigation: { section: "approvals" },
batchKey: "pending_approval",
});
service.notify({
title: "Approval Required",
body: "Second approval",
urgency: "critical",
navigation: { section: "approvals" },
batchKey: "pending_approval",
});
// Both should fire individually
expect(constructedNotifications).toHaveLength(2);
});
it("Test 9: Active Notification objects are held in a Set and removed on 'close' event", () => {
service.notify({
title: "Test",
body: "Test body",
urgency: "informational",
});
expect(constructedNotifications).toHaveLength(1);
// The close handler should exist and be callable without error
const closeHandler = constructedNotifications[0]!.closeHandler;
expect(closeHandler).toBeInstanceOf(Function);
// Calling close handler should not throw (notification removed from internal Set)
closeHandler!();
});
it("Test 10: On Linux, urgency 'critical' sets urgency option to 'critical'", () => {
const originalPlatform = process.platform;
Object.defineProperty(process, "platform", { value: "linux", configurable: true });
service.notify({
title: "Critical Alert",
body: "Something critical",
urgency: "critical",
});
expect(constructedNotifications).toHaveLength(1);
expect(constructedNotifications[0]!.options.urgency).toBe("critical");
Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true });
});
});