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', today: { day: '2026-10-05', pv: 42, visitors: 20, contacts: 1 }, 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, / /data-kpi="time".*?class="an-spark-line" d="([^"]*)"/s.exec(html)[1] assert.match(timeLine(noTime), /^M6\.9 /) // der erste Tag (x = 0) fehlt, die Linie beginnt am zweiten assert.equal(count(timeLine(noTime), /M/g), 1) assert.doesNotMatch(timeLine(noTime), /M0 |L0 /) assert.match(timeLine(kpisHtml(full)), /^M0 /) // vollständige Reihe: Linie ab dem ersten Tag // Zählwerte behalten 0: ein Tag ohne Aufrufe ist ein echter Tiefpunkt const zeroPv = kpisHtml(stats({ series: full.series.map((d, i) => ({ ...d, pv: i === 0 ? 0 : 231 })) })) assert.match(/data-kpi="pv".*?class="an-spark-line" d="([^"]*)"/s.exec(zeroPv)[1], /^M0 36 /) // Reihe ohne avgSec-Feld: kein Verlauf bei der Zeit assert.equal(count(kpisHtml(stats({ series: full.series.map(({ avgSec, ...d }) => d) })), /–<\/b>/); assert.doesNotMatch(none, /gescrollt|vs\. –/) }) test('kpisHtml: neue Website zeigt "Vergleich ab 19.11."', () => { const h = kpisHtml(stats({ firstDay: '2026-09-20', previous: { ...prev, complete: false } })) assert.equal(count(h, /Vergleich ab 19\.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 12\.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 line = (h) => /class="an-spark-line" d="([^"]*)"/.exec(h)[1] const area = (h) => /class="an-spark-area" d="([^"]*)"/.exec(h)[1] // Wert 0 liegt auf der Grundlinie (y = 36); eine Lücke darf dort keinen Punkt erzeugen const gap = sparkHtml([4, 6, null, 6, 8]) assert.equal(count(line(gap), /M/g), 2) // zwei Abschnitte: Tage 0-1 und 3-4 assert.doesNotMatch(line(gap), / 36( |$)/) assert.equal(count(area(gap), /Z/g), 2) // Fläche je Abschnitt assert.doesNotMatch(line(gap), /(^|[ML])100 /) // der Tag mit Lücke (x = 100) hat keinen Punkt assert.match(line(sparkHtml([null, 5, 7, 9])), /^M66\.7 /) // Lücke am Anfang: Linie beginnt später, nicht bei 0 assert.equal(sparkHtml([null, null, null]), ''); assert.equal(sparkHtml([null, 4]), '') // unter 2 Messwerten nichts assert.doesNotMatch(sparkHtml([4, null, 6]), /NaN|undefined|null/) // alle Werte vorhanden: ein Abschnitt wie bisher assert.equal(count(line(sparkHtml([1, 5, 3, 8])), /M/g), 1) }) test('chartHtml: Linie, Fläche, Vorzeitraum nur mit Vergleich, Legende, Punkte mit Daten', () => { 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('chartHtml: Achsenbeschriftung hat Platz – y-Rand wächst mit der Stellenzahl, x-Beschriftungen überlappen nicht', () => { const padOf = (html) => Number(/class="an-grid" x1="([\d.]+)"/.exec(html)[1]) const series = (pv, n = 30) => days('2026-09-05', n, pv).map((d) => ({ ...d, visitors: 1, contacts: 0, avgSec: 1 })) const none = { previous: { ...prev, complete: false } } // Skala 500 (3 Zeichen), 5.000 (4), 20.000 (5), 50 (2): Platz für die Ziffern der y-Beschriftung assert.equal(padOf(chartHtml(stats({ series: series(300), ...none }))), 49) assert.equal(padOf(chartHtml(stats({ series: series(2400), ...none }))), 64) assert.equal(padOf(chartHtml(stats({ series: series(12000), ...none }))), 78) assert.equal(padOf(chartHtml(stats({ series: series(30), ...none }))), 40) // y-Beschriftung steht rechtsbündig 6 Einheiten vor dem Raster und ist an der Gitterlinie zentriert const h = chartHtml(stats({ series: series(2400), ...none })) assert.match(h, /5000<\/text>/) // x-Beschriftungen: nie näher als 100 Einheiten (Platz für "29.09." in der größten Schrift), letzter Tag bleibt for (const n of [7, 30, 90]) { const xs = [...chartHtml(stats({ series: series(300, n), ...none })).matchAll(/class="an-axis" x="([\d.]+)" y="[\d.]+" text-anchor="middle"/g)].map((m) => Number(m[1])) assert.ok(xs.length >= 3, `range ${n}`) assert.ok(xs.every((x, i) => !i || x - xs[i - 1] >= 100), `range ${n}: ${xs}`) assert.equal(xs[xs.length - 1], 616) // 640 - rechter Rand 24: der letzte Tag } }) 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>46 %<\/em>/) // 3200 von 6930 Aufrufen (die Liste zeigt nur 5410 davon) 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/) // Nenner: Seiten, Länder, Geräte relativ zu max(Summe der Liste, Seitenaufrufe); Herkunft nur zur Summe der Liste const part = stats({ totals: { ...t, pv: 1000 }, pages: [{ key: '/a', count: 300 }, { key: '/b', count: 100 }], countries: [{ key: 'DE', count: 250 }], devices: [{ key: 'm', count: 500 }], refs: [{ key: 'google.com', count: 30 }, { key: '', count: 10 }] }) assert.match(rankCardHtml('seen', part), /300<\/b>30 %<\/em>/) // 300 von 1000, nicht von 400 (75 %) assert.match(rankCardHtml('who', part, { tab: 'countries' }), /250<\/b>25 %<\/em>/) assert.match(rankCardHtml('who', part), /500<\/b>50 %<\/em>/) assert.match(rankCardHtml('seen', part, { tab: 'refs' }), /30<\/b>75 %<\/em>/) // Herkunft: 30 von 40 // Liste größer als die Gesamtzahl (Zählungen laufen auseinander): die Summe, nie mehr als 100 % const over = stats({ totals: { ...t, pv: 100 }, pages: [{ key: '/a', count: 300 }, { key: '/b', count: 100 }] }) assert.match(rankCardHtml('seen', over), /300<\/b>75 %<\/em>/) // ohne totals (alter Collector): Summe der Liste assert.match(rankCardHtml('seen', { pages: [{ key: '/a', count: 3 }, { key: '/b', count: 1 }] }), /75 %<\/em>/) // 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', today: { day: '2026-10-05', pv: 1234, visitors: 80, contacts: 3 } }) assert.doesNotMatch(one, /an-project|Café "Zum <Bären>"<\/option>/) assert.match(two, /', today: { pv: '5' } }) assert.match(odd, /value="p"1" selected/); assert.match(odd, /Stand: <b>x<\/b> Uhr/); assert.doesNotMatch(odd, /x|/) // Heute bisher: Einzahl, 0 und nur mit Zahl; ohne Uhrzeit nur der Zähler const today = (pv, stamp = '') => headerHtml({ project: a, usable: [a], range: 30, showRange: false, stamp, today: { day: '2026-10-05', pv, visitors: 0, contacts: 0 } }) const plain = (h) => /(.*?)<\/small>/.exec(h)[1].replace(/<[^>]+>/g, '') // Text ohne die Umbruch-Spans assert.equal(plain(today(1, '08:05')), 'Analyse aktiv · Heute bisher: 1 Aufruf · Stand: 08:05 Uhr') assert.match(today(0, '08:05'), /Heute bisher: 0 Aufrufe/) assert.equal(plain(today(42)), 'Analyse aktiv · Heute bisher: 42 Aufrufe') assert.doesNotMatch(headerHtml({ project: a, usable: [a], today: {} }), /Heute bisher|NaN/) // today ohne pv: nichts behaupten // Zustände ohne Messung zeigen kein "Heute bisher" assert.doesNotMatch(headerHtml({ project: project({ analyse: { state: 'disabled' } }), usable: [], today: { pv: 5 } }), /Heute bisher/) // 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, /