import { test } from 'node:test' import assert from 'node:assert/strict' import { useTestEnv } from './support/testEnv.js' import { beispielAuftrag, beispielSettings } from './auftraegePdfHilfe.js' useTestEnv({ appwriteUrl: 'http://127.0.0.1:1', stripeUrl: 'http://127.0.0.1:1' }) const { auftragPdfDaten, paginiere, baueAtome, BEREICHE, SEITE, SPALTEN, INHALT_BREITE } = await import('../server/services/auftraege/pdf.js') // Kleine Seiten: erste Seite 0–200 pt, Folgeseiten 20–200 pt const SEITEN = { erste: { oben: 0, unten: 200 }, folge: { oben: 20, unten: 200 } } const baustein = (id, hoehe, extra = {}) => ({ id, typ: 'absatz', hoehe, abstandDavor: 0, haengtAnNaechstem: false, umbruchDavor: false, kopf: null, ...extra }) const lage = (seiten) => seiten.map((s) => s.posten.map((p) => `${p.atom.id}${p.wiederholt ? '*' : ''}@${p.y}`)) test('Maße: A4, Inhaltsbreite und Spaltenbreiten passen zusammen', () => { assert.equal(SEITE.breite, 595.28) assert.equal(SEITE.hoehe, 841.89) assert.ok(Math.abs(INHALT_BREITE - 481.88) < 1e-9) assert.ok(Math.abs(SPALTEN.reduce((a, b) => a + b, 0) - INHALT_BREITE) < 1e-9) assert.ok(BEREICHE.erste.oben < BEREICHE.folge.oben && BEREICHE.erste.unten === BEREICHE.folge.unten) assert.ok(BEREICHE.erste.unten < SEITE.hoehe - 80, 'Platz für den Fuß bleibt frei') }) test('paginiere: alles passt auf eine Seite, Abstände nur zwischen Bausteinen', () => { const seiten = paginiere([baustein('a', 30, { abstandDavor: 9 }), baustein('b', 20, { abstandDavor: 8 }), baustein('c', 10, { abstandDavor: 8 })], SEITEN) assert.deepEqual(lage(seiten), [['a@0', 'b@38', 'c@66']]) assert.deepEqual(paginiere([], SEITEN).map((s) => s.posten), [[]], 'auch ohne Inhalt gibt es eine Seite') }) test('paginiere: was nicht mehr passt, beginnt die nächste Seite ohne Abstand davor', () => { const seiten = paginiere([baustein('a', 170), baustein('b', 40, { abstandDavor: 5 }), baustein('c', 40, { abstandDavor: 5 })], SEITEN) assert.deepEqual(lage(seiten), [['a@0'], ['b@20', 'c@65']]) assert.deepEqual(seiten.map((s) => s.nummer), [1, 2]) }) test('paginiere: Überschrift (haengtAnNaechstem) bleibt bei ihrem Absatz', () => { const seiten = paginiere([baustein('a', 150), baustein('kopf', 15, { abstandDavor: 5, haengtAnNaechstem: true }), baustein('text', 40)], SEITEN) assert.deepEqual(lage(seiten), [['a@0'], ['kopf@20', 'text@35']]) }) test('paginiere: umbruchDavor beginnt eine neue Seite, aber nie eine leere', () => { assert.deepEqual(lage(paginiere([baustein('a', 30), baustein('b', 30, { umbruchDavor: true })], SEITEN)), [['a@0'], ['b@20']]) assert.deepEqual(lage(paginiere([baustein('a', 30, { umbruchDavor: true })], SEITEN)), [['a@0']]) assert.deepEqual(lage(paginiere([baustein('a', 200), baustein('b', 30, { umbruchDavor: true })], SEITEN)), [['a@0'], ['b@20']]) }) const tabelle = (zeilen, extra = {}) => { const kopf = baustein('kopf', 20, { typ: 'tabellenkopf', haengtAnNaechstem: true }) const rows = Array.from({ length: zeilen }, (_, i) => baustein(`z${i + 1}`, 30, { typ: 'zeile', kopf, haengtAnNaechstem: i === zeilen - 1 && extra.mitSumme })) return [kopf, ...rows, ...(extra.mitSumme ? [baustein('summe', 30, { haengtAnNaechstem: true }), baustein('hinweis', 10)] : [])] } test('paginiere: lange Tabelle läuft über mehrere Seiten, der Kopf wiederholt sich, keine Zeile wird geteilt', () => { const seiten = paginiere(tabelle(20), SEITEN) assert.deepEqual(lage(seiten).map((s) => s.length), [7, 6, 6, 5], 'Seite 1: Kopf + 6 Zeilen; Folgeseiten: wiederholter Kopf + je 5 Zeilen, Rest auf der letzten') assert.equal(seiten.length, 4) for (const seite of seiten.slice(1)) { assert.equal(seite.posten[0].atom.id, 'kopf') assert.equal(seite.posten[0].wiederholt, true) assert.equal(seite.posten[0].y, 20) } const zeilen = seiten.flatMap((s) => s.posten.filter((p) => !p.wiederholt).map((p) => p.atom.id)) assert.deepEqual(zeilen, ['kopf', ...Array.from({ length: 20 }, (_, i) => `z${i + 1}`)]) for (const seite of seiten) for (const p of seite.posten) assert.ok(p.y + p.atom.hoehe <= seite.unten, `${p.atom.id} ragt über die Seite`) }) test('paginiere: letzte Zeile, Summe und Hinweis bleiben zusammen, auch wenn die Zeile allein noch gepasst hätte', () => { // Seite 1: Kopf (20) + 6 Zeilen (180) = 200 – Zeile 6 ist die letzte und hängt an Summe und Hinweis const seiten = paginiere(tabelle(6, { mitSumme: true }), SEITEN) assert.deepEqual(lage(seiten), [['kopf@0', 'z1@20', 'z2@50', 'z3@80', 'z4@110', 'z5@140'], ['kopf*@20', 'z6@40', 'summe@70', 'hinweis@100']]) }) test('paginiere: Kette größer als eine Seite wird zwischen Bausteinen getrennt, zu hoher Baustein steht allein', () => { const kette = [baustein('a', 120, { haengtAnNaechstem: true }), baustein('b', 120, { haengtAnNaechstem: true }), baustein('c', 120)] assert.deepEqual(lage(paginiere(kette, SEITEN)), [['a@0'], ['b@20'], ['c@20']]) assert.deepEqual(lage(paginiere([baustein('a', 50), baustein('riesig', 500), baustein('b', 50)], SEITEN)), [['a@0'], ['riesig@20'], ['b@20']]) }) // Messfunktion ohne pdfkit: 10 pt je angefangene 60 Zeichen const messe = (text) => 10 * Math.max(1, Math.ceil(String(text).length / 60)) const bausteine = (extra = {}, optionen = {}) => baueAtome(auftragPdfDaten(beispielAuftrag(extra), { settings: beispielSettings, ...optionen }), messe) const ids = (atome) => atome.map((a) => a.id) test('baueAtome Festpreis: Reihenfolge, Zeilenhöhen und Zusammenhalt der Tabelle', () => { const atome = bausteine() assert.deepEqual(ids(atome), [ 'briefkopf', 'einleitung', 'leistung', 'tabellenkopf', 'zeile-1', 'zeile-2', 'summe', 'kleinunternehmer', 'zahlung-kopf', 'zahlung', 'termine-kopf', 'termine', 'mitwirkung-kopf', 'mitwirkung', 'agb', 'gruss', ]) const nach = (id) => atome.find((a) => a.id === id) assert.equal(nach('briefkopf').typ, 'briefkopf') assert.ok(nach('briefkopf').hoehe > 150) assert.equal(nach('briefkopf').masse.infoZeilen.length, 3) assert.equal(nach('zeile-1').hoehe, 10 + 12, 'eine Zeile Text plus 2 × 6 pt Rand') assert.deepEqual([nach('leistung'), nach('tabellenkopf'), nach('zeile-1'), nach('zeile-2'), nach('summe'), nach('kleinunternehmer')].map((a) => a.haengtAnNaechstem), [true, true, false, true, true, false]) assert.equal(nach('zeile-1').kopf, nach('tabellenkopf')) assert.equal(nach('zeile-2').kopf, nach('tabellenkopf')) assert.equal(nach('summe').label, 'Gesamtpreis') assert.ok(atome.every((a) => a.hoehe > 0 && Number.isFinite(a.hoehe))) }) test('baueAtome Aufwand, ersetzt und Privatperson: nur die passenden Bausteine', () => { const aufwand = bausteine({ art: 'aufwand', positionen: [], summeCents: null, stundensatzCents: 10000, schaetzungMinuten: 600, zahlungsplan: { art: 'monatlich', prozent: null, text: '' } }) assert.ok(ids(aufwand).includes('aufwand') && !ids(aufwand).includes('tabellenkopf') && !ids(aufwand).includes('summe')) assert.equal(aufwand.find((a) => a.id === 'aufwand').kasten, true) const mitErsetzt = bausteine({}, { ersetzt: { nummer: 'AB-2026-0003', datum: '2026-10-01' } }) assert.ok(ids(mitErsetzt).indexOf('ersetzt') > ids(mitErsetzt).indexOf('agb') && ids(mitErsetzt).indexOf('ersetzt') < ids(mitErsetzt).indexOf('gruss')) assert.equal(ids(bausteine()).some((id) => /^(widerruf|formular)/.test(id)), false) const privat = bausteine({ kunde: { ...beispielAuftrag().kunde, kundenart: 'privat' } }) const titel = privat.find((a) => a.id === 'widerruf-titel') assert.equal(titel.umbruchDavor, true) assert.ok(ids(privat).indexOf('formular-titel') > ids(privat).indexOf('widerruf-titel')) assert.equal(privat.find((a) => a.id === 'formular-titel').haengtAnNaechstem, true) assert.equal(privat.at(-1).haengtAnNaechstem, false) }) test('20 Positionen mit je 200 Zeichen ohne Leerzeichen: alle Zeilen da, mehrere Seiten, Tabellenkopf auf jeder Folgeseite', () => { const positionen = Array.from({ length: 20 }, (_, i) => ({ beschreibung: 'X'.repeat(200), menge: i + 1, einzelpreisCents: 1000 })) const summe = positionen.reduce((s, p) => s + p.menge * p.einzelpreisCents, 0) const seiten = paginiere(bausteine({ positionen, summeCents: summe }), BEREICHE) assert.ok(seiten.length >= 2) const zeilen = seiten.flatMap((s) => s.posten.filter((p) => p.atom.typ === 'zeile').map((p) => p.atom.id)) assert.deepEqual(zeilen, Array.from({ length: 20 }, (_, i) => `zeile-${i + 1}`)) for (const seite of seiten.slice(1)) { const ersteZeile = seite.posten.findIndex((p) => p.atom.typ === 'zeile') if (ersteZeile >= 0) assert.equal(seite.posten[0].atom.typ, 'tabellenkopf', `Seite ${seite.nummer} beginnt mit dem Tabellenkopf`) } const letzte = seiten.at(-1).posten.map((p) => p.atom.id) assert.ok(letzte.indexOf('summe') > letzte.indexOf('zeile-20'), 'Summe steht bei der letzten Zeile') for (const seite of seiten) for (const p of seite.posten) assert.ok(p.y + p.atom.hoehe <= seite.unten + 1e-9) }) test('lange Widerrufsbelehrung: beginnt auf einer neuen Seite, das Formular bleibt zusammen', () => { const absatz = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. '.repeat(12) const d = auftragPdfDaten(beispielAuftrag({ kunde: { ...beispielAuftrag().kunde, kundenart: 'privat' } }), { settings: beispielSettings }) d.widerruf = { ...d.widerruf, belehrung: { titel: 'Widerrufsbelehrung', abschnitte: Array.from({ length: 6 }, (_, i) => ({ ueberschrift: `Abschnitt ${i + 1}`, absaetze: [absatz, absatz] })) } } const seiten = paginiere(baueAtome(d, messe), BEREICHE) const seiteVon = (id) => seiten.find((s) => s.posten.some((p) => p.atom.id === id)).nummer assert.ok(seiten.length >= 4) assert.ok(seiteVon('widerruf-titel') >= 2) assert.equal(seiten[seiteVon('widerruf-titel') - 1].posten.find((p) => !p.wiederholt).atom.id, 'widerruf-titel', 'Belehrung beginnt oben auf einer neuen Seite') const formular = seiten.flatMap((s) => s.posten.filter((p) => p.atom.id.startsWith('formular')).map(() => s.nummer)) assert.ok(formular.length >= 2 && new Set(formular).size === 1, 'alle Zeilen des Formulars stehen auf derselben Seite') for (const seite of seiten) for (const p of seite.posten) assert.ok(p.y + p.atom.hoehe <= seite.unten + 1e-9) })