1583 lines
48 KiB
TypeScript
1583 lines
48 KiB
TypeScript
import React, { act } from 'react';
|
|
import { createRoot, type Root } from 'react-dom/client';
|
|
|
|
import { TooltipProvider } from '@renderer/components/ui/tooltip';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { TEAM_CHANGES_LOAD_TIMEOUT_MS } from '../teamChangesLoadTimeout';
|
|
import { TeamChangesSection } from '../TeamChangesSection';
|
|
import { type TeamChangeSummaryState, useTeamChangesSummaries } from '../useTeamChangesSummaries';
|
|
|
|
import type {
|
|
TaskChangeSetV2,
|
|
TeamTaskChangeSummariesResponse,
|
|
TeamTaskChangeSummaryRequest,
|
|
TeamTaskWithKanban,
|
|
} from '@shared/types';
|
|
|
|
const hoisted = vi.hoisted(() => ({
|
|
fetchConfig: vi.fn(),
|
|
getTeamTaskChangeSummaries: vi.fn(),
|
|
recordTaskChangePresence: vi.fn(),
|
|
setSelectedTeamTaskChangePresence: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('@renderer/api', () => ({
|
|
api: {
|
|
review: {
|
|
getTeamTaskChangeSummaries: hoisted.getTeamTaskChangeSummaries,
|
|
},
|
|
},
|
|
}));
|
|
|
|
vi.mock('@renderer/store', () => ({
|
|
useStore: (selector: (state: Record<string, unknown>) => unknown) =>
|
|
selector({
|
|
appConfig: { general: { theme: 'dark' } },
|
|
configLoading: false,
|
|
fetchConfig: hoisted.fetchConfig,
|
|
memberActivityMetaByTeam: {},
|
|
recordTaskChangePresence: hoisted.recordTaskChangePresence,
|
|
setSelectedTeamTaskChangePresence: hoisted.setSelectedTeamTaskChangePresence,
|
|
selectedTeamData: null,
|
|
selectedTeamName: undefined,
|
|
teamDataCacheByName: {},
|
|
}),
|
|
}));
|
|
|
|
interface Deferred<T> {
|
|
promise: Promise<T>;
|
|
resolve: (value: T) => void;
|
|
reject: (error: unknown) => void;
|
|
}
|
|
|
|
function createDeferred<T>(): Deferred<T> {
|
|
let resolve!: (value: T) => void;
|
|
let reject!: (error: unknown) => void;
|
|
const promise = new Promise<T>((res, rej) => {
|
|
resolve = res;
|
|
reject = rej;
|
|
});
|
|
return { promise, resolve, reject };
|
|
}
|
|
|
|
function task(overrides: Partial<TeamTaskWithKanban> = {}): TeamTaskWithKanban {
|
|
return {
|
|
id: 'task-1',
|
|
subject: 'Task 1',
|
|
status: 'completed',
|
|
owner: 'alice',
|
|
createdAt: '2026-05-10T10:00:00.000Z',
|
|
updatedAt: '2026-05-10T10:00:00.000Z',
|
|
changePresence: 'unknown',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function changedTasks(count: number): TeamTaskWithKanban[] {
|
|
return Array.from({ length: count }, (_, index) =>
|
|
task({
|
|
id: `changed-${index}`,
|
|
subject: `Changed ${index}`,
|
|
changePresence: 'has_changes',
|
|
updatedAt: `2026-05-10T10:${String(index).padStart(2, '0')}:00.000Z`,
|
|
})
|
|
);
|
|
}
|
|
|
|
function changeSet(taskId = 'task-1'): TaskChangeSetV2 {
|
|
return {
|
|
teamName: 'team-a',
|
|
taskId,
|
|
files: [],
|
|
totalFiles: 0,
|
|
totalLinesAdded: 0,
|
|
totalLinesRemoved: 0,
|
|
confidence: 'high',
|
|
computedAt: '2026-05-10T10:00:00.000Z',
|
|
scope: {
|
|
taskId,
|
|
memberName: 'alice',
|
|
startLine: 0,
|
|
endLine: 0,
|
|
startTimestamp: '2026-05-10T10:00:00.000Z',
|
|
endTimestamp: '2026-05-10T10:01:00.000Z',
|
|
toolUseIds: [],
|
|
filePaths: [],
|
|
confidence: { tier: 1, label: 'high', reason: 'test' },
|
|
},
|
|
warnings: [],
|
|
};
|
|
}
|
|
|
|
function fileChange(
|
|
overrides: Partial<TaskChangeSetV2['files'][number]> = {}
|
|
): TaskChangeSetV2['files'][number] {
|
|
return {
|
|
filePath: '/repo/src/app.ts',
|
|
relativePath: 'src/app.ts',
|
|
snippets: [],
|
|
linesAdded: 1,
|
|
linesRemoved: 0,
|
|
isNewFile: false,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function response(summary: TaskChangeSetV2 = changeSet()): TeamTaskChangeSummariesResponse {
|
|
return {
|
|
teamName: 'team-a',
|
|
computedAt: '2026-05-10T10:00:01.000Z',
|
|
items: [{ taskId: 'task-1', changeSet: summary }],
|
|
};
|
|
}
|
|
|
|
function responseForRequests(
|
|
requests: TeamTaskChangeSummaryRequest[]
|
|
): TeamTaskChangeSummariesResponse {
|
|
return {
|
|
teamName: 'team-a',
|
|
computedAt: '2026-05-10T10:00:01.000Z',
|
|
items: requests.map((request) => ({
|
|
taskId: request.taskId,
|
|
changeSet: changeSet(request.taskId),
|
|
})),
|
|
};
|
|
}
|
|
|
|
function malformedLegacyChangeSet(): TaskChangeSetV2 {
|
|
return {
|
|
...changeSet(),
|
|
files: undefined,
|
|
scope: undefined,
|
|
totalFiles: 1,
|
|
warnings: ['legacy warning'],
|
|
} as unknown as TaskChangeSetV2;
|
|
}
|
|
|
|
function malformedUnknownChangeSet(): TaskChangeSetV2 {
|
|
return {
|
|
...changeSet(),
|
|
confidence: 'fallback',
|
|
files: undefined,
|
|
warnings: undefined,
|
|
} as unknown as TaskChangeSetV2;
|
|
}
|
|
|
|
function malformedResponse(): TeamTaskChangeSummariesResponse {
|
|
return {
|
|
teamName: 'team-a',
|
|
computedAt: '2026-05-10T10:00:01.000Z',
|
|
items: undefined,
|
|
} as unknown as TeamTaskChangeSummariesResponse;
|
|
}
|
|
|
|
function malformedItemResponse(): TeamTaskChangeSummariesResponse {
|
|
return {
|
|
teamName: 'team-a',
|
|
computedAt: '2026-05-10T10:00:01.000Z',
|
|
items: [
|
|
{
|
|
taskId: ' task-1 ',
|
|
changeSet: 'not-a-change-set',
|
|
error: { message: 'not a string' },
|
|
},
|
|
],
|
|
} as unknown as TeamTaskChangeSummariesResponse;
|
|
}
|
|
|
|
function incompleteChangeSetResponse(): TeamTaskChangeSummariesResponse {
|
|
return {
|
|
teamName: 'team-a',
|
|
computedAt: '2026-05-10T10:00:01.000Z',
|
|
items: [
|
|
{
|
|
taskId: 'task-1',
|
|
changeSet: {
|
|
teamName: 'team-a',
|
|
taskId: 'task-1',
|
|
files: [],
|
|
warnings: [],
|
|
confidence: 'high',
|
|
},
|
|
},
|
|
],
|
|
} as unknown as TeamTaskChangeSummariesResponse;
|
|
}
|
|
|
|
function quietNoLogChangeSet(): TaskChangeSetV2 {
|
|
return {
|
|
...changeSet(),
|
|
confidence: 'fallback',
|
|
scope: {
|
|
taskId: 'task-1',
|
|
memberName: '',
|
|
startLine: 0,
|
|
endLine: 0,
|
|
startTimestamp: '',
|
|
endTimestamp: '',
|
|
toolUseIds: [],
|
|
filePaths: [],
|
|
confidence: { tier: 4, label: 'fallback', reason: 'No log files found for task' },
|
|
},
|
|
warnings: [],
|
|
};
|
|
}
|
|
|
|
function lowConfidenceFileResponse(): TeamTaskChangeSummariesResponse {
|
|
return response({
|
|
...changeSet(),
|
|
confidence: 'low',
|
|
files: [fileChange()],
|
|
totalFiles: 1,
|
|
totalLinesAdded: 1,
|
|
});
|
|
}
|
|
|
|
function intervalScopedFileResponse(): TeamTaskChangeSummariesResponse {
|
|
return response({
|
|
...changeSet(),
|
|
confidence: 'medium',
|
|
files: [
|
|
fileChange({
|
|
filePath: '/repo/791/calculator.js',
|
|
relativePath: '791/calculator.js',
|
|
}),
|
|
],
|
|
totalFiles: 1,
|
|
totalLinesAdded: 162,
|
|
scope: {
|
|
...changeSet().scope,
|
|
confidence: {
|
|
tier: 2,
|
|
label: 'medium',
|
|
reason: 'Scoped by persisted task workIntervals (timestamp-based)',
|
|
},
|
|
},
|
|
warnings: ['Task start boundary missing - scoped by persisted workIntervals timestamps.'],
|
|
});
|
|
}
|
|
|
|
function warningFileResponse(): TeamTaskChangeSummariesResponse {
|
|
return response({
|
|
...changeSet(),
|
|
files: [fileChange()],
|
|
totalFiles: 1,
|
|
totalLinesAdded: 1,
|
|
warnings: ['Unexpected ledger warning.'],
|
|
});
|
|
}
|
|
|
|
function invalidFileSummaryResponse(): TeamTaskChangeSummariesResponse {
|
|
return response({
|
|
...changeSet(),
|
|
confidence: 'low',
|
|
files: [{} as TaskChangeSetV2['files'][number]],
|
|
totalFiles: 1,
|
|
totalLinesAdded: 1,
|
|
});
|
|
}
|
|
|
|
interface HookSnapshot {
|
|
loading: boolean;
|
|
refreshing: boolean;
|
|
error: string | null;
|
|
badgeCount: number | null;
|
|
summariesByTaskId: Record<string, TeamChangeSummaryState>;
|
|
refresh: () => void;
|
|
}
|
|
|
|
const HookHarness = ({
|
|
tasks,
|
|
sectionOpen = true,
|
|
onSnapshot,
|
|
}: {
|
|
tasks: TeamTaskWithKanban[];
|
|
sectionOpen?: boolean;
|
|
onSnapshot: (snapshot: HookSnapshot) => void;
|
|
}): null => {
|
|
const state = useTeamChangesSummaries({
|
|
teamName: 'team-a',
|
|
tasks,
|
|
sectionOpen,
|
|
});
|
|
React.useEffect(() => {
|
|
onSnapshot({
|
|
loading: state.loading,
|
|
refreshing: state.refreshing,
|
|
error: state.error,
|
|
badgeCount: state.badgeCount,
|
|
summariesByTaskId: state.summariesByTaskId,
|
|
refresh: state.refresh,
|
|
});
|
|
}, [
|
|
onSnapshot,
|
|
state.badgeCount,
|
|
state.error,
|
|
state.loading,
|
|
state.refresh,
|
|
state.refreshing,
|
|
state.summariesByTaskId,
|
|
]);
|
|
return null;
|
|
};
|
|
|
|
describe('useTeamChangesSummaries', () => {
|
|
let container: HTMLDivElement | null = null;
|
|
let root: Root | null = null;
|
|
|
|
afterEach(() => {
|
|
if (root) {
|
|
act(() => {
|
|
root?.unmount();
|
|
});
|
|
}
|
|
container?.remove();
|
|
container = null;
|
|
root = null;
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('does not keep initial loading stuck when tasks change during an active request', async () => {
|
|
const first = createDeferred<TeamTaskChangeSummariesResponse>();
|
|
const second = createDeferred<TeamTaskChangeSummariesResponse>();
|
|
hoisted.getTeamTaskChangeSummaries
|
|
.mockReturnValueOnce(first.promise)
|
|
.mockReturnValueOnce(second.promise);
|
|
|
|
const snapshots: HookSnapshot[] = [];
|
|
const onSnapshot = (snapshot: HookSnapshot): void => {
|
|
snapshots.push(snapshot);
|
|
};
|
|
container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root?.render(React.createElement(HookHarness, { tasks: [task()], onSnapshot }));
|
|
});
|
|
|
|
expect(snapshots.at(-1)?.loading).toBe(true);
|
|
|
|
await act(async () => {
|
|
root?.render(
|
|
React.createElement(HookHarness, {
|
|
tasks: [task({ updatedAt: '2026-05-10T10:00:02.000Z' })],
|
|
onSnapshot,
|
|
})
|
|
);
|
|
});
|
|
|
|
await act(async () => {
|
|
first.resolve(response());
|
|
await first.promise;
|
|
});
|
|
|
|
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(2);
|
|
expect(snapshots.at(-1)?.loading).toBe(false);
|
|
expect(snapshots.at(-1)?.refreshing).toBe(true);
|
|
});
|
|
|
|
it('does not cache a stale active response when a newer task snapshot is queued', async () => {
|
|
const first = createDeferred<TeamTaskChangeSummariesResponse>();
|
|
const second = createDeferred<TeamTaskChangeSummariesResponse>();
|
|
hoisted.getTeamTaskChangeSummaries
|
|
.mockReturnValueOnce(first.promise)
|
|
.mockReturnValueOnce(second.promise);
|
|
|
|
const snapshots: HookSnapshot[] = [];
|
|
const onSnapshot = (snapshot: HookSnapshot): void => {
|
|
snapshots.push(snapshot);
|
|
};
|
|
container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root?.render(React.createElement(HookHarness, { tasks: [task()], onSnapshot }));
|
|
});
|
|
|
|
await act(async () => {
|
|
root?.render(
|
|
React.createElement(HookHarness, {
|
|
tasks: [task({ updatedAt: '2026-05-10T10:00:02.000Z' })],
|
|
onSnapshot,
|
|
})
|
|
);
|
|
});
|
|
|
|
await act(async () => {
|
|
first.resolve(
|
|
response({
|
|
...changeSet(),
|
|
files: [fileChange({ filePath: '/repo/src/stale.ts', relativePath: 'src/stale.ts' })],
|
|
totalFiles: 1,
|
|
totalLinesAdded: 1,
|
|
})
|
|
);
|
|
await first.promise;
|
|
});
|
|
|
|
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(2);
|
|
expect(hoisted.recordTaskChangePresence).not.toHaveBeenCalled();
|
|
expect(snapshots.at(-1)?.summariesByTaskId).toEqual({});
|
|
|
|
await act(async () => {
|
|
second.resolve(response());
|
|
await second.promise;
|
|
});
|
|
|
|
expect(hoisted.recordTaskChangePresence).toHaveBeenCalledWith(
|
|
'team-a',
|
|
'task-1',
|
|
expect.any(Object),
|
|
'no_changes'
|
|
);
|
|
expect(hoisted.setSelectedTeamTaskChangePresence).toHaveBeenCalledWith(
|
|
'team-a',
|
|
'task-1',
|
|
'no_changes'
|
|
);
|
|
});
|
|
|
|
it('retries the initial load after React StrictMode effect remount replay', async () => {
|
|
const first = createDeferred<TeamTaskChangeSummariesResponse>();
|
|
const second = createDeferred<TeamTaskChangeSummariesResponse>();
|
|
hoisted.getTeamTaskChangeSummaries
|
|
.mockReturnValueOnce(first.promise)
|
|
.mockReturnValueOnce(second.promise);
|
|
|
|
const snapshots: HookSnapshot[] = [];
|
|
const onSnapshot = (snapshot: HookSnapshot): void => {
|
|
snapshots.push(snapshot);
|
|
};
|
|
container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root?.render(
|
|
React.createElement(
|
|
React.StrictMode,
|
|
null,
|
|
React.createElement(HookHarness, { tasks: [task()], onSnapshot })
|
|
)
|
|
);
|
|
});
|
|
|
|
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(2);
|
|
|
|
await act(async () => {
|
|
first.resolve(response());
|
|
await first.promise;
|
|
});
|
|
|
|
expect(snapshots.at(-1)?.loading).toBe(true);
|
|
|
|
await act(async () => {
|
|
second.resolve(response());
|
|
await second.promise;
|
|
});
|
|
|
|
expect(snapshots.at(-1)?.loading).toBe(false);
|
|
expect(snapshots.at(-1)?.summariesByTaskId['task-1']?.changeSet?.taskId).toBe('task-1');
|
|
});
|
|
|
|
it('clears initial loading and reports an error when the batch request times out', async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
hoisted.getTeamTaskChangeSummaries.mockReturnValue(new Promise(() => undefined));
|
|
|
|
const snapshots: HookSnapshot[] = [];
|
|
const onSnapshot = (snapshot: HookSnapshot): void => {
|
|
snapshots.push(snapshot);
|
|
};
|
|
container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root?.render(React.createElement(HookHarness, { tasks: [task()], onSnapshot }));
|
|
});
|
|
|
|
expect(snapshots.at(-1)?.loading).toBe(true);
|
|
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(TEAM_CHANGES_LOAD_TIMEOUT_MS);
|
|
});
|
|
|
|
expect(snapshots.at(-1)?.loading).toBe(false);
|
|
expect(snapshots.at(-1)?.refreshing).toBe(false);
|
|
expect(snapshots.at(-1)?.error).toBe('Team changes request timed out. Refresh to try again.');
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('does not immediately run a queued refresh after a request failure', async () => {
|
|
const first = createDeferred<TeamTaskChangeSummariesResponse>();
|
|
hoisted.getTeamTaskChangeSummaries.mockReturnValue(first.promise);
|
|
|
|
const snapshots: HookSnapshot[] = [];
|
|
const onSnapshot = (snapshot: HookSnapshot): void => {
|
|
snapshots.push(snapshot);
|
|
};
|
|
container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root?.render(React.createElement(HookHarness, { tasks: [task()], onSnapshot }));
|
|
});
|
|
|
|
await act(async () => {
|
|
root?.render(
|
|
React.createElement(HookHarness, {
|
|
tasks: [task({ updatedAt: '2026-05-10T10:00:02.000Z' })],
|
|
onSnapshot,
|
|
})
|
|
);
|
|
});
|
|
|
|
await act(async () => {
|
|
first.reject(new Error('boom'));
|
|
await first.promise.catch(() => undefined);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(1);
|
|
expect(snapshots.at(-1)?.loading).toBe(false);
|
|
expect(snapshots.at(-1)?.refreshing).toBe(false);
|
|
expect(snapshots.at(-1)?.error).toBe('boom');
|
|
});
|
|
|
|
it('clears loading and reports an error for a malformed batch response', async () => {
|
|
hoisted.getTeamTaskChangeSummaries.mockResolvedValue(malformedResponse());
|
|
|
|
const snapshots: HookSnapshot[] = [];
|
|
const onSnapshot = (snapshot: HookSnapshot): void => {
|
|
snapshots.push(snapshot);
|
|
};
|
|
container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root?.render(React.createElement(HookHarness, { tasks: [task()], onSnapshot }));
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(snapshots.at(-1)?.loading).toBe(false);
|
|
expect(snapshots.at(-1)?.refreshing).toBe(false);
|
|
expect(snapshots.at(-1)?.error).toBe('Team changes response was malformed.');
|
|
expect(snapshots.at(-1)?.summariesByTaskId).toEqual({});
|
|
});
|
|
|
|
it('normalizes malformed batch response items before storing summaries', async () => {
|
|
hoisted.getTeamTaskChangeSummaries.mockResolvedValue(malformedItemResponse());
|
|
|
|
const snapshots: HookSnapshot[] = [];
|
|
const onSnapshot = (snapshot: HookSnapshot): void => {
|
|
snapshots.push(snapshot);
|
|
};
|
|
container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root?.render(React.createElement(HookHarness, { tasks: [task()], onSnapshot }));
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(snapshots.at(-1)?.error).toBeNull();
|
|
expect(snapshots.at(-1)?.summariesByTaskId['task-1']).toEqual({
|
|
taskId: 'task-1',
|
|
changeSet: null,
|
|
});
|
|
expect(hoisted.recordTaskChangePresence).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not cache presence for incomplete change summaries', async () => {
|
|
hoisted.getTeamTaskChangeSummaries.mockResolvedValue(incompleteChangeSetResponse());
|
|
|
|
const snapshots: HookSnapshot[] = [];
|
|
const onSnapshot = (snapshot: HookSnapshot): void => {
|
|
snapshots.push(snapshot);
|
|
};
|
|
container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root?.render(React.createElement(HookHarness, { tasks: [task()], onSnapshot }));
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(snapshots.at(-1)?.loading).toBe(false);
|
|
expect(snapshots.at(-1)?.summariesByTaskId['task-1']?.changeSet).not.toBeNull();
|
|
expect(hoisted.recordTaskChangePresence).not.toHaveBeenCalled();
|
|
expect(hoisted.setSelectedTeamTaskChangePresence).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('caches has_changes for low-confidence summaries with safe file details', async () => {
|
|
hoisted.getTeamTaskChangeSummaries.mockResolvedValue(lowConfidenceFileResponse());
|
|
|
|
const snapshots: HookSnapshot[] = [];
|
|
const onSnapshot = (snapshot: HookSnapshot): void => {
|
|
snapshots.push(snapshot);
|
|
};
|
|
container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root?.render(React.createElement(HookHarness, { tasks: [task()], onSnapshot }));
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(snapshots.at(-1)?.summariesByTaskId['task-1']?.changeSet?.confidence).toBe('low');
|
|
expect(hoisted.recordTaskChangePresence).toHaveBeenCalledWith(
|
|
'team-a',
|
|
'task-1',
|
|
expect.any(Object),
|
|
'has_changes'
|
|
);
|
|
expect(hoisted.setSelectedTeamTaskChangePresence).toHaveBeenCalledWith(
|
|
'team-a',
|
|
'task-1',
|
|
'has_changes'
|
|
);
|
|
});
|
|
|
|
it('does not cache presence for summaries with unsafe file details', async () => {
|
|
hoisted.getTeamTaskChangeSummaries.mockResolvedValue(invalidFileSummaryResponse());
|
|
|
|
const snapshots: HookSnapshot[] = [];
|
|
const onSnapshot = (snapshot: HookSnapshot): void => {
|
|
snapshots.push(snapshot);
|
|
};
|
|
container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root?.render(React.createElement(HookHarness, { tasks: [task()], onSnapshot }));
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(snapshots.at(-1)?.summariesByTaskId['task-1']?.changeSet).not.toBeNull();
|
|
expect(hoisted.recordTaskChangePresence).not.toHaveBeenCalled();
|
|
expect(hoisted.setSelectedTeamTaskChangePresence).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('renders legacy malformed summaries without crashing the section', async () => {
|
|
hoisted.getTeamTaskChangeSummaries.mockResolvedValue(response(malformedLegacyChangeSet()));
|
|
|
|
const scrollIntoViewDescriptor = Object.getOwnPropertyDescriptor(
|
|
Element.prototype,
|
|
'scrollIntoView'
|
|
);
|
|
Object.defineProperty(Element.prototype, 'scrollIntoView', {
|
|
configurable: true,
|
|
value: vi.fn(),
|
|
});
|
|
try {
|
|
container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root?.render(
|
|
React.createElement(
|
|
TooltipProvider,
|
|
null,
|
|
React.createElement(TeamChangesSection, {
|
|
teamName: 'team-a',
|
|
tasks: [task()],
|
|
onOpenTask: vi.fn(),
|
|
onViewChanges: vi.fn(),
|
|
})
|
|
)
|
|
);
|
|
});
|
|
|
|
const expandButton = container.querySelector<HTMLButtonElement>(
|
|
'button[aria-label="Expand section"]'
|
|
);
|
|
expect(expandButton).not.toBeNull();
|
|
|
|
await act(async () => {
|
|
expandButton?.click();
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(container.textContent).toContain('legacy warning');
|
|
expect(container.textContent).toContain(
|
|
'The change summary reported one file without safe review details.'
|
|
);
|
|
expect(hoisted.recordTaskChangePresence).not.toHaveBeenCalled();
|
|
} finally {
|
|
if (scrollIntoViewDescriptor) {
|
|
Object.defineProperty(Element.prototype, 'scrollIntoView', scrollIntoViewDescriptor);
|
|
} else {
|
|
delete (Element.prototype as { scrollIntoView?: Element['scrollIntoView'] }).scrollIntoView;
|
|
}
|
|
}
|
|
});
|
|
|
|
it('does not render active no-log summaries as Changes warnings', async () => {
|
|
hoisted.getTeamTaskChangeSummaries.mockResolvedValue(response(quietNoLogChangeSet()));
|
|
|
|
const scrollIntoViewDescriptor = Object.getOwnPropertyDescriptor(
|
|
Element.prototype,
|
|
'scrollIntoView'
|
|
);
|
|
Object.defineProperty(Element.prototype, 'scrollIntoView', {
|
|
configurable: true,
|
|
value: vi.fn(),
|
|
});
|
|
try {
|
|
container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root?.render(
|
|
React.createElement(
|
|
TooltipProvider,
|
|
null,
|
|
React.createElement(TeamChangesSection, {
|
|
teamName: 'team-a',
|
|
tasks: [task({ status: 'in_progress', changePresence: 'needs_attention' })],
|
|
onOpenTask: vi.fn(),
|
|
onViewChanges: vi.fn(),
|
|
})
|
|
)
|
|
);
|
|
});
|
|
|
|
const expandButton = container.querySelector<HTMLButtonElement>(
|
|
'button[aria-label="Expand section"]'
|
|
);
|
|
expect(expandButton).not.toBeNull();
|
|
|
|
await act(async () => {
|
|
expandButton?.click();
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(container.textContent).toContain('No file changes recorded');
|
|
expect(container.textContent).not.toContain('No log files found for this task.');
|
|
expect(hoisted.recordTaskChangePresence).not.toHaveBeenCalled();
|
|
expect(hoisted.setSelectedTeamTaskChangePresence).toHaveBeenCalledWith(
|
|
'team-a',
|
|
'task-1',
|
|
'unknown'
|
|
);
|
|
} finally {
|
|
if (scrollIntoViewDescriptor) {
|
|
Object.defineProperty(Element.prototype, 'scrollIntoView', scrollIntoViewDescriptor);
|
|
} else {
|
|
delete (Element.prototype as { scrollIntoView?: Element['scrollIntoView'] }).scrollIntoView;
|
|
}
|
|
}
|
|
});
|
|
|
|
it('hides work-interval scoping advisories in the compact Changes list when files are present', async () => {
|
|
hoisted.getTeamTaskChangeSummaries.mockResolvedValue(intervalScopedFileResponse());
|
|
|
|
const scrollIntoViewDescriptor = Object.getOwnPropertyDescriptor(
|
|
Element.prototype,
|
|
'scrollIntoView'
|
|
);
|
|
Object.defineProperty(Element.prototype, 'scrollIntoView', {
|
|
configurable: true,
|
|
value: vi.fn(),
|
|
});
|
|
try {
|
|
container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root?.render(
|
|
React.createElement(
|
|
TooltipProvider,
|
|
null,
|
|
React.createElement(TeamChangesSection, {
|
|
teamName: 'team-a',
|
|
tasks: [task({ status: 'completed', owner: 'jack' })],
|
|
onOpenTask: vi.fn(),
|
|
onViewChanges: vi.fn(),
|
|
})
|
|
)
|
|
);
|
|
});
|
|
|
|
const expandButton = container.querySelector<HTMLButtonElement>(
|
|
'button[aria-label="Expand section"]'
|
|
);
|
|
expect(expandButton).not.toBeNull();
|
|
|
|
await act(async () => {
|
|
expandButton?.click();
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(container.textContent).toContain('791/calculator.js');
|
|
expect(container.textContent).not.toContain(
|
|
'Task start boundary missing - scoped by persisted workIntervals timestamps.'
|
|
);
|
|
} finally {
|
|
if (scrollIntoViewDescriptor) {
|
|
Object.defineProperty(Element.prototype, 'scrollIntoView', scrollIntoViewDescriptor);
|
|
} else {
|
|
delete (Element.prototype as { scrollIntoView?: Element['scrollIntoView'] }).scrollIntoView;
|
|
}
|
|
}
|
|
});
|
|
|
|
it('keeps unrelated file warnings visible in the compact Changes list', async () => {
|
|
hoisted.getTeamTaskChangeSummaries.mockResolvedValue(warningFileResponse());
|
|
|
|
const scrollIntoViewDescriptor = Object.getOwnPropertyDescriptor(
|
|
Element.prototype,
|
|
'scrollIntoView'
|
|
);
|
|
Object.defineProperty(Element.prototype, 'scrollIntoView', {
|
|
configurable: true,
|
|
value: vi.fn(),
|
|
});
|
|
try {
|
|
container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root?.render(
|
|
React.createElement(
|
|
TooltipProvider,
|
|
null,
|
|
React.createElement(TeamChangesSection, {
|
|
teamName: 'team-a',
|
|
tasks: [task({ status: 'completed' })],
|
|
onOpenTask: vi.fn(),
|
|
onViewChanges: vi.fn(),
|
|
})
|
|
)
|
|
);
|
|
});
|
|
|
|
const expandButton = container.querySelector<HTMLButtonElement>(
|
|
'button[aria-label="Expand section"]'
|
|
);
|
|
expect(expandButton).not.toBeNull();
|
|
|
|
await act(async () => {
|
|
expandButton?.click();
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(container.textContent).toContain('src/app.ts');
|
|
expect(container.textContent).toContain('Unexpected ledger warning.');
|
|
} finally {
|
|
if (scrollIntoViewDescriptor) {
|
|
Object.defineProperty(Element.prototype, 'scrollIntoView', scrollIntoViewDescriptor);
|
|
} else {
|
|
delete (Element.prototype as { scrollIntoView?: Element['scrollIntoView'] }).scrollIntoView;
|
|
}
|
|
}
|
|
});
|
|
|
|
it('does not clear completed task presence from an uncertain empty summary', async () => {
|
|
hoisted.getTeamTaskChangeSummaries.mockResolvedValue(response(quietNoLogChangeSet()));
|
|
|
|
container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root?.render(
|
|
React.createElement(HookHarness, {
|
|
tasks: [task({ status: 'completed', changePresence: 'needs_attention' })],
|
|
onSnapshot: vi.fn(),
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(hoisted.recordTaskChangePresence).not.toHaveBeenCalled();
|
|
expect(hoisted.setSelectedTeamTaskChangePresence).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('clears stale selected presence for newly created pending tasks without logs', async () => {
|
|
hoisted.getTeamTaskChangeSummaries.mockResolvedValue(response(quietNoLogChangeSet()));
|
|
|
|
container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root?.render(
|
|
React.createElement(HookHarness, {
|
|
tasks: [task({ status: 'pending', changePresence: 'needs_attention' })],
|
|
onSnapshot: vi.fn(),
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(hoisted.recordTaskChangePresence).not.toHaveBeenCalled();
|
|
expect(hoisted.setSelectedTeamTaskChangePresence).toHaveBeenCalledWith(
|
|
'team-a',
|
|
'task-1',
|
|
'unknown'
|
|
);
|
|
});
|
|
|
|
it('uses the first duplicate task id when deciding whether to clear stale presence', async () => {
|
|
hoisted.getTeamTaskChangeSummaries.mockResolvedValue(response(quietNoLogChangeSet()));
|
|
|
|
container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root?.render(
|
|
React.createElement(HookHarness, {
|
|
tasks: [
|
|
task({ status: 'completed', changePresence: 'needs_attention' }),
|
|
task({ status: 'in_progress', changePresence: 'needs_attention' }),
|
|
],
|
|
onSnapshot: vi.fn(),
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(hoisted.recordTaskChangePresence).not.toHaveBeenCalled();
|
|
expect(hoisted.setSelectedTeamTaskChangePresence).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not clear task presence from malformed unknown summaries', async () => {
|
|
hoisted.getTeamTaskChangeSummaries.mockResolvedValue(response(malformedUnknownChangeSet()));
|
|
|
|
container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root?.render(
|
|
React.createElement(HookHarness, {
|
|
tasks: [task({ status: 'in_progress', changePresence: 'needs_attention' })],
|
|
onSnapshot: vi.fn(),
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(hoisted.recordTaskChangePresence).not.toHaveBeenCalled();
|
|
expect(hoisted.setSelectedTeamTaskChangePresence).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('shows the closed-section counter only after the background count load resolves', async () => {
|
|
const deferred = createDeferred<TeamTaskChangeSummariesResponse>();
|
|
hoisted.getTeamTaskChangeSummaries.mockReturnValue(deferred.promise);
|
|
|
|
container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root?.render(
|
|
React.createElement(
|
|
TooltipProvider,
|
|
null,
|
|
React.createElement(TeamChangesSection, {
|
|
teamName: 'team-a',
|
|
tasks: [task()],
|
|
onOpenTask: vi.fn(),
|
|
onViewChanges: vi.fn(),
|
|
})
|
|
)
|
|
);
|
|
});
|
|
|
|
expect(container.textContent).toContain('Changes');
|
|
expect(container.textContent).not.toContain('0');
|
|
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(1);
|
|
|
|
await act(async () => {
|
|
deferred.resolve(response());
|
|
await deferred.promise;
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(container.textContent).toContain('0');
|
|
});
|
|
|
|
it('loads the closed-section counter without rendering full change rows', async () => {
|
|
hoisted.getTeamTaskChangeSummaries.mockResolvedValue(lowConfidenceFileResponse());
|
|
|
|
container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root?.render(
|
|
React.createElement(
|
|
TooltipProvider,
|
|
null,
|
|
React.createElement(TeamChangesSection, {
|
|
teamName: 'team-a',
|
|
tasks: [task()],
|
|
onOpenTask: vi.fn(),
|
|
onViewChanges: vi.fn(),
|
|
})
|
|
)
|
|
);
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(1);
|
|
expect(container.textContent).toContain('1');
|
|
expect(container.textContent).not.toContain('src/app.ts');
|
|
});
|
|
|
|
it('loads staged open batches without repeating successful tasks', async () => {
|
|
const first = createDeferred<TeamTaskChangeSummariesResponse>();
|
|
const second = createDeferred<TeamTaskChangeSummariesResponse>();
|
|
const third = createDeferred<TeamTaskChangeSummariesResponse>();
|
|
hoisted.getTeamTaskChangeSummaries
|
|
.mockReturnValueOnce(first.promise)
|
|
.mockReturnValueOnce(second.promise)
|
|
.mockReturnValueOnce(third.promise);
|
|
|
|
const tasks = changedTasks(30);
|
|
const snapshots: HookSnapshot[] = [];
|
|
const onSnapshot = (snapshot: HookSnapshot): void => {
|
|
snapshots.push(snapshot);
|
|
};
|
|
container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root?.render(React.createElement(HookHarness, { tasks, onSnapshot }));
|
|
});
|
|
|
|
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(1);
|
|
const firstRequests = hoisted.getTeamTaskChangeSummaries.mock
|
|
.calls[0][1] as TeamTaskChangeSummaryRequest[];
|
|
expect(firstRequests).toHaveLength(3);
|
|
expect(firstRequests[0]?.taskId).toBe('changed-29');
|
|
|
|
await act(async () => {
|
|
first.resolve(responseForRequests(firstRequests));
|
|
await first.promise;
|
|
await Promise.resolve();
|
|
});
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(2);
|
|
const secondRequests = hoisted.getTeamTaskChangeSummaries.mock
|
|
.calls[1][1] as TeamTaskChangeSummaryRequest[];
|
|
expect(secondRequests).toHaveLength(9);
|
|
expect(Object.keys(snapshots.at(-1)?.summariesByTaskId ?? {})).toHaveLength(3);
|
|
expect(snapshots.at(-1)?.loading).toBe(false);
|
|
expect(snapshots.at(-1)?.refreshing).toBe(true);
|
|
expect(
|
|
secondRequests.some((request) =>
|
|
firstRequests.some((firstRequest) => firstRequest.taskId === request.taskId)
|
|
)
|
|
).toBe(false);
|
|
|
|
await act(async () => {
|
|
second.resolve(responseForRequests(secondRequests));
|
|
await second.promise;
|
|
await Promise.resolve();
|
|
});
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(3);
|
|
const thirdRequests = hoisted.getTeamTaskChangeSummaries.mock
|
|
.calls[2][1] as TeamTaskChangeSummaryRequest[];
|
|
expect(thirdRequests).toHaveLength(18);
|
|
expect(
|
|
thirdRequests.some((request) =>
|
|
[...firstRequests, ...secondRequests].some(
|
|
(previousRequest) => previousRequest.taskId === request.taskId
|
|
)
|
|
)
|
|
).toBe(false);
|
|
|
|
await act(async () => {
|
|
third.resolve(responseForRequests(thirdRequests));
|
|
await third.promise;
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(Object.keys(snapshots.at(-1)?.summariesByTaskId ?? {})).toHaveLength(30);
|
|
expect(snapshots.at(-1)?.loading).toBe(false);
|
|
});
|
|
|
|
it('does not skip failed first-pass tasks from the queued full refresh', async () => {
|
|
const first = createDeferred<TeamTaskChangeSummariesResponse>();
|
|
const second = createDeferred<TeamTaskChangeSummariesResponse>();
|
|
const third = createDeferred<TeamTaskChangeSummariesResponse>();
|
|
hoisted.getTeamTaskChangeSummaries
|
|
.mockReturnValueOnce(first.promise)
|
|
.mockReturnValueOnce(second.promise)
|
|
.mockReturnValueOnce(third.promise);
|
|
|
|
const tasks = changedTasks(30);
|
|
const snapshots: HookSnapshot[] = [];
|
|
const onSnapshot = (snapshot: HookSnapshot): void => {
|
|
snapshots.push(snapshot);
|
|
};
|
|
container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root?.render(React.createElement(HookHarness, { tasks, onSnapshot }));
|
|
});
|
|
|
|
const firstRequests = hoisted.getTeamTaskChangeSummaries.mock
|
|
.calls[0][1] as TeamTaskChangeSummaryRequest[];
|
|
expect(firstRequests).toHaveLength(3);
|
|
const failedTaskId = firstRequests[0]?.taskId ?? '';
|
|
|
|
await act(async () => {
|
|
first.resolve({
|
|
teamName: 'team-a',
|
|
computedAt: '2026-05-10T10:00:01.000Z',
|
|
items: firstRequests.map((request, index) =>
|
|
index === 0
|
|
? { taskId: request.taskId, changeSet: null, error: 'first pass failed' }
|
|
: { taskId: request.taskId, changeSet: changeSet(request.taskId) }
|
|
),
|
|
});
|
|
await first.promise;
|
|
await Promise.resolve();
|
|
});
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(2);
|
|
const secondRequests = hoisted.getTeamTaskChangeSummaries.mock
|
|
.calls[1][1] as TeamTaskChangeSummaryRequest[];
|
|
expect(secondRequests).toHaveLength(9);
|
|
expect(secondRequests.some((request) => request.taskId === failedTaskId)).toBe(true);
|
|
expect(secondRequests.some((request) => request.taskId === firstRequests[1]?.taskId)).toBe(
|
|
false
|
|
);
|
|
|
|
await act(async () => {
|
|
second.resolve(responseForRequests(secondRequests));
|
|
await second.promise;
|
|
await Promise.resolve();
|
|
});
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(3);
|
|
const thirdRequests = hoisted.getTeamTaskChangeSummaries.mock
|
|
.calls[2][1] as TeamTaskChangeSummaryRequest[];
|
|
expect(thirdRequests).toHaveLength(19);
|
|
|
|
await act(async () => {
|
|
third.resolve(responseForRequests(thirdRequests));
|
|
await third.promise;
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(Object.keys(snapshots.at(-1)?.summariesByTaskId ?? {})).toHaveLength(30);
|
|
});
|
|
|
|
it('does not apply staged in-flight results after the section closes', async () => {
|
|
const first = createDeferred<TeamTaskChangeSummariesResponse>();
|
|
const second = createDeferred<TeamTaskChangeSummariesResponse>();
|
|
hoisted.getTeamTaskChangeSummaries
|
|
.mockReturnValueOnce(first.promise)
|
|
.mockReturnValueOnce(second.promise);
|
|
|
|
const tasks = changedTasks(30);
|
|
const snapshots: HookSnapshot[] = [];
|
|
const onSnapshot = (snapshot: HookSnapshot): void => {
|
|
snapshots.push(snapshot);
|
|
};
|
|
container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root?.render(React.createElement(HookHarness, { tasks, onSnapshot }));
|
|
});
|
|
const firstRequests = hoisted.getTeamTaskChangeSummaries.mock
|
|
.calls[0][1] as TeamTaskChangeSummaryRequest[];
|
|
|
|
await act(async () => {
|
|
first.resolve(responseForRequests(firstRequests));
|
|
await first.promise;
|
|
await Promise.resolve();
|
|
});
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(2);
|
|
expect(Object.keys(snapshots.at(-1)?.summariesByTaskId ?? {})).toHaveLength(3);
|
|
const secondRequests = hoisted.getTeamTaskChangeSummaries.mock
|
|
.calls[1][1] as TeamTaskChangeSummaryRequest[];
|
|
|
|
await act(async () => {
|
|
root?.render(
|
|
React.createElement(HookHarness, {
|
|
tasks: [],
|
|
sectionOpen: false,
|
|
onSnapshot,
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
await act(async () => {
|
|
second.resolve(responseForRequests(secondRequests));
|
|
await second.promise;
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(snapshots.at(-1)?.loading).toBe(false);
|
|
expect(snapshots.at(-1)?.refreshing).toBe(false);
|
|
expect(snapshots.at(-1)?.summariesByTaskId).toEqual({});
|
|
});
|
|
|
|
it('starts force refresh from the first staged batch after a completed staged load', async () => {
|
|
const first = createDeferred<TeamTaskChangeSummariesResponse>();
|
|
const second = createDeferred<TeamTaskChangeSummariesResponse>();
|
|
const third = createDeferred<TeamTaskChangeSummariesResponse>();
|
|
const fourth = createDeferred<TeamTaskChangeSummariesResponse>();
|
|
hoisted.getTeamTaskChangeSummaries
|
|
.mockReturnValueOnce(first.promise)
|
|
.mockReturnValueOnce(second.promise)
|
|
.mockReturnValueOnce(third.promise)
|
|
.mockReturnValueOnce(fourth.promise);
|
|
|
|
const tasks = changedTasks(30);
|
|
const snapshots: HookSnapshot[] = [];
|
|
const onSnapshot = (snapshot: HookSnapshot): void => {
|
|
snapshots.push(snapshot);
|
|
};
|
|
container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root?.render(React.createElement(HookHarness, { tasks, onSnapshot }));
|
|
});
|
|
const firstRequests = hoisted.getTeamTaskChangeSummaries.mock
|
|
.calls[0][1] as TeamTaskChangeSummaryRequest[];
|
|
|
|
await act(async () => {
|
|
first.resolve(responseForRequests(firstRequests));
|
|
await first.promise;
|
|
await Promise.resolve();
|
|
});
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
});
|
|
const secondRequests = hoisted.getTeamTaskChangeSummaries.mock
|
|
.calls[1][1] as TeamTaskChangeSummaryRequest[];
|
|
await act(async () => {
|
|
second.resolve(responseForRequests(secondRequests));
|
|
await second.promise;
|
|
await Promise.resolve();
|
|
});
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
});
|
|
const thirdRequests = hoisted.getTeamTaskChangeSummaries.mock
|
|
.calls[2][1] as TeamTaskChangeSummaryRequest[];
|
|
await act(async () => {
|
|
third.resolve(responseForRequests(thirdRequests));
|
|
await third.promise;
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(Object.keys(snapshots.at(-1)?.summariesByTaskId ?? {})).toHaveLength(30);
|
|
|
|
await act(async () => {
|
|
snapshots.at(-1)?.refresh();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(4);
|
|
const refreshRequests = hoisted.getTeamTaskChangeSummaries.mock
|
|
.calls[3][1] as TeamTaskChangeSummaryRequest[];
|
|
expect(refreshRequests).toHaveLength(3);
|
|
expect(refreshRequests.map((request) => request.taskId)).toEqual(
|
|
firstRequests.map((request) => request.taskId)
|
|
);
|
|
expect(refreshRequests.every((request) => request.options?.forceFresh === true)).toBe(true);
|
|
|
|
await act(async () => {
|
|
fourth.resolve(responseForRequests(refreshRequests));
|
|
await fourth.promise;
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('runs a queued closed counter refresh when tasks change during an active count load', async () => {
|
|
const first = createDeferred<TeamTaskChangeSummariesResponse>();
|
|
const second = createDeferred<TeamTaskChangeSummariesResponse>();
|
|
hoisted.getTeamTaskChangeSummaries
|
|
.mockReturnValueOnce(first.promise)
|
|
.mockReturnValueOnce(second.promise);
|
|
|
|
container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root?.render(
|
|
React.createElement(
|
|
TooltipProvider,
|
|
null,
|
|
React.createElement(TeamChangesSection, {
|
|
teamName: 'team-a',
|
|
tasks: [task()],
|
|
onOpenTask: vi.fn(),
|
|
onViewChanges: vi.fn(),
|
|
})
|
|
)
|
|
);
|
|
});
|
|
|
|
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(1);
|
|
|
|
await act(async () => {
|
|
root?.render(
|
|
React.createElement(
|
|
TooltipProvider,
|
|
null,
|
|
React.createElement(TeamChangesSection, {
|
|
teamName: 'team-a',
|
|
tasks: [task({ updatedAt: '2026-05-10T10:00:02.000Z' })],
|
|
onOpenTask: vi.fn(),
|
|
onViewChanges: vi.fn(),
|
|
})
|
|
)
|
|
);
|
|
});
|
|
|
|
await act(async () => {
|
|
first.resolve(lowConfidenceFileResponse());
|
|
await first.promise;
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(2);
|
|
|
|
await act(async () => {
|
|
second.resolve(response());
|
|
await second.promise;
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(container.textContent).toContain('0');
|
|
});
|
|
|
|
it('starts the full load immediately when opening during an active count load', async () => {
|
|
const first = createDeferred<TeamTaskChangeSummariesResponse>();
|
|
const second = createDeferred<TeamTaskChangeSummariesResponse>();
|
|
hoisted.getTeamTaskChangeSummaries
|
|
.mockReturnValueOnce(first.promise)
|
|
.mockReturnValueOnce(second.promise);
|
|
|
|
const scrollIntoViewDescriptor = Object.getOwnPropertyDescriptor(
|
|
Element.prototype,
|
|
'scrollIntoView'
|
|
);
|
|
Object.defineProperty(Element.prototype, 'scrollIntoView', {
|
|
configurable: true,
|
|
value: vi.fn(),
|
|
});
|
|
try {
|
|
container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root?.render(
|
|
React.createElement(
|
|
TooltipProvider,
|
|
null,
|
|
React.createElement(TeamChangesSection, {
|
|
teamName: 'team-a',
|
|
tasks: [task()],
|
|
onOpenTask: vi.fn(),
|
|
onViewChanges: vi.fn(),
|
|
})
|
|
)
|
|
);
|
|
});
|
|
|
|
const expandButton = container.querySelector<HTMLButtonElement>(
|
|
'button[aria-label="Expand section"]'
|
|
);
|
|
expect(expandButton).not.toBeNull();
|
|
|
|
await act(async () => {
|
|
expandButton?.click();
|
|
});
|
|
|
|
expect(container.textContent).toContain('Loading changes...');
|
|
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(2);
|
|
|
|
await act(async () => {
|
|
first.resolve(lowConfidenceFileResponse());
|
|
await first.promise;
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(container.textContent).toContain('Loading changes...');
|
|
expect(container.textContent).not.toContain('src/app.ts');
|
|
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(2);
|
|
|
|
await act(async () => {
|
|
second.resolve(lowConfidenceFileResponse());
|
|
await second.promise;
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(container.textContent).toContain('src/app.ts');
|
|
} finally {
|
|
if (scrollIntoViewDescriptor) {
|
|
Object.defineProperty(Element.prototype, 'scrollIntoView', scrollIntoViewDescriptor);
|
|
} else {
|
|
delete (Element.prototype as { scrollIntoView?: Element['scrollIntoView'] }).scrollIntoView;
|
|
}
|
|
}
|
|
});
|
|
|
|
it('opens the task popup from summary header and keeps diff on the review action', async () => {
|
|
const taskItem = task({ changePresence: 'has_changes' });
|
|
const onOpenTask = vi.fn();
|
|
const onViewChanges = vi.fn();
|
|
hoisted.getTeamTaskChangeSummaries.mockResolvedValue(lowConfidenceFileResponse());
|
|
|
|
const scrollIntoViewDescriptor = Object.getOwnPropertyDescriptor(
|
|
Element.prototype,
|
|
'scrollIntoView'
|
|
);
|
|
Object.defineProperty(Element.prototype, 'scrollIntoView', {
|
|
configurable: true,
|
|
value: vi.fn(),
|
|
});
|
|
try {
|
|
container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root?.render(
|
|
React.createElement(
|
|
TooltipProvider,
|
|
null,
|
|
React.createElement(TeamChangesSection, {
|
|
teamName: 'team-a',
|
|
tasks: [taskItem],
|
|
memberColorMap: new Map([['alice', 'blue']]),
|
|
onOpenTask,
|
|
onViewChanges,
|
|
})
|
|
)
|
|
);
|
|
});
|
|
|
|
const expandButton = container.querySelector<HTMLButtonElement>(
|
|
'button[aria-label="Expand section"]'
|
|
);
|
|
expect(expandButton).not.toBeNull();
|
|
|
|
await act(async () => {
|
|
expandButton?.click();
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(container.querySelector('img')).not.toBeNull();
|
|
|
|
const openTaskButton = container.querySelector<HTMLButtonElement>(
|
|
'button[aria-label="Open task Task 1"]'
|
|
);
|
|
expect(openTaskButton).not.toBeNull();
|
|
|
|
await act(async () => {
|
|
openTaskButton?.click();
|
|
});
|
|
|
|
expect(onOpenTask).toHaveBeenCalledWith(taskItem);
|
|
expect(onViewChanges).not.toHaveBeenCalled();
|
|
|
|
const reviewTaskDiffButton = container.querySelector<HTMLButtonElement>(
|
|
'button[aria-label="Review task diff"]'
|
|
);
|
|
expect(reviewTaskDiffButton).not.toBeNull();
|
|
|
|
await act(async () => {
|
|
reviewTaskDiffButton?.click();
|
|
});
|
|
|
|
expect(onViewChanges).toHaveBeenCalledWith('task-1');
|
|
|
|
onViewChanges.mockClear();
|
|
|
|
const fileRow = container.querySelector<HTMLElement>('[role="button"][title="src/app.ts"]');
|
|
expect(fileRow).not.toBeNull();
|
|
|
|
await act(async () => {
|
|
fileRow?.click();
|
|
});
|
|
|
|
expect(onViewChanges).toHaveBeenCalledTimes(1);
|
|
expect(onViewChanges).toHaveBeenCalledWith('task-1', '/repo/src/app.ts');
|
|
|
|
onViewChanges.mockClear();
|
|
|
|
const reviewFileDiffButton = container.querySelector<HTMLButtonElement>(
|
|
'button[aria-label="Review diff"]'
|
|
);
|
|
expect(reviewFileDiffButton).not.toBeNull();
|
|
|
|
await act(async () => {
|
|
reviewFileDiffButton?.click();
|
|
});
|
|
|
|
expect(onViewChanges).toHaveBeenCalledTimes(1);
|
|
expect(onViewChanges).toHaveBeenCalledWith('task-1', '/repo/src/app.ts');
|
|
} finally {
|
|
if (scrollIntoViewDescriptor) {
|
|
Object.defineProperty(Element.prototype, 'scrollIntoView', scrollIntoViewDescriptor);
|
|
} else {
|
|
delete (Element.prototype as { scrollIntoView?: Element['scrollIntoView'] }).scrollIntoView;
|
|
}
|
|
}
|
|
});
|
|
});
|