Files
panel/agent/internal/state/tracker_test.go
T
2026-07-15 01:06:16 -07:00

171 lines
5.1 KiB
Go

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)
}
}