mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 16:59:43 +02:00
- i18n: the spreadsheet notice's feature words (charts, drawings, macros,
pivot tables, external links) were bare, case-insensitive zh-CN keys, so
the page translator also renamed a charts/ or macros/ folder in the Files
panel and a case of that name in the case picker. They are now scoped
'Spreadsheet feature: <word>' keys; warningText() falls back to the plain
word when the scoped key has no translation (English, no i18n). Removing
the bare keys closes this branch's regression. The older 'models' key has
the same class of problem; it is left alone here, since adding
.case-combobox-option-label to USER_TEXT_SELECTOR would also untranslate
the picker's two action rows and still miss the title attribute.
- Spreadsheet notice bar: marked data-i18n-skip. It is written already
translated, item by item, and ends with a number format's code, which the
observer's t() over the whole line rewrote ({name}, "Codeman").
- Connection tile (Header Stats Style Tiles): applyLocalization() now repaints
the indicator, so a switch back to English no longer leaves the Chinese
value word in its data-i18n-skip span until the next keystroke or ACK.
- Tiles default: loadAppSettingsFromStorage() no longer caches the
posture-dependent showTileGridButton default, so the init merge never
persists it; a 2-in-1 first opened as a tablet gets the button once docked.
Every reader still resolves the absent key through a fresh
getDefaultSettings(), so phones stay OFF and desktops ON.
- Tile grid over a split: closeSplitPane() skips Pane A's closing resize only
when Pane A becomes a tile. With mergeSplit false (Open group as tiles, a
stored grid) a Pane A left out of the set gets its full width back before
the main terminal parks, instead of keeping the split's half width.
- By-case tab strip on tablets and phones: with the boxes dissolved, the
-<case> part of a generated name shows again, so w1-alpha and w1-beta no
longer both read "w1".
Each new assertion fails against the previous source.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
664 lines
30 KiB
TypeScript
664 lines
30 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.
|
||
*
|
||
* 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.
|
||
*/
|
||
|
||
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 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');
|
||
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');
|
||
});
|
||
});
|
||
|
||
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'
|
||
);
|
||
// 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);
|
||
(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);
|
||
// 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;
|
||
}
|
||
});
|
||
|
||
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');
|
||
});
|
||
});
|
||
|
||
/**
|
||
* 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');
|
||
});
|
||
});
|