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 { 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 = ` `; 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); }); });