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.
271 lines
8.5 KiB
TypeScript
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 });
|
|
});
|
|
});
|