Skip to content

Commit 8a084cd

Browse files
committed
Indexing: Add event loop tests
- `merge_fs_events` flag priority: 3-way merge, `must_scan_sub_dirs` wins over `item_removed` and `item_modified`, distinct paths preserved - `EventReconciler` buffer overflow: boundary at 500K, post-overflow drops, overflow flag lifecycle, mode transitions
1 parent dbccec1 commit 8a084cd

1 file changed

Lines changed: 245 additions & 0 deletions

File tree

apps/desktop/src-tauri/src/indexing/event_loop.rs

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)