package sign import ( "fmt" "strings" "sync" "testing" "eq2emu/internal/spawn" ) // Mock implementations for testing // MockLogger implements the Logger interface for testing type MockLogger struct { logs []string mu sync.Mutex } func (ml *MockLogger) LogInfo(message string, args ...any) { ml.mu.Lock() defer ml.mu.Unlock() ml.logs = append(ml.logs, fmt.Sprintf("INFO: "+message, args...)) } func (ml *MockLogger) LogError(message string, args ...any) { ml.mu.Lock() defer ml.mu.Unlock() ml.logs = append(ml.logs, fmt.Sprintf("ERROR: "+message, args...)) } func (ml *MockLogger) LogDebug(message string, args ...any) { ml.mu.Lock() defer ml.mu.Unlock() ml.logs = append(ml.logs, fmt.Sprintf("DEBUG: "+message, args...)) } func (ml *MockLogger) LogWarning(message string, args ...any) { ml.mu.Lock() defer ml.mu.Unlock() ml.logs = append(ml.logs, fmt.Sprintf("WARNING: "+message, args...)) } func (ml *MockLogger) GetLogs() []string { ml.mu.Lock() defer ml.mu.Unlock() result := make([]string, len(ml.logs)) copy(result, ml.logs) return result } func (ml *MockLogger) Clear() { ml.mu.Lock() defer ml.mu.Unlock() ml.logs = ml.logs[:0] } // MockDatabase implements the Database interface for testing type MockDatabase struct { signs map[int32][]*Sign zoneNames map[int32]string charNames map[int32]string signMarks map[int32]map[int32]string // charID -> widgetID -> charName mu sync.RWMutex } func NewMockDatabase() *MockDatabase { return &MockDatabase{ signs: make(map[int32][]*Sign), zoneNames: make(map[int32]string), charNames: make(map[int32]string), signMarks: make(map[int32]map[int32]string), } } func (md *MockDatabase) GetZoneName(zoneID int32) (string, error) { md.mu.RLock() defer md.mu.RUnlock() if name, exists := md.zoneNames[zoneID]; exists { return name, nil } return "", fmt.Errorf("zone %d not found", zoneID) } func (md *MockDatabase) GetCharacterName(charID int32) (string, error) { md.mu.RLock() defer md.mu.RUnlock() if name, exists := md.charNames[charID]; exists { return name, nil } return "", fmt.Errorf("character %d not found", charID) } func (md *MockDatabase) SaveSignMark(charID int32, widgetID int32, charName string, client Client) error { md.mu.Lock() defer md.mu.Unlock() if md.signMarks[charID] == nil { md.signMarks[charID] = make(map[int32]string) } md.signMarks[charID][widgetID] = charName return nil } func (md *MockDatabase) LoadSigns(zoneID int32) ([]*Sign, error) { md.mu.RLock() defer md.mu.RUnlock() return md.signs[zoneID], nil } func (md *MockDatabase) SaveSign(sign *Sign) error { return nil // No-op for testing } func (md *MockDatabase) DeleteSign(signID int32) error { return nil // No-op for testing } func (md *MockDatabase) AddZone(zoneID int32, name string) { md.mu.Lock() defer md.mu.Unlock() md.zoneNames[zoneID] = name } func (md *MockDatabase) AddCharacter(charID int32, name string) { md.mu.Lock() defer md.mu.Unlock() md.charNames[charID] = name } func (md *MockDatabase) AddSignToZone(zoneID int32, sign *Sign) { md.mu.Lock() defer md.mu.Unlock() md.signs[zoneID] = append(md.signs[zoneID], sign) } // MockPlayer implements the Player interface for testing type MockPlayer struct { x, y, z, heading float32 zone Zone target *spawn.Spawn } func (mp *MockPlayer) GetDistance(target *spawn.Spawn) float32 { if target == nil { return 0 } return 5.0 // Mock distance } func (mp *MockPlayer) SetX(x float32) { mp.x = x } func (mp *MockPlayer) SetY(y float32) { mp.y = y } func (mp *MockPlayer) SetZ(z float32) { mp.z = z } func (mp *MockPlayer) SetHeading(heading float32) { mp.heading = heading } func (mp *MockPlayer) GetZone() Zone { return mp.zone } func (mp *MockPlayer) GetTarget() *spawn.Spawn { return mp.target } // MockZone implements the Zone interface for testing type MockZone struct { transporters map[int32][]TransportDestination } func (mz *MockZone) GetTransporters(client Client, transporterID int32) ([]TransportDestination, error) { if transporters, exists := mz.transporters[transporterID]; exists { return transporters, nil } return nil, fmt.Errorf("transporter %d not found", transporterID) } func (mz *MockZone) ProcessEntityCommand(command *EntityCommand, player Player, target *spawn.Spawn) error { return nil // No-op for testing } // MockClient implements the Client interface for testing type MockClient struct { player Player charID int32 database Database zone Zone messages []string tempTransportID int32 zoneAccess map[string]bool mu sync.Mutex } func (mc *MockClient) GetPlayer() Player { return mc.player } func (mc *MockClient) GetCharacterID() int32 { return mc.charID } func (mc *MockClient) GetDatabase() Database { return mc.database } func (mc *MockClient) GetCurrentZone() Zone { return mc.zone } func (mc *MockClient) SetTemporaryTransportID(id int32) { mc.tempTransportID = id } func (mc *MockClient) SimpleMessage(channel int32, message string) { mc.mu.Lock() defer mc.mu.Unlock() mc.messages = append(mc.messages, message) } func (mc *MockClient) Message(channel int32, format string, args ...any) { mc.mu.Lock() defer mc.mu.Unlock() mc.messages = append(mc.messages, fmt.Sprintf(format, args...)) } func (mc *MockClient) CheckZoneAccess(zoneName string) bool { return mc.zoneAccess[zoneName] } func (mc *MockClient) TryZoneInstance(zoneID int32, useDefaults bool) bool { return false // Mock always uses regular zones } func (mc *MockClient) Zone(zoneName string, useDefaults bool) error { return nil // No-op for testing } func (mc *MockClient) ProcessTeleport(sign *Sign, destinations []TransportDestination, transporterID int32) error { return nil // No-op for testing } func (mc *MockClient) GetMessages() []string { mc.mu.Lock() defer mc.mu.Unlock() result := make([]string, len(mc.messages)) copy(result, mc.messages) return result } func (mc *MockClient) ClearMessages() { mc.mu.Lock() defer mc.mu.Unlock() mc.messages = mc.messages[:0] } // MockEntity implements the Entity interface for testing type MockEntity struct { id int32 name string databaseID int32 } func (me *MockEntity) GetID() int32 { return me.id } func (me *MockEntity) GetName() string { return me.name } func (me *MockEntity) GetDatabaseID() int32 { return me.databaseID } // Helper functions for testing func createTestSign() *Sign { sign := NewSign() sign.Spawn.SetDatabaseID(1) sign.Spawn.SetName("Test Sign") sign.SetWidgetID(100) sign.SetSignTitle("Welcome") sign.SetSignDescription("This is a test sign") return sign } func createZoneSign() *Sign { sign := createTestSign() sign.SetSignType(SignTypeZone) sign.SetSignZoneID(2) sign.SetSignZoneX(100.0) sign.SetSignZoneY(200.0) sign.SetSignZoneZ(300.0) sign.SetSignZoneHeading(45.0) return sign } // Tests func TestNewSign(t *testing.T) { sign := NewSign() if sign == nil { t.Fatal("NewSign() returned nil") } if sign.Spawn == nil { t.Error("Sign spawn is nil") } if sign.GetSignType() != SignTypeGeneric { t.Errorf("Expected sign type %d, got %d", SignTypeGeneric, sign.GetSignType()) } if !sign.IsSign() { t.Error("IsSign() should return true") } if sign.IsZoneSign() { t.Error("IsZoneSign() should return false for generic sign") } if !sign.IsGenericSign() { t.Error("IsGenericSign() should return true for generic sign") } } func TestSignProperties(t *testing.T) { sign := NewSign() // Test widget properties sign.SetWidgetID(123) if sign.GetWidgetID() != 123 { t.Errorf("Expected widget ID 123, got %d", sign.GetWidgetID()) } sign.SetWidgetX(10.5) if sign.GetWidgetX() != 10.5 { t.Errorf("Expected widget X 10.5, got %f", sign.GetWidgetX()) } // Test sign properties sign.SetSignTitle("Test Title") if sign.GetSignTitle() != "Test Title" { t.Errorf("Expected title 'Test Title', got '%s'", sign.GetSignTitle()) } sign.SetSignDescription("Test Description") if sign.GetSignDescription() != "Test Description" { t.Errorf("Expected description 'Test Description', got '%s'", sign.GetSignDescription()) } // Test zone properties sign.SetSignZoneID(456) if sign.GetSignZoneID() != 456 { t.Errorf("Expected zone ID 456, got %d", sign.GetSignZoneID()) } sign.SetSignZoneX(100.0) sign.SetSignZoneY(200.0) sign.SetSignZoneZ(300.0) sign.SetSignZoneHeading(45.0) if !sign.HasZoneCoordinates() { t.Error("Should have zone coordinates") } // Test display options sign.SetIncludeLocation(true) if !sign.GetIncludeLocation() { t.Error("Include location should be true") } } func TestSignValidation(t *testing.T) { // Valid sign sign := createTestSign() if !sign.IsValid() { t.Error("Sign should be valid") } issues := sign.Validate() if len(issues) > 0 { t.Errorf("Valid sign should have no issues, got: %v", issues) } // Invalid sign - no widget ID sign.SetWidgetID(0) if sign.IsValid() { t.Error("Sign without widget ID should be invalid") } issues = sign.Validate() if len(issues) == 0 { t.Error("Sign without widget ID should have validation issues") } // Invalid sign - title too long sign.SetWidgetID(100) sign.SetSignTitle(strings.Repeat("a", MaxSignTitleLength+1)) issues = sign.Validate() found := false for _, issue := range issues { if strings.Contains(issue, "title too long") { found = true break } } if !found { t.Error("Should have title too long validation issue") } // Invalid zone sign - no zone ID sign = createTestSign() sign.SetSignType(SignTypeZone) // Don't set zone ID issues = sign.Validate() found = false for _, issue := range issues { if strings.Contains(issue, "no zone ID") { found = true break } } if !found { t.Error("Zone sign without zone ID should have validation issue") } } func TestSignCopy(t *testing.T) { original := createTestSign() original.SetSignTitle("Original Title") original.SetSignDescription("Original Description") copy := original.Copy() if copy == nil { t.Fatal("Copy returned nil") } if copy == original { t.Error("Copy should not be the same instance") } if copy.GetSignTitle() != original.GetSignTitle() { t.Error("Copy should have same title") } if copy.GetSignDescription() != original.GetSignDescription() { t.Error("Copy should have same description") } if copy.GetWidgetID() != original.GetWidgetID() { t.Error("Copy should have same widget ID") } // Modify copy and ensure original is unchanged copy.SetSignTitle("Modified Title") if original.GetSignTitle() == "Modified Title" { t.Error("Modifying copy should not affect original") } } func TestSignDisplayText(t *testing.T) { sign := NewSign() // Empty sign text := sign.GetDisplayText() if len(text) > 0 { t.Error("Empty sign should have no display text") } // Title only sign.SetSignTitle("Test Title") text = sign.GetDisplayText() if text != "Test Title" { t.Errorf("Expected 'Test Title', got '%s'", text) } // Title and description sign.SetSignDescription("Test Description") text = sign.GetDisplayText() expected := "Test Title\nTest Description" if text != expected { t.Errorf("Expected '%s', got '%s'", expected, text) } // With location sign.SetSignZoneX(100.0) sign.SetSignZoneY(200.0) sign.SetSignZoneZ(300.0) sign.SetIncludeLocation(true) text = sign.GetDisplayText() if !strings.Contains(text, "Location: 100.00, 200.00, 300.00") { t.Error("Display text should contain location information") } // With heading sign.SetSignZoneHeading(45.0) sign.SetIncludeHeading(true) text = sign.GetDisplayText() if !strings.Contains(text, "Heading: 45.00") { t.Error("Display text should contain heading information") } } func TestSignHandleUse(t *testing.T) { // Setup test environment database := NewMockDatabase() database.AddZone(2, "Test Zone") database.AddCharacter(1, "TestPlayer") player := &MockPlayer{} client := &MockClient{ player: player, charID: 1, database: database, zoneAccess: map[string]bool{"Test Zone": true}, } // Test zone transport sign sign := createZoneSign() sign.SetSignDistance(10.0) // Set distance limit err := sign.HandleUse(client, "") if err != nil { t.Errorf("HandleUse failed: %v", err) } // Check if player position was set if player.x != 100.0 || player.y != 200.0 || player.z != 300.0 { t.Error("Player position should be updated for zone transport") } if player.heading != 45.0 { t.Error("Player heading should be updated for zone transport") } // Test mark command sign = createTestSign() err = sign.HandleUse(client, "mark") if err != nil { t.Errorf("Mark command failed: %v", err) } // Test distance check sign = createZoneSign() sign.SetSignDistance(1.0) // Very short distance client.ClearMessages() err = sign.HandleUse(client, "") if err != nil { t.Errorf("HandleUse with distance check failed: %v", err) } messages := client.GetMessages() found := false for _, msg := range messages { if strings.Contains(msg, "too far away") { found = true break } } if !found { t.Error("Should get 'too far away' message when outside distance") } } func TestSignSpawn(t *testing.T) { baseSpawn := spawn.NewSpawn() baseSpawn.SetName("Test Sign Spawn") signSpawn := NewSignSpawn(baseSpawn) if signSpawn == nil { t.Fatal("NewSignSpawn returned nil") } if !signSpawn.IsSign() { t.Error("SignSpawn should be a sign") } if signSpawn.Spawn != baseSpawn { t.Error("SignSpawn should reference the base spawn") } // Test copy copy := signSpawn.Copy() if copy == nil { t.Fatal("SignSpawn Copy returned nil") } if copy == signSpawn { t.Error("SignSpawn Copy should create new instance") } if copy.Spawn.GetName() != baseSpawn.GetName() { t.Error("Copied SignSpawn should have same spawn name") } } func TestSignAdapter(t *testing.T) { logger := &MockLogger{} entity := &MockEntity{id: 1, name: "Test Entity", databaseID: 100} adapter := NewSignAdapter(entity, logger) if adapter == nil { t.Fatal("NewSignAdapter returned nil") } if !adapter.IsSign() { t.Error("SignAdapter should be a sign") } if adapter.GetSign() == nil { t.Error("SignAdapter should have a sign") } // Test setting properties with logging adapter.GetSign().SetWidgetID(500) // Set widget ID for validation adapter.SetSignTitle("Test Title") adapter.SetSignDescription("Test Description") adapter.SetSignType(SignTypeZone) adapter.SetZoneTransport(2, 100.0, 200.0, 300.0, 45.0) adapter.SetSignDistance(15.0) logs := logger.GetLogs() if len(logs) == 0 { t.Error("SignAdapter operations should generate log messages") } // Verify sign properties were set sign := adapter.GetSign() if sign.GetSignTitle() != "Test Title" { t.Error("Sign title not set correctly") } if sign.GetSignType() != SignTypeZone { t.Error("Sign type not set correctly") } if sign.GetSignZoneID() != 2 { t.Error("Zone ID not set correctly") } // Test validation if !adapter.IsValid() { issues := adapter.Validate() t.Errorf("SignAdapter should be valid, issues: %v", issues) } } func TestManager(t *testing.T) { database := NewMockDatabase() logger := &MockLogger{} manager := NewManager(database, logger) if manager == nil { t.Fatal("NewManager returned nil") } // Test initialization err := manager.Initialize() if err != nil { t.Errorf("Manager initialization failed: %v", err) } // Test adding signs sign1 := createTestSign() sign1.Spawn.SetDatabaseID(1) sign1.SetWidgetID(100) err = manager.AddSign(sign1) if err != nil { t.Errorf("Adding sign failed: %v", err) } if manager.GetSignCount() != 1 { t.Errorf("Expected 1 sign, got %d", manager.GetSignCount()) } // Test retrieving signs retrieved := manager.GetSign(1) if retrieved == nil { t.Error("Should be able to retrieve sign by ID") } retrieved = manager.GetSignByWidget(100) if retrieved == nil { t.Error("Should be able to retrieve sign by widget ID") } // Test zone functionality sign2 := createZoneSign() sign2.Spawn.SetDatabaseID(2) sign2.SetWidgetID(200) err = manager.AddSignToZone(sign2, 1) if err != nil { t.Errorf("Adding sign to zone failed: %v", err) } zoneSigns := manager.GetZoneSigns(1) if len(zoneSigns) != 1 { t.Errorf("Expected 1 zone sign, got %d", len(zoneSigns)) } // Test statistics stats := manager.GetStatistics() if stats["total_signs"] != int64(2) { t.Errorf("Expected 2 total signs in stats, got %v", stats["total_signs"]) } typeStats := stats["signs_by_type"].(map[int8]int64) if typeStats[SignTypeGeneric] != 1 { t.Errorf("Expected 1 generic sign in stats, got %d", typeStats[SignTypeGeneric]) } if typeStats[SignTypeZone] != 1 { t.Errorf("Expected 1 zone sign in stats, got %d", typeStats[SignTypeZone]) } // Test removing signs if !manager.RemoveSign(1) { t.Error("Should be able to remove sign") } if manager.GetSignCount() != 1 { t.Errorf("Expected 1 sign after removal, got %d", manager.GetSignCount()) } // Test validation issues := manager.ValidateAllSigns() if len(issues) > 0 { t.Errorf("All signs should be valid, found issues: %v", issues) } // Test commands result, err := manager.ProcessCommand("stats", nil) if err != nil { t.Errorf("Stats command failed: %v", err) } if !strings.Contains(result, "Sign System Statistics") { t.Error("Stats command should return statistics") } result, err = manager.ProcessCommand("list", nil) if err != nil { t.Errorf("List command failed: %v", err) } if !strings.Contains(result, "Signs (1)") { t.Error("List command should show sign count") } result, err = manager.ProcessCommand("validate", nil) if err != nil { t.Errorf("Validate command failed: %v", err) } if !strings.Contains(result, "All signs are valid") { t.Error("Validate command should report all signs valid") } result, err = manager.ProcessCommand("info", []string{"2"}) if err != nil { t.Errorf("Info command failed: %v", err) } if !strings.Contains(result, "Sign Information") { t.Error("Info command should show sign information") } // Test unknown command _, err = manager.ProcessCommand("unknown", nil) if err == nil { t.Error("Unknown command should return error") } } func TestManagerConcurrency(t *testing.T) { database := NewMockDatabase() logger := &MockLogger{} manager := NewManager(database, logger) const numGoroutines = 10 const numOperations = 100 var wg sync.WaitGroup wg.Add(numGoroutines) // Test concurrent sign additions for i := 0; i < numGoroutines; i++ { go func(id int) { defer wg.Done() for j := 0; j < numOperations; j++ { sign := createTestSign() signID := int32(id*numOperations + j + 1) sign.Spawn.SetDatabaseID(signID) sign.SetWidgetID(signID) err := manager.AddSign(sign) if err != nil { t.Errorf("Concurrent AddSign failed: %v", err) } } }(i) } wg.Wait() expectedCount := int64(numGoroutines * numOperations) if manager.GetSignCount() != expectedCount { t.Errorf("Expected %d signs after concurrent additions, got %d", expectedCount, manager.GetSignCount()) } // Test concurrent reads wg.Add(numGoroutines) for i := 0; i < numGoroutines; i++ { go func() { defer wg.Done() for j := 0; j < numOperations; j++ { stats := manager.GetStatistics() if stats["total_signs"].(int64) <= 0 { t.Error("Stats should show positive sign count") } } }() } wg.Wait() } func TestSignSerialization(t *testing.T) { sign := createTestSign() player := &MockPlayer{} // Test serialization (currently returns error due to incomplete implementation) _, err := sign.Serialize(player, 1146) if err == nil { t.Error("Serialization should return error with current implementation") } if !strings.Contains(err.Error(), "not yet implemented") { t.Error("Should get 'not yet implemented' error message") } } func TestSignConstants(t *testing.T) { // Test sign type constants if SignTypeGeneric != 0 { t.Errorf("SignTypeGeneric should be 0, got %d", SignTypeGeneric) } if SignTypeZone != 1 { t.Errorf("SignTypeZone should be 1, got %d", SignTypeZone) } // Test default constants if DefaultSpawnType != 2 { t.Errorf("DefaultSpawnType should be 2, got %d", DefaultSpawnType) } if DefaultActivityStatus != 64 { t.Errorf("DefaultActivityStatus should be 64, got %d", DefaultActivityStatus) } if DefaultSignDistance != 0.0 { t.Errorf("DefaultSignDistance should be 0.0, got %f", DefaultSignDistance) } } // Benchmark tests func BenchmarkNewSign(b *testing.B) { for i := 0; i < b.N; i++ { _ = NewSign() } } func BenchmarkSignCopy(b *testing.B) { sign := createTestSign() b.ResetTimer() for i := 0; i < b.N; i++ { _ = sign.Copy() } } func BenchmarkSignValidation(b *testing.B) { sign := createTestSign() b.ResetTimer() for i := 0; i < b.N; i++ { _ = sign.Validate() } } func BenchmarkManagerAddSign(b *testing.B) { database := NewMockDatabase() logger := &MockLogger{} manager := NewManager(database, logger) b.ResetTimer() for i := 0; i < b.N; i++ { sign := createTestSign() sign.Spawn.SetDatabaseID(int32(i + 1)) sign.SetWidgetID(int32(i + 100)) _ = manager.AddSign(sign) } } func BenchmarkManagerGetSign(b *testing.B) { database := NewMockDatabase() logger := &MockLogger{} manager := NewManager(database, logger) // Pre-populate with signs for i := 0; i < 1000; i++ { sign := createTestSign() sign.Spawn.SetDatabaseID(int32(i + 1)) sign.SetWidgetID(int32(i + 100)) _ = manager.AddSign(sign) } b.ResetTimer() for i := 0; i < b.N; i++ { _ = manager.GetSign(int32((i % 1000) + 1)) } }