返回 DeepSeek-Reasonix
native_host_test.go
根目录 / desktop / native_host_test.go
1 package main
2
3 import (
4 "context"
5 "fmt"
6 goruntime "runtime"
7 "slices"
8 "sync"
9 "testing"
10 )
11
12 // recordingNativeHost captures host calls in order; onCall observes App state
13 // at call time so ordering claims can be asserted.
14 type recordingNativeHost struct {
15 mu sync.Mutex
16 calls []string
17 dialogPath string
18 screens []nativeScreen
19 minimised bool
20 onCall func(name string)
21 }
22
23 func (h *recordingNativeHost) record(name string) {
24 h.mu.Lock()
25 h.calls = append(h.calls, name)
26 hook := h.onCall
27 h.mu.Unlock()
28 if hook != nil {
29 hook(name)
30 }
31 }
32
33 func (h *recordingNativeHost) callNames() []string {
34 h.mu.Lock()
35 defer h.mu.Unlock()
36 return slices.Clone(h.calls)
37 }
38
39 func (h *recordingNativeHost) ShowWindow(context.Context) { h.record("ShowWindow") }
40 func (h *recordingNativeHost) ShowApplication(context.Context) { h.record("ShowApplication") }
41 func (h *recordingNativeHost) HideWindow(context.Context) { h.record("HideWindow") }
42 func (h *recordingNativeHost) HideApplication(context.Context) { h.record("HideApplication") }
43 func (h *recordingNativeHost) MaximiseWindow(context.Context) { h.record("MaximiseWindow") }
44 func (h *recordingNativeHost) UnmaximiseWindow(context.Context) { h.record("UnmaximiseWindow") }
45 func (h *recordingNativeHost) MinimiseWindow(context.Context) { h.record("MinimiseWindow") }
46 func (h *recordingNativeHost) UnminimiseWindow(context.Context) { h.record("UnminimiseWindow") }
47 func (h *recordingNativeHost) ToggleMaximiseWindow(context.Context) { h.record("ToggleMaximiseWindow") }
48 func (h *recordingNativeHost) CenterWindow(context.Context) { h.record("CenterWindow") }
49 func (h *recordingNativeHost) WindowIsMaximised(context.Context) bool {
50 h.record("WindowIsMaximised")
51 return false
52 }
53 func (h *recordingNativeHost) Quit(context.Context) { h.record("Quit") }
54 func (h *recordingNativeHost) OpenDevTools(context.Context) { h.record("OpenDevTools") }
55
56 func (h *recordingNativeHost) WindowIsMinimised(context.Context) bool {
57 h.record("WindowIsMinimised")
58 return h.minimised
59 }
60
61 func (h *recordingNativeHost) SetWindowPosition(_ context.Context, x, y int) {
62 h.record(fmt.Sprintf("SetWindowPosition(%d,%d)", x, y))
63 }
64
65 func (h *recordingNativeHost) SetWindowTitle(_ context.Context, title string) {
66 h.record("SetWindowTitle:" + title)
67 }
68
69 func (h *recordingNativeHost) Screens(context.Context) ([]nativeScreen, error) {
70 h.record("Screens")
71 return h.screens, nil
72 }
73
74 func (h *recordingNativeHost) OpenDirectoryDialog(_ context.Context, opts nativeDialogOptions) (string, error) {
75 h.record("OpenDirectoryDialog:" + opts.Title)
76 return h.dialogPath, nil
77 }
78
79 func (h *recordingNativeHost) OpenFileDialog(_ context.Context, opts nativeDialogOptions) (string, error) {
80 h.record("OpenFileDialog:" + opts.Title)
81 return h.dialogPath, nil
82 }
83
84 func (h *recordingNativeHost) SaveFileDialog(_ context.Context, opts nativeDialogOptions) (string, error) {
85 h.record("SaveFileDialog:" + opts.Title)
86 return h.dialogPath, nil
87 }
88
89 func (h *recordingNativeHost) MessageDialog(_ context.Context, opts nativeMessageOptions) (string, error) {
90 h.record("MessageDialog:" + opts.Title)
91 return opts.DefaultButton, nil
92 }
93
94 func (h *recordingNativeHost) OpenExternal(_ context.Context, url string) {
95 h.record("OpenExternal:" + url)
96 }
97
98 func newRecordingHostApp(t *testing.T) (*App, *recordingNativeHost) {
99 t.Helper()
100 app := NewApp()
101 app.ctx = context.Background()
102 host := &recordingNativeHost{}
103 app.setNativeHost(host)
104 return app, host
105 }
106
107 func assertHostCalls(t *testing.T, host *recordingNativeHost, want ...string) {
108 t.Helper()
109 if got := host.callNames(); !slices.Equal(got, want) {
110 t.Fatalf("host calls = %q, want %q", got, want)
111 }
112 }
113
114 func TestBareAppUsesNoopHost(t *testing.T) {
115 if _, ok := NewApp().nativeHost().(noopNativeHost); !ok {
116 t.Fatal("a fresh App must fall back to the no-op host until the shell attaches")
117 }
118 if _, ok := (&App{}).nativeHost().(noopNativeHost); !ok {
119 t.Fatal("an App without a host must fall back to the no-op host")
120 }
121 var nilApp *App
122 if _, ok := nilApp.nativeHost().(noopNativeHost); !ok {
123 t.Fatal("a nil App must fall back to the no-op host")
124 }
125 }
126
127 func TestQuitAppSetsForceQuitBeforeHostQuit(t *testing.T) {
128 app, host := newRecordingHostApp(t)
129 forceQuitAtQuit := false
130 host.onCall = func(name string) {
131 if name == "Quit" {
132 forceQuitAtQuit = app.forceQuit.Load()
133 }
134 }
135 app.quitApp()
136 assertHostCalls(t, host, "Quit")
137 if !forceQuitAtQuit {
138 t.Fatal("forceQuit must be set before the host quits")
139 }
140 }
141
142 func TestQuitAppWithoutContextNeverReachesHost(t *testing.T) {
143 app := NewApp()
144 host := &recordingNativeHost{}
145 app.setNativeHost(host)
146 app.quitApp()
147 assertHostCalls(t, host)
148 if app.forceQuit.Load() {
149 t.Fatal("quitApp without a context must not arm forceQuit")
150 }
151 }
152
153 func TestHideForBackgroundChoosesPlatformHide(t *testing.T) {
154 host := &recordingNativeHost{}
155 hideForBackground(context.Background(), host)
156 want := "HideWindow"
157 if backgroundCloseUsesApplicationHide(goruntime.GOOS) {
158 want = "HideApplication"
159 }
160 assertHostCalls(t, host, want)
161 }
162
163 func TestHideToBackgroundRoutesThroughCoordinatorHost(t *testing.T) {
164 app, host := newRecordingHostApp(t)
165 if !app.desktopShell.coordinator.hideToBackground(app.ctx, func() bool { return true }) {
166 t.Fatal("hideToBackground should succeed when the tray check passes")
167 }
168 want := "HideWindow"
169 if backgroundCloseUsesApplicationHide(goruntime.GOOS) {
170 want = "HideApplication"
171 }
172 assertHostCalls(t, host, want)
173 }
174
175 func seedWindowStateFile(t *testing.T, state DesktopWindowState) {
176 t.Helper()
177 isolateDesktopUserDirs(t)
178 resetLastKnownWindowStateForTest()
179 t.Cleanup(resetLastKnownWindowStateForTest)
180 if state != (DesktopWindowState{}) {
181 if err := writeWindowState(state); err != nil {
182 t.Fatalf("writeWindowState: %v", err)
183 }
184 }
185 }
186
187 func TestRestoreWindowGeometryPreservesShellPosition(t *testing.T) {
188 seedWindowStateFile(t, DesktopWindowState{Width: 1280, Height: 800, X: 40, Y: 50})
189 app, host := newRecordingHostApp(t)
190 host.screens = []nativeScreen{{Width: 1920, Height: 1080, Primary: true, Scale: 1}}
191 app.restoreWindowGeometry()
192 assertHostCalls(t, host)
193 if app.backgroundMaximised.Load() {
194 t.Fatal("a non-maximised state must not arm the maximise-before-show plan")
195 }
196 }
197
198 func TestRestoreWindowGeometryPreservesShellOffscreenCorrection(t *testing.T) {
199 seedWindowStateFile(t, DesktopWindowState{Width: 1280, Height: 800, X: 50_000, Y: 50})
200 app, host := newRecordingHostApp(t)
201 host.screens = []nativeScreen{{Width: 1920, Height: 1080, Primary: true, Scale: 1}}
202 app.restoreWindowGeometry()
203 assertHostCalls(t, host)
204 }
205
206 func TestRestoreWindowGeometryPreservesShellDefaultPosition(t *testing.T) {
207 seedWindowStateFile(t, DesktopWindowState{})
208 app, host := newRecordingHostApp(t)
209 app.restoreWindowGeometry()
210 assertHostCalls(t, host)
211 }
212
213 func TestRestoreWindowGeometryMaximisedFollowsPlatformOrdering(t *testing.T) {
214 seedWindowStateFile(t, DesktopWindowState{Width: 1280, Height: 800, X: 40, Y: 50, Maximised: true})
215 app, host := newRecordingHostApp(t)
216 host.screens = []nativeScreen{{Width: 1920, Height: 1080}}
217 app.restoreWindowGeometry()
218 // The shell owns the restore rectangle; Go only applies presentation state.
219 if goruntime.GOOS == "windows" {
220 assertHostCalls(t, host)
221 if !app.backgroundMaximised.Load() {
222 t.Fatal("Windows must defer maximise to the presentation plan")
223 }
224 return
225 }
226 assertHostCalls(t, host, "MaximiseWindow")
227 }
228
229 func TestDOMReadyPreservesShellGeometryWhenPresenting(t *testing.T) {
230 seedWindowStateFile(t, DesktopWindowState{Width: 1280, Height: 800, X: 40, Y: 50})
231 app, host := newRecordingHostApp(t)
232 host.screens = []nativeScreen{{Width: 1920, Height: 1080}}
233 app.domReady(app.ctx)
234 calls := host.callNames()
235 position := slices.Index(calls, "SetWindowPosition(40,50)")
236 show := slices.Index(calls, "ShowWindow")
237 if position >= 0 || slices.Contains(calls, "CenterWindow") || show < 0 {
238 t.Fatalf("domReady must preserve shell geometry when presenting, got %q", calls)
239 }
240 }
241
242 func TestPickBlankProjectParentReturnsHostChoice(t *testing.T) {
243 dir := t.TempDir()
244 app, host := newRecordingHostApp(t)
245 host.dialogPath = dir
246 got, err := app.PickBlankProjectParent()
247 if err != nil {
248 t.Fatalf("PickBlankProjectParent: %v", err)
249 }
250 if got != dir {
251 t.Fatalf("PickBlankProjectParent = %q, want %q", got, dir)
252 }
253 assertHostCalls(t, host, "OpenDirectoryDialog:Choose where to create the project")
254 }
255
256 func TestPickSkillFolderCancelledReturnsEmpty(t *testing.T) {
257 app, host := newRecordingHostApp(t)
258 got, err := app.PickSkillFolder()
259 if err != nil || got != "" {
260 t.Fatalf("cancelled picker = (%q, %v), want empty", got, err)
261 }
262 assertHostCalls(t, host, "OpenDirectoryDialog:Choose skills folder")
263 }
264
265 func TestConfirmActionMapsButtonsThroughHost(t *testing.T) {
266 app, host := newRecordingHostApp(t)
267 ok, err := app.ConfirmAction(NativeConfirmRequest{Title: "Delete?", Message: "Gone forever", Destructive: true})
268 if err != nil {
269 t.Fatalf("ConfirmAction: %v", err)
270 }
271 if ok {
272 t.Fatal("a destructive confirm must default to cancel")
273 }
274 assertHostCalls(t, host, "MessageDialog:Delete?")
275 }
276
277 func TestWindowControlsWithoutHostAreNoops(t *testing.T) {
278 app := &App{ctx: context.Background()}
279 app.MinimiseMainWindow()
280 app.ToggleMaximiseMainWindow()
281 if app.IsMainWindowMaximised() {
282 t.Fatal("the no-op host must report an unmaximised window")
283 }
284 if got, err := app.PickSkillFolder(); err != nil || got != "" {
285 t.Fatalf("no-op host picker = (%q, %v), want empty", got, err)
286 }
287 }
288
288 lines GO