5232609719
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
171 lines
5.1 KiB
Go
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)
|
|
}
|
|
}
|