package companion import ( "reflect" "strings" "testing" "time" "longheng.io/server/internal/modules/dnf/repository" ) func TestDefaultAIConfigIsProductConfig(t *testing.T) { got := DefaultAIConfig() want := AIConfig{BuffPolicy: "leader_first", ComboPreset: "auto", ComboEnabled: true, AOEMinTargets: 3, ProtectLeader: true} if !reflect.DeepEqual(got, want) { t.Fatalf("default AI=%+v want=%+v", got, want) } } func TestAIConfigRejectsInvalidProductValues(t *testing.T) { ai := DefaultAIConfig() for name, edit := range map[string]func(*AIConfig){ "buff_policy": func(v *AIConfig) { v.BuffPolicy = "unknown" }, "aoe_low": func(v *AIConfig) { v.AOEMinTargets = 1 }, "aoe_high": func(v *AIConfig) { v.AOEMinTargets = 7 }, "preset": func(v *AIConfig) { v.ComboPreset = strings.Repeat("x", 65) }, } { t.Run(name, func(t *testing.T) { value := ai edit(&value) if err := value.Validate(); err != ErrSelection { t.Fatalf("validate=%v want=%v", err, ErrSelection) } }) } } func TestAIConfigSettingsRoundTrip(t *testing.T) { want := Config{SchemaVersion: 1, Revision: 3, Enabled: true, SourceCharacterID: "8", SourceCharacterIDs: []string{"8"}, AI: DefaultAIConfig(), AIProfiles: map[string]AIProfile{ "8": {Behavior: "aggressive", ClassID: 9, BuffPolicy: "leader_first", ComboPreset: "sword_guard", ComboEnabled: true, AOEMinTargets: 3, ProtectLeader: true}, }} record, err := AccountConfigSettingsRecord("ai-round-trip", want, time.Date(2026, 10, 3, 5, 0, 0, 0, time.UTC)) if err != nil { t.Fatal(err) } if record.Values["ai_schema_version"] != "2" || record.Values["ai_defaults"] == "" { t.Fatalf("product AI fields missing: %+v", record.Values) } for key := range record.Values { if strings.HasPrefix(key, "ai_") && key != "ai_schema_version" && key != "ai_defaults" && key != "ai_profiles" { t.Fatalf("legacy AI field was written: %q", key) } } got, err := ConfigFromAccountSettings(record, "ai-round-trip") if err != nil { t.Fatal(err) } if !reflect.DeepEqual(got, want) { t.Fatalf("round trip=%+v want=%+v", got, want) } } func TestAITuningSettingsRoundTrip(t *testing.T) { tuning := &AITuning{ThinkIntervalMS: 200, FollowDistance: 60, HealLeaderPercent: 70, StuckMS: 900, DetourMS: 1100, GiveUpMS: 4000} want := Config{ SchemaVersion: 1, Revision: 4, Enabled: true, SourceCharacterID: "8", SourceCharacterIDs: []string{"8"}, AIProfiles: map[string]AIProfile{"8": { Behavior: "assist", ClassID: 9, BuffPolicy: AIBuffPolicyLeaderFirst, ComboPreset: "priest_support", ComboEnabled: false, AOEMinTargets: 3, ProtectLeader: true, Tuning: tuning, }}, } record, err := AccountConfigSettingsRecord("ai-tuning", want, time.Date(2026, 10, 4, 5, 0, 0, 0, time.UTC)) if err != nil { t.Fatal(err) } got, err := ConfigFromAccountSettings(record, "ai-tuning") if err != nil { t.Fatal(err) } if !reflect.DeepEqual(got, want) { t.Fatalf("tuning round trip=%+v want=%+v", got, want) } } func TestLegacyAIInternalFieldsAreIgnoredAndCustomMigrates(t *testing.T) { record := repository.SettingsRecord{ Scope: "account:ai-legacy:companion-v1", Values: map[string]string{ "schema_version": "1", "revision": "2", "enabled": "true", "source_character_id": "8", "source_character_ids": "8", "ai_tempo": "custom", "ai_leader_leash_x": "999", "ai_give_up_ms": "20000", "ai_profiles": `{"8":{"behavior":"custom","class_id":9,"combo_preset":"auto","combo_enabled":true}}`, }, } cfg, err := ConfigFromAccountSettings(record, "ai-legacy") if err != nil { t.Fatal(err) } profile := cfg.AIProfiles["8"] if profile.Behavior != "assist" || profile.BuffPolicy != AIBuffPolicyLeaderFirst || profile.AOEMinTargets != 3 || !profile.ProtectLeader { t.Fatalf("legacy profile was not normalized: %+v", profile) } if !cfg.AI.IsZero() { t.Fatalf("legacy internal scalar fields were retained: %+v", cfg.AI) } } func TestAIProfileRejectsInvalidModeOrPreset(t *testing.T) { for name, profile := range map[string]AIProfile{ "mode": {Behavior: "wander", ComboPreset: "auto"}, "preset": {Behavior: "assist", ComboPreset: strings.Repeat("x", 65)}, } { t.Run(name, func(t *testing.T) { if err := profile.Validate(); err != ErrSelection { t.Fatalf("validate=%v want=%v", err, ErrSelection) } }) } } func TestConfigFromSettingsLegacySourceRow(t *testing.T) { record := repository.SettingsRecord{ Scope: "character:3:companion-v1", Values: map[string]string{"source_character_id": "2"}, } cfg, err := ConfigFromSettings(record, "3") if err != nil { t.Fatalf("ConfigFromSettings legacy: %v", err) } if !cfg.Enabled || cfg.Revision != 0 || cfg.SourceCharacterID != "2" { t.Fatalf("legacy cfg = %+v", cfg) } } func TestConfigSettingsRecordRoundTrip(t *testing.T) { now := time.Date(2026, 9, 25, 5, 0, 0, 0, time.UTC) want := Config{SchemaVersion: 1, Revision: 7, Enabled: true, BalanceEnabled: true, RandomEquipmentEnabled: true, SourceCharacterID: "8", SourceCharacterIDs: []string{"8"}} record, err := ConfigSettingsRecord("3", want, now) if err != nil { t.Fatalf("ConfigSettingsRecord: %v", err) } if !record.UpdatedAt.Equal(now) { t.Fatalf("updated_at = %v want %v", record.UpdatedAt, now) } got, err := ConfigFromSettings(record, "3") if err != nil { t.Fatalf("ConfigFromSettings: %v", err) } if !reflect.DeepEqual(got, want) { t.Fatalf("round trip = %+v want %+v", got, want) } } func TestConfigFromSettingsRejectsNonCanonicalRevision(t *testing.T) { record := repository.SettingsRecord{ Scope: "character:3:companion-v1", Values: map[string]string{ "schema_version": "1", "revision": "007", "enabled": "true", "source_character_id": "2", }, } if _, err := ConfigFromSettings(record, "3"); err != ErrSelection { t.Fatalf("err = %v want %v", err, ErrSelection) } } func TestSourceCharacterIDFromSettingsDisabled(t *testing.T) { record := repository.SettingsRecord{ Scope: "character:3:companion-v1", Values: map[string]string{ "schema_version": "1", "revision": "2", "enabled": "false", "source_character_id": "8", }, } source, selected, err := SourceCharacterIDFromSettings(record, "3") if err != nil { t.Fatalf("SourceCharacterIDFromSettings: %v", err) } if selected || source != "" { t.Fatalf("disabled source = %q selected=%v", source, selected) } } func TestNextConfigPersistsBalanceSwitch(t *testing.T) { current := Config{ SchemaVersion: 1, Revision: 4, Enabled: true, BalanceEnabled: false, SourceCharacterID: "2", } next, err := NextConfig(current, 4, true, "2", "3", nil, true) if err != nil { t.Fatal(err) } if next.Revision != 5 || !next.Enabled || !next.BalanceEnabled || next.SourceCharacterID != "2" { t.Fatalf("next=%+v", next) } original, err := NextConfig(next, 5, true, "2", "3", nil, false) if err != nil { t.Fatal(err) } if original.BalanceEnabled { t.Fatalf("balance switch did not turn off: %+v", original) } } func TestNextConfigPersistsProgressionSwitchDefaultOff(t *testing.T) { current := Config{ SchemaVersion: 1, Revision: 4, Enabled: true, SourceCharacterID: "2", SourceCharacterIDs: []string{"2"}, } next, err := NextConfigMany(current, 4, true, []string{"2"}, "3", false, true) if err != nil { t.Fatal(err) } if !next.ProgressionEnabled || next.BalanceEnabled { t.Fatalf("next=%+v", next) } record, err := AccountConfigSettingsRecord("account-progression", next, time.Date(2026, 9, 25, 5, 0, 0, 0, time.UTC)) if err != nil { t.Fatal(err) } if record.Values[configProgressionKey] != "true" { t.Fatalf("stored progression switch=%q", record.Values[configProgressionKey]) } legacy := repository.SettingsRecord{ Scope: "account:account-progression:companion-v1", Values: map[string]string{ "schema_version": "1", "revision": "1", "enabled": "true", "source_character_id": "2", }, } legacyCfg, err := ConfigFromAccountSettings(legacy, "account-progression") if err != nil { t.Fatal(err) } if legacyCfg.ProgressionEnabled { t.Fatalf("legacy config unexpectedly enabled progression: %+v", legacyCfg) } } func TestNextConfigPersistsRandomEquipmentSwitchDefaultOff(t *testing.T) { current := Config{ SchemaVersion: 1, Revision: 4, Enabled: true, SourceCharacterID: "2", SourceCharacterIDs: []string{"2"}, } next, err := NextConfigMany(current, 4, true, []string{"2"}, "3", false, false, true) if err != nil { t.Fatal(err) } if !next.RandomEquipmentEnabled || next.BalanceEnabled || next.ProgressionEnabled { t.Fatalf("next=%+v", next) } record, err := AccountConfigSettingsRecord("account-random-equipment", next, time.Date(2026, 9, 25, 5, 0, 0, 0, time.UTC)) if err != nil { t.Fatal(err) } if record.Values[configRandomEquipmentKey] != "true" { t.Fatalf("stored random equipment switch=%q", record.Values[configRandomEquipmentKey]) } legacy := repository.SettingsRecord{ Scope: "account:account-random-equipment:companion-v1", Values: map[string]string{ "schema_version": "1", "revision": "1", "enabled": "true", "source_character_id": "2", }, } legacyCfg, err := ConfigFromAccountSettings(legacy, "account-random-equipment") if err != nil { t.Fatal(err) } if legacyCfg.RandomEquipmentEnabled { t.Fatalf("legacy config unexpectedly enabled random equipment: %+v", legacyCfg) } } func TestAccountConfigSettingsRoundTrip(t *testing.T) { now := time.Date(2026, 9, 25, 15, 0, 0, 0, time.FixedZone("CST", 8*60*60)) cfg := Config{ SchemaVersion: 1, Revision: 7, Enabled: true, BalanceEnabled: true, RandomEquipmentEnabled: true, SourceCharacterID: "11", SourceCharacterIDs: []string{"11"}, } record, err := AccountConfigSettingsRecord("account-projection", cfg, now) if err != nil { t.Fatal(err) } if record.Scope != "account:account-projection:companion-v1" { t.Fatalf("scope=%q", record.Scope) } got, err := ConfigFromAccountSettings(record, "account-projection") if err != nil { t.Fatal(err) } if !reflect.DeepEqual(got, cfg) { t.Fatalf("got=%+v want=%+v", got, cfg) } if _, err := ConfigFromAccountSettings(record, "other-account"); err != ErrSelection { t.Fatalf("wrong-account scope err=%v", err) } } func TestAccountConfigSettingsMultiSourceRoundTrip(t *testing.T) { now := time.Date(2026, 9, 27, 2, 0, 0, 0, time.FixedZone("CST", 8*60*60)) cfg := Config{ SchemaVersion: 1, Revision: 9, Enabled: true, SourceCharacterID: "2", SourceCharacterIDs: []string{"2", "8", "11"}, } record, err := AccountConfigSettingsRecord("account-multi", cfg, now) if err != nil { t.Fatal(err) } if got := record.Values[sourceCharactersSettingKey]; got != "2,8,11" { t.Fatalf("stored sources=%q", got) } got, err := ConfigFromAccountSettings(record, "account-multi") if err != nil { t.Fatal(err) } if !reflect.DeepEqual(got, cfg) { t.Fatalf("got=%+v want=%+v", got, cfg) } }