Reine String-Builder (Kopfleiste, Reiter, Das Wichtigste, Kacheln mit Mini-Verlauf und Chips, Diagramm, Kontakt-Aktionen, Ranglisten, Datenbasis, Heatmap-Karte, Laden, Fehler, Einstellungen) für die neue Analyse-Ansicht. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
335 lines
24 KiB
JavaScript
335 lines
24 KiB
JavaScript
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, /<img src=x>/); 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>< 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é "Zum <Bären>"<\/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, /<img src=x onerror=alert\(1\)>/)
|
||
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"1" selected/); assert.match(odd, /heute, <b>x<\/b> 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"/)
|
||
// 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>'), /<img src=x>/); 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, /<script>/)
|
||
})
|