feat: v4.6.0 — AI assistant, collaboration, knowledge base, and 15 more features

- AI Assistant plugin: multi-provider chat (OpenAI/Anthropic/Ollama/LM Studio),
  summarize/improve/translate commands, proofread via ai:analyze; calls
  proxied through main so API keys stay out of the renderer
- Collaboration plugin: anchor-based comments in .comments/ sidecars with
  drift detection and F8 navigation
- Local knowledge base: [[wiki-links]] with click-to-create + Backlinks panel
- Crash recovery: debounced session snapshots with restore prompt on launch
- Version history: pre-save snapshots, History panel with restore/diff/delete
- Real PDF encryption: swap pdf-lib for @cantoo/pdf-lib (probe-driven UI)
- XLSX export (native workbooks via JSZip), ODT headers/footers + page size
- Offline KaTeX (bundled CSS+fonts), local-first PlantUML rendering
- Editor: vim mode toggle, snippet Tab-expansion, zen word-goal setter,
  writing heatmap, writing-studio panels wired with rail icons
- Quick Note global scratchpad (Ctrl+Alt+Q), markdownconverter:// deep links,
  REPL first-run confirmation
- Fix: Ctrl+Shift+P collision, pandoc converter availability check, CLI
  dangling --css/--reference-doc flags, dead converter button

8 new test suites; 613 tests green; lint clean
This commit is contained in:
2026-09-05 20:48:39 +05:30
parent b83ba91731
commit c4dcbd8caf
105 changed files with 5695 additions and 195 deletions
+140
View File
@@ -0,0 +1,140 @@
/**
* @jest-environment node
*
* AiProviders tests with a stubbed fetch — no network access. Verifies the
* provider dispatch, auth handling, timeouts, and the user-safe error paths.
*/
const {
complete,
resolveSettings,
AiProviderError,
PROVIDER_DEFAULTS,
MAX_PROMPT_CHARS,
} = require('../../src/main/AiProviders');
/** Build a fetch stub returning the given JSON with status 200. */
function okFetch(body) {
return jest.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => body,
});
}
describe('AiProviders', () => {
describe('resolveSettings', () => {
it('applies provider defaults for base URL and model', () => {
const s = resolveSettings({ provider: 'ollama' });
expect(s.baseUrl).toBe(PROVIDER_DEFAULTS.ollama.baseUrl);
expect(s.model).toBe(PROVIDER_DEFAULTS.ollama.defaultModel);
expect(s.apiKey).toBe('');
});
it('does not require an API key for local providers', () => {
expect(() => resolveSettings({ provider: 'lmstudio' })).not.toThrow();
expect(() => resolveSettings({ provider: 'ollama' })).not.toThrow();
});
it('requires an API key for remote providers', () => {
expect(() => resolveSettings({ provider: 'openai' })).toThrow(AiProviderError);
expect(() => resolveSettings({ provider: 'anthropic' })).toThrow(/API key/);
});
it('rejects unknown providers and non-http(s) base URLs', () => {
expect(() => resolveSettings({ provider: 'nope' })).toThrow(/Unknown AI provider/);
expect(() =>
resolveSettings({ provider: 'openai-compatible', apiKey: 'k', baseUrl: 'file:///etc' })
).toThrow(/Invalid API base URL/);
});
it('clamps temperature into [0, 2]', () => {
expect(resolveSettings({ provider: 'ollama', temperature: 99 }).temperature).toBe(2);
expect(resolveSettings({ provider: 'ollama', temperature: -5 }).temperature).toBe(0);
});
});
describe('complete', () => {
const messages = [{ role: 'user', content: 'hello' }];
it('rejects requests with no messages', async () => {
await expect(complete({ provider: 'ollama', messages: [] })).rejects.toThrow(/No messages/);
});
it('rejects prompts over the size cap', async () => {
await expect(
complete({
provider: 'ollama',
messages: [{ role: 'user', content: 'x'.repeat(MAX_PROMPT_CHARS + 1) }],
})
).rejects.toThrow(/too large/i);
});
it('sends the OpenAI chat-completions shape with a bearer key', async () => {
const fetchImpl = okFetch({ choices: [{ message: { content: 'hi there' } }] });
const result = await complete(
{ provider: 'openai', apiKey: 'sk-test', messages },
{ fetchImpl }
);
expect(result.content).toBe('hi there');
const [url, init] = fetchImpl.mock.calls[0];
expect(url).toBe('https://api.openai.com/v1/chat/completions');
expect(init.headers.Authorization).toBe('Bearer sk-test');
const body = JSON.parse(init.body);
expect(body.messages[0].role).toBe('user');
});
it('prepends the system prompt as a system message for OpenAI-style providers', async () => {
const fetchImpl = okFetch({ choices: [{ message: { content: 'ok' } }] });
await complete({ provider: 'ollama', system: 'be brief', messages }, { fetchImpl });
const body = JSON.parse(fetchImpl.mock.calls[0][1].body);
expect(body.messages[0]).toEqual({ role: 'system', content: 'be brief' });
});
it('sends the Anthropic messages shape with x-api-key header', async () => {
const fetchImpl = okFetch({
content: [{ type: 'text', text: 'claude says hi' }],
});
const result = await complete(
{ provider: 'anthropic', apiKey: 'ak-test', system: 'be nice', messages },
{ fetchImpl }
);
expect(result.content).toBe('claude says hi');
const [url, init] = fetchImpl.mock.calls[0];
expect(url).toBe('https://api.anthropic.com/v1/messages');
expect(init.headers['x-api-key']).toBe('ak-test');
expect(init.headers['anthropic-version']).toBe('2023-06-01');
const body = JSON.parse(init.body);
expect(body.system).toBe('be nice');
});
it('surfaces HTTP failures as user-safe errors without the body', async () => {
const fetchImpl = jest.fn().mockResolvedValue({ ok: false, status: 401 });
await expect(
complete({ provider: 'openai', apiKey: 'bad', messages }, { fetchImpl })
).rejects.toThrow(/HTTP 401/);
});
it('rejects unexpected response shapes', async () => {
const fetchImpl = okFetch({ unexpected: true });
await expect(
complete({ provider: 'openai', apiKey: 'k', messages }, { fetchImpl })
).rejects.toThrow(/unexpected response/i);
});
it('surfaces aborts as timeouts', async () => {
const fetchImpl = jest.fn(
(_url, init) =>
new Promise((_res, rej) => {
init.signal.addEventListener('abort', () =>
rej(new Error('The operation was aborted'))
);
})
);
await expect(
complete({ provider: 'ollama', messages }, { fetchImpl, timeoutMs: 30 })
).rejects.toThrow();
});
});
});
+187
View File
@@ -0,0 +1,187 @@
/**
* @jest-environment node
*
* OdtStyling.js tests: page-size patching and header/footer injection into a
* Pandoc-style ODT package. Fixtures are minimal zips built with PizZip,
* mirroring the structure Pandoc's ODT writer emits (styles.xml with a "pm1"
* page layout and a "Standard" master page).
*/
const fs = require('fs');
const os = require('os');
const path = require('path');
const PizZip = require('pizzip');
const {
setOdtPageSize,
addHeaderFooterToOdt,
parseDimensionsMm,
escapeOdtText,
} = require('../../src/main/OdtStyling');
// Minimal Pandoc-like styles.xml: page layout pm1 + Standard master page
const STYLES_XML = `<?xml version="1.0" encoding="utf-8"?>
<office:document-styles xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0"
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"
office:version="1.2">
<office:automatic-styles>
<style:page-layout style:name="pm1">
<style:page-layout-properties fo:margin-top="0.787in" fo:page-width="8.267in" fo:page-height="11.69in" style:print-orientation="portrait"/>
</style:page-layout>
</office:automatic-styles>
<office:master-styles>
<style:master-page style:name="Standard" style:page-layout-name="pm1"/>
</office:master-styles>
</office:document-styles>`;
/**
* Write a minimal .odt fixture containing just styles.xml.
* @param {string} filePath destination
* @param {string} [stylesXml] styles.xml content (defaults to STYLES_XML)
*/
function writeOdtFixture(filePath, stylesXml = STYLES_XML) {
const zip = new PizZip();
zip.file('mimetype', 'application/vnd.oasis.opendocument.text');
zip.file('styles.xml', stylesXml);
zip.file('content.xml', '<office:document-content/>');
fs.writeFileSync(filePath, zip.generate({ type: 'nodebuffer' }));
}
/** Read styles.xml back out of a written fixture. */
function readStylesXml(filePath) {
return new PizZip(fs.readFileSync(filePath)).file('styles.xml').asText();
}
describe('OdtStyling', () => {
let tmpDir, odtPath;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'odtstyle_'));
odtPath = path.join(tmpDir, 'doc.odt');
writeOdtFixture(odtPath);
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
describe('parseDimensionsMm', () => {
it('parses "210×297mm" (U+00D7 separator) into numbers', () => {
expect(parseDimensionsMm('210×297mm')).toEqual({ widthMm: 210, heightMm: 297 });
});
it('returns null for unparseable input', () => {
expect(parseDimensionsMm('8.5in')).toBeNull();
expect(parseDimensionsMm(undefined)).toBeNull();
});
});
describe('escapeOdtText', () => {
it('escapes XML-significant characters', () => {
expect(escapeOdtText('a<b>&"c"')).toBe('a&lt;b&gt;&amp;&quot;c&quot;');
});
});
describe('setOdtPageSize', () => {
it('rewrites width/height/orientation on the existing page layout (A4 portrait)', async () => {
await setOdtPageSize(odtPath, {
size: 'a4',
orientation: 'portrait',
pageSizes: { a4: { dimensions: '210×297mm' } },
});
const xml = readStylesXml(odtPath);
expect(xml).toContain('fo:page-width="210mm"');
expect(xml).toContain('fo:page-height="297mm"');
expect(xml).toContain('style:print-orientation="portrait"');
// Old inch-based attributes must be gone (no duplicate size attrs)
expect(xml).not.toContain('fo:page-width="8.267in"');
});
it('swaps dimensions for landscape', async () => {
await setOdtPageSize(odtPath, {
size: 'a4',
orientation: 'landscape',
pageSizes: { a4: { dimensions: '210×297mm' } },
});
const xml = readStylesXml(odtPath);
expect(xml).toContain('fo:page-width="297mm"');
expect(xml).toContain('fo:page-height="210mm"');
expect(xml).toContain('style:print-orientation="landscape"');
});
it('falls back to custom mm dimensions and A4 default', async () => {
await setOdtPageSize(odtPath, { customWidth: '148', customHeight: '210' });
expect(readStylesXml(odtPath)).toContain('fo:page-width="148mm"');
writeOdtFixture(odtPath);
await setOdtPageSize(odtPath, {});
expect(readStylesXml(odtPath)).toContain('fo:page-width="210mm"');
});
});
describe('addHeaderFooterToOdt', () => {
it('is a no-op when disabled', async () => {
await addHeaderFooterToOdt(odtPath, { enabled: false, header: { left: 'X' } });
expect(readStylesXml(odtPath)).not.toContain('<style:header');
});
it('injects header and footer regions into the Standard master page', async () => {
await addHeaderFooterToOdt(odtPath, {
enabled: true,
header: { left: 'My Doc', right: 'Draft <v2>' },
footer: { center: 'Page $PAGE$ of $TOTAL$' },
});
const xml = readStylesXml(odtPath);
expect(xml).toContain('<style:header>');
expect(xml).toContain('<style:region-left');
expect(xml).toContain('My Doc');
// User text must be XML-escaped
expect(xml).toContain('Draft &lt;v2&gt;');
expect(xml).toContain('<text:page-number>');
expect(xml).toContain('<text:page-count>');
// The self-closing master page must have been expanded to hold children
expect(xml).toMatch(/<style:master-page[^>]*>[\s\S]*<\/style:master-page>/);
});
it('does nothing when all regions are empty', async () => {
await addHeaderFooterToOdt(odtPath, {
enabled: true,
header: { left: '', center: '', right: '' },
footer: {},
});
expect(readStylesXml(odtPath)).toBe(STYLES_XML);
});
it('creates a master page when the package has none', async () => {
const bareXml = STYLES_XML.replace(
/ <office:master-styles>[\s\S]*<\/office:master-styles>/,
''
);
writeOdtFixture(odtPath, bareXml);
await addHeaderFooterToOdt(odtPath, {
enabled: true,
footer: { center: 'hi' },
});
const xml = readStylesXml(odtPath);
expect(xml).toContain('<office:master-styles>');
expect(xml).toContain('style:name="Standard"');
expect(xml).toContain('hi');
});
it('replaces existing header/footer content on re-export', async () => {
await addHeaderFooterToOdt(odtPath, { enabled: true, header: { left: 'first' } });
await addHeaderFooterToOdt(odtPath, { enabled: true, header: { left: 'second' } });
const xml = readStylesXml(odtPath);
expect(xml).toContain('second');
expect(xml).not.toContain('first');
// Exactly one header element, not nested duplicates
expect(xml.match(/<style:header>/g)).toHaveLength(1);
});
});
});
+1 -1
View File
@@ -17,7 +17,7 @@ const fs = require('fs');
const os = require('os');
const path = require('path');
const sharp = require('sharp');
const { PDFDocument, StandardFonts, rgb } = require('pdf-lib');
const { PDFDocument, StandardFonts, rgb } = require('@cantoo/pdf-lib');
const PDFOperations = require('../../src/main/PDFOperations');
const {
runPDFBatchOperation,
+67 -37
View File
@@ -14,7 +14,7 @@ const fs = require('fs');
const os = require('os');
const path = require('path');
const sharp = require('sharp');
const { PDFDocument, StandardFonts, rgb } = require('pdf-lib');
const { PDFDocument, StandardFonts, rgb } = require('@cantoo/pdf-lib');
const PDFOperations = require('../../src/main/PDFOperations');
describe('PDFOperations - Task 15 new operations', () => {
@@ -386,7 +386,7 @@ describe('PDFOperations - Task 16 form field fill/flatten', () => {
});
});
describe('PDFOperations - Task 27 honest encryption failure', () => {
describe('PDFOperations - real encryption (@cantoo/pdf-lib)', () => {
let tmpDir, inputPath;
beforeEach(async () => {
@@ -402,16 +402,16 @@ describe('PDFOperations - Task 27 honest encryption failure', () => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('detects the bundled pdf-lib as encryption-incapable via the module-load probe', async () => {
// Pins the Task 27 premise: pdf-lib 1.17.1's save() ignores password
// options (SaveOptions has no such fields), so the probe — which saves a
// tiny document with a userPassword and checks the bytes for /Encrypt —
// must report false. If this fails after a library swap, the probe
// re-enabled the ops and the honest-failure tests below no longer apply.
await expect(PDFOperations.pdfEncryptionSupported).resolves.toBe(false);
it('detects the encryption-capable library via the module-load probe', async () => {
// The @cantoo/pdf-lib fork adds doc.encrypt(); the probe — which encrypts
// a tiny document and checks the bytes for /Encrypt — must report true so
// the password ops are enabled in the UI. If this fails after a library
// change, the ops fall back to honest failure and these tests no longer
// apply.
await expect(PDFOperations.pdfEncryptionSupported).resolves.toBe(true);
});
it('pdfEncrypt fails honestly without writing an output file', async () => {
it('pdfEncrypt writes an encrypted PDF that requires the password', async () => {
const outputPath = path.join(tmpDir, 'encrypted.pdf');
const result = await PDFOperations.pdfEncrypt({
inputPath,
@@ -421,60 +421,90 @@ describe('PDFOperations - Task 27 honest encryption failure', () => {
permissions: { printing: true },
});
expect(result.success).toBe(false);
expect(result.message).toBe(PDFOperations.PDF_ENCRYPTION_UNAVAILABLE_MESSAGE);
expect(fs.existsSync(outputPath)).toBe(false);
expect(result.success).toBe(true);
expect(fs.existsSync(outputPath)).toBe(true);
const bytes = fs.readFileSync(outputPath);
expect(bytes.includes('/Encrypt')).toBe(true);
// Correct password opens it; wrong password is rejected.
const opened = await PDFDocument.load(bytes, { password: 'secret' });
expect(opened.getPageCount()).toBe(1);
await expect(PDFDocument.load(bytes, { password: 'wrong' })).rejects.toThrow();
});
it('pdfDecrypt fails honestly without writing an output file', async () => {
it('pdfDecrypt removes password protection and writes a clean PDF', async () => {
const encryptedPath = path.join(tmpDir, 'encrypted.pdf');
const encryptResult = await PDFOperations.pdfEncrypt({
inputPath,
outputPath: encryptedPath,
userPassword: 'secret',
permissions: { printing: true },
});
expect(encryptResult.success).toBe(true);
const outputPath = path.join(tmpDir, 'decrypted.pdf');
const result = await PDFOperations.pdfDecrypt({
inputPath,
inputPath: encryptedPath,
outputPath,
password: 'secret',
});
expect(result.success).toBe(true);
const bytes = fs.readFileSync(outputPath);
expect(bytes.includes('/Encrypt')).toBe(false);
const opened = await PDFDocument.load(bytes);
expect(opened.getPageCount()).toBe(1);
});
it('pdfDecrypt rejects a wrong password without writing output', async () => {
const encryptedPath = path.join(tmpDir, 'encrypted.pdf');
await PDFOperations.pdfEncrypt({
inputPath,
outputPath: encryptedPath,
userPassword: 'secret',
permissions: {},
});
const outputPath = path.join(tmpDir, 'decrypted.pdf');
const result = await PDFOperations.pdfDecrypt({
inputPath: encryptedPath,
outputPath,
password: 'wrong',
});
expect(result.success).toBe(false);
expect(result.message).toBe(PDFOperations.PDF_ENCRYPTION_UNAVAILABLE_MESSAGE);
expect(result.error).toMatch(/password/i);
expect(fs.existsSync(outputPath)).toBe(false);
});
it('pdfSetPermissions fails honestly without writing an output file', async () => {
it('pdfSetPermissions writes an encrypted PDF with an owner password', async () => {
const outputPath = path.join(tmpDir, 'permissions.pdf');
const result = await PDFOperations.pdfSetPermissions({
inputPath,
outputPath,
ownerPassword: 'owner-secret',
permissions: { printing: true },
permissions: { printing: true, copying: false },
});
expect(result.success).toBe(false);
expect(result.message).toBe(PDFOperations.PDF_ENCRYPTION_UNAVAILABLE_MESSAGE);
expect(fs.existsSync(outputPath)).toBe(false);
expect(result.success).toBe(true);
const bytes = fs.readFileSync(outputPath);
expect(bytes.includes('/Encrypt')).toBe(true);
// Owner password grants full access, so loading with it must succeed.
const opened = await PDFDocument.load(bytes, { password: 'owner-secret' });
expect(opened.getPageCount()).toBe(1);
});
it('fails honestly even before reading the input, so a missing input reports unavailability', async () => {
const result = await PDFOperations.pdfEncrypt({
inputPath: path.join(tmpDir, 'missing.pdf'),
outputPath: path.join(tmpDir, 'never-written.pdf'),
userPassword: 'secret',
});
expect(result.success).toBe(false);
expect(result.message).toBe(PDFOperations.PDF_ENCRYPTION_UNAVAILABLE_MESSAGE);
expect(fs.existsSync(path.join(tmpDir, 'never-written.pdf'))).toBe(false);
});
it('executeOperation routes the password ops to the honest failure', async () => {
it('executeOperation routes encrypt to the real implementation', async () => {
const outputPath = path.join(tmpDir, 'exec-encrypted.pdf');
const result = await PDFOperations.executeOperation('encrypt', {
inputPath,
outputPath: path.join(tmpDir, 'exec-encrypted.pdf'),
outputPath,
userPassword: 'secret',
permissions: { printing: true },
});
expect(result.success).toBe(false);
expect(result.message).toBe(PDFOperations.PDF_ENCRYPTION_UNAVAILABLE_MESSAGE);
expect(result.success).toBe(true);
expect(fs.readFileSync(outputPath).includes('/Encrypt')).toBe(true);
});
it('the module-load probe does not affect other operations', async () => {
+102
View File
@@ -0,0 +1,102 @@
/**
* @jest-environment node
*
* VersionHistory tests with a temp-dir-backed IO bundle: capture-before-save
* semantics, per-document isolation, pruning, and id validation.
*/
const fs = require('fs');
const os = require('os');
const path = require('path');
const crypto = require('crypto');
const {
saveVersion,
listVersions,
readVersion,
deleteVersion,
isValidVersionId,
folderFor,
DEFAULT_MAX_KEEP,
} = require('../../src/main/VersionHistory');
describe('VersionHistory', () => {
let tmpDir, io;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vh_'));
io = { rootDir: path.join(tmpDir, 'versions'), fs, pathUtil: path, crypto };
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('saves and lists versions newest-first with metadata', () => {
const v1 = saveVersion({ docPath: '/docs/a.md', content: 'one', label: 'before save', io });
saveVersion({ docPath: '/docs/a.md', content: 'one two', label: 'before save', io });
const versions = listVersions({ docPath: '/docs/a.md', io });
expect(versions).toHaveLength(2);
expect(versions[0].createdAt).toBeGreaterThanOrEqual(v1.createdAt);
expect(versions[0].wordCount).toBe(2);
expect(versions.every((v) => v.label === 'before save')).toBe(true);
});
it('round-trips version content', () => {
const { id } = saveVersion({ docPath: '/docs/a.md', content: '# hello\n\nworld', io });
expect(readVersion({ docPath: '/docs/a.md', id, io })).toBe('# hello\n\nworld');
});
it('isolates documents by path', () => {
saveVersion({ docPath: '/docs/a.md', content: 'A', io });
saveVersion({ docPath: '/docs/b.md', content: 'B', io });
expect(listVersions({ docPath: '/docs/a.md', io })).toHaveLength(1);
expect(listVersions({ docPath: '/docs/b.md', io })).toHaveLength(1);
expect(listVersions({ docPath: '/docs/other.md', io })).toHaveLength(0);
});
it('prunes the oldest versions beyond maxKeep', () => {
const docPath = '/docs/many.md';
for (let i = 0; i < DEFAULT_MAX_KEEP + 5; i++) {
saveVersion({ docPath, content: `v${i}`, label: `v${i}`, io });
}
const versions = listVersions({ docPath, io });
expect(versions).toHaveLength(DEFAULT_MAX_KEEP);
// Newest survive; the oldest five labels are gone
expect(versions[0].label).toBe(`v${DEFAULT_MAX_KEEP + 4}`);
expect(versions.some((v) => v.label === 'v0')).toBe(false);
});
it('deleteVersion removes the entry and its blob', () => {
const docPath = '/docs/del.md';
const { id } = saveVersion({ docPath, content: 'x', io });
expect(deleteVersion({ docPath, id, io })).toBe(true);
expect(listVersions({ docPath, io })).toHaveLength(0);
expect(() => readVersion({ docPath, id, io })).toThrow();
expect(deleteVersion({ docPath, id, io })).toBe(false);
});
it('rejects malformed version ids on read (path traversal guard)', () => {
saveVersion({ docPath: '/docs/a.md', content: 'x', io });
expect(() => readVersion({ docPath: '/docs/a.md', id: '../../etc/passwd', io })).toThrow(
/Invalid version id/
);
expect(() => readVersion({ docPath: '/docs/a.md', id: 'UPPER!!', io })).toThrow();
});
it('isValidVersionId accepts generated ids and rejects hostile ones', () => {
expect(isValidVersionId('m3x1e2a-abc123')).toBe(true);
expect(isValidVersionId('../etc')).toBe(false);
expect(isValidVersionId('')).toBe(false);
expect(isValidVersionId(null)).toBe(false);
});
it('folderFor is deterministic and hex-safe', () => {
expect(folderFor('/docs/a.md', path, crypto)).toBe(
path.join(
'by-path',
crypto.createHash('sha1').update('/docs/a.md').digest('hex').slice(0, 16)
)
);
expect(folderFor('/docs/WEIRDname!!.md', path, crypto)).toMatch(/^[\\/a-z0-9-]+$/i);
});
});
+96
View File
@@ -0,0 +1,96 @@
/**
* @jest-environment node
*
* XlsxExporter tests: verify the generated OOXML package structure by
* reopening the buffer with JSZip and checking the key parts (workbook sheet
* list, per-sheet cell XML, content types). Uses only string assertions —
* no Excel engine needed.
*/
const JSZip = require('jszip');
const {
buildXlsx,
buildSheetXml,
columnLetter,
looksNumeric,
} = require('../../src/main/XlsxExporter');
describe('XlsxExporter', () => {
describe('columnLetter', () => {
it('maps 0-based indexes to Excel letters', () => {
expect(columnLetter(0)).toBe('A');
expect(columnLetter(25)).toBe('Z');
expect(columnLetter(26)).toBe('AA');
expect(columnLetter(27)).toBe('AB');
expect(columnLetter(52)).toBe('BA');
});
});
describe('looksNumeric', () => {
it('accepts integers and decimals, rejects text and leading zeros', () => {
expect(looksNumeric('42')).toBe(true);
expect(looksNumeric('-3.14')).toBe(true);
expect(looksNumeric('007')).toBe(false); // keeps identifier-looking values as text
expect(looksNumeric('1,000')).toBe(false);
expect(looksNumeric('abc')).toBe(false);
expect(looksNumeric('')).toBe(false);
});
});
describe('buildSheetXml', () => {
it('emits inline strings and numeric cells with row/column refs', () => {
const xml = buildSheetXml([
['Name', 'Score'],
['Ada', '99'],
]);
expect(xml).toContain(
'<c r="A1" t="inlineStr"><is><t xml:space="preserve">Name</t></is></c>'
);
expect(xml).toContain('<c r="B2" t="n"><v>99</v></c>');
expect(xml).toContain('<row r="1">');
});
it('escapes XML-significant characters in cells', () => {
const xml = buildSheetXml([['<b>&"quotes"</b>']]);
expect(xml).toContain('&lt;b&gt;&amp;&quot;quotes&quot;&lt;/b&gt;');
expect(xml).not.toContain('<b>');
});
});
describe('buildXlsx', () => {
it('creates a workbook with one sheet per table and all required parts', async () => {
const buffer = await buildXlsx([
[
['h1', 'h2'],
['a', '1'],
],
[['only', 'column']],
]);
const zip = await JSZip.loadAsync(buffer);
const parts = Object.keys(zip.files).sort();
expect(parts).toContain('[Content_Types].xml');
expect(parts).toContain('_rels/.rels');
expect(parts).toContain('xl/workbook.xml');
expect(parts).toContain('xl/_rels/workbook.xml.rels');
expect(parts).toContain('xl/styles.xml');
expect(parts).toContain('xl/worksheets/sheet1.xml');
expect(parts).toContain('xl/worksheets/sheet2.xml');
const workbook = await zip.file('xl/workbook.xml').async('string');
expect(workbook).toContain('name="Table 1"');
expect(workbook).toContain('name="Table 2"');
expect(workbook).toContain('r:id="rId2"');
const rels = await zip.file('xl/_rels/workbook.xml.rels').async('string');
expect(rels).toContain('Target="worksheets/sheet1.xml"');
expect(rels).toContain('Target="styles.xml"');
const sheet1 = await zip.file('xl/worksheets/sheet1.xml').async('string');
expect(sheet1).toContain('inlineStr');
});
it('rejects empty table lists', async () => {
await expect(buildXlsx([])).rejects.toThrow(/No tables/);
});
});
});