117 lines
7.8 KiB
TypeScript
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();
|
|
});
|
|
});
|