package state import ( "io" "log/slog" "testing" modulepkg "github.com/dbledeez/panel/pkg/module" panelv1 "github.com/dbledeez/panel/proto/panel/v1" ) type captureEmitter struct { appStates []*panelv1.AppStateUpdate playerEvents []*panelv1.PlayerEvent } func (c *captureEmitter) EmitAppState(a *panelv1.AppStateUpdate) { c.appStates = append(c.appStates, a) } func (c *captureEmitter) EmitPlayerEvent(p *panelv1.PlayerEvent) { c.playerEvents = append(c.playerEvents, p) } func newTestTracker(t *testing.T, m *modulepkg.Manifest) (*Tracker, *captureEmitter) { t.Helper() cap := &captureEmitter{} log := slog.New(slog.NewTextHandler(io.Discard, nil)) tr, err := New(log, Config{ InstanceID: "inst-1", Manifest: m, Emitter: cap, }) if err != nil { t.Fatalf("New: %v", err) } return tr, cap } func Test7DTDJoinPattern(t *testing.T) { manifest, err := modulepkg.LoadFile("../../../modules/7dtd/module.yaml") if err != nil { t.Fatalf("load 7dtd: %v", err) } tr, cap := newTestTracker(t, manifest) // Real-world 7DTD log line shape line := `2026-04-19T10:00:00 42.123 INF PlayerSpawnedInWorld (by system): PltfmId='Steam_76561198000000001', CrossId='EOS_00000000', OwnerID='Steam_76561198000000001', PlayerName='Alice'` tr.OnLogLine(line) if len(cap.playerEvents) != 1 { t.Fatalf("expected 1 player event, got %d", len(cap.playerEvents)) } ev := cap.playerEvents[0] if ev.Kind != panelv1.PlayerEvent_KIND_JOIN { t.Errorf("kind = %v, want JOIN", ev.Kind) } if ev.PlayerName != "Alice" { t.Errorf("name = %q, want Alice", ev.PlayerName) } if ev.PlayerId == "" { t.Errorf("player_id empty; expected Steam_... from named groups") } } func Test7DTDChatPattern(t *testing.T) { manifest, err := modulepkg.LoadFile("../../../modules/7dtd/module.yaml") if err != nil { t.Fatalf("load 7dtd: %v", err) } tr, cap := newTestTracker(t, manifest) line := `2026-04-19T10:00:00 42.123 INF Chat (from 'Steam_76561198000000001', entity id '123', to 'Global'): 'Alice': hello world` tr.OnLogLine(line) if len(cap.playerEvents) != 1 { t.Fatalf("expected 1 player event, got %d", len(cap.playerEvents)) } ev := cap.playerEvents[0] if ev.Kind != panelv1.PlayerEvent_KIND_CHAT { t.Errorf("kind = %v, want CHAT", ev.Kind) } if ev.PlayerName != "Alice" { t.Errorf("name = %q, want Alice", ev.PlayerName) } if ev.Detail != "hello world" { t.Errorf("detail = %q, want 'hello world'", ev.Detail) } } func Test7DTDLPParse(t *testing.T) { manifest, err := modulepkg.LoadFile("../../../modules/7dtd/module.yaml") if err != nil { t.Fatalf("load 7dtd: %v", err) } var lp *modulepkg.StateSource for i := range manifest.StateSources { if manifest.StateSources[i].Type == "rcon" { lp = &manifest.StateSources[i] break } } if lp == nil { t.Fatal("no rcon state source in 7dtd manifest") } sample := "Total of 3 in the game\n\n1. id=1, Alice, pos=(0,0,0)\n2. id=2, Bob, pos=(1,0,0)\n3. id=3, Carol, pos=(2,0,0)" app := parseStateOutput("inst-1", sample, *lp) if app == nil { t.Fatal("parse returned nil") } if app.PlayersOnline != 3 { t.Errorf("players_online = %d, want 3", app.PlayersOnline) } } func TestLineWithNoMatchYieldsNoEvent(t *testing.T) { manifest, err := modulepkg.LoadFile("../../../modules/7dtd/module.yaml") if err != nil { t.Fatalf("load 7dtd: %v", err) } tr, cap := newTestTracker(t, manifest) tr.OnLogLine("this is some random log line that matches nothing") if len(cap.playerEvents) != 0 { t.Errorf("expected 0 events, got %d", len(cap.playerEvents)) } } // ---- WI-05: hoisted state-source regex compilation ---- // TestNewRejectsBadStateSourcePattern: a malformed parse regex must fail at // construction (surfacing the manifest bug at init) instead of silently // recompiling-and-failing on every poll tick. func TestNewRejectsBadStateSourcePattern(t *testing.T) { m := &modulepkg.Manifest{ StateSources: []modulepkg.StateSource{{ Type: "rcon", Command: "lp", Parse: &modulepkg.ParseConfig{Kind: "regex", Pattern: "("}, }}, } log := slog.New(slog.NewTextHandler(io.Discard, nil)) if _, err := New(log, Config{InstanceID: "i", Manifest: m, Emitter: &captureEmitter{}}); err == nil { t.Fatal("New accepted an invalid state-source regex; want construction error") } } // TestParseStateUsesPrecompiledRegex drives the tracker's precompiled parse // path with the real 7DTD manifest and confirms it matches the uncached // package-level parser. func TestParseStateUsesPrecompiledRegex(t *testing.T) { manifest, err := modulepkg.LoadFile("../../../modules/7dtd/module.yaml") if err != nil { t.Fatalf("load 7dtd: %v", err) } tr, _ := newTestTracker(t, manifest) var lp *modulepkg.StateSource for i := range manifest.StateSources { if manifest.StateSources[i].Type == "rcon" { lp = &manifest.StateSources[i] break } } if lp == nil { t.Fatal("no rcon state source in 7dtd manifest") } sample := "Total of 3 in the game\n\n1. id=1, Alice, pos=(0,0,0)\n2. id=2, Bob, pos=(1,0,0)\n3. id=3, Carol, pos=(2,0,0)" app := tr.parseState(sample, *lp) if app == nil { t.Fatal("precompiled parseState returned nil") } if app.PlayersOnline != 3 { t.Errorf("players_online = %d, want 3", app.PlayersOnline) } }