@@ -35,10 +35,16 @@ import { useRestoreStore } from "../stores/restore";
3535import type {
3636 FileSearchHitDto ,
3737 RemoteEntryDto ,
38+ RemoteTreeDto ,
3839 RestoreJobStatus ,
3940 SourceDto ,
4041} from "../ipc/types" ;
4142
43+ /** Wrap entries as the RemoteTreeDto the backend now returns (M8-P2-1). */
44+ function tree ( entries : RemoteEntryDto [ ] , truncated = false ) : RemoteTreeDto {
45+ return { entries, truncated } ;
46+ }
47+
4248function source ( id : string , name : string ) : SourceDto {
4349 return {
4450 id,
@@ -98,7 +104,7 @@ describe("restore store", () => {
98104 if ( cmd === "list_sources" )
99105 return Promise . resolve ( [ source ( "s1" , "Documents" ) ] ) ;
100106 if ( cmd === "list_remote_tree" )
101- return Promise . resolve ( [ folder ( "src" ) , file ( "a.txt" ) ] ) ;
107+ return Promise . resolve ( tree ( [ folder ( "src" ) , file ( "a.txt" ) ] ) ) ;
102108 return Promise . resolve ( [ ] ) ;
103109 } ) ;
104110
@@ -127,10 +133,10 @@ describe("restore store", () => {
127133 return Promise . resolve ( [ source ( "s1" , "Documents" ) ] ) ;
128134 if ( cmd === "list_remote_tree" ) {
129135 const prefix = ( args as { prefix : string } ) . prefix ;
130- if ( prefix === "" ) return Promise . resolve ( [ folder ( "src" ) ] ) ;
136+ if ( prefix === "" ) return Promise . resolve ( tree ( [ folder ( "src" ) ] ) ) ;
131137 if ( prefix === "src" )
132- return Promise . resolve ( [ file ( "main.rs" , "src" ) ] ) ;
133- return Promise . resolve ( [ ] ) ;
138+ return Promise . resolve ( tree ( [ file ( "main.rs" , "src" ) ] ) ) ;
139+ return Promise . resolve ( tree ( [ ] ) ) ;
134140 }
135141 return Promise . resolve ( [ ] ) ;
136142 } ) ;
@@ -160,7 +166,7 @@ describe("restore store", () => {
160166 invokeMock . mockImplementation ( ( cmd : string ) => {
161167 if ( cmd === "list_sources" )
162168 return Promise . resolve ( [ source ( "s1" , "Documents" ) ] ) ;
163- if ( cmd === "list_remote_tree" ) return Promise . resolve ( [ ] ) ;
169+ if ( cmd === "list_remote_tree" ) return Promise . resolve ( tree ( [ ] ) ) ;
164170 if ( cmd === "search_files" ) return Promise . resolve ( hits ) ;
165171 return Promise . resolve ( [ ] ) ;
166172 } ) ;
@@ -186,7 +192,7 @@ describe("restore store", () => {
186192 if ( cmd === "list_sources" )
187193 return Promise . resolve ( [ source ( "s1" , "Documents" ) ] ) ;
188194 if ( cmd === "list_remote_tree" )
189- return Promise . resolve ( [ file ( "a.txt" ) , file ( "b.txt" ) ] ) ;
195+ return Promise . resolve ( tree ( [ file ( "a.txt" ) , file ( "b.txt" ) ] ) ) ;
190196 return Promise . resolve ( [ ] ) ;
191197 } ) ;
192198 const store = useRestoreStore ( ) ;
@@ -210,12 +216,35 @@ describe("restore store", () => {
210216 } ) ;
211217
212218 it ( "restores selected files with a dialog token and accumulates progress to done" , async ( ) => {
219+ // The seeded snapshot getRestoreJob returns right after start (before any
220+ // live tick) - the reconcile path (M8-P2-4).
221+ const seeded : RestoreJobStatus = {
222+ jobId : "job-1" ,
223+ totalFiles : 1 ,
224+ completedFiles : 0 ,
225+ failedFiles : 0 ,
226+ totalBytes : 100 ,
227+ bytesDone : 0 ,
228+ currentFile : null ,
229+ done : false ,
230+ cancelled : false ,
231+ files : [
232+ {
233+ relativePath : "secret.bin" ,
234+ state : "pending" ,
235+ bytesDone : 0 ,
236+ bytesTotal : 100 ,
237+ errorCode : null ,
238+ } ,
239+ ] ,
240+ } ;
213241 invokeMock . mockImplementation ( ( cmd : string ) => {
214242 if ( cmd === "list_sources" )
215243 return Promise . resolve ( [ source ( "s1" , "Documents" ) ] ) ;
216244 if ( cmd === "list_remote_tree" )
217- return Promise . resolve ( [ file ( "secret.bin" ) ] ) ;
245+ return Promise . resolve ( tree ( [ file ( "secret.bin" ) ] ) ) ;
218246 if ( cmd === "restore_files" ) return Promise . resolve ( "job-1" ) ;
247+ if ( cmd === "get_restore_job" ) return Promise . resolve ( seeded ) ;
219248 return Promise . resolve ( [ ] ) ;
220249 } ) ;
221250
@@ -250,6 +279,7 @@ describe("restore store", () => {
250279 bytesDone : 50 ,
251280 currentFile : "secret.bin" ,
252281 done : false ,
282+ cancelled : false ,
253283 files : [
254284 {
255285 relativePath : "secret.bin" ,
@@ -297,4 +327,134 @@ describe("restore store", () => {
297327 store . unsubscribeProgress ( ) ;
298328 expect ( unlistenMock ) . toHaveBeenCalledTimes ( 1 ) ;
299329 } ) ;
330+
331+ it ( "reconciles the active job by id on (re)subscription after a remount (M8-P2-4)" , async ( ) => {
332+ // Simulate: a restore was started (activeJobId set), then the view remounts
333+ // and re-subscribes. subscribeProgress must fetch getRestoreJob(jobId) so the
334+ // current state is recovered even if a terminal event was missed.
335+ const terminal : RestoreJobStatus = {
336+ jobId : "job-9" ,
337+ totalFiles : 1 ,
338+ completedFiles : 1 ,
339+ failedFiles : 0 ,
340+ totalBytes : 100 ,
341+ bytesDone : 100 ,
342+ currentFile : null ,
343+ done : true ,
344+ cancelled : false ,
345+ files : [
346+ {
347+ relativePath : "a.bin" ,
348+ state : "done" ,
349+ bytesDone : 100 ,
350+ bytesTotal : 100 ,
351+ errorCode : null ,
352+ } ,
353+ ] ,
354+ } ;
355+ invokeMock . mockImplementation ( ( cmd : string ) => {
356+ if ( cmd === "list_sources" )
357+ return Promise . resolve ( [ source ( "s1" , "Documents" ) ] ) ;
358+ if ( cmd === "list_remote_tree" )
359+ return Promise . resolve ( tree ( [ file ( "a.bin" ) ] ) ) ;
360+ if ( cmd === "restore_files" ) return Promise . resolve ( "job-9" ) ;
361+ if ( cmd === "get_restore_job" ) return Promise . resolve ( terminal ) ;
362+ return Promise . resolve ( [ ] ) ;
363+ } ) ;
364+
365+ const store = useRestoreStore ( ) ;
366+ await store . loadSources ( ) ;
367+ store . toggleSelect ( "s1" , "a.bin" ) ;
368+ store . setDestination ( "/home/u/restored" , "tok-1" ) ;
369+ await store . startRestore ( ) ;
370+ expect ( store . activeJobId ) . toBe ( "job-9" ) ;
371+
372+ // Remount: unsubscribe then re-subscribe; the re-subscribe reconciles by id.
373+ store . unsubscribeProgress ( ) ;
374+ await store . subscribeProgress ( ) ;
375+
376+ const getCall = invokeMock . mock . calls . find (
377+ ( c ) => c [ 0 ] === "get_restore_job" ,
378+ ) ;
379+ expect ( getCall ?. [ 1 ] ) . toMatchObject ( { job : "job-9" } ) ;
380+ // The reconciled terminal state is reflected (controls re-enabled).
381+ expect ( store . job ?. done ) . toBe ( true ) ;
382+ expect ( store . restoring ) . toBe ( false ) ;
383+ } ) ;
384+
385+ it ( "cancels the active job and reflects a terminal cancelled status (M8-P1-1)" , async ( ) => {
386+ invokeMock . mockImplementation ( ( cmd : string ) => {
387+ if ( cmd === "list_sources" )
388+ return Promise . resolve ( [ source ( "s1" , "Documents" ) ] ) ;
389+ if ( cmd === "list_remote_tree" )
390+ return Promise . resolve ( tree ( [ file ( "big.bin" ) ] ) ) ;
391+ if ( cmd === "restore_files" ) return Promise . resolve ( "job-c" ) ;
392+ if ( cmd === "cancel_restore_job" ) return Promise . resolve ( null ) ;
393+ if ( cmd === "get_restore_job" )
394+ return Promise . resolve ( {
395+ jobId : "job-c" ,
396+ totalFiles : 1 ,
397+ completedFiles : 0 ,
398+ failedFiles : 0 ,
399+ totalBytes : 100 ,
400+ bytesDone : 0 ,
401+ currentFile : "big.bin" ,
402+ done : false ,
403+ cancelled : false ,
404+ files : [
405+ {
406+ relativePath : "big.bin" ,
407+ state : "restoring" ,
408+ bytesDone : 0 ,
409+ bytesTotal : 100 ,
410+ errorCode : null ,
411+ } ,
412+ ] ,
413+ } as RestoreJobStatus ) ;
414+ return Promise . resolve ( [ ] ) ;
415+ } ) ;
416+
417+ const store = useRestoreStore ( ) ;
418+ await store . subscribeProgress ( ) ;
419+ await store . loadSources ( ) ;
420+ store . toggleSelect ( "s1" , "big.bin" ) ;
421+ store . setDestination ( "/home/u/restored" , "tok-c" ) ;
422+ await store . startRestore ( ) ;
423+ expect ( store . activeJobId ) . toBe ( "job-c" ) ;
424+
425+ // Request cancel: the cancel IPC is invoked with the job id; cancelling gates.
426+ await store . cancelRestore ( ) ;
427+ const cancelCall = invokeMock . mock . calls . find (
428+ ( c ) => c [ 0 ] === "cancel_restore_job" ,
429+ ) ;
430+ expect ( cancelCall ?. [ 1 ] ) . toMatchObject ( { job : "job-c" } ) ;
431+ expect ( store . cancelling ) . toBe ( true ) ;
432+
433+ // The backend emits a terminal CANCELLED status; the store clears the flags.
434+ const cancelled : RestoreJobStatus = {
435+ jobId : "job-c" ,
436+ totalFiles : 1 ,
437+ completedFiles : 0 ,
438+ failedFiles : 0 ,
439+ totalBytes : 100 ,
440+ bytesDone : 30 ,
441+ currentFile : null ,
442+ done : true ,
443+ cancelled : true ,
444+ files : [
445+ {
446+ relativePath : "big.bin" ,
447+ state : "cancelled" ,
448+ bytesDone : 30 ,
449+ bytesTotal : 100 ,
450+ errorCode : null ,
451+ } ,
452+ ] ,
453+ } ;
454+ progressHandler ?.( cancelled ) ;
455+ expect ( store . job ?. cancelled ) . toBe ( true ) ;
456+ expect ( store . job ?. done ) . toBe ( true ) ;
457+ expect ( store . restoring ) . toBe ( false ) ;
458+ expect ( store . cancelling ) . toBe ( false ) ;
459+ } ) ;
300460} ) ;
0 commit comments