package cef import ( "context" "testing" "time" purecef "github.com/bnema/purego-cef/cef" "github.com/stretchr/testify/require" ) type viewportSyncOrderHost struct { purecef.BrowserHost calls []string zoomLevel float64 getZoomLevelCalls int } type pendingBrowserCreateObservedSizeBridgeStub struct { sizeWidth int32 sizeHeight int32 refreshWidth int32 refreshHeight int32 refreshCalls int refreshProvided bool } func (s *pendingBrowserCreateObservedSizeBridgeStub) Size() (int32, int32) { return s.sizeWidth, s.sizeHeight } func (s *pendingBrowserCreateObservedSizeBridgeStub) RefreshObservedSizeOnGTKThread() (int32, int32) { s.refreshCalls++ if s.refreshProvided { return s.refreshWidth, s.refreshHeight } return s.sizeWidth, s.sizeHeight } func (h *viewportSyncOrderHost) WasHidden(state int32) { h.calls = append(h.calls, "WasHidden") if state != 0 { h.calls = append(h.calls, "WasHidden(nonzero)") } } func (h *viewportSyncOrderHost) NotifyScreenInfoChanged() { h.calls = append(h.calls, "NotifyScreenInfoChanged") } func (h *viewportSyncOrderHost) WasResized() { h.calls = append(h.calls, "WasResized") } func (h *viewportSyncOrderHost) Invalidate(_ purecef.PaintElementType) { h.calls = append(h.calls, "Invalidate") } func (h *viewportSyncOrderHost) SetZoomLevel(level float64) { h.calls = append(h.calls, "SetZoomLevel") h.zoomLevel = level } func (h *viewportSyncOrderHost) GetZoomLevel() float64 { h.getZoomLevelCalls++ return h.zoomLevel } func TestNotifyBrowserViewportSync_VisibleCallsFullSequence(t *testing.T) { host := &viewportSyncOrderHost{} notifyBrowserViewportSync(host, true) require.Equal(t, []string{"WasHidden", "NotifyScreenInfoChanged", "WasResized", "Invalidate"}, host.calls) } func TestNotifyBrowserViewportSync_HiddenSkipsWasHidden(t *testing.T) { host := &viewportSyncOrderHost{} notifyBrowserViewportSync(host, false) require.Equal(t, []string{"NotifyScreenInfoChanged", "WasResized", "Invalidate"}, host.calls) } func TestNotifyViewportSyncOnCEFUIThread_PostsViewportWorkOnly(t *testing.T) { oldNewTask := cefNewTask oldPostDelayedTask := cefPostDelayedTask defer func() { cefNewTask = oldNewTask cefPostDelayedTask = oldPostDelayedTask }() cefNewTask = func(task purecef.Task) purecef.Task { return task } var scheduled purecef.Task cefPostDelayedTask = func(threadID purecef.ThreadID, task purecef.Task, delayMs int64) int32 { require.Equal(t, purecef.ThreadIDTidUi, threadID) require.NotNil(t, task) if delayMs == 0 && scheduled == nil { scheduled = task } return 1 } host := &viewportSyncOrderHost{} wv := &WebView{ctx: context.Background(), host: host} wv.notifyViewportSyncOnCEFUIThread(host, true) require.Empty(t, host.calls) require.NotNil(t, scheduled) scheduled.Execute() require.Equal(t, []string{ "WasHidden", "NotifyScreenInfoChanged", "WasResized", "Invalidate", }, host.calls) } func TestNotifyViewportSyncOnCEFUIThread_RemainsViewportOnlyAcrossRepeatedResizes(t *testing.T) { oldNewTask := cefNewTask oldPostDelayedTask := cefPostDelayedTask defer func() { cefNewTask = oldNewTask cefPostDelayedTask = oldPostDelayedTask }() cefNewTask = func(task purecef.Task) purecef.Task { return task } var scheduled purecef.Task cefPostDelayedTask = func(_ purecef.ThreadID, task purecef.Task, delayMs int64) int32 { if delayMs == 0 && scheduled == nil { scheduled = task } return 1 } host := &viewportSyncOrderHost{} wv := &WebView{ctx: context.Background(), host: host} wv.notifyViewportSyncOnCEFUIThread(host, true) require.NotNil(t, scheduled) scheduled.Execute() host.calls = nil scheduled = nil wv.notifyViewportSyncOnCEFUIThread(host, true) require.NotNil(t, scheduled) scheduled.Execute() require.Equal(t, []string{ "NotifyScreenInfoChanged", "WasResized", "Invalidate", }, host.calls) } func TestSyncZoomForBackingScaleOnCEFUIThread_PostsZoomWork(t *testing.T) { oldNewTask := cefNewTask oldPostDelayedTask := cefPostDelayedTask defer func() { cefNewTask = oldNewTask cefPostDelayedTask = oldPostDelayedTask }() cefNewTask = func(task purecef.Task) purecef.Task { return task } var scheduled purecef.Task cefPostDelayedTask = func(threadID purecef.ThreadID, task purecef.Task, delayMs int64) int32 { require.Equal(t, purecef.ThreadIDTidUi, threadID) require.NotNil(t, task) if delayMs == 0 && scheduled == nil { scheduled = task } return 1 } host := &viewportSyncOrderHost{} wv := &WebView{ctx: context.Background(), host: host} wv.syncZoomForBackingScaleOnCEFUIThread(host, "gtk-size-observer") require.Empty(t, host.calls) require.NotNil(t, scheduled) scheduled.Execute() require.Equal(t, []string{ "SetZoomLevel", "NotifyScreenInfoChanged", }, host.calls) } func TestSyncZoomForBackingScaleOnCEFUIThread_SkipsRedundantReapplyWhenBackingScaleUnchanged(t *testing.T) { oldNewTask := cefNewTask oldPostDelayedTask := cefPostDelayedTask defer func() { cefNewTask = oldNewTask cefPostDelayedTask = oldPostDelayedTask }() cefNewTask = func(task purecef.Task) purecef.Task { return task } var scheduled purecef.Task cefPostDelayedTask = func(_ purecef.ThreadID, task purecef.Task, delayMs int64) int32 { if delayMs == 0 && scheduled == nil { scheduled = task } return 1 } host := &viewportSyncOrderHost{} wv := &WebView{ctx: context.Background(), host: host, zoomCompensation: zoomCompensationStub{compensation: 1.25}} wv.syncZoomForBackingScaleOnCEFUIThread(host, "gtk-size-observer") require.NotNil(t, scheduled) scheduled.Execute() host.calls = nil scheduled = nil wv.syncZoomForBackingScaleOnCEFUIThread(host, "gtk-size-observer") require.Nil(t, scheduled) require.Empty(t, host.calls) } func TestSyncZoomForBackingScaleOnCEFUIThread_ReappliesWhenCompensationChangesWithoutTouchingUserZoom(t *testing.T) { oldNewTask := cefNewTask oldPostDelayedTask := cefPostDelayedTask defer func() { cefNewTask = oldNewTask cefPostDelayedTask = oldPostDelayedTask }() cefNewTask = func(task purecef.Task) purecef.Task { return task } var scheduled []purecef.Task cefPostDelayedTask = func(threadID purecef.ThreadID, task purecef.Task, delayMs int64) int32 { require.Equal(t, purecef.ThreadIDTidUi, threadID) if delayMs == 0 { scheduled = append(scheduled, task) } return 1 } host := &viewportSyncOrderHost{} wv := &WebView{ctx: context.Background(), host: host, zoomCompensation: zoomCompensationStub{compensation: 1.25}} wv.zoomFactor.Store(1.3) wv.syncZoomForBackingScaleOnCEFUIThread(host, "gtk-size-observer") require.Len(t, scheduled, 1) scheduled[0].Execute() require.InDelta(t, cefZoomFromFactor(1.625), host.zoomLevel, 1e-9) // Moving to a different output scale must reapply the same user zoom under the // new compensation and must not mutate the persisted user zoom. host.calls = nil scheduled = nil wv.zoomCompensation = zoomCompensationStub{compensation: 2.0} wv.syncZoomForBackingScaleOnCEFUIThread(host, "gtk-size-observer") require.Len(t, scheduled, 1) scheduled[0].Execute() require.InDelta(t, cefZoomFromFactor(2.6), host.zoomLevel, 1e-9) require.InDelta(t, 1.3, wv.GetZoomLevel(), 1e-9, "user zoom must survive an output transition") require.Equal(t, []string{"SetZoomLevel", "NotifyScreenInfoChanged"}, host.calls) } func TestSyncZoomForBackingScaleOnCEFUIThread_SkipsStaleHostAndDestroyedView(t *testing.T) { oldNewTask := cefNewTask oldPostDelayedTask := cefPostDelayedTask defer func() { cefNewTask = oldNewTask cefPostDelayedTask = oldPostDelayedTask }() cefNewTask = func(task purecef.Task) purecef.Task { return task } var scheduled []purecef.Task cefPostDelayedTask = func(_ purecef.ThreadID, task purecef.Task, _ int64) int32 { scheduled = append(scheduled, task) return 1 } capturedHost := &viewportSyncOrderHost{} currentHost := &viewportSyncOrderHost{} wv := &WebView{ctx: context.Background(), host: currentHost, zoomCompensation: zoomCompensationStub{compensation: 1.25}} wv.syncZoomForBackingScaleOnCEFUIThread(capturedHost, "host-replaced") require.Len(t, scheduled, 1) scheduled[0].Execute() require.Empty(t, capturedHost.calls) require.Empty(t, currentHost.calls) destroyed := &WebView{ctx: context.Background(), host: currentHost, zoomCompensation: zoomCompensationStub{compensation: 1.25}} destroyed.destroyed.Store(true) scheduled = nil destroyed.syncZoomForBackingScaleOnCEFUIThread(currentHost, "destroyed") require.Len(t, scheduled, 1) scheduled[0].Execute() require.Empty(t, currentHost.calls) } func TestSyncZoomForBackingScaleOnCEFUIThread_SkipsMissingHost(t *testing.T) { wv := &WebView{ctx: context.Background(), zoomCompensation: zoomCompensationStub{compensation: 1.25}} wv.syncZoomForBackingScaleOnCEFUIThread(nil, "missing-host") require.Zero(t, wv.appliedZoomCompensation()) } func TestNotifyViewportSyncOnCEFUIThread_SkipsStaleHost(t *testing.T) { oldNewTask := cefNewTask oldPostDelayedTask := cefPostDelayedTask defer func() { cefNewTask = oldNewTask cefPostDelayedTask = oldPostDelayedTask }() cefNewTask = func(task purecef.Task) purecef.Task { return task } var scheduled purecef.Task cefPostDelayedTask = func(_ purecef.ThreadID, task purecef.Task, _ int64) int32 { scheduled = task return 1 } capturedHost := &viewportSyncOrderHost{} currentHost := &viewportSyncOrderHost{} wv := &WebView{ctx: context.Background(), host: currentHost} wv.notifyViewportSyncOnCEFUIThread(capturedHost, true) require.NotNil(t, scheduled) scheduled.Execute() require.Empty(t, capturedHost.calls) require.Empty(t, currentHost.calls) } func TestNotifyViewportSyncOnCEFUIThread_SkipsDestroyedWebView(t *testing.T) { oldNewTask := cefNewTask oldPostDelayedTask := cefPostDelayedTask defer func() { cefNewTask = oldNewTask cefPostDelayedTask = oldPostDelayedTask }() cefNewTask = func(task purecef.Task) purecef.Task { return task } var scheduled purecef.Task cefPostDelayedTask = func(_ purecef.ThreadID, task purecef.Task, _ int64) int32 { scheduled = task return 1 } host := &viewportSyncOrderHost{} wv := &WebView{ctx: context.Background(), host: host} wv.destroyed.Store(true) wv.notifyViewportSyncOnCEFUIThread(host, true) require.NotNil(t, scheduled) scheduled.Execute() require.Empty(t, host.calls) } func TestObservedViewportSizeReady(t *testing.T) { tests := []struct { name string observedWidth int32 observedHeight int32 allocatedWidth int32 allocatedHeight int32 want bool }{ { name: "exact match", observedWidth: 793, observedHeight: 1753, allocatedWidth: 793, allocatedHeight: 1753, want: true, }, { name: "one pixel drift is accepted", observedWidth: 792, observedHeight: 1754, allocatedWidth: 793, allocatedHeight: 1753, want: true, }, { name: "fallback observed size is rejected", observedWidth: 1, observedHeight: 1, allocatedWidth: 793, allocatedHeight: 1753, want: false, }, { name: "missing allocation is rejected", observedWidth: 793, observedHeight: 1753, allocatedWidth: 0, allocatedHeight: 1753, want: false, }, { name: "large mismatch is rejected", observedWidth: 1200, observedHeight: 1400, allocatedWidth: 1587, allocatedHeight: 1679, want: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { require.Equal(t, tt.want, observedViewportSizeReady(tt.observedWidth, tt.observedHeight, tt.allocatedWidth, tt.allocatedHeight)) }) } } func TestPendingBrowserCreateObservedSize_RefreshesStaleFallbackSize(t *testing.T) { bridge := &pendingBrowserCreateObservedSizeBridgeStub{ sizeWidth: 1, sizeHeight: 1, refreshWidth: 1055, refreshHeight: 1719, refreshProvided: true, } observedWidth, observedHeight, ready := pendingBrowserCreateObservedSize(bridge, 1055, 1719) require.True(t, ready) require.Equal(t, int32(1055), observedWidth) require.Equal(t, int32(1719), observedHeight) require.Equal(t, 1, bridge.refreshCalls) } func TestPendingBrowserCreateObservedSize_SkipsRefreshWhenObservedSizeAlreadyMatches(t *testing.T) { bridge := &pendingBrowserCreateObservedSizeBridgeStub{sizeWidth: 1055, sizeHeight: 1719} observedWidth, observedHeight, ready := pendingBrowserCreateObservedSize(bridge, 1055, 1719) require.True(t, ready) require.Equal(t, int32(1055), observedWidth) require.Equal(t, int32(1719), observedHeight) require.Equal(t, 0, bridge.refreshCalls) } func TestShouldDeferPendingBrowserCreateFromViewportSync_WaitsForNativePopupAttach(t *testing.T) { wv := &WebView{pendingCreate: &pendingBrowserCreate{}, nativePopupCandidate: true} require.True(t, wv.shouldDeferPendingBrowserCreateFromViewportSync()) wv.nativePopupFallbackStarted = true require.False(t, wv.shouldDeferPendingBrowserCreateFromViewportSync()) } func TestPreparePendingBrowserCreateObservedSizeRetry_CoalescesScheduling(t *testing.T) { oldScheduleAfter := cefScheduleAfter defer func() { cefScheduleAfter = oldScheduleAfter }() scheduleCalls := 0 cefScheduleAfter = func(delay time.Duration, fn func()) { require.Equal(t, pendingBrowserCreateObservedSizeRetryDelay, delay) require.NotNil(t, fn) scheduleCalls++ } wv := &WebView{pendingCreate: &pendingBrowserCreate{}} attempt, action := wv.preparePendingBrowserCreateObservedSizeRetry(context.Background(), "test") require.Equal(t, 1, attempt) require.Equal(t, pendingBrowserCreateObservedSizeRetryScheduled, action) require.Equal(t, 1, scheduleCalls) attempt, action = wv.preparePendingBrowserCreateObservedSizeRetry(context.Background(), "test") require.Equal(t, 1, attempt) require.Equal(t, pendingBrowserCreateObservedSizeRetryAlreadyScheduled, action) require.Equal(t, 1, scheduleCalls) } func TestPreparePendingBrowserCreateObservedSizeRetry_MaxRetriesProceedsWithoutScheduling(t *testing.T) { oldScheduleAfter := cefScheduleAfter defer func() { cefScheduleAfter = oldScheduleAfter }() scheduleCalls := 0 cefScheduleAfter = func(delay time.Duration, fn func()) { require.Equal(t, pendingBrowserCreateObservedSizeRetryDelay, delay) require.NotNil(t, fn) scheduleCalls++ } wv := &WebView{pendingCreate: &pendingBrowserCreate{observedSizeRetries: pendingBrowserCreateObservedSizeMaxRetries}} attempt, action := wv.preparePendingBrowserCreateObservedSizeRetry(context.Background(), "test") require.Equal(t, pendingBrowserCreateObservedSizeMaxRetries+1, attempt) require.Equal(t, pendingBrowserCreateObservedSizeRetryProceedWithoutDelay, action) require.Equal(t, 0, scheduleCalls) require.False(t, wv.pendingCreate.observedSizeRetryScheduled) } func TestScheduleResizeRepaintPulse_CoalescesToLatestSequence(t *testing.T) { oldNewTask := cefNewTask oldPostDelayedTask := cefPostDelayedTask defer func() { cefNewTask = oldNewTask cefPostDelayedTask = oldPostDelayedTask }() cefNewTask = func(task purecef.Task) purecef.Task { return task } var scheduled []purecef.Task var delays []int64 cefPostDelayedTask = func(threadID purecef.ThreadID, task purecef.Task, delayMs int64) int32 { require.Equal(t, purecef.ThreadIDTidUi, threadID) require.NotNil(t, task) scheduled = append(scheduled, task) delays = append(delays, delayMs) return 1 } host := &viewportSyncOrderHost{} wv := &WebView{ctx: context.Background(), host: host} wv.scheduleResizeRepaintPulse(context.Background(), "first") wv.scheduleResizeRepaintPulse(context.Background(), "second") const pulses = 6 require.Equal(t, []int64{16, 48, 96, 160, 250, 400, 16, 48, 96, 160, 250, 400}, delays) require.Len(t, scheduled, 2*pulses) for _, task := range scheduled[:pulses] { task.Execute() } require.Empty(t, host.calls) for _, task := range scheduled[pulses:] { task.Execute() } // Only the first two pulses resync the viewport; later ones only request a // refresh frame so the surface ID is not reallocated repeatedly. require.Equal(t, []string{ "NotifyScreenInfoChanged", "WasResized", "Invalidate", "NotifyScreenInfoChanged", "WasResized", "Invalidate", "Invalidate", "Invalidate", "Invalidate", "Invalidate", }, host.calls) } func TestScheduleResizeRepaintPulse_SkipsStaleHost(t *testing.T) { oldNewTask := cefNewTask oldPostDelayedTask := cefPostDelayedTask defer func() { cefNewTask = oldNewTask cefPostDelayedTask = oldPostDelayedTask }() cefNewTask = func(task purecef.Task) purecef.Task { return task } var scheduled []purecef.Task cefPostDelayedTask = func(threadID purecef.ThreadID, task purecef.Task, _ int64) int32 { require.Equal(t, purecef.ThreadIDTidUi, threadID) require.NotNil(t, task) scheduled = append(scheduled, task) return 1 } capturedHost := &viewportSyncOrderHost{} currentHost := &viewportSyncOrderHost{} wv := &WebView{ctx: context.Background(), host: capturedHost} wv.scheduleResizeRepaintPulse(context.Background(), "stale-host") wv.host = currentHost require.Len(t, scheduled, 6) for _, task := range scheduled { task.Execute() } require.Empty(t, capturedHost.calls) require.Empty(t, currentHost.calls) } func TestScheduleResizeRepaintPulse_SkipsWhenHostNilAtScheduleTime(t *testing.T) { oldNewTask := cefNewTask oldPostDelayedTask := cefPostDelayedTask defer func() { cefNewTask = oldNewTask cefPostDelayedTask = oldPostDelayedTask }() cefNewTask = func(task purecef.Task) purecef.Task { return task } var scheduled []purecef.Task cefPostDelayedTask = func(_ purecef.ThreadID, task purecef.Task, _ int64) int32 { scheduled = append(scheduled, task) return 1 } wv := &WebView{ctx: context.Background()} wv.scheduleResizeRepaintPulse(context.Background(), "nil-host") require.Empty(t, scheduled) } func TestScheduleResizeRepaintPulse_SkipsWhenHostClearedBeforeExecution(t *testing.T) { oldNewTask := cefNewTask oldPostDelayedTask := cefPostDelayedTask defer func() { cefNewTask = oldNewTask cefPostDelayedTask = oldPostDelayedTask }() cefNewTask = func(task purecef.Task) purecef.Task { return task } var scheduled []purecef.Task cefPostDelayedTask = func(threadID purecef.ThreadID, task purecef.Task, _ int64) int32 { require.Equal(t, purecef.ThreadIDTidUi, threadID) require.NotNil(t, task) scheduled = append(scheduled, task) return 1 } host := &viewportSyncOrderHost{} wv := &WebView{ctx: context.Background(), host: host} wv.scheduleResizeRepaintPulse(context.Background(), "nil-host-before-execute") wv.host = nil require.Len(t, scheduled, 6) for _, task := range scheduled { task.Execute() } require.Empty(t, host.calls) }