Assessor_2.0/tests/features/results-excel-export.test.tsx

128 lines
9.5 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// @vitest-environment jsdom
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { read, utils } from 'xlsx';
import { FloatingResultsWindow } from '../../src/features/linked-results/FloatingResultsWindow';
import { makeSheet } from '../../src/importers/sheet-factory';
import { buildResultsWindowExcel } from '../../src/exports/results-window-excel';
import { resultsExcelContent } from '../../src/features/linked-results/results-excel-content';
import { downloadBrowserBlob } from '../../src/platform/browser/browser-download-adapter';
vi.mock('../../src/platform/browser/browser-download-adapter', () => ({ downloadBrowserBlob: vi.fn() }));
vi.mock('../../src/app/AppProvider', () => ({ useOptionalApp: () => undefined }));
afterEach(() => { vi.clearAllMocks(); vi.unstubAllGlobals(); });
describe.each(['dark', 'light'])('complete Results Excel (%s)', theme => {
it('keeps every linked, Markdown and HTML row; exports all content or exactly the checked tables', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} });
document.documentElement.dataset.theme = theme;
const data = Array.from({ length: 1001 }, (_, index) => [`OBJECT-${index + 1}`, index === 1000 ? '=literal-evidence' : String(index)]);
const sheet = makeSheet('Full evidence', [['Object', 'Value'], ...data], '', 'synthetic.xlsx');
const markdown = '| Object | Status |\n| --- | --- |\n' + data.map(([name]) => `| ${name} | Failed |`).join('\n');
const html = '<p>Original source note</p><table><thead><tr><th>Object</th></tr></thead><tbody>' + data.map(([name]) => `<tr><td>${name}</td></tr>`).join('') + '</tbody></table><details>Hidden technical source</details>';
const sections = [{ title: 'Markdown evidence', value: markdown }, { title: 'HTML evidence', value: html }, { title: 'Plain evidence', value: 'Complete result prose' }];
const before = JSON.stringify({ sheet, sections });
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host);
try {
await act(async () => root.render(<FloatingResultsWindow id="all-rows" title="Results – Full synthetic result" sheets={[sheet]}
sections={sections} color="" zIndex={1} offset={0} search="OBJECT-1001" onActivate={() => {}} onClose={() => {}} onToggleBookmark={() => {}}/>));
const tables = [...host.querySelectorAll<HTMLTableElement>('table')];
expect(tables).toHaveLength(3);
expect(tables.map(table => table.tBodies[0].rows.length)).toEqual([1001, 1001, 1001]);
const all = host.querySelector<HTMLButtonElement>('[aria-label="Export entire Results to Excel"]')!;
const selected = host.querySelector<HTMLButtonElement>('[aria-label="Export selected tables to Excel"]')!;
expect(all.disabled).toBe(false); expect(selected.disabled).toBe(true);
expect(all.previousElementSibling?.querySelector('svg')).not.toBeNull();
await act(async () => all.click());
expect(downloadBrowserBlob).toHaveBeenCalledOnce();
expect(vi.mocked(downloadBrowserBlob).mock.calls[0][1]).toMatch(/-all\.xlsx$/);
const snapshot = resultsExcelContent(host.querySelector('.results-window-body')!, 'Full synthetic result', sections);
const full = read(buildResultsWindowExcel(snapshot), { type: 'array' });
const tableNames = full.SheetNames.filter(name => /^Results(?: \d+)?$/.test(name));
expect(tableNames).toHaveLength(3);
for (const name of tableNames) {
const rows = utils.sheet_to_json<string[]>(full.Sheets[name], { header: 1 });
expect(rows).toHaveLength(name === 'Results' ? 1006 : 1003); expect(rows[1002][0]).toBe('OBJECT-1001');
expect(rows[0][0]).toContain('Full synthetic result');
}
expect(full.Sheets[tableNames[2]].B1003.t).toBe('s'); expect(full.Sheets[tableNames[2]].B1003.f).toBeUndefined();
expect(full.Sheets.Results.A1006.v).toContain('Complete result prose');
expect(full.SheetNames).not.toContain('Results text');
const overflow = utils.sheet_to_json<string[]>(full.Sheets['Long text'], { header: 1 }).slice(2);
const sourceValue = full.Sheets['Source text'].B4.v;
expect(sourceValue === html ? sourceValue : overflow.filter(row => row[0] === 'Source text' && row[1] === '4' && row[2] === '2').map(row => row[4]).join('')).toBe(html);
expect(full.Sheets.Results.A1006.v).not.toContain('Select table for Excel export');
const checkboxes = [...host.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')];
await act(async () => checkboxes[1].click());
expect(selected.disabled).toBe(false);
await act(async () => selected.click());
expect(vi.mocked(downloadBrowserBlob).mock.calls.at(-1)![1]).toMatch(/-selected\.xlsx$/);
const subset = read(buildResultsWindowExcel(snapshot, new Set([tables[1].dataset.resultTableId!])), { type: 'array' });
expect(subset.SheetNames).toHaveLength(2); expect(subset.SheetNames).not.toContain('Source text');
expect(utils.sheet_to_json(subset.Sheets[subset.SheetNames[0]], { header: 1 })).toHaveLength(1003);
expect(subset.SheetNames[1]).toBe('Export info');
await act(async () => checkboxes[1].click()); expect(selected.disabled).toBe(true);
expect(JSON.stringify({ sheet, sections })).toBe(before);
} finally { await act(async () => root.unmount()); host.remove(); }
}, 30000);
});
it('preserves long Unicode cells, literal formulas, leading zeros and duplicate invalid sheet names without loss', () => {
const long = 'x'.repeat(32766) + '🧪' + 'tail'.repeat(1000);
const content = { title: 'Synthetic', text: long, sourceSections: [], tables: [
{ id: 'a', title: 'Duplicate/[name]'.repeat(4), rows: [['Value'], ['0000123'], ['=1+1'], [long]] },
{ id: 'b', title: 'Duplicate/[name]'.repeat(4), rows: [['Value'], ['Second']] }
] };
const workbook = read(buildResultsWindowExcel(content), { type: 'array' });
expect(new Set(workbook.SheetNames).size).toBe(workbook.SheetNames.length);
expect(workbook.SheetNames.every(name => name.length <= 31 && !/[\\/?:*\[\]]/.test(name))).toBe(true);
const table = workbook.Sheets[workbook.SheetNames[0]];
expect(table.A3.v).toBe('0000123'); expect(table.A4.v).toBe('=1+1'); expect(table.A4.f).toBeUndefined();
const parts = utils.sheet_to_json<string[]>(workbook.Sheets['Long text'], { header: 1 }).slice(2);
expect(parts.filter(row => row[0] === 'Results' && row[1] === '5').map(row => row[4]).join('')).toBe(long);
expect(parts.filter(row => row[0] === 'Results' && row[1] === '8').map(row => row[4]).join('')).toBe(long);
expect(() => buildResultsWindowExcel(content, new Set())).toThrow('Select at least one table');
});
it('keeps the complete export active for a text-only result', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} });
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host);
try {
await act(async () => root.render(<FloatingResultsWindow id="text" title="Results – Text only" sheets={[]} sections={[{ title: 'Details', value: 'No affected objects' }]}
color="" zIndex={1} offset={0} search="" onActivate={() => {}} onClose={() => {}}/>));
const all = host.querySelector<HTMLButtonElement>('[aria-label="Export entire Results to Excel"]')!;
expect(all.disabled).toBe(false);
await act(async () => all.click()); expect(downloadBrowserBlob).toHaveBeenCalledOnce();
expect(host.querySelector<HTMLButtonElement>('[aria-label="Export selected tables to Excel"]')!.disabled).toBe(true);
} finally { await act(async () => root.unmount()); host.remove(); }
});
it('isolates table selection per Results window and disables selective export when a selected table disappears', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} });
const sheet = makeSheet('Synthetic', [['Value'], ['Evidence']], '', 'synthetic.csv');
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host);
const window = (id: string, sheets = [sheet]) => <FloatingResultsWindow key={id} id={id} title={`Results – ${id}`} sheets={sheets}
color="" zIndex={1} offset={0} search="" onActivate={() => {}} onClose={() => {}}/>;
try {
await act(async () => root.render(<>{window('first')}{window('second')}</>));
const dialogs = host.querySelectorAll('[role="dialog"]');
await act(async () => dialogs[0].querySelector<HTMLInputElement>('input')!.click());
const selected = (dialog: Element) => dialog.querySelector<HTMLButtonElement>('[aria-label="Export selected tables to Excel"]')!;
expect(selected(dialogs[0]).disabled).toBe(false); expect(selected(dialogs[1]).disabled).toBe(true);
await act(async () => root.render(<>{window('first', [])}{window('second')}</>));
expect(selected(dialogs[0]).disabled).toBe(true);
} finally { await act(async () => root.unmount()); host.remove(); }
});
it('retains completely empty evidence rows in the spreadsheet extent', () => {
const bytes = buildResultsWindowExcel({ title: 'Empty rows', text: '', sourceSections: [], tables: [
{ id: 'blank', title: 'Evidence', rows: [['Value'], [''], ['Evidence'], ['']] }
] });
const workbook = read(bytes, { type: 'array' });
expect(utils.sheet_to_json(workbook.Sheets.Results, { header: 1, defval: '' })).toEqual([['Empty rows\nEvidence'], ['Value'], [''], ['Evidence'], ['']]);
});