2026-09-26 23:13:21 -04:00
|
|
|
|
/**
|
|
|
|
|
|
* @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.
|
2026-10-09 09:23:05 +02:00
|
|
|
|
*
|
|
|
|
|
|
* What the user reads is the renderer's, not the worker's: each worker error
|
|
|
|
|
|
* code is one sentence (the raw message goes to the console), feature ids are
|
|
|
|
|
|
* words, and every one of those, the HTTP status line and the notice bar's
|
|
|
|
|
|
* items read in Chinese through i18n.js's real t(); a number format's code is
|
|
|
|
|
|
* workbook text and passes through as is.
|
2026-09-26 23:13:21 -04:00
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
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');
|
2026-10-09 09:23:05 +02:00
|
|
|
|
const coreSource = readFileSync(resolve(import.meta.dirname, '../src/web/public/spreadsheet-xlsx-core.js'), 'utf8');
|
|
|
|
|
|
const workerSource = readFileSync(
|
|
|
|
|
|
resolve(import.meta.dirname, '../src/web/public/spreadsheet-preview-worker.js'),
|
|
|
|
|
|
'utf8'
|
|
|
|
|
|
);
|
|
|
|
|
|
const i18nSource = readFileSync(resolve(import.meta.dirname, '../src/web/public/i18n.js'), 'utf8');
|
2026-09-26 23:13:21 -04:00
|
|
|
|
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');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-10-05 09:51:04 -04:00
|
|
|
|
// 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/);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-26 23:13:21 -04:00
|
|
|
|
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');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-27 07:55:29 -04:00
|
|
|
|
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();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-10-05 21:30:29 -04:00
|
|
|
|
// 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
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-26 23:13:21 -04:00
|
|
|
|
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');
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-10-09 09:23:05 +02:00
|
|
|
|
describe('spreadsheet preview text the user reads', () => {
|
|
|
|
|
|
type Translate = (s: string) => string;
|
|
|
|
|
|
const translator = (language: string): Translate => {
|
|
|
|
|
|
const i18nDom = new JSDOM('<!doctype html><html><body></body></html>', {
|
|
|
|
|
|
runScripts: 'outside-only',
|
|
|
|
|
|
url: 'http://localhost/',
|
|
|
|
|
|
});
|
|
|
|
|
|
vm.runInContext(i18nSource, i18nDom.getInternalVMContext(), { filename: 'i18n.js' });
|
|
|
|
|
|
const api = (i18nDom.window as any).CodemanI18n as { t: Translate; configure(o: object): void };
|
|
|
|
|
|
api.configure({ language });
|
|
|
|
|
|
return api.t;
|
|
|
|
|
|
};
|
|
|
|
|
|
const zh = translator('zh-CN');
|
|
|
|
|
|
const en = translator('en');
|
|
|
|
|
|
/** What may stay Latin in a translation: file formats, the URL acronym, digits. */
|
|
|
|
|
|
const leftover = (text: string) => text.replace(/\b(xlsx|xls|ZIP64|URL)\b/g, '').match(/[A-Za-z]+/g) ?? [];
|
|
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
|
WorkerMock.instances = [];
|
|
|
|
|
|
document.body.innerHTML = '<div id="preview"></div>';
|
|
|
|
|
|
delete (window as any).codemanT;
|
|
|
|
|
|
window.requestAnimationFrame = (callback: FrameRequestCallback) =>
|
|
|
|
|
|
setTimeout(() => callback(0), 0) as unknown as number;
|
|
|
|
|
|
window.cancelAnimationFrame = (id: number) => clearTimeout(id);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
async function openLoaded() {
|
|
|
|
|
|
document.body.innerHTML = '<div id="preview"></div>';
|
|
|
|
|
|
const fetchMock = vi.fn(async () => ({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) }));
|
|
|
|
|
|
loadRenderer(fetchMock).open({ container: document.querySelector('#preview'), url: '/book.xlsx', size: 8 });
|
|
|
|
|
|
const worker = WorkerMock.instances.at(-1)!;
|
|
|
|
|
|
worker.emit({ type: 'ready' });
|
|
|
|
|
|
await vi.waitFor(() => expect(worker.postMessage).toHaveBeenCalled());
|
|
|
|
|
|
return worker;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const shown = () => document.querySelector('.spreadsheet-preview-message')?.textContent ?? '';
|
|
|
|
|
|
|
|
|
|
|
|
it('shows one sentence per worker error code, never the raw message, and each reads in Chinese', async () => {
|
|
|
|
|
|
const codes = [
|
|
|
|
|
|
...new Set(
|
|
|
|
|
|
[...`${coreSource}\n${workerSource}`.matchAll(/(?:\bfail|XlsxPreviewError)\('([a-z0-9-]+)'/g)].map((m) => m[1])
|
|
|
|
|
|
),
|
|
|
|
|
|
];
|
|
|
|
|
|
// Not vacuous: the refusals the core and the worker raise.
|
|
|
|
|
|
expect(codes).toEqual(expect.arrayContaining(['encrypted', 'zip64', 'malformed', 'cell-limit', 'style-limit']));
|
|
|
|
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
|
|
|
|
try {
|
|
|
|
|
|
const texts = new Map<string, string>();
|
|
|
|
|
|
for (const code of [...codes, 'parse-failed']) {
|
|
|
|
|
|
const worker = await openLoaded();
|
|
|
|
|
|
worker.emit({ type: 'error', code, message: `raw ${code} detail` });
|
|
|
|
|
|
texts.set(code, shown());
|
|
|
|
|
|
}
|
|
|
|
|
|
expect([...texts.values()].filter((text) => /raw .* detail/.test(text))).toEqual([]);
|
|
|
|
|
|
// Every code the core and worker raise has its own sentence; only an
|
|
|
|
|
|
// unlisted one (ExcelJS's own failures) shows the generic failure.
|
|
|
|
|
|
expect(codes.filter((code) => texts.get(code) === 'Spreadsheet preview failed')).toEqual([]);
|
|
|
|
|
|
expect(texts.get('parse-failed')).toBe('Spreadsheet preview failed');
|
|
|
|
|
|
expect(texts.get('encrypted')).toBe(
|
|
|
|
|
|
'This workbook is password-protected or in the old .xls format, so it cannot be previewed.'
|
|
|
|
|
|
);
|
|
|
|
|
|
// The developer detail is kept, in the console.
|
|
|
|
|
|
expect(warn).toHaveBeenCalledWith(expect.stringContaining('raw encrypted detail'));
|
|
|
|
|
|
const bad = [...new Set(texts.values())].filter((text) => {
|
|
|
|
|
|
const t = zh(text);
|
|
|
|
|
|
return t === text || leftover(t).length > 0 || en(text) !== text;
|
|
|
|
|
|
});
|
|
|
|
|
|
expect(bad).toEqual([]);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
warn.mockRestore();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('words the notice bar: feature ids as words, each item translated, a format code as it is', async () => {
|
|
|
|
|
|
const notice = async () => {
|
|
|
|
|
|
const worker = await openLoaded();
|
|
|
|
|
|
worker.emit({ ...metadata(), warnings: ['pivotTables', 'externalLinks', 'charts', 'drawings', 'macros'] });
|
|
|
|
|
|
const request = worker.postMessage.mock.calls.at(-1)?.[0];
|
|
|
|
|
|
worker.emit({
|
|
|
|
|
|
type: 'tile',
|
|
|
|
|
|
requestId: request.requestId,
|
|
|
|
|
|
sheetId: '1',
|
|
|
|
|
|
cells: [],
|
|
|
|
|
|
warnings: [
|
|
|
|
|
|
'View truncated to the first 2500 cells',
|
|
|
|
|
|
'Formula has no cached result',
|
|
|
|
|
|
'Unsupported cell value',
|
|
|
|
|
|
'Unsupported number format: {name}"x"',
|
|
|
|
|
|
'3 unsupported number formats',
|
|
|
|
|
|
],
|
|
|
|
|
|
});
|
|
|
|
|
|
return document.querySelector('.spreadsheet-preview-notice')!.textContent!;
|
|
|
|
|
|
};
|
|
|
|
|
|
const english = await notice();
|
|
|
|
|
|
expect(english).toBe(
|
|
|
|
|
|
'Some workbook features are not shown: pivot tables, external links, charts, drawings, macros, ' +
|
|
|
|
|
|
'View truncated to the first 2500 cells, Formula has no cached result, Unsupported cell value, ' +
|
|
|
|
|
|
'Unsupported number format: {name}"x", 3 unsupported number formats'
|
|
|
|
|
|
);
|
2026-10-09 10:17:44 +02:00
|
|
|
|
// The real English translator reads the feature words as plain words too,
|
|
|
|
|
|
// never as their scoped keys.
|
|
|
|
|
|
(window as any).codemanT = en;
|
|
|
|
|
|
expect(await notice()).toBe(english);
|
2026-10-09 09:23:05 +02:00
|
|
|
|
(window as any).codemanT = zh;
|
|
|
|
|
|
const chinese = await notice();
|
|
|
|
|
|
// The format code is workbook text: kept as is ({name} is not a placeholder here).
|
|
|
|
|
|
expect(chinese).toContain('不支持的数字格式: {name}"x"');
|
|
|
|
|
|
expect(leftover(chinese.replace('{name}"x"', ''))).toEqual([]);
|
|
|
|
|
|
expect(chinese.startsWith(`${zh('Some workbook features are not shown')}: 数据透视表, 外部链接, 图表`)).toBe(true);
|
2026-10-09 10:17:44 +02:00
|
|
|
|
// Through scoped keys only: the translator runs over the Files panel tree and
|
|
|
|
|
|
// the case picker too, where a bare 'charts' or 'macros' key would rename a
|
|
|
|
|
|
// Helm chart's charts/ or a dbt project's macros/ folder, or a case of that name.
|
|
|
|
|
|
const words = ['charts', 'drawings', 'pivot tables', 'external links', 'macros'];
|
|
|
|
|
|
expect(words.filter((word) => zh(word) !== word || zh(word.toUpperCase()) !== word.toUpperCase())).toEqual([]);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('keeps the notice bar away from the page translator, so a format code is never rewritten', async () => {
|
|
|
|
|
|
// The real i18n.js in the renderer's own window. configure() walks the whole
|
|
|
|
|
|
// body with t(), which fills `{name}` and rebrands "Codeman" in every text
|
|
|
|
|
|
// node it reaches; the bar is written already translated, item by item, and
|
|
|
|
|
|
// ends with workbook text.
|
|
|
|
|
|
const { confirm, alert } = window;
|
|
|
|
|
|
const context = vm.createContext({
|
|
|
|
|
|
window,
|
|
|
|
|
|
document,
|
|
|
|
|
|
Node: window.Node,
|
|
|
|
|
|
NodeFilter: window.NodeFilter,
|
|
|
|
|
|
MutationObserver: window.MutationObserver,
|
|
|
|
|
|
console,
|
|
|
|
|
|
});
|
|
|
|
|
|
vm.runInContext(i18nSource, context, { filename: 'i18n.js' });
|
|
|
|
|
|
try {
|
|
|
|
|
|
const i18n = (window as any).CodemanI18n as { configure(o: object): void };
|
|
|
|
|
|
i18n.configure({ language: 'en', displayName: 'Acme' });
|
|
|
|
|
|
const worker = await openLoaded();
|
|
|
|
|
|
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: ['Unsupported number format: {name}"Codeman"0'],
|
|
|
|
|
|
});
|
|
|
|
|
|
const bar = document.querySelector('.spreadsheet-preview-notice')!;
|
|
|
|
|
|
const written = 'Some workbook features are not shown: charts, Unsupported number format: {name}"Codeman"0';
|
|
|
|
|
|
expect(bar.textContent).toBe(written);
|
|
|
|
|
|
// A language or display-name change re-walks the page.
|
|
|
|
|
|
i18n.configure({ language: 'en', displayName: 'Acme' });
|
|
|
|
|
|
expect(bar.textContent).toBe(written);
|
|
|
|
|
|
expect(bar.hasAttribute('data-i18n-skip')).toBe(true);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
delete (window as any).CodemanI18n;
|
|
|
|
|
|
delete (window as any).codemanT;
|
|
|
|
|
|
window.confirm = confirm;
|
|
|
|
|
|
window.alert = alert;
|
|
|
|
|
|
}
|
2026-10-09 09:23:05 +02:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('the HTTP status line reads in Chinese with its status', async () => {
|
|
|
|
|
|
const fetchMock = vi.fn(async () => ({ ok: false, status: 404, arrayBuffer: async () => new ArrayBuffer(0) }));
|
|
|
|
|
|
loadRenderer(fetchMock).open({ container: document.querySelector('#preview'), url: '/book.xlsx', size: 8 });
|
|
|
|
|
|
WorkerMock.instances.at(-1)!.emit({ type: 'ready' });
|
|
|
|
|
|
await vi.waitFor(() => expect(shown()).toBe('Spreadsheet preview failed (404)'));
|
|
|
|
|
|
expect(zh(shown())).toBe('电子表格预览失败(404)');
|
|
|
|
|
|
expect(en(shown())).toBe(shown());
|
|
|
|
|
|
expect(zh('Spreadsheet preview must use a same-origin URL')).toBe('电子表格预览必须使用同源 URL');
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-26 23:13:21 -04:00
|
|
|
|
/**
|
|
|
|
|
|
* 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');
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|