247 lines
8 KiB
TypeScript
247 lines
8 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
import {
|
|
MEMBER_WORK_SYNC_GET_METRICS,
|
|
MEMBER_WORK_SYNC_GET_STATUS,
|
|
MEMBER_WORK_SYNC_REFRESH_STATUS,
|
|
MEMBER_WORK_SYNC_REPORT,
|
|
} from '@features/member-work-sync/contracts';
|
|
import {
|
|
registerMemberWorkSyncIpc,
|
|
removeMemberWorkSyncIpc,
|
|
} from '@features/member-work-sync/main';
|
|
|
|
import type {
|
|
MemberWorkSyncMetricsRequest,
|
|
MemberWorkSyncReportRequest,
|
|
MemberWorkSyncStatusRequest,
|
|
} from '@features/member-work-sync/contracts';
|
|
import type { MemberWorkSyncFeatureFacade } from '@features/member-work-sync/main';
|
|
import type { IpcMain } from 'electron';
|
|
|
|
vi.mock('@shared/utils/logger', () => ({
|
|
createLogger: () => ({
|
|
debug: vi.fn(),
|
|
error: vi.fn(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
}),
|
|
}));
|
|
|
|
type IpcHandler = (event: unknown, request: unknown) => Promise<unknown>;
|
|
|
|
function makeIpcMain() {
|
|
const handlers = new Map<string, IpcHandler>();
|
|
const ipcMain = {
|
|
handle: vi.fn((channel: string, handler: IpcHandler) => {
|
|
handlers.set(channel, handler);
|
|
}),
|
|
removeHandler: vi.fn((channel: string) => {
|
|
handlers.delete(channel);
|
|
}),
|
|
};
|
|
|
|
return { handlers, ipcMain: ipcMain as unknown as IpcMain };
|
|
}
|
|
|
|
function makeFeature(): MemberWorkSyncFeatureFacade {
|
|
return {
|
|
getStatus: vi.fn(async (request) => ({
|
|
teamName: request.teamName,
|
|
memberName: request.memberName,
|
|
state: 'unknown' as const,
|
|
agenda: {
|
|
teamName: request.teamName,
|
|
memberName: request.memberName,
|
|
generatedAt: '2026-04-29T00:00:00.000Z',
|
|
fingerprint: 'agenda:v1:test',
|
|
items: [],
|
|
diagnostics: [],
|
|
},
|
|
evaluatedAt: '2026-04-29T00:00:00.000Z',
|
|
diagnostics: [],
|
|
})),
|
|
refreshStatus: vi.fn(async (request) => ({
|
|
teamName: request.teamName,
|
|
memberName: request.memberName,
|
|
state: 'needs_sync' as const,
|
|
agenda: {
|
|
teamName: request.teamName,
|
|
memberName: request.memberName,
|
|
generatedAt: '2026-04-29T00:00:00.000Z',
|
|
fingerprint: 'agenda:v1:test',
|
|
items: [],
|
|
diagnostics: [],
|
|
},
|
|
evaluatedAt: '2026-04-29T00:00:00.000Z',
|
|
diagnostics: [],
|
|
})),
|
|
getMetrics: vi.fn(async (request) => ({
|
|
teamName: request.teamName,
|
|
generatedAt: '2026-04-29T00:00:00.000Z',
|
|
memberCount: 0,
|
|
stateCounts: {
|
|
blocked: 0,
|
|
caught_up: 0,
|
|
inactive: 0,
|
|
needs_sync: 0,
|
|
still_working: 0,
|
|
unknown: 0,
|
|
},
|
|
actionableItemCount: 0,
|
|
wouldNudgeCount: 0,
|
|
fingerprintChangeCount: 0,
|
|
reportAcceptedCount: 0,
|
|
reportRejectedCount: 0,
|
|
recentEvents: [],
|
|
phase2Readiness: {
|
|
state: 'collecting_shadow_data' as const,
|
|
reasons: ['insufficient_members' as const],
|
|
thresholds: {
|
|
maxFingerprintChangesPerMemberHour: 4,
|
|
maxReportRejectionRate: 0.2,
|
|
maxWouldNudgesPerMemberHour: 2,
|
|
minObservationHours: 24,
|
|
minObservedMembers: 3,
|
|
minStatusEvents: 20,
|
|
},
|
|
rates: {
|
|
fingerprintChangesPerMemberHour: 0,
|
|
observationHours: 0,
|
|
reportRejectionRate: 0,
|
|
statusEventCount: 0,
|
|
wouldNudgesPerMemberHour: 0,
|
|
},
|
|
diagnostics: [],
|
|
},
|
|
})),
|
|
report: vi.fn(async (request) => ({
|
|
accepted: true,
|
|
code: 'accepted',
|
|
message: 'Report accepted.',
|
|
status: {
|
|
teamName: request.teamName,
|
|
memberName: request.memberName,
|
|
state: request.state,
|
|
agenda: {
|
|
teamName: request.teamName,
|
|
memberName: request.memberName,
|
|
generatedAt: '2026-04-29T00:00:00.000Z',
|
|
fingerprint: request.agendaFingerprint,
|
|
items: [],
|
|
diagnostics: [],
|
|
},
|
|
evaluatedAt: '2026-04-29T00:00:00.000Z',
|
|
diagnostics: [],
|
|
},
|
|
})),
|
|
noteTeamChange: vi.fn(),
|
|
enqueueStartupScan: vi.fn(),
|
|
replayPendingReports: vi.fn(),
|
|
dispatchDueNudges: vi.fn(),
|
|
buildRuntimeTurnSettledHookSettings: vi.fn(),
|
|
buildRuntimeTurnSettledEnvironment: vi.fn(),
|
|
drainRuntimeTurnSettledEvents: vi.fn(),
|
|
getQueueDiagnostics: vi.fn(),
|
|
dispose: vi.fn(),
|
|
};
|
|
}
|
|
|
|
describe('registerMemberWorkSyncIpc', () => {
|
|
it('registers status, metrics and report handlers that delegate requests unchanged', async () => {
|
|
const { handlers, ipcMain } = makeIpcMain();
|
|
const feature = makeFeature();
|
|
|
|
registerMemberWorkSyncIpc(ipcMain, feature);
|
|
|
|
expect(ipcMain.handle).toHaveBeenCalledTimes(4);
|
|
expect([...handlers.keys()].sort()).toEqual(
|
|
[
|
|
MEMBER_WORK_SYNC_GET_METRICS,
|
|
MEMBER_WORK_SYNC_GET_STATUS,
|
|
MEMBER_WORK_SYNC_REFRESH_STATUS,
|
|
MEMBER_WORK_SYNC_REPORT,
|
|
].sort()
|
|
);
|
|
|
|
const statusRequest: MemberWorkSyncStatusRequest = { teamName: 'team-a', memberName: 'bob' };
|
|
const metricsRequest: MemberWorkSyncMetricsRequest = { teamName: 'team-a' };
|
|
const reportRequest: MemberWorkSyncReportRequest = {
|
|
teamName: 'team-a',
|
|
memberName: 'bob',
|
|
state: 'still_working',
|
|
agendaFingerprint: 'agenda:v1:test',
|
|
};
|
|
|
|
await expect(
|
|
handlers.get(MEMBER_WORK_SYNC_GET_STATUS)?.({}, statusRequest)
|
|
).resolves.toMatchObject({ teamName: 'team-a', memberName: 'bob' });
|
|
await expect(
|
|
handlers.get(MEMBER_WORK_SYNC_REFRESH_STATUS)?.({}, statusRequest)
|
|
).resolves.toMatchObject({ teamName: 'team-a', memberName: 'bob', state: 'needs_sync' });
|
|
await expect(
|
|
handlers.get(MEMBER_WORK_SYNC_GET_METRICS)?.({}, metricsRequest)
|
|
).resolves.toMatchObject({ teamName: 'team-a' });
|
|
await expect(handlers.get(MEMBER_WORK_SYNC_REPORT)?.({}, reportRequest)).resolves.toMatchObject(
|
|
{ accepted: true, code: 'accepted' }
|
|
);
|
|
|
|
expect(feature.getStatus).toHaveBeenCalledWith(statusRequest);
|
|
expect(feature.refreshStatus).toHaveBeenCalledWith(statusRequest);
|
|
expect(feature.getMetrics).toHaveBeenCalledWith(metricsRequest);
|
|
expect(feature.report).toHaveBeenCalledWith(reportRequest);
|
|
});
|
|
|
|
it('propagates feature errors so the renderer receives the real status failure', async () => {
|
|
const { handlers, ipcMain } = makeIpcMain();
|
|
const feature = makeFeature();
|
|
const failure = new Error('status failed');
|
|
vi.mocked(feature.getStatus).mockRejectedValueOnce(failure);
|
|
|
|
registerMemberWorkSyncIpc(ipcMain, feature);
|
|
|
|
await expect(
|
|
handlers.get(MEMBER_WORK_SYNC_GET_STATUS)?.({}, { teamName: 'team-a', memberName: 'bob' })
|
|
).rejects.toThrow('status failed');
|
|
});
|
|
|
|
it('propagates metrics and report errors without replacing them', async () => {
|
|
const { handlers, ipcMain } = makeIpcMain();
|
|
const feature = makeFeature();
|
|
vi.mocked(feature.getMetrics).mockRejectedValueOnce(new Error('metrics failed'));
|
|
vi.mocked(feature.report).mockRejectedValueOnce(new Error('report failed'));
|
|
|
|
registerMemberWorkSyncIpc(ipcMain, feature);
|
|
|
|
await expect(
|
|
handlers.get(MEMBER_WORK_SYNC_GET_METRICS)?.({}, { teamName: 'team-a' })
|
|
).rejects.toThrow('metrics failed');
|
|
await expect(
|
|
handlers.get(MEMBER_WORK_SYNC_REPORT)?.(
|
|
{},
|
|
{
|
|
teamName: 'team-a',
|
|
memberName: 'bob',
|
|
state: 'blocked',
|
|
agendaFingerprint: 'agenda:v1:test',
|
|
}
|
|
)
|
|
).rejects.toThrow('report failed');
|
|
});
|
|
|
|
it('removes exactly the member work sync handlers', () => {
|
|
const { handlers, ipcMain } = makeIpcMain();
|
|
const feature = makeFeature();
|
|
registerMemberWorkSyncIpc(ipcMain, feature);
|
|
handlers.set('unrelated:channel', vi.fn());
|
|
|
|
removeMemberWorkSyncIpc(ipcMain);
|
|
|
|
expect(ipcMain.removeHandler).toHaveBeenCalledTimes(4);
|
|
expect(ipcMain.removeHandler).toHaveBeenCalledWith(MEMBER_WORK_SYNC_GET_STATUS);
|
|
expect(ipcMain.removeHandler).toHaveBeenCalledWith(MEMBER_WORK_SYNC_REFRESH_STATUS);
|
|
expect(ipcMain.removeHandler).toHaveBeenCalledWith(MEMBER_WORK_SYNC_GET_METRICS);
|
|
expect(ipcMain.removeHandler).toHaveBeenCalledWith(MEMBER_WORK_SYNC_REPORT);
|
|
expect([...handlers.keys()]).toEqual(['unrelated:channel']);
|
|
});
|
|
});
|