Assessor_2.0/tests/comparison/comparison-tab.test.tsx

117 lines
7.8 KiB
TypeScript

// @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(<ComparisonTab projectId="dummy-project" assessmentIds={['assessment-c']} onOpenChange={onOpenChange}/>));
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<HTMLButtonElement>('.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(<ComparisonTab projectId="dummy-project" assessmentIds={['assessment-a']}/>));
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(<ComparisonTab projectId="dummy-project" assessmentIds={['assessment-c']}/>));
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(<ComparisonTab projectId="dummy-project" assessmentIds={['assessment-a']}/>));
await settle();
expect(host.textContent).toContain('only one timeslot');
expect(request).not.toHaveBeenCalled();
});
});