import { test } from 'node:test' import assert from 'node:assert/strict' import { headerHtml, tabsHtml, overviewHtml, summaryHtml, kpisHtml, sparkHtml, chartHtml, chartTipText, contactsHtml, rankCardHtml, basisHtml, heatTeaserHtml, skeletonHtml, errorHtml, settingsHtml } from '../public/analyse-cards.js' // Testdaten: Form wie analytics/src/stats.js (gleiche Helfer wie test/analyseInsights.test.js) const isoDay = (from, n) => new Date(Date.UTC(+from.slice(0, 4), +from.slice(5, 7) - 1, +from.slice(8, 10) + n)).toISOString().slice(0, 10) const days = (from, n, pv) => Array.from({ length: n }, (_, i) => ({ day: isoDay(from, i), pv: typeof pv === 'function' ? pv(i, isoDay(from, i)) : pv })) const rich = (rows) => rows.map((d) => ({ ...d, visitors: 80, contacts: 6, avgSec: 100 })) const count = (html, re) => (html.match(re) || []).length const t = { pv: 6930, visitors: 2410, contacts: 188, consentRate: 0.61, engaged: 1800, avgSec: 108, avgScroll: 64 } const prev = { from: '2026-08-06', to: '2026-09-04', complete: true, totals: { pv: 6080, visitors: 2200, contacts: 143, consentRate: 0.6, engaged: 1500, avgSec: 114, avgScroll: 60 }, series: rich(days('2026-08-06', 30, 203)), contacts: [{ key: 'tel', count: 80 }, { key: 'mail', count: 40 }, { key: 'wa', count: 23 }], } const stats = (over = {}) => ({ range: 30, from: '2026-09-05', to: '2026-10-04', firstDay: '2026-07-01', totals: t, series: rich(days('2026-09-05', 30, (i) => (i === 14 ? 371 : 231))), pages: [{ key: '/', count: 3200 }, { key: '/preise', count: 1310 }, { key: '/kontakt', count: 900 }], refs: [{ key: 'google.com', count: 880 }, { key: '', count: 700 }, { key: 'instagram.com', count: 400 }, { key: 'l.facebook.com', count: 290 }], countries: [{ key: 'DE', count: 6000 }, { key: 'AT', count: 600 }, { key: '', count: 330 }], devices: [{ key: 'm', count: 4590 }, { key: 'd', count: 2200 }, { key: 't', count: 140 }], contacts: [{ key: 'tel', count: 96 }, { key: 'mail', count: 52 }, { key: 'wa', count: 30 }, { key: 'out', count: 10 }], banner: { shown: 3950, accept: 2410, reject: 1540 }, previous: prev, ...over, }) const full = stats() const zeroTotals = { pv: 0, visitors: 0, contacts: 0, consentRate: 0, engaged: 0, avgSec: null, avgScroll: null } const zero = { range: 30, from: '2026-09-05', to: '2026-10-04', firstDay: '', totals: zeroTotals, series: days('2026-09-05', 30, 0).map((d) => ({ ...d, visitors: 0, contacts: 0, avgSec: null })), pages: [], refs: [], countries: [], devices: [], contacts: [], banner: { shown: 0, accept: 0, reject: 0 }, previous: { from: '2026-08-06', to: '2026-09-04', complete: false, totals: zeroTotals, series: [], contacts: [] }, } const OLD = { totals: { pv: 612, visitors: 214, contacts: 18, consentRate: 0.62 }, series: [{ day: '2026-09-17', pv: 20 }, { day: '2026-09-18', pv: 30 }], pages: [], refs: [], countries: [], devices: [], contacts: [], banner: { shown: 1, accept: 1, reject: 0 } } const project = (over = {}) => ({ projectId: 'p1', projectName: 'Boulderhalle Überhang', analyse: { state: 'active', devMode: false, accentColor: '#1d4ed8', avvVersion: '2026-10-02', legal: { avv: '/rechtliches/avv-website-analyse-2026-10-02.html', lb: '/rechtliches/leistungsbeschreibung-website-analyse-2026-10-02.html' }, activatedAt: '2026-10-01T22:30:00.000Z' }, ...over }) test('overviewHtml: volle Daten – Kacheln mit Chips, Vorzeitraum-Linie, Kontakte, Quote', () => { const h = overviewHtml(full) assert.match(h, /▲ 14 %/); assert.match(h, /vs\. 6\.080/); assert.match(h, /class="an-prev"/) assert.match(h, /Ø Zeit pro Seite/); assert.match(h, /1:48/); assert.match(h, /Ø 64 % gescrollt/) assert.match(h, /Auf 100 Besucher kommen 7,8 Kontakt-Aktionen \(davor 6,5\)\./) assert.match(h, /61 % haben dem Banner zugestimmt \(2\.410 von 3\.950 Entscheidungen\)/) assert.match(h, /Das Wichtigste · 5\. Sep\. – 4\. Okt\./) assert.match(h, /14 % mehr<\/b>/) // Satz aus summarize assert.match(h, /Länderdaten: IP-Geolocation by DB-IP<\/a>/) assert.match(h, /data-rank="seen"/); assert.match(h, /data-rank="who"/); assert.match(h, /data-tab="heatmap"/) assert.doesNotMatch(h, /undefined|NaN|Infinity|null/) }) test('overviewHtml: alter Collector ohne previous/firstDay – keine Chips, kein Fehler', () => { const h = overviewHtml(OLD) assert.doesNotMatch(h, /▲|▼|undefined|NaN|null/); assert.doesNotMatch(h, /class="an-prev"/) assert.doesNotMatch(h, /an-delta/); assert.doesNotMatch(h, /Vergleich ab/) assert.doesNotMatch(h, /Ø \d+ % gescrollt/) // kein avgScroll assert.match(h, /612<\/b>/) assert.equal(count(h, / { const h = overviewHtml(zero) assert.match(h, /Sobald ein paar mehr Besuche da sind/); assert.match(h, /Noch hat niemand über das Banner entschieden\./) assert.match(h, /Noch keine Kontakt-Aktionen\. Sie zählen, wenn jemand auf deiner Website auf Telefonnummer, E-Mail-Adresse oder WhatsApp tippt\./) assert.doesNotMatch(h, /NaN|Infinity|undefined|null/) assert.doesNotMatch(h, /an-delta|Vergleich ab/) // firstDay '' => kein Datum assert.match(overviewHtml({}), /Sobald ein paar mehr Besuche da sind/) // sogar eine leere Antwort bricht nichts assert.doesNotMatch(overviewHtml({}), /NaN|Infinity|undefined|null/) }) test('summaryHtml: Satz und Hinweise mit Symbol; Leerzustand unter 30 Aufrufen', () => { const h = summaryHtml(full) assert.match(h, /Deine Website wurde in den letzten 30 Tagen 6\.930-mal<\/b> aufgerufen/) assert.equal(count(h, /
  • /g), 4); assert.match(h, /
      Das Wichtigste<\/h3>/) // ohne from/to kein Zeitraum }) test('kpisHtml: Chips, Vorwerte, Mini-Verläufe, Zeit mit Scrolltiefe', () => { const h = kpisHtml(full) assert.equal(count(h, /class="an-card an-kpi"/g), 4) assert.equal(count(h, /an-delta up/g), 3); assert.equal(count(h, /an-delta down/g), 1) assert.match(h, /▲ 10 %/); assert.match(h, /▲ 31 %/); assert.match(h, /▼ 6 s/) assert.match(h, /vs\. 2\.200/); assert.match(h, /vs\. 143/); assert.match(h, /vs\. 1:54/) assert.match(h, /aria-label="plus 14 Prozent gegenüber dem Zeitraum davor"/) assert.match(h, /Besucher\*<\/span>/) assert.equal(count(h, / ({ ...d, avgSec: i === 0 ? null : 100 })) })) assert.equal(count(noTime, / d) })), /–<\/b>/); assert.doesNotMatch(none, /gescrollt|vs\. –/) }) test('kpisHtml: neue Website zeigt "Vergleich ab 18.11."', () => { const h = kpisHtml(stats({ firstDay: '2026-09-20', previous: { ...prev, complete: false } })) assert.equal(count(h, /Vergleich ab 18\.11\./g), 4) assert.doesNotMatch(h, /an-delta|vs\./) const k7 = kpisHtml(stats({ range: 7, firstDay: '2026-09-28', previous: { ...prev, complete: false } })) assert.match(k7, /Vergleich ab 11\.10\./) // vollständiger Vorzeitraum, aber unter 20 Aufrufen: weder Chip noch Datum const tiny = kpisHtml(stats({ previous: { ...prev, totals: { ...prev.totals, pv: 5 } } })) assert.doesNotMatch(tiny, /an-delta|vs\.|Vergleich ab/) }) test('kpisHtml: Richtungen – weniger und gleich', () => { const less = kpisHtml(stats({ totals: { ...t, pv: 5000 } })) assert.match(less, /▼ 18 %<\/span>/) const same = kpisHtml(stats({ totals: { ...t, contacts: 143 } })) assert.match(same, /an-delta flat/); assert.match(same, /± 0/) }) test('sparkHtml: Fläche und Linie, nichts bei weniger als 2 Werten, kein NaN', () => { const h = sparkHtml([1, 5, 3, 8]) assert.match(h, / { const h = chartHtml(full) assert.match(h, /Seitenaufrufe pro Tag vom 05\.09\. bis 04\.10\., insgesamt 7\.\d{3}<\/title>/) assert.match(h, /class="an-area"/); assert.match(h, /class="an-line"/); assert.match(h, /class="an-prev"/) assert.match(h, /30 Tage/); assert.match(h, /Zeitraum davor/) assert.equal(count(h, /class="an-dot"/g), 30) assert.match(h, /data-i="14" /); assert.match(h, /data-day="2026-09-19"/); assert.match(h, /data-pv="371"/); assert.match(h, /data-prev="203"/) assert.match(h, /class="an-tip"/) // ohne Vergleich: keine gestrichelte Linie, keine zweite Legende, kein data-prev const nc = chartHtml(stats({ previous: { ...prev, complete: false } })) assert.doesNotMatch(nc, /class="an-prev"|Zeitraum davor|data-prev/) assert.match(nc, /30 Tage/) // ganz ohne Daten assert.match(chartHtml({ series: [] }), /Noch keine Daten\./) assert.doesNotMatch(chartHtml(zero), /NaN|Infinity|undefined|null/) }) test('chartTipText: Blase mit und ohne Vorwert', () => { assert.equal(chartTipText('2026-09-19', 371, 210), 'Sa., 19.09.: 371 Aufrufe (davor 210)') assert.equal(chartTipText('2026-09-19', '371', '210'), 'Sa., 19.09.: 371 Aufrufe (davor 210)') // Werte aus data-Attributen sind Strings assert.equal(chartTipText('2026-09-19', 371), 'Sa., 19.09.: 371 Aufrufe') assert.equal(chartTipText('2026-09-19', 371, null), 'Sa., 19.09.: 371 Aufrufe') assert.equal(chartTipText('2026-09-19', 1, 0), 'Sa., 19.09.: 1 Aufruf (davor 0)') assert.equal(chartTipText('2026-09-19', 6930, 6080), 'Sa., 19.09.: 6.930 Aufrufe (davor 6.080)') }) test('contactsHtml: Arten mit 0 in beiden Zeiträumen entfallen, Leertext ohne Kontakte', () => { const h = contactsHtml(full) assert.match(h, /188<\/b>/) assert.equal(count(h, /aria-hidden="true"><\/svg>/g), 0) // jede Zeile hat ihr Symbol assert.equal(count(h, /96<\/b>[^]*?▲ 16/) // 96 vs 80: beide unter 100 => absolut statt Prozent assert.match(h, /data-kind="out"[^]*?10<\/b>[^]*?▲ 10/) // 10 vs 0 // Art ohne Kontakte, in beiden Zeiträumen 0: Zeile entfällt const noOut = contactsHtml(stats({ contacts: [{ key: 'tel', count: 96 }, { key: 'mail', count: 52 }, { key: 'out', count: 0 }], totals: { ...t, contacts: 148 }, previous: { ...prev, contacts: [{ key: 'tel', count: 80 }, { key: 'mail', count: 40 }] } })) assert.doesNotMatch(noOut, /data-kind="out"|data-kind="wa"|WhatsApp|Links nach außen/) assert.match(noOut, /data-kind="tel"/); assert.match(noOut, /data-kind="mail"/) // Vorzeitraum hatte WhatsApp, jetzt 0: Zeile bleibt mit ▼ const lost = contactsHtml(stats({ contacts: [{ key: 'tel', count: 96 }], totals: { ...t, contacts: 96 } })) assert.match(lost, /data-kind="wa"[^]*?0<\/b>[^]*?▼ 23/) // ohne Vergleich: keine Chips, Zeilen nur mit Kontakten const nc = contactsHtml(stats({ previous: { ...prev, complete: false } })) assert.doesNotMatch(nc, /an-delta|davor/); assert.match(nc, /data-kind="tel"/) // Leertext const none = contactsHtml(zero) assert.match(none, /Noch keine Kontakt-Aktionen\. Sie zählen, wenn jemand auf deiner Website auf Telefonnummer, E-Mail-Adresse oder WhatsApp tippt\./) assert.doesNotMatch(none, /data-kind|an-quote/) // 0 Kontakte, aber der Vorzeitraum hatte welche: weiter Leertext (Kopfzahl 0) assert.match(contactsHtml(stats({ totals: { ...t, contacts: 0 }, contacts: [] })), /Noch keine Kontakt-Aktionen\./) }) test('contactsHtml: Quote pro 100 Besucher, mit und ohne Vorwert, erst ab 30 Besuchern', () => { assert.match(contactsHtml(full), /

      Auf 100 Besucher kommen 7,8 Kontakt-Aktionen \(davor 6,5\)\.<\/p>/) const nc = contactsHtml(stats({ previous: { ...prev, complete: false } })) assert.match(nc, /Auf 100 Besucher kommen 7,8 Kontakt-Aktionen\./) const fewPrev = contactsHtml(stats({ previous: { ...prev, totals: { ...prev.totals, visitors: 10 } } })) assert.match(fewPrev, /Auf 100 Besucher kommen 7,8 Kontakt-Aktionen\./) // Vorquote unsicher => ohne "davor" assert.doesNotMatch(contactsHtml(stats({ totals: { ...t, visitors: 29, contacts: 3 }, contacts: [{ key: 'tel', count: 3 }] })), /an-quote/) assert.match(contactsHtml(stats({ totals: { ...t, visitors: 400, contacts: 28 } })), /kommen 7,0 Kontakt-Aktionen/) // immer eine Nachkommastelle }) test('rankCardHtml: Startseite statt /, Anteil in %, 6 Zeilen, expanded bis 20, Herkunft mit refName, Pfad maskiert', () => { const h = rankCardHtml('seen', stats({ pages: [{ key: '/', count: 9 }] })) assert.match(h, /<img src=x>/); assert.doesNotMatch(h, //) const pages = rankCardHtml('seen', full) assert.match(pages, /Startseite/); assert.doesNotMatch(pages, />\/3\.200<\/b>59 %<\/em>/) // 3200 von 5410 assert.match(pages, /data-rank="seen" data-rank-active="pages"/) assert.match(pages, /

      Was angesehen wird<\/h3>/) assert.match(pages, /data-rank-tab="pages" aria-pressed="true"/); assert.match(pages, /data-rank-tab="refs" aria-pressed="false"/) // Herkunft const refs = rankCardHtml('seen', full, { tab: 'refs' }) assert.match(refs, /Google/); assert.match(refs, /Direkt oder unbekannt/); assert.match(refs, /Instagram/); assert.match(refs, /Facebook/) assert.doesNotMatch(refs, /google\.com|l\.facebook/); assert.match(refs, /data-rank-active="refs"/) // 6 Zeilen, "Alle anzeigen" nur bei mehr als 6; expanded bis 20 const many = stats({ pages: Array.from({ length: 25 }, (_, i) => ({ key: `/seite-${i}`, count: 100 - i })) }) const six = rankCardHtml('seen', many) assert.equal(count(six, /class="an-lr"/g), 6); assert.match(six, /data-rank-more[^>]*>Alle anzeigen ({ key: `/s${i}`, count: 9 })) })), /Alle anzeigen/) assert.equal(count(rankCardHtml('seen', stats({ pages: Array.from({ length: 6 }, (_, i) => ({ key: `/s${i}`, count: 9 })) })), /class="an-lr"/g), 6) assert.match(rankCardHtml('seen', many), /an-fill" style="width:100%"/) // größter Wert = volle Breite }) test('rankCardHtml: Geräte, Länder mit Flagge, ungültiger Reiter, leere Liste, Anteil unter 1 %', () => { const dev = rankCardHtml('who', full) assert.match(dev, /

      Wer zu Besuch ist<\/h3>/); assert.match(dev, /Handy/); assert.match(dev, /Computer/); assert.match(dev, /Tablet/) assert.match(dev, /4\.590<\/b>66 %<\/em>/) assert.match(rankCardHtml('who', stats({ devices: [{ key: 'x', count: 2 }, { key: 'constructor', count: 1 }] })), /Unbekannt/) const land = rankCardHtml('who', full, { tab: 'countries' }) assert.match(land, /🇩🇪/); assert.match(land, /Deutschland/); assert.match(land, /Österreich/); assert.match(land, /Unbekannt/) assert.match(rankCardHtml('who', full, { tab: 'pages' }), /data-rank-active="devices"/) // Reiter einer anderen Karte => erster Reiter assert.match(rankCardHtml('seen', stats({ pages: [] })), /Noch keine Daten\./) assert.equal(rankCardHtml('nope', full), '') const tiny = rankCardHtml('seen', stats({ pages: [{ key: '/a', count: 10000 }, { key: '/b', count: 1 }] })) assert.match(tiny, /< 1 %<\/em>/) assert.doesNotMatch(rankCardHtml('seen', stats({ pages: [{ key: '/a', count: 0 }] })), /NaN|Infinity/) // Länder-Schlüssel maskiert const bad = rankCardHtml('who', stats({ countries: [{ key: '', count: 3 }] }), { tab: 'countries' }) assert.match(bad, /Unbekannt/); assert.doesNotMatch(bad, /<|&/); assert.doesNotMatch(bad, /an-flag/) }) test('basisHtml: Ring, Sätze mit und ohne Entscheidungen', () => { const h = basisHtml(full) assert.match(h, /]*>61 %<\/text>/); assert.match(h, /class="an-ring-fg"/) assert.match(h, /Seitenaufrufe zählen wir vollständig; Besucher, Kontakt-Aktionen und Zeit pro Seite \(\*\) nur bei Zustimmung\./) const none = basisHtml(zero) assert.match(none, /Noch hat niemand über das Banner entschieden\./); assert.doesNotMatch(none, /an-ring-fg|haben dem Banner zugestimmt|NaN/) assert.match(none, />–<\/text>/) assert.match(basisHtml(stats({ banner: { shown: 1, accept: 1, reject: 0 }, totals: { ...t, consentRate: 1 } })), /\(1 von 1 Entscheidung\)/) assert.doesNotMatch(basisHtml({}), /NaN|undefined|null/) }) test('headerHtml: Auswahl nur bei mehreren Websites, Zeitraum nur bei showRange, Dev-Hinweis, Name maskiert', () => { const a = project() const b = { projectId: 'p2', projectName: 'Café "Zum "' } const one = headerHtml({ project: a, usable: [a], range: 30, showRange: true, devMode: false, stamp: '19:42' }) assert.doesNotMatch(one, /an-project|Café "Zum <Bären>"<\/option>/) assert.match(two, /' }) assert.match(odd, /value="p"1" selected/); assert.match(odd, /heute, <b>x<\/b> Uhr/); assert.doesNotMatch(odd, /x/) // Zustände ohne Messung sagen nicht "aktiv" const off = headerHtml({ project: project({ analyse: { state: 'off', eligible: true } }), usable: [], range: 30, showRange: false }) assert.doesNotMatch(off, /Analyse aktiv/); assert.match(off, /data-state="off"/) assert.match(headerHtml({ project: project({ analyse: { state: 'disabled' } }), usable: [] }), /Analyse ist abgeschaltet/) assert.match(headerHtml({ project: project({ analyse: { state: 'off', eligible: false } }), usable: [] }), /Analyse nicht verfügbar/) assert.doesNotMatch(headerHtml({}), /undefined|null|NaN/) }) test('tabsHtml: tablist, drei Reiter, aria-selected', () => { const h = tabsHtml('heatmap') assert.match(h, /role="tablist"/); assert.equal(count(h, /role="tab"/g), 3) for (const k of ['uebersicht', 'heatmap', 'einstellungen']) assert.match(h, new RegExp(`data-tab="${k}"`)) assert.match(h, /data-tab="heatmap" aria-selected="true"/); assert.match(h, /data-tab="uebersicht" aria-selected="false"/) assert.equal(count(h, /aria-selected="true"/g), 1) assert.match(tabsHtml('quatsch'), /data-tab="uebersicht" aria-selected="true"/) assert.match(h, />Übersicht<\/button>/); assert.match(h, />Heatmap<\/button>/); assert.match(h, />Einstellungen<\/button>/) }) test('heatTeaserHtml: Knopf wechselt in den Reiter Heatmap', () => { const h = heatTeaserHtml() assert.match(h, /