simpleadmin-web/internal/playtime/playtime_db_test.go
Astra 2516759fa9 Playtime stats between players only; no ping; page-wide grid
K/D, headshot %, kills and deaths (drawer, Top players, staff session
lists) now come from pt_events, so kills of bots and deaths to bots don't
count, as in Records; damage per round and MVPs are gone, since only the
game's counters have them and those include bots. Average ping and the
record count footnote are no longer shown. The server header's grid now
runs behind the whole page.
2026-10-04 22:41:12 +01:00

313 lines
12 KiB
Go

package playtime
import (
"context"
"database/sql"
_ "embed"
"os"
"strings"
"testing"
"time"
"github.com/go-sql-driver/mysql"
)
//go:embed testdata/schema.sql
var schema string
// TestPlaytimeDB runs every query against a real MySQL/MariaDB holding PlaytimeHistory's schema
// (testdata/schema.sql). It drops and recreates the pt_ tables, so only point it at a scratch
// database:
//
// SAW_TEST_PT_DSN='root:test@tcp(127.0.0.1:33071)/playtime_history' go test ./internal/playtime -run DB
func TestPlaytimeDB(t *testing.T) {
dsn := os.Getenv("SAW_TEST_PT_DSN")
if dsn == "" {
t.Skip("SAW_TEST_PT_DSN not set")
}
cfg, err := mysql.ParseDSN(dsn)
if err != nil {
t.Fatal(err)
}
cfg.ParseTime, cfg.Loc = true, time.UTC
db, err := sql.Open("mysql", cfg.FormatDSN())
if err != nil {
t.Fatal(err)
}
defer db.Close()
exec := func(q string, args ...any) {
t.Helper()
if _, err := db.Exec(q, args...); err != nil {
t.Fatalf("%s: %v", q, err)
}
}
exec("DROP VIEW IF EXISTS pt_segments_hourly")
for _, table := range tables {
exec("DROP TABLE IF EXISTS " + table)
}
exec("DROP TABLE IF EXISTS pt_servers")
ctx := context.Background()
st := New(db, 1)
if ok, _, err := st.State(ctx); err != nil || ok {
t.Fatalf("State without tables: %v, %v", ok, err)
}
for stmt := range strings.SplitSeq(schema, ";\n") {
if code := strings.TrimSpace(stripComments(stmt)); code != "" {
exec(code)
}
}
if ok, data, err := st.State(ctx); err != nil || !ok || data {
t.Fatalf("State with empty tables: %v, %v, %v", ok, data, err)
}
// Times are whole seconds at a few minutes past the hour, so segments cross hour boundaries.
now := time.Now().UTC().Truncate(time.Hour).Add(-50 * time.Minute)
ago := func(d time.Duration) time.Time { return now.Add(-d) }
const a, b = uint64(76561198000001001), uint64(76561198000001002)
exec(`INSERT INTO pt_players (steamid64, first_seen, last_seen, sessions_count, total_seconds, t_seconds, ct_seconds,
spec_seconds, alive_seconds, dead_seconds, last_nickname, last_country, last_server_id)
VALUES (?, ?, ?, 1, 5400, 3600, 1800, 1800, 4000, 1400, 'Alpha', 'GB', 1),
(?, ?, ?, 2, 7200, 7200, 0, 0, 7200, 0, 'Bravo', 'DE', 1)`,
a, ago(3*time.Hour), ago(time.Hour), b, ago(10*24*time.Hour), ago(10*24*time.Hour-2*time.Hour))
exec(`INSERT INTO pt_nicknames (steamid64, nickname, first_seen, last_seen, times_seen)
VALUES (?, 'Alpha', ?, ?, 3), (?, 'alpha_old', ?, ?, 1)`, a, ago(3*time.Hour), ago(time.Hour), a, ago(5*time.Hour), ago(4*time.Hour))
session := func(key string, id uint64, start time.Time, end *time.Time, kind int, reason string, kills, deaths int) {
var dur any
if end != nil {
dur = int(end.Sub(start).Seconds())
}
hb := start
if end != nil {
hb = *end
}
exec(`INSERT INTO pt_sessions (session_key, steamid64, server_id, nickname, started_at, ended_at, heartbeat_at,
duration_seconds, end_kind, connect_map, disconnect_map, disconnect_reason_name, players_online, max_players,
country_iso, country_name, city, kills, deaths, rounds_played, damage, ping_avg, plugin_version)
VALUES (?, ?, 1, 'x', ?, ?, ?, ?, ?, 'de_mirage', 'de_mirage', NULLIF(?, ''), 2, 10, 'GB', 'United Kingdom', 'London',
?, ?, 10, 900, 20, '0.1.0')`, key, id, start, end, hb, dur, kind, reason, kills, deaths)
}
segment := func(key, sess string, id uint64, team int, start time.Time, end *time.Time) {
var secs any
if end != nil {
secs = int(end.Sub(start).Seconds())
}
alive := any(nil)
if team >= TeamT {
alive = 1
}
exec(`INSERT INTO pt_segments (segment_key, session_key, steamid64, server_id, map, team, alive, started_at, ended_at,
heartbeat_at, seconds) VALUES (?, ?, ?, 1, 'de_mirage', ?, ?, ?, ?, ?, ?)`, key, sess, id, team, alive, start, end, start, secs)
}
at := func(d time.Duration) *time.Time { x := ago(d); return &x }
session("a1", a, ago(3*time.Hour), at(time.Hour), EndDisconnect, "NETWORK_DISCONNECT_DISCONNECT_BY_USER", 10, 5)
segment("a1-1", "a1", a, TeamT, ago(3*time.Hour), at(2*time.Hour))
segment("a1-2", "a1", a, TeamCT, ago(2*time.Hour), at(90*time.Minute))
segment("a1-3", "a1", a, TeamSpectator, ago(90*time.Minute), at(time.Hour))
session("b1", b, ago(10*24*time.Hour), at(10*24*time.Hour-2*time.Hour), EndMapChange, "", 3, 3)
segment("b1-1", "b1", b, TeamT, ago(10*24*time.Hour), at(10*24*time.Hour-2*time.Hour))
session("b2", b, ago(30*time.Minute), nil, EndOpen, "", 0, 0)
segment("b2-1", "b2", b, TeamCT, ago(30*time.Minute), nil)
// Another server's session, which a store for server 1 never counts.
exec(`INSERT INTO pt_sessions (session_key, steamid64, server_id, nickname, started_at, heartbeat_at, end_kind, connect_map,
players_online, max_players, plugin_version) VALUES ('other', ?, 2, 'x', ?, ?, 1, 'de_dust2', 1, 10, '0.1.0')`, a, ago(time.Hour), ago(time.Hour))
exec(`INSERT INTO pt_online_snapshots (server_id, taken_at, players, bots, max_players, map)
VALUES (1, ?, 5, 0, 10, 'de_mirage'), (1, ?, 9, 0, 10, 'de_mirage'), (2, ?, 20, 0, 10, 'de_dust2')`,
ago(24*time.Hour), ago(48*time.Hour), ago(24*time.Hour))
// Events: Alpha plants twice (once 10 days ago), defuses once and knifes a player and a bot;
// Bravo gets a Zeus kill and a headshot.
// Kills say who died (nil for a bot) and the killer's session.
event := func(id uint64, sess string, kind int, at time.Time, weapon any, hs any, victim any, bot any) {
t.Helper()
exec(`INSERT INTO pt_events (event_key, server_id, session_key, steamid64, kind, at, map, team, weapon, headshot,
victim_steamid64, victim_bot)
VALUES (REPLACE(UUID(), '-', ''), 1, NULLIF(?, ''), ?, ?, ?, 'de_mirage', 2, ?, ?, ?, ?)`, sess, id, kind, at, weapon, hs, victim, bot)
}
event(a, "", EventPlanted, ago(10*24*time.Hour), nil, nil, nil, nil)
event(a, "a1", EventPlanted, ago(2*time.Hour), nil, nil, nil, nil)
event(a, "a1", EventDefused, ago(2*time.Hour), nil, nil, nil, nil)
event(a, "a1", EventKill, ago(2*time.Hour), "knife_karambit", 0, b, 0)
event(a, "a1", EventKill, ago(2*time.Hour), "knife", 1, nil, 1) // a bot: doesn't count, not even as a headshot
event(b, "b2", EventKill, ago(20*time.Minute), "taser", 0, a, 0)
event(b, "b2", EventKill, ago(20*time.Minute), "ak47", 1, a, 0)
if ok, data, err := st.State(ctx); err != nil || !ok || !data || !st.HasEvents() {
t.Fatalf("State with data: %v, %v, %v (events %v)", ok, data, err, st.HasEvents())
}
// The hour split must add up to exactly the segments' lengths.
buckets, err := st.hours(ctx, ago(30*24*time.Hour), a)
if err != nil {
t.Fatal(err)
}
var total int64
for _, bk := range buckets {
total += bk.Seconds
}
if total != 5400 || len(buckets) < 2 {
t.Fatalf("Alpha's buckets add up to %d s over %d hours, want 5400 s over 2+", total, len(buckets))
}
// Clamped to the start of the window: only the 30 min since.
if buckets, err = st.hours(ctx, ago(150*time.Minute), a); err != nil {
t.Fatal(err)
}
total = 0
for _, bk := range buckets {
total += bk.Seconds
}
if total != 3600 {
t.Fatalf("since 2.5 h ago: %d s, want 3600 (30 min of T, 30 of CT)", total)
}
london, _ := time.LoadLocation("Europe/London")
o, err := st.Overview(ctx, now, london)
if err != nil {
t.Fatal(err)
}
s := o.Summary
if s.Players != 2 || s.PlayersPrev != 1 || s.Hours != 1.5 || s.HoursPrev != 2 || s.Peak != 9 || s.NewPlayers != 1 {
t.Fatalf("summary %+v", s)
}
var byHour float64
for _, h := range o.ByHour {
byHour += h
}
// Recording began with Bravo's session 10 days ago, so 3.5 h spread over those 11 days.
if o.ByHourDays != 11 || o.HeatDays != 11 {
t.Fatalf("averaged over %d days (heatmap %d), want 11", o.ByHourDays, o.HeatDays)
}
if want := 3.5 / 11; byHour < want-0.05 || byHour > want+0.05 {
t.Fatalf("byHour adds up to %.3f, want about %.3f", byHour, want)
}
// Alpha on one day, Bravo on two days ten days apart.
players := 0
for _, d := range o.Days {
players += d.Players
}
if len(o.Days) != 30 || players != 3 {
t.Fatalf("%d players over %d days", players, len(o.Days))
}
if len(o.Maps) != 1 || o.Maps[0].Map != "de_mirage" || o.Maps[0].Hours != 3.5 || o.Maps[0].Players != 2 {
t.Fatalf("maps %+v", o.Maps)
}
week := ago(7 * 24 * time.Hour)
leaders, err := st.Leaders(ctx, &week, 10)
if err != nil {
t.Fatal(err)
}
if len(leaders) != 1 || leaders[0].Name != "Alpha" || leaders[0].T != 1 || leaders[0].CT != 0.5 || leaders[0].Kills != 1 || leaders[0].Deaths != 2 {
t.Fatalf("leaders this week %+v", leaders)
}
if leaders, err = st.Leaders(ctx, nil, 10); err != nil {
t.Fatal(err)
}
if len(leaders) != 2 || leaders[0].Name != "Bravo" || leaders[0].Kills != 2 || leaders[0].Deaths != 1 {
t.Fatalf("leaders all time %+v", leaders)
}
p, err := st.Player(ctx, a, now, london)
if err != nil || p == nil {
t.Fatalf("Player: %v, %v", p, err)
}
var days float64
for _, d := range p.Days {
days += d.Hours
}
if p.T != 1 || p.CT != 0.5 || p.Spec != 0.5 || p.Stats == nil || *p.Stats != (Stats{Kills: 1, Deaths: 2}) || days != 1.5 || len(p.Maps) != 1 {
t.Fatalf("player %+v, %.1f h in days", p, days)
}
if p, err := st.Player(ctx, 76561198000009999, now, london); err != nil || p != nil {
t.Fatalf("unknown player: %v, %v", p, err)
}
ps, err := st.PlayerStaff(ctx, a)
if err != nil {
t.Fatal(err)
}
// Alpha's knife kill was in a1; Bravo killed Alpha twice, but after a1 ended.
if r := ps.Recent; len(r) != 1 || r[0].Kills == nil || *r[0].Kills != 1 || r[0].Deaths == nil || *r[0].Deaths != 0 {
t.Fatalf("recent session kills and deaths %+v", ps.Recent)
}
if len(ps.Names) != 2 || ps.Names[0].Name != "Alpha" || len(ps.Recent) != 1 || ps.Recent[0].Seconds != 7200 ||
ps.Recent[0].Reason != "NETWORK_DISCONNECT_DISCONNECT_BY_USER" || ps.Country != "GB" || ps.City != "London" {
t.Fatalf("staff %+v", ps)
}
on, err := st.Online(ctx, []uint64{a, b})
if err != nil {
t.Fatal(err)
}
if len(on) != 1 || on["76561198000001002"].Team == nil || *on["76561198000001002"].Team != TeamCT {
t.Fatalf("online %+v", on)
}
act, err := st.Activity(ctx, []uint64{a, b}, week)
if err != nil {
t.Fatal(err)
}
if x := act["76561198000001001"]; x.Hours != 1.5 || x.Spec != 0.5 || x.LastSeen == nil {
t.Fatalf("activity %+v", act)
}
if x := act["76561198000001002"]; x.Hours != 0 || x.LastSeen == nil {
t.Fatalf("Bravo's activity %+v", x)
}
rec, err := st.Records(ctx, &week, 5)
if err != nil {
t.Fatal(err)
}
one := func(name string, got []Record, who string, n int) {
t.Helper()
if len(got) != 1 || got[0].Name != who || got[0].Count != n {
t.Fatalf("%s: %+v, want %s with %d", name, got, who, n)
}
}
one("plants", rec.Plants, "Alpha", 1)
one("defuses", rec.Defuses, "Alpha", 1)
one("knife kills", rec.KnifeKills, "Alpha", 1)
one("zeus kills", rec.ZeusKills, "Bravo", 1)
one("headshots", rec.Headshots, "Bravo", 1)
if rec.Since == nil || !rec.Since.Equal(ago(10*24*time.Hour)) {
t.Fatalf("records since %v", rec.Since)
}
if rec, err = st.Records(ctx, nil, 5); err != nil {
t.Fatal(err)
}
one("plants ever", rec.Plants, "Alpha", 2)
if e := p.Events; e == nil || e.Plants != 2 || e.Defuses != 1 || e.KnifeKills != 1 || e.ZeusKills != 0 || e.Headshots != 0 {
t.Fatalf("Alpha's events %+v", e)
}
reasons, err := st.Reasons(ctx, ago(30*24*time.Hour))
if err != nil {
t.Fatal(err)
}
if len(reasons) != 1 || reasons[0].Count != 1 || reasons[0].AvgSeconds != 7200 {
t.Fatalf("reasons %+v", reasons)
}
countries, err := st.Countries(ctx)
if err != nil {
t.Fatal(err)
}
if len(countries) != 1 || countries[0].ISO != "GB" || countries[0].Players != 2 {
t.Fatalf("countries %+v", countries)
}
ends, err := st.EndKinds(ctx, ago(30*24*time.Hour))
if err != nil {
t.Fatal(err)
}
if ends[EndDisconnect] != 1 || ends[EndMapChange] != 1 || ends[EndOpen] != 1 {
t.Fatalf("end kinds %+v", ends)
}
}
func stripComments(stmt string) string {
var out []string
for l := range strings.SplitSeq(stmt, "\n") {
if !strings.HasPrefix(strings.TrimSpace(l), "--") {
out = append(out, l)
}
}
return strings.Join(out, "\n")
}