package wayland import ( "errors" "sync" "testing" "time" "github.com/stretchr/testify/assert" mocks_wlclient "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/wlclient" "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_gamma_control" ) func TestManager_ActorSerializesOutputStateAccess(t *testing.T) { m := &Manager{ cmdq: make(chan cmd, 8192), stopChan: make(chan struct{}), } m.wg.Add(1) go m.waylandActor() state := &outputState{ id: 1, registryName: 100, rampSize: 256, } m.outputs.Store(state.id, state) var wg sync.WaitGroup const goroutines = 50 const iterations = 100 for i := 0; i < goroutines; i++ { wg.Add(1) go func(id int) { defer wg.Done() for j := 0; j < iterations; j++ { m.post(func() { if out, ok := m.outputs.Load(state.id); ok { out.rampSize = uint32(j) out.failed = j%2 == 0 out.retryCount = j out.lastFailTime = time.Now() } }) } }(i) } wg.Wait() done := make(chan struct{}) m.post(func() { close(done) }) <-done close(m.stopChan) m.wg.Wait() } func TestManager_ConcurrentSubscriberAccess(t *testing.T) { m := &Manager{ stopChan: make(chan struct{}), dirty: make(chan struct{}, 1), updateTrigger: make(chan struct{}, 1), } var wg sync.WaitGroup const goroutines = 20 for i := 0; i < goroutines; i++ { wg.Add(1) go func(id int) { defer wg.Done() subID := string(rune('a' + id)) ch := m.Subscribe(subID) assert.NotNil(t, ch) time.Sleep(time.Millisecond) m.Unsubscribe(subID) }(i) } wg.Wait() } func TestManager_ConcurrentGetState(t *testing.T) { m := &Manager{ state: &State{ CurrentTemp: 5000, IsDay: true, }, } var wg sync.WaitGroup const goroutines = 50 const iterations = 100 for i := 0; i < goroutines/2; i++ { wg.Add(1) go func() { defer wg.Done() for j := 0; j < iterations; j++ { s := m.GetState() assert.GreaterOrEqual(t, s.CurrentTemp, 0) } }() } for i := 0; i < goroutines/2; i++ { wg.Add(1) go func(i int) { defer wg.Done() for j := 0; j < iterations; j++ { m.stateMutex.Lock() m.state = &State{ CurrentTemp: 4000 + i*100, IsDay: j%2 == 0, } m.stateMutex.Unlock() } }(i) } wg.Wait() } func TestManager_ConcurrentConfigAccess(t *testing.T) { m := &Manager{ config: DefaultConfig(), } var wg sync.WaitGroup const goroutines = 30 const iterations = 100 for i := 0; i < goroutines/2; i++ { wg.Add(1) go func() { defer wg.Done() for j := 0; j < iterations; j++ { m.configMutex.RLock() _ = m.config.LowTemp _ = m.config.HighTemp _ = m.config.Enabled m.configMutex.RUnlock() } }() } for i := 0; i < goroutines/2; i++ { wg.Add(1) go func(i int) { defer wg.Done() for j := 0; j < iterations; j++ { m.configMutex.Lock() m.config.LowTemp = 3000 + j m.config.HighTemp = 7000 - j m.config.Enabled = j%2 == 0 m.configMutex.Unlock() } }(i) } wg.Wait() } func TestManager_SyncmapOutputsConcurrentAccess(t *testing.T) { m := &Manager{} var wg sync.WaitGroup const goroutines = 30 const iterations = 50 for i := 0; i < goroutines; i++ { wg.Add(1) go func(id int) { defer wg.Done() key := uint32(id) for j := 0; j < iterations; j++ { state := &outputState{ id: key, rampSize: uint32(j), failed: j%2 == 0, } m.outputs.Store(key, state) if loaded, ok := m.outputs.Load(key); ok { assert.Equal(t, key, loaded.id) } m.outputs.Range(func(k uint32, v *outputState) bool { _ = v.rampSize _ = v.failed return true }) } m.outputs.Delete(key) }(i) } wg.Wait() } func TestManager_LocationCacheConcurrentAccess(t *testing.T) { m := &Manager{} var wg sync.WaitGroup const goroutines = 20 const iterations = 100 for i := 0; i < goroutines/2; i++ { wg.Add(1) go func() { defer wg.Done() for j := 0; j < iterations; j++ { m.locationMutex.RLock() _ = m.cachedIPLat _ = m.cachedIPLon m.locationMutex.RUnlock() } }() } for i := 0; i < goroutines/2; i++ { wg.Add(1) go func(i int) { defer wg.Done() for j := 0; j < iterations; j++ { lat := float64(40 + i) lon := float64(-74 + j) m.locationMutex.Lock() m.cachedIPLat = &lat m.cachedIPLon = &lon m.locationMutex.Unlock() } }(i) } wg.Wait() } func TestManager_ScheduleConcurrentAccess(t *testing.T) { now := time.Now() m := &Manager{ schedule: sunSchedule{ times: SunTimes{ Dawn: now, Sunrise: now.Add(time.Hour), Sunset: now.Add(12 * time.Hour), Night: now.Add(13 * time.Hour), }, }, } var wg sync.WaitGroup const goroutines = 20 const iterations = 100 for i := 0; i < goroutines/2; i++ { wg.Add(1) go func() { defer wg.Done() for j := 0; j < iterations; j++ { m.scheduleMutex.RLock() _ = m.schedule.times.Dawn _ = m.schedule.times.Sunrise _ = m.schedule.times.Sunset _ = m.schedule.condition m.scheduleMutex.RUnlock() } }() } for i := 0; i < goroutines/2; i++ { wg.Add(1) go func() { defer wg.Done() for j := 0; j < iterations; j++ { m.scheduleMutex.Lock() m.schedule.times.Dawn = time.Now() m.schedule.times.Sunrise = time.Now().Add(time.Hour) m.schedule.condition = SunNormal m.scheduleMutex.Unlock() } }() } wg.Wait() } func TestInterpolate_EdgeCases(t *testing.T) { now := time.Now() tests := []struct { name string now time.Time start time.Time stop time.Time expected float64 }{ { name: "same start and stop", now: now, start: now, stop: now, expected: 1.0, }, { name: "now before start", now: now, start: now.Add(time.Hour), stop: now.Add(2 * time.Hour), expected: 0.0, }, { name: "now after stop", now: now.Add(3 * time.Hour), start: now, stop: now.Add(time.Hour), expected: 1.0, }, { name: "now at midpoint", now: now.Add(30 * time.Minute), start: now, stop: now.Add(time.Hour), expected: 0.5, }, { name: "now equals start", now: now, start: now, stop: now.Add(time.Hour), expected: 0.0, }, { name: "now equals stop", now: now.Add(time.Hour), start: now, stop: now.Add(time.Hour), expected: 1.0, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := interpolate(tt.now, tt.start, tt.stop) assert.InDelta(t, tt.expected, result, 0.01) }) } } func TestGenerateGammaRamp_ZeroSize(t *testing.T) { ramp := GenerateGammaRamp(0, 5000, 1.0) assert.Empty(t, ramp.Red) assert.Empty(t, ramp.Green) assert.Empty(t, ramp.Blue) } func TestGenerateGammaRamp_ValidSizes(t *testing.T) { sizes := []uint32{1, 256, 1024} temps := []int{1000, 4000, 6500, 10000} gammas := []float64{0.5, 1.0, 2.0} for _, size := range sizes { for _, temp := range temps { for _, gamma := range gammas { ramp := GenerateGammaRamp(size, temp, gamma) assert.Len(t, ramp.Red, int(size)) assert.Len(t, ramp.Green, int(size)) assert.Len(t, ramp.Blue, int(size)) } } } } func TestNotifySubscribers_NonBlocking(t *testing.T) { m := &Manager{ dirty: make(chan struct{}, 1), } for i := 0; i < 10; i++ { m.notifySubscribers() } assert.Len(t, m.dirty, 1) } func TestNewManager_GetRegistryError(t *testing.T) { mockDisplay := mocks_wlclient.NewMockWaylandDisplay(t) mockDisplay.EXPECT().Context().Return(nil) mockDisplay.EXPECT().GetRegistry().Return(nil, errors.New("failed to get registry")) config := DefaultConfig() _, err := NewManager(mockDisplay, config) assert.Error(t, err) assert.Contains(t, err.Error(), "get registry") } func TestNewManager_InvalidConfig(t *testing.T) { mockDisplay := mocks_wlclient.NewMockWaylandDisplay(t) config := Config{ LowTemp: 500, HighTemp: 6500, Gamma: 1.0, } _, err := NewManager(mockDisplay, config) assert.Error(t, err) } func TestSetters_RejectedValuesLeaveConfigUntouched(t *testing.T) { newManager := func() *Manager { return &Manager{ config: DefaultConfig(), updateTrigger: make(chan struct{}, 1), } } t.Run("SetTemperature", func(t *testing.T) { m := newManager() before := m.config err := m.SetTemperature(3200, 2500) assert.Error(t, err) assert.Equal(t, before, m.config) assert.Empty(t, m.updateTrigger) }) t.Run("SetLocation", func(t *testing.T) { m := newManager() before := m.config err := m.SetLocation(120.0, 10.0) assert.Error(t, err) assert.Equal(t, before, m.config) assert.Empty(t, m.updateTrigger) }) t.Run("SetGamma", func(t *testing.T) { m := newManager() before := m.config err := m.SetGamma(-1.0) assert.Error(t, err) assert.Equal(t, before, m.config) assert.Empty(t, m.updateTrigger) }) } func TestSetters_ValidValuesCommitAndTrigger(t *testing.T) { m := &Manager{ config: DefaultConfig(), updateTrigger: make(chan struct{}, 1), } err := m.SetTemperature(3000, 6000) assert.NoError(t, err) assert.Equal(t, 3000, m.config.LowTemp) assert.Equal(t, 6000, m.config.HighTemp) assert.Len(t, m.updateTrigger, 1) } func TestApplyGamma_SkipsUnchangedTempAndGamma(t *testing.T) { m := &Manager{config: DefaultConfig()} m.controlsInitialized = true out := &outputState{ id: 1, rampSize: 256, gammaControl: &wlr_gamma_control.ZwlrGammaControlV1{}, lastTemp: 5000, lastGamma: m.config.Gamma, } m.outputs.Store(out.id, out) m.applyGamma(5000) assert.False(t, out.failed, "unchanged temp must not reach the compositor write path") assert.Equal(t, 5000, out.lastTemp) assert.Equal(t, uint32(256), out.rampSize) }