import { describe, expect, it } from 'vitest'; import * as XLSX from 'xlsx'; import { HandelswareFileError, MAX_DATA_ROWS, parseHandelswareXlsx, parseUmsatz, } from './handelsware-xlsx'; /** Baut eine Arbeitsmappe aus einer Matrix (Zeile 1 = Kopf). */ function workbook(aoa: unknown[][]): Buffer { const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(aoa), 'Blatt1'); return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }) as Buffer; } describe('parseUmsatz', () => { it('uebernimmt Zahlen unveraendert', () => { expect(parseUmsatz(12.5)).toBe(12.5); expect(parseUmsatz(-3)).toBe(-3); }); it('liest deutsche Texte', () => { expect(parseUmsatz('1.234,56')).toBe(1234.56); expect(parseUmsatz('-12,5')).toBe(-12.5); expect(parseUmsatz(' 7,00 ')).toBe(7); }); it('liest Text mit Punkt als Dezimalzeichen', () => { expect(parseUmsatz('12.5')).toBe(12.5); }); it.each(['', 'abc', '1,2,3', '12,5x', '--1', null, undefined, true, NaN])('lehnt %j ab', (v) => { expect(parseUmsatz(v)).toBeNull(); }); }); describe('parseHandelswareXlsx', () => { it('liest Kopftext aus B1 (Text oder Zahl) und die Zeilen ab Zeile 2', () => { const textHeader = parseHandelswareXlsx( workbook([ ['', 'Marz'], ['Kaffee', 12.5], ]), ); expect(textHeader.headerText).toBe('Marz'); const numberHeader = parseHandelswareXlsx( workbook([ ['', 2025], ['Kaffee', 1], ]), ); expect(numberHeader.headerText).toBe('2025'); }); it('liest Zahlen und deutsche Texte als Umsatz und merkt sich die Zeilennummer', () => { const r = parseHandelswareXlsx( workbook([ ['', 'X'], ['Kaffee', 12.5], ['Tee', '1.234,56'], ['Kakao', '-12,5'], ]), ); expect(r.rows).toEqual([ { line: 2, buchungstext: 'Kaffee', umsatz: 12.5 }, { line: 3, buchungstext: 'Tee', umsatz: 1234.56 }, { line: 4, buchungstext: 'Kakao', umsatz: -12.5 }, ]); expect(r.rowErrors).toEqual([]); }); it('endet an der ersten Zeile, in der A und B leer sind', () => { const r = parseHandelswareXlsx(workbook([['', 'X'], ['Kaffee', 1], [], ['Tee', 2]])); expect(r.rows.map((x) => x.buchungstext)).toEqual(['Kaffee']); }); it('meldet nicht numerischen oder leeren Umsatz als Zeilenfehler', () => { const r = parseHandelswareXlsx( workbook([ ['', 'X'], ['Kaffee', 'viel'], ['Tee', null], ['Kakao', 3], ]), ); expect(r.rowErrors).toEqual([ expect.objectContaining({ line: 2, code: 'umsatzInvalid' }), expect.objectContaining({ line: 3, code: 'umsatzInvalid' }), ]); expect(r.rows).toHaveLength(1); }); it('ueberspringt eine Zeile ohne Buchungstext, aber mit Wert (wie die Vorlage)', () => { const r = parseHandelswareXlsx( workbook([ ['', 'X'], ['', 5], ['Kaffee', 1], ]), ); expect(r.rows.map((x) => x.buchungstext)).toEqual(['Kaffee']); expect(r.rowErrors).toEqual([]); }); it('ersetzt Tabulatoren und Zeilenumbrueche im Text durch Leerzeichen', () => { const r = parseHandelswareXlsx( workbook([ ['', 'Kopf\tText'], ['Kaf\tfee\nneu', 1], ]), ); expect(r.headerText).toBe('Kopf Text'); expect(r.rows[0].buchungstext).toBe('Kaf fee neu'); }); it('wirft invalidFile bei Muelldaten', () => { expect(() => parseHandelswareXlsx(Buffer.from('das ist keine Excel-Datei;1;2'))).toThrow( HandelswareFileError, ); try { parseHandelswareXlsx(Buffer.from([1, 2, 3, 4, 5, 6])); expect.unreachable(); } catch (e) { expect((e as HandelswareFileError).code).toBe('invalidFile'); } }); it('wirft invalidFile bei kaputtem ZIP', () => { const broken = Buffer.concat([Buffer.from([0x50, 0x4b, 0x03, 0x04]), Buffer.from('kaputt')]); expect(() => parseHandelswareXlsx(broken)).toThrow(HandelswareFileError); }); it('wirft tooManyRows ab mehr als 10 000 Datenzeilen, nicht davor', () => { const header = ['', 'X']; const make = (n: number) => workbook([header, ...Array.from({ length: n }, (_, i) => [`P${i}`, 1])]); expect(parseHandelswareXlsx(make(MAX_DATA_ROWS)).rows).toHaveLength(MAX_DATA_ROWS); try { parseHandelswareXlsx(make(MAX_DATA_ROWS + 1)); expect.unreachable(); } catch (e) { expect((e as HandelswareFileError).code).toBe('tooManyRows'); } }); });