143 lines
6.5 KiB
TypeScript
143 lines
6.5 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import ExcelJS from 'exceljs';
|
|
import { mkdtempSync } from 'fs';
|
|
import { tmpdir } from 'os';
|
|
import { join } from 'path';
|
|
import { resolveThemePalette, applyTint, extractSheetStyles } from './excel-styles.js';
|
|
|
|
async function roundTrip(build: (ws: ExcelJS.Worksheet) => void): Promise<ExcelJS.Worksheet> {
|
|
const wb = new ExcelJS.Workbook(); const ws = wb.addWorksheet('S'); build(ws);
|
|
const p = join(mkdtempSync(join(tmpdir(), 'xls-')), 't.xlsx');
|
|
await wb.xlsx.writeFile(p);
|
|
const wb2 = new ExcelJS.Workbook(); await wb2.xlsx.readFile(p);
|
|
return wb2.getWorksheet('S')!;
|
|
}
|
|
|
|
describe('resolveThemePalette', () => {
|
|
it('parses srgbClr + sysClr and applies the lt/dk swap', () => {
|
|
const xml = `<a:theme><a:themeElements><a:clrScheme name="x">
|
|
<a:dk1><a:sysClr val="windowText" lastClr="000000"/></a:dk1>
|
|
<a:lt1><a:sysClr val="window" lastClr="FFFFFF"/></a:lt1>
|
|
<a:dk2><a:srgbClr val="44546A"/></a:dk2>
|
|
<a:lt2><a:srgbClr val="E7E6E6"/></a:lt2>
|
|
<a:accent1><a:srgbClr val="4472C4"/></a:accent1>
|
|
<a:accent2><a:srgbClr val="ED7D31"/></a:accent2>
|
|
<a:accent3><a:srgbClr val="A5A5A5"/></a:accent3>
|
|
<a:accent4><a:srgbClr val="FFC000"/></a:accent4>
|
|
<a:accent5><a:srgbClr val="5B9BD5"/></a:accent5>
|
|
<a:accent6><a:srgbClr val="70AD47"/></a:accent6>
|
|
<a:hlink><a:srgbClr val="0563C1"/></a:hlink>
|
|
<a:folHlink><a:srgbClr val="954F72"/></a:folHlink>
|
|
</a:clrScheme></a:themeElements></a:theme>`;
|
|
const pal = resolveThemePalette(xml);
|
|
expect(pal[0]).toBe('#FFFFFF'); // theme idx 0 = Light1 = lt1
|
|
expect(pal[1]).toBe('#000000'); // theme idx 1 = Dark1 = dk1
|
|
expect(pal[4]).toBe('#4472C4'); // accent1
|
|
});
|
|
it('returns [] on missing scheme', () => { expect(resolveThemePalette(undefined)).toEqual([]); });
|
|
});
|
|
|
|
describe('applyTint', () => {
|
|
it('lightens with positive tint and darkens with negative', () => {
|
|
const lighter = applyTint('#4472C4', 0.4);
|
|
const darker = applyTint('#4472C4', -0.4);
|
|
expect(lighter).not.toBe('#4472C4');
|
|
expect(darker).not.toBe('#4472C4');
|
|
// positive tint raises luminance
|
|
const lum = (h: string) => parseInt(h.slice(1,3),16)+parseInt(h.slice(3,5),16)+parseInt(h.slice(5,7),16);
|
|
expect(lum(lighter)).toBeGreaterThan(lum('#4472C4'));
|
|
expect(lum(darker)).toBeLessThan(lum('#4472C4'));
|
|
});
|
|
});
|
|
|
|
describe('extractSheetStyles', () => {
|
|
it('dedups identical fills into one legend entry with a merged range', async () => {
|
|
const ws = await roundTrip((ws) => {
|
|
for (const a of ['A1','B1','C1','D1']) ws.getCell(a).fill = { type:'pattern', pattern:'solid', fgColor:{ argb:'FFFFF2CC' } };
|
|
});
|
|
const r = extractSheetStyles(ws, [], 250);
|
|
expect(r.legend.length).toBe(1);
|
|
expect(r.legend[0].sig).toContain('#FFF2CC');
|
|
expect(r.assignments[0].ranges).toEqual(['A1:D1']); // horizontal run merged
|
|
});
|
|
it('captures a decorated but EMPTY cell', async () => {
|
|
const ws = await roundTrip((ws) => { ws.getCell('B5').fill = { type:'pattern', pattern:'solid', fgColor:{ argb:'FFFF0000' } }; });
|
|
const r = extractSheetStyles(ws, [], 250);
|
|
expect(r.assignments.some(a => a.ranges.includes('B5'))).toBe(true);
|
|
});
|
|
it('reports font bold + color', async () => {
|
|
const ws = await roundTrip((ws) => { ws.getCell('A1').value='x'; ws.getCell('A1').font={ bold:true, color:{ argb:'FF9C0006' } }; });
|
|
const r = extractSheetStyles(ws, [], 250);
|
|
expect(r.legend[0].sig).toMatch(/font:.*bold/);
|
|
expect(r.legend[0].sig).toContain('#9C0006');
|
|
});
|
|
it('merges vertically-adjacent identical column-runs into a rectangle', async () => {
|
|
const ws = await roundTrip((ws) => {
|
|
for (let row=2; row<=20; row++) ws.getCell(`B${row}`).fill={ type:'pattern', pattern:'solid', fgColor:{ argb:'FF00FF00' } };
|
|
});
|
|
const r = extractSheetStyles(ws, [], 250);
|
|
expect(r.assignments[0].ranges).toEqual(['B2:B20']);
|
|
});
|
|
it('respects max_style_ranges cap', async () => {
|
|
const ws = await roundTrip((ws) => {
|
|
// 5 disjoint single cells with distinct fills (no merge possible)
|
|
const colors=['FFAA0000','FF00AA00','FF0000AA','FFAAAA00','FF00AAAA'];
|
|
colors.forEach((c,i)=>{ ws.getCell(`A${i*2+1}`).fill={type:'pattern',pattern:'solid',fgColor:{argb:c}}; });
|
|
});
|
|
const r = extractSheetStyles(ws, [], 2);
|
|
expect(r.truncated).toBe(true);
|
|
});
|
|
|
|
// Fix 4 (M-1): merges
|
|
it('includes merged cell range in result merges', async () => {
|
|
const ws = await roundTrip((ws) => {
|
|
ws.getCell('A1').value = 'merged';
|
|
ws.mergeCells('A1:C2');
|
|
});
|
|
const r = extractSheetStyles(ws, [], 250);
|
|
expect(r.merges).toContain('A1:C2');
|
|
});
|
|
|
|
// Fix 4 (M-2): conditional formatting — round-trip keeps conditionalFormattings
|
|
it('detects conditional formatting', async () => {
|
|
const ws = await roundTrip((ws) => {
|
|
ws.addConditionalFormatting({
|
|
ref: 'A1:A10',
|
|
rules: [{ type: 'expression', formulae: ['TRUE'], style: { fill: { type: 'pattern', pattern: 'solid', bgColor: { argb: 'FFFF0000' } } } }],
|
|
});
|
|
});
|
|
// ExcelJS preserves conditionalFormattings after round-trip (empirically verified)
|
|
const r = extractSheetStyles(ws, [], 250);
|
|
expect(r.conditionalFormatting).toBe(true);
|
|
});
|
|
|
|
// Fix 4 (M-3): border + numFmt + alignment
|
|
it('captures border, numFmt, and alignment in cell signature', async () => {
|
|
const ws = await roundTrip((ws) => {
|
|
const cell = ws.getCell('B2');
|
|
cell.value = 0.42;
|
|
cell.border = { bottom: { style: 'thin' } };
|
|
cell.numFmt = '0.00%';
|
|
cell.alignment = { horizontal: 'center' };
|
|
});
|
|
const r = extractSheetStyles(ws, [], 250);
|
|
expect(r.legend.length).toBeGreaterThan(0);
|
|
const sig = r.legend[0]!.sig;
|
|
expect(sig).toContain('border:');
|
|
expect(sig).toContain('numFmt:"0.00%"');
|
|
expect(sig).toContain('align:');
|
|
});
|
|
|
|
// Fix 4 (I-1 range honoring): rangeBounds filters cells
|
|
it('honors rangeBounds — includes A1 but excludes Z99', async () => {
|
|
const ws = await roundTrip((ws) => {
|
|
ws.getCell('A1').fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF0000FF' } };
|
|
ws.getCell('Z99').fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF00FF00' } };
|
|
});
|
|
const r = extractSheetStyles(ws, [], 250, { minRow: 1, maxRow: 5, minCol: 1, maxCol: 5 });
|
|
const allRanges = r.assignments.flatMap(a => a.ranges);
|
|
expect(allRanges.some(rng => rng === 'A1' || rng.startsWith('A1:'))).toBe(true);
|
|
expect(allRanges.every(rng => !rng.includes('Z99') && !rng.startsWith('Z'))).toBe(true);
|
|
});
|
|
});
|