@@ -69,6 +69,10 @@ const fileMocks = vi.hoisted(() => ({
6969 delete : vi . fn ( ) ,
7070} ) ) ;
7171
72+ const coreMocks = vi . hoisted ( ( ) => ( {
73+ generateUUID : vi . fn ( ) ,
74+ } ) ) ;
75+
7276const appLogMock = vi . hoisted ( ( ) => ( {
7377 logInfo : vi . fn ( ) ,
7478} ) ) ;
@@ -146,7 +150,7 @@ vi.mock('@mindwtr/core', async (importOriginal) => {
146150 // Only the id and clock are pinned, so attachment names stay deterministic;
147151 // `buildTaskUpdatesFromSpeechResult` runs for real.
148152 return mockCore ( importOriginal , ( ) => ( { } ) , {
149- generateUUID : ( ) => 'attachment-1' ,
153+ generateUUID : coreMocks . generateUUID ,
150154 safeFormatDate : ( _value : Date | string , format : string ) => {
151155 if ( format === 'yyyyMMdd-HHmmss' ) return '20260629-090027' ;
152156 if ( format === 'Pp' ) return '06/29/2026, 9:00 AM' ;
@@ -269,6 +273,8 @@ describe('useQuickCaptureAudio', () => {
269273 latest = null ;
270274 submissionCoordinator = new CaptureSessionCoordinator ( ) ;
271275 activeSubmissionSession = submissionCoordinator . beginSession ( ) ;
276+ let uuidSequence = 0 ;
277+ coreMocks . generateUUID . mockImplementation ( ( ) => `capture-${ uuidSequence += 1 } ` ) ;
272278 storeMocks . state . areas = [ ] ;
273279 storeMocks . state . projects = [ ] ;
274280 storeMocks . state . settings = settings ;
@@ -280,9 +286,9 @@ describe('useQuickCaptureAudio', () => {
280286 audioMocks . release . mockReturnValue ( undefined ) ;
281287 audioMocks . stop . mockResolvedValue ( undefined ) ;
282288 attachmentMocks . getAttachmentsDir . mockResolvedValue ( 'file:///document/attachments/' ) ;
283- attachmentMocks . persistAttachmentLocally . mockImplementation ( async ( attachment : { uri : string } ) => ( {
289+ attachmentMocks . persistAttachmentLocally . mockImplementation ( async ( attachment : { id : string ; uri : string } ) => ( {
284290 ...attachment ,
285- uri : ' file:///document/attachments/attachment-1. wav' ,
291+ uri : ` file:///document/attachments/${ attachment . id } . wav` ,
286292 } ) ) ;
287293 buildTaskProps . mockImplementation ( async ( fallbackTitle : string , extraProps ?: Record < string , unknown > ) => ( {
288294 title : fallbackTitle ,
@@ -339,6 +345,7 @@ describe('useQuickCaptureAudio', () => {
339345 // so this line asserted behaviour the app does not have.
340346 expect ( storeMocks . updateTask ) . toHaveBeenCalledWith ( 'task-1' , { title : 'Buy milk' } ) ;
341347 expect ( handleClose ) . toHaveBeenCalledOnce ( ) ;
348+ expect ( fileMocks . delete ) . not . toHaveBeenCalled ( ) ;
342349 } ) ;
343350
344351 it ( 'shows a notice and never starts the recorder when speech-to-text is unconfigured' , async ( ) => {
@@ -694,6 +701,12 @@ describe('useQuickCaptureAudio', () => {
694701 expect ( stopAHandler ) . toHaveBeenCalledTimes ( 1 ) ;
695702 expect ( speechMocks . startWhisperRealtimeCapture ) . toHaveBeenCalledTimes ( 2 ) ;
696703 expect ( audioMocks . setAudioModeAsync ) . toHaveBeenCalledTimes ( 2 ) ;
704+ const firstOutputPath = String ( speechMocks . startWhisperRealtimeCapture . mock . calls [ 0 ] ?. [ 0 ] ) ;
705+ const secondOutputPath = String ( speechMocks . startWhisperRealtimeCapture . mock . calls [ 1 ] ?. [ 0 ] ) ;
706+ expect ( firstOutputPath ) . not . toBe ( secondOutputPath ) ;
707+ expect ( fileMocks . delete ) . toHaveBeenCalledWith ( `file://${ firstOutputPath } ` ) ;
708+ expect ( fileMocks . delete ) . not . toHaveBeenCalledWith ( `file://${ secondOutputPath } ` ) ;
709+ expect ( fileMocks . delete ) . toHaveBeenCalledTimes ( 1 ) ;
697710 expect ( latest ?. recording ) . toEqual ( expect . objectContaining ( { kind : 'whisper' , stop : stopBHandler } ) ) ;
698711 expect ( latest ?. recordingBusy ) . toBe ( false ) ;
699712 } ) ;
@@ -737,5 +750,143 @@ describe('useQuickCaptureAudio', () => {
737750 expect ( handleClose ) . not . toHaveBeenCalled ( ) ;
738751 expect ( submissionCoordinator . isSubmitting ( reopenedSession ) ) . toBe ( true ) ;
739752 expect ( latest ?. recordingBusy ) . toBe ( false ) ;
753+ expect ( fileMocks . delete ) . toHaveBeenCalledWith (
754+ 'file:///document/audio-captures/mindwtr-audio-20260629-090027-capture-1.wav' ,
755+ ) ;
756+ expect ( fileMocks . delete ) . toHaveBeenCalledWith (
757+ 'file:///document/attachments/capture-2.wav' ,
758+ ) ;
759+ expect ( fileMocks . delete ) . toHaveBeenCalledTimes ( 2 ) ;
760+ } ) ;
761+
762+ it ( 'keeps reopened capture settings when stopped audio A resolves its model late' , async ( ) => {
763+ speechMocks . startWhisperRealtimeCapture . mockRejectedValueOnce ( new Error ( 'use Expo fallback' ) ) ;
764+ let tree ! : ReturnType < typeof create > ;
765+ await act ( async ( ) => {
766+ tree = create ( < Harness submissionKey = { 1 } /> ) ;
767+ await flushPromises ( ) ;
768+ } ) ;
769+ await act ( async ( ) => {
770+ await latest ?. startRecording ( ) ;
771+ await flushPromises ( ) ;
772+ } ) ;
773+
774+ const modelResolution = deferred < {
775+ exists : boolean ;
776+ path : string ;
777+ uri : string ;
778+ size : number ;
779+ } > ( ) ;
780+ speechMocks . ensureWhisperModelPathForConfigAsync . mockReturnValueOnce ( modelResolution . promise ) ;
781+ updateSpeechSettings . mockClear ( ) ;
782+ let stopRun ! : Promise < void > ;
783+ await act ( async ( ) => {
784+ stopRun = latest ! . stopRecording ( { saveTask : true } ) ;
785+ await flushPromises ( ) ;
786+ } ) ;
787+
788+ const audioSession = activeSubmissionSession ! ;
789+ submissionCoordinator . invalidateSession ( audioSession ) ;
790+ activeSubmissionSession = submissionCoordinator . beginSession ( ) ;
791+ await act ( async ( ) => {
792+ tree . update ( < Harness submissionKey = { 2 } /> ) ;
793+ modelResolution . resolve ( {
794+ exists : true ,
795+ path : '/document/whisper-models/reopened-b.bin' ,
796+ uri : 'file:///document/whisper-models/reopened-b.bin' ,
797+ size : 77704715 ,
798+ } ) ;
799+ await stopRun ;
800+ await flushPromises ( ) ;
801+ } ) ;
802+
803+ expect ( updateSpeechSettings ) . not . toHaveBeenCalled ( ) ;
804+ expect ( addTask ) . not . toHaveBeenCalled ( ) ;
805+ expect ( fileMocks . delete ) . toHaveBeenCalledWith ( 'file:///recording.m4a' ) ;
806+ expect ( fileMocks . delete ) . toHaveBeenCalledTimes ( 1 ) ;
807+ } ) ;
808+
809+ it ( 'keeps a newer speech model selected while the same audio save is resolving' , async ( ) => {
810+ speechMocks . startWhisperRealtimeCapture . mockRejectedValueOnce ( new Error ( 'use Expo fallback' ) ) ;
811+ await act ( async ( ) => {
812+ create ( < Harness /> ) ;
813+ await flushPromises ( ) ;
814+ } ) ;
815+ await act ( async ( ) => {
816+ await latest ?. startRecording ( ) ;
817+ await flushPromises ( ) ;
818+ } ) ;
819+
820+ const modelResolution = deferred < {
821+ exists : boolean ;
822+ path : string ;
823+ uri : string ;
824+ size : number ;
825+ } > ( ) ;
826+ speechMocks . ensureWhisperModelPathForConfigAsync . mockReturnValueOnce ( modelResolution . promise ) ;
827+ updateSpeechSettings . mockClear ( ) ;
828+ let stopRun ! : Promise < void > ;
829+ await act ( async ( ) => {
830+ stopRun = latest ! . stopRecording ( { saveTask : true } ) ;
831+ await flushPromises ( ) ;
832+ } ) ;
833+
834+ storeMocks . state . settings = {
835+ ...settings ,
836+ ai : {
837+ speechToText : {
838+ ...settings . ai . speechToText ,
839+ model : 'whisper-base' ,
840+ offlineModelPath : 'file:///document/whisper-models/newer-b.bin' ,
841+ } ,
842+ } ,
843+ } ;
844+ await act ( async ( ) => {
845+ modelResolution . resolve ( {
846+ exists : true ,
847+ path : '/document/whisper-models/obsolete-a.bin' ,
848+ uri : 'file:///document/whisper-models/obsolete-a.bin' ,
849+ size : 77704715 ,
850+ } ) ;
851+ await stopRun ;
852+ await flushPromises ( ) ;
853+ } ) ;
854+
855+ expect ( updateSpeechSettings ) . not . toHaveBeenCalled ( ) ;
856+ expect ( addTask ) . toHaveBeenCalledTimes ( 1 ) ;
857+ } ) ;
858+
859+ it ( 'does not persist a model path from a canceled Whisper preload' , async ( ) => {
860+ const modelResolution = deferred < {
861+ exists : boolean ;
862+ path : string ;
863+ uri : string ;
864+ size : number ;
865+ } > ( ) ;
866+ speechMocks . ensureWhisperModelPathForConfigAsync . mockReturnValueOnce ( modelResolution . promise ) ;
867+ let tree ! : ReturnType < typeof create > ;
868+ await act ( async ( ) => {
869+ tree = create ( < Harness /> ) ;
870+ await flushPromises ( ) ;
871+ } ) ;
872+ updateSpeechSettings . mockClear ( ) ;
873+
874+ await act ( async ( ) => {
875+ tree . unmount ( ) ;
876+ await flushPromises ( ) ;
877+ } ) ;
878+ await act ( async ( ) => {
879+ modelResolution . resolve ( {
880+ exists : true ,
881+ path : '/document/whisper-models/obsolete-a.bin' ,
882+ uri : 'file:///document/whisper-models/obsolete-a.bin' ,
883+ size : 77704715 ,
884+ } ) ;
885+ await modelResolution . promise ;
886+ await flushPromises ( ) ;
887+ } ) ;
888+
889+ expect ( updateSpeechSettings ) . not . toHaveBeenCalled ( ) ;
890+ expect ( speechMocks . preloadWhisperContext ) . not . toHaveBeenCalled ( ) ;
740891 } ) ;
741892} ) ;
0 commit comments