Files
Webklar-Kundenbereich/test/analyseCards.test.js
T
ClaudeandClaude Opus 5.5 1964e5fcca Analyse: Farbfeld der Einstellungen zeigt die tatsächliche Banner-Farbe
Das Farbfeld nutzt bannerColor() wie die Website: eine für weiße Schrift
zu helle Akzentfarbe wird zu #111111 statt der gespeicherten Farbe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 21:34:43 +00:00

339 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, /<b>14 % mehr<\/b>/) // Satz aus summarize
assert.match(h, /Länderdaten: IP-Geolocation by <a href="https:\/\/db-ip\.com" target="_blank" rel="noopener">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, /<b class="an-v">612<\/b>/)
assert.equal(count(h, /<svg class="an-spark"/g), 1) // nur Seitenaufrufe haben eine Reihe (series hat nur pv)
})
test('overviewHtml: Website ohne Aufrufe – Leerzustand, keine NaN/Infinity', () => {
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 <b>6\.930-mal<\/b> aufgerufen/)
assert.equal(count(h, /<li>/g), 4); assert.match(h, /<svg class="an-ico"/)
assert.equal(count(h, /aria-hidden="true"><\/svg>/g), 0) // alle Symbole der Hinweise sind belegt
assert.match(h, /188-mal hat dich jemand direkt kontaktiert, meist per Telefon \(96\)\./)
const few = summaryHtml(stats({ totals: { ...t, pv: 12 } }))
assert.match(few, /Sobald ein paar mehr Besuche da sind, steht hier das Wichtigste über deine Website in zwei, drei Sätzen\./)
assert.doesNotMatch(few, /<ul/)
assert.match(summaryHtml({ totals: { pv: 3 } }), /<h3 class="an-eyebrow">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 class="an-star">\*<\/span>/)
assert.equal(count(h, /<svg class="an-spark"/g), 4)
// Verlauf der Zeit: fehlende Tageswerte (null) zählen als 0
const noTime = kpisHtml(stats({ series: full.series.map((d, i) => ({ ...d, avgSec: i === 0 ? null : 100 })) }))
assert.equal(count(noTime, /<svg class="an-spark"/g), 4)
// Reihe ohne avgSec-Feld: kein Verlauf bei der Zeit
assert.equal(count(kpisHtml(stats({ series: full.series.map(({ avgSec, ...d }) => d) })), /<svg class="an-spark"/g), 3)
// keine Zeitmessung: Strich statt "0:00"
const none = kpisHtml(stats({ totals: { ...t, avgSec: null, avgScroll: null }, previous: { ...prev, totals: { ...prev.totals, avgSec: null } } }))
assert.match(none, /<b class="an-v">–<\/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, /<span class="an-delta down" role="img" aria-label="minus 18 Prozent gegenüber dem Zeitraum davor">▼ 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, /<svg class="an-spark" viewBox="0 0 200 38" preserveAspectRatio="none"/)
assert.match(h, /class="an-spark-area"/); assert.match(h, /class="an-spark-line"/)
assert.equal(sparkHtml([4]), ''); assert.equal(sparkHtml([]), ''); assert.equal(sparkHtml(undefined), '')
assert.doesNotMatch(sparkHtml([0, 0, 0]), /NaN|Infinity/)
assert.doesNotMatch(sparkHtml([3, null, undefined, 'x']), /NaN|undefined|null/)
})
test('chartHtml: Linie, Fläche, Vorzeitraum nur mit Vergleich, Legende, Punkte mit Daten', () => {
const h = chartHtml(full)
assert.match(h, /<title id="an-chart-title">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, /<b class="an-sum-n">188<\/b>/)
assert.equal(count(h, /aria-hidden="true"><\/svg>/g), 0) // jede Zeile hat ihr Symbol
assert.equal(count(h, /<svg class="an-ico"/g), 4)
for (const label of ['Anrufe', 'E-Mails', 'WhatsApp', 'Links nach außen']) assert.match(h, new RegExp(label))
assert.match(h, /data-kind="tel"[^]*?<b>96<\/b>[^]*?▲ 16/) // 96 vs 80: beide unter 100 => absolut statt Prozent
assert.match(h, /data-kind="out"[^]*?<b>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"[^]*?<b>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), /<p class="an-quote">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: '/<img src=x>', count: 9 }] }))
assert.match(h, /&lt;img src=x&gt;/); assert.doesNotMatch(h, /<img src=x>/)
const pages = rankCardHtml('seen', full)
assert.match(pages, /Startseite/); assert.doesNotMatch(pages, />\/</)
assert.match(pages, /<b>3\.200<\/b><em>59 %<\/em>/) // 3200 von 5410
assert.match(pages, /data-rank="seen" data-rank-active="pages"/)
assert.match(pages, /<h3>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</)
const open = rankCardHtml('seen', many, { expanded: true })
assert.equal(count(open, /class="an-lr"/g), 20); assert.doesNotMatch(open, /Alle anzeigen/)
assert.doesNotMatch(rankCardHtml('seen', stats({ pages: Array.from({ length: 6 }, (_, i) => ({ 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, /<h3>Wer zu Besuch ist<\/h3>/); assert.match(dev, /Handy/); assert.match(dev, /Computer/); assert.match(dev, /Tablet/)
assert.match(dev, /<b>4\.590<\/b><em>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, /<em>&lt; 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: '<b>', count: 3 }] }), { tab: 'countries' })
assert.match(bad, /Unbekannt/); assert.doesNotMatch(bad, /<b><|<b>&/); assert.doesNotMatch(bad, /an-flag/)
})
test('basisHtml: Ring, Sätze mit und ohne Entscheidungen', () => {
const h = basisHtml(full)
assert.match(h, /<text class="an-ring-n"[^>]*>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 <Bären>"' }
const one = headerHtml({ project: a, usable: [a], range: 30, showRange: true, devMode: false, stamp: '19:42' })
assert.doesNotMatch(one, /an-project|<select/); assert.match(one, /<b class="an-site-name">Boulderhalle Überhang<\/b>/)
assert.match(one, /Analyse aktiv · Stand: heute, 19:42 Uhr/)
assert.match(one, /<button type="button" class="on" data-range="30" aria-pressed="true">30 Tage<\/button>/)
assert.equal(count(one, /data-range="/g), 3); assert.match(one, /data-range="7"[^>]*aria-pressed="false"/)
const two = headerHtml({ project: b, usable: [a, b], range: 7, showRange: false, devMode: false, stamp: '' })
assert.match(two, /<select id="an-project"/); assert.match(two, /<option value="p2" selected>Café &quot;Zum &lt;Bären&gt;&quot;<\/option>/)
assert.match(two, /<option value="p1">Boulderhalle Überhang<\/option>/)
assert.doesNotMatch(two, /data-range/); assert.doesNotMatch(two, /Zum <Bären>/); assert.doesNotMatch(two, /Stand:/)
assert.match(two, /Analyse aktiv/)
assert.match(headerHtml({ project: a, usable: [a], range: 365, showRange: true }), /class="on" data-range="30"/) // unbekannter Zeitraum => 30
// Dev-Modus
const dev = headerHtml({ project: a, usable: [a], range: 30, showRange: true, devMode: true, stamp: '19:42' })
assert.match(dev, /Im Dev-Modus wird nicht gemessen\./); assert.doesNotMatch(dev, /Analyse aktiv|Stand:/); assert.match(dev, /data-state="warn"/)
assert.match(one, /data-state="on"/)
// Name maskiert
const evil = headerHtml({ project: { projectId: 'x', projectName: '<img src=x onerror=alert(1)>' }, usable: [], range: 30, showRange: false })
assert.doesNotMatch(evil, /<img/); assert.match(evil, /&lt;img src=x onerror=alert\(1\)&gt;/)
const odd = headerHtml({ project: { projectId: 'p"1', projectName: 'A' }, usable: [{ projectId: 'p"1', projectName: 'A' }, { projectId: 'p2', projectName: 'B' }], stamp: '<b>x</b>' })
assert.match(odd, /value="p&quot;1" selected/); assert.match(odd, /heute, &lt;b&gt;x&lt;\/b&gt; Uhr/); assert.doesNotMatch(odd, /<b>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, /<button type="button" class="an-btn" data-tab="heatmap">Öffnen<\/button>/); assert.match(h, /<h3>Heatmap<\/h3>/)
assert.match(h, /an-mini-heat/)
})
test('settingsHtml: aktiv seit TT.MM.JJJJ, Farbfeld, Fassung, Links AVV und Leistungsbeschreibung, #an-off', () => {
const h = settingsHtml(project())
assert.match(h, /aktiv seit 02\.10\.2026/) // 22:30 UTC = 00:30 Berlin am 02.10.
assert.match(h, /class="an-swatch" style="background:#1d4ed8"/); assert.match(h, /#1d4ed8/)
assert.match(h, /Fassung 2026-10-02/)
assert.match(h, /<a href="\/rechtliches\/avv-website-analyse-2026-10-02\.html" target="_blank" rel="noopener">Auftragsverarbeitungsvertrag Website-Analyse<\/a>/)
assert.match(h, /<a href="\/rechtliches\/leistungsbeschreibung-website-analyse-2026-10-02\.html" target="_blank" rel="noopener">Leistungsbeschreibung Website-Analyse<\/a>/)
assert.match(h, /<section class="an-card" id="an-off"><\/section>/)
// Farbe ungültig => #111111; kein Datum => ohne "seit"
const bad = settingsHtml(project({ analyse: { state: 'active', accentColor: 'red;"><x>', avvVersion: '2026-10-02', legal: {}, activatedAt: null } }))
assert.match(bad, /background:#111111/); assert.doesNotMatch(bad, /<x>|seit|<a /)
assert.match(bad, /id="an-off"/)
// zu helle Farbe: das Banner nutzt Schwarz, das Farbfeld zeigt dasselbe (bannerColor)
const light = settingsHtml(project({ analyse: { state: 'active', accentColor: '#ffee00', avvVersion: '2026-10-02', legal: {}, activatedAt: null } }))
assert.match(light, /class="an-swatch" style="background:#111111"/); assert.doesNotMatch(light, /#ffee00/i)
assert.match(settingsHtml(project({ analyse: { state: 'active', accentColor: '#1D4ED8' } })), /background:#1d4ed8/) // groß geschrieben => klein
// gefährliche Link-Ziele und Namen
const evil = settingsHtml(project({ analyse: { state: 'active', accentColor: '#fff', avvVersion: '<b>', legal: { avv: 'javascript:alert(1)', lb: '/x"onmouseover="y' }, activatedAt: 'kaputt' } }))
assert.doesNotMatch(evil, /javascript:|<b>|onmouseover="y/); assert.doesNotMatch(evil, /NaN|undefined|null|Invalid/)
assert.doesNotMatch(settingsHtml({ projectId: 'p' }), /NaN|undefined|null|Invalid/)
})
test('errorHtml und skeletonHtml', () => {
assert.match(errorHtml('Auswertung gerade nicht erreichbar'), /id="an-retry"/)
assert.match(errorHtml('Auswertung gerade nicht erreichbar'), /Auswertung gerade nicht erreichbar/); assert.match(errorHtml('x'), /role="alert"/); assert.match(errorHtml('x'), />Neu laden</)
assert.match(errorHtml('<img src=x>'), /&lt;img src=x&gt;/); assert.doesNotMatch(errorHtml('<img src=x>'), /<img/)
const sk = skeletonHtml()
assert.equal(count(sk, /class="an-card an-kpi an-sk"/g), 4); assert.match(sk, /aria-busy="true"/)
assert.doesNotMatch(sk, /Lade…|undefined|NaN/)
})
test('Alle Bausteine: Werte aus Daten werden maskiert (Pfade, Hosts, Tage, Länder)', () => {
const evil = '"><script>alert(1)</script>'
const s = stats({
pages: [{ key: evil, count: 5 }],
refs: [{ key: evil, count: 5 }],
countries: [{ key: evil, count: 5 }],
devices: [{ key: evil, count: 5 }],
series: [{ day: evil, pv: 5 }, { day: '2026-09-18', pv: 7 }],
previous: { ...prev, series: [{ pv: 3 }, { pv: 4 }] },
})
const all = overviewHtml(s) + rankCardHtml('seen', s, { tab: 'refs' }) + rankCardHtml('who', s, { tab: 'countries' }) + rankCardHtml('who', s) + chartHtml(s)
assert.doesNotMatch(all, /<script>/)
assert.match(all, /&lt;script&gt;/)
})