1package content
2
3import (
4 "context"
5 "sync"
6 "testing"
7
8 "github.com/stretchr/testify/assert"
9
10 "github.com/bnema/dumber/internal/domain/entity"
11 "github.com/bnema/dumber/internal/ui/component"
12)
13
14func TestOnTitleChanged_DoesNotEmitWindowCallbackForBackgroundPane(t *testing.T) {
15 t.Parallel()
16
17 pane1 := entity.PaneID("pane-1")
18 pane2 := entity.PaneID("pane-2")
19 root := &entity.PaneNode{ID: "root"}
20 leaf1 := &entity.PaneNode{ID: string(pane1), Pane: entity.NewPane(pane1), Parent: root}
21 leaf2 := &entity.PaneNode{ID: string(pane2), Pane: entity.NewPane(pane2), Parent: root}
22 root.Children = []*entity.PaneNode{leaf1, leaf2}
23
24 ws := &entity.Workspace{Root: root, ActivePaneID: pane1}
25 var gotPaneID entity.PaneID
26 var gotTitle string
27 c := &Coordinator{
28 paneTitles: make(map[entity.PaneID]string),
29 getActiveWS: func() (*entity.Workspace, *component.WorkspaceView) { return ws, nil },
30 onWindowTitleChanged: func(paneID entity.PaneID, title string) {
31 gotPaneID = paneID
32 gotTitle = title
33 },
34 }
35
36 c.onTitleChanged(context.Background(), pane2, "Pane Two")
37
38 assert.Empty(t, gotPaneID)
39 assert.Empty(t, gotTitle)
40 assert.Equal(t, "Pane Two", c.GetTitle(pane2))
41}
42
43func TestOnTitleChanged_EmitsWindowCallbackForActivePane(t *testing.T) {
44 t.Parallel()
45
46 pane := entity.PaneID("pane-1")
47 root := &entity.PaneNode{ID: "root"}
48 leaf := &entity.PaneNode{ID: string(pane), Pane: entity.NewPane(pane), Parent: root}
49 root.Children = []*entity.PaneNode{leaf}
50
51 ws := &entity.Workspace{Root: root, ActivePaneID: pane}
52 var gotPaneID entity.PaneID
53 var gotTitle string
54 c := &Coordinator{
55 paneTitles: make(map[entity.PaneID]string),
56 getActiveWS: func() (*entity.Workspace, *component.WorkspaceView) { return ws, nil },
57 onWindowTitleChanged: func(paneID entity.PaneID, title string) {
58 gotPaneID = paneID
59 gotTitle = title
60 },
61 }
62
63 c.onTitleChanged(context.Background(), pane, "Pane One")
64
65 assert.Equal(t, pane, gotPaneID)
66 assert.Equal(t, "Pane One", gotTitle)
67 assert.Equal(t, "Pane One", c.GetTitle(pane))
68}
69
70// TestGetTitle tests the GetTitle method for title state retrieval.
71
72func TestGetTitle_ReturnsEmptyForUnknownPane(t *testing.T) {
73 t.Parallel()
74
75 c := &Coordinator{
76 paneTitles: make(map[entity.PaneID]string),
77 }
78
79 title := c.GetTitle("unknown-pane")
80 assert.Empty(t, title)
81}
82
83func TestGetTitle_ReturnsTrackedTitle(t *testing.T) {
84 t.Parallel()
85
86 paneID := entity.PaneID("pane-1")
87 c := &Coordinator{
88 paneTitles: map[entity.PaneID]string{
89 paneID: "My Page Title",
90 },
91 }
92
93 title := c.GetTitle(paneID)
94 assert.Equal(t, "My Page Title", title)
95}
96
97func TestGetTitle_DirectMapUpdate(t *testing.T) {
98 t.Parallel()
99
100 paneID := entity.PaneID("pane-1")
101 c := &Coordinator{
102 paneTitles: make(map[entity.PaneID]string),
103 }
104
105 // Simulate the write that onTitleChanged performs
106 c.titleMu.Lock()
107 c.paneTitles[paneID] = "Updated Title"
108 c.titleMu.Unlock()
109
110 title := c.GetTitle(paneID)
111 assert.Equal(t, "Updated Title", title)
112}
113
114func TestGetTitle_EmptyStringAfterZeroValue(t *testing.T) {
115 t.Parallel()
116
117 paneID := entity.PaneID("pane-1")
118 c := &Coordinator{
119 paneTitles: map[entity.PaneID]string{
120 paneID: "",
121 },
122 }
123
124 title := c.GetTitle(paneID)
125 assert.Empty(t, title)
126}
127
128// TestPaneTitles_ConcurrentAccess verifies that concurrent reads and writes to
129// paneTitles do not race.
130func TestPaneTitles_ConcurrentAccess(t *testing.T) {
131 t.Parallel()
132
133 c := &Coordinator{
134 paneTitles: make(map[entity.PaneID]string),
135 }
136
137 paneIDs := []entity.PaneID{"pane-1", "pane-2", "pane-3"}
138
139 var wg sync.WaitGroup
140 for i := 0; i < 50; i++ {
141 for _, paneID := range paneIDs {
142 wg.Add(2)
143
144 go func(id entity.PaneID) {
145 defer wg.Done()
146 c.titleMu.Lock()
147 c.paneTitles[id] = "title for " + string(id)
148 c.titleMu.Unlock()
149 }(paneID)
150
151 go func(id entity.PaneID) {
152 defer wg.Done()
153 _ = c.GetTitle(id)
154 }(paneID)
155 }
156 }
157
158 wg.Wait()
159 // Test passes if no race conditions occur
160}
161
162func TestPaneTitles_MultiplepanesTrackedIndependently(t *testing.T) {
163 t.Parallel()
164
165 pane1 := entity.PaneID("pane-1")
166 pane2 := entity.PaneID("pane-2")
167
168 c := &Coordinator{
169 paneTitles: map[entity.PaneID]string{
170 pane1: "Title One",
171 pane2: "Title Two",
172 },
173 }
174
175 assert.Equal(t, "Title One", c.GetTitle(pane1))
176 assert.Equal(t, "Title Two", c.GetTitle(pane2))
177}
178
179// TestSetNavigationOrigin tests navigation origin storage and retrieval.
180
181func TestSetNavigationOrigin_StoresOriginURL(t *testing.T) {
182 t.Parallel()
183
184 paneID := entity.PaneID("pane-1")
185 c := &Coordinator{
186 navOrigins: make(map[entity.PaneID]string),
187 }
188
189 c.SetNavigationOrigin(paneID, "https://google.fr/search?q=test")
190
191 c.navOriginMu.RLock()
192 origin := c.navOrigins[paneID]
193 c.navOriginMu.RUnlock()
194
195 assert.Equal(t, "https://google.fr/search?q=test", origin)
196}
197
198func TestSetNavigationOrigin_OverwritesPreviousOrigin(t *testing.T) {
199 t.Parallel()
200
201 paneID := entity.PaneID("pane-1")
202 c := &Coordinator{
203 navOrigins: make(map[entity.PaneID]string),
204 }
205
206 c.SetNavigationOrigin(paneID, "https://google.fr/")
207 c.SetNavigationOrigin(paneID, "https://example.com/new")
208
209 c.navOriginMu.RLock()
210 origin := c.navOrigins[paneID]
211 c.navOriginMu.RUnlock()
212
213 assert.Equal(t, "https://example.com/new", origin)
214}
215
216func TestSetNavigationOrigin_IndependentPerPane(t *testing.T) {
217 t.Parallel()
218
219 pane1 := entity.PaneID("pane-1")
220 pane2 := entity.PaneID("pane-2")
221 c := &Coordinator{
222 navOrigins: make(map[entity.PaneID]string),
223 }
224
225 c.SetNavigationOrigin(pane1, "https://pane1.example.com/")
226 c.SetNavigationOrigin(pane2, "https://pane2.example.com/")
227
228 c.navOriginMu.RLock()
229 origin1 := c.navOrigins[pane1]
230 origin2 := c.navOrigins[pane2]
231 c.navOriginMu.RUnlock()
232
233 assert.Equal(t, "https://pane1.example.com/", origin1)
234 assert.Equal(t, "https://pane2.example.com/", origin2)
235}
236
237func TestSetNavigationOrigin_EmptyOrigin(t *testing.T) {
238 t.Parallel()
239
240 paneID := entity.PaneID("pane-1")
241 c := &Coordinator{
242 navOrigins: map[entity.PaneID]string{
243 paneID: "https://old-origin.example.com/",
244 },
245 }
246
247 c.SetNavigationOrigin(paneID, "")
248
249 c.navOriginMu.RLock()
250 origin := c.navOrigins[paneID]
251 c.navOriginMu.RUnlock()
252
253 assert.Empty(t, origin)
254}
255
256// TestNavOrigins_ConcurrentAccess verifies that concurrent reads and writes to
257// navOrigins do not race.
258func TestNavOrigins_ConcurrentAccess(t *testing.T) {
259 t.Parallel()
260
261 c := &Coordinator{
262 navOrigins: make(map[entity.PaneID]string),
263 }
264
265 paneIDs := []entity.PaneID{"pane-1", "pane-2", "pane-3"}
266
267 var wg sync.WaitGroup
268 for i := 0; i < 50; i++ {
269 for _, paneID := range paneIDs {
270 wg.Add(2)
271
272 go func(id entity.PaneID) {
273 defer wg.Done()
274 c.SetNavigationOrigin(id, "https://origin-for-"+string(id)+".example.com/")
275 }(paneID)
276
277 go func(id entity.PaneID) {
278 defer wg.Done()
279 c.navOriginMu.RLock()
280 _ = c.navOrigins[id]
281 c.navOriginMu.RUnlock()
282 }(paneID)
283 }
284 }
285
286 wg.Wait()
287 // Test passes if no race conditions occur
288}
289
290// TestQueueThemeApply tests the appearance queue management for theme applies.
291
292func TestQueueThemeApply_TakeReturnsTrue(t *testing.T) {
293 t.Parallel()
294
295 paneID := entity.PaneID("pane-1")
296 c := &Coordinator{}
297
298 c.queueThemeApply(paneID, true, "body { color: white; }")
299
300 update, ok := c.takePendingThemeApply(paneID)
301 assert.True(t, ok)
302 assert.True(t, update.prefersDark)
303 assert.Equal(t, "body { color: white; }", update.cssText)
304}
305
306func TestQueueThemeApply_SecondTakeReturnsFalse(t *testing.T) {
307 t.Parallel()
308
309 paneID := entity.PaneID("pane-1")
310 c := &Coordinator{}
311
312 c.queueThemeApply(paneID, false, "body { color: black; }")
313
314 _, firstOk := c.takePendingThemeApply(paneID)
315 assert.True(t, firstOk)
316
317 _, secondOk := c.takePendingThemeApply(paneID)
318 assert.False(t, secondOk)
319}
320
321func TestQueueThemeApply_TakeWithoutQueue_ReturnsFalse(t *testing.T) {
322 t.Parallel()
323
324 paneID := entity.PaneID("pane-1")
325 c := &Coordinator{}
326
327 _, ok := c.takePendingThemeApply(paneID)
328 assert.False(t, ok)
329}
330
331func TestQueueThemeApply_MultiplePanesQueued_Independently(t *testing.T) {
332 t.Parallel()
333
334 pane1 := entity.PaneID("pane-1")
335 pane2 := entity.PaneID("pane-2")
336 c := &Coordinator{}
337
338 // pendingThemeUpdate is a single shared payload; queueThemeApply always
339 // overwrites it with the latest call. Both panes are marked as pending,
340 // but they share the last-written update.
341 c.queueThemeApply(pane1, true, "shared-css")
342 c.queueThemeApply(pane2, true, "shared-css")
343
344 // Both panes should be independently takeable
345 update1, ok1 := c.takePendingThemeApply(pane1)
346 assert.True(t, ok1)
347 assert.Equal(t, "shared-css", update1.cssText)
348
349 // pane2 should still be queued after taking pane1
350 update2, ok2 := c.takePendingThemeApply(pane2)
351 assert.True(t, ok2)
352 assert.Equal(t, "shared-css", update2.cssText)
353
354 // Neither should be available after both are taken
355 _, ok3 := c.takePendingThemeApply(pane1)
356 assert.False(t, ok3)
357 _, ok4 := c.takePendingThemeApply(pane2)
358 assert.False(t, ok4)
359}
360
361func TestQueueThemeApply_LastWriteWins(t *testing.T) {
362 t.Parallel()
363
364 paneID := entity.PaneID("pane-1")
365 c := &Coordinator{}
366
367 // Queue twice; the second call overwrites pendingThemeUpdate
368 c.queueThemeApply(paneID, true, "first-css")
369 c.queueThemeApply(paneID, false, "second-css")
370
371 update, ok := c.takePendingThemeApply(paneID)
372 assert.True(t, ok)
373 assert.False(t, update.prefersDark)
374 assert.Equal(t, "second-css", update.cssText)
375}
376
377func TestQueueThemeApply_HasPendingFlagClearedWhenAllTaken(t *testing.T) {
378 t.Parallel()
379
380 pane1 := entity.PaneID("pane-1")
381 pane2 := entity.PaneID("pane-2")
382 c := &Coordinator{}
383
384 c.queueThemeApply(pane1, true, "css")
385 c.queueThemeApply(pane2, true, "css")
386
387 _, _ = c.takePendingThemeApply(pane1)
388
389 // hasPendingThemeUpdate should still be true since pane2 is pending
390 c.appearanceMu.Lock()
391 assert.True(t, c.hasPendingThemeUpdate)
392 c.appearanceMu.Unlock()
393
394 _, _ = c.takePendingThemeApply(pane2)
395
396 // Now hasPendingThemeUpdate should be false
397 c.appearanceMu.Lock()
398 assert.False(t, c.hasPendingThemeUpdate)
399 c.appearanceMu.Unlock()
400}
401
402// TestQueueScriptRefresh tests the appearance queue management for script refreshes.
403
404func TestQueueScriptRefresh_TakeReturnsTrue(t *testing.T) {
405 t.Parallel()
406
407 paneID := entity.PaneID("pane-1")
408 c := &Coordinator{}
409
410 c.queueScriptRefresh(paneID)
411
412 ok := c.takePendingScriptRefresh(paneID)
413 assert.True(t, ok)
414}
415
416func TestQueueScriptRefresh_SecondTakeReturnsFalse(t *testing.T) {
417 t.Parallel()
418
419 paneID := entity.PaneID("pane-1")
420 c := &Coordinator{}
421
422 c.queueScriptRefresh(paneID)
423
424 firstOk := c.takePendingScriptRefresh(paneID)
425 assert.True(t, firstOk)
426
427 secondOk := c.takePendingScriptRefresh(paneID)
428 assert.False(t, secondOk)
429}
430
431func TestQueueScriptRefresh_TakeWithoutQueue_ReturnsFalse(t *testing.T) {
432 t.Parallel()
433
434 paneID := entity.PaneID("pane-1")
435 c := &Coordinator{}
436
437 ok := c.takePendingScriptRefresh(paneID)
438 assert.False(t, ok)
439}
440
441func TestQueueScriptRefresh_MultiplePanesQueuedIndependently(t *testing.T) {
442 t.Parallel()
443
444 pane1 := entity.PaneID("pane-1")
445 pane2 := entity.PaneID("pane-2")
446 pane3 := entity.PaneID("pane-3")
447 c := &Coordinator{}
448
449 c.queueScriptRefresh(pane1)
450 c.queueScriptRefresh(pane2)
451
452 // pane3 was never queued
453 assert.True(t, c.takePendingScriptRefresh(pane1))
454 assert.True(t, c.takePendingScriptRefresh(pane2))
455 assert.False(t, c.takePendingScriptRefresh(pane3))
456}
457
458// TestApplyPendingThemeUpdate tests the state management aspect of applyPendingThemeUpdate.
459
460func TestApplyPendingThemeUpdate_ReturnsFalseWhenNothingQueued(t *testing.T) {
461 t.Parallel()
462
463 paneID := entity.PaneID("pane-1")
464 c := &Coordinator{}
465
466 // applyPendingThemeUpdate calls takePendingThemeApply; with nothing queued it
467 // returns false immediately without touching the WebView.
468 result := c.applyPendingThemeUpdate(context.TODO(), paneID, nil)
469 assert.False(t, result)
470}
471
472// TestClearPendingAppearance tests that clearPendingAppearance removes both
473// theme and script queues for the targeted pane only.
474func TestClearPendingAppearance_ClearsBothQueuesForPane(t *testing.T) {
475 t.Parallel()
476
477 pane1 := entity.PaneID("pane-1")
478 pane2 := entity.PaneID("pane-2")
479 c := &Coordinator{}
480
481 c.queueThemeApply(pane1, true, "css")
482 c.queueScriptRefresh(pane1)
483 c.queueThemeApply(pane2, true, "css")
484 c.queueScriptRefresh(pane2)
485
486 c.clearPendingAppearance(pane1)
487
488 // pane1 queues should be gone
489 assert.False(t, c.takePendingScriptRefresh(pane1))
490 _, themeOk1 := c.takePendingThemeApply(pane1)
491 assert.False(t, themeOk1)
492
493 // pane2 queues should still be present
494 assert.True(t, c.takePendingScriptRefresh(pane2))
495 _, themeOk2 := c.takePendingThemeApply(pane2)
496 assert.True(t, themeOk2)
497}