Final-Review-Fixwelle der Cockpit-Übersicht:
- F1 Collector: Der Zeitraum sind die range Kalendertage bis gestern (Berliner Tag),
der Vorzeitraum die range Tage davor; heute steht getrennt in today { day, pv, visitors,
contacts }. Kein Einbruch mehr am rechten Rand von Diagramm und Mini-Verläufen,
kein falsches "x % weniger" tagsüber. Portal: weekdayStats bekommt die ganze Reihe
(completedDays entfällt), "Vergleich ab" = firstDay + 2 x range, Kopfzeile
"Analyse aktiv · Heute bisher: N Aufrufe · Stand: HH:MM Uhr" (alter Collector ohne today
bleibt ohne "Heute bisher").
- F2 "direkt kontaktiert" zählt nur Telefon, E-Mail und WhatsApp (auch im Vorzeitraum),
Links nach außen nicht.
- F3 Mini-Verlauf "Ø Zeit pro Seite": Tage ohne Messung sind Lücken statt 0-Tälern.
- F4 Fehlerfarbe beim Ausschalten rot, --an-faint auf 55 %, Achsenschrift per
Container-Abfragen auf 11-12 px (y-Rand wächst mit der Stellenzahl, x-Beschriftungen mit
Mindestabstand), Ranglisten-Anteile an allen Seitenaufrufen, Tippflächen bis 880 px
mindestens 40 px hoch.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
405 lines
31 KiB
JavaScript
405 lines
31 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',
|
||
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, /<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, /178-mal hat dich jemand direkt kontaktiert, meist per Telefon \(96\)\./) // ohne die 10 Links nach außen
|
||
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: Tage ohne Messung (null) fehlen im Verlauf, sie werden nicht als 0 gezeichnet
|
||
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)
|
||
const timeLine = (html) => /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) })), /<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 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, /<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('sparkHtml: Lücken (null) unterbrechen die Linie statt als 0 zu gelten', () => {
|
||
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, /<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('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, /<text class="an-axis" x="58" y="[\d.]+" dy="0\.35em" text-anchor="end">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, /<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>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, /<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/)
|
||
// 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), /<b>300<\/b><em>30 %<\/em>/) // 300 von 1000, nicht von 400 (75 %)
|
||
assert.match(rankCardHtml('who', part, { tab: 'countries' }), /<b>250<\/b><em>25 %<\/em>/)
|
||
assert.match(rankCardHtml('who', part), /<b>500<\/b><em>50 %<\/em>/)
|
||
assert.match(rankCardHtml('seen', part, { tab: 'refs' }), /<b>30<\/b><em>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), /<b>300<\/b><em>75 %<\/em>/)
|
||
// ohne totals (alter Collector): Summe der Liste
|
||
assert.match(rankCardHtml('seen', { pages: [{ key: '/a', count: 3 }, { key: '/b', count: 1 }] }), /<em>75 %<\/em>/)
|
||
// 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', today: { day: '2026-10-05', pv: 1234, visitors: 80, contacts: 3 } })
|
||
assert.doesNotMatch(one, /an-project|<select/); assert.match(one, /<b class="an-site-name">Boulderhalle Überhang<\/b>/)
|
||
assert.match(one, /<small class="an-status"><span class="an-st">Analyse aktiv<\/span> · <span class="an-st">Heute bisher: 1\.234 Aufrufe<\/span> · <span class="an-st">Stand: 19:42 Uhr<\/span><\/small>/)
|
||
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.doesNotMatch(two, /Heute bisher/) // ohne today (alter Collector) entfällt der Teil
|
||
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:|Heute bisher/); 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>', today: { pv: '<i>5</i>' } })
|
||
assert.match(odd, /value="p"1" selected/); assert.match(odd, /Stand: <b>x<\/b> Uhr/); assert.doesNotMatch(odd, /<b>x|<i>/)
|
||
// 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 class="an-status">(.*?)<\/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, /<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>'), /<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>/)
|
||
})
|