128 lines
9.5 KiB
TypeScript
128 lines
9.5 KiB
TypeScript
// @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'], ['']]);
|
||
});
|