From a4ce39caa575da02d71f8f5bddf25116280dbf3f Mon Sep 17 00:00:00 2001 From: bbedward Date: Thu, 11 Dec 2025 13:47:18 -0500 Subject: [PATCH] core: add test coverage for some of the wayland stack - mostly targeting any race issue detection --- core/internal/colorpicker/state_test.go | 314 ++++++++++++ .../internal/server/clipboard/manager_test.go | 456 ++++++++++++++++++ core/internal/server/dwl/manager_test.go | 352 ++++++++++++++ core/internal/server/wayland/manager.go | 45 +- core/internal/server/wayland/manager_test.go | 386 +++++++++++++++ .../internal/server/wlcontext/context_test.go | 127 +++++ .../internal/server/wlroutput/manager_test.go | 400 +++++++++++++++ 7 files changed, 2060 insertions(+), 20 deletions(-) create mode 100644 core/internal/colorpicker/state_test.go create mode 100644 core/internal/server/clipboard/manager_test.go create mode 100644 core/internal/server/dwl/manager_test.go create mode 100644 core/internal/server/wayland/manager_test.go create mode 100644 core/internal/server/wlcontext/context_test.go create mode 100644 core/internal/server/wlroutput/manager_test.go diff --git a/core/internal/colorpicker/state_test.go b/core/internal/colorpicker/state_test.go new file mode 100644 index 00000000..6daef67f --- /dev/null +++ b/core/internal/colorpicker/state_test.go @@ -0,0 +1,314 @@ +package colorpicker + +import ( + "sync" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSurfaceState_ConcurrentPointerMotion(t *testing.T) { + s := NewSurfaceState(FormatHex, false) + + 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++ { + s.OnPointerMotion(float64(id*10+j), float64(id*10+j)) + } + }(i) + } + + wg.Wait() +} + +func TestSurfaceState_ConcurrentScaleAccess(t *testing.T) { + s := NewSurfaceState(FormatHex, false) + + var wg sync.WaitGroup + const goroutines = 30 + const iterations = 100 + + for i := 0; i < goroutines/2; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + for j := 0; j < iterations; j++ { + s.SetScale(int32(id%3 + 1)) + } + }(i) + } + + for i := 0; i < goroutines/2; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < iterations; j++ { + scale := s.Scale() + assert.GreaterOrEqual(t, scale, int32(1)) + } + }() + } + + wg.Wait() +} + +func TestSurfaceState_ConcurrentLogicalSize(t *testing.T) { + s := NewSurfaceState(FormatHex, false) + + var wg sync.WaitGroup + const goroutines = 20 + const iterations = 100 + + for i := 0; i < goroutines/2; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + for j := 0; j < iterations; j++ { + _ = s.OnLayerConfigure(1920+id, 1080+j) + } + }(i) + } + + for i := 0; i < goroutines/2; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < iterations; j++ { + w, h := s.LogicalSize() + _ = w + _ = h + } + }() + } + + wg.Wait() +} + +func TestSurfaceState_ConcurrentIsDone(t *testing.T) { + s := NewSurfaceState(FormatHex, false) + + var wg sync.WaitGroup + const goroutines = 30 + const iterations = 100 + + for i := 0; i < goroutines/3; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < iterations; j++ { + s.OnPointerButton(0x110, 1) + } + }() + } + + for i := 0; i < goroutines/3; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < iterations; j++ { + s.OnKey(1, 1) + } + }() + } + + for i := 0; i < goroutines/3; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < iterations; j++ { + picked, cancelled := s.IsDone() + _ = picked + _ = cancelled + } + }() + } + + wg.Wait() +} + +func TestSurfaceState_ConcurrentIsReady(t *testing.T) { + s := NewSurfaceState(FormatHex, false) + + var wg sync.WaitGroup + const goroutines = 20 + const iterations = 100 + + for i := 0; i < goroutines; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < iterations; j++ { + _ = s.IsReady() + } + }() + } + + wg.Wait() +} + +func TestSurfaceState_ConcurrentSwapBuffers(t *testing.T) { + s := NewSurfaceState(FormatHex, false) + + var wg sync.WaitGroup + const goroutines = 20 + const iterations = 100 + + for i := 0; i < goroutines; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < iterations; j++ { + s.SwapBuffers() + } + }() + } + + wg.Wait() +} + +func TestSurfaceState_ZeroScale(t *testing.T) { + s := NewSurfaceState(FormatHex, false) + s.SetScale(0) + assert.Equal(t, int32(1), s.Scale()) +} + +func TestSurfaceState_NegativeScale(t *testing.T) { + s := NewSurfaceState(FormatHex, false) + s.SetScale(-5) + assert.Equal(t, int32(1), s.Scale()) +} + +func TestSurfaceState_ZeroDimensionConfigure(t *testing.T) { + s := NewSurfaceState(FormatHex, false) + + err := s.OnLayerConfigure(0, 100) + assert.NoError(t, err) + + err = s.OnLayerConfigure(100, 0) + assert.NoError(t, err) + + err = s.OnLayerConfigure(-1, 100) + assert.NoError(t, err) + + w, h := s.LogicalSize() + assert.Equal(t, 0, w) + assert.Equal(t, 0, h) +} + +func TestSurfaceState_PickColorNilBuffer(t *testing.T) { + s := NewSurfaceState(FormatHex, false) + color, ok := s.PickColor() + assert.False(t, ok) + assert.Equal(t, Color{}, color) +} + +func TestSurfaceState_RedrawNilBuffer(t *testing.T) { + s := NewSurfaceState(FormatHex, false) + buf := s.Redraw() + assert.Nil(t, buf) +} + +func TestSurfaceState_RedrawScreenOnlyNilBuffer(t *testing.T) { + s := NewSurfaceState(FormatHex, false) + buf := s.RedrawScreenOnly() + assert.Nil(t, buf) +} + +func TestSurfaceState_FrontRenderBufferNil(t *testing.T) { + s := NewSurfaceState(FormatHex, false) + buf := s.FrontRenderBuffer() + assert.Nil(t, buf) +} + +func TestSurfaceState_ScreenBufferNil(t *testing.T) { + s := NewSurfaceState(FormatHex, false) + buf := s.ScreenBuffer() + assert.Nil(t, buf) +} + +func TestSurfaceState_DestroyMultipleTimes(t *testing.T) { + s := NewSurfaceState(FormatHex, false) + s.Destroy() + s.Destroy() +} + +func TestClamp(t *testing.T) { + tests := []struct { + v, lo, hi, expected int + }{ + {5, 0, 10, 5}, + {-5, 0, 10, 0}, + {15, 0, 10, 10}, + {0, 0, 10, 0}, + {10, 0, 10, 10}, + } + + for _, tt := range tests { + result := clamp(tt.v, tt.lo, tt.hi) + assert.Equal(t, tt.expected, result) + } +} + +func TestClampF(t *testing.T) { + tests := []struct { + v, lo, hi, expected float64 + }{ + {5.0, 0.0, 10.0, 5.0}, + {-5.0, 0.0, 10.0, 0.0}, + {15.0, 0.0, 10.0, 10.0}, + {0.0, 0.0, 10.0, 0.0}, + {10.0, 0.0, 10.0, 10.0}, + } + + for _, tt := range tests { + result := clampF(tt.v, tt.lo, tt.hi) + assert.InDelta(t, tt.expected, result, 0.001) + } +} + +func TestAbs(t *testing.T) { + tests := []struct { + v, expected int + }{ + {5, 5}, + {-5, 5}, + {0, 0}, + } + + for _, tt := range tests { + result := abs(tt.v) + assert.Equal(t, tt.expected, result) + } +} + +func TestBlendColors(t *testing.T) { + bg := Color{R: 0, G: 0, B: 0, A: 255} + fg := Color{R: 255, G: 255, B: 255, A: 255} + + result := blendColors(bg, fg, 0.0) + assert.Equal(t, bg.R, result.R) + assert.Equal(t, bg.G, result.G) + assert.Equal(t, bg.B, result.B) + + result = blendColors(bg, fg, 1.0) + assert.Equal(t, fg.R, result.R) + assert.Equal(t, fg.G, result.G) + assert.Equal(t, fg.B, result.B) + + result = blendColors(bg, fg, 0.5) + assert.InDelta(t, 127, int(result.R), 1) + assert.InDelta(t, 127, int(result.G), 1) + assert.InDelta(t, 127, int(result.B), 1) + + result = blendColors(bg, fg, -1.0) + assert.Equal(t, bg.R, result.R) + + result = blendColors(bg, fg, 2.0) + assert.Equal(t, fg.R, result.R) +} diff --git a/core/internal/server/clipboard/manager_test.go b/core/internal/server/clipboard/manager_test.go new file mode 100644 index 00000000..e1686588 --- /dev/null +++ b/core/internal/server/clipboard/manager_test.go @@ -0,0 +1,456 @@ +package clipboard + +import ( + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestEncodeDecodeEntry_Roundtrip(t *testing.T) { + original := Entry{ + ID: 12345, + Data: []byte("hello world"), + MimeType: "text/plain;charset=utf-8", + Preview: "hello world", + Size: 11, + Timestamp: time.Now().Truncate(time.Second), + IsImage: false, + } + + encoded, err := encodeEntry(original) + assert.NoError(t, err) + + decoded, err := decodeEntry(encoded) + assert.NoError(t, err) + + assert.Equal(t, original.ID, decoded.ID) + assert.Equal(t, original.Data, decoded.Data) + assert.Equal(t, original.MimeType, decoded.MimeType) + assert.Equal(t, original.Preview, decoded.Preview) + assert.Equal(t, original.Size, decoded.Size) + assert.Equal(t, original.Timestamp.Unix(), decoded.Timestamp.Unix()) + assert.Equal(t, original.IsImage, decoded.IsImage) +} + +func TestEncodeDecodeEntry_Image(t *testing.T) { + original := Entry{ + ID: 99999, + Data: []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, + MimeType: "image/png", + Preview: "[[ image 8 B png 100x100 ]]", + Size: 8, + Timestamp: time.Now().Truncate(time.Second), + IsImage: true, + } + + encoded, err := encodeEntry(original) + assert.NoError(t, err) + + decoded, err := decodeEntry(encoded) + assert.NoError(t, err) + + assert.Equal(t, original.ID, decoded.ID) + assert.Equal(t, original.Data, decoded.Data) + assert.True(t, decoded.IsImage) + assert.Equal(t, original.Preview, decoded.Preview) +} + +func TestEncodeDecodeEntry_EmptyData(t *testing.T) { + original := Entry{ + ID: 1, + Data: []byte{}, + MimeType: "text/plain", + Preview: "", + Size: 0, + Timestamp: time.Now().Truncate(time.Second), + IsImage: false, + } + + encoded, err := encodeEntry(original) + assert.NoError(t, err) + + decoded, err := decodeEntry(encoded) + assert.NoError(t, err) + + assert.Equal(t, original.ID, decoded.ID) + assert.Empty(t, decoded.Data) +} + +func TestEncodeDecodeEntry_LargeData(t *testing.T) { + largeData := make([]byte, 100000) + for i := range largeData { + largeData[i] = byte(i % 256) + } + + original := Entry{ + ID: 777, + Data: largeData, + MimeType: "application/octet-stream", + Preview: "binary data...", + Size: len(largeData), + Timestamp: time.Now().Truncate(time.Second), + IsImage: false, + } + + encoded, err := encodeEntry(original) + assert.NoError(t, err) + + decoded, err := decodeEntry(encoded) + assert.NoError(t, err) + + assert.Equal(t, original.Data, decoded.Data) + assert.Equal(t, original.Size, decoded.Size) +} + +func TestStateEqual_BothNil(t *testing.T) { + assert.False(t, stateEqual(nil, nil)) +} + +func TestStateEqual_OneNil(t *testing.T) { + s := &State{Enabled: true} + assert.False(t, stateEqual(s, nil)) + assert.False(t, stateEqual(nil, s)) +} + +func TestStateEqual_EnabledDiffers(t *testing.T) { + a := &State{Enabled: true, History: []Entry{}} + b := &State{Enabled: false, History: []Entry{}} + assert.False(t, stateEqual(a, b)) +} + +func TestStateEqual_HistoryLengthDiffers(t *testing.T) { + a := &State{Enabled: true, History: []Entry{{ID: 1}}} + b := &State{Enabled: true, History: []Entry{}} + assert.False(t, stateEqual(a, b)) +} + +func TestStateEqual_BothEqual(t *testing.T) { + a := &State{Enabled: true, History: []Entry{{ID: 1}, {ID: 2}}} + b := &State{Enabled: true, History: []Entry{{ID: 3}, {ID: 4}}} + assert.True(t, stateEqual(a, b)) +} + +func TestManager_ConcurrentSubscriberAccess(t *testing.T) { + m := &Manager{ + subscribers: make(map[string]chan State), + dirty: 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{ + Enabled: true, + History: []Entry{{ID: 1}, {ID: 2}}, + }, + } + + 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() + _ = s.Enabled + _ = len(s.History) + } + }() + } + + 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{ + Enabled: j%2 == 0, + History: []Entry{{ID: uint64(j)}}, + } + 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++ { + cfg := m.getConfig() + _ = cfg.MaxHistory + _ = cfg.MaxEntrySize + } + }() + } + + 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.MaxHistory = 50 + j + m.config.MaxEntrySize = int64(1024 * j) + m.configMutex.Unlock() + } + }(i) + } + + wg.Wait() +} + +func TestManager_NotifySubscribersNonBlocking(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 TestManager_ConcurrentOfferAccess(t *testing.T) { + m := &Manager{ + offerMimeTypes: make(map[any][]string), + offerRegistry: make(map[uint32]any), + } + + var wg sync.WaitGroup + const goroutines = 20 + 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++ { + m.offerMutex.Lock() + m.offerRegistry[key] = struct{}{} + m.offerMimeTypes[key] = []string{"text/plain"} + m.offerMutex.Unlock() + + m.offerMutex.RLock() + _ = m.offerRegistry[key] + _ = m.offerMimeTypes[key] + m.offerMutex.RUnlock() + + m.offerMutex.Lock() + delete(m.offerRegistry, key) + delete(m.offerMimeTypes, key) + m.offerMutex.Unlock() + } + }(i) + } + + wg.Wait() +} + +func TestManager_ConcurrentPersistAccess(t *testing.T) { + m := &Manager{ + persistData: make(map[string][]byte), + persistMimeTypes: []string{}, + } + + var wg sync.WaitGroup + const goroutines = 20 + const iterations = 50 + + for i := 0; i < goroutines/2; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < iterations; j++ { + m.persistMutex.RLock() + _ = m.persistData + _ = m.persistMimeTypes + m.persistMutex.RUnlock() + } + }() + } + + for i := 0; i < goroutines/2; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + for j := 0; j < iterations; j++ { + m.persistMutex.Lock() + m.persistMimeTypes = []string{"text/plain", "text/html"} + m.persistData = map[string][]byte{ + "text/plain": []byte("test"), + } + m.persistMutex.Unlock() + } + }(i) + } + + wg.Wait() +} + +func TestManager_ConcurrentOwnerAccess(t *testing.T) { + m := &Manager{} + + 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.ownerLock.Lock() + _ = m.isOwner + m.ownerLock.Unlock() + } + }() + } + + for i := 0; i < goroutines/2; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < iterations; j++ { + m.ownerLock.Lock() + m.isOwner = j%2 == 0 + m.ownerLock.Unlock() + } + }() + } + + wg.Wait() +} + +func TestItob(t *testing.T) { + tests := []struct { + input uint64 + expected []byte + }{ + {0, []byte{0, 0, 0, 0, 0, 0, 0, 0}}, + {1, []byte{0, 0, 0, 0, 0, 0, 0, 1}}, + {256, []byte{0, 0, 0, 0, 0, 0, 1, 0}}, + {0xFFFFFFFFFFFFFFFF, []byte{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}}, + } + + for _, tt := range tests { + result := itob(tt.input) + assert.Equal(t, tt.expected, result) + } +} + +func TestSizeStr(t *testing.T) { + tests := []struct { + input int + expected string + }{ + {0, "0 B"}, + {100, "100 B"}, + {1024, "1 KiB"}, + {2048, "2 KiB"}, + {1048576, "1 MiB"}, + {5242880, "5 MiB"}, + } + + for _, tt := range tests { + result := sizeStr(tt.input) + assert.Equal(t, tt.expected, result) + } +} + +func TestSelectMimeType(t *testing.T) { + m := &Manager{} + + tests := []struct { + mimes []string + expected string + }{ + {[]string{"text/plain;charset=utf-8", "text/html"}, "text/plain;charset=utf-8"}, + {[]string{"text/html", "text/plain"}, "text/plain"}, + {[]string{"application/json", "image/png"}, "image/png"}, + {[]string{"application/json", "application/xml"}, ""}, + {[]string{}, ""}, + } + + for _, tt := range tests { + result := m.selectMimeType(tt.mimes) + assert.Equal(t, tt.expected, result) + } +} + +func TestIsImageMimeType(t *testing.T) { + m := &Manager{} + + assert.True(t, m.isImageMimeType("image/png")) + assert.True(t, m.isImageMimeType("image/jpeg")) + assert.True(t, m.isImageMimeType("image/gif")) + assert.False(t, m.isImageMimeType("text/plain")) + assert.False(t, m.isImageMimeType("application/json")) +} + +func TestTextPreview(t *testing.T) { + m := &Manager{} + + short := m.textPreview([]byte("hello world")) + assert.Equal(t, "hello world", short) + + withWhitespace := m.textPreview([]byte(" hello world ")) + assert.Equal(t, "hello world", withWhitespace) + + longText := make([]byte, 200) + for i := range longText { + longText[i] = 'a' + } + preview := m.textPreview(longText) + assert.True(t, len(preview) > 100) + assert.Contains(t, preview, "…") +} + +func TestDefaultConfig(t *testing.T) { + cfg := DefaultConfig() + assert.Equal(t, 100, cfg.MaxHistory) + assert.Equal(t, int64(5*1024*1024), cfg.MaxEntrySize) + assert.Equal(t, 0, cfg.AutoClearDays) + assert.False(t, cfg.ClearAtStartup) + assert.False(t, cfg.Disabled) + assert.False(t, cfg.DisableHistory) + assert.False(t, cfg.DisablePersist) +} diff --git a/core/internal/server/dwl/manager_test.go b/core/internal/server/dwl/manager_test.go new file mode 100644 index 00000000..03129af8 --- /dev/null +++ b/core/internal/server/dwl/manager_test.go @@ -0,0 +1,352 @@ +package dwl + +import ( + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestStateChanged_BothNil(t *testing.T) { + assert.True(t, stateChanged(nil, nil)) +} + +func TestStateChanged_OneNil(t *testing.T) { + s := &State{TagCount: 9} + assert.True(t, stateChanged(s, nil)) + assert.True(t, stateChanged(nil, s)) +} + +func TestStateChanged_TagCountDiffers(t *testing.T) { + a := &State{TagCount: 9, Outputs: make(map[string]*OutputState), Layouts: []string{}} + b := &State{TagCount: 10, Outputs: make(map[string]*OutputState), Layouts: []string{}} + assert.True(t, stateChanged(a, b)) +} + +func TestStateChanged_LayoutLengthDiffers(t *testing.T) { + a := &State{TagCount: 9, Layouts: []string{"tile"}, Outputs: make(map[string]*OutputState)} + b := &State{TagCount: 9, Layouts: []string{"tile", "monocle"}, Outputs: make(map[string]*OutputState)} + assert.True(t, stateChanged(a, b)) +} + +func TestStateChanged_ActiveOutputDiffers(t *testing.T) { + a := &State{TagCount: 9, ActiveOutput: "eDP-1", Outputs: make(map[string]*OutputState), Layouts: []string{}} + b := &State{TagCount: 9, ActiveOutput: "HDMI-A-1", Outputs: make(map[string]*OutputState), Layouts: []string{}} + assert.True(t, stateChanged(a, b)) +} + +func TestStateChanged_OutputCountDiffers(t *testing.T) { + a := &State{ + TagCount: 9, + Outputs: map[string]*OutputState{"eDP-1": {}}, + Layouts: []string{}, + } + b := &State{ + TagCount: 9, + Outputs: map[string]*OutputState{}, + Layouts: []string{}, + } + assert.True(t, stateChanged(a, b)) +} + +func TestStateChanged_OutputFieldsDiffer(t *testing.T) { + a := &State{ + TagCount: 9, + Layouts: []string{}, + Outputs: map[string]*OutputState{ + "eDP-1": {Active: 1, Layout: 0, Title: "Firefox"}, + }, + } + b := &State{ + TagCount: 9, + Layouts: []string{}, + Outputs: map[string]*OutputState{ + "eDP-1": {Active: 0, Layout: 0, Title: "Firefox"}, + }, + } + assert.True(t, stateChanged(a, b)) + + b.Outputs["eDP-1"].Active = 1 + b.Outputs["eDP-1"].Layout = 1 + assert.True(t, stateChanged(a, b)) + + b.Outputs["eDP-1"].Layout = 0 + b.Outputs["eDP-1"].Title = "Code" + assert.True(t, stateChanged(a, b)) +} + +func TestStateChanged_TagsDiffer(t *testing.T) { + a := &State{ + TagCount: 9, + Layouts: []string{}, + Outputs: map[string]*OutputState{ + "eDP-1": {Tags: []TagState{{Tag: 1, State: 1, Clients: 2, Focused: 1}}}, + }, + } + b := &State{ + TagCount: 9, + Layouts: []string{}, + Outputs: map[string]*OutputState{ + "eDP-1": {Tags: []TagState{{Tag: 1, State: 2, Clients: 2, Focused: 1}}}, + }, + } + assert.True(t, stateChanged(a, b)) + + b.Outputs["eDP-1"].Tags[0].State = 1 + b.Outputs["eDP-1"].Tags[0].Clients = 3 + assert.True(t, stateChanged(a, b)) +} + +func TestStateChanged_Equal(t *testing.T) { + a := &State{ + TagCount: 9, + ActiveOutput: "eDP-1", + Layouts: []string{"tile", "monocle"}, + Outputs: map[string]*OutputState{ + "eDP-1": { + Name: "eDP-1", + Active: 1, + Layout: 0, + LayoutSymbol: "[]=", + Title: "Firefox", + AppID: "firefox", + KbLayout: "us", + Keymode: "", + Tags: []TagState{{Tag: 1, State: 1, Clients: 2, Focused: 1}}, + }, + }, + } + b := &State{ + TagCount: 9, + ActiveOutput: "eDP-1", + Layouts: []string{"tile", "monocle"}, + Outputs: map[string]*OutputState{ + "eDP-1": { + Name: "eDP-1", + Active: 1, + Layout: 0, + LayoutSymbol: "[]=", + Title: "Firefox", + AppID: "firefox", + KbLayout: "us", + Keymode: "", + Tags: []TagState{{Tag: 1, State: 1, Clients: 2, Focused: 1}}, + }, + }, + } + assert.False(t, stateChanged(a, b)) +} + +func TestManager_ConcurrentGetState(t *testing.T) { + m := &Manager{ + state: &State{ + TagCount: 9, + Layouts: []string{"tile"}, + Outputs: map[string]*OutputState{"eDP-1": {Name: "eDP-1"}}, + }, + } + + 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() + _ = s.TagCount + _ = s.Outputs + } + }() + } + + 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{ + TagCount: uint32(j % 10), + Layouts: []string{"tile", "monocle"}, + Outputs: map[string]*OutputState{"eDP-1": {Active: uint32(j % 2)}}, + } + m.stateMutex.Unlock() + } + }(i) + } + + wg.Wait() +} + +func TestManager_ConcurrentSubscriberAccess(t *testing.T) { + m := &Manager{ + stopChan: make(chan struct{}), + dirty: 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_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, + name: "test-output", + active: uint32(j % 2), + tags: []TagState{{Tag: uint32(j), State: 1}}, + } + 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.name + _ = v.active + return true + }) + } + + m.outputs.Delete(key) + }(i) + } + + wg.Wait() +} + +func TestManager_NotifySubscribersNonBlocking(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 TestManager_PostQueueFull(t *testing.T) { + m := &Manager{ + cmdq: make(chan cmd, 2), + stopChan: make(chan struct{}), + } + + m.post(func() {}) + m.post(func() {}) + m.post(func() {}) + m.post(func() {}) + + assert.Len(t, m.cmdq, 2) +} + +func TestManager_GetStateNilState(t *testing.T) { + m := &Manager{} + + s := m.GetState() + assert.NotNil(t, s.Outputs) + assert.NotNil(t, s.Layouts) + assert.Equal(t, uint32(0), s.TagCount) +} + +func TestTagState_Fields(t *testing.T) { + tag := TagState{ + Tag: 1, + State: 2, + Clients: 3, + Focused: 1, + } + + assert.Equal(t, uint32(1), tag.Tag) + assert.Equal(t, uint32(2), tag.State) + assert.Equal(t, uint32(3), tag.Clients) + assert.Equal(t, uint32(1), tag.Focused) +} + +func TestOutputState_Fields(t *testing.T) { + out := OutputState{ + Name: "eDP-1", + Active: 1, + Tags: []TagState{{Tag: 1}}, + Layout: 0, + LayoutSymbol: "[]=", + Title: "Firefox", + AppID: "firefox", + KbLayout: "us", + Keymode: "", + } + + assert.Equal(t, "eDP-1", out.Name) + assert.Equal(t, uint32(1), out.Active) + assert.Len(t, out.Tags, 1) + assert.Equal(t, "[]=", out.LayoutSymbol) +} + +func TestStateChanged_NewOutputAppears(t *testing.T) { + a := &State{ + TagCount: 9, + Layouts: []string{}, + Outputs: map[string]*OutputState{ + "eDP-1": {Name: "eDP-1"}, + }, + } + b := &State{ + TagCount: 9, + Layouts: []string{}, + Outputs: map[string]*OutputState{ + "eDP-1": {Name: "eDP-1"}, + "HDMI-A-1": {Name: "HDMI-A-1"}, + }, + } + assert.True(t, stateChanged(a, b)) +} + +func TestStateChanged_TagsLengthDiffers(t *testing.T) { + a := &State{ + TagCount: 9, + Layouts: []string{}, + Outputs: map[string]*OutputState{ + "eDP-1": {Tags: []TagState{{Tag: 1}}}, + }, + } + b := &State{ + TagCount: 9, + Layouts: []string{}, + Outputs: map[string]*OutputState{ + "eDP-1": {Tags: []TagState{{Tag: 1}, {Tag: 2}}}, + }, + } + assert.True(t, stateChanged(a, b)) +} diff --git a/core/internal/server/wayland/manager.go b/core/internal/server/wayland/manager.go index 43a64488..974ff8ca 100644 --- a/core/internal/server/wayland/manager.go +++ b/core/internal/server/wayland/manager.go @@ -268,31 +268,36 @@ func (m *Manager) setupOutputControls(outputs []*wlclient.Output, manager *wlr_g } func (m *Manager) setupControlHandlers(state *outputState, control *wlr_gamma_control.ZwlrGammaControlV1) { + outputID := state.id + control.SetGammaSizeHandler(func(e wlr_gamma_control.ZwlrGammaControlV1GammaSizeEvent) { - if out, ok := m.outputs.Load(state.id); ok { - out.rampSize = e.Size - out.failed = false - out.retryCount = 0 - } + size := e.Size m.post(func() { + if out, ok := m.outputs.Load(outputID); ok { + out.rampSize = size + out.failed = false + out.retryCount = 0 + } m.applyCurrentTemp() }) }) control.SetFailedHandler(func(_ wlr_gamma_control.ZwlrGammaControlV1FailedEvent) { - out, ok := m.outputs.Load(state.id) - if !ok { - return - } - out.failed = true - out.rampSize = 0 - out.retryCount++ - out.lastFailTime = time.Now() + m.post(func() { + out, ok := m.outputs.Load(outputID) + if !ok { + return + } + out.failed = true + out.rampSize = 0 + out.retryCount++ + out.lastFailTime = time.Now() - backoff := time.Duration(300<