11import { expect , test } from "bun:test" ;
2- import { isFileContentChangeEvent } from "../src/hooks/use-file-watcher" ;
2+ import type { UnwatchFn , WatchEvent } from "@tauri-apps/plugin-fs" ;
3+ import {
4+ createFileWatcherController ,
5+ isFileContentChangeEvent ,
6+ } from "../src/hooks/use-file-watcher" ;
7+
8+ const CONTENT_CHANGE : WatchEvent = {
9+ type : { modify : { kind : "data" , mode : "content" } } ,
10+ paths : [ ] ,
11+ attrs : null ,
12+ } ;
13+ const ACCESS_CHANGE : WatchEvent = {
14+ type : { access : { kind : "open" , mode : "read" } } ,
15+ paths : [ ] ,
16+ attrs : null ,
17+ } ;
318
419test ( "reloads for file create, remove, and content changes" , ( ) => {
520 expect ( isFileContentChangeEvent ( { type : { create : { kind : "file" } } , paths : [ ] , attrs : null } ) ) . toBe ( true ) ;
@@ -12,3 +27,127 @@ test("ignores access, metadata, and unrelated events", () => {
1227 expect ( isFileContentChangeEvent ( { type : { modify : { kind : "metadata" , mode : "permissions" } } , paths : [ ] , attrs : null } ) ) . toBe ( false ) ;
1328 expect ( isFileContentChangeEvent ( { type : "other" , paths : [ ] , attrs : null } ) ) . toBe ( false ) ;
1429} ) ;
30+
31+ function fakeWatchFactory ( ) {
32+ const registrations : Array < {
33+ path : string ;
34+ emit : ( event : WatchEvent ) => void ;
35+ resolve : ( ) => void ;
36+ reject : ( error : unknown ) => void ;
37+ unwatchCalls : number ;
38+ } > = [ ] ;
39+
40+ const watch = ( path : string , onEvent : ( event : WatchEvent ) => void ) : Promise < UnwatchFn > => {
41+ return new Promise < UnwatchFn > ( ( resolve , reject ) => {
42+ const reg = {
43+ path,
44+ emit : onEvent ,
45+ resolve : ( ) => resolve ( ( ( ) => { reg . unwatchCalls += 1 ; } ) as UnwatchFn ) ,
46+ reject,
47+ unwatchCalls : 0 ,
48+ } ;
49+ registrations . push ( reg ) ;
50+ } ) ;
51+ } ;
52+
53+ return { watch, registrations } ;
54+ }
55+
56+ function fakeScheduler ( ) {
57+ const tasks = new Map < number , ( ) => void > ( ) ;
58+ let nextId = 1 ;
59+
60+ return {
61+ schedule ( fn : ( ) => void ) {
62+ const id = nextId ;
63+ nextId += 1 ;
64+ tasks . set ( id , fn ) ;
65+ return id ;
66+ } ,
67+ cancel ( id : unknown ) {
68+ tasks . delete ( id as number ) ;
69+ } ,
70+ runAll ( ) {
71+ const pending = Array . from ( tasks . entries ( ) ) ;
72+ tasks . clear ( ) ;
73+ for ( const [ , fn ] of pending ) fn ( ) ;
74+ } ,
75+ get size ( ) {
76+ return tasks . size ;
77+ } ,
78+ } ;
79+ }
80+
81+ function flushAsyncCleanup ( ) : Promise < void > {
82+ return new Promise ( ( resolve ) => setTimeout ( resolve , 0 ) ) ;
83+ }
84+
85+ test ( "coalesces active file content changes and ignores access events" , ( ) => {
86+ const { watch, registrations } = fakeWatchFactory ( ) ;
87+ const scheduler = fakeScheduler ( ) ;
88+ let reloads = 0 ;
89+
90+ const controller = createFileWatcherController ( ( ) => { reloads += 1 ; } , {
91+ watch,
92+ schedule : scheduler . schedule ,
93+ cancel : scheduler . cancel ,
94+ } ) ;
95+
96+ controller . setPath ( "/notes/today.md" ) ;
97+ registrations [ 0 ] . emit ( CONTENT_CHANGE ) ;
98+ registrations [ 0 ] . emit ( CONTENT_CHANGE ) ;
99+ registrations [ 0 ] . emit ( ACCESS_CHANGE ) ;
100+
101+ expect ( scheduler . size ) . toBe ( 1 ) ;
102+ scheduler . runAll ( ) ;
103+ expect ( reloads ) . toBe ( 1 ) ;
104+ } ) ;
105+
106+ test ( "keeps stale file watcher events from reloading the new active file" , async ( ) => {
107+ const { watch, registrations } = fakeWatchFactory ( ) ;
108+ const scheduler = fakeScheduler ( ) ;
109+ let reloads = 0 ;
110+
111+ const controller = createFileWatcherController ( ( ) => { reloads += 1 ; } , {
112+ watch,
113+ schedule : scheduler . schedule ,
114+ cancel : scheduler . cancel ,
115+ } ) ;
116+
117+ controller . setPath ( "/notes/old.md" ) ;
118+ controller . setPath ( "/notes/new.md" ) ;
119+
120+ registrations [ 0 ] . emit ( CONTENT_CHANGE ) ;
121+ registrations [ 1 ] . emit ( CONTENT_CHANGE ) ;
122+
123+ scheduler . runAll ( ) ;
124+ expect ( reloads ) . toBe ( 1 ) ;
125+
126+ registrations [ 0 ] . resolve ( ) ;
127+ await flushAsyncCleanup ( ) ;
128+ expect ( registrations [ 0 ] . unwatchCalls ) . toBe ( 1 ) ;
129+ } ) ;
130+
131+ test ( "dispose releases the active file watcher and cancels pending reloads" , async ( ) => {
132+ const { watch, registrations } = fakeWatchFactory ( ) ;
133+ const scheduler = fakeScheduler ( ) ;
134+ let reloads = 0 ;
135+
136+ const controller = createFileWatcherController ( ( ) => { reloads += 1 ; } , {
137+ watch,
138+ schedule : scheduler . schedule ,
139+ cancel : scheduler . cancel ,
140+ } ) ;
141+
142+ controller . setPath ( "/notes/today.md" ) ;
143+ registrations [ 0 ] . emit ( CONTENT_CHANGE ) ;
144+ expect ( scheduler . size ) . toBe ( 1 ) ;
145+
146+ controller . dispose ( ) ;
147+ scheduler . runAll ( ) ;
148+ expect ( reloads ) . toBe ( 0 ) ;
149+
150+ registrations [ 0 ] . resolve ( ) ;
151+ await flushAsyncCleanup ( ) ;
152+ expect ( registrations [ 0 ] . unwatchCalls ) . toBe ( 1 ) ;
153+ } ) ;
0 commit comments