214 lines
6.8 KiB
TypeScript
214 lines
6.8 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
|
|
import {
|
|
buildTeamChangeRequestPlan,
|
|
buildTeamChangesTasksFingerprint,
|
|
TEAM_CHANGES_MAX_REQUESTS,
|
|
TEAM_CHANGES_UNKNOWN_SCAN_LIMIT,
|
|
} from '../teamChangesRequestPlan';
|
|
|
|
import type { TeamTaskWithKanban } from '@shared/types';
|
|
|
|
function task(overrides: Partial<TeamTaskWithKanban> & { id: string }): TeamTaskWithKanban {
|
|
const { id, subject, status, ...rest } = overrides;
|
|
return {
|
|
id,
|
|
subject: subject ?? `Task ${id}`,
|
|
status: status ?? 'pending',
|
|
...rest,
|
|
};
|
|
}
|
|
|
|
function changedTasks(count: number): TeamTaskWithKanban[] {
|
|
return Array.from({ length: count }, (_, index) =>
|
|
task({
|
|
id: `changed-${index}`,
|
|
status: 'completed',
|
|
changePresence: 'has_changes',
|
|
updatedAt: `2026-05-09T08:${String(index).padStart(2, '0')}:00.000Z`,
|
|
})
|
|
);
|
|
}
|
|
|
|
describe('buildTeamChangeRequestPlan', () => {
|
|
it('scans unknown pending tasks only when they have work evidence', () => {
|
|
const plan = buildTeamChangeRequestPlan(
|
|
[
|
|
task({ id: 'plain-pending', status: 'pending', changePresence: 'unknown' }),
|
|
task({
|
|
id: 'worked-pending',
|
|
status: 'pending',
|
|
changePresence: 'unknown',
|
|
workIntervals: [{ startedAt: '2026-05-09T08:00:00.000Z' }],
|
|
}),
|
|
],
|
|
0,
|
|
false
|
|
);
|
|
|
|
expect(plan.requests.map((request) => request.taskId)).toEqual(['worked-pending']);
|
|
expect([...plan.eligibleTaskIds]).toEqual(['worked-pending']);
|
|
});
|
|
|
|
it('keeps known changed tasks even when they are currently pending', () => {
|
|
const plan = buildTeamChangeRequestPlan(
|
|
[task({ id: 'known-changed', status: 'pending', changePresence: 'has_changes' })],
|
|
0,
|
|
false
|
|
);
|
|
|
|
expect(plan.requests.map((request) => request.taskId)).toEqual(['known-changed']);
|
|
expect(plan.eligibleTaskIds.has('known-changed')).toBe(true);
|
|
});
|
|
|
|
it('skips deleted and duplicate tasks before counting candidates', () => {
|
|
const plan = buildTeamChangeRequestPlan(
|
|
[
|
|
task({ id: 'changed', status: 'completed', changePresence: 'has_changes' }),
|
|
task({ id: 'changed', status: 'completed', changePresence: 'has_changes' }),
|
|
task({ id: 'deleted', status: 'deleted', changePresence: 'has_changes' }),
|
|
],
|
|
0,
|
|
false
|
|
);
|
|
|
|
expect(plan.requests.map((request) => request.taskId)).toEqual(['changed']);
|
|
expect(plan.eligibleCount).toBe(1);
|
|
expect(plan.deferredCount).toBe(0);
|
|
});
|
|
|
|
it('caps selected requests and reports deferred candidates', () => {
|
|
const plan = buildTeamChangeRequestPlan(changedTasks(TEAM_CHANGES_MAX_REQUESTS + 5), 0, false);
|
|
|
|
expect(plan.requests).toHaveLength(TEAM_CHANGES_MAX_REQUESTS);
|
|
expect(plan.eligibleCount).toBe(TEAM_CHANGES_MAX_REQUESTS + 5);
|
|
expect(plan.deferredCount).toBe(5);
|
|
});
|
|
|
|
it('supports smaller first-pass caps without changing eligible counts', () => {
|
|
const plan = buildTeamChangeRequestPlan(changedTasks(30), 0, false, {
|
|
maxRequests: 12,
|
|
unknownScanLimit: 6,
|
|
});
|
|
|
|
expect(plan.requests).toHaveLength(12);
|
|
expect(plan.eligibleCount).toBe(30);
|
|
expect(plan.deferredCount).toBe(18);
|
|
});
|
|
|
|
it('skips satisfied candidates without counting them as deferred', () => {
|
|
const tasks = changedTasks(30);
|
|
const satisfiedTaskIds = new Set(
|
|
Array.from({ length: 12 }, (_, index) => `changed-${29 - index}`)
|
|
);
|
|
|
|
const plan = buildTeamChangeRequestPlan(tasks, 0, false, { satisfiedTaskIds });
|
|
|
|
expect(plan.requests).toHaveLength(18);
|
|
expect(plan.requests.some((request) => satisfiedTaskIds.has(request.taskId))).toBe(false);
|
|
expect(plan.eligibleCount).toBe(30);
|
|
expect(plan.deferredCount).toBe(0);
|
|
expect([...satisfiedTaskIds].every((taskId) => plan.eligibleTaskIds.has(taskId))).toBe(true);
|
|
});
|
|
|
|
it('keeps forceFresh request options while skipping same-chain satisfied candidates', () => {
|
|
const tasks = changedTasks(30);
|
|
const satisfiedTaskIds = new Set(['changed-29', 'changed-28', 'changed-27']);
|
|
|
|
const plan = buildTeamChangeRequestPlan(tasks, 0, true, {
|
|
maxRequests: 9,
|
|
satisfiedTaskIds,
|
|
});
|
|
|
|
expect(plan.requests).toHaveLength(9);
|
|
expect(plan.requests.some((request) => satisfiedTaskIds.has(request.taskId))).toBe(false);
|
|
expect(plan.requests.every((request) => request.options?.forceFresh === true)).toBe(true);
|
|
});
|
|
|
|
it('rotates unknown scans and preserves summary-only request options', () => {
|
|
const tasks = Array.from({ length: TEAM_CHANGES_UNKNOWN_SCAN_LIMIT + 4 }, (_, index) =>
|
|
task({
|
|
id: `task-${index}`,
|
|
status: 'completed',
|
|
changePresence: 'unknown',
|
|
updatedAt: `2026-05-09T08:${String(index).padStart(2, '0')}:00.000Z`,
|
|
})
|
|
);
|
|
|
|
const firstPass = buildTeamChangeRequestPlan(tasks, 0, true);
|
|
const secondPass = buildTeamChangeRequestPlan(tasks, firstPass.nextUnknownScanCursor, false);
|
|
|
|
expect(firstPass.requests).toHaveLength(TEAM_CHANGES_UNKNOWN_SCAN_LIMIT);
|
|
expect(firstPass.requests[0].options?.summaryOnly).toBe(true);
|
|
expect(firstPass.requests[0].options?.forceFresh).toBe(true);
|
|
expect(secondPass.requests[0].taskId).toBe('task-3');
|
|
});
|
|
|
|
it('changes fingerprint when review state changes without timestamp changes', () => {
|
|
const baseTask = task({
|
|
id: 'reviewing',
|
|
status: 'completed',
|
|
changePresence: 'unknown',
|
|
updatedAt: '2026-05-09T08:00:00.000Z',
|
|
reviewState: 'none',
|
|
});
|
|
|
|
expect(buildTeamChangesTasksFingerprint([baseTask])).not.toBe(
|
|
buildTeamChangesTasksFingerprint([{ ...baseTask, reviewState: 'review' }])
|
|
);
|
|
});
|
|
|
|
it('keeps fingerprint stable for task reorder and irrelevant history events', () => {
|
|
const first = task({
|
|
id: 'task-a',
|
|
status: 'pending',
|
|
historyEvents: [
|
|
{
|
|
id: 'event-created',
|
|
type: 'task_created',
|
|
timestamp: '2026-05-09T08:00:00.000Z',
|
|
status: 'pending',
|
|
},
|
|
{
|
|
id: 'event-owner',
|
|
type: 'owner_changed',
|
|
timestamp: '2026-05-09T08:01:00.000Z',
|
|
from: 'alice',
|
|
to: 'bob',
|
|
},
|
|
],
|
|
});
|
|
const second = task({
|
|
id: 'task-b',
|
|
status: 'completed',
|
|
historyEvents: [
|
|
{
|
|
id: 'event-status',
|
|
type: 'status_changed',
|
|
timestamp: '2026-05-09T08:02:00.000Z',
|
|
from: 'in_progress',
|
|
to: 'completed',
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(buildTeamChangesTasksFingerprint([first, second])).toBe(
|
|
buildTeamChangesTasksFingerprint([
|
|
second,
|
|
{
|
|
...first,
|
|
historyEvents: [
|
|
...(first.historyEvents ?? []),
|
|
{
|
|
id: 'event-owner-2',
|
|
type: 'owner_changed',
|
|
timestamp: '2026-05-09T08:03:00.000Z',
|
|
from: 'bob',
|
|
to: 'carol',
|
|
},
|
|
],
|
|
},
|
|
])
|
|
);
|
|
});
|
|
});
|