// @vitest-environment jsdom import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { AssessorApiClient } from '../../src/api/assessor-api-client'; import type { ImportFileRecord, ScanRun, Assessment } from '../../src/domain/canonical-model'; import type { TimeslotComparisonReport } from '../../src/comparison/timeslot-comparison-report'; import { ComparisonTab } from '../../src/features/comparison/ComparisonTab'; import { resetTimeslotComparisonRequestsForTests } from '../../src/features/comparison/use-timeslot-comparison'; // Client Info `Compare` tab content (mockup A): A/B selection with B as the open timeslot, score with a // labelled delta, change facts that open Work and a source table. Synthetic data only. const assessment = (id: string, effectiveAt: string): Assessment => ({ id, projectId: 'dummy-project', name: id, createdAt: effectiveAt, updatedAt: effectiveAt, effectiveAt }); const run = (id: string, assessmentId: string, at: string): ScanRun => ({ id, assessmentId, sequence: 1, label: id, tool: { id: 'cis-cat', name: 'CIS-CAT Pro' }, importedAt: at }); const file = (runId: string, at: string) => ({ id: `file-${runId}`, scanRunId: runId, fileName: 'synthetic.html', format: 'HTML', sizeBytes: 1, importedAt: at, lastModifiedAt: at, detectedTool: { id: 'cis-cat', name: 'CIS-CAT Pro' } }) as unknown as ImportFileRecord; const dates = { a: '2026-01-10T10:00:00.000Z', b: '2026-02-10T10:00:00.000Z', c: '2026-03-10T10:00:00.000Z' }; const report: TimeslotComparisonReport = { version: '1.0.0', projectId: 'dummy-project', baselineRunIds: ['run-b'], targetRunIds: ['run-c'], sources: [{ sourceId: 'cis-cat', state: 'compared', reasons: [] }, { sourceId: 'maester', state: 'not-comparable', reasons: ['baseline-source-missing'] }], changes: [ { sourceId: 'cis-cat', change: 'resolved', side: 'baseline', recordIds: ['b-1'], counterpartIds: ['c-1'], reasons: [] }, { sourceId: 'cis-cat', change: 'regressed', side: 'target', recordIds: ['c-3'], counterpartIds: [], reasons: [] }, { sourceId: 'maester', change: 'not-comparable', side: 'target', recordIds: ['m-1'], counterpartIds: [], reasons: ['baseline-source-missing'] } ], summary: { resolved: 1, unchanged: 0, 'value-changed': 0, unverified: 0, 'not-comparable': 0, 'not-applicable': 0, new: 0, regressed: 1, targetNotComparable: 1 }, score: { baselineScore: 52.4, targetScore: 55.2, change: 2.8, sources: [ { sourceId: 'cis-cat', weight: 1, method: 'paired-scans', reasons: [], baselineScore: 52.4, targetScore: 55.2, change: 2.8 }, { sourceId: 'maester', weight: 1, method: 'paired-scans', reasons: ['baseline-source-missing'] } ], excludedSources: [{ sourceId: 'maester', reasons: ['baseline-source-missing'] }] }, scanner: {} as TimeslotComparisonReport['scanner'] }; let host: HTMLDivElement; let root: Root; beforeEach(() => { resetTimeslotComparisonRequestsForTests(); vi.spyOn(AssessorApiClient.prototype, 'listProjectImportFiles').mockResolvedValue([file('run-a', dates.a), file('run-b', dates.b), file('run-c', dates.c)]); vi.spyOn(AssessorApiClient.prototype, 'listProjectAssessmentHistory').mockResolvedValue({ assessments: [assessment('assessment-a', dates.a), assessment('assessment-b', dates.b), assessment('assessment-c', dates.c)], scanRuns: [run('run-a', 'assessment-a', dates.a), run('run-b', 'assessment-b', dates.b), run('run-c', 'assessment-c', dates.c)] } as never); vi.spyOn(AssessorApiClient.prototype, 'listProjectGpoBackupStatus').mockResolvedValue({ records: [] } as never); host = document.createElement('div'); document.body.append(host); root = createRoot(host); }); afterEach(async () => { await act(async () => root.unmount()); host.remove(); vi.restoreAllMocks(); }); const settle = () => act(async () => { for (let index = 0; index < 5; index++) await Promise.resolve(); }); describe('Comparison Engine Compare tab', () => { it('compares the open timeslot with the one before it and opens Work from a change', async () => { const request = vi.spyOn(AssessorApiClient.prototype, 'getTimeslotComparison').mockResolvedValue(report); const onOpenChange = vi.fn(); await act(async () => root.render()); await settle(); expect(request).toHaveBeenCalledWith('dummy-project', expect.objectContaining({ assessmentIds: ['assessment-b'] }), expect.objectContaining({ assessmentIds: ['assessment-c'] })); const selects = [...host.querySelectorAll('select')]; expect(selects.map(select => select.value)).toEqual(['timeslot-2026-02-10', 'timeslot-2026-03-10']); expect([...host.querySelectorAll('.comparison-score dd')].map(item => item.textContent)).toEqual(['52', '55']); expect(host.querySelector('.comparison-tile .comparison-delta')?.textContent).toBe('▲ +3 Better'); const facts = [...host.querySelectorAll('.comparison-fact')]; const fact = (label: string) => facts.find(item => item.querySelector('.comparison-fact__label')?.textContent === label)!; expect(fact('Regressed').querySelector('.comparison-fact__value')?.className).toContain('is-worse'); expect(fact('Not comparable').textContent).toContain('1'); expect(fact('New').disabled).toBe(true); await act(async () => fact('Resolved').click()); expect(onOpenChange).toHaveBeenCalledWith('resolved', expect.objectContaining({ id: 'timeslot-2026-02-10' }), expect.objectContaining({ id: 'timeslot-2026-03-10' })); const maester = [...host.querySelectorAll('.comparison-table tbody tr')].find(row => row.textContent?.includes('Maester')); expect(maester?.textContent).toContain('Not in timeslot A'); }); it('explains that the oldest timeslot has nothing before it', async () => { const request = vi.spyOn(AssessorApiClient.prototype, 'getTimeslotComparison'); await act(async () => root.render()); await settle(); expect(host.querySelectorAll('select')[1].value).toBe('timeslot-2026-01-10'); expect(host.textContent).toContain('no earlier timeslot'); expect(request).not.toHaveBeenCalled(); }); it('offers only earlier timeslots as A and requests the new pair on change', async () => { const request = vi.spyOn(AssessorApiClient.prototype, 'getTimeslotComparison').mockResolvedValue(report); await act(async () => root.render()); await settle(); const [baseline] = [...host.querySelectorAll('select')]; expect([...baseline.options].map(option => option.value)).toEqual(['timeslot-2026-01-10', 'timeslot-2026-02-10']); await act(async () => { baseline.value = 'timeslot-2026-01-10'; baseline.dispatchEvent(new Event('change', { bubbles: true })); }); await settle(); expect(request).toHaveBeenLastCalledWith('dummy-project', expect.objectContaining({ assessmentIds: ['assessment-a'] }), expect.objectContaining({ assessmentIds: ['assessment-c'] })); }); it('explains a client with a single timeslot instead of comparing', async () => { vi.spyOn(AssessorApiClient.prototype, 'listProjectImportFiles').mockResolvedValue([file('run-a', dates.a)]); vi.spyOn(AssessorApiClient.prototype, 'listProjectAssessmentHistory').mockResolvedValue({ assessments: [assessment('assessment-a', dates.a)], scanRuns: [run('run-a', 'assessment-a', dates.a)] } as never); const request = vi.spyOn(AssessorApiClient.prototype, 'getTimeslotComparison'); await act(async () => root.render()); await settle(); expect(host.textContent).toContain('only one timeslot'); expect(request).not.toHaveBeenCalled(); }); });