@@ -14,20 +14,49 @@ vi.mock("@tauri-apps/api/core", () => ({
1414 invoke : ( cmd : string , args ?: unknown ) => invokeMock ( cmd , args ) ,
1515} ) ) ;
1616
17- // The live-tail seam: capture the handler `onActivityNew` registers so the test
18- // can fire `activity:new` events on demand. `listen` returns an unlisten fn.
17+ // The live-tail seam: capture the handlers `onActivityNew` / `onActivityLagged`
18+ // register so the test can fire `activity:new` + `activity:lagged` on demand.
19+ // `listen` returns an unlisten fn. M7-P2-3: each `listen` call resolves to a
20+ // DISTINCT unlisten so the unsubscribe-before-resolve test can assert teardown.
1921let liveHandler : ( ( payload : unknown ) => void ) | null = null ;
20- const unlistenMock = vi . fn ( ) ;
22+ let laggedHandler : ( ( ) => void ) | null = null ;
23+ const unlistenNewMock = vi . fn ( ) ;
24+ const unlistenLaggedMock = vi . fn ( ) ;
25+ // Allows a test to defer `listen` resolution (the leak-on-unmount race). Each
26+ // blocked `listen` call parks its own resolver; `flushListen()` releases all.
27+ let pendingResolvers : Array < ( ) => void > = [ ] ;
28+ let blockListen = false ;
29+ function flushListen ( ) : void {
30+ const resolvers = pendingResolvers ;
31+ pendingResolvers = [ ] ;
32+ for ( const r of resolvers ) r ( ) ;
33+ }
2134vi . mock ( "@tauri-apps/api/event" , ( ) => ( {
22- listen : vi . fn ( ( event : string , cb : ( e : { payload : unknown } ) => void ) => {
23- if ( event === "activity:new" ) {
24- liveHandler = ( payload : unknown ) => cb ( { payload } ) ;
25- }
26- return Promise . resolve ( unlistenMock ) ;
27- } ) ,
35+ listen : vi . fn (
36+ async ( event : string , cb : ( e : { payload : unknown } ) => void ) => {
37+ if ( blockListen ) {
38+ await new Promise < void > ( ( res ) => {
39+ pendingResolvers . push ( res ) ;
40+ } ) ;
41+ }
42+ if ( event === "activity:new" ) {
43+ liveHandler = ( payload : unknown ) => cb ( { payload } ) ;
44+ return unlistenNewMock ;
45+ }
46+ if ( event === "activity:lagged" ) {
47+ laggedHandler = ( ) => cb ( { payload : null } ) ;
48+ return unlistenLaggedMock ;
49+ }
50+ return vi . fn ( ) ;
51+ } ,
52+ ) ,
2853} ) ) ;
2954
30- import { useActivityStore , ACTIVITY_PAGE_SIZE } from "../stores/activity" ;
55+ import {
56+ useActivityStore ,
57+ ACTIVITY_PAGE_SIZE ,
58+ LIVE_TAIL_CAP ,
59+ } from "../stores/activity" ;
3160import type { ActivityEntry } from "../ipc/types" ;
3261
3362function makeEntry ( over : Partial < ActivityEntry > = { } ) : ActivityEntry {
@@ -69,8 +98,12 @@ function makePage(
6998beforeEach ( ( ) => {
7099 setActivePinia ( createPinia ( ) ) ;
71100 invokeMock . mockReset ( ) ;
72- unlistenMock . mockReset ( ) ;
101+ unlistenNewMock . mockReset ( ) ;
102+ unlistenLaggedMock . mockReset ( ) ;
73103 liveHandler = null ;
104+ laggedHandler = null ;
105+ pendingResolvers = [ ] ;
106+ blockListen = false ;
74107} ) ;
75108
76109describe ( "activity store: pagination" , ( ) => {
@@ -182,11 +215,101 @@ describe("activity store: live tail", () => {
182215 expect ( store . entries . map ( ( e ) => e . id ) ) . toEqual ( [ 10 ] ) ;
183216 } ) ;
184217
185- it ( "unsubscribeLive calls the unlisten fn " , async ( ) => {
218+ it ( "unsubscribeLive calls BOTH unlisten fns (new + lagged) " , async ( ) => {
186219 const store = useActivityStore ( ) ;
187220 await store . subscribeLive ( ) ;
188221 store . unsubscribeLive ( ) ;
189- expect ( unlistenMock ) . toHaveBeenCalledTimes ( 1 ) ;
222+ expect ( unlistenNewMock ) . toHaveBeenCalledTimes ( 1 ) ;
223+ expect ( unlistenLaggedMock ) . toHaveBeenCalledTimes ( 1 ) ;
224+ } ) ;
225+
226+ // M7-P2-3: unsubscribe-before-resolve must not leak a listener.
227+ it ( "tears down listeners that resolve AFTER unsubscribe (no leak)" , async ( ) => {
228+ blockListen = true ;
229+ const store = useActivityStore ( ) ;
230+ // Start subscribing; `listen` is blocked, so it has not resolved yet.
231+ const pending = store . subscribeLive ( ) ;
232+ // The view unmounts before the listeners resolve.
233+ store . unsubscribeLive ( ) ;
234+ // Now let the blocked `listen` calls resolve.
235+ blockListen = false ;
236+ flushListen ( ) ;
237+ await pending ;
238+ // Both resolved unlisten fns were invoked immediately on arrival.
239+ expect ( unlistenNewMock ) . toHaveBeenCalledTimes ( 1 ) ;
240+ expect ( unlistenLaggedMock ) . toHaveBeenCalledTimes ( 1 ) ;
241+ } ) ;
242+ } ) ;
243+
244+ describe ( "activity store: lag reconcile (M7-P1-1)" , ( ) => {
245+ it ( "activity:lagged re-queries page 0 and merges dropped rows without duplicates" , async ( ) => {
246+ // Initial page 0 has rows 5 and 4.
247+ invokeMock . mockResolvedValueOnce (
248+ makePage ( [ makeEntry ( { id : 5 , ts : 500 } ) , makeEntry ( { id : 4 , ts : 400 } ) ] , 0 , 2 ) ,
249+ ) ;
250+ const store = useActivityStore ( ) ;
251+ await store . subscribeLive ( ) ;
252+ await store . loadInitial ( ) ;
253+ expect ( store . entries . map ( ( e ) => e . id ) ) . toEqual ( [ 5 , 4 ] ) ;
254+
255+ // A burst happened and the live broadcast lagged: the durable log now also
256+ // has rows 7 and 6 (dropped from the live tail). The reconcile re-query
257+ // returns the newest page including the already-present 5 + the new 7, 6.
258+ invokeMock . mockResolvedValueOnce (
259+ makePage (
260+ [
261+ makeEntry ( { id : 7 , ts : 700 } ) ,
262+ makeEntry ( { id : 6 , ts : 600 } ) ,
263+ makeEntry ( { id : 5 , ts : 500 } ) ,
264+ ] ,
265+ 0 ,
266+ 4 ,
267+ ) ,
268+ ) ;
269+ laggedHandler ?.( ) ;
270+ // Let the async reconcile settle.
271+ await Promise . resolve ( ) ;
272+ await Promise . resolve ( ) ;
273+
274+ const ids = store . entries . map ( ( e ) => e . id ) ;
275+ // No duplicate of id 5; the dropped 7 + 6 are recovered, newest-first.
276+ expect ( ids ) . toEqual ( [ 7 , 6 , 5 , 4 ] ) ;
277+ expect ( ids . filter ( ( i ) => i === 5 ) ) . toHaveLength ( 1 ) ;
278+ } ) ;
279+ } ) ;
280+
281+ describe ( "activity store: live-tail cap (M7-P2-2)" , ( ) => {
282+ it ( "caps the live tail to LIVE_TAIL_CAP, evicting oldest live events" , async ( ) => {
283+ invokeMock . mockResolvedValueOnce ( makePage ( [ ] , 0 , 0 ) ) ;
284+ const store = useActivityStore ( ) ;
285+ await store . subscribeLive ( ) ;
286+ await store . loadInitial ( ) ;
287+
288+ // Push CAP + 50 live events (ids 1..CAP+50, ascending ts).
289+ const overflow = 50 ;
290+ for ( let i = 1 ; i <= LIVE_TAIL_CAP + overflow ; i ++ ) {
291+ liveHandler ?.( makeEntry ( { id : i , ts : i } ) ) ;
292+ }
293+ // The store is bounded to the cap (oldest live entries evicted).
294+ expect ( store . entries ) . toHaveLength ( LIVE_TAIL_CAP ) ;
295+ // Newest is the last pushed; the oldest retained is id overflow+1.
296+ expect ( store . entries [ 0 ] . id ) . toBe ( LIVE_TAIL_CAP + overflow ) ;
297+ expect ( store . entries [ store . entries . length - 1 ] . id ) . toBe ( overflow + 1 ) ;
298+ } ) ;
299+
300+ it ( "does NOT evict explicitly loaded history pages" , async ( ) => {
301+ // One history row (id 1). Then flood the live tail past the cap.
302+ invokeMock . mockResolvedValueOnce ( makePage ( [ makeEntry ( { id : 1 , ts : 1 } ) ] , 0 , 1 ) ) ;
303+ const store = useActivityStore ( ) ;
304+ await store . subscribeLive ( ) ;
305+ await store . loadInitial ( ) ;
306+
307+ for ( let i = 2 ; i <= LIVE_TAIL_CAP + 100 ; i ++ ) {
308+ liveHandler ?.( makeEntry ( { id : i , ts : i } ) ) ;
309+ }
310+ // Live tail capped at CAP, but the loaded history row survives at the tail.
311+ expect ( store . entries . length ) . toBe ( LIVE_TAIL_CAP + 1 ) ;
312+ expect ( store . entries [ store . entries . length - 1 ] . id ) . toBe ( 1 ) ;
190313 } ) ;
191314} ) ;
192315
@@ -221,14 +344,95 @@ describe("activity store: empty state", () => {
221344 await store . loadInitial ( ) ;
222345 expect ( store . entries ) . toHaveLength ( 0 ) ;
223346 expect ( store . isEmpty ) . toBe ( true ) ;
224- expect ( store . error ) . toBeNull ( ) ;
347+ expect ( store . errorCode ) . toBeNull ( ) ;
225348 } ) ;
226349
227350 it ( "isEmpty is false when an error occurred" , async ( ) => {
228351 invokeMock . mockRejectedValueOnce ( new Error ( "db locked" ) ) ;
229352 const store = useActivityStore ( ) ;
230353 await store . loadInitial ( ) ;
231- expect ( store . error ) . toContain ( "db locked" ) ;
354+ // M7-P2-6: a plain Error (no `.code`) normalizes to internal.bug.
355+ expect ( store . errorCode ) . toBe ( "internal.bug" ) ;
232356 expect ( store . isEmpty ) . toBe ( false ) ;
233357 } ) ;
234358} ) ;
359+
360+ describe ( "activity store: coded errors (M7-P2-6)" , ( ) => {
361+ it ( "surfaces the stable SPEC s24 code from a Tauri object error" , async ( ) => {
362+ invokeMock . mockRejectedValueOnce ( {
363+ code : "state.db_locked" ,
364+ message : "Driven's database is briefly locked" ,
365+ } ) ;
366+ const store = useActivityStore ( ) ;
367+ await store . loadInitial ( ) ;
368+ expect ( store . errorCode ) . toBe ( "state.db_locked" ) ;
369+ } ) ;
370+ } ) ;
371+
372+ describe ( "activity store: request token (M7-P2-1)" , ( ) => {
373+ it ( "discards a stale page response after the filter changed mid-flight" , async ( ) => {
374+ const store = useActivityStore ( ) ;
375+ await store . subscribeLive ( ) ;
376+
377+ // First load (default filter) is slow to resolve; capture its resolver.
378+ let resolveFirst : ( v : unknown ) => void = ( ) => { } ;
379+ invokeMock . mockImplementationOnce (
380+ ( ) =>
381+ new Promise ( ( res ) => {
382+ resolveFirst = res ;
383+ } ) ,
384+ ) ;
385+ const firstLoad = store . loadInitial ( ) ;
386+
387+ // While in flight, the user applies an error-only filter, which re-queries.
388+ invokeMock . mockResolvedValueOnce (
389+ makePage ( [ makeEntry ( { id : 99 , level : "error" } ) ] , 0 , 1 ) ,
390+ ) ;
391+ await store . applyFilter ( { minLevel : "error" } ) ;
392+ expect ( store . entries . map ( ( e ) => e . id ) ) . toEqual ( [ 99 ] ) ;
393+
394+ // Now the STALE first response (default-filter rows) resolves - it must be
395+ // discarded, not appended over the current filtered result.
396+ resolveFirst ( makePage ( [ makeEntry ( { id : 1 } ) , makeEntry ( { id : 2 } ) ] , 0 , 250 ) ) ;
397+ await firstLoad ;
398+
399+ expect ( store . entries . map ( ( e ) => e . id ) ) . toEqual ( [ 99 ] ) ;
400+ expect ( store . total ) . toBe ( 1 ) ;
401+ expect ( store . loadedPage ) . toBe ( 0 ) ;
402+ } ) ;
403+ } ) ;
404+
405+ describe ( "activity store: backend facets + summary (M7-P2-4, P2-5)" , ( ) => {
406+ it ( "loadEventTypeOptions populates the dropdown source from the backend" , async ( ) => {
407+ invokeMock . mockResolvedValueOnce ( [ "paused" , "scan_done" , "upload_done" ] ) ;
408+ const store = useActivityStore ( ) ;
409+ await store . loadEventTypeOptions ( ) ;
410+ expect ( invokeMock ) . toHaveBeenCalledWith (
411+ "distinct_activity_event_types" ,
412+ undefined ,
413+ ) ;
414+ expect ( store . eventTypeOptions ) . toEqual ( [
415+ "paused" ,
416+ "scan_done" ,
417+ "upload_done" ,
418+ ] ) ;
419+ } ) ;
420+
421+ it ( "loadSummary stores the header aggregates" , async ( ) => {
422+ const summary = {
423+ bytesToday : 1024 ,
424+ bytesWeek : 4096 ,
425+ fileStatusCounts : [ { status : "synced" , count : 3 } ] ,
426+ throughputWindowBytes : 512 ,
427+ throughputWindowMs : 60000 ,
428+ } ;
429+ invokeMock . mockResolvedValueOnce ( summary ) ;
430+ const store = useActivityStore ( ) ;
431+ await store . loadSummary ( ) ;
432+ expect ( invokeMock ) . toHaveBeenCalledWith (
433+ "activity_summary" ,
434+ expect . objectContaining ( { throughputWindowMs : 60000 } ) ,
435+ ) ;
436+ expect ( store . summary ) . toEqual ( summary ) ;
437+ } ) ;
438+ } ) ;
0 commit comments