1use super::command::Command;
4use super::effect::Effect;
5use super::filter::Filter;
6use super::msg::Msg;
7use super::state::{Loading, Modal, Overlay, RunOutcome, State};
8
9pub fn update(mut state: State, msg: Msg) -> (State, Command<Effect, Msg>) {
22 match msg {
23 Msg::MoveUp => {
24 if state.focus_right {
25 state.files_cursor = state.files_cursor.saturating_sub(1);
26 } else if state.cursor > 0 {
27 state.cursor -= 1;
28 }
29 (state, Command::done())
30 }
31 Msg::MoveDown => {
32 if state.focus_right {
33 let len = state
34 .sorted_indices()
35 .get(state.cursor)
36 .and_then(|&i| state.all.get(i))
37 .map(|c| c.top_files.len())
38 .unwrap_or(0);
39 if state.files_cursor + 1 < len {
40 state.files_cursor += 1;
41 }
42 } else {
43 let n = state.sorted_indices().len();
44 if state.cursor + 1 < n {
45 state.cursor += 1;
46 }
47 }
48 (state, Command::done())
49 }
50 Msg::ToggleMark => {
51 let visible = state.sorted_indices();
52 if let Some(&underlying) = visible.get(state.cursor) {
53 let is_active = state.floor.is_active(state.all[underlying].age(state.now));
54 if is_active && !state.marks.is_marked(underlying) {
55 state.modal = Modal::ActiveMark(vec![underlying]);
56 } else {
57 state.marks.toggle(underlying);
58 if state.cursor + 1 < visible.len() {
59 state.cursor += 1;
60 }
61 }
62 }
63 (state, Command::done())
64 }
65 Msg::MarkDownToCursor => {
66 let visible = state.sorted_indices();
67 let take = (state.cursor + 1).min(visible.len());
68 let mut active_in_range = Vec::new();
69 let mut benign = Vec::new();
70 for &underlying in visible.iter().take(take) {
71 if state.marks.is_marked(underlying) {
72 continue;
73 }
74 if state.floor.is_active(state.all[underlying].age(state.now)) {
75 active_in_range.push(underlying);
76 } else {
77 benign.push(underlying);
78 }
79 }
80 for i in benign {
81 state.marks.marked.insert(i);
82 }
83 if !active_in_range.is_empty() {
84 state.modal = Modal::ActiveMark(active_in_range);
85 }
86 (state, Command::done())
87 }
88 Msg::CycleSort => {
89 let was_top = state.cursor == 0;
98 let pinned = state.sorted_indices().get(state.cursor).copied();
99 state.sort = state.sort.next();
100 state.cursor = if was_top {
101 0
102 } else {
103 let visible = state.sorted_indices();
104 pinned
105 .and_then(|i| visible.iter().position(|&v| v == i))
106 .unwrap_or(0)
107 };
108 (state, Command::done())
109 }
110 Msg::DrillIn => {
111 if state.loading.is_some() {
115 return (state, Command::done());
116 }
117 let visible = state.sorted_indices();
118 let Some(&idx) = visible.get(state.cursor) else {
119 return (state, Command::done());
120 };
121 let parent_label = state.all[idx].label.clone();
122 let parent_path = state.all[idx].path.clone();
123 state.loading = Some(Loading {
124 label: format!("scanning {parent_label}"),
125 frame: 0,
126 started: std::time::Instant::now(),
127 folders: Some(0),
128 });
129 (
130 state,
131 Command::effect(Effect::SpawnScan {
132 parent_label,
133 parent_path,
134 }),
135 )
136 }
137 Msg::ScanCompleted {
138 parent_label,
139 parent_path,
140 children,
141 } => {
142 if !children.is_empty() {
143 state.stack_labels.push(parent_label);
144 state.drill_paths.push(parent_path);
145 state.drill_into(children);
146 }
147 state.loading = None;
148 (state, Command::done())
149 }
150 Msg::ScanProgress { folders } => {
151 if let Some(l) = state.loading.as_mut() {
152 l.folders = Some(folders);
153 }
154 (state, Command::done())
155 }
156 Msg::SeedsLoaded { caches } => {
157 state.all = caches;
161 state.cursor = 0;
162 state.loading = None;
163 (state, Command::done())
164 }
165 Msg::DrillOut => {
166 if state.loading.is_some() {
171 return (state, Command::done());
172 }
173 let was_dirty = state.level_dirty;
174 let popped_path = state.drill_out_with_path();
175 if was_dirty {
176 if let Some(path) = popped_path {
177 let path_label = path
178 .file_name()
179 .map(|s| s.to_string_lossy().to_string())
180 .unwrap_or_else(|| path.display().to_string());
181 state.loading = Some(Loading {
182 label: format!("refreshing {path_label}"),
183 frame: 0,
184 started: std::time::Instant::now(),
185 folders: None,
186 });
187 state.level_dirty = true;
192 return (state, Command::effect(Effect::SpawnRefresh { path }));
193 }
194 }
195 (state, Command::done())
196 }
197 Msg::RefreshCompleted { path, cache } => {
198 if let Some(slot) = state.all.iter_mut().find(|c| c.path == path) {
199 *slot = cache;
200 }
201 state.loading = None;
202 (state, Command::done())
203 }
204 Msg::ToggleFocus => {
205 state.focus_right = !state.focus_right;
206 state.files_cursor = 0;
207 (state, Command::done())
208 }
209 Msg::RequestQuit => {
210 state.quit = true;
211 (state, Command::done())
212 }
213 Msg::DeletePressed => {
214 if state.marks.count() == 0 {
215 return (state, Command::done());
216 }
217 state.modal = Modal::DeleteConfirm;
218 if state.yes_mode {
219 (state, Command::event(Msg::ConfirmDelete))
220 } else {
221 (state, Command::done())
222 }
223 }
224 Msg::CancelDelete => {
225 state.modal = Modal::None;
226 (state, Command::done())
227 }
228 Msg::ConfirmDelete => {
229 let to_delete: Vec<(usize, std::path::PathBuf, u64)> = state
230 .marks
231 .marked
232 .iter()
233 .filter_map(|&i| state.all.get(i).map(|c| (i, c.path.clone(), c.size_bytes)))
234 .collect();
235 state.modal = Modal::None;
236 state.marks.clear();
237 if to_delete.is_empty() {
238 return (state, Command::done());
239 }
240 let count = to_delete.len();
241 state.loading = Some(Loading {
242 label: format!(
243 "deleting {count} {}",
244 crate::caches::format::pluralize(count as u64, "folder", "folders")
245 ),
246 frame: 0,
247 started: std::time::Instant::now(),
248 folders: None,
249 });
250 let dry_run = state.dry_run;
251 (
252 state,
253 Command::effect(Effect::SpawnDelete {
254 items: to_delete,
255 dry_run,
256 }),
257 )
258 }
259 Msg::DeleteCompleted {
260 freed,
261 deleted_count,
262 failed_count,
263 deleted_indices,
264 } => {
265 state.total_freed += freed;
266 if !state.dry_run && deleted_count > 0 {
267 state.level_dirty = true;
268 }
269 if !state.dry_run {
270 let mut idxs = deleted_indices;
271 idxs.sort_unstable_by(|a, b| b.cmp(a));
272 for i in idxs {
273 if i < state.all.len() {
274 state.all.remove(i);
275 }
276 }
277 state.clamp_cursor_to_visible();
280 }
281 state.loading = None;
282 state.overlay = Some(Overlay {
283 outcome: RunOutcome {
284 freed,
285 deleted: deleted_count,
286 failed: failed_count,
287 dry_run: state.dry_run,
288 },
289 });
290 (
291 state,
292 Command::effect(Effect::EmitAfter {
293 dur: std::time::Duration::from_secs(2),
294 msg: Msg::OverlayDismiss,
295 }),
296 )
297 }
298 Msg::ConfirmActiveMark => {
299 if let Modal::ActiveMark(indices) = std::mem::replace(&mut state.modal, Modal::None) {
300 for i in indices {
301 state.marks.marked.insert(i);
302 }
303 let visible_len = state.sorted_indices().len();
304 if state.cursor + 1 < visible_len {
305 state.cursor += 1;
306 }
307 }
308 (state, Command::done())
309 }
310 Msg::CancelActiveMark => {
311 state.modal = Modal::None;
312 (state, Command::done())
313 }
314 Msg::FilterStart => {
315 if state.filter.is_none() {
316 state.filter = Some(Filter::default());
317 }
318 state.modal = Modal::FilterEdit;
319 (state, Command::done())
320 }
321 Msg::FilterChar(c) => {
322 if matches!(state.modal, Modal::FilterEdit) {
323 if let Some(f) = state.filter.as_mut() {
324 f.input.push(c);
325 }
326 }
327 state.clamp_cursor_to_visible();
328 (state, Command::done())
329 }
330 Msg::FilterBackspace => {
331 if matches!(state.modal, Modal::FilterEdit) {
332 if let Some(f) = state.filter.as_mut() {
333 f.input.pop();
334 }
335 }
336 state.clamp_cursor_to_visible();
337 (state, Command::done())
338 }
339 Msg::FilterApply => {
340 state.modal = Modal::None;
341 if let Some(f) = state.filter.as_ref() {
342 if f.input.is_empty() {
343 state.filter = None;
344 }
345 }
346 state.clamp_cursor_to_visible();
347 (state, Command::done())
348 }
349 Msg::FilterCancel => {
350 state.filter = None;
351 state.modal = Modal::None;
352 state.clamp_cursor_to_visible();
353 (state, Command::done())
354 }
355 Msg::MarkAllVisible => {
356 let visible = state.sorted_indices();
357 let mut active_in_range = Vec::new();
358 for &underlying in &visible {
359 if state.marks.is_marked(underlying) {
360 continue;
361 }
362 if state.floor.is_active(state.all[underlying].age(state.now)) {
363 active_in_range.push(underlying);
364 } else {
365 state.marks.marked.insert(underlying);
366 }
367 }
368 if !active_in_range.is_empty() {
369 state.modal = Modal::ActiveMark(active_in_range);
370 }
371 (state, Command::done())
372 }
373 Msg::Tick => {
374 if let Some(l) = state.loading.as_mut() {
375 l.update_frame();
376 }
377 (state, Command::done())
378 }
379 Msg::OverlayDismiss => {
380 state.overlay = None;
381 (state, Command::done())
382 }
383 }
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389 use crate::caches::model::*;
390 use std::path::PathBuf;
391 use std::time::{Duration, SystemTime};
392
393 fn cache(label: &str, size: u64, mtime_secs: u64) -> Cache {
394 Cache {
395 label: label.into(),
396 path: PathBuf::from(format!("/x/{label}")),
397 size_bytes: size,
398 newest_mtime: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(mtime_secs)),
399 file_count: 1,
400 dir_count: 0,
401 top_files: Vec::new(),
402 unreadable: 0,
403 }
404 }
405
406 fn state(items: Vec<Cache>) -> State {
407 State {
408 now: SystemTime::UNIX_EPOCH + Duration::from_secs(10_000_000),
409 all: items,
410 sort: Sort::Score,
411 marks: MarkSet::default(),
412 cursor: 0,
413 files_cursor: 0,
414 floor: FloorPolicy {
415 floor: Duration::from_secs(7 * 86_400),
416 },
417 focus_right: false,
418 stack: Vec::new(),
419 stack_labels: Vec::new(),
420 quit: false,
421 dry_run: false,
422 yes_mode: false,
423 total_freed: 0,
424 modal: Modal::None,
425 filter: None,
426 loading: None,
427 overlay: None,
428 level_dirty: false,
429 drill_paths: Vec::new(),
430 cursor_stack: Vec::new(),
431 }
432 }
433
434 #[test]
435 fn move_up_decrements_and_floors_at_zero() {
436 let mut s = state(vec![cache("a", 1, 0), cache("b", 1, 0), cache("c", 1, 0)]);
437 s.cursor = 2;
438 let (s, c) = update(s, Msg::MoveUp);
439 assert!(c.is_done());
440 assert_eq!(s.cursor, 1);
441 let (s, _) = update(s, Msg::MoveUp);
442 assert_eq!(s.cursor, 0);
443 let (s, _) = update(s, Msg::MoveUp);
444 assert_eq!(s.cursor, 0, "cursor must not underflow");
445 }
446
447 #[test]
448 fn mark_down_to_cursor_marks_benign_range() {
449 let mut s = state(vec![cache("a", 1, 0), cache("b", 1, 0), cache("c", 1, 0)]);
451 s.cursor = 1;
452 let (s, c) = update(s, Msg::MarkDownToCursor);
453 assert!(c.is_done());
454 assert!(s.marks.is_marked(0));
455 assert!(s.marks.is_marked(1));
456 assert!(!s.marks.is_marked(2));
457 assert!(matches!(s.modal, Modal::None));
458 }
459
460 #[test]
461 fn mark_down_to_cursor_defers_active_rows_to_modal() {
462 let mut s = state(vec![
464 cache("recent", 1, NOW_SECS - 86_400), cache("old", 1, 0), ]);
467 s.cursor = 1;
468 let (s, c) = update(s, Msg::MarkDownToCursor);
469 assert!(c.is_done());
470 assert!(s.marks.is_marked(1), "benign row marked immediately");
471 assert!(!s.marks.is_marked(0), "active row deferred to modal");
472 assert!(matches!(s.modal, Modal::ActiveMark(_)));
473 }
474
475 #[test]
476 fn filter_backspace_pops_last_char_while_editing() {
477 let s = state(vec![cache("a", 1, 0)]);
478 let (s, _) = update(s, Msg::FilterStart);
479 let (s, _) = update(s, Msg::FilterChar('y'));
480 let (s, _) = update(s, Msg::FilterChar('a'));
481 let (s, _) = update(s, Msg::FilterChar('r'));
482 let (s, _) = update(s, Msg::FilterBackspace);
483 let (s, c) = update(s, Msg::FilterBackspace);
484 assert!(c.is_done());
485 assert_eq!(s.filter.as_ref().unwrap().input, "y");
486 }
487
488 #[test]
489 fn filter_backspace_is_noop_when_not_in_edit_mode() {
490 let s = state(vec![cache("a", 1, 0)]);
493 let (s, _) = update(s, Msg::FilterStart);
494 let (s, _) = update(s, Msg::FilterChar('a'));
495 let (s, _) = update(s, Msg::FilterApply);
496 assert!(matches!(s.modal, Modal::None));
497 let (s, _) = update(s, Msg::FilterBackspace);
498 assert_eq!(s.filter.as_ref().unwrap().input, "a", "guarded by modal");
499 }
500
501 #[test]
502 fn move_down_advances_until_last() {
503 let s = state(vec![cache("a", 1, 0), cache("b", 1, 0)]);
504 let (s, c) = update(s, Msg::MoveDown);
505 assert!(c.is_done());
506 assert_eq!(s.cursor, 1);
507 let (s, c) = update(s, Msg::MoveDown);
508 assert!(c.is_done());
509 assert_eq!(s.cursor, 1);
510 }
511
512 #[test]
513 fn move_down_respects_active_filter_bound() {
514 let s = state(vec![
517 cache("npm", 1, 0),
518 cache("yarn", 1, 0),
519 cache("bun", 1, 0),
520 ]);
521 let (s, _) = update(s, Msg::FilterStart);
522 let (s, _) = update(s, Msg::FilterChar('y'));
523 let (s, _) = update(s, Msg::FilterChar('a'));
524 let (s, _) = update(s, Msg::FilterApply);
525 assert_eq!(s.cursor, 0);
526 let (s, c) = update(s, Msg::MoveDown);
527 assert!(c.is_done());
528 assert_eq!(s.cursor, 0, "cursor must stay inside the visible set");
529 }
530
531 #[test]
532 fn cycle_sort_pins_cursor_to_underlying_cache() {
533 let s = state(vec![
538 cache("small", 1024, 9_000_000),
539 cache("huge", 1_000_000_000, 9_999_990),
540 ]);
541 let mut s = s;
543 s.cursor = 1; let underlying_small = s.sorted_indices()[1];
545 let (s, c) = update(s, Msg::CycleSort);
546 assert!(c.is_done());
547 assert_eq!(s.sort, Sort::Size);
548 let visible = s.sorted_indices();
550 assert_eq!(
551 visible[s.cursor], underlying_small,
552 "cursor must still point at 'small' after sort change"
553 );
554 }
555
556 #[test]
557 fn cycle_sort_keeps_top_row_pinned() {
558 let mut s = state(vec![
565 cache("old_smaller", 10 * 1_048_576, 10_000_000 - 864_000),
566 cache("recent_huge", 100 * 1_048_576, 10_000_000 - 43_200),
567 ]);
568 s.cursor = 0;
569 let head_under_score = s.sorted_indices()[0];
570 let (s, _) = update(s, Msg::CycleSort); let head_under_size = s.sorted_indices()[0];
572 assert_ne!(
573 head_under_score, head_under_size,
574 "fixture must make the two metrics disagree, else the test is vacuous"
575 );
576 assert_eq!(s.cursor, 0, "row 0 must stay row 0 across sort cycles");
577 }
578
579 #[test]
580 fn cycle_sort_resets_cursor_when_pin_unreachable() {
581 let mut s = state(vec![cache("a", 1, 0), cache("b", 1, 0)]);
583 s.cursor = 7;
584 let (s, _) = update(s, Msg::CycleSort);
585 assert_eq!(s.cursor, 0);
586 }
587
588 #[test]
589 fn quit_without_marks_quits_immediately() {
590 let s = state(vec![cache("a", 1, 0)]);
591 let (s, c) = update(s, Msg::RequestQuit);
592 assert!(c.is_done());
593 assert!(s.quit);
594 assert!(matches!(s.modal, Modal::None));
595 }
596
597 #[test]
598 fn quit_with_marks_also_quits_immediately() {
599 let mut s = state(vec![cache("a", 1, 0)]);
600 s.marks.toggle(0);
601 let (s, c) = update(s, Msg::RequestQuit);
602 assert!(c.is_done());
603 assert!(s.quit);
604 assert!(matches!(s.modal, Modal::None));
605 }
606
607 #[test]
608 fn sorted_indices_score_descending() {
609 let s = state(vec![
610 cache("small", 1024, 9_000_000),
611 cache("huge", 1_000_000_000, 9_999_990),
612 ]);
613 let idx = s.sorted_indices();
614 assert_eq!(s.all[idx[0]].label, "huge");
616 }
617
618 #[test]
619 fn drill_in_replaces_list_pushes_stack() {
620 let mut s = state(vec![cache("npm", 100, 0)]);
621 s.cursor = 0;
622 let children = vec![cache("registry", 10, 0), cache("cache", 5, 0)];
623 s.drill_into(children);
624 assert_eq!(s.all.len(), 2);
625 assert_eq!(s.stack.len(), 1);
626 assert_eq!(s.cursor, 0);
627 }
628
629 #[test]
630 fn drill_out_restores_parent() {
631 let mut s = state(vec![cache("npm", 100, 0)]);
632 s.drill_into(vec![cache("registry", 10, 0)]);
633 s.drill_out();
634 assert_eq!(s.all.len(), 1);
635 assert_eq!(s.stack.len(), 0);
636 assert_eq!(s.all[0].label, "npm");
637 }
638
639 #[test]
640 fn drill_out_at_top_is_noop() {
641 let mut s = state(vec![cache("npm", 100, 0)]);
642 s.drill_out();
643 assert_eq!(s.all.len(), 1);
644 assert_eq!(s.stack.len(), 0);
645 }
646
647 #[test]
648 fn delete_pressed_opens_modal_when_marks_present() {
649 let mut s = state(vec![cache("a", 1, 0)]);
650 s.marks.toggle(0);
651 let (s, c) = update(s, Msg::DeletePressed);
652 assert!(c.is_done());
653 assert!(matches!(s.modal, Modal::DeleteConfirm));
654 }
655
656 #[test]
657 fn delete_pressed_noop_when_no_marks() {
658 let s = state(vec![cache("a", 1, 0)]);
659 let (s, c) = update(s, Msg::DeletePressed);
660 assert!(c.is_done());
661 assert!(matches!(s.modal, Modal::None));
662 }
663
664 #[test]
665 fn cancel_delete_closes_modal() {
666 let mut s = state(vec![cache("a", 1, 0)]);
667 s.marks.toggle(0);
668 let (s, c) = update(s, Msg::DeletePressed);
669 assert!(c.is_done());
670 let (s, c) = update(s, Msg::CancelDelete);
671 assert!(c.is_done());
672 assert!(matches!(s.modal, Modal::None));
673 }
674
675 #[test]
676 fn confirm_delete_with_no_marks_returns_done() {
677 let s = state(vec![cache("a", 1, 0)]);
678 let (s, cmd) = update(s, Msg::ConfirmDelete);
679 assert!(s.loading.is_none());
680 assert!(cmd.is_done());
681 assert!(matches!(s.modal, Modal::None));
682 }
683
684 #[test]
685 fn confirm_delete_with_marks_emits_spawn_delete_and_sets_loading() {
686 let mut s = state(vec![cache("a", 1, 0)]);
687 s.marks.marked.insert(0);
688 let (s, cmd) = update(s, Msg::ConfirmDelete);
689 assert!(s.loading.is_some());
690 assert_eq!(s.marks.count(), 0, "marks cleared on confirm");
691 assert!(matches!(
692 cmd.effects.as_slice(),
693 [Effect::SpawnDelete { .. }]
694 ));
695 }
696
697 #[test]
698 fn delete_completed_real_run_removes_rows_and_accumulates_freed() {
699 let mut s = state(vec![cache("a", 1, 0), cache("b", 2, 0), cache("c", 3, 0)]);
700 s.dry_run = false;
701 s.cursor = 2;
702 let (s, cmd) = update(
703 s,
704 Msg::DeleteCompleted {
705 freed: 5,
706 deleted_count: 2,
707 failed_count: 0,
708 deleted_indices: vec![0, 2],
709 },
710 );
711 assert_eq!(s.all.len(), 1);
712 assert_eq!(s.all[0].label, "b");
713 assert_eq!(
714 s.cursor, 0,
715 "cursor parked at min(deleted_indices), clamped to survivor"
716 );
717 assert_eq!(s.total_freed, 5);
718 assert!(s.level_dirty);
719 assert!(s.overlay.is_some());
720 assert!(matches!(
721 cmd.effects.as_slice(),
722 [Effect::EmitAfter {
723 msg: Msg::OverlayDismiss,
724 ..
725 }]
726 ));
727 }
728
729 #[test]
730 fn delete_completed_dry_run_keeps_rows_intact() {
731 let mut s = state(vec![cache("a", 1, 0), cache("b", 2, 0)]);
732 s.dry_run = true;
733 let (s, cmd) = update(
734 s,
735 Msg::DeleteCompleted {
736 freed: 3,
737 deleted_count: 2,
738 failed_count: 0,
739 deleted_indices: vec![0, 1],
740 },
741 );
742 assert_eq!(s.all.len(), 2, "dry-run leaves rows in view");
743 assert_eq!(s.total_freed, 3);
744 assert!(!s.level_dirty);
745 assert!(s.overlay.is_some());
746 assert!(matches!(
747 cmd.effects.as_slice(),
748 [Effect::EmitAfter {
749 msg: Msg::OverlayDismiss,
750 ..
751 }]
752 ));
753 }
754
755 #[test]
756 fn delete_completed_clamps_cursor_against_visible_under_filter() {
757 let s = state(vec![
761 cache("npm", 1, 0),
762 cache("yarn", 1, 0),
763 cache("bun", 1, 0),
764 ]);
765 let (s, _) = update(s, Msg::FilterStart);
766 let (s, _) = update(s, Msg::FilterChar('y'));
767 let (s, _) = update(s, Msg::FilterChar('a'));
768 let (mut s, _) = update(s, Msg::FilterApply);
769 s.dry_run = false;
770 let (s, _) = update(
771 s,
772 Msg::DeleteCompleted {
773 freed: 1,
774 deleted_count: 1,
775 failed_count: 0,
776 deleted_indices: vec![1],
777 },
778 );
779 assert_eq!(s.all.len(), 2);
780 assert_eq!(s.sorted_indices().len(), 0, "filter still matches nothing");
781 assert_eq!(s.cursor, 0, "cursor clamped against visible bound");
782 }
783
784 #[test]
785 fn delete_completed_carries_failed_count_to_overlay() {
786 let mut s = state(vec![cache("a", 1, 0), cache("b", 1, 0)]);
787 s.dry_run = false;
788 let (s, _) = update(
789 s,
790 Msg::DeleteCompleted {
791 freed: 1,
792 deleted_count: 1,
793 failed_count: 1,
794 deleted_indices: vec![0],
795 },
796 );
797 let outcome = &s.overlay.as_ref().unwrap().outcome;
798 assert_eq!(outcome.failed, 1);
799 assert_eq!(outcome.deleted, 1);
800 }
801
802 #[test]
803 fn delete_completed_sets_overlay_and_emits_dismiss_after_2s() {
804 let mut s = state(vec![cache("a", 1, 0)]);
805 s.dry_run = false;
806 let (s, cmd) = update(
807 s,
808 Msg::DeleteCompleted {
809 freed: 100,
810 deleted_count: 1,
811 failed_count: 0,
812 deleted_indices: vec![0],
813 },
814 );
815 assert!(s.overlay.is_some());
816 assert_eq!(s.overlay.as_ref().unwrap().outcome.freed, 100);
817 assert!(matches!(
818 cmd.effects.as_slice(),
819 [Effect::EmitAfter { dur, msg: Msg::OverlayDismiss }] if *dur == std::time::Duration::from_secs(2)
820 ));
821 }
822
823 #[test]
824 fn overlay_dismiss_clears_overlay() {
825 let mut s = state(vec![cache("a", 1, 0)]);
826 s.overlay = Some(Overlay {
827 outcome: RunOutcome {
828 freed: 1,
829 deleted: 1,
830 failed: 0,
831 dry_run: false,
832 },
833 });
834 let (s, cmd) = update(s, Msg::OverlayDismiss);
835 assert!(s.overlay.is_none());
836 assert!(cmd.is_done());
837 }
838
839 #[test]
840 fn mark_survives_sort_change() {
841 let s = state(vec![
849 cache("small", 1024, 9_000_000),
850 cache("huge", 1_000_000_000, 9_999_990),
851 ]);
852 let idx_huge_before = s.sorted_indices()[0];
854 let (s, c) = update(s, Msg::ToggleMark);
855 assert!(c.is_done());
856 let s = if matches!(s.modal, Modal::ActiveMark(_)) {
858 let (s, c) = update(s, Msg::ConfirmActiveMark);
859 assert!(c.is_done());
860 s
861 } else {
862 s
863 };
864 assert!(
865 s.marks.is_marked(idx_huge_before),
866 "after toggling cursor on huge, marks must store huge's underlying index"
867 );
868
869 let (s, c) = update(s, Msg::CycleSort); assert!(c.is_done());
872 let (s, c) = update(s, Msg::CycleSort); assert!(c.is_done());
874 let visible = s.sorted_indices();
875 let row_of_huge = visible
876 .iter()
877 .position(|&i| s.all[i].label == "huge")
878 .unwrap();
879 assert!(
880 s.marks.is_marked(visible[row_of_huge]),
881 "after sort change, the SAME underlying cache should still be marked"
882 );
883 }
884
885 #[test]
886 fn drill_out_msg_pops_stack() {
887 let mut s = state(vec![cache("parent", 100, 0)]);
888 s.drill_into(vec![cache("child", 10, 0)]);
889 assert_eq!(s.all[0].label, "child");
890
891 let (s, c) = update(s, Msg::DrillOut);
892 assert!(c.is_done());
893 assert_eq!(s.all.len(), 1);
894 assert_eq!(s.all[0].label, "parent");
895 }
896
897 #[test]
898 fn drill_in_via_scan_enumerates_children() {
899 use std::fs;
900 let tmp = tempfile::tempdir().unwrap();
901 let cache = tmp.path().join("npm");
902 fs::create_dir_all(cache.join("a")).unwrap();
903 fs::create_dir_all(cache.join("b")).unwrap();
904
905 let mut s = state(vec![Cache {
907 label: "npm".into(),
908 path: cache.clone(),
909 size_bytes: 0,
910 newest_mtime: None,
911 file_count: 0,
912 dir_count: 0,
913 top_files: Vec::new(),
914 unreadable: 0,
915 }]);
916 let children = crate::caches::scan::enumerate_seed(&cache);
917 s.drill_into(children);
918 assert_eq!(s.all.len(), 2);
919 assert!(s.stack.len() == 1);
920 }
921
922 #[test]
923 fn empty_caches_sort_last_under_age() {
924 let mut s = state(vec![
925 cache("populated", 1024, 0), cache("empty", 0, 0), ]);
928 s.all[1].newest_mtime = None;
929 s.sort = Sort::Age;
930 let idx = s.sorted_indices();
931 assert_eq!(
932 s.all[idx.last().copied().unwrap()].label,
933 "empty",
934 "empty caches must land at the bottom under Age sort"
935 );
936 }
937
938 const NOW_SECS: u64 = 10_000_000;
939
940 #[test]
941 fn marking_active_row_opens_active_confirm() {
942 let s = state(vec![cache("recent", 1_000_000, NOW_SECS - 86_400)]);
943 let (s, c) = update(s, Msg::ToggleMark);
944 assert!(c.is_done());
945 assert!(matches!(s.modal, Modal::ActiveMark(_)));
946 assert_eq!(s.marks.count(), 0);
947 }
948
949 #[test]
950 fn confirm_active_mark_inserts_and_closes() {
951 let s = state(vec![cache("recent", 1_000_000, NOW_SECS - 86_400)]);
952 let (s, c) = update(s, Msg::ToggleMark);
953 assert!(c.is_done());
954 let (s, c) = update(s, Msg::ConfirmActiveMark);
955 assert!(c.is_done());
956 assert_eq!(s.marks.count(), 1);
957 assert!(matches!(s.modal, Modal::None));
958 }
959
960 #[test]
961 fn cancel_active_mark_closes_without_inserting() {
962 let s = state(vec![cache("recent", 1_000_000, NOW_SECS - 86_400)]);
963 let (s, c) = update(s, Msg::ToggleMark);
964 assert!(c.is_done());
965 let (s, c) = update(s, Msg::CancelActiveMark);
966 assert!(c.is_done());
967 assert_eq!(s.marks.count(), 0);
968 assert!(matches!(s.modal, Modal::None));
969 }
970
971 #[test]
972 fn filter_start_creates_editing_filter() {
973 let s = state(vec![cache("a", 1, 0)]);
974 let (s, c) = update(s, Msg::FilterStart);
975 assert!(c.is_done());
976 let f = s.filter.as_ref().unwrap();
977 assert!(matches!(s.modal, Modal::FilterEdit));
978 assert_eq!(f.input, "");
979 }
980
981 #[test]
982 fn filter_chars_accumulate() {
983 let s = state(vec![cache("a", 1, 0)]);
984 let (s, c) = update(s, Msg::FilterStart);
985 assert!(c.is_done());
986 let (s, c) = update(s, Msg::FilterChar('n'));
987 assert!(c.is_done());
988 let (s, c) = update(s, Msg::FilterChar('p'));
989 assert!(c.is_done());
990 let (s, c) = update(s, Msg::FilterChar('m'));
991 assert!(c.is_done());
992 assert_eq!(s.filter.as_ref().unwrap().input, "npm");
993 }
994
995 #[test]
996 fn filter_apply_closes_editing() {
997 let s = state(vec![cache("a", 1, 0)]);
998 let (s, c) = update(s, Msg::FilterStart);
999 assert!(c.is_done());
1000 let (s, c) = update(s, Msg::FilterChar('a'));
1001 assert!(c.is_done());
1002 let (s, c) = update(s, Msg::FilterApply);
1003 assert!(c.is_done());
1004 assert!(matches!(s.modal, Modal::None));
1005 let f = s.filter.as_ref().unwrap();
1006 assert_eq!(f.input, "a");
1007 }
1008
1009 #[test]
1010 fn filter_cancel_drops_filter() {
1011 let s = state(vec![cache("a", 1, 0)]);
1012 let (s, c) = update(s, Msg::FilterStart);
1013 assert!(c.is_done());
1014 let (s, c) = update(s, Msg::FilterChar('a'));
1015 assert!(c.is_done());
1016 let (s, c) = update(s, Msg::FilterCancel);
1017 assert!(c.is_done());
1018 assert!(s.filter.is_none());
1019 }
1020
1021 #[test]
1022 fn empty_filter_apply_drops_filter() {
1023 let s = state(vec![cache("a", 1, 0)]);
1024 let (s, c) = update(s, Msg::FilterStart);
1025 assert!(c.is_done());
1026 let (s, c) = update(s, Msg::FilterApply);
1027 assert!(c.is_done());
1028 assert!(s.filter.is_none());
1029 }
1030
1031 #[test]
1032 fn filter_hides_non_matching_rows() {
1033 let s = state(vec![
1034 cache("npm", 1, 0),
1035 cache("yarn", 1, 0),
1036 cache("bun", 1, 0),
1037 ]);
1038 let (s, c) = update(s, Msg::FilterStart);
1039 assert!(c.is_done());
1040 let (s, c) = update(s, Msg::FilterChar('y'));
1041 assert!(c.is_done());
1042 let visible = s.sorted_indices();
1043 let labels: Vec<&str> = visible.iter().map(|&i| s.all[i].label.as_str()).collect();
1044 assert_eq!(labels, ["yarn"]);
1045 }
1046
1047 #[test]
1048 fn mark_all_visible_marks_filtered_rows() {
1049 let s = state(vec![
1050 cache("npm", 1, 0),
1051 cache("yarn", 1, 0),
1052 cache("bun", 1, 0),
1053 ]);
1054 let (s, c) = update(s, Msg::FilterStart);
1056 assert!(c.is_done());
1057 let (s, c) = update(s, Msg::FilterChar('r'));
1058 assert!(c.is_done());
1059 let (s, c) = update(s, Msg::FilterChar('n'));
1060 assert!(c.is_done());
1061 let (s, c) = update(s, Msg::FilterApply);
1062 assert!(c.is_done());
1063 let (s, c) = update(s, Msg::MarkAllVisible);
1064 assert!(c.is_done());
1065 assert_eq!(s.marks.count(), 1);
1066 let (s, c) = update(s, Msg::FilterCancel);
1069 assert!(c.is_done());
1070 let (s, c) = update(s, Msg::MarkAllVisible);
1071 assert!(c.is_done());
1072 assert_eq!(s.marks.count(), 3);
1073 }
1074
1075 #[test]
1076 fn scrolling_right_pane_advances_files_selection() {
1077 let s = state(vec![Cache {
1078 label: "x".into(),
1079 path: PathBuf::from("/x"),
1080 size_bytes: 0,
1081 newest_mtime: None,
1082 file_count: 0,
1083 dir_count: 0,
1084 top_files: vec![
1085 TopFile {
1086 name: "a".into(),
1087 size_bytes: 1,
1088 mtime: None,
1089 },
1090 TopFile {
1091 name: "b".into(),
1092 size_bytes: 1,
1093 mtime: None,
1094 },
1095 TopFile {
1096 name: "c".into(),
1097 size_bytes: 1,
1098 mtime: None,
1099 },
1100 ],
1101 unreadable: 0,
1102 }]);
1103 let (s, c) = update(s, Msg::ToggleFocus);
1104 assert!(c.is_done());
1105 assert!(s.focus_right);
1106 assert_eq!(s.files_cursor, 0);
1107 let (s, c) = update(s, Msg::MoveDown);
1108 assert!(c.is_done());
1109 assert_eq!(s.files_cursor, 1);
1110 let (s, c) = update(s, Msg::MoveDown);
1111 assert!(c.is_done());
1112 assert_eq!(s.files_cursor, 2);
1113 let (s, c) = update(s, Msg::MoveDown);
1114 assert!(c.is_done());
1115 assert_eq!(s.files_cursor, 2);
1116 }
1117
1118 #[test]
1119 fn toggle_focus_resets_scroll() {
1120 let mut s = state(vec![cache("x", 1, 0)]);
1121 s.focus_right = true;
1122 s.files_cursor = 5;
1123 let (s, c) = update(s, Msg::ToggleFocus);
1124 assert!(c.is_done());
1125 assert_eq!(s.files_cursor, 0);
1126 assert!(!s.focus_right);
1127 }
1128
1129 #[test]
1130 fn drill_in_is_noop_while_loading() {
1131 let mut s = state(vec![cache("npm", 100, 0)]);
1132 let started = std::time::Instant::now();
1133 s.loading = Some(Loading {
1134 label: "scanning previous".into(),
1135 frame: 7,
1136 started,
1137 folders: None,
1138 });
1139 let (s, cmd) = update(s, Msg::DrillIn);
1140 assert!(cmd.is_done(), "no second scan must be emitted");
1141 let l = s.loading.as_ref().expect("loading preserved");
1142 assert_eq!(l.label, "scanning previous");
1143 assert_eq!(l.frame, 7);
1144 assert_eq!(l.started, started);
1145 }
1146
1147 #[test]
1148 fn loading_frame_advances() {
1149 let mut l = Loading {
1150 label: "x".into(),
1151 frame: 0,
1152 started: std::time::Instant::now(),
1153 folders: None,
1154 };
1155 l.update_frame();
1156 assert_eq!(l.frame, 1);
1157 for _ in 0..super::super::SPINNER_FRAMES.len() {
1158 l.update_frame();
1159 }
1160 assert_eq!(l.frame, 1);
1162 }
1163
1164 #[test]
1165 fn tick_advances_spinner_frame_when_loading() {
1166 let mut s = state(vec![cache("a", 1, 0)]);
1167 s.loading = Some(Loading {
1168 label: "x".into(),
1169 frame: 0,
1170 started: std::time::Instant::now(),
1171 folders: None,
1172 });
1173 let (s, c) = update(s, Msg::Tick);
1174 assert!(c.is_done());
1175 assert_eq!(s.loading.as_ref().unwrap().frame, 1);
1176 }
1177
1178 #[test]
1179 fn tick_is_noop_when_not_loading() {
1180 let s = state(vec![cache("a", 1, 0)]);
1181 let (s, c) = update(s, Msg::Tick);
1182 assert!(c.is_done());
1183 assert!(s.loading.is_none());
1184 }
1185
1186 #[test]
1187 fn space_toggle_advances_cursor() {
1188 let s = state(vec![cache("a", 1, 0), cache("b", 1, 0), cache("c", 1, 0)]);
1189 let (s, c) = update(s, Msg::ToggleMark);
1190 assert!(c.is_done());
1191 assert_eq!(s.cursor, 1, "cursor should advance after Space");
1192 let (s, c) = update(s, Msg::ToggleMark);
1193 assert!(c.is_done());
1194 assert_eq!(s.cursor, 2);
1195 let (s, c) = update(s, Msg::ToggleMark);
1196 assert!(c.is_done());
1197 assert_eq!(s.cursor, 2);
1199 }
1200
1201 #[test]
1202 fn drill_out_with_path_returns_popped_path() {
1203 let mut s = state(vec![cache("npm", 100, 0)]);
1204 s.drill_paths.push(std::path::PathBuf::from("/x/npm"));
1205 s.drill_into(vec![cache("registry", 10, 0)]);
1206 let popped = s.drill_out_with_path();
1207 assert_eq!(popped, Some(std::path::PathBuf::from("/x/npm")));
1208 }
1209
1210 #[test]
1211 fn drill_out_with_path_at_top_returns_none() {
1212 let mut s = state(vec![cache("npm", 100, 0)]);
1213 assert_eq!(s.drill_out_with_path(), None);
1214 }
1215
1216 #[test]
1217 fn level_dirty_resets_on_drill_in() {
1218 let mut s = state(vec![cache("npm", 100, 0)]);
1219 s.level_dirty = true;
1220 s.drill_into(vec![cache("registry", 10, 0)]);
1221 assert!(!s.level_dirty);
1222 }
1223
1224 #[test]
1225 fn level_dirty_resets_on_drill_out() {
1226 let mut s = state(vec![cache("npm", 100, 0)]);
1227 s.drill_into(vec![cache("registry", 10, 0)]);
1228 s.level_dirty = true;
1229 s.drill_out_with_path();
1230 assert!(!s.level_dirty);
1231 }
1232
1233 #[test]
1234 fn drilldown_delete_drillout_refreshes_parent() {
1235 let mut s = state(vec![cache("npm", 100, 0), cache("cargo", 50, 0)]);
1238 s.cursor = 0;
1239 s.dry_run = false;
1240
1241 let (s, cmd) = update(s, Msg::DrillIn);
1243 let parent_path = match cmd.effects.as_slice() {
1244 [Effect::SpawnScan { parent_path, .. }] => parent_path.clone(),
1245 other => panic!("expected SpawnScan, got {other:?}"),
1246 };
1247 assert_eq!(parent_path, std::path::PathBuf::from("/x/npm"));
1248
1249 let (s, _) = update(
1251 s,
1252 Msg::ScanCompleted {
1253 parent_label: "npm".into(),
1254 parent_path: parent_path.clone(),
1255 children: vec![cache("registry", 60, 0), cache("logs", 40, 0)],
1256 },
1257 );
1258 assert_eq!(s.all.len(), 2);
1259 assert_eq!(s.stack.len(), 1);
1260 assert_eq!(s.drill_paths.last(), Some(&parent_path));
1261 assert!(!s.level_dirty, "fresh level starts clean");
1262
1263 let mut s = s;
1265 s.marks.marked.insert(0);
1266 let (s, cmd) = update(s, Msg::ConfirmDelete);
1267 assert!(matches!(
1268 cmd.effects.as_slice(),
1269 [Effect::SpawnDelete { .. }]
1270 ));
1271
1272 let (s, _) = update(
1274 s,
1275 Msg::DeleteCompleted {
1276 freed: 60,
1277 deleted_count: 1,
1278 failed_count: 0,
1279 deleted_indices: vec![0],
1280 },
1281 );
1282 assert!(
1283 s.level_dirty,
1284 "DeleteCompleted on a real run must mark the level dirty"
1285 );
1286 assert!(s.loading.is_none(), "delete spinner cleared");
1287 assert!(s.overlay.is_some(), "overlay shown");
1288
1289 let (s, cmd) = update(s, Msg::DrillOut);
1292 match cmd.effects.as_slice() {
1293 [Effect::SpawnRefresh { path }] => {
1294 assert_eq!(*path, parent_path, "refresh target must be /x/npm");
1295 }
1296 other => panic!("expected SpawnRefresh, got {other:?}"),
1297 }
1298 assert_eq!(s.all[0].label, "npm");
1299 assert!(s.loading.is_some(), "refresh spinner shown");
1300
1301 let updated = Cache {
1303 label: "npm".into(),
1304 path: parent_path.clone(),
1305 size_bytes: 40,
1306 newest_mtime: None,
1307 file_count: 0,
1308 dir_count: 0,
1309 top_files: Vec::new(),
1310 unreadable: 0,
1311 };
1312 let (s, _) = update(
1313 s,
1314 Msg::RefreshCompleted {
1315 path: parent_path,
1316 cache: updated,
1317 },
1318 );
1319 assert_eq!(
1320 s.all[0].size_bytes, 40,
1321 "parent row must reflect post-delete size"
1322 );
1323 assert!(s.loading.is_none());
1324 }
1325
1326 #[test]
1327 fn drilldown_delete_propagates_dirty_up_the_full_stack() {
1328 let mut s = state(vec![cache("npm", 100, 0)]);
1333 s.drill_paths.push(std::path::PathBuf::from("/x/npm"));
1335 s.drill_into(vec![cache("registry", 60, 0)]);
1336 s.drill_paths
1338 .push(std::path::PathBuf::from("/x/npm/registry"));
1339 s.drill_into(vec![cache("v1", 30, 0), cache("v2", 30, 0)]);
1340 s.level_dirty = true;
1342
1343 let (mut s, cmd) = update(s, Msg::DrillOut);
1345 assert!(matches!(
1346 cmd.effects.as_slice(),
1347 [Effect::SpawnRefresh { path }] if path == &std::path::PathBuf::from("/x/npm/registry")
1348 ));
1349 assert!(s.level_dirty, "L1 inherits dirtiness from the propagation");
1350 assert!(s.loading.is_some());
1351 s.loading = None;
1353
1354 let (s, cmd) = update(s, Msg::DrillOut);
1356 assert!(matches!(
1357 cmd.effects.as_slice(),
1358 [Effect::SpawnRefresh { path }] if path == &std::path::PathBuf::from("/x/npm")
1359 ));
1360 assert!(s.loading.is_some());
1361 }
1362
1363 #[test]
1364 fn drill_out_restores_cursor_to_pre_drill_position() {
1365 let mut s = state(vec![
1368 cache("a", 1, 0),
1369 cache("b", 1, 0),
1370 cache("c", 1, 0),
1371 cache("npm", 100, 0),
1372 cache("e", 1, 0),
1373 ]);
1374 s.cursor = 3; s.drill_into(vec![cache("registry", 10, 0), cache("logs", 5, 0)]);
1376 assert_eq!(s.cursor, 0, "drill_into resets cursor to 0 in the child");
1377 s.drill_out();
1378 assert_eq!(
1379 s.cursor, 3,
1380 "drill_out must restore the cursor the user had on the parent"
1381 );
1382 }
1383
1384 #[test]
1385 fn drill_out_clamps_restored_cursor_to_visible() {
1386 let mut s = state(vec![cache("a", 1, 0), cache("b", 1, 0)]);
1391 s.cursor = 1;
1392 s.drill_into(vec![cache("x", 1, 0)]);
1393 if let Some(parent) = s.stack.last_mut() {
1397 *parent = vec![cache("a", 1, 0)];
1398 }
1399 s.drill_out();
1400 assert_eq!(s.cursor, 0, "cursor must clamp into the restored vec");
1401 }
1402
1403 #[test]
1404 fn drill_in_clears_marks() {
1405 let mut s = state(vec![cache("npm", 100, 0)]);
1406 s.marks.toggle(0);
1407 s.drill_into(vec![cache("a", 1, 0)]);
1408 assert_eq!(s.marks.count(), 0);
1409 }
1410
1411 #[test]
1412 fn drill_in_emits_scan_effect_and_sets_loading() {
1413 let s = state(vec![cache("npm", 100, 0)]);
1414 let (s, cmd) = update(s, Msg::DrillIn);
1415 let l = s.loading.as_ref().expect("loading set");
1416 assert_eq!(
1417 l.folders,
1418 Some(0),
1419 "drill-in spinner seeds the same folder-count UX as LoadSeeds"
1420 );
1421 assert!(matches!(cmd.effects.as_slice(), [Effect::SpawnScan { .. }]));
1422 }
1423
1424 #[test]
1425 fn drill_in_with_empty_list_is_noop() {
1426 let s = state(Vec::new());
1427 let (s, cmd) = update(s, Msg::DrillIn);
1428 assert!(s.loading.is_none());
1429 assert!(cmd.is_done());
1430 }
1431
1432 #[test]
1433 fn scan_completed_drills_into_children() {
1434 let s = state(vec![cache("npm", 100, 0)]);
1435 let (s, cmd) = update(
1436 s,
1437 Msg::ScanCompleted {
1438 parent_label: "npm".into(),
1439 parent_path: std::path::PathBuf::from("/x/npm"),
1440 children: vec![cache("registry", 10, 0), cache("cache", 5, 0)],
1441 },
1442 );
1443 assert_eq!(s.all.len(), 2);
1444 assert_eq!(s.stack.len(), 1);
1445 assert_eq!(
1446 s.drill_paths.last().unwrap(),
1447 &std::path::PathBuf::from("/x/npm")
1448 );
1449 assert!(s.loading.is_none());
1450 assert!(cmd.is_done());
1451 }
1452
1453 #[test]
1454 fn scan_completed_empty_children_just_clears_loading() {
1455 let mut s = state(vec![cache("npm", 100, 0)]);
1456 s.loading = Some(Loading {
1457 label: "scanning npm".into(),
1458 frame: 0,
1459 started: std::time::Instant::now(),
1460 folders: None,
1461 });
1462 let (s, cmd) = update(
1463 s,
1464 Msg::ScanCompleted {
1465 parent_label: "npm".into(),
1466 parent_path: std::path::PathBuf::from("/x/npm"),
1467 children: vec![],
1468 },
1469 );
1470 assert_eq!(s.all.len(), 1);
1471 assert!(s.loading.is_none());
1472 assert!(cmd.is_done());
1473 }
1474
1475 #[test]
1476 fn scan_progress_updates_loading_folder_count() {
1477 let mut s = state(Vec::new());
1478 s.loading = Some(Loading {
1479 label: "scanning caches".into(),
1480 frame: 0,
1481 started: std::time::Instant::now(),
1482 folders: Some(0),
1483 });
1484 let (s, cmd) = update(s, Msg::ScanProgress { folders: 1234 });
1485 assert!(cmd.is_done());
1486 assert_eq!(s.loading.as_ref().unwrap().folders, Some(1234));
1487 }
1488
1489 #[test]
1490 fn scan_progress_is_noop_when_not_loading() {
1491 let s = state(vec![cache("a", 1, 0)]);
1492 let (s, cmd) = update(s, Msg::ScanProgress { folders: 5 });
1493 assert!(cmd.is_done());
1494 assert!(s.loading.is_none());
1495 }
1496
1497 #[test]
1498 fn seeds_loaded_replaces_all_and_clears_loading() {
1499 let mut s = state(Vec::new());
1503 s.loading = Some(Loading {
1504 label: "scanning caches".into(),
1505 frame: 3,
1506 started: std::time::Instant::now(),
1507 folders: None,
1508 });
1509 s.cursor = 7; let (s, cmd) = update(
1511 s,
1512 Msg::SeedsLoaded {
1513 caches: vec![cache("npm", 100, 0), cache("cargo", 50, 0)],
1514 },
1515 );
1516 assert_eq!(s.all.len(), 2);
1517 assert_eq!(s.cursor, 0);
1518 assert!(s.loading.is_none());
1519 assert!(cmd.is_done());
1520 }
1521
1522 #[test]
1523 fn drill_out_when_clean_returns_done() {
1524 let mut s = state(vec![cache("npm", 100, 0)]);
1525 s.drill_into(vec![cache("registry", 10, 0)]);
1526 let (s, cmd) = update(s, Msg::DrillOut);
1528 assert!(cmd.is_done());
1529 assert_eq!(s.all[0].label, "npm");
1530 }
1531
1532 #[test]
1533 fn drill_out_is_noop_while_loading() {
1534 let mut s = state(vec![cache("npm", 100, 0)]);
1538 s.drill_into(vec![cache("registry", 10, 0)]);
1539 s.loading = Some(Loading {
1540 label: "deleting 1 cache".into(),
1541 frame: 0,
1542 started: std::time::Instant::now(),
1543 folders: None,
1544 });
1545 let (s, cmd) = update(s, Msg::DrillOut);
1546 assert!(cmd.is_done(), "no refresh effect must be emitted");
1547 assert_eq!(s.all[0].label, "registry", "stack must not be popped");
1548 assert_eq!(s.stack.len(), 1);
1549 }
1550
1551 #[test]
1552 fn drill_out_when_dirty_emits_refresh_effect() {
1553 let mut s = state(vec![cache("npm", 100, 0)]);
1554 s.drill_paths.push(std::path::PathBuf::from("/x/npm"));
1555 s.drill_into(vec![cache("registry", 10, 0)]);
1556 s.level_dirty = true;
1557 let (s, cmd) = update(s, Msg::DrillOut);
1558 assert!(s.loading.is_some());
1559 assert!(matches!(
1560 cmd.effects.as_slice(),
1561 [Effect::SpawnRefresh { .. }]
1562 ));
1563 }
1564
1565 #[test]
1566 fn refresh_completed_replaces_matching_cache() {
1567 let mut s = state(vec![cache("a", 100, 0), cache("b", 200, 0)]);
1568 s.loading = Some(Loading {
1569 label: "x".into(),
1570 frame: 0,
1571 started: std::time::Instant::now(),
1572 folders: None,
1573 });
1574 let updated = Cache {
1575 label: "b".into(),
1576 path: std::path::PathBuf::from("/x/b"),
1577 size_bytes: 999,
1578 newest_mtime: None,
1579 file_count: 0,
1580 dir_count: 0,
1581 top_files: Vec::new(),
1582 unreadable: 0,
1583 };
1584 let (s, cmd) = update(
1585 s,
1586 Msg::RefreshCompleted {
1587 path: std::path::PathBuf::from("/x/b"),
1588 cache: updated,
1589 },
1590 );
1591 assert_eq!(s.all[1].size_bytes, 999);
1592 assert!(s.loading.is_none());
1593 assert!(cmd.is_done());
1594 }
1595
1596 #[test]
1597 fn refresh_completed_unknown_path_clears_loading() {
1598 let mut s = state(vec![cache("a", 100, 0)]);
1599 s.loading = Some(Loading {
1600 label: "x".into(),
1601 frame: 0,
1602 started: std::time::Instant::now(),
1603 folders: None,
1604 });
1605 let (s, cmd) = update(
1606 s,
1607 Msg::RefreshCompleted {
1608 path: std::path::PathBuf::from("/x/gone"),
1609 cache: cache("gone", 1, 0),
1610 },
1611 );
1612 assert_eq!(s.all[0].size_bytes, 100);
1613 assert!(s.loading.is_none());
1614 assert!(cmd.is_done());
1615 }
1616
1617 #[test]
1618 fn delete_pressed_with_yes_mode_chains_confirm_event() {
1619 let mut s = state(vec![cache("a", 1, 0)]);
1620 s.marks.toggle(0);
1621 s.yes_mode = true;
1622 let (s, cmd) = update(s, Msg::DeletePressed);
1623 assert!(matches!(s.modal, Modal::DeleteConfirm));
1624 assert!(matches!(cmd.events.as_slice(), [Msg::ConfirmDelete]));
1625 }
1626
1627 #[test]
1628 fn delete_pressed_without_yes_mode_just_opens_modal() {
1629 let mut s = state(vec![cache("a", 1, 0)]);
1630 s.marks.toggle(0);
1631 s.yes_mode = false;
1632 let (s, cmd) = update(s, Msg::DeletePressed);
1633 assert!(matches!(s.modal, Modal::DeleteConfirm));
1634 assert!(cmd.events.is_empty());
1635 assert!(cmd.effects.is_empty());
1636 }
1637}