1package systemviews
2
3import (
4 "context"
5 "errors"
6 "sync"
7 "testing"
8 "time"
9
10 "github.com/bnema/dumber/internal/application/dto"
11 "github.com/bnema/dumber/internal/application/port"
12 "github.com/bnema/dumber/internal/domain/entity"
13 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/require"
15)
16
17func TestParseRoute(t *testing.T) {
18 t.Parallel()
19
20 tests := []struct {
21 name string
22 uri string
23 want Route
24 }{
25 {name: "empty", uri: "", want: RouteUnknown},
26 {name: "scheme only", uri: "://", want: RouteUnknown},
27 {name: "dumb scheme only", uri: "dumb://", want: RouteUnknown},
28 {name: "dumb unknown host", uri: "dumb://unknown", want: RouteUnknown},
29 {name: "dumb opaque bogus", uri: "dumb:bogus", want: RouteUnknown},
30 {name: "history host", uri: "dumb://history", want: RouteHistory},
31 {name: "history opaque", uri: "dumb:history", want: RouteHistory},
32 {name: "favorites host", uri: "dumb://favorites", want: RouteFavorites},
33 {name: "favorites opaque", uri: "dumb:favorites", want: RouteFavorites},
34 {name: "config host", uri: "dumb://config", want: RouteConfig},
35 {name: "config opaque", uri: "dumb:config", want: RouteConfig},
36 }
37
38 for _, tt := range tests {
39 t.Run(tt.name, func(t *testing.T) {
40 t.Parallel()
41 if got := ParseRoute(tt.uri); got != tt.want {
42 t.Fatalf("ParseRoute(%q) = %v, want %v", tt.uri, got, tt.want)
43 }
44 })
45 }
46}
47
48func TestAppRunMountsPlaceholderAndRecordsRoute(t *testing.T) {
49 t.Parallel()
50
51 dom := &fakeDOM{}
52 app := NewApp(Dependencies{
53 DOM: dom,
54 LocationURI: "dumb://history",
55 })
56
57 require.NoError(t, app.Run())
58 assert.Equal(t, RouteHistory, app.CurrentRoute())
59 assert.True(t, dom.Mounted())
60 assert.Contains(t, dom.HTML(), "history")
61 assert.Contains(t, dom.HTML(), "systemviews")
62}
63
64func TestAppLoadInitialHistoryRouteUsesStyledSections(t *testing.T) {
65 t.Parallel()
66
67 history := &fakeHistoryService{entries: []*entity.HistoryEntry{{
68 URL: "https://example.com",
69 Title: "Example",
70 }}}
71
72 app := NewApp(Dependencies{
73 History: history,
74 LocationURI: "dumb://history",
75 })
76
77 require.NoError(t, app.LoadInitial(context.Background()))
78 assert.Equal(t, RouteHistory, app.CurrentRoute())
79 assert.Len(t, app.historyEntries, 1)
80 assert.Contains(t, app.renderedHTML, "Example")
81 assert.Contains(t, app.renderedHTML, "https://example.com")
82 assert.True(t, history.called)
83 assert.False(t, history.analyticsCalled)
84 assert.Equal(t, 0, history.limit)
85 assert.Equal(t, 0, history.offset)
86
87 // Shell frame present, no full document wrapper.
88 assert.NotContains(t, app.renderedHTML, "<html")
89 assert.NotContains(t, app.renderedHTML, "<head")
90 assert.NotContains(t, app.renderedHTML, "<body")
91 assert.Contains(t, app.renderedHTML, `class="sv-shell"`)
92 assert.Contains(t, app.renderedHTML, `data-route="history"`)
93 assert.Contains(t, app.renderedHTML, `data-page-title="History - 1 entry, 1 visit, 0 days"`)
94 assert.Contains(t, app.renderedHTML, `sv-section`)
95 assert.Contains(t, app.renderedHTML, `class="sv-list"`)
96}
97
98func TestAppLoadInitialHistoryRouteShowsTotalStatsWithWindowedTimeline(t *testing.T) {
99 t.Parallel()
100
101 history := &fakeHistoryService{
102 entries: []*entity.HistoryEntry{
103 {ID: 1, URL: "https://older.example", Title: "Older", VisitCount: 2},
104 {ID: 2, URL: "https://newer.example", Title: "Newer", VisitCount: 3},
105 },
106 stats: &entity.HistoryStats{TotalEntries: 8904, TotalVisits: 40947, UniqueDays: 121},
107 }
108 app := NewApp(Dependencies{History: history, LocationURI: "dumb://history"})
109
110 require.NoError(t, app.LoadInitial(context.Background()))
111 assert.True(t, history.statsCalled)
112 assert.Contains(t, app.renderedHTML, "Loaded 2 items")
113 assert.Contains(t, app.renderedHTML, `data-page-title="History - 8904 entries, 40947 visits, 121 days"`)
114 assert.Contains(t, app.renderedHTML, `<h1 class="sv-title">History <span class="sv-title-detail">- 8904 entries, 40947 visits, 121 days</span></h1>`)
115 assert.NotContains(t, app.renderedHTML, `sv-history-summary`)
116 assert.NotContains(t, app.renderedHTML, `sv-stat-value`)
117}
118
119func TestAppRunMountsHistoryLoadingBeforeAsyncHydration(t *testing.T) {
120 t.Parallel()
121
122 dom := &fakeActionDOM{fakeDOM: fakeDOM{mounts: make(chan string, 4)}}
123 history := &fakeHistoryService{
124 entries: []*entity.HistoryEntry{{ID: 1, URL: "https://example.com", Title: "Loaded entry"}},
125 timelineStarted: make(chan struct{}),
126 releaseTimeline: make(chan struct{}),
127 }
128 app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})
129
130 require.NoError(t, app.RunWithContext(context.Background()))
131 loadingHTML := receiveMount(t, dom.mounts)
132 assert.Contains(t, loadingHTML, "Loading history")
133 assert.NotContains(t, loadingHTML, "Loaded entry")
134
135 select {
136 case <-history.timelineStarted:
137 case <-time.After(time.Second):
138 t.Fatal("history timeline was not started asynchronously")
139 }
140 close(history.releaseTimeline)
141
142 hydratedHTML := receiveMount(t, dom.mounts)
143 assert.Contains(t, hydratedHTML, "Loaded entry")
144 assert.Contains(t, hydratedHTML, "Loaded 1 item")
145}
146
147func TestSurfaceActionErrorPreventsStaleAsyncHydrationMount(t *testing.T) {
148 t.Parallel()
149
150 dom := &fakeDOM{mounts: make(chan string, 4)}
151 app := NewApp(Dependencies{DOM: dom, LocationURI: "dumb://history"})
152 app.currentRoute = RouteHistory
153 staleHTML := "<div>Stale hydrated entry</div>"
154 staleGeneration := app.renderGeneration
155 ctx := context.Background()
156
157 app.surfaceActionError(ctx, errors.New("action worker failed"))
158 errorHTML := receiveMount(t, dom.mounts)
159 assert.Contains(t, errorHTML, "action worker failed")
160 assert.NotContains(t, errorHTML, "Stale hydrated entry")
161
162 require.NoError(t, app.mountHTMLIfCurrent(ctx, staleHTML, staleGeneration))
163 assertNoMount(t, dom.mounts, 100*time.Millisecond)
164 assert.Contains(t, dom.HTML(), "action worker failed")
165 assert.NotContains(t, dom.HTML(), "Stale hydrated entry")
166}
167
168func TestSurfaceActionErrorWhileAsyncHydrationBlockedPreventsStaleMount(t *testing.T) {
169 t.Parallel()
170
171 dom := &fakeActionDOM{fakeDOM: fakeDOM{mounts: make(chan string, 4)}}
172 history := &fakeHistoryService{
173 entries: []*entity.HistoryEntry{{ID: 1, URL: "https://example.com", Title: "Stale hydrated entry"}},
174 timelineStarted: make(chan struct{}),
175 releaseTimeline: make(chan struct{}),
176 }
177 app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})
178 ctx := context.Background()
179
180 require.NoError(t, app.RunWithContext(ctx))
181 loadingHTML := receiveMount(t, dom.mounts)
182 assert.Contains(t, loadingHTML, "Loading history")
183
184 select {
185 case <-history.timelineStarted:
186 case <-time.After(time.Second):
187 t.Fatal("history timeline was not started asynchronously")
188 }
189
190 app.surfaceActionError(ctx, errors.New("action worker failed"))
191 errorHTML := receiveMount(t, dom.mounts)
192 assert.Contains(t, errorHTML, "action worker failed")
193 assert.NotContains(t, errorHTML, "Stale hydrated entry")
194
195 close(history.releaseTimeline)
196 assertNoMount(t, dom.mounts, 100*time.Millisecond)
197 assert.Contains(t, dom.HTML(), "action worker failed")
198 assert.NotContains(t, dom.HTML(), "Stale hydrated entry")
199}
200
201func TestCurrentHistoryRouteSnapshotIncludesWindowAfter(t *testing.T) {
202 cursor := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC)
203 app := NewApp(Dependencies{History: &fakeHistoryService{}, LocationURI: "dumb://history"})
204 app.currentRoute = RouteHistory
205 app.historyWindowAfter = cursor
206
207 snapshot := app.currentHistoryRouteSnapshotLocked()
208 assert.Equal(t, cursor, snapshot.windowAfter)
209}
210
211func TestAppHandleHistoryLoadMoreAppendsOlderWindow(t *testing.T) {
212 t.Parallel()
213
214 cursor := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC)
215 history := &fakeHistoryService{window: &entity.HistoryWindow{
216 Entries: []*entity.HistoryEntry{{ID: 2, URL: "https://older.example", Title: "Older entry", LastVisited: cursor.Add(-time.Hour)}},
217 Before: cursor,
218 After: cursor.Add(-24 * time.Hour),
219 HasMore: true,
220 }}
221 dom := &fakeActionDOM{}
222 app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})
223 app.currentRoute = RouteHistory
224 app.historyEntries = []*entity.HistoryEntry{{ID: 1, URL: "https://newer.example", Title: "Newer entry", LastVisited: cursor}}
225 app.historyWindowAfter = cursor
226 app.historyHasMore = true
227
228 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
229 Action: historyActionLoadMore,
230 Data: map[string]string{"before": cursor.Format(time.RFC3339Nano)},
231 }))
232 assert.Equal(t, cursor, history.windowBefore)
233 assert.Len(t, app.historyEntries, 2)
234 assert.Contains(t, dom.AppendedHTML(), "Older entry")
235 assert.Contains(t, dom.AppendedHTML(), `data-sv-action="history.loadMore"`)
236 assert.Empty(t, dom.HTML(), "load-more should append a fragment instead of remounting the whole page")
237}
238
239func TestAppHandleHistoryLoadMoreRemountsWhenAppendUnavailable(t *testing.T) {
240 t.Parallel()
241
242 cursor := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC)
243 history := &fakeHistoryService{window: &entity.HistoryWindow{
244 Entries: []*entity.HistoryEntry{{ID: 2, URL: "https://older.example", Title: "Older entry", LastVisited: cursor.Add(-time.Hour)}},
245 Before: cursor,
246 After: cursor.Add(-24 * time.Hour),
247 HasMore: true,
248 }}
249 dom := &fakeDOM{}
250 app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})
251 app.currentRoute = RouteHistory
252 app.historyEntries = []*entity.HistoryEntry{{ID: 1, URL: "https://newer.example", Title: "Newer entry", LastVisited: cursor}}
253 app.historyWindowAfter = cursor
254 app.historyHasMore = true
255
256 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
257 Action: historyActionLoadMore,
258 Data: map[string]string{"before": cursor.Format(time.RFC3339Nano)},
259 }))
260 assert.Equal(t, cursor, history.windowBefore)
261 assert.Len(t, app.historyEntries, 2)
262 assert.Contains(t, dom.HTML(), "Older entry")
263 assert.Contains(t, dom.HTML(), `data-sv-action="history.loadMore"`)
264}
265
266func TestAppHandleHistoryLoadMoreIgnoresStaleCursor(t *testing.T) {
267 t.Parallel()
268
269 cursor := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC)
270 history := &fakeHistoryService{window: &entity.HistoryWindow{Entries: []*entity.HistoryEntry{{ID: 2, URL: "https://older.example"}}}}
271 dom := &fakeActionDOM{}
272 app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})
273 app.currentRoute = RouteHistory
274 app.historyWindowAfter = cursor
275 app.historyHasMore = true
276
277 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
278 Action: historyActionLoadMore,
279 Data: map[string]string{"before": cursor.Add(-24 * time.Hour).Format(time.RFC3339Nano)},
280 }))
281 assert.False(t, history.called)
282 assert.Empty(t, dom.AppendedHTML())
283}
284
285func TestAppLoadInitialHistoryRouteRendersManagementActions(t *testing.T) {
286 t.Parallel()
287
288 history := &fakeHistoryService{
289 entries: []*entity.HistoryEntry{{
290 ID: 42,
291 URL: "https://example.com/page",
292 Title: "Example",
293 }},
294 domainStats: []*entity.DomainStat{{Domain: "www.example.com:8080", PageCount: 1, TotalVisits: 3}},
295 }
296
297 app := NewApp(Dependencies{
298 History: history,
299 LocationURI: "dumb://history",
300 })
301
302 require.NoError(t, app.LoadInitial(context.Background()))
303 assert.Contains(t, app.renderedHTML, `data-sv-action="history.search"`)
304 assert.Contains(t, app.renderedHTML, `data-sv-action="history.deleteEntry"`)
305 assert.Contains(t, app.renderedHTML, `data-id="42"`)
306 assert.Contains(t, app.renderedHTML, `data-sv-action="history.deleteRange"`)
307 assert.Contains(t, app.renderedHTML, `data-range="hour"`)
308 assert.Contains(t, app.renderedHTML, `data-sv-action="history.filterDomain"`)
309 assert.Contains(t, app.renderedHTML, `data-sv-action="history.deleteDomain"`)
310 assert.Contains(t, app.renderedHTML, `data-domain="example.com:8080"`)
311 assert.Contains(t, app.renderedHTML, `>example.com</button>`)
312 assert.Contains(t, app.renderedHTML, `data-sv-history-search data-sv-autofocus`)
313 assert.NotContains(t, app.renderedHTML, "Keys:")
314}
315
316func TestAppHandleHistoryActionsRefreshesDOM(t *testing.T) {
317 dom := &fakeDOM{}
318 history := &fakeHistoryService{
319 entries: []*entity.HistoryEntry{{ID: 42, URL: "https://example.com", Title: "Example"}},
320 searchEntries: []*entity.HistoryEntry{{ID: 7, URL: "https://search.example", Title: "Search result"}},
321 }
322 app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})
323
324 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
325 Action: historyActionSearch,
326 Data: map[string]string{"query": " example "},
327 }))
328 assert.True(t, history.searchCalled)
329 assert.Equal(t, "example", history.query)
330 assert.Contains(t, dom.HTML(), "Search result")
331 assert.Contains(t, dom.HTML(), "Query: example")
332
333 app.historyOffset = historyTimelineLimit
334 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
335 Action: historyActionDeleteEntry,
336 Data: map[string]string{"id": "42"},
337 }))
338 assert.Equal(t, int64(42), history.deletedEntryID)
339 assert.Equal(t, 0, app.historyOffset)
340 assert.Contains(t, dom.HTML(), "Deleted history entry")
341
342 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
343 Action: historyActionDeleteRange,
344 Data: map[string]string{"range": "week"},
345 }))
346 assert.Equal(t, "week", history.deletedRangeID)
347 assert.Contains(t, dom.HTML(), "Deleted history from this week")
348
349 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{Action: historyActionClear}))
350 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
351 Action: historyActionFilterDomain,
352 Data: map[string]string{"domain": "example.com"},
353 }))
354 assert.True(t, history.called)
355 assert.Equal(t, "example.com", history.windowDomain)
356 assert.Empty(t, app.historyQuery)
357
358 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
359 Action: historyActionDeleteDomain,
360 Data: map[string]string{"domain": "example.com"},
361 }))
362 assert.Equal(t, "example.com", history.deletedDomain)
363 assert.Contains(t, dom.HTML(), "Deleted history for example.com")
364}
365
366func TestAppCloseStopsActionWorkerAndReleasesDOM(t *testing.T) {
367 dom := &fakeActionDOM{}
368 history := &fakeHistoryService{entries: []*entity.HistoryEntry{{ID: 1, URL: "https://example.com"}}}
369 app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})
370
371 ctx, cancel := context.WithCancel(context.Background())
372 defer cancel()
373 require.NoError(t, app.RunWithContext(ctx))
374 require.NotNil(t, dom.handler)
375
376 app.Close()
377 assert.True(t, dom.released)
378 assert.False(t, app.enqueueDOMAction(DOMAction{Action: historyActionClear}))
379 require.NotPanics(t, func() {
380 dom.handler(DOMAction{Action: historyActionClear})
381 })
382}
383
384func TestAppRunCleansActionWorkerWhenBindingFails(t *testing.T) {
385 bindErr := errors.New("bind failed")
386 dom := &failingActionDOM{bindErr: bindErr}
387 app := NewApp(Dependencies{DOM: dom, LocationURI: "dumb://history"})
388
389 err := app.RunWithContext(context.Background())
390 require.ErrorIs(t, err, bindErr)
391 assert.Nil(t, app.actionQueue)
392 assert.Nil(t, app.actionErrorQueue)
393 assert.Nil(t, app.actionCtx)
394 assert.False(t, app.actionWorkerActive())
395 require.NotPanics(t, app.Close)
396}
397
398func TestAppRunReleasesDOMWhenActionWorkerCannotStart(t *testing.T) {
399 dom := &fakeActionDOM{}
400 history := &fakeHistoryService{entries: []*entity.HistoryEntry{{ID: 1, URL: "https://example.com"}}}
401 app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})
402
403 ctx, cancel := context.WithCancel(context.Background())
404 cancel()
405 err := app.RunWithContext(ctx)
406
407 require.ErrorContains(t, err, "systemview action worker unavailable")
408 assert.True(t, dom.released)
409 assert.Nil(t, app.actionQueue)
410 assert.Nil(t, app.actionErrorQueue)
411 assert.Nil(t, app.actionCtx)
412 assert.False(t, app.actionWorkerActive())
413}
414
415func TestAppLoadInitialHistoryRouteRendersErrorState(t *testing.T) {
416 t.Parallel()
417
418 history := &fakeHistoryService{err: errors.New("database unavailable")}
419 app := NewApp(Dependencies{
420 History: history,
421 LocationURI: "dumb://history",
422 })
423
424 require.NoError(t, app.LoadInitial(context.Background()))
425 assert.Equal(t, RouteHistory, app.CurrentRoute())
426 assert.True(t, history.called)
427 assert.Contains(t, app.renderedHTML, "Could not load this system view")
428 assert.Contains(t, app.renderedHTML, "database unavailable")
429 assert.Contains(t, app.renderedHTML, `role="alert"`)
430}
431
432func TestAppLoadInitialHistoryRouteAppliesThemeTokens(t *testing.T) {
433 t.Parallel()
434
435 history := &fakeHistoryService{entries: []*entity.HistoryEntry{{
436 URL: "https://example.com",
437 Title: "Example",
438 }}}
439 config := &fakeConfigService{current: dto.SystemviewConfigPayload{
440 Appearance: dto.WebUIAppearanceConfig{
441 ColorScheme: "prefer-light",
442 LightPalette: dto.ColorPalette{
443 Background: "#ffffff",
444 Surface: "#fafafa",
445 SurfaceVariant: "#eeeeee",
446 Text: "#111111",
447 Muted: "#666666",
448 Accent: "#0055ff",
449 Border: "#dddddd",
450 },
451 DarkPalette: dto.ColorPalette{
452 Background: "#111111",
453 Surface: "#1a1a1a",
454 SurfaceVariant: "#2a2a2a",
455 Text: "#f5f5f5",
456 Muted: "#a0a0a0",
457 Accent: "#66aaff",
458 Border: "#333333",
459 },
460 SansFont: "Inter",
461 SerifFont: "Georgia",
462 MonospaceFont: "JetBrains Mono",
463 DefaultFontSize: 16,
464 },
465 }}
466
467 app := NewApp(Dependencies{
468 Config: config,
469 History: history,
470 LocationURI: "dumb://history",
471 })
472
473 require.NoError(t, app.LoadInitial(context.Background()))
474 assert.Contains(t, app.renderedHTML, `class="sv-app sv-light"`)
475 assert.Contains(t, app.renderedHTML, `--sv-background: #ffffff;`)
476 assert.Contains(t, app.renderedHTML, `--sv-surface-variant: #eeeeee;`)
477}
478
479func TestAppLoadInitialFavoritesRouteRendersData(t *testing.T) {
480 t.Parallel()
481
482 favorites := &fakeFavoritesService{
483 favorites: []*entity.Favorite{{URL: "https://example.com", Title: "Example"}},
484 folders: []*entity.Folder{{Name: "Read Later"}},
485 tags: []*entity.Tag{{Name: "Go"}},
486 }
487
488 app := NewApp(Dependencies{
489 Favorites: favorites,
490 LocationURI: "dumb://favorites",
491 })
492
493 require.NoError(t, app.LoadInitial(context.Background()))
494 assert.Equal(t, RouteFavorites, app.CurrentRoute())
495 assert.Len(t, app.favorites, 1)
496 assert.Len(t, app.folders, 1)
497 assert.Len(t, app.tags, 1)
498 assert.Contains(t, app.renderedHTML, "Favorites")
499 assert.Contains(t, app.renderedHTML, "Example")
500 assert.Contains(t, app.renderedHTML, "Read Later")
501 assert.Contains(t, app.renderedHTML, "Go")
502 assert.True(t, favorites.calledList)
503 assert.True(t, favorites.calledFolders)
504 assert.True(t, favorites.calledTags)
505
506 // Shell frame present, no full document wrapper.
507 assert.NotContains(t, app.renderedHTML, "<html")
508 assert.NotContains(t, app.renderedHTML, "<head")
509 assert.NotContains(t, app.renderedHTML, "<body")
510 assert.Contains(t, app.renderedHTML, `class="sv-shell"`)
511 assert.Contains(t, app.renderedHTML, `data-route="favorites"`)
512 assert.Contains(t, app.renderedHTML, `data-page-title="Favorites — 1 bookmark"`)
513 assert.Contains(t, app.renderedHTML, `sv-section`)
514 assert.Contains(t, app.renderedHTML, `class="sv-meta"`)
515}
516
517func TestAppLoadInitialFavoritesRouteRendersCRUDControls(t *testing.T) {
518 t.Parallel()
519
520 folderID := entity.FolderID(1)
521 shortcut := 3
522 favorites := &fakeFavoritesService{
523 favorites: []*entity.Favorite{{ID: 42, URL: "https://example.com", Title: "Example", FolderID: &folderID, ShortcutKey: &shortcut}},
524 folders: []*entity.Folder{{ID: folderID, Name: "Read Later", Icon: "📚"}},
525 tags: []*entity.Tag{{ID: 7, Name: "Go", Color: "#00add8"}},
526 }
527
528 app := NewApp(Dependencies{Favorites: favorites, LocationURI: "dumb://favorites"})
529
530 require.NoError(t, app.LoadInitial(context.Background()))
531 assert.Contains(t, app.renderedHTML, `data-sv-action="favorite.create"`)
532 assert.Contains(t, app.renderedHTML, `data-sv-action="favorite.update"`)
533 assert.Contains(t, app.renderedHTML, `data-sv-action="favorite.delete"`)
534 assert.Contains(t, app.renderedHTML, `data-sv-action="folder.create"`)
535 assert.Contains(t, app.renderedHTML, `data-sv-action="folder.update"`)
536 assert.Contains(t, app.renderedHTML, `data-sv-action="folder.delete"`)
537 assert.Contains(t, app.renderedHTML, `data-sv-action="tag.create"`)
538 assert.Contains(t, app.renderedHTML, `data-sv-action="tag.update"`)
539 assert.Contains(t, app.renderedHTML, `data-sv-action="tag.delete"`)
540 assert.Contains(t, app.renderedHTML, `data-sv-action="tag.assign"`)
541 assert.Contains(t, app.renderedHTML, `name="tags"`)
542 assert.Contains(t, app.renderedHTML, `value="7"`)
543 assert.Contains(t, app.renderedHTML, "Read Later")
544 assert.Contains(t, app.renderedHTML, "Go")
545 assert.Contains(t, app.renderedHTML, "Shortcut 3")
546}
547
548func TestAppHandleFavoriteActionsRefreshesDOM(t *testing.T) {
549 dom := &fakeDOM{}
550 favorites := &fakeFavoritesService{
551 favorites: []*entity.Favorite{{ID: 42, URL: "https://example.com", Title: "Example"}},
552 folders: []*entity.Folder{{ID: 5, Name: "Read Later"}},
553 tags: []*entity.Tag{{ID: 7, Name: "Go", Color: "#00add8"}},
554 }
555 app := NewApp(Dependencies{DOM: dom, Favorites: favorites, LocationURI: "dumb://favorites"})
556
557 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
558 Action: favoriteActionCreate,
559 Data: map[string]string{"url": "https://new.example", "title": "New", "folder_id": "5", "tags": "7"},
560 }))
561 require.NotNil(t, favorites.createdFavorite.FolderID)
562 assert.Equal(t, "https://new.example", favorites.createdFavorite.URL)
563 assert.Equal(t, entity.FolderID(5), *favorites.createdFavorite.FolderID)
564 assert.Equal(t, []entity.TagID{7}, favorites.createdFavorite.Tags)
565 assert.Contains(t, dom.HTML(), "Added favorite New")
566
567 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
568 Action: favoriteActionUpdate,
569 Data: map[string]string{"id": "42", "title": "Updated", "favicon_url": "https://icon.example/favicon.ico", "shortcut_key": "4"},
570 }))
571 assert.Equal(t, entity.FavoriteID(42), favorites.updatedFavorite.ID)
572 assert.Equal(t, "Updated", favorites.updatedFavorite.Title)
573 require.NotNil(t, favorites.updatedFavorite.ShortcutKey)
574 assert.Equal(t, 4, *favorites.updatedFavorite.ShortcutKey)
575 assert.Contains(t, dom.HTML(), "Saved favorite Updated")
576
577 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
578 Action: tagActionAssign,
579 Data: map[string]string{"favoriteId": "42", "tagId": "7"},
580 }))
581 assert.Equal(t, int64(42), favorites.assignedFavoriteID)
582 assert.Equal(t, int64(7), favorites.assignedTagID)
583
584 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
585 Action: favoriteActionFilterFolder,
586 Data: map[string]string{"folderId": "5"},
587 }))
588 require.NotNil(t, app.favoriteFolderFilter)
589 assert.Equal(t, entity.FolderID(5), *app.favoriteFolderFilter)
590 assert.Nil(t, app.favoriteTagFilter)
591
592 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
593 Action: favoriteActionFilterTag,
594 Data: map[string]string{"tagId": "7"},
595 }))
596 require.NotNil(t, app.favoriteTagFilter)
597 assert.Equal(t, entity.TagID(7), *app.favoriteTagFilter)
598 assert.Nil(t, app.favoriteFolderFilter)
599
600 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
601 Action: folderActionCreate,
602 Data: map[string]string{"name": "Reading", "icon": "📚"},
603 }))
604 assert.Equal(t, "Reading", favorites.createdFolder)
605 assert.Equal(t, "📚", favorites.createdFolderIcon)
606 assert.Contains(t, dom.HTML(), "Created folder 📚 Reading")
607
608 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
609 Action: favoriteActionDelete,
610 Data: map[string]string{"id": "42"},
611 }))
612 assert.Equal(t, int64(42), favorites.deletedFavorite)
613 assert.Contains(t, dom.HTML(), "Deleted favorite")
614}
615
616func TestAppLoadInitialConfigRouteRendersData(t *testing.T) {
617 t.Parallel()
618
619 config := dto.SystemviewConfigPayload{
620 EngineType: "webkit",
621 Appearance: dto.WebUIAppearanceConfig{
622 ColorScheme: "prefer-dark",
623 SansFont: "Inter",
624 SerifFont: "Georgia",
625 MonospaceFont: "JetBrains Mono",
626 DefaultFontSize: 16,
627 LightPalette: dto.ColorPalette{
628 Background: "#ffffff",
629 Surface: "#fafafa",
630 SurfaceVariant: "#eeeeee",
631 Text: "#111111",
632 Muted: "#666666",
633 Accent: "#0055ff",
634 Border: "#dddddd",
635 },
636 DarkPalette: dto.ColorPalette{
637 Background: "#111111",
638 Surface: "#1a1a1a",
639 SurfaceVariant: "#2a2a2a",
640 Text: "#f5f5f5",
641 Muted: "#a0a0a0",
642 Accent: "#66aaff",
643 Border: "#333333",
644 },
645 },
646 Performance: dto.SystemviewPerformancePayload{
647 Profile: "balanced",
648 Custom: dto.SystemviewCustomPerformancePayload{
649 SkiaCPUThreads: 4,
650 SkiaGPUThreads: 2,
651 WebProcessMemoryMB: 512,
652 NetworkProcessMemoryMB: 128,
653 WebViewPoolPrewarm: 1,
654 },
655 Hardware: dto.SystemviewHardwarePayload{
656 CPUCores: 8,
657 CPUThreads: 16,
658 TotalRAMMB: 32768,
659 GPUVendor: "NVIDIA",
660 GPUName: "RTX 4060",
661 VRAMMB: 8192,
662 },
663 },
664 DefaultSearchEngine: "https://duckduckgo.com/?q=%s",
665 SearchShortcuts: map[string]dto.SearchShortcut{
666 "ddg": {
667 URL: "https://duckduckgo.com/?q=%s",
668 Description: "DuckDuckGo",
669 },
670 },
671 }
672 service := &fakeConfigService{
673 current: config,
674 keybindings: port.KeybindingsConfig{Groups: []port.KeybindingGroup{
675 {
676 Mode: "default",
677 DisplayName: "Default",
678 Bindings: []port.KeybindingEntry{
679 {Action: "open", Description: "Open", Keys: []string{"ctrl+o"}, DefaultKeys: []string{"ctrl+o"}},
680 {Action: "new-tab", Description: "New tab", Keys: []string{"ctrl+t"}, DefaultKeys: []string{"ctrl+shift+t"}, IsCustom: true},
681 },
682 },
683 {
684 Mode: "search",
685 DisplayName: "Search",
686 Bindings: []port.KeybindingEntry{
687 {Action: "find", Description: "Find", Keys: []string{"ctrl+f"}, DefaultKeys: []string{"ctrl+f"}},
688 },
689 },
690 }},
691 }
692
693 app := NewApp(Dependencies{
694 Config: service,
695 LocationURI: "dumb://config",
696 })
697
698 require.NoError(t, app.LoadInitial(context.Background()))
699 assert.Equal(t, RouteConfig, app.CurrentRoute())
700 require.NotNil(t, app.config)
701 assert.Equal(t, "webkit", app.config.EngineType)
702 assert.Contains(t, app.renderedHTML, "webkit")
703 assert.Contains(t, app.renderedHTML, "https://duckduckgo.com/?q=%s")
704 assert.Contains(t, app.renderedHTML, "appearance.color_scheme")
705 assert.Contains(t, app.renderedHTML, "prefer-dark")
706 assert.Contains(t, app.renderedHTML, "appearance.sans_font")
707 assert.Contains(t, app.renderedHTML, "Inter")
708 assert.Contains(t, app.renderedHTML, "appearance.light_palette.background")
709 assert.Contains(t, app.renderedHTML, "#ffffff")
710 assert.Contains(t, app.renderedHTML, "performance.profile")
711 assert.Contains(t, app.renderedHTML, "balanced")
712 assert.Contains(t, app.renderedHTML, "performance.custom.skia_cpu_threads")
713 assert.Contains(t, app.renderedHTML, "4")
714 assert.Contains(t, app.renderedHTML, "performance.hardware.gpu_vendor")
715 assert.Contains(t, app.renderedHTML, "NVIDIA")
716 assert.Contains(t, app.renderedHTML, "search_shortcuts.ddg.url")
717 assert.Contains(t, app.renderedHTML, "Default")
718 assert.Contains(t, app.renderedHTML, "Search")
719 assert.Contains(t, app.renderedHTML, "Open")
720 assert.Contains(t, app.renderedHTML, "ctrl+o")
721 assert.Contains(t, app.renderedHTML, "New tab")
722 assert.Contains(t, app.renderedHTML, "ctrl+t")
723 assert.Contains(t, app.renderedHTML, "ctrl+shift+t")
724 assert.Contains(t, app.renderedHTML, "Find")
725 assert.Contains(t, app.renderedHTML, "open")
726 assert.Contains(t, app.renderedHTML, "default")
727 assert.Contains(t, app.renderedHTML, "custom")
728 assert.NotContains(t, app.renderedHTML, "groups[0].bindings[0].action")
729 assert.True(t, service.calledCurrent)
730 assert.True(t, service.calledKeybindings)
731
732 // Shell frame present, no full document wrapper.
733 assert.NotContains(t, app.renderedHTML, "<html")
734 assert.NotContains(t, app.renderedHTML, "<head")
735 assert.NotContains(t, app.renderedHTML, "<body")
736 assert.Contains(t, app.renderedHTML, `class="sv-shell"`)
737 assert.Contains(t, app.renderedHTML, `data-route="config"`)
738 assert.Contains(t, app.renderedHTML, `data-page-title="Config — Dumber"`)
739 assert.Contains(t, app.renderedHTML, `sv-section`)
740 assert.Contains(t, app.renderedHTML, `class="sv-meta"`)
741}
742
743func TestAppLoadInitialConfigRouteRendersEditControls(t *testing.T) {
744 t.Parallel()
745
746 service := &fakeConfigService{
747 current: testConfigPayload(),
748 keybindings: port.KeybindingsConfig{Groups: []port.KeybindingGroup{{
749 Mode: "default",
750 DisplayName: "Default",
751 Bindings: []port.KeybindingEntry{{
752 Action: "toggle_history_systemview",
753 Description: "Toggle history",
754 Keys: []string{"ctrl+h"},
755 DefaultKeys: []string{"ctrl+h"},
756 }},
757 }}},
758 }
759 app := NewApp(Dependencies{Config: service, LocationURI: "dumb://config"})
760
761 require.NoError(t, app.LoadInitial(context.Background()))
762 assert.Contains(t, app.renderedHTML, `data-sv-action="config.appearance.save"`)
763 assert.Contains(t, app.renderedHTML, `data-sv-action="config.appearance.reset"`)
764 assert.Contains(t, app.renderedHTML, `data-sv-action="config.search.save"`)
765 assert.Contains(t, app.renderedHTML, `data-sv-action="config.searchShortcut.create"`)
766 assert.Contains(t, app.renderedHTML, `data-sv-action="config.searchShortcut.update"`)
767 assert.Contains(t, app.renderedHTML, `data-sv-action="config.searchShortcut.delete"`)
768 assert.Contains(t, app.renderedHTML, `data-sv-action="config.performance.save"`)
769 assert.Contains(t, app.renderedHTML, `data-sv-action="config.keybinding.set"`)
770 assert.Contains(t, app.renderedHTML, `data-sv-action="config.keybinding.reset"`)
771 assert.Contains(t, app.renderedHTML, `data-sv-action="config.keybinding.resetAll"`)
772 assert.Contains(t, app.renderedHTML, "may require restarting")
773}
774
775func TestAppHandleConfigActionsRefreshesDOM(t *testing.T) {
776 dom := &fakeDOM{}
777 service := &fakeConfigService{
778 current: testConfigPayload(),
779 defaultCfg: testDefaultConfigPayload(),
780 keybindings: port.KeybindingsConfig{Groups: []port.KeybindingGroup{{
781 Mode: "default",
782 DisplayName: "Default",
783 Bindings: []port.KeybindingEntry{{
784 Action: "toggle_history_systemview",
785 Description: "Toggle history",
786 Keys: []string{"ctrl+h"},
787 DefaultKeys: []string{"ctrl+h"},
788 }},
789 }}},
790 setResp: port.SetKeybindingResponse{},
791 }
792 app := NewApp(Dependencies{DOM: dom, Config: service, LocationURI: "dumb://config"})
793
794 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
795 Action: configActionSaveAppearance,
796 Data: map[string]string{
797 "sans_font": "Inter", "serif_font": "Georgia", "monospace_font": "JetBrains Mono",
798 "default_font_size": "18", "default_ui_scale": "1.25", "color_scheme": "prefer-light",
799 "light_background": "#ffffff", "light_surface": "#fafafa", "light_surface_variant": "#eeeeee", "light_text": "#111111", "light_muted": "#666666", "light_accent": "#0055ff", "light_border": "#dddddd",
800 "dark_background": "#111111", "dark_surface": "#1a1a1a", "dark_surface_variant": "#2a2a2a", "dark_text": "#f5f5f5", "dark_muted": "#a0a0a0", "dark_accent": "#66aaff", "dark_border": "#333333",
801 },
802 }))
803 assert.True(t, service.calledSave)
804 assert.Equal(t, 18, service.savedConfig.Appearance.DefaultFontSize)
805 assert.InEpsilon(t, 1.25, service.savedConfig.DefaultUIScale, 0.001)
806 assert.Contains(t, dom.HTML(), "Saved appearance settings")
807 assert.Contains(t, dom.HTML(), `class="sv-app sv-light"`)
808
809 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
810 Action: configActionCreateSearchShortcut,
811 Data: map[string]string{"key": "g", "url": "https://google.com/search?q=%s", "description": "Google"},
812 }))
813 assert.Equal(t, "Google", service.savedConfig.SearchShortcuts["g"].Description)
814 assert.Contains(t, dom.HTML(), "Created search shortcut g")
815
816 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
817 Action: configActionSavePerformance,
818 Data: map[string]string{
819 "profile": "custom", "skia_cpu_threads": "4", "skia_gpu_threads": "2",
820 "web_process_memory_mb": "2048", "network_process_memory_mb": "512", "webview_pool_prewarm": "4",
821 },
822 }))
823 assert.Equal(t, "custom", service.savedConfig.Performance.Profile)
824 assert.Equal(t, 2048, service.savedConfig.Performance.Custom.WebProcessMemoryMB)
825 assert.Contains(t, dom.HTML(), "Saved performance settings")
826
827 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
828 Action: configActionSavePerformance,
829 Data: map[string]string{"profile": "default"},
830 }))
831 assert.Equal(t, "default", service.savedConfig.Performance.Profile)
832 assert.Contains(t, dom.HTML(), "Saved performance settings")
833
834 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
835 Action: configActionSetKeybinding,
836 Data: map[string]string{"mode": "default", "action": "toggle_history_systemview", "keys": "ctrl+h, alt+h"},
837 }))
838 assert.True(t, service.calledSet)
839 assert.Equal(t, []string{"ctrl+h", "alt+h"}, service.setReq.Keys)
840 assert.Contains(t, service.setReq.RequestID, "systemviews-config-")
841 assert.Contains(t, dom.HTML(), "Saved keybinding toggle_history_systemview")
842
843 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
844 Action: configActionResetKeybinding,
845 Data: map[string]string{"mode": "default", "action": "toggle_history_systemview"},
846 }))
847 assert.True(t, service.calledReset)
848 assert.Equal(t, "toggle_history_systemview", service.resetReq.Action)
849 assert.Contains(t, service.resetReq.RequestID, "systemviews-config-")
850 assert.NotEqual(t, service.setReq.RequestID, service.resetReq.RequestID)
851 assert.Contains(t, dom.HTML(), "Reset keybinding toggle_history_systemview")
852
853 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{Action: configActionResetAllKeybindings}))
854 assert.True(t, service.calledResetAll)
855 assert.Contains(t, dom.HTML(), "Reset all keybindings to defaults")
856}
857
858func TestAppRejectsSearchURLsWithoutPlaceholder(t *testing.T) {
859 t.Parallel()
860
861 service := &fakeConfigService{current: testConfigPayload(), defaultCfg: testDefaultConfigPayload()}
862 app := NewApp(Dependencies{Config: service, LocationURI: "dumb://config"})
863
864 err := app.handleConfigAction(context.Background(), DOMAction{
865 Action: configActionSaveSearch,
866 Data: map[string]string{"default_search_engine": "https://example.com/search"},
867 })
868 require.Error(t, err)
869 assert.False(t, service.calledSave)
870
871 err = app.handleConfigAction(context.Background(), DOMAction{
872 Action: configActionCreateSearchShortcut,
873 Data: map[string]string{"key": "bad", "url": "https://example.com/search", "description": "Bad"},
874 })
875 require.Error(t, err)
876 assert.False(t, service.calledSave)
877}
878
879func testConfigPayload() dto.SystemviewConfigPayload {
880 return dto.SystemviewConfigPayload{
881 EngineType: "webkit",
882 Appearance: dto.WebUIAppearanceConfig{
883 ColorScheme: "prefer-dark",
884 SansFont: "Inter",
885 SerifFont: "Georgia",
886 MonospaceFont: "JetBrains Mono",
887 DefaultFontSize: 16,
888 LightPalette: dto.ColorPalette{Background: "#ffffff", Surface: "#fafafa", SurfaceVariant: "#eeeeee", Text: "#111111", Muted: "#666666", Accent: "#0055ff", Border: "#dddddd"},
889 DarkPalette: dto.ColorPalette{Background: "#111111", Surface: "#1a1a1a", SurfaceVariant: "#2a2a2a", Text: "#f5f5f5", Muted: "#a0a0a0", Accent: "#66aaff", Border: "#333333"},
890 },
891 DefaultUIScale: 1,
892 DefaultSearchEngine: "https://duckduckgo.com/?q=%s",
893 SearchShortcuts: map[string]dto.SearchShortcut{
894 "ddg": {URL: "https://duckduckgo.com/?q=%s", Description: "DuckDuckGo"},
895 },
896 Performance: dto.SystemviewPerformancePayload{
897 Profile: "default",
898 Custom: dto.SystemviewCustomPerformancePayload{SkiaCPUThreads: 2, SkiaGPUThreads: 1, WebProcessMemoryMB: 1024, NetworkProcessMemoryMB: 256, WebViewPoolPrewarm: 2},
899 Hardware: dto.SystemviewHardwarePayload{CPUCores: 4, CPUThreads: 8, TotalRAMMB: 16384, GPUVendor: "AMD", GPUName: "Radeon", VRAMMB: 4096},
900 },
901 }
902}
903
904func testDefaultConfigPayload() dto.SystemviewConfigPayload {
905 cfg := testConfigPayload()
906 cfg.Appearance.DefaultFontSize = 14
907 cfg.DefaultUIScale = 1
908 return cfg
909}
910
911// Handwritten fake to capture DOM mounts for stateful render assertions.
912type fakeDOM struct {
913 mu sync.Mutex
914 mounted bool
915 html string
916 appendedHTML string
917 mounts chan string
918}
919
920func (d *fakeDOM) Mount(markup string) error {
921 d.mu.Lock()
922 d.mounted = true
923 d.html = markup
924 mounts := d.mounts
925 d.mu.Unlock()
926 if mounts != nil {
927 select {
928 case mounts <- markup:
929 default:
930 }
931 }
932 return nil
933}
934
935func (d *fakeDOM) Mounted() bool {
936 d.mu.Lock()
937 defer d.mu.Unlock()
938 return d.mounted
939}
940
941func (d *fakeDOM) HTML() string {
942 d.mu.Lock()
943 defer d.mu.Unlock()
944 return d.html
945}
946
947func (d *fakeDOM) AppendedHTML() string {
948 d.mu.Lock()
949 defer d.mu.Unlock()
950 return d.appendedHTML
951}
952
953func receiveMount(t *testing.T, mounts <-chan string) string {
954 t.Helper()
955 select {
956 case html := <-mounts:
957 return html
958 case <-time.After(time.Second):
959 t.Fatal("timed out waiting for DOM mount")
960 return ""
961 }
962}
963
964func assertNoMount(t *testing.T, mounts <-chan string, wait time.Duration) {
965 t.Helper()
966 select {
967 case html := <-mounts:
968 t.Fatalf("unexpected DOM mount: %s", html)
969 case <-time.After(wait):
970 }
971}
972
973type fakeActionDOM struct {
974 fakeDOM
975 handler DOMActionHandler
976 released bool
977}
978
979func (d *fakeActionDOM) BindActions(handler DOMActionHandler) error {
980 d.handler = handler
981 return nil
982}
983
984func (d *fakeActionDOM) AppendHistoryTimeline(markup string) error {
985 d.mu.Lock()
986 d.appendedHTML += markup
987 d.mu.Unlock()
988 return nil
989}
990
991func (d *fakeActionDOM) Release() {
992 d.released = true
993}
994
995type failingActionDOM struct {
996 fakeDOM
997 bindErr error
998}
999
1000func (d *failingActionDOM) BindActions(DOMActionHandler) error {
1001 return d.bindErr
1002}
1003
1004// Handwritten fake to capture history state for stateful render assertions.
1005type fakeHistoryService struct {
1006 called bool
1007 limit int
1008 offset int
1009 entries []*entity.HistoryEntry
1010 err error
1011 domainCalled bool
1012 domain string
1013
1014 searchCalled bool
1015 query string
1016 searchLimit int
1017 searchEntries []*entity.HistoryEntry
1018
1019 deletedEntryID int64
1020 deletedRangeID string
1021 deletedDomain string
1022 domainStats []*entity.DomainStat
1023 stats *entity.HistoryStats
1024 statsCalled bool
1025 window *entity.HistoryWindow
1026 windowBefore time.Time
1027 windowDomain string
1028 analyticsCalled bool
1029
1030 timelineStarted chan struct{}
1031 releaseTimeline chan struct{}
1032 startOnce sync.Once
1033}
1034
1035func (s *fakeHistoryService) Timeline(_ context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {
1036 s.called = true
1037 s.limit = limit
1038 s.offset = offset
1039 if s.timelineStarted != nil {
1040 s.startOnce.Do(func() { close(s.timelineStarted) })
1041 }
1042 if s.releaseTimeline != nil {
1043 <-s.releaseTimeline
1044 }
1045 return s.entries, s.err
1046}
1047
1048func (s *fakeHistoryService) TimelineByDomain(_ context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) {
1049 s.domainCalled = true
1050 s.domain = domain
1051 s.limit = limit
1052 s.offset = offset
1053 return s.entries, s.err
1054}
1055
1056func (s *fakeHistoryService) TimelineWindow(_ context.Context, before time.Time, domain string) (*entity.HistoryWindow, error) {
1057 s.called = true
1058 s.windowBefore = before
1059 s.windowDomain = domain
1060 if s.timelineStarted != nil {
1061 s.startOnce.Do(func() { close(s.timelineStarted) })
1062 }
1063 if s.releaseTimeline != nil {
1064 <-s.releaseTimeline
1065 }
1066 if s.err != nil {
1067 return nil, s.err
1068 }
1069 if s.window != nil {
1070 return s.window, nil
1071 }
1072 return &entity.HistoryWindow{Entries: s.entries, Before: before, After: before.Add(-24 * time.Hour)}, nil
1073}
1074
1075func (s *fakeHistoryService) Search(_ context.Context, query string, limit int) ([]*entity.HistoryEntry, error) {
1076 s.searchCalled = true
1077 s.query = query
1078 s.searchLimit = limit
1079 if s.searchEntries != nil {
1080 return s.searchEntries, nil
1081 }
1082 return s.entries, nil
1083}
1084
1085func (s *fakeHistoryService) DeleteEntry(_ context.Context, id int64) error {
1086 s.deletedEntryID = id
1087 return nil
1088}
1089
1090func (s *fakeHistoryService) DeleteRange(_ context.Context, rangeID string) error {
1091 s.deletedRangeID = rangeID
1092 return nil
1093}
1094
1095func (s *fakeHistoryService) Stats(context.Context) (*entity.HistoryStats, error) {
1096 s.statsCalled = true
1097 return s.stats, nil
1098}
1099
1100func (s *fakeHistoryService) Analytics(context.Context) (*entity.HistoryAnalytics, error) {
1101 s.analyticsCalled = true
1102 return nil, nil
1103}
1104
1105func (s *fakeHistoryService) DomainStats(context.Context, int) ([]*entity.DomainStat, error) {
1106 return s.domainStats, nil
1107}
1108
1109func (s *fakeHistoryService) DeleteDomain(_ context.Context, domain string) error {
1110 s.deletedDomain = domain
1111 return nil
1112}
1113
1114// Handwritten fake to capture favorites state for stateful render assertions.
1115type fakeFavoritesService struct {
1116 calledList bool
1117 calledFolders bool
1118 calledTags bool
1119 favorites []*entity.Favorite
1120 folders []*entity.Folder
1121 tags []*entity.Tag
1122
1123 createdFavorite dto.FavoriteCreateInput
1124 updatedFavorite dto.FavoriteUpdateInput
1125 deletedFavorite int64
1126 createdFolder string
1127 createdFolderIcon string
1128 updatedFolderID int64
1129 deletedFolderID int64
1130 createdTag string
1131 updatedTagID int64
1132 deletedTagID int64
1133 assignedFavoriteID int64
1134 assignedTagID int64
1135 removedFavoriteID int64
1136 removedTagID int64
1137}
1138
1139func (s *fakeFavoritesService) List(context.Context) ([]*entity.Favorite, error) {
1140 s.calledList = true
1141 return s.favorites, nil
1142}
1143
1144func (s *fakeFavoritesService) ListFolders(context.Context) ([]*entity.Folder, error) {
1145 s.calledFolders = true
1146 return s.folders, nil
1147}
1148
1149func (s *fakeFavoritesService) ListTags(context.Context) ([]*entity.Tag, error) {
1150 s.calledTags = true
1151 return s.tags, nil
1152}
1153
1154func (s *fakeFavoritesService) CreateFavorite(_ context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) {
1155 s.createdFavorite = input
1156 return &entity.Favorite{ID: 99, URL: input.URL, Title: input.Title, FolderID: input.FolderID}, nil
1157}
1158
1159func (s *fakeFavoritesService) UpdateFavorite(_ context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error) {
1160 s.updatedFavorite = input
1161 return &entity.Favorite{ID: input.ID, URL: "https://example.com", Title: input.Title, FaviconURL: input.FaviconURL, FolderID: input.FolderID, ShortcutKey: input.ShortcutKey}, nil
1162}
1163
1164func (s *fakeFavoritesService) DeleteFavorite(_ context.Context, id int64) error {
1165 s.deletedFavorite = id
1166 return nil
1167}
1168
1169func (s *fakeFavoritesService) SetShortcut(context.Context, int64, *int) error { return nil }
1170
1171func (s *fakeFavoritesService) SetFolder(context.Context, int64, *int64) error { return nil }
1172
1173func (s *fakeFavoritesService) CreateFolder(_ context.Context, name, icon string, _ *int64) (*entity.Folder, error) {
1174 s.createdFolder = name
1175 s.createdFolderIcon = icon
1176 return &entity.Folder{ID: 77, Name: name, Icon: icon}, nil
1177}
1178
1179func (s *fakeFavoritesService) UpdateFolder(_ context.Context, id int64, _, _ string) error {
1180 s.updatedFolderID = id
1181 return nil
1182}
1183
1184func (s *fakeFavoritesService) DeleteFolder(_ context.Context, id int64) error {
1185 s.deletedFolderID = id
1186 return nil
1187}
1188
1189func (s *fakeFavoritesService) CreateTag(_ context.Context, name, color string) (*entity.Tag, error) {
1190 s.createdTag = name
1191 return &entity.Tag{ID: 55, Name: name, Color: color}, nil
1192}
1193
1194func (s *fakeFavoritesService) UpdateTag(_ context.Context, id int64, _, _ string) error {
1195 s.updatedTagID = id
1196 return nil
1197}
1198
1199func (s *fakeFavoritesService) DeleteTag(_ context.Context, id int64) error {
1200 s.deletedTagID = id
1201 return nil
1202}
1203
1204func (s *fakeFavoritesService) AssignTag(_ context.Context, favoriteID, tagID int64) error {
1205 s.assignedFavoriteID = favoriteID
1206 s.assignedTagID = tagID
1207 return nil
1208}
1209
1210func (s *fakeFavoritesService) RemoveTag(_ context.Context, favoriteID, tagID int64) error {
1211 s.removedFavoriteID = favoriteID
1212 s.removedTagID = tagID
1213 return nil
1214}
1215
1216// Handwritten fake to capture config state for stateful render assertions.
1217type fakeConfigService struct {
1218 calledCurrent bool
1219 calledDefault bool
1220 calledSave bool
1221 calledKeybindings bool
1222 calledSet bool
1223 calledReset bool
1224 calledResetAll bool
1225
1226 current dto.SystemviewConfigPayload
1227 defaultCfg dto.SystemviewConfigPayload
1228 keybindings port.KeybindingsConfig
1229 savedConfig dto.WebUIConfig
1230 setReq port.SetKeybindingRequest
1231 setResp port.SetKeybindingResponse
1232 resetReq port.ResetKeybindingRequest
1233}
1234
1235func (s *fakeConfigService) Current(context.Context) (dto.SystemviewConfigPayload, error) {
1236 s.calledCurrent = true
1237 return s.current, nil
1238}
1239
1240func (s *fakeConfigService) Default(context.Context) (dto.SystemviewConfigPayload, error) {
1241 s.calledDefault = true
1242 return s.defaultCfg, nil
1243}
1244
1245func (s *fakeConfigService) Save(_ context.Context, cfg dto.WebUIConfig) error {
1246 s.calledSave = true
1247 s.savedConfig = cfg
1248 s.current.Appearance = cfg.Appearance
1249 s.current.DefaultUIScale = cfg.DefaultUIScale
1250 s.current.DefaultSearchEngine = cfg.DefaultSearchEngine
1251 s.current.SearchShortcuts = cfg.SearchShortcuts
1252 s.current.Performance.Profile = cfg.Performance.Profile
1253 s.current.Performance.Custom = dto.SystemviewCustomPerformancePayload{
1254 SkiaCPUThreads: cfg.Performance.Custom.SkiaCPUThreads,
1255 SkiaGPUThreads: cfg.Performance.Custom.SkiaGPUThreads,
1256 WebProcessMemoryMB: cfg.Performance.Custom.WebProcessMemoryMB,
1257 NetworkProcessMemoryMB: cfg.Performance.Custom.NetworkProcessMemoryMB,
1258 WebViewPoolPrewarm: cfg.Performance.Custom.WebViewPoolPrewarm,
1259 }
1260 return nil
1261}
1262
1263func (s *fakeConfigService) GetKeybindings(context.Context) (port.KeybindingsConfig, error) {
1264 s.calledKeybindings = true
1265 return s.keybindings, nil
1266}
1267
1268func (s *fakeConfigService) SetKeybinding(_ context.Context, req port.SetKeybindingRequest) (port.SetKeybindingResponse, error) {
1269 s.calledSet = true
1270 s.setReq = req
1271 return s.setResp, nil
1272}
1273
1274func (s *fakeConfigService) ResetKeybinding(_ context.Context, req port.ResetKeybindingRequest) error {
1275 s.calledReset = true
1276 s.resetReq = req
1277 return nil
1278}
1279
1280func (s *fakeConfigService) ResetAllKeybindings(context.Context) error {
1281 s.calledResetAll = true
1282 return nil
1283}
1284
1285func TestHandleFavoriteActionValidatesCreateURL(t *testing.T) {
1286 app := NewApp(Dependencies{Favorites: &fakeFavoritesService{}})
1287
1288 err := app.handleFavoriteAction(context.Background(), DOMAction{
1289 Action: favoriteActionCreate,
1290 Data: map[string]string{"url": " ", "title": "Blank"},
1291 })
1292 require.Error(t, err)
1293 assert.Contains(t, err.Error(), "favorite URL is required")
1294
1295 err = app.handleFavoriteAction(context.Background(), DOMAction{
1296 Action: favoriteActionCreate,
1297 Data: map[string]string{"url": "example.com", "title": "Relative"},
1298 })
1299 require.Error(t, err)
1300 assert.Contains(t, err.Error(), "favorite URL must be absolute")
1301
1302 err = app.handleFavoriteAction(context.Background(), DOMAction{
1303 Action: favoriteActionCreate,
1304 Data: map[string]string{"url": "javascript:alert(1)", "title": "Script"},
1305 })
1306 require.Error(t, err)
1307 assert.Contains(t, err.Error(), "favorite URL must use http, https, or dumb scheme")
1308}
1309
1310func TestHandleFavoriteCreateAcceptsInternalDumbRoutes(t *testing.T) {
1311 favorites := &fakeFavoritesService{}
1312 app := NewApp(Dependencies{Favorites: favorites})
1313
1314 require.NoError(t, app.handleFavoriteAction(context.Background(), DOMAction{
1315 Action: favoriteActionCreate,
1316 Data: map[string]string{"url": "dumb:history", "title": "History"},
1317 }))
1318 assert.Equal(t, "dumb:history", favorites.createdFavorite.URL)
1319
1320 require.NoError(t, app.handleFavoriteAction(context.Background(), DOMAction{
1321 Action: favoriteActionCreate,
1322 Data: map[string]string{"url": "dumb://config", "title": "Config"},
1323 }))
1324 assert.Equal(t, "dumb://config", favorites.createdFavorite.URL)
1325}
1326
1327func TestHandleFavoriteCreateRejectsUnknownDumbRoutes(t *testing.T) {
1328 favorites := &fakeFavoritesService{}
1329 app := NewApp(Dependencies{Favorites: favorites})
1330
1331 err := app.handleFavoriteAction(context.Background(), DOMAction{
1332 Action: favoriteActionCreate,
1333 Data: map[string]string{"url": "dumb:unknown", "title": "Unknown"},
1334 })
1335
1336 require.Error(t, err)
1337 assert.Contains(t, err.Error(), "known systemview route")
1338}
1339
1340func TestParsePositiveInt64DistinguishesParseAndRangeErrors(t *testing.T) {
1341 _, err := parsePositiveInt64("abc", "tag id")
1342 require.Error(t, err)
1343 assert.Contains(t, err.Error(), "invalid tag id")
1344 assert.Contains(t, err.Error(), "invalid syntax")
1345
1346 _, err = parsePositiveInt64("0", "tag id")
1347 require.Error(t, err)
1348 assert.Contains(t, err.Error(), "tag id must be a positive integer")
1349}
1350
1351func TestHandleFavoriteTagActionsAcceptSnakeCaseIDs(t *testing.T) {
1352 favorites := &fakeFavoritesService{}
1353 app := NewApp(Dependencies{Favorites: favorites})
1354
1355 require.NoError(t, app.handleFavoriteAction(context.Background(), DOMAction{
1356 Action: tagActionAssign,
1357 Data: map[string]string{"favorite_id": "42", "tag_id": "7"},
1358 }))
1359 assert.Equal(t, int64(42), favorites.assignedFavoriteID)
1360 assert.Equal(t, int64(7), favorites.assignedTagID)
1361
1362 require.NoError(t, app.handleFavoriteAction(context.Background(), DOMAction{
1363 Action: tagActionRemove,
1364 Data: map[string]string{"favorite_id": "42", "tag_id": "7"},
1365 }))
1366 assert.Equal(t, int64(42), favorites.removedFavoriteID)
1367 assert.Equal(t, int64(7), favorites.removedTagID)
1368}
1369
1370func TestHandleHistorySearchClearsDomainFilter(t *testing.T) {
1371 dom := &fakeDOM{}
1372 history := &fakeHistoryService{searchEntries: []*entity.HistoryEntry{{ID: 7, URL: "https://search.example", Title: "Search result"}}}
1373 app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})
1374 app.historyDomainFilter = "example.com"
1375
1376 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
1377 Action: historyActionSearch,
1378 Data: map[string]string{"query": "search"},
1379 }))
1380
1381 assert.Equal(t, "search", app.historyQuery)
1382 assert.Empty(t, app.historyDomainFilter)
1383 assert.True(t, history.searchCalled)
1384 assert.False(t, history.domainCalled)
1385}
1386
1387func TestAppRunWithContextRejectsNilContext(t *testing.T) {
1388 app := NewApp(Dependencies{DOM: &fakeDOM{}, LocationURI: "dumb://history"})
1389
1390 var ctx context.Context
1391 err := app.RunWithContext(ctx)
1392
1393 require.ErrorContains(t, err, "context is nil")
1394}
1395
1396func TestAppRunWithContextRequiresDOMBeforeLoading(t *testing.T) {
1397 history := &fakeHistoryService{entries: []*entity.HistoryEntry{{ID: 1, URL: "https://example.com"}}}
1398 app := NewApp(Dependencies{History: history, LocationURI: "dumb://history"})
1399
1400 err := app.RunWithContext(context.Background())
1401
1402 require.ErrorContains(t, err, "DOM not configured")
1403 assert.False(t, history.called)
1404}
1405
1406func TestHandleActionsRejectUnknownAction(t *testing.T) {
1407 app := NewApp(Dependencies{
1408 History: &fakeHistoryService{},
1409 Favorites: &fakeFavoritesService{},
1410 Config: &fakeConfigService{current: testConfigPayload()},
1411 })
1412
1413 require.ErrorContains(t, app.handleHistoryAction(context.Background(), DOMAction{Action: "history.unknown"}), "unknown history action")
1414 require.ErrorContains(t, app.handleFavoriteAction(context.Background(), DOMAction{Action: "favorite.unknown"}), "unknown favorite action")
1415 require.ErrorContains(t, app.handleConfigAction(context.Background(), DOMAction{Action: "config.unknown"}), "unknown config action")
1416}
1417
1418func TestSavePerformanceConfigRejectsOutOfRangeValues(t *testing.T) {
1419 service := &fakeConfigService{current: testConfigPayload()}
1420 app := NewApp(Dependencies{Config: service})
1421
1422 err := app.handleConfigAction(context.Background(), DOMAction{
1423 Action: configActionSavePerformance,
1424 Data: map[string]string{
1425 "profile": "custom", "skia_cpu_threads": "65", "skia_gpu_threads": "2",
1426 "web_process_memory_mb": "2048", "network_process_memory_mb": "512", "webview_pool_prewarm": "4",
1427 },
1428 })
1429
1430 require.ErrorContains(t, err, "Skia CPU threads must be between 0 and 8")
1431 assert.False(t, service.calledSave)
1432}