@@ -1037,4 +1037,249 @@ mod tests {
10371037 let merged = merge_fs_events ( & newer, & older) ;
10381038 assert_eq ! ( merged. event_id, 300 , "higher event_id should be kept" ) ;
10391039 }
1040+
1041+ // ── merge_fs_events dedup/flag tests ─────────────────────────────
1042+
1043+ /// Three events for the same path merge into one with the highest
1044+ /// priority flag and the highest event_id.
1045+ #[ test]
1046+ fn merge_three_events_same_path_keeps_highest_priority ( ) {
1047+ let modified = make_event (
1048+ "/test/file.txt" ,
1049+ 10 ,
1050+ watcher:: FsEventFlags {
1051+ item_modified : true ,
1052+ item_is_file : true ,
1053+ ..Default :: default ( )
1054+ } ,
1055+ ) ;
1056+ let created = make_event (
1057+ "/test/file.txt" ,
1058+ 20 ,
1059+ watcher:: FsEventFlags {
1060+ item_created : true ,
1061+ item_is_file : true ,
1062+ ..Default :: default ( )
1063+ } ,
1064+ ) ;
1065+ let modified2 = make_event (
1066+ "/test/file.txt" ,
1067+ 30 ,
1068+ watcher:: FsEventFlags {
1069+ item_modified : true ,
1070+ item_is_file : true ,
1071+ ..Default :: default ( )
1072+ } ,
1073+ ) ;
1074+
1075+ // Simulate HashMap-style dedup: fold sequentially
1076+ let merged = merge_fs_events ( & modified, & created) ;
1077+ let merged = merge_fs_events ( & merged, & modified2) ;
1078+
1079+ assert ! ( merged. flags. item_created, "item_created should survive (higher priority than modified)" ) ;
1080+ assert ! ( !merged. flags. item_modified, "item_modified is subsumed by item_created" ) ;
1081+ assert_eq ! ( merged. event_id, 30 , "highest event_id should be kept" ) ;
1082+ }
1083+
1084+ /// Events for different paths are preserved independently when stored
1085+ /// in a HashMap keyed by path (the live event loop's dedup strategy).
1086+ #[ test]
1087+ fn distinct_paths_are_all_preserved ( ) {
1088+ let paths = [ "/a.txt" , "/b.txt" , "/c.txt" , "/d/e.txt" , "/f/g/h.txt" ] ;
1089+ let mut map = HashMap :: < String , watcher:: FsChangeEvent > :: new ( ) ;
1090+
1091+ for ( i, path) in paths. iter ( ) . enumerate ( ) {
1092+ let event = make_event (
1093+ path,
1094+ ( i + 1 ) as u64 ,
1095+ watcher:: FsEventFlags {
1096+ item_modified : true ,
1097+ item_is_file : true ,
1098+ ..Default :: default ( )
1099+ } ,
1100+ ) ;
1101+ map. entry ( path. to_string ( ) )
1102+ . and_modify ( |existing| {
1103+ * existing = merge_fs_events ( existing, & event) ;
1104+ } )
1105+ . or_insert ( event) ;
1106+ }
1107+
1108+ assert_eq ! ( map. len( ) , paths. len( ) , "each distinct path should have its own entry" ) ;
1109+ for path in & paths {
1110+ assert ! ( map. contains_key( * path) , "map should contain {path}" ) ;
1111+ }
1112+ }
1113+
1114+ /// `must_scan_sub_dirs` always wins when merged with other flags.
1115+ #[ test]
1116+ fn merge_must_scan_sub_dirs_wins_over_modified ( ) {
1117+ let modified = make_event (
1118+ "/test/dir" ,
1119+ 10 ,
1120+ watcher:: FsEventFlags {
1121+ item_modified : true ,
1122+ item_is_dir : true ,
1123+ ..Default :: default ( )
1124+ } ,
1125+ ) ;
1126+ let must_scan = make_event (
1127+ "/test/dir" ,
1128+ 20 ,
1129+ watcher:: FsEventFlags {
1130+ must_scan_sub_dirs : true ,
1131+ item_is_dir : true ,
1132+ ..Default :: default ( )
1133+ } ,
1134+ ) ;
1135+
1136+ // must_scan_sub_dirs incoming
1137+ let merged = merge_fs_events ( & modified, & must_scan) ;
1138+ assert ! ( merged. flags. must_scan_sub_dirs, "must_scan_sub_dirs should win" ) ;
1139+ assert_eq ! ( merged. event_id, 20 ) ;
1140+
1141+ // must_scan_sub_dirs existing
1142+ let merged = merge_fs_events ( & must_scan, & modified) ;
1143+ assert ! ( merged. flags. must_scan_sub_dirs, "must_scan_sub_dirs should win regardless of order" ) ;
1144+ assert_eq ! ( merged. event_id, 20 ) ;
1145+ }
1146+
1147+ /// `must_scan_sub_dirs` wins even when the other event has `item_removed`.
1148+ #[ test]
1149+ fn merge_must_scan_sub_dirs_wins_over_removed ( ) {
1150+ let removed = make_event (
1151+ "/test/dir" ,
1152+ 10 ,
1153+ watcher:: FsEventFlags {
1154+ item_removed : true ,
1155+ item_is_dir : true ,
1156+ ..Default :: default ( )
1157+ } ,
1158+ ) ;
1159+ let must_scan = make_event (
1160+ "/test/dir" ,
1161+ 20 ,
1162+ watcher:: FsEventFlags {
1163+ must_scan_sub_dirs : true ,
1164+ item_is_dir : true ,
1165+ ..Default :: default ( )
1166+ } ,
1167+ ) ;
1168+
1169+ let merged = merge_fs_events ( & removed, & must_scan) ;
1170+ assert ! ( merged. flags. must_scan_sub_dirs, "must_scan_sub_dirs should win over item_removed" ) ;
1171+ }
1172+
1173+ // ── EventReconciler buffer overflow tests ────────────────────────
1174+
1175+ /// Buffering exactly MAX_BUFFER_CAPACITY (500K) events does NOT
1176+ /// trigger overflow. Adding one more does.
1177+ #[ test]
1178+ fn buffer_capacity_boundary ( ) {
1179+ // MAX_BUFFER_CAPACITY is 500_000 (private to reconciler.rs)
1180+ let cap = 500_000usize ;
1181+ let mut reconciler = EventReconciler :: new ( ) ;
1182+
1183+ for i in 0 ..cap {
1184+ reconciler. buffer_event ( make_event ( "/test/file.txt" , i as u64 , watcher:: FsEventFlags {
1185+ item_modified : true ,
1186+ item_is_file : true ,
1187+ ..Default :: default ( )
1188+ } ) ) ;
1189+ }
1190+
1191+ assert_eq ! ( reconciler. buffer_len( ) , cap, "buffer should hold exactly MAX_BUFFER_CAPACITY events" ) ;
1192+ assert ! ( !reconciler. did_buffer_overflow( ) , "should not overflow at exactly MAX_BUFFER_CAPACITY" ) ;
1193+
1194+ // One more triggers overflow
1195+ reconciler. buffer_event ( make_event ( "/test/overflow.txt" , cap as u64 , watcher:: FsEventFlags {
1196+ item_modified : true ,
1197+ item_is_file : true ,
1198+ ..Default :: default ( )
1199+ } ) ) ;
1200+
1201+ assert ! ( reconciler. did_buffer_overflow( ) , "should overflow after exceeding MAX_BUFFER_CAPACITY" ) ;
1202+ assert_eq ! ( reconciler. buffer_len( ) , 0 , "buffer should be cleared on overflow" ) ;
1203+ }
1204+
1205+ /// After overflow, subsequent buffer_event calls are no-ops.
1206+ #[ test]
1207+ fn buffer_overflow_drops_further_events ( ) {
1208+ let cap = 500_000usize ;
1209+ let mut reconciler = EventReconciler :: new ( ) ;
1210+
1211+ // Fill to capacity + 1 to trigger overflow
1212+ for i in 0 ..=cap {
1213+ reconciler. buffer_event ( make_event ( "/test/file.txt" , i as u64 , watcher:: FsEventFlags {
1214+ item_modified : true ,
1215+ item_is_file : true ,
1216+ ..Default :: default ( )
1217+ } ) ) ;
1218+ }
1219+ assert ! ( reconciler. did_buffer_overflow( ) ) ;
1220+
1221+ // Further events are silently dropped
1222+ reconciler. buffer_event ( make_event ( "/test/new.txt" , 999_999 , watcher:: FsEventFlags {
1223+ item_created : true ,
1224+ item_is_file : true ,
1225+ ..Default :: default ( )
1226+ } ) ) ;
1227+ assert_eq ! ( reconciler. buffer_len( ) , 0 , "buffer should remain empty after overflow" ) ;
1228+ }
1229+
1230+ /// `did_buffer_overflow()` returns true after overflow, but
1231+ /// `switch_to_live()` resets it. This matches the production flow:
1232+ /// overflow is checked (and acted on) BEFORE `switch_to_live()`.
1233+ #[ test]
1234+ fn overflow_flag_is_readable_before_switch_to_live ( ) {
1235+ let cap = 500_000usize ;
1236+ let mut reconciler = EventReconciler :: new ( ) ;
1237+
1238+ for i in 0 ..=cap {
1239+ reconciler. buffer_event ( make_event ( "/test/file.txt" , i as u64 , watcher:: FsEventFlags {
1240+ item_modified : true ,
1241+ item_is_file : true ,
1242+ ..Default :: default ( )
1243+ } ) ) ;
1244+ }
1245+
1246+ // Overflow is observable before switch_to_live
1247+ assert ! ( reconciler. did_buffer_overflow( ) , "overflow flag should be set" ) ;
1248+
1249+ // switch_to_live resets it (by design: the caller already consumed the flag)
1250+ reconciler. switch_to_live ( ) ;
1251+ assert ! ( !reconciler. did_buffer_overflow( ) , "switch_to_live should reset overflow flag" ) ;
1252+ assert ! ( !reconciler. is_buffering( ) , "should be in live mode" ) ;
1253+ }
1254+
1255+ /// Buffering mode transitions: new -> buffering, switch_to_live ->
1256+ /// live, buffer_event is no-op in live mode.
1257+ #[ test]
1258+ fn buffering_mode_transitions ( ) {
1259+ let mut reconciler = EventReconciler :: new ( ) ;
1260+
1261+ // Starts in buffering mode
1262+ assert ! ( reconciler. is_buffering( ) ) ;
1263+
1264+ // Buffer works
1265+ reconciler. buffer_event ( make_event ( "/a.txt" , 1 , watcher:: FsEventFlags {
1266+ item_created : true ,
1267+ item_is_file : true ,
1268+ ..Default :: default ( )
1269+ } ) ) ;
1270+ assert_eq ! ( reconciler. buffer_len( ) , 1 ) ;
1271+
1272+ // Switch to live
1273+ reconciler. switch_to_live ( ) ;
1274+ assert ! ( !reconciler. is_buffering( ) ) ;
1275+ assert_eq ! ( reconciler. buffer_len( ) , 0 , "buffer cleared on switch" ) ;
1276+
1277+ // buffer_event is no-op in live mode
1278+ reconciler. buffer_event ( make_event ( "/b.txt" , 2 , watcher:: FsEventFlags {
1279+ item_created : true ,
1280+ item_is_file : true ,
1281+ ..Default :: default ( )
1282+ } ) ) ;
1283+ assert_eq ! ( reconciler. buffer_len( ) , 0 , "buffer_event should be no-op in live mode" ) ;
1284+ }
10401285}
0 commit comments