mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 09:19:42 +02:00
Admission counted cells, rows, merges and styles only in worksheets, styles.xml and workbook.xml, so the objects ExcelJS builds per element elsewhere (shared-string runs, fonts, fills, borders, comments, drawings, VML, tables) were bounded only by the inflated-byte caps, and empty stored deflate blocks pad a stream past the ratio cap. createXmlCounter now counts every start tag in every part except the pure-bytes xl/media/<name>.<ext> entries into counts.elements and refuses above LIMITS.maxElements (2,000,000) as element-limit. Parts that read no attributes carry only a trailing '<' between chunks, so long text or binary is never taken for an oversized tag. Very tall sheets now scale only the scroll position: the scroll range maps onto the sheet's whole range and the tile is laid out at real row heights and column widths, with spans clipped at the spacer, instead of dividing every cell and heading by the scale.
481 lines
21 KiB
TypeScript
481 lines
21 KiB
TypeScript
/**
|
|
* @fileoverview Spreadsheet renderer lifecycle and virtualization, plus its
|
|
* wiring into the file-preview overlay (panels-ui.js).
|
|
*
|
|
* The renderer runs against a standalone JSDOM window (like i18n-branding.test.ts)
|
|
* rather than the `jsdom` vitest environment. Workbook strings are asserted to
|
|
* land as text, never markup.
|
|
*/
|
|
|
|
import { readFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import vm from 'node:vm';
|
|
import { JSDOM } from 'jsdom';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/spreadsheet-preview.js'), 'utf8');
|
|
const panelsSource = readFileSync(resolve(import.meta.dirname, '../src/web/public/panels-ui.js'), 'utf8');
|
|
|
|
// A real origin: the renderer resolves its fetch URL against `location.href`.
|
|
const dom = new JSDOM('<!doctype html><body></body>', { url: 'http://localhost/' });
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const window = dom.window as any;
|
|
const document = window.document as Document;
|
|
|
|
class WorkerMock {
|
|
static instances: WorkerMock[] = [];
|
|
onmessage: ((event: { data: any }) => void) | null = null;
|
|
onerror: (() => void) | null = null;
|
|
onmessageerror: (() => void) | null = null;
|
|
postMessage = vi.fn();
|
|
terminate = vi.fn();
|
|
constructor(readonly url: string) {
|
|
WorkerMock.instances.push(this);
|
|
}
|
|
emit(data: unknown) {
|
|
this.onmessage?.({ data });
|
|
}
|
|
}
|
|
|
|
function loadRenderer(fetchMock: ReturnType<typeof vi.fn>) {
|
|
const context = vm.createContext({
|
|
window,
|
|
document,
|
|
URL,
|
|
AbortController,
|
|
Worker: WorkerMock,
|
|
ResizeObserver: undefined,
|
|
fetch: fetchMock,
|
|
setTimeout,
|
|
clearTimeout,
|
|
console,
|
|
});
|
|
vm.runInContext(source, context);
|
|
return (window as any).CodemanSpreadsheetPreview as { open(options: Record<string, unknown>): { dispose(): void } };
|
|
}
|
|
|
|
function metadata() {
|
|
return {
|
|
type: 'metadata',
|
|
sheets: [
|
|
{
|
|
id: '1',
|
|
name: '<Summary>',
|
|
rows: 1_000_000,
|
|
cols: 100,
|
|
defaultRowHeight: 20,
|
|
defaultColumnWidth: 64,
|
|
rowOverrides: [],
|
|
columnOverrides: [],
|
|
},
|
|
{ id: '2', name: 'Details', rows: 1, cols: 1, defaultRowHeight: 20, defaultColumnWidth: 64 },
|
|
],
|
|
};
|
|
}
|
|
|
|
describe('spreadsheet preview renderer', () => {
|
|
beforeEach(() => {
|
|
WorkerMock.instances = [];
|
|
document.body.innerHTML = '<div id="preview"></div>';
|
|
delete (window as any).CodemanSpreadsheetPreview;
|
|
window.requestAnimationFrame = (callback: FrameRequestCallback) =>
|
|
setTimeout(() => callback(0), 0) as unknown as number;
|
|
window.cancelAnimationFrame = (id: number) => clearTimeout(id);
|
|
});
|
|
|
|
it('refuses oversized metadata before fetch or worker creation', () => {
|
|
const fetchMock = vi.fn();
|
|
const renderer = loadRenderer(fetchMock);
|
|
renderer.open({ container: document.querySelector('#preview'), url: '/book.xlsx', size: 10 * 1024 * 1024 + 1 });
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
expect(WorkerMock.instances).toHaveLength(0);
|
|
expect(document.body.textContent).toContain('10 MB limit');
|
|
});
|
|
|
|
it('fetches same-origin preview bytes, transfers them, and bounds rendered cells', async () => {
|
|
const bytes = new ArrayBuffer(8);
|
|
const fetchMock = vi.fn(async () => ({ ok: true, arrayBuffer: async () => bytes }));
|
|
const renderer = loadRenderer(fetchMock);
|
|
renderer.open({ container: document.querySelector('#preview'), url: '/api/book.xlsx', size: 8 });
|
|
const worker = WorkerMock.instances[0];
|
|
worker.emit({ type: 'ready' });
|
|
await vi.waitFor(() => expect(worker.postMessage).toHaveBeenCalledWith({ type: 'load', bytes }, [bytes]));
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'/api/book.xlsx?preview=true',
|
|
expect.objectContaining({ signal: expect.anything() })
|
|
);
|
|
|
|
worker.emit(metadata());
|
|
const tileRequest = worker.postMessage.mock.calls.at(-1)?.[0];
|
|
expect(tileRequest.type).toBe('tile');
|
|
expect(tileRequest.range.r2 - tileRequest.range.r1).toBeLessThan(100);
|
|
worker.emit({
|
|
type: 'tile',
|
|
requestId: tileRequest.requestId,
|
|
sheetId: '1',
|
|
cells: Array.from({ length: 3000 }, (_, index) => ({
|
|
row: index + 1,
|
|
col: 1,
|
|
text: `<b>${index}</b>`,
|
|
styleId: 0,
|
|
})),
|
|
});
|
|
expect(document.querySelectorAll('.spreadsheet-cell')).toHaveLength(2500);
|
|
expect(document.querySelector('.spreadsheet-cell')?.textContent).toBe('<b>0</b>');
|
|
expect((document.querySelector('.spreadsheet-cell') as HTMLElement).style.top).toBe('20px');
|
|
expect((document.querySelector('.spreadsheet-cell') as HTMLElement).style.left).toBe('36px');
|
|
const grid = document.querySelector('.spreadsheet-grid') as HTMLElement;
|
|
grid.scrollLeft = 400;
|
|
grid.scrollTop = 300;
|
|
grid.dispatchEvent(new window.Event('scroll'));
|
|
expect((document.querySelector('.spreadsheet-row-heading') as HTMLElement).style.left).toBe('400px');
|
|
expect((document.querySelector('.spreadsheet-column-heading') as HTMLElement).style.top).toBe('300px');
|
|
expect(document.querySelector('.spreadsheet-sheet-tabs')?.hasAttribute('data-i18n-skip')).toBe(true);
|
|
expect(document.querySelector('.spreadsheet-grid')?.hasAttribute('data-i18n-skip')).toBe(true);
|
|
expect(Number.parseFloat((document.querySelector('.spreadsheet-grid-spacer') as HTMLElement).style.height)).toBe(
|
|
8_000_020
|
|
);
|
|
(document.querySelectorAll('[role="tab"]')[1] as HTMLButtonElement).click();
|
|
expect((document.querySelector('.spreadsheet-grid-spacer') as HTMLElement).style.height).toBe('40px');
|
|
});
|
|
|
|
it('drops stale tiles and aborts fetch plus terminates worker on dispose', async () => {
|
|
const fetchMock = vi.fn(async () => ({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) }));
|
|
const renderer = loadRenderer(fetchMock);
|
|
const handle = renderer.open({ container: document.querySelector('#preview'), url: '/book.xlsx', size: 8 });
|
|
const worker = WorkerMock.instances[0];
|
|
worker.emit({ type: 'ready' });
|
|
await vi.waitFor(() => expect(worker.postMessage).toHaveBeenCalled());
|
|
worker.emit(metadata());
|
|
const request = worker.postMessage.mock.calls.at(-1)?.[0];
|
|
worker.emit({
|
|
type: 'tile',
|
|
requestId: request.requestId - 1,
|
|
sheetId: '1',
|
|
cells: [{ row: 1, col: 1, text: 'stale' }],
|
|
});
|
|
expect(document.body.textContent).not.toContain('stale');
|
|
const signal = fetchMock.mock.calls[0][1].signal as AbortSignal;
|
|
handle.dispose();
|
|
expect(signal.aborted).toBe(true);
|
|
expect(worker.terminate).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('shows feature warnings and reports worker failures after readiness', async () => {
|
|
const fetchMock = vi.fn(async () => ({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) }));
|
|
const renderer = loadRenderer(fetchMock);
|
|
renderer.open({ container: document.querySelector('#preview'), url: '/book.xlsx', size: 8 });
|
|
const worker = WorkerMock.instances[0];
|
|
worker.emit({ type: 'ready' });
|
|
await vi.waitFor(() => expect(worker.postMessage).toHaveBeenCalled());
|
|
worker.emit({ ...metadata(), warnings: ['charts'] });
|
|
const request = worker.postMessage.mock.calls.at(-1)?.[0];
|
|
worker.emit({ type: 'tile', requestId: request.requestId, sheetId: '1', cells: [], warnings: [] });
|
|
expect(document.body.textContent).toContain('charts');
|
|
worker.onerror?.();
|
|
expect(document.body.textContent).toContain('Spreadsheet parser failed');
|
|
});
|
|
|
|
// The bar sits above the grid in a flex column; unbounded, enough warnings
|
|
// pushed the grid out of view.
|
|
it('clamps the notice bar height and scrolls its overflow', () => {
|
|
const css = readFileSync(resolve(import.meta.dirname, '../src/web/public/styles.css'), 'utf8');
|
|
const rule = /\.spreadsheet-preview-notice\s*\{([^}]*)\}/.exec(css)?.[1] || '';
|
|
expect(rule).toMatch(/max-height:\s*\d/);
|
|
expect(rule).toMatch(/overflow-y:\s*auto/);
|
|
});
|
|
|
|
it('shows an explicit empty-sheet state without dropping workbook warnings', async () => {
|
|
const fetchMock = vi.fn(async () => ({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) }));
|
|
const renderer = loadRenderer(fetchMock);
|
|
renderer.open({ container: document.querySelector('#preview'), url: '/book.xlsx', size: 8 });
|
|
const worker = WorkerMock.instances[0];
|
|
worker.emit({ type: 'ready' });
|
|
await vi.waitFor(() => expect(worker.postMessage).toHaveBeenCalled());
|
|
worker.emit({
|
|
type: 'metadata',
|
|
warnings: ['charts'],
|
|
styles: [],
|
|
sheets: [
|
|
{
|
|
id: '0',
|
|
name: 'Data',
|
|
rows: 1,
|
|
cols: 1,
|
|
defaultRowHeight: 20,
|
|
defaultColumnWidth: 64,
|
|
rowOverrides: [],
|
|
columnOverrides: [],
|
|
},
|
|
{
|
|
id: '1',
|
|
name: 'Empty',
|
|
rows: 0,
|
|
cols: 0,
|
|
defaultRowHeight: 20,
|
|
defaultColumnWidth: 64,
|
|
rowOverrides: [],
|
|
columnOverrides: [],
|
|
},
|
|
],
|
|
});
|
|
const request = worker.postMessage.mock.calls.at(-1)?.[0];
|
|
worker.emit({
|
|
type: 'tile',
|
|
requestId: request.requestId,
|
|
sheetId: '0',
|
|
cells: [{ row: 1, col: 1, text: 'old cell', styleId: 0 }],
|
|
warnings: [],
|
|
});
|
|
expect(document.body.textContent).toContain('old cell');
|
|
(document.querySelectorAll('[role="tab"]')[1] as HTMLButtonElement).click();
|
|
expect(document.body.textContent).toContain('This worksheet is empty.');
|
|
expect(document.body.textContent).toContain('charts');
|
|
expect(document.body.textContent).not.toContain('old cell');
|
|
});
|
|
|
|
it('sizes row and column headings from the same axis math as the cells', async () => {
|
|
const fetchMock = vi.fn(async () => ({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) }));
|
|
const renderer = loadRenderer(fetchMock);
|
|
renderer.open({ container: document.querySelector('#preview'), url: '/book.xlsx', size: 8 });
|
|
const worker = WorkerMock.instances[0];
|
|
worker.emit({ type: 'ready' });
|
|
await vi.waitFor(() => expect(worker.postMessage).toHaveBeenCalled());
|
|
// Column B 18 wide and row 2 30pt tall, as the worker reports the fixture in
|
|
// spreadsheet-preview-worker.test.ts; row 3 and column C hidden.
|
|
worker.emit({
|
|
type: 'metadata',
|
|
styles: [],
|
|
sheets: [
|
|
{
|
|
id: '1',
|
|
name: 'Summary',
|
|
rows: 4,
|
|
cols: 4,
|
|
defaultRowHeight: 20,
|
|
defaultColumnWidth: 64,
|
|
rowOverrides: [
|
|
[2, 40],
|
|
[3, 0],
|
|
],
|
|
columnOverrides: [
|
|
[2, 126],
|
|
[3, 0],
|
|
],
|
|
},
|
|
],
|
|
});
|
|
const request = worker.postMessage.mock.calls.at(-1)?.[0];
|
|
worker.emit({
|
|
type: 'tile',
|
|
requestId: request.requestId,
|
|
sheetId: '1',
|
|
cells: [{ row: 2, col: 2, text: 'B2', styleId: 0 }],
|
|
warnings: [],
|
|
});
|
|
const cell = document.querySelector('.spreadsheet-cell') as HTMLElement;
|
|
const heading = (selector: string, text: string) =>
|
|
[...document.querySelectorAll(selector)].find((element) => element.textContent === text) as
|
|
| HTMLElement
|
|
| undefined;
|
|
const columnB = heading('.spreadsheet-column-heading', 'B');
|
|
const row2 = heading('.spreadsheet-row-heading', '2');
|
|
expect(columnB?.style.width).toBe(cell.style.width);
|
|
expect(columnB?.style.left).toBe(cell.style.left);
|
|
expect(row2?.style.height).toBe(cell.style.height);
|
|
expect(row2?.style.top).toBe(cell.style.top);
|
|
expect(heading('.spreadsheet-column-heading', 'A')?.style.width).toBe('64px');
|
|
expect(heading('.spreadsheet-row-heading', '1')?.style.height).toBe('20px');
|
|
// A hidden row or column has no size, so it gets no heading at all.
|
|
expect(heading('.spreadsheet-column-heading', 'C')).toBeUndefined();
|
|
expect(heading('.spreadsheet-row-heading', '3')).toBeUndefined();
|
|
});
|
|
|
|
// Past MAX_SCROLL_PX only the scroll position may be scaled: dividing every
|
|
// cell and heading by the scale drew a 1,048,576-row sheet 7.6 px a row.
|
|
it('lays rows out at their real height on a sheet taller than the scroll cap', async () => {
|
|
const fetchMock = vi.fn(async () => ({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) }));
|
|
const renderer = loadRenderer(fetchMock);
|
|
renderer.open({ container: document.querySelector('#preview'), url: '/book.xlsx', size: 8 });
|
|
const worker = WorkerMock.instances[0];
|
|
worker.emit({ type: 'ready' });
|
|
await vi.waitFor(() => expect(worker.postMessage).toHaveBeenCalled());
|
|
const sheet = { id: '1', name: 'Tall', rows: 1_048_576, cols: 1, defaultRowHeight: 20, defaultColumnWidth: 64 };
|
|
worker.emit({ type: 'metadata', styles: [], sheets: [{ ...sheet, rowOverrides: [], columnOverrides: [] }] });
|
|
const cellAt = (row: number) => document.querySelector(`.spreadsheet-cell[data-row="${row}"]`) as HTMLElement;
|
|
const rowHeading = (row: number) =>
|
|
[...document.querySelectorAll('.spreadsheet-row-heading')].find(
|
|
(element) => element.textContent === String(row)
|
|
) as HTMLElement;
|
|
const px = (value: string) => Number.parseFloat(value);
|
|
|
|
const first = worker.postMessage.mock.calls.at(-1)?.[0];
|
|
expect(first.range.r1).toBe(1);
|
|
const cell = (row: number) => ({ row, col: 1, text: `A${row}`, styleId: 0 });
|
|
worker.emit({ type: 'tile', requestId: first.requestId, sheetId: '1', cells: [cell(1), cell(2)], warnings: [] });
|
|
expect(cellAt(1).style.top).toBe('20px');
|
|
expect(cellAt(1).style.height).toBe('20px');
|
|
expect(px(cellAt(2).style.top) - px(cellAt(1).style.top)).toBe(20);
|
|
expect(rowHeading(2).style.height).toBe('20px');
|
|
// At scale 1 the first tile asks for about a viewport of rows, not a scaled one.
|
|
expect(first.range.r2).toBeLessThan(40);
|
|
|
|
// Scrolled to the end, the last row is requested, drawn at its real height,
|
|
// and ends exactly at the bottom of the scroll area.
|
|
const grid = document.querySelector('.spreadsheet-grid') as HTMLElement;
|
|
const spacerHeight = px((document.querySelector('.spreadsheet-grid-spacer') as HTMLElement).style.height);
|
|
grid.scrollTop = spacerHeight - 500;
|
|
grid.dispatchEvent(new window.Event('scroll'));
|
|
await vi.waitFor(() =>
|
|
expect(worker.postMessage.mock.calls.at(-1)?.[0].requestId).toBeGreaterThan(first.requestId)
|
|
);
|
|
const last = worker.postMessage.mock.calls.at(-1)?.[0];
|
|
expect(last.range.r2).toBe(1_048_576);
|
|
expect(last.range.r2 - last.range.r1).toBeLessThan(40);
|
|
worker.emit({ type: 'tile', requestId: last.requestId, sheetId: '1', cells: [cell(1_048_576)], warnings: [] });
|
|
expect(cellAt(1_048_576).style.height).toBe('20px');
|
|
expect(rowHeading(1_048_576).style.height).toBe('20px');
|
|
expect(px(cellAt(1_048_576).style.top) + 20).toBeCloseTo(spacerHeight, 6);
|
|
expect(px(rowHeading(1_048_575).style.top)).toBeCloseTo(px(cellAt(1_048_576).style.top) - 20, 6);
|
|
});
|
|
|
|
it('keeps a merge spanning a too-tall sheet inside the scroll area', async () => {
|
|
const fetchMock = vi.fn(async () => ({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) }));
|
|
const renderer = loadRenderer(fetchMock);
|
|
renderer.open({ container: document.querySelector('#preview'), url: '/book.xlsx', size: 8 });
|
|
const worker = WorkerMock.instances[0];
|
|
worker.emit({ type: 'ready' });
|
|
await vi.waitFor(() => expect(worker.postMessage).toHaveBeenCalled());
|
|
const sheet = { id: '1', name: 'Tall', rows: 1_048_576, cols: 1, defaultRowHeight: 20, defaultColumnWidth: 64 };
|
|
worker.emit({ type: 'metadata', styles: [], sheets: [{ ...sheet, rowOverrides: [], columnOverrides: [] }] });
|
|
const request = worker.postMessage.mock.calls.at(-1)?.[0];
|
|
worker.emit({
|
|
type: 'tile',
|
|
requestId: request.requestId,
|
|
sheetId: '1',
|
|
cells: [{ row: 1, col: 1, text: 'whole column', styleId: 0 }],
|
|
merges: ['A1:A1048576'],
|
|
warnings: [],
|
|
});
|
|
const merged = document.querySelector('.spreadsheet-cell') as HTMLElement;
|
|
const spacerHeight = Number.parseFloat(
|
|
(document.querySelector('.spreadsheet-grid-spacer') as HTMLElement).style.height
|
|
);
|
|
expect(Number.parseFloat(merged.style.top) + Number.parseFloat(merged.style.height)).toBeLessThanOrEqual(
|
|
spacerHeight
|
|
);
|
|
});
|
|
|
|
it('emits colour and background together or not at all', async () => {
|
|
const fetchMock = vi.fn(async () => ({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) }));
|
|
const renderer = loadRenderer(fetchMock);
|
|
renderer.open({ container: document.querySelector('#preview'), url: '/book.xlsx', size: 8 });
|
|
const worker = WorkerMock.instances[0];
|
|
worker.emit({ type: 'ready' });
|
|
await vi.waitFor(() => expect(worker.postMessage).toHaveBeenCalled());
|
|
worker.emit({
|
|
...metadata(),
|
|
styles: [
|
|
{ font: { color: '#000000' }, fill: '#ffffff' },
|
|
{ font: { bold: true, color: '#000000' }, fill: undefined },
|
|
{ font: {}, fill: '#ffff00' },
|
|
{ font: { italic: true }, alignment: 'center' },
|
|
],
|
|
});
|
|
const css = (document.querySelector('.spreadsheet-preview-shell style') as HTMLElement).textContent || '';
|
|
expect(css).toContain('.spreadsheet-style-0{color:#000000;background-color:#ffffff}');
|
|
// A half pair would strand the text on the skin's own background, so drop both.
|
|
expect(css).toContain('.spreadsheet-style-1{font-weight:700}');
|
|
expect(css).toContain('.spreadsheet-style-2{}');
|
|
expect(css).toContain('.spreadsheet-style-3{font-style:italic;text-align:center}');
|
|
expect(css).not.toContain('background-color:#ffff00');
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The overlay wiring in panels-ui.js: which route the renderer is pointed at,
|
|
* and that the worker/fetch lifecycle is torn down on close and on re-open.
|
|
*/
|
|
describe('file-preview overlay spreadsheet wiring', () => {
|
|
function loadOverlay(fileContent: Record<string, unknown>) {
|
|
const handles: Array<{ dispose: ReturnType<typeof vi.fn> }> = [];
|
|
const open = vi.fn(() => {
|
|
const handle = { dispose: vi.fn() };
|
|
handles.push(handle);
|
|
return handle;
|
|
});
|
|
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ success: true, data: fileContent }) }));
|
|
const CodemanApp = function CodemanApp(this: unknown) {} as unknown as new () => Record<string, any>;
|
|
const context = vm.createContext({
|
|
CodemanApp,
|
|
console,
|
|
escapeHtml: (value: unknown) => String(value ?? ''),
|
|
CodemanBase: { base: '', url: (p: string) => p },
|
|
fetch: fetchMock,
|
|
setTimeout,
|
|
clearTimeout,
|
|
confirm: () => true,
|
|
document,
|
|
window: { CodemanSpreadsheetPreview: { open }, addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
|
});
|
|
vm.runInContext(panelsSource, context, { filename: 'panels-ui.js' });
|
|
document.body.innerHTML =
|
|
'<div id="filePreviewOverlay"></div><div id="filePreviewTitle"></div><div id="filePreviewBody"></div>' +
|
|
'<div id="filePreviewFooter"></div>';
|
|
const app = new CodemanApp();
|
|
app.$ = (id: string) => document.getElementById(id);
|
|
app.sessions = new Map([['s1', { workingDir: '/work' }]]);
|
|
app.formatFileSize = () => '8 KB';
|
|
return { app, open, handles, fetchMock };
|
|
}
|
|
|
|
it('renders a workspace xlsx through the renderer, pointed at the confined file-raw route', async () => {
|
|
const { app, open, handles } = loadOverlay({
|
|
type: 'spreadsheet',
|
|
size: 8192,
|
|
extension: 'xlsx',
|
|
url: '/api/sessions/s1/file-raw?path=book.xlsx',
|
|
});
|
|
await app.openFilePreview('book.xlsx', 's1');
|
|
const body = document.getElementById('filePreviewBody');
|
|
expect(open).toHaveBeenCalledTimes(1);
|
|
expect(open).toHaveBeenCalledWith(
|
|
expect.objectContaining({ container: body, url: '/api/sessions/s1/file-raw?path=book.xlsx', size: 8192 })
|
|
);
|
|
expect(document.getElementById('filePreviewFooter')?.textContent).toContain('xlsx');
|
|
|
|
app.closeFilePreview();
|
|
expect(handles[0].dispose).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('disposes the previous spreadsheet when another preview opens, and stale renders stop', async () => {
|
|
const { app, open, handles } = loadOverlay({
|
|
type: 'spreadsheet',
|
|
size: 1,
|
|
extension: 'xlsx',
|
|
url: '/api/sessions/s1/file-raw?path=a.xlsx',
|
|
});
|
|
await app.openFilePreview('a.xlsx', 's1');
|
|
const firstIsCurrent = (open.mock.calls[0] as unknown as [{ isCurrent(): boolean }])[0].isCurrent;
|
|
expect(firstIsCurrent()).toBe(true);
|
|
await app.openFilePreview('b.xlsx', 's1');
|
|
expect(handles[0].dispose).toHaveBeenCalledTimes(1);
|
|
expect(firstIsCurrent()).toBe(false);
|
|
expect(handles[1].dispose).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('renders a registered xlsx attachment from its by-id raw route', async () => {
|
|
const { app, open, fetchMock } = loadOverlay({});
|
|
await app.openFilePreview('book.xlsx', 's1', 'att-1');
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
expect(open).toHaveBeenCalledWith(expect.objectContaining({ url: '/api/sessions/s1/attachments/att-1/raw' }));
|
|
});
|
|
|
|
it('leaves xls/ods on the download-only binary path', async () => {
|
|
const { app, open } = loadOverlay({ type: 'binary', size: 10, extension: 'xls' });
|
|
await app.openFilePreview('old.xls', 's1');
|
|
expect(open).not.toHaveBeenCalled();
|
|
expect(document.getElementById('filePreviewBody')?.textContent).toContain('Cannot preview');
|
|
});
|
|
});
|