eq2go/internal/sign/sign_test.go
2025-08-06 12:31:24 -05:00

904 lines
21 KiB
Go

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