Files
irrlichtandClaude Opus 5.5 07a2b64eff Statistik: tägliche SVG-Diagramme statt CSS-Balken
Der Server rendert einmal täglich (0:01 Uhr deutscher Zeit) Liniendiagramme
für Aufrufe, Netze und Beiträge pro Tag sowie gestapelte Säulen (Threads/
Antworten) für den Ø pro Wochentag und pro Tageszeit, je schmal (600) und
breit (1200), ausgeliefert über /api/stats/charts. Hovern zeigt den
Datenpunkt in einer Infozeile und markiert ihn mit einer senkrechten Linie.

Die Statistik-Seite wird auf breiten Bildschirmen bis 1200 px breit.
"Netze nach Betreiber" entfällt; neu ist die Kennzahl Ø Beiträge/Tag der
letzten 30 Tage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
2026-10-06 20:13:30 +02:00

200 lines
6.1 KiB
Go

package main
import (
"encoding/json"
"encoding/xml"
"io"
"math"
"net/http"
"strings"
"testing"
"time"
)
func TestNiceScale(t *testing.T) {
cases := []struct{ max, step, top float64 }{
{0, 0.5, 1},
{1, 0.5, 1},
{7, 2, 8},
{123, 50, 150},
{0.37, 0.1, 0.4},
{1000, 500, 1000},
}
for _, c := range cases {
step, top := niceScale(c.max)
if math.Abs(step-c.step) > 1e-9 || math.Abs(top-c.top) > 1e-9 {
t.Errorf("niceScale(%v) = %v, %v; erwartet %v, %v", c.max, step, top, c.step, c.top)
}
}
}
func TestDateTicks(t *testing.T) {
day := func(s string) time.Time { d, _ := time.Parse(time.DateOnly, s); return d }
labels := func(from, to string) []string {
days := dayRange(day(from), day(to))
var l []string
for _, i := range dateTicks(days) {
l = append(l, dateTickLabel(days[i]))
}
return strings.Fields(strings.Join(l, " "))
}
if got := strings.Join(labels("2026-06-18", "2026-10-06"), " "); got != "Jul Aug Sep Okt" {
t.Errorf("Monate: %q", got)
}
if got := strings.Join(labels("2025-12-13", "2027-12-01"), " "); got != "2026 Apr Jul Okt 2027 Apr Jul Okt" {
t.Errorf("Quartale: %q", got)
}
if got := strings.Join(labels("2026-09-20", "2026-10-06"), " "); got != "21.09. 28.09. 05.10." {
t.Errorf("Wochen: %q", got)
}
}
// Ende-zu-Ende: Beiträge und Aufrufe direkt in die DB, Aggregate prüfen,
// dann über die API ausliefern und jedes SVG als XML parsen.
func TestStatsCharts(t *testing.T) {
srv := newTestServer(t)
at := func(s string) int64 {
tm, err := time.ParseInLocation("2006-01-02 15:04", s, berlin)
if err != nil {
t.Fatal(err)
}
return tm.Unix()
}
// Mo 2026-09-28 bis Mi 2026-09-30; "heute" ist Do 2026-10-01.
posts := []struct {
created int64
reply int64
deleted int64
}{
{at("2026-09-28 09:30"), 0, 0},
{at("2026-09-28 09:45"), 1, 0},
{at("2026-09-28 23:30"), 1, 1}, // gelöscht, zählt trotzdem; UTC wäre es 21 Uhr
{at("2026-09-30 00:10"), 0, 0},
{at("2026-10-01 08:00"), 0, 0}, // heute -> fehlt
}
for i, p := range posts {
if _, err := db.Exec(`INSERT INTO entry (pid, uid, created_at, reply_to, deleted) VALUES ($1, 1, $2, $3, $4)`,
100+i, p.created, p.reply, p.deleted); err != nil {
t.Fatal(err)
}
}
for _, imp := range []struct {
day, ip string
hits int
}{
{"2026-09-27", "10.1.0.0", 3},
{"2026-09-27", "10.2.0.0", 2},
{"2026-09-29", "10.1.0.0", 1},
{"2026-10-01", "10.1.0.0", 9}, // heute -> fehlt
} {
db.Exec(`INSERT INTO impression (day, anon_ip, path, hits) VALUES ($1, $2, '/', $3)`, imp.day, imp.ip, imp.hits)
}
now := time.Date(2026, 10, 1, 12, 0, 0, 0, berlin)
ent, err := collectEntryStats(now)
if err != nil {
t.Fatal(err)
}
if len(ent.days) != 3 || ent.perDay[0] != 3 || ent.perDay[1] != 0 || ent.perDay[2] != 1 {
t.Errorf("perDay %v über %d Tage", ent.perDay, len(ent.days))
}
// Je ein Mo/Di/Mi im Zeitraum -> Ø = Summe.
if ent.weekday[0] != [2]float64{1, 2} || ent.weekday[2] != [2]float64{1, 0} || ent.weekday[3] != [2]float64{} {
t.Errorf("weekday %v", ent.weekday)
}
// Stunden: Summe / 3 Tage.
if ent.hour[9] != [2]float64{1.0 / 3, 1.0 / 3} || ent.hour[23] != [2]float64{0, 1.0 / 3} || ent.hour[0] != [2]float64{1.0 / 3, 0} {
t.Errorf("hour 0/9/23: %v %v %v", ent.hour[0], ent.hour[9], ent.hour[23])
}
impr, err := collectImpressionStats(now)
if err != nil {
t.Fatal(err)
}
wantHits, wantNets := []float64{5, 0, 1, 0}, []float64{2, 0, 1, 0}
if len(impr.days) != 4 {
t.Fatalf("impression-Tage: %v", impr.days)
}
for i := range wantHits {
if impr.hits[i] != wantHits[i] || impr.networks[i] != wantNets[i] {
t.Errorf("Tag %d: %v/%v", i, impr.hits[i], impr.networks[i])
}
}
if err := renderCharts(now); err != nil {
t.Fatal(err)
}
resp, err := http.Get(srv.URL + "/api/stats/charts")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var set StatsCharts
if err := json.NewDecoder(resp.Body).Decode(&set); err != nil {
t.Fatal(err)
}
for _, name := range []string{"impressions", "networks", "entries", "weekday", "hour"} {
pair := set.Charts[name]
if !strings.Contains(pair.Narrow, `viewBox="0 0 600 `) || !strings.Contains(pair.Wide, `viewBox="0 0 1200 `) {
t.Errorf("%s: falsche Größen", name)
}
for _, s := range []string{pair.Narrow, pair.Wide} {
if !strings.HasPrefix(s, "<svg") || strings.Contains(s, "style=") {
t.Errorf("%s: kein CSP-taugliches SVG", name)
}
d := xml.NewDecoder(strings.NewReader(s))
for {
if _, err := d.Token(); err == io.EOF {
break
} else if err != nil {
t.Errorf("%s: kein gültiges XML: %v", name, err)
break
}
}
}
}
if !strings.Contains(set.Charts["weekday"].Wide, `data-tip="Montag: Ø 3 Beiträge pro Tag (1 Threads, 2 Antworten)"`) {
t.Error("Tooltip Montag fehlt")
}
}
func TestEmptyCharts(t *testing.T) {
newTestServer(t)
if err := renderCharts(time.Now()); err != nil {
t.Fatal(err)
}
for name, pair := range chartCache.Charts {
if !strings.Contains(pair.Narrow, "Noch keine Daten.") || !strings.Contains(pair.Wide, "Noch keine Daten.") {
t.Errorf("%s ohne Daten: %+v", name, pair)
}
}
}
// Ø Beiträge/Tag: nur die letzten 30 vollen Tage, gelöschte zählen, heute nicht.
func TestStatsDetailAverage(t *testing.T) {
srv := newTestServer(t)
now := time.Now().In(berlin)
midnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, berlin)
for i, created := range []int64{
midnight.Add(-time.Hour).Unix(), // gestern
midnight.AddDate(0, 0, -30).Unix(), // erster Tag des Fensters
midnight.AddDate(0, 0, -30).Unix() - 1, // davor
midnight.Add(time.Minute).Unix(), // heute
midnight.AddDate(0, 0, -10).Add(time.Hour).Unix(), // gelöscht
} {
db.Exec(`INSERT INTO entry (pid, uid, created_at, deleted) VALUES ($1, 1, $2, $3)`, 200+i, created, i/4)
}
resp, err := http.Get(srv.URL + "/api/stats/detail")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var body struct {
Avg float64 `json:"entries_per_day_30"`
}
json.NewDecoder(resp.Body).Decode(&body)
if math.Abs(body.Avg-3.0/30) > 1e-9 {
t.Errorf("entries_per_day_30 = %v, erwartet 0,1", body.Avg)
}
}