clipboard_text_orchestrator_test.go RawHistoryFolder
1package usecase
2
3import (
4 "context"
5 "errors"
6 "sync"
7 "testing"
8 "time"
9
10 "github.com/bnema/dumber/internal/application/dto"
11 portmocks "github.com/bnema/dumber/internal/application/port/mocks"
12 "github.com/bnema/dumber/internal/domain/entity"
13)
14
15type blockingClipboard struct {
16 started chan struct{}
17 release chan struct{}
18 mu sync.Mutex
19 writes []string
20}
21
22type countingClipboard struct {
23 mu sync.Mutex
24 writes int
25}
26
27type staticAutoCopyConfig struct {
28 enabled bool
29}
30
31func (s *staticAutoCopyConfig) IsAutoCopyEnabled() bool { return s.enabled }
32
33func newBlockingClipboard() *blockingClipboard {
34 return &blockingClipboard{
35 started: make(chan struct{}, 2),
36 release: make(chan struct{}),
37 }
38}
39
40func (b *blockingClipboard) WriteText(_ context.Context, text string) error {
41 b.mu.Lock()
42 b.writes = append(b.writes, text)
43 b.mu.Unlock()
44 b.started <- struct{}{}
45 <-b.release
46 return nil
47}
48
49func (*blockingClipboard) WriteImage(context.Context, entity.ImageData) error { return nil }
50func (*blockingClipboard) ReadText(context.Context) (string, error) { return "", nil }
51func (*blockingClipboard) Clear(context.Context) error { return nil }
52func (*blockingClipboard) HasText(context.Context) (bool, error) { return false, nil }
53
54func (b *blockingClipboard) writeCount() int {
55 b.mu.Lock()
56 defer b.mu.Unlock()
57 return len(b.writes)
58}
59
60func (c *countingClipboard) WriteText(_ context.Context, _ string) error {
61 c.mu.Lock()
62 c.writes++
63 c.mu.Unlock()
64 return nil
65}
66
67func (*countingClipboard) WriteImage(context.Context, entity.ImageData) error { return nil }
68func (*countingClipboard) ReadText(context.Context) (string, error) { return "", nil }
69func (*countingClipboard) Clear(context.Context) error { return nil }
70func (*countingClipboard) HasText(context.Context) (bool, error) { return false, nil }
71
72func (c *countingClipboard) writeCount() int {
73 c.mu.Lock()
74 defer c.mu.Unlock()
75 return c.writes
76}
77
78func TestClipboardTextOrchestrator_HandleSelectionUpdate_AutoCopyDisabledDoesNothing(t *testing.T) {
79 ctx := context.Background()
80 clipboard := portmocks.NewMockClipboard(t)
81 autoCopyConfig := &staticAutoCopyConfig{enabled: false}
82 var toastCalls []int
83
84 uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) {
85 toastCalls = append(toastCalls, textLen)
86 })
87
88 if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "hello", SourceEngine: dto.ClipboardSourceCEF}); err != nil {
89 t.Fatalf("HandleSelectionUpdate() error = %v, want nil", err)
90 }
91
92 if len(toastCalls) != 0 {
93 t.Fatalf("toast calls = %v, want none", toastCalls)
94 }
95}
96
97func TestClipboardTextOrchestrator_HandleSelectionUpdate_WritesOncePerUniqueSelectionAndResetsAfterEmptySelection(t *testing.T) {
98 ctx := context.Background()
99 clipboard := portmocks.NewMockClipboard(t)
100 autoCopyConfig := &staticAutoCopyConfig{enabled: true}
101 var toastCalls []int
102
103 clipboard.EXPECT().WriteText(ctx, "éé").Return(nil).Times(2)
104
105 uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) {
106 toastCalls = append(toastCalls, textLen)
107 })
108
109 if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "é", SourceEngine: dto.ClipboardSourceCEF}); err != nil {
110 t.Fatalf("HandleSelectionUpdate(short) error = %v, want nil", err)
111 }
112 if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceCEF}); err != nil {
113 t.Fatalf("HandleSelectionUpdate(first) error = %v, want nil", err)
114 }
115 if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceCEF}); err != nil {
116 t.Fatalf("HandleSelectionUpdate(duplicate) error = %v, want nil", err)
117 }
118 if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "", SourceEngine: dto.ClipboardSourceCEF}); err != nil {
119 t.Fatalf("HandleSelectionUpdate(reset) error = %v, want nil", err)
120 }
121 if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceCEF}); err != nil {
122 t.Fatalf("HandleSelectionUpdate(after reset) error = %v, want nil", err)
123 }
124
125 if len(toastCalls) != 2 || toastCalls[0] != 2 || toastCalls[1] != 2 {
126 t.Fatalf("toast calls = %v, want [2 2]", toastCalls)
127 }
128}
129
130func TestClipboardTextOrchestrator_HandleSelectionUpdate_DoesNotDedupAcrossEngines(t *testing.T) {
131 ctx := context.Background()
132 clipboard := portmocks.NewMockClipboard(t)
133 autoCopyConfig := &staticAutoCopyConfig{enabled: true}
134
135 clipboard.EXPECT().WriteText(ctx, "shared").Return(nil).Times(2)
136
137 uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil)
138
139 if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceWebKit}); err != nil {
140 t.Fatalf("HandleSelectionUpdate(webkit) error = %v, want nil", err)
141 }
142 if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF}); err != nil {
143 t.Fatalf("HandleSelectionUpdate(cef) error = %v, want nil", err)
144 }
145}
146
147func TestClipboardTextOrchestrator_HandleSelectionUpdate_DoesNotDedupAcrossViews(t *testing.T) {
148 ctx := context.Background()
149 clipboard := portmocks.NewMockClipboard(t)
150 autoCopyConfig := &staticAutoCopyConfig{enabled: true}
151
152 clipboard.EXPECT().WriteText(ctx, "shared").Return(nil).Times(2)
153
154 uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil)
155
156 if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF, ViewID: 1}); err != nil {
157 t.Fatalf("HandleSelectionUpdate(view 1) error = %v, want nil", err)
158 }
159 if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF, ViewID: 2}); err != nil {
160 t.Fatalf("HandleSelectionUpdate(view 2) error = %v, want nil", err)
161 }
162}
163
164func TestClipboardTextOrchestrator_HandleExplicitCopy_DeduplicatesIdenticalPayloadsWithinWindow(t *testing.T) {
165 ctx := context.Background()
166 clipboard := portmocks.NewMockClipboard(t)
167 autoCopyConfig := &staticAutoCopyConfig{enabled: true}
168 var toastCalls []int
169
170 clipboard.EXPECT().WriteText(ctx, "éé").Return(nil).Times(2)
171
172 uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) {
173 toastCalls = append(toastCalls, textLen)
174 })
175
176 now := time.Unix(0, 0)
177 uc.now = func() time.Time { return now }
178
179 if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceWebKit, Action: "copy"}); err != nil {
180 t.Fatalf("HandleExplicitCopy(first) error = %v, want nil", err)
181 }
182 now = now.Add(100 * time.Millisecond)
183 if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceWebKit, Action: "copy"}); err != nil {
184 t.Fatalf("HandleExplicitCopy(duplicate) error = %v, want nil", err)
185 }
186 now = now.Add(151 * time.Millisecond)
187 if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceWebKit, Action: "copy"}); err != nil {
188 t.Fatalf("HandleExplicitCopy(after window) error = %v, want nil", err)
189 }
190
191 if len(toastCalls) != 2 || toastCalls[0] != 2 || toastCalls[1] != 2 {
192 t.Fatalf("toast calls = %v, want [2 2]", toastCalls)
193 }
194}
195
196func TestClipboardTextOrchestrator_HandleExplicitCopy_NativeHandledSkipsBackendWrite(t *testing.T) {
197 ctx := context.Background()
198 clipboard := &countingClipboard{}
199 autoCopyConfig := &staticAutoCopyConfig{enabled: true}
200 var toastCalls []int
201
202 uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) {
203 toastCalls = append(toastCalls, textLen)
204 })
205
206 if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceWebKit, Action: "copy", NativeHandled: true}); err != nil {
207 t.Fatalf("HandleExplicitCopy() error = %v, want nil", err)
208 }
209
210 if got := clipboard.writeCount(); got != 0 {
211 t.Fatalf("clipboard writes = %d, want 0", got)
212 }
213 if len(toastCalls) != 1 || toastCalls[0] != 6 {
214 t.Fatalf("toast calls = %v, want [6]", toastCalls)
215 }
216 if got := len(uc.lastExplicit); got != 1 {
217 t.Fatalf("lastExplicit entries = %d, want 1", got)
218 }
219}
220
221func TestClipboardTextOrchestrator_HandleExplicitCopy_DoesNotDedupDifferentActionsWithinWindow(t *testing.T) {
222 ctx := context.Background()
223 clipboard := portmocks.NewMockClipboard(t)
224 autoCopyConfig := &staticAutoCopyConfig{enabled: true}
225
226 clipboard.EXPECT().WriteText(ctx, "shared").Return(nil).Times(2)
227
228 uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil)
229 now := time.Unix(0, 0)
230 uc.now = func() time.Time { return now }
231
232 if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF, ViewID: 1, Action: "copy"}); err != nil {
233 t.Fatalf("HandleExplicitCopy(copy) error = %v, want nil", err)
234 }
235 now = now.Add(100 * time.Millisecond)
236 if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF, ViewID: 1, Action: "cut"}); err != nil {
237 t.Fatalf("HandleExplicitCopy(cut) error = %v, want nil", err)
238 }
239}
240
241func TestClipboardTextOrchestrator_HandleExplicitCopy_DeduplicatesRecentAutoCopySelection(t *testing.T) {
242 ctx := context.Background()
243 clipboard := portmocks.NewMockClipboard(t)
244 autoCopyConfig := &staticAutoCopyConfig{enabled: true}
245 var toastCalls []int
246
247 clipboard.EXPECT().WriteText(ctx, "shared").Return(nil).Once()
248
249 uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) {
250 toastCalls = append(toastCalls, textLen)
251 })
252
253 now := time.Unix(0, 0)
254 uc.now = func() time.Time { return now }
255
256 if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF, ViewID: 1}); err != nil {
257 t.Fatalf("HandleSelectionUpdate() error = %v, want nil", err)
258 }
259 now = now.Add(100 * time.Millisecond)
260 if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF, ViewID: 1, Action: "copy"}); err != nil {
261 t.Fatalf("HandleExplicitCopy() error = %v, want nil", err)
262 }
263
264 if len(toastCalls) != 1 || toastCalls[0] != 6 {
265 t.Fatalf("toast calls = %v, want [6]", toastCalls)
266 }
267 if got := len(uc.lastExplicit); got != 1 {
268 t.Fatalf("lastExplicit entries = %d, want 1", got)
269 }
270}
271
272func TestClipboardTextOrchestrator_HandleExplicitCopy_DoesNotDedupAcrossEngines(t *testing.T) {
273 ctx := context.Background()
274 clipboard := portmocks.NewMockClipboard(t)
275 autoCopyConfig := &staticAutoCopyConfig{enabled: true}
276
277 clipboard.EXPECT().WriteText(ctx, "shared").Return(nil).Times(2)
278
279 uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil)
280 uc.now = func() time.Time { return time.Unix(0, 0) }
281
282 if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceWebKit, Action: "copy"}); err != nil {
283 t.Fatalf("HandleExplicitCopy(webkit) error = %v, want nil", err)
284 }
285 if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF, Action: "copy"}); err != nil {
286 t.Fatalf("HandleExplicitCopy(cef) error = %v, want nil", err)
287 }
288}
289
290func TestClipboardTextOrchestrator_HandleExplicitCopy_DoesNotDedupAcrossViews(t *testing.T) {
291 ctx := context.Background()
292 clipboard := portmocks.NewMockClipboard(t)
293 autoCopyConfig := &staticAutoCopyConfig{enabled: true}
294
295 clipboard.EXPECT().WriteText(ctx, "shared").Return(nil).Times(2)
296
297 uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil)
298 uc.now = func() time.Time { return time.Unix(0, 0) }
299
300 if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF, ViewID: 1, Action: "copy"}); err != nil {
301 t.Fatalf("HandleExplicitCopy(view 1) error = %v, want nil", err)
302 }
303 if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF, ViewID: 2, Action: "copy"}); err != nil {
304 t.Fatalf("HandleExplicitCopy(view 2) error = %v, want nil", err)
305 }
306}
307
308func TestClipboardTextOrchestrator_HandleExplicitCopy_KeepsBoundedStatePerScope(t *testing.T) {
309 ctx := context.Background()
310 clipboard := portmocks.NewMockClipboard(t)
311 autoCopyConfig := &staticAutoCopyConfig{enabled: true}
312
313 clipboard.EXPECT().WriteText(ctx, "one").Return(nil).Once()
314 clipboard.EXPECT().WriteText(ctx, "two").Return(nil).Once()
315 clipboard.EXPECT().WriteText(ctx, "three").Return(nil).Once()
316
317 uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil)
318
319 for i, text := range []string{"one", "two", "three"} {
320 advancedTime := time.Unix(0, 0).Add(time.Duration(i) * time.Second)
321 uc.now = func() time.Time { return advancedTime }
322 if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: text, SourceEngine: dto.ClipboardSourceCEF, ViewID: 7, Action: "copy"}); err != nil {
323 t.Fatalf("HandleExplicitCopy(%q) error = %v, want nil", text, err)
324 }
325 }
326
327 if got := len(uc.lastExplicit); got != 1 {
328 t.Fatalf("lastExplicit entries = %d, want 1", got)
329 }
330}
331
332func TestClipboardTextOrchestrator_HandleSelectionUpdate_ReenableClearsDedupState(t *testing.T) {
333 ctx := context.Background()
334 clipboard := portmocks.NewMockClipboard(t)
335 autoCopyConfig := &staticAutoCopyConfig{enabled: true}
336
337 clipboard.EXPECT().WriteText(ctx, "toggle").Return(nil).Times(2)
338
339 uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil)
340
341 if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "toggle", SourceEngine: dto.ClipboardSourceCEF, ViewID: 1}); err != nil {
342 t.Fatalf("HandleSelectionUpdate(enabled) error = %v, want nil", err)
343 }
344 autoCopyConfig.enabled = false
345 if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "toggle", SourceEngine: dto.ClipboardSourceCEF, ViewID: 1}); err != nil {
346 t.Fatalf("HandleSelectionUpdate(disabled) error = %v, want nil", err)
347 }
348 autoCopyConfig.enabled = true
349 if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "toggle", SourceEngine: dto.ClipboardSourceCEF, ViewID: 1}); err != nil {
350 t.Fatalf("HandleSelectionUpdate(reenabled) error = %v, want nil", err)
351 }
352}
353
354func TestClipboardTextOrchestrator_HandleExplicitCopy_ClipboardFailureDoesNotToast(t *testing.T) {
355 ctx := context.Background()
356 clipboard := portmocks.NewMockClipboard(t)
357 clipboard.EXPECT().WriteText(ctx, "éé").Return(errors.New("clipboard failed")).Once()
358 autoCopyConfig := &staticAutoCopyConfig{enabled: true}
359
360 var toastCalls []int
361 uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) {
362 toastCalls = append(toastCalls, textLen)
363 })
364
365 err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceCEF, Action: "copy"})
366 if err == nil {
367 t.Fatal("HandleExplicitCopy() error = nil, want non-nil")
368 }
369 if len(toastCalls) != 0 {
370 t.Fatalf("toast calls = %v, want none", toastCalls)
371 }
372}
373
374func TestClipboardTextOrchestrator_HandleSelectionUpdate_DedupIsAtomic(t *testing.T) {
375 ctx := context.Background()
376 clipboard := newBlockingClipboard()
377 autoCopyConfig := &staticAutoCopyConfig{enabled: true}
378 var (
379 toastCalls []int
380 toastMu sync.Mutex
381 )
382 uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) {
383 toastMu.Lock()
384 defer toastMu.Unlock()
385 toastCalls = append(toastCalls, textLen)
386 })
387
388 done1 := make(chan error, 1)
389 go func() {
390 done1 <- uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceCEF})
391 }()
392 <-clipboard.started
393
394 done2 := make(chan error, 1)
395 go func() {
396 done2 <- uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceCEF})
397 }()
398
399 select {
400 case <-clipboard.started:
401 t.Fatal("second selection write started before first completed")
402 default:
403 }
404
405 close(clipboard.release)
406
407 if err := <-done1; err != nil {
408 t.Fatalf("first HandleSelectionUpdate() error = %v, want nil", err)
409 }
410 if err := <-done2; err != nil {
411 t.Fatalf("second HandleSelectionUpdate() error = %v, want nil", err)
412 }
413
414 if got := clipboard.writeCount(); got != 1 {
415 t.Fatalf("clipboard writes = %d, want 1", got)
416 }
417 toastMu.Lock()
418 defer toastMu.Unlock()
419 if len(toastCalls) != 1 || toastCalls[0] != 2 {
420 t.Fatalf("toast calls = %v, want [2]", toastCalls)
421 }
422}
423
424func TestClipboardTextOrchestrator_HandleExplicitCopy_DedupIsAtomic(t *testing.T) {
425 ctx := context.Background()
426 clipboard := newBlockingClipboard()
427 autoCopyConfig := &staticAutoCopyConfig{enabled: true}
428 var (
429 toastCalls []int
430 toastMu sync.Mutex
431 )
432 uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) {
433 toastMu.Lock()
434 defer toastMu.Unlock()
435 toastCalls = append(toastCalls, textLen)
436 })
437 uc.now = func() time.Time { return time.Unix(0, 0) }
438
439 done1 := make(chan error, 1)
440 go func() {
441 done1 <- uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceWebKit, Action: "copy"})
442 }()
443 <-clipboard.started
444
445 done2 := make(chan error, 1)
446 go func() {
447 done2 <- uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceWebKit, Action: "copy"})
448 }()
449
450 select {
451 case <-clipboard.started:
452 t.Fatal("second explicit write started before first completed")
453 default:
454 }
455
456 close(clipboard.release)
457
458 if err := <-done1; err != nil {
459 t.Fatalf("first HandleExplicitCopy() error = %v, want nil", err)
460 }
461 if err := <-done2; err != nil {
462 t.Fatalf("second HandleExplicitCopy() error = %v, want nil", err)
463 }
464
465 if got := clipboard.writeCount(); got != 1 {
466 t.Fatalf("clipboard writes = %d, want 1", got)
467 }
468 toastMu.Lock()
469 defer toastMu.Unlock()
470 if len(toastCalls) != 1 || toastCalls[0] != 2 {
471 t.Fatalf("toast calls = %v, want [2]", toastCalls)
472 }
473}