Assessor_2.0/tests/features/work-view-export-interactio...

104 lines
6.6 KiB
TypeScript

// @vitest-environment jsdom
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, expect, it, vi } from 'vitest';
import { AppProvider, useApp } from '../../src/app/AppProvider';
import { AssessorApiClient } from '../../src/api/assessor-api-client';
import type { CanonicalAssessmentModel } from '../../src/domain/canonical-model';
import type { ImportedDataset } from '../../src/domain/dataset';
import { ActionlistControls } from '../../src/features/actionlist/ActionlistControls';
import { loadCanonicalProjectWorkspace } from '../../src/features/project-import/load-canonical-project-workspace';
import { useFilteredRecords } from '../../src/features/search-filter/use-filtered-records';
import { makeSheet } from '../../src/importers/sheet-factory';
import { browserDownloadAdapter } from '../../src/platform/browser/browser-download-adapter';
import * as exporter from '../../src/exports/work-view-export';
vi.mock('../../src/features/project-import/load-canonical-project-workspace', () => ({ loadCanonicalProjectWorkspace: vi.fn() }));
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); localStorage.clear(); });
function Harness() {
const app = useApp();
const records = useFilteredRecords();
return <>
<button data-open onClick={() => void app.openProject('synthetic', ['run'], ['slot'])}>Open</button>
<button data-filter onClick={() => { app.setStatusFilter('failed'); app.setSearch('included'); app.setSortColumn('Order'); }}>Filter</button>
<button data-reverse onClick={() => app.toggleSortDirection()}>Sort</button>
<button data-mark onClick={() => app.updateReview(app.activeSheet!.analysis.records.at(-1)!.id, { include: true })}>Mark other</button>
<button data-workspace onClick={() => app.setActionOnly(true)}>My Workspace</button>
<button data-empty onClick={() => app.setSearch('No matching synthetic record')}>Empty</button>
<output>{records.map(record => record.title).join(',')}</output>
<ActionlistControls/>
</>;
}
async function mount() {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
vi.spyOn(AssessorApiClient.prototype, 'health').mockResolvedValue({ status: 'ok' } as never);
const rows = Array.from({ length: 90 }, (_, i) => [`Included ${String(i).padStart(3, '0')}`, 'Fail', 'Expected synthetic state', 'source-table:42 "Synthetic evidence"', String(i)]);
const sheet = makeSheet('All Records', [['Title', 'Status', 'Expected', 'Results', 'Order'], ...rows,
['Excluded passed', 'Pass', 'Pass target', '', '90'], ['Excluded marked', 'Fail', 'Marked target', '', '91']], '', 'synthetic.html', { isProjectSheet: true, toolId: 'cis-cat', sourceFile: 'synthetic.html' });
const evidence = makeSheet('Synthetic evidence', [['Host', 'State'], ['Synthetic affected host', '0']], '', 'synthetic.html', { sourceFile: 'synthetic.html', sourceTableIndex: 42, toolId: 'cis-cat' });
const dataset = { fileName: 'Synthetic', profileId: 'cis-cat', sheets: [sheet, evidence] } as ImportedDataset;
vi.mocked(loadCanonicalProjectWorkspace).mockResolvedValue({ dataset, assessmentIds: ['slot'], models: [{
project: { id: 'synthetic', name: 'Synthetic' }, assessment: { id: 'slot' }, reviews: []
} as unknown as CanonicalAssessmentModel] });
const container = document.createElement('div');
const root = createRoot(container);
const downloads = vi.spyOn(browserDownloadAdapter, 'download').mockImplementation(() => {});
const projection = vi.spyOn(exporter, 'buildWorkViewExport');
await act(async () => root.render(<AppProvider><Harness/></AppProvider>));
const click = async (selector: string) => act(async () => container.querySelector<HTMLButtonElement>(selector)!.click());
await click('[data-open]');
return { container, downloads, projection, click, close: () => act(async () => root.unmount()) };
}
it.each(['CSV', 'MD', 'HTML'])('exports the current filtered and sorted view, not marked records, with Results using %s', async format => {
const ui = await mount();
try {
await ui.click('[data-filter]');
await ui.click('[data-mark]');
expect(ui.projection).not.toHaveBeenCalled(); // no eager evidence projection on navigation/filtering
const button = [...ui.container.querySelectorAll('button')].find(item => item.textContent === format)!;
expect(button.disabled).toBe(false);
await act(async () => button.click());
expect(ui.downloads).toHaveBeenCalledTimes(1);
const payload = ui.downloads.mock.calls[0][0];
expect(payload.text).toContain('Synthetic affected host');
expect(payload.text).not.toContain('Excluded marked');
expect(payload.text).not.toContain('Excluded passed');
expect(payload.text).toContain('Included 089');
expect(payload.text).toContain('Included 000');
const report = ui.projection.mock.results[0].value as exporter.WorkViewExport;
expect(report.entries.map(entry => entry.title).join(',')).toBe(ui.container.querySelector('output')!.textContent);
expect(report.entries).toHaveLength(90);
expect(report.entries.every(entry => entry.resultRowCount === 1)).toBe(true);
expect(report.context.find(block => block.label === 'Scope')?.value).toContain('slot');
if (format === 'HTML') {
const doc = new DOMParser().parseFromString(payload.text, 'text/html');
expect(doc.querySelectorAll('article')).toHaveLength(90);
expect([...doc.querySelectorAll('table')].every(table => table.querySelectorAll('thead th').length === 2)).toBe(true);
expect(doc.querySelector('script,img,iframe,link')).toBeNull();
}
await ui.click('[data-reverse]');
await act(async () => button.click());
const next = ui.projection.mock.results.at(-1)!.value as exporter.WorkViewExport;
expect(next.entries.map(entry => entry.id)).toEqual([...report.entries].reverse().map(entry => entry.id));
await ui.click('[data-workspace]');
expect(button.disabled).toBe(true); // marked record does not bypass active search
await ui.click('[data-empty]');
expect(button.disabled).toBe(true);
} finally { await ui.close(); }
});
it('disables empty exports and reports a failed complete projection without downloading partial data', async () => {
const ui = await mount();
try {
ui.projection.mockImplementationOnce(() => { throw new Error('synthetic private evidence must not be exposed'); });
const button = [...ui.container.querySelectorAll('button')].find(item => item.textContent === 'CSV')!;
await act(async () => button.click());
expect(ui.downloads).not.toHaveBeenCalled();
expect(document.body.querySelector('[role="dialog"]')?.textContent).toContain('No partial file');
expect(document.body.textContent).not.toContain('private evidence');
} finally { await ui.close(); }
});