1mod block;
28mod layout;
29mod lifecycle;
30mod snapshot;
31mod tab;
32mod window;
33mod workspace;
34
35use agentmux_common::ipc::{Command, ErrorCode, Event};
36use crate::state::State;
37#[cfg(test)]
41use agentmux_common::ipc::{ClientKind, LifecyclePhase};
42#[cfg(test)]
43use crate::state::ProcessState;
44
45#[derive(Debug, Clone)]
48pub struct Ctx {
49 pub now_rfc3339: String,
50 pub conn_id: u64,
51 pub registered_pid: Option<u32>,
52}
53
54pub fn update(state: &mut State, cmd: Command, ctx: &Ctx) -> Vec<Event> {
55 match cmd {
56 Command::Register { kind, pid, version } => lifecycle::handle_register(state, ctx, kind, pid, version),
57 Command::Goodbye => lifecycle::handle_goodbye(state, ctx),
58 Command::Ping { nonce } => {
59 let v = state.bump_version();
60 vec![Event::Pong { nonce, version: v }]
61 }
62 Command::GetSrvSnapshot => snapshot::handle_get_srv_snapshot(state),
63 Command::GetEvents { .. } => Vec::new(), Command::CreateWorkspace { name } => workspace::handle_create_workspace(state, name),
65 Command::DeleteWorkspace { workspace_id, force } => {
66 workspace::handle_delete_workspace(state, workspace_id, force)
67 }
68 Command::CreateTab { workspace_id, name } => tab::handle_create_tab(state, workspace_id, name),
69 Command::DeleteTab { workspace_id, tab_id, force } => tab::handle_delete_tab(state, workspace_id, tab_id, force),
70 Command::SetActiveTab { workspace_id, tab_id } => {
71 tab::handle_set_active_tab(state, workspace_id, tab_id)
72 }
73 Command::ReorderTab {
74 workspace_id,
75 tab_id,
76 new_index,
77 } => tab::handle_reorder_tab(state, workspace_id, tab_id, new_index),
78 Command::CreateBlock { tab_id, meta } => block::handle_create_block(state, tab_id, meta),
79 Command::DeleteBlock { tab_id, block_id } => block::handle_delete_block(state, tab_id, block_id),
80 Command::SetFocusedNode { tab_id, node_id } => {
81 layout::handle_set_focused_node(state, tab_id, node_id)
82 }
83 Command::SetMagnifiedNode { tab_id, node_id } => {
84 layout::handle_set_magnified_node(state, tab_id, node_id)
85 }
86 Command::LayoutClear {
93 tab_id,
94 correlation_id,
95 } => layout::handle_layout_clear(state, tab_id, correlation_id),
96 Command::LayoutSetTree {
97 tab_id,
98 new_tree,
99 correlation_id,
100 } => layout::handle_layout_set_tree(state, tab_id, new_tree, correlation_id),
101 Command::LayoutInsertNode {
102 tab_id,
103 node,
104 parent_id,
105 index,
106 focus_after,
107 magnify_after,
108 correlation_id,
109 } => layout::handle_layout_insert_node(
110 state,
111 tab_id,
112 node,
113 parent_id,
114 index,
115 focus_after,
116 magnify_after,
117 correlation_id,
118 ),
119 Command::LayoutDeleteNode {
120 tab_id,
121 node_id,
122 correlation_id,
123 } => layout::handle_layout_delete_node(state, tab_id, node_id, correlation_id),
124 Command::CreateWindow {
125 window_id,
126 workspace_id,
127 } => window::handle_create_window(state, window_id, workspace_id),
128 Command::CloseWindowInternal { window_id } => {
129 window::handle_close_window_internal(state, window_id)
130 }
131 Command::SwitchWorkspace {
132 window_id,
133 workspace_id,
134 } => window::handle_switch_workspace(state, window_id, workspace_id),
135 Command::ReorderTabsBulk {
136 workspace_id,
137 tab_ids,
138 } => tab::handle_reorder_tabs_bulk(state, workspace_id, tab_ids),
139 Command::RenameWorkspace { workspace_id, name } => {
140 workspace::handle_rename_workspace(state, workspace_id, name)
141 }
142 Command::RenameTab { tab_id, name } => tab::handle_rename_tab(state, tab_id, name),
143 Command::UpdateWorkspaceMeta {
144 workspace_id,
145 meta_patch,
146 } => workspace::handle_update_workspace_meta(state, workspace_id, meta_patch),
147 Command::UpdateTabMeta {
148 tab_id,
149 meta_patch,
150 } => tab::handle_update_tab_meta(state, tab_id, meta_patch),
151 Command::UpdateBlockMeta {
152 block_id,
153 meta_patch,
154 } => block::handle_update_block_meta(state, block_id, meta_patch),
155 Command::UpdateWindowMeta {
156 window_id,
157 meta_patch,
158 } => window::handle_update_window_meta(state, window_id, meta_patch),
159 Command::MoveTab {
160 tab_id,
161 src_workspace_id,
162 dst_workspace_id,
163 dst_index,
164 } => tab::handle_move_tab(state, tab_id, src_workspace_id, dst_workspace_id, dst_index),
165 Command::MoveBlock {
166 block_id,
167 src_tab_id,
168 dst_tab_id,
169 dst_index,
170 } => block::handle_move_block(state, block_id, src_tab_id, dst_tab_id, dst_index),
171 other => {
175 let v = state.bump_version();
176 vec![Event::Error {
177 code: ErrorCode::InvalidCommand,
178 message: format!("srv reducer does not accept: {:?}", other),
179 fatal: false,
180 version: v,
181 }]
182 }
183 }
184}
185
186#[cfg(test)]
187mod tests {
188 use super::*;
189
190 fn ctx(conn_id: u64) -> Ctx {
191 Ctx {
192 now_rfc3339: "2026-04-30T00:00:00Z".to_string(),
193 conn_id,
194 registered_pid: None,
195 }
196 }
197
198 fn ctx_with_pid(conn_id: u64, pid: u32) -> Ctx {
199 Ctx {
200 now_rfc3339: "2026-04-30T00:00:00Z".to_string(),
201 conn_id,
202 registered_pid: Some(pid),
203 }
204 }
205
206 fn extract_version(e: &Event) -> u64 {
207 match e {
208 Event::ProcessSpawned { version, .. }
209 | Event::ProcessExited { version, .. }
210 | Event::LifecyclePhaseChanged { version, .. }
211 | Event::Registered { version, .. }
212 | Event::Pong { version, .. }
213 | Event::WindowOpened { version, .. }
214 | Event::WindowClosed { version, .. }
215 | Event::PoolWindowAdded { version, .. }
216 | Event::PoolWindowRemoved { version, .. }
217 | Event::PoolWindowPromoted { version, .. }
218 | Event::PanesReaped { version, .. }
219 | Event::PoolDrained { version, .. }
220 | Event::PoolNotLast { version, .. }
221 | Event::WindowInstanceAssigned { version, .. }
222 | Event::WindowInstanceReleased { version, .. }
223 | Event::BackendWindowIdRegistered { version, .. }
224 | Event::BackendWindowIdUnregistered { version, .. }
225 | Event::DriftDetected { version, .. }
226 | Event::HwndDriftDetected { version, .. }
227 | Event::CorrectiveWindowMove { version, .. }
228 | Event::HostShouldQuit { version, .. }
229 | Event::Snapshot { version, .. }
230 | Event::EventList { version, .. }
231 | Event::SrvSnapshot { version, .. }
232 | Event::SagaStarted { version, .. }
233 | Event::SagaCompleted { version, .. }
234 | Event::SagaFailed { version, .. }
235 | Event::WorkspaceCreated { version, .. }
236 | Event::WorkspaceDeleted { version, .. }
237 | Event::TabCreated { version, .. }
238 | Event::TabDeleted { version, .. }
239 | Event::ActiveTabChanged { version, .. }
240 | Event::TabReordered { version, .. }
241 | Event::BlockCreated { version, .. }
242 | Event::BlockDeleted { version, .. }
243 | Event::SrvWindowOpened { version, .. }
244 | Event::SrvWindowClosed { version, .. }
245 | Event::SrvWindowWorkspaceChanged { version, .. }
246 | Event::TabsReorderedBulk { version, .. }
247 | Event::WorkspaceRenamed { version, .. }
248 | Event::TabRenamed { version, .. }
249 | Event::WorkspaceMetaUpdated { version, .. }
250 | Event::TabMetaUpdated { version, .. }
251 | Event::BlockMetaUpdated { version, .. }
252 | Event::WindowMetaUpdated { version, .. }
253 | Event::TabMoved { version, .. }
254 | Event::BlockMoved { version, .. }
255 | Event::FocusedNodeChanged { version, .. }
256 | Event::MagnifiedNodeChanged { version, .. }
257 | Event::SagaActionFailed { version, .. }
258 | Event::Error { version, .. }
259 | Event::LayoutNodeInserted { version, .. }
261 | Event::LayoutNodeInsertedAtIndex { version, .. }
262 | Event::LayoutNodeDeleted { version, .. }
263 | Event::LayoutNodeMoved { version, .. }
264 | Event::LayoutNodesSwapped { version, .. }
265 | Event::LayoutNodesResized { version, .. }
266 | Event::LayoutNodeReplaced { version, .. }
267 | Event::LayoutSplitHorizontalApplied { version, .. }
268 | Event::LayoutSplitVerticalApplied { version, .. }
269 | Event::LayoutCleared { version, .. }
270 | Event::LayoutTreeReplaced { version, .. } => *version,
271 }
272 }
273
274 #[test]
275 fn first_register_transitions_to_running() {
276 let mut state = State::default();
277 let events = update(
278 &mut state,
279 Command::Register {
280 kind: ClientKind::Host,
281 pid: 100,
282 version: "0.0.0".into(),
283 },
284 &ctx(1),
285 );
286 assert_eq!(state.lifecycle, LifecyclePhase::Running);
287 assert!(state.processes.contains_key(&100));
288 assert_eq!(events.len(), 3);
289 assert!(matches!(&events[0], Event::ProcessSpawned { pid: 100, .. }));
290 assert!(matches!(
291 &events[1],
292 Event::LifecyclePhaseChanged {
293 from: LifecyclePhase::Starting,
294 to: LifecyclePhase::Running,
295 ..
296 }
297 ));
298 assert!(matches!(&events[2], Event::Registered { .. }));
299 }
300
301 #[test]
302 fn second_register_does_not_re_emit_lifecycle_change() {
303 let mut state = State::default();
304 let _ = update(
305 &mut state,
306 Command::Register {
307 kind: ClientKind::Host,
308 pid: 100,
309 version: "v1".into(),
310 },
311 &ctx(1),
312 );
313 let events = update(
314 &mut state,
315 Command::Register {
316 kind: ClientKind::Tool,
317 pid: 200,
318 version: "v2".into(),
319 },
320 &ctx(2),
321 );
322 assert_eq!(state.lifecycle, LifecyclePhase::Running);
323 assert_eq!(events.len(), 2);
325 assert!(events
326 .iter()
327 .all(|e| !matches!(e, Event::LifecyclePhaseChanged { .. })));
328 }
329
330 #[test]
331 fn duplicate_register_returns_already_registered_error() {
332 let mut state = State::default();
333 let _ = update(
334 &mut state,
335 Command::Register {
336 kind: ClientKind::Host,
337 pid: 100,
338 version: "v1".into(),
339 },
340 &ctx(1),
341 );
342 let events = update(
343 &mut state,
344 Command::Register {
345 kind: ClientKind::Host,
346 pid: 100,
347 version: "v2".into(),
348 },
349 &ctx(2),
350 );
351 assert!(matches!(
352 &events[0],
353 Event::Error {
354 code: ErrorCode::AlreadyRegistered,
355 ..
356 }
357 ));
358 assert_eq!(&state.processes[&100].version, "v1");
360 }
361
362 #[test]
363 fn register_replaces_exited_record_for_recycled_pid() {
364 let mut state = State::default();
365 let _ = update(
366 &mut state,
367 Command::Register {
368 kind: ClientKind::Host,
369 pid: 100,
370 version: "v1".into(),
371 },
372 &ctx(1),
373 );
374 let _ = update(&mut state, Command::Goodbye, &ctx_with_pid(1, 100));
375 let events = update(
377 &mut state,
378 Command::Register {
379 kind: ClientKind::Host,
380 pid: 100,
381 version: "v2".into(),
382 },
383 &ctx(2),
384 );
385 assert!(matches!(&events[0], Event::ProcessSpawned { .. }));
386 assert_eq!(&state.processes[&100].version, "v2");
387 assert!(matches!(state.processes[&100].state, ProcessState::Running));
388 }
389
390 #[test]
391 fn ping_returns_pong_with_same_nonce() {
392 let mut state = State::default();
393 let events = update(&mut state, Command::Ping { nonce: 42 }, &ctx(1));
394 assert!(matches!(&events[0], Event::Pong { nonce: 42, .. }));
395 }
396
397 #[test]
398 fn get_srv_snapshot_returns_lifecycle_and_bumps_version() {
399 let mut state = State::default();
400 let v0 = state.event_version;
401 let events = update(&mut state, Command::GetSrvSnapshot, &ctx(1));
402 assert_eq!(events.len(), 1);
403 let Event::SrvSnapshot { version, lifecycle, .. } = events[0].clone() else {
404 panic!("expected SrvSnapshot, got {:?}", events[0]);
405 };
406 assert_eq!(lifecycle, LifecyclePhase::Starting);
407 assert!(version > v0);
408 }
409
410 #[test]
411 fn unaccepted_command_returns_invalid_command_error() {
412 let mut state = State::default();
413 let events = update(
414 &mut state,
415 Command::ReportWindowOpened {
416 label: "main".into(),
417 kind: agentmux_common::ipc::WindowKind::FullInstance,
418 parent_label: None,
419 },
420 &ctx(1),
421 );
422 assert!(matches!(
423 &events[0],
424 Event::Error {
425 code: ErrorCode::InvalidCommand,
426 fatal: false,
427 ..
428 }
429 ));
430 }
431
432 #[test]
433 fn versions_strictly_monotonic_across_sequence() {
434 let mut state = State::default();
435 let mut versions = vec![];
436 for pid in [100, 200, 300] {
437 let events = update(
438 &mut state,
439 Command::Register {
440 kind: ClientKind::Host,
441 pid,
442 version: "v".into(),
443 },
444 &ctx(pid as u64),
445 );
446 versions.extend(events.iter().map(extract_version));
447 }
448 for w in versions.windows(2) {
449 assert!(w[1] > w[0], "version regression: {} -> {}", w[0], w[1]);
450 }
451 }
452
453 #[test]
454 fn create_workspace_inserts_record_and_emits_event() {
455 let mut state = State::default();
456 let events = update(
457 &mut state,
458 Command::CreateWorkspace {
459 name: "myws".into(),
460 },
461 &ctx(1),
462 );
463 assert_eq!(events.len(), 1);
464 let Event::WorkspaceCreated {
465 workspace_id, name, ..
466 } = &events[0]
467 else {
468 panic!("expected WorkspaceCreated, got {:?}", events[0]);
469 };
470 assert_eq!(name, "myws");
471 assert!(state.workspaces.contains_key(workspace_id));
472 assert_eq!(state.workspaces[workspace_id].name, "myws");
473 }
474
475 #[test]
476 fn delete_workspace_removes_record_and_emits_event() {
477 let mut state = State::default();
478 let events = update(
479 &mut state,
480 Command::CreateWorkspace {
481 name: "to-delete".into(),
482 },
483 &ctx(1),
484 );
485 let Event::WorkspaceCreated { workspace_id, .. } = &events[0] else {
486 panic!();
487 };
488 let ws_id = workspace_id.clone();
489 let events = update(
490 &mut state,
491 Command::DeleteWorkspace {
492 workspace_id: ws_id.clone(),
493 force: false,
494 },
495 &ctx(2),
496 );
497 assert_eq!(events.len(), 1);
498 assert!(matches!(
499 &events[0],
500 Event::WorkspaceDeleted { workspace_id, .. } if workspace_id == &ws_id
501 ));
502 assert!(!state.workspaces.contains_key(&ws_id));
503 }
504
505 #[test]
506 fn delete_workspace_unknown_is_silent_no_op() {
507 let mut state = State::default();
508 let events = update(
509 &mut state,
510 Command::DeleteWorkspace {
511 workspace_id: "does-not-exist".into(),
512 force: false,
513 },
514 &ctx(1),
515 );
516 assert!(events.is_empty());
517 }
518
519 #[test]
520 fn snapshot_includes_workspaces_sorted_by_id() {
521 let mut state = State::default();
522 let _ = update(
523 &mut state,
524 Command::CreateWorkspace { name: "a".into() },
525 &ctx(1),
526 );
527 let _ = update(
528 &mut state,
529 Command::CreateWorkspace { name: "b".into() },
530 &ctx(2),
531 );
532 let events = update(&mut state, Command::GetSrvSnapshot, &ctx(3));
533 let Event::SrvSnapshot { workspaces, .. } = &events[0] else {
534 panic!();
535 };
536 assert_eq!(workspaces.len(), 2);
537 assert!(workspaces[0].0 < workspaces[1].0);
539 }
540
541 fn create_workspace(state: &mut State, name: &str) -> String {
542 let events = update(
543 state,
544 Command::CreateWorkspace { name: name.into() },
545 &ctx(1),
546 );
547 match &events[0] {
548 Event::WorkspaceCreated { workspace_id, .. } => workspace_id.clone(),
549 _ => panic!("expected WorkspaceCreated"),
550 }
551 }
552
553 #[test]
554 fn create_tab_validates_workspace_exists() {
555 let mut state = State::default();
556 let events = update(
557 &mut state,
558 Command::CreateTab {
559 workspace_id: "no-such-ws".into(),
560 name: "t1".into(),
561 },
562 &ctx(1),
563 );
564 assert!(matches!(&events[0], Event::Error { .. }));
565 assert!(state.tabs.is_empty());
566 }
567
568 #[test]
569 fn create_tab_first_tab_becomes_active() {
570 let mut state = State::default();
571 let ws_id = create_workspace(&mut state, "w");
572 let events = update(
573 &mut state,
574 Command::CreateTab {
575 workspace_id: ws_id.clone(),
576 name: "t1".into(),
577 },
578 &ctx(1),
579 );
580 assert!(matches!(&events[0], Event::TabCreated { .. }));
582 assert!(matches!(&events[1], Event::ActiveTabChanged { .. }));
583 let workspace = &state.workspaces[&ws_id];
584 assert_eq!(workspace.tab_ids.len(), 1);
585 assert_eq!(workspace.active_tab_id, Some(workspace.tab_ids[0].clone()));
586 assert_eq!(state.tabs.len(), 1);
587 }
588
589 #[test]
590 fn create_tab_second_tab_does_not_steal_active() {
591 let mut state = State::default();
592 let ws_id = create_workspace(&mut state, "w");
593 let _ = update(
594 &mut state,
595 Command::CreateTab {
596 workspace_id: ws_id.clone(),
597 name: "t1".into(),
598 },
599 &ctx(1),
600 );
601 let first_active = state.workspaces[&ws_id].active_tab_id.clone();
602 let events = update(
603 &mut state,
604 Command::CreateTab {
605 workspace_id: ws_id.clone(),
606 name: "t2".into(),
607 },
608 &ctx(2),
609 );
610 assert!(matches!(&events[0], Event::TabCreated { .. }));
612 assert_eq!(events.len(), 1);
613 assert_eq!(state.workspaces[&ws_id].active_tab_id, first_active);
614 assert_eq!(state.workspaces[&ws_id].tab_ids.len(), 2);
615 }
616
617 #[test]
618 fn delete_tab_removes_from_state_and_workspace_list() {
619 let mut state = State::default();
620 let ws_id = create_workspace(&mut state, "w");
621 let _ = update(
622 &mut state,
623 Command::CreateTab {
624 workspace_id: ws_id.clone(),
625 name: "t1".into(),
626 },
627 &ctx(1),
628 );
629 let _ = update(
630 &mut state,
631 Command::CreateTab {
632 workspace_id: ws_id.clone(),
633 name: "t2".into(),
634 },
635 &ctx(2),
636 );
637 let tab2_id = state.workspaces[&ws_id].tab_ids[1].clone();
638 let events = update(
639 &mut state,
640 Command::DeleteTab {
641 workspace_id: ws_id.clone(),
642 tab_id: tab2_id.clone(),
643 force: false,
644 },
645 &ctx(3),
646 );
647 assert!(matches!(&events[0], Event::TabDeleted { .. }));
648 assert_eq!(events.len(), 1);
650 assert!(!state.tabs.contains_key(&tab2_id));
651 assert_eq!(state.workspaces[&ws_id].tab_ids.len(), 1);
652 }
653
654 #[test]
655 fn delete_active_tab_promotes_neighbor() {
656 let mut state = State::default();
657 let ws_id = create_workspace(&mut state, "w");
658 let _ = update(
659 &mut state,
660 Command::CreateTab {
661 workspace_id: ws_id.clone(),
662 name: "t1".into(),
663 },
664 &ctx(1),
665 );
666 let _ = update(
667 &mut state,
668 Command::CreateTab {
669 workspace_id: ws_id.clone(),
670 name: "t2".into(),
671 },
672 &ctx(2),
673 );
674 let tab1_id = state.workspaces[&ws_id].tab_ids[0].clone();
675 let tab2_id = state.workspaces[&ws_id].tab_ids[1].clone();
676 let events = update(
678 &mut state,
679 Command::DeleteTab {
680 workspace_id: ws_id.clone(),
681 tab_id: tab1_id.clone(),
682 force: false,
683 },
684 &ctx(3),
685 );
686 assert!(matches!(&events[0], Event::TabDeleted { .. }));
687 assert!(matches!(
688 &events[1],
689 Event::ActiveTabChanged { tab_id: Some(_), .. }
690 ));
691 assert_eq!(state.workspaces[&ws_id].active_tab_id, Some(tab2_id));
693 }
694
695 #[test]
696 fn delete_last_tab_clears_active_to_none() {
697 let mut state = State::default();
703 let ws_id = create_workspace(&mut state, "w");
704 let _ = update(
705 &mut state,
706 Command::CreateTab {
707 workspace_id: ws_id.clone(),
708 name: "t1".into(),
709 },
710 &ctx(1),
711 );
712 let tab1_id = state.workspaces[&ws_id].tab_ids[0].clone();
713 let events = update(
714 &mut state,
715 Command::DeleteTab {
716 workspace_id: ws_id.clone(),
717 tab_id: tab1_id,
718 force: true,
723 },
724 &ctx(2),
725 );
726 assert!(matches!(&events[0], Event::TabDeleted { .. }));
727 assert!(matches!(
728 &events[1],
729 Event::ActiveTabChanged { tab_id: None, .. }
730 ));
731 assert_eq!(state.workspaces[&ws_id].active_tab_id, None);
732 assert!(state.tabs.is_empty());
733 }
734
735 #[test]
736 fn delete_unknown_tab_silent_no_op() {
737 let mut state = State::default();
738 let ws_id = create_workspace(&mut state, "w");
739 let events = update(
740 &mut state,
741 Command::DeleteTab {
742 workspace_id: ws_id,
743 tab_id: "ghost".into(),
744 force: false,
745 },
746 &ctx(1),
747 );
748 assert!(events.is_empty());
749 }
750
751 #[test]
752 fn set_active_tab_validates_workspace_and_tab() {
753 let mut state = State::default();
754 let ws_id = create_workspace(&mut state, "w");
755 let _ = update(
756 &mut state,
757 Command::CreateTab {
758 workspace_id: ws_id.clone(),
759 name: "t1".into(),
760 },
761 &ctx(1),
762 );
763 let events = update(
765 &mut state,
766 Command::SetActiveTab {
767 workspace_id: "no-such".into(),
768 tab_id: "x".into(),
769 },
770 &ctx(2),
771 );
772 assert!(matches!(&events[0], Event::Error { .. }));
773 let events = update(
775 &mut state,
776 Command::SetActiveTab {
777 workspace_id: ws_id,
778 tab_id: "ghost".into(),
779 },
780 &ctx(3),
781 );
782 assert!(matches!(&events[0], Event::Error { .. }));
783 }
784
785 #[test]
786 fn set_active_tab_idempotent_no_event_when_already_active() {
787 let mut state = State::default();
788 let ws_id = create_workspace(&mut state, "w");
789 let _ = update(
790 &mut state,
791 Command::CreateTab {
792 workspace_id: ws_id.clone(),
793 name: "t1".into(),
794 },
795 &ctx(1),
796 );
797 let tab_id = state.workspaces[&ws_id].tab_ids[0].clone();
798 let events = update(
800 &mut state,
801 Command::SetActiveTab {
802 workspace_id: ws_id,
803 tab_id,
804 },
805 &ctx(2),
806 );
807 assert!(events.is_empty());
808 }
809
810 #[test]
811 fn reorder_tab_moves_to_new_index() {
812 let mut state = State::default();
813 let ws_id = create_workspace(&mut state, "w");
814 for i in 1..=3 {
815 let _ = update(
816 &mut state,
817 Command::CreateTab {
818 workspace_id: ws_id.clone(),
819 name: format!("t{}", i),
820 },
821 &ctx(i),
822 );
823 }
824 let original = state.workspaces[&ws_id].tab_ids.clone();
825 let events = update(
827 &mut state,
828 Command::ReorderTab {
829 workspace_id: ws_id.clone(),
830 tab_id: original[0].clone(),
831 new_index: 2,
832 },
833 &ctx(10),
834 );
835 assert!(matches!(&events[0], Event::TabReordered { .. }));
836 let after = &state.workspaces[&ws_id].tab_ids;
837 assert_eq!(after[0], original[1]);
838 assert_eq!(after[1], original[2]);
839 assert_eq!(after[2], original[0]);
840 }
841
842 #[test]
843 fn reorder_tab_clamps_to_last_index() {
844 let mut state = State::default();
845 let ws_id = create_workspace(&mut state, "w");
846 let _ = update(
847 &mut state,
848 Command::CreateTab {
849 workspace_id: ws_id.clone(),
850 name: "t1".into(),
851 },
852 &ctx(1),
853 );
854 let _ = update(
855 &mut state,
856 Command::CreateTab {
857 workspace_id: ws_id.clone(),
858 name: "t2".into(),
859 },
860 &ctx(2),
861 );
862 let original = state.workspaces[&ws_id].tab_ids.clone();
863 let events = update(
865 &mut state,
866 Command::ReorderTab {
867 workspace_id: ws_id.clone(),
868 tab_id: original[0].clone(),
869 new_index: 99,
870 },
871 &ctx(3),
872 );
873 if let Event::TabReordered { new_index, .. } = &events[0] {
874 assert_eq!(*new_index, 1);
875 } else {
876 panic!("expected TabReordered");
877 }
878 }
879
880 #[test]
881 fn reorder_tab_already_at_position_no_op() {
882 let mut state = State::default();
883 let ws_id = create_workspace(&mut state, "w");
884 let _ = update(
885 &mut state,
886 Command::CreateTab {
887 workspace_id: ws_id.clone(),
888 name: "t1".into(),
889 },
890 &ctx(1),
891 );
892 let tab_id = state.workspaces[&ws_id].tab_ids[0].clone();
893 let events = update(
894 &mut state,
895 Command::ReorderTab {
896 workspace_id: ws_id,
897 tab_id,
898 new_index: 0,
899 },
900 &ctx(2),
901 );
902 assert!(events.is_empty());
903 }
904
905 #[test]
906 fn reorder_tab_validates_workspace_and_tab() {
907 let mut state = State::default();
908 let events = update(
909 &mut state,
910 Command::ReorderTab {
911 workspace_id: "no-such-ws".into(),
912 tab_id: "x".into(),
913 new_index: 0,
914 },
915 &ctx(1),
916 );
917 assert!(matches!(&events[0], Event::Error { .. }));
918 let ws_id = create_workspace(&mut state, "w");
919 let events = update(
920 &mut state,
921 Command::ReorderTab {
922 workspace_id: ws_id,
923 tab_id: "ghost".into(),
924 new_index: 0,
925 },
926 &ctx(2),
927 );
928 assert!(matches!(&events[0], Event::Error { .. }));
929 }
930
931 #[test]
938 fn reorder_tabs_bulk_accepts_unknown_ids_during_migration() {
939 let mut state = State::default();
940 let ws_id = create_workspace(&mut state, "w");
941 let known = create_tab(&mut state, &ws_id, "known");
942 let imported = "imported-tab".to_string();
949 let events = update(
950 &mut state,
951 Command::ReorderTabsBulk {
952 workspace_id: ws_id.clone(),
953 tab_ids: vec![imported.clone(), known.clone()],
954 },
955 &ctx(99),
956 );
957 assert!(
958 matches!(&events[0], Event::TabsReorderedBulk { .. }),
959 "expected TabsReorderedBulk, got {:?}",
960 events.first()
961 );
962 let ws = state.workspaces.get(&ws_id).expect("ws still present");
963 assert_eq!(ws.tab_ids, vec![imported, known]);
964 }
965
966 #[test]
970 fn reorder_tabs_bulk_rejects_duplicates() {
971 let mut state = State::default();
972 let ws_id = create_workspace(&mut state, "w");
973 let t1 = create_tab(&mut state, &ws_id, "t1");
974 let events = update(
975 &mut state,
976 Command::ReorderTabsBulk {
977 workspace_id: ws_id,
978 tab_ids: vec![t1.clone(), t1.clone()],
979 },
980 &ctx(2),
981 );
982 match &events[0] {
983 Event::Error { code, message, .. } => {
984 assert_eq!(*code, ErrorCode::InvalidCommand);
985 assert!(
986 message.contains("duplicate"),
987 "error should mention duplicate, got: {}",
988 message
989 );
990 }
991 other => panic!("expected Error event, got {:?}", other),
992 }
993 }
994
995 #[test]
996 fn reorder_tabs_bulk_validates_workspace() {
997 let mut state = State::default();
998 let events = update(
999 &mut state,
1000 Command::ReorderTabsBulk {
1001 workspace_id: "no-such-ws".into(),
1002 tab_ids: vec!["a".into()],
1003 },
1004 &ctx(1),
1005 );
1006 assert!(matches!(&events[0], Event::Error { .. }));
1007 }
1008
1009 #[test]
1010 fn delete_workspace_cascades_tabs() {
1011 let mut state = State::default();
1012 let ws_id = create_workspace(&mut state, "w");
1013 let _ = update(
1014 &mut state,
1015 Command::CreateTab {
1016 workspace_id: ws_id.clone(),
1017 name: "t1".into(),
1018 },
1019 &ctx(1),
1020 );
1021 let _ = update(
1022 &mut state,
1023 Command::CreateTab {
1024 workspace_id: ws_id.clone(),
1025 name: "t2".into(),
1026 },
1027 &ctx(2),
1028 );
1029 assert_eq!(state.tabs.len(), 2);
1030 let events = update(
1031 &mut state,
1032 Command::DeleteWorkspace {
1033 workspace_id: ws_id.clone(),
1034 force: false,
1035 },
1036 &ctx(3),
1037 );
1038 assert!(matches!(&events[0], Event::WorkspaceDeleted { .. }));
1039 assert!(state.tabs.is_empty());
1040 assert!(!state.workspaces.contains_key(&ws_id));
1041 }
1042
1043 fn create_tab(state: &mut State, workspace_id: &str, name: &str) -> String {
1044 let events = update(
1045 state,
1046 Command::CreateTab {
1047 workspace_id: workspace_id.into(),
1048 name: name.into(),
1049 },
1050 &ctx(1),
1051 );
1052 match &events[0] {
1053 Event::TabCreated { tab_id, .. } => tab_id.clone(),
1054 _ => panic!("expected TabCreated"),
1055 }
1056 }
1057
1058 #[test]
1063 fn create_tab_auto_generates_tabN_when_name_empty() {
1064 let mut state = State::default();
1065 let ws_id = create_workspace(&mut state, "w");
1066 let events = update(
1067 &mut state,
1068 Command::CreateTab {
1069 workspace_id: ws_id.clone(),
1070 name: String::new(),
1071 },
1072 &ctx(2),
1073 );
1074 match &events[0] {
1075 Event::TabCreated { name, tab_id, .. } => {
1076 assert_eq!(name, "tab1", "first tab in fresh workspace");
1077 assert_eq!(state.tabs[tab_id].name, "tab1");
1078 }
1079 other => panic!("expected TabCreated, got {:?}", other),
1080 }
1081 let events = update(
1083 &mut state,
1084 Command::CreateTab {
1085 workspace_id: ws_id.clone(),
1086 name: String::new(),
1087 },
1088 &ctx(3),
1089 );
1090 match &events[0] {
1091 Event::TabCreated { name, .. } => assert_eq!(name, "tab2"),
1092 other => panic!("expected TabCreated, got {:?}", other),
1093 }
1094 let events = update(
1096 &mut state,
1097 Command::CreateTab {
1098 workspace_id: ws_id,
1099 name: "my custom tab".into(),
1100 },
1101 &ctx(4),
1102 );
1103 match &events[0] {
1104 Event::TabCreated { name, .. } => assert_eq!(name, "my custom tab"),
1105 other => panic!("expected TabCreated, got {:?}", other),
1106 }
1107 }
1108
1109 #[test]
1110 fn create_block_validates_tab_exists() {
1111 let mut state = State::default();
1112 let events = update(
1113 &mut state,
1114 Command::CreateBlock { tab_id: "no-such-tab".into(), meta: serde_json::Value::Null },
1115 &ctx(1),
1116 );
1117 assert!(matches!(&events[0], Event::Error { .. }));
1118 assert!(state.blocks.is_empty());
1119 }
1120
1121 #[test]
1122 fn create_block_appends_to_tab_block_ids() {
1123 let mut state = State::default();
1124 let ws_id = create_workspace(&mut state, "w");
1125 let tab_id = create_tab(&mut state, &ws_id, "t");
1126 let events = update(
1127 &mut state,
1128 Command::CreateBlock { tab_id: tab_id.clone(), meta: serde_json::Value::Null },
1129 &ctx(2),
1130 );
1131 assert!(matches!(&events[0], Event::BlockCreated { .. }));
1132 let block_id = match &events[0] {
1133 Event::BlockCreated { block_id, .. } => block_id.clone(),
1134 _ => panic!(),
1135 };
1136 assert_eq!(state.tabs[&tab_id].block_ids, vec![block_id.clone()]);
1137 assert_eq!(state.blocks[&block_id].tab_id, tab_id);
1138 }
1139
1140 #[test]
1141 fn delete_block_removes_from_state_and_tab() {
1142 let mut state = State::default();
1143 let ws_id = create_workspace(&mut state, "w");
1144 let tab_id = create_tab(&mut state, &ws_id, "t");
1145 let _ = update(
1146 &mut state,
1147 Command::CreateBlock { tab_id: tab_id.clone(), meta: serde_json::Value::Null },
1148 &ctx(2),
1149 );
1150 let block_id = state.tabs[&tab_id].block_ids[0].clone();
1151 let events = update(
1152 &mut state,
1153 Command::DeleteBlock {
1154 tab_id: tab_id.clone(),
1155 block_id: block_id.clone(),
1156 },
1157 &ctx(3),
1158 );
1159 assert!(matches!(&events[0], Event::BlockDeleted { .. }));
1160 assert!(!state.blocks.contains_key(&block_id));
1161 assert!(state.tabs[&tab_id].block_ids.is_empty());
1162 }
1163
1164 #[test]
1165 fn delete_block_unknown_silent_no_op() {
1166 let mut state = State::default();
1167 let ws_id = create_workspace(&mut state, "w");
1168 let tab_id = create_tab(&mut state, &ws_id, "t");
1169 let events = update(
1170 &mut state,
1171 Command::DeleteBlock {
1172 tab_id,
1173 block_id: "ghost".into(),
1174 },
1175 &ctx(2),
1176 );
1177 assert!(events.is_empty());
1178 }
1179
1180 #[test]
1185 fn set_focused_node_round_trip_emits_event_and_updates_state() {
1186 let mut state = State::default();
1187 let ws_id = create_workspace(&mut state, "w");
1188 let tab_id = create_tab(&mut state, &ws_id, "t1");
1189 assert_eq!(state.tabs[&tab_id].focused_node_id, "");
1190 let events = update(
1191 &mut state,
1192 Command::SetFocusedNode {
1193 tab_id: tab_id.clone(),
1194 node_id: "node-7".into(),
1195 },
1196 &ctx(2),
1197 );
1198 assert_eq!(events.len(), 1);
1199 match &events[0] {
1200 Event::FocusedNodeChanged { tab_id: t, node_id, .. } => {
1201 assert_eq!(t, &tab_id);
1202 assert_eq!(node_id, "node-7");
1203 }
1204 other => panic!("expected FocusedNodeChanged, got {:?}", other),
1205 }
1206 assert_eq!(state.tabs[&tab_id].focused_node_id, "node-7");
1207 }
1208
1209 #[test]
1210 fn set_focused_node_no_op_when_value_unchanged() {
1211 let mut state = State::default();
1212 let ws_id = create_workspace(&mut state, "w");
1213 let tab_id = create_tab(&mut state, &ws_id, "t1");
1214 let _ = update(
1215 &mut state,
1216 Command::SetFocusedNode {
1217 tab_id: tab_id.clone(),
1218 node_id: "node-1".into(),
1219 },
1220 &ctx(2),
1221 );
1222 let version_before = state.event_version;
1223 let events = update(
1224 &mut state,
1225 Command::SetFocusedNode {
1226 tab_id,
1227 node_id: "node-1".into(),
1228 },
1229 &ctx(3),
1230 );
1231 assert!(events.is_empty(), "no-op should emit no events");
1232 assert_eq!(state.event_version, version_before, "no version bump on no-op");
1233 }
1234
1235 #[test]
1236 fn set_focused_node_unknown_tab_emits_error() {
1237 let mut state = State::default();
1238 let events = update(
1239 &mut state,
1240 Command::SetFocusedNode {
1241 tab_id: "ghost-tab".into(),
1242 node_id: "node-1".into(),
1243 },
1244 &ctx(1),
1245 );
1246 assert_eq!(events.len(), 1);
1247 assert!(
1248 matches!(&events[0], Event::Error { .. }),
1249 "expected Event::Error, got {:?}",
1250 events[0]
1251 );
1252 }
1253
1254 #[test]
1255 fn set_magnified_node_round_trip_emits_event_and_updates_state() {
1256 let mut state = State::default();
1257 let ws_id = create_workspace(&mut state, "w");
1258 let tab_id = create_tab(&mut state, &ws_id, "t1");
1259 let events = update(
1260 &mut state,
1261 Command::SetMagnifiedNode {
1262 tab_id: tab_id.clone(),
1263 node_id: "node-9".into(),
1264 },
1265 &ctx(2),
1266 );
1267 assert_eq!(events.len(), 1);
1268 match &events[0] {
1269 Event::MagnifiedNodeChanged { tab_id: t, node_id, .. } => {
1270 assert_eq!(t, &tab_id);
1271 assert_eq!(node_id, "node-9");
1272 }
1273 other => panic!("expected MagnifiedNodeChanged, got {:?}", other),
1274 }
1275 assert_eq!(state.tabs[&tab_id].magnified_node_id, "node-9");
1276 }
1277
1278 #[test]
1279 fn set_magnified_node_no_op_when_value_unchanged() {
1280 let mut state = State::default();
1281 let ws_id = create_workspace(&mut state, "w");
1282 let tab_id = create_tab(&mut state, &ws_id, "t1");
1283 let _ = update(
1284 &mut state,
1285 Command::SetMagnifiedNode {
1286 tab_id: tab_id.clone(),
1287 node_id: "node-2".into(),
1288 },
1289 &ctx(2),
1290 );
1291 let version_before = state.event_version;
1292 let events = update(
1293 &mut state,
1294 Command::SetMagnifiedNode {
1295 tab_id,
1296 node_id: "node-2".into(),
1297 },
1298 &ctx(3),
1299 );
1300 assert!(events.is_empty());
1301 assert_eq!(state.event_version, version_before);
1302 }
1303
1304 #[test]
1305 fn set_magnified_node_clear_with_empty_node_id() {
1306 let mut state = State::default();
1307 let ws_id = create_workspace(&mut state, "w");
1308 let tab_id = create_tab(&mut state, &ws_id, "t1");
1309 let _ = update(
1311 &mut state,
1312 Command::SetMagnifiedNode {
1313 tab_id: tab_id.clone(),
1314 node_id: "node-3".into(),
1315 },
1316 &ctx(2),
1317 );
1318 assert_eq!(state.tabs[&tab_id].magnified_node_id, "node-3");
1319 let events = update(
1321 &mut state,
1322 Command::SetMagnifiedNode {
1323 tab_id: tab_id.clone(),
1324 node_id: String::new(),
1325 },
1326 &ctx(3),
1327 );
1328 assert_eq!(events.len(), 1);
1329 match &events[0] {
1330 Event::MagnifiedNodeChanged { node_id, .. } => assert_eq!(node_id, ""),
1331 other => panic!("expected MagnifiedNodeChanged with empty node_id, got {:?}", other),
1332 }
1333 assert_eq!(state.tabs[&tab_id].magnified_node_id, "");
1334 }
1335
1336 #[test]
1337 fn set_magnified_node_unknown_tab_emits_error() {
1338 let mut state = State::default();
1339 let events = update(
1340 &mut state,
1341 Command::SetMagnifiedNode {
1342 tab_id: "ghost-tab".into(),
1343 node_id: "node-1".into(),
1344 },
1345 &ctx(1),
1346 );
1347 assert_eq!(events.len(), 1);
1348 assert!(matches!(&events[0], Event::Error { .. }));
1349 }
1350
1351 #[test]
1352 fn delete_tab_cascades_blocks() {
1353 let mut state = State::default();
1354 let ws_id = create_workspace(&mut state, "w");
1355 let tab_id = create_tab(&mut state, &ws_id, "t");
1356 let _ = update(
1357 &mut state,
1358 Command::CreateBlock { tab_id: tab_id.clone(), meta: serde_json::Value::Null },
1359 &ctx(2),
1360 );
1361 let _ = update(
1362 &mut state,
1363 Command::CreateBlock { tab_id: tab_id.clone(), meta: serde_json::Value::Null },
1364 &ctx(3),
1365 );
1366 assert_eq!(state.blocks.len(), 2);
1367 let _ = update(
1368 &mut state,
1369 Command::DeleteTab {
1370 workspace_id: ws_id,
1371 tab_id,
1372 force: true,
1375 },
1376 &ctx(4),
1377 );
1378 assert!(state.blocks.is_empty());
1379 }
1380
1381 #[test]
1382 fn delete_workspace_cascades_through_tabs_to_blocks() {
1383 let mut state = State::default();
1384 let ws_id = create_workspace(&mut state, "w");
1385 let tab_id = create_tab(&mut state, &ws_id, "t");
1386 let _ = update(
1387 &mut state,
1388 Command::CreateBlock { tab_id: tab_id.clone(), meta: serde_json::Value::Null },
1389 &ctx(2),
1390 );
1391 let _ = update(
1392 &mut state,
1393 Command::CreateBlock { tab_id, meta: serde_json::Value::Null },
1394 &ctx(3),
1395 );
1396 assert_eq!(state.blocks.len(), 2);
1397 let _ = update(
1398 &mut state,
1399 Command::DeleteWorkspace { workspace_id: ws_id, force: false },
1400 &ctx(4),
1401 );
1402 assert!(state.blocks.is_empty());
1403 assert!(state.tabs.is_empty());
1404 }
1405
1406 #[test]
1407 fn snapshot_includes_blocks() {
1408 let mut state = State::default();
1409 let ws_id = create_workspace(&mut state, "w");
1410 let tab_id = create_tab(&mut state, &ws_id, "t");
1411 let _ = update(
1412 &mut state,
1413 Command::CreateBlock { tab_id, meta: serde_json::Value::Null },
1414 &ctx(2),
1415 );
1416 let events = update(&mut state, Command::GetSrvSnapshot, &ctx(3));
1417 let Event::SrvSnapshot { blocks, .. } = &events[0] else {
1418 panic!("expected SrvSnapshot");
1419 };
1420 assert_eq!(blocks.len(), 1);
1421 }
1422
1423 #[test]
1426 fn create_window_validates_workspace_exists() {
1427 let mut state = State::default();
1428 let events = update(
1429 &mut state,
1430 Command::CreateWindow {
1431 window_id: "win-1".into(),
1432 workspace_id: "no-such-ws".into(),
1433 },
1434 &ctx(1),
1435 );
1436 assert!(matches!(&events[0], Event::Error { .. }));
1437 assert!(state.windows.is_empty());
1438 }
1439
1440 #[test]
1441 fn create_window_inserts_record_and_emits_event() {
1442 let mut state = State::default();
1443 let ws_id = create_workspace(&mut state, "w");
1444 let events = update(
1445 &mut state,
1446 Command::CreateWindow {
1447 window_id: "win-1".into(),
1448 workspace_id: ws_id.clone(),
1449 },
1450 &ctx(2),
1451 );
1452 assert!(matches!(
1453 &events[0],
1454 Event::SrvWindowOpened { window_id, workspace_id, .. }
1455 if window_id == "win-1" && *workspace_id == ws_id
1456 ));
1457 assert_eq!(state.windows["win-1"].workspace_id, ws_id);
1458 }
1459
1460 #[test]
1461 fn create_window_idempotent_on_same_workspace() {
1462 let mut state = State::default();
1463 let ws_id = create_workspace(&mut state, "w");
1464 let _ = update(
1465 &mut state,
1466 Command::CreateWindow {
1467 window_id: "win-1".into(),
1468 workspace_id: ws_id.clone(),
1469 },
1470 &ctx(2),
1471 );
1472 let events = update(
1473 &mut state,
1474 Command::CreateWindow {
1475 window_id: "win-1".into(),
1476 workspace_id: ws_id,
1477 },
1478 &ctx(3),
1479 );
1480 assert!(events.is_empty());
1481 }
1482
1483 #[test]
1484 fn close_window_internal_removes_record() {
1485 let mut state = State::default();
1486 let ws_id = create_workspace(&mut state, "w");
1487 let _ = update(
1488 &mut state,
1489 Command::CreateWindow {
1490 window_id: "win-1".into(),
1491 workspace_id: ws_id,
1492 },
1493 &ctx(2),
1494 );
1495 let events = update(
1496 &mut state,
1497 Command::CloseWindowInternal {
1498 window_id: "win-1".into(),
1499 },
1500 &ctx(3),
1501 );
1502 assert!(matches!(&events[0], Event::SrvWindowClosed { .. }));
1503 assert!(state.windows.is_empty());
1504 }
1505
1506 #[test]
1507 fn close_window_internal_silent_on_missing() {
1508 let mut state = State::default();
1509 let events = update(
1510 &mut state,
1511 Command::CloseWindowInternal {
1512 window_id: "ghost".into(),
1513 },
1514 &ctx(1),
1515 );
1516 assert!(events.is_empty());
1517 }
1518
1519 #[test]
1520 fn switch_workspace_updates_window_pointer() {
1521 let mut state = State::default();
1522 let ws_a = create_workspace(&mut state, "a");
1523 let ws_b = create_workspace(&mut state, "b");
1524 let _ = update(
1525 &mut state,
1526 Command::CreateWindow {
1527 window_id: "win-1".into(),
1528 workspace_id: ws_a.clone(),
1529 },
1530 &ctx(2),
1531 );
1532 let events = update(
1533 &mut state,
1534 Command::SwitchWorkspace {
1535 window_id: "win-1".into(),
1536 workspace_id: ws_b.clone(),
1537 },
1538 &ctx(3),
1539 );
1540 assert!(matches!(
1541 &events[0],
1542 Event::SrvWindowWorkspaceChanged { workspace_id, .. } if *workspace_id == ws_b
1543 ));
1544 assert_eq!(state.windows["win-1"].workspace_id, ws_b);
1545 }
1546
1547 #[test]
1548 fn switch_workspace_validates_window_and_destination() {
1549 let mut state = State::default();
1550 let ws_id = create_workspace(&mut state, "a");
1551 let events = update(
1553 &mut state,
1554 Command::SwitchWorkspace {
1555 window_id: "ghost".into(),
1556 workspace_id: ws_id.clone(),
1557 },
1558 &ctx(2),
1559 );
1560 assert!(matches!(&events[0], Event::Error { .. }));
1561 let _ = update(
1563 &mut state,
1564 Command::CreateWindow {
1565 window_id: "win-1".into(),
1566 workspace_id: ws_id,
1567 },
1568 &ctx(3),
1569 );
1570 let events = update(
1571 &mut state,
1572 Command::SwitchWorkspace {
1573 window_id: "win-1".into(),
1574 workspace_id: "no-such-ws".into(),
1575 },
1576 &ctx(4),
1577 );
1578 assert!(matches!(&events[0], Event::Error { .. }));
1579 }
1580
1581 #[test]
1582 fn switch_workspace_no_op_when_already_pointing() {
1583 let mut state = State::default();
1584 let ws_id = create_workspace(&mut state, "a");
1585 let _ = update(
1586 &mut state,
1587 Command::CreateWindow {
1588 window_id: "win-1".into(),
1589 workspace_id: ws_id.clone(),
1590 },
1591 &ctx(2),
1592 );
1593 let events = update(
1594 &mut state,
1595 Command::SwitchWorkspace {
1596 window_id: "win-1".into(),
1597 workspace_id: ws_id,
1598 },
1599 &ctx(3),
1600 );
1601 assert!(events.is_empty());
1602 }
1603
1604 #[test]
1605 fn delete_workspace_drops_pointing_windows_and_emits_events() {
1606 let mut state = State::default();
1607 let ws_a = create_workspace(&mut state, "a");
1608 let ws_b = create_workspace(&mut state, "b");
1609 let _ = update(
1610 &mut state,
1611 Command::CreateWindow {
1612 window_id: "win-a".into(),
1613 workspace_id: ws_a.clone(),
1614 },
1615 &ctx(2),
1616 );
1617 let _ = update(
1618 &mut state,
1619 Command::CreateWindow {
1620 window_id: "win-b".into(),
1621 workspace_id: ws_b.clone(),
1622 },
1623 &ctx(3),
1624 );
1625 let events = update(
1628 &mut state,
1629 Command::DeleteWorkspace { workspace_id: ws_a, force: false },
1630 &ctx(4),
1631 );
1632 assert!(matches!(&events[0], Event::WorkspaceDeleted { .. }));
1633 assert!(events.iter().any(|e| matches!(
1634 e,
1635 Event::SrvWindowClosed { window_id, .. } if window_id == "win-a"
1636 )));
1637 assert!(!events.iter().any(|e| matches!(
1639 e,
1640 Event::SrvWindowClosed { window_id, .. } if window_id == "win-b"
1641 )));
1642 assert!(!state.windows.contains_key("win-a"));
1643 assert_eq!(state.windows["win-b"].workspace_id, ws_b);
1644 }
1645
1646 #[test]
1647 fn snapshot_includes_tabs_and_active_tabs() {
1648 let mut state = State::default();
1649 let ws_id = create_workspace(&mut state, "w");
1650 let _ = update(
1651 &mut state,
1652 Command::CreateTab {
1653 workspace_id: ws_id.clone(),
1654 name: "t1".into(),
1655 },
1656 &ctx(1),
1657 );
1658 let events = update(&mut state, Command::GetSrvSnapshot, &ctx(2));
1659 let Event::SrvSnapshot {
1660 tabs, active_tabs, ..
1661 } = &events[0]
1662 else {
1663 panic!("expected SrvSnapshot");
1664 };
1665 assert_eq!(tabs.len(), 1);
1666 assert_eq!(active_tabs.len(), 1);
1667 assert_eq!(active_tabs[0].0, ws_id);
1668 }
1669
1670 #[test]
1671 fn goodbye_marks_process_exited() {
1672 let mut state = State::default();
1673 let _ = update(
1674 &mut state,
1675 Command::Register {
1676 kind: ClientKind::Host,
1677 pid: 100,
1678 version: "v".into(),
1679 },
1680 &ctx(1),
1681 );
1682 let events = update(&mut state, Command::Goodbye, &ctx_with_pid(1, 100));
1683 assert!(matches!(
1684 &events[0],
1685 Event::ProcessExited { pid: 100, code: 0, .. }
1686 ));
1687 assert!(matches!(
1688 state.processes[&100].state,
1689 ProcessState::Exited { code: 0 }
1690 ));
1691 }
1692
1693 #[test]
1696 fn move_tab_cross_workspace_updates_lists_and_parent() {
1697 let mut state = State::default();
1698 let src = create_workspace(&mut state, "src");
1699 let dst = create_workspace(&mut state, "dst");
1700 let t1 = create_tab(&mut state, &src, "t1");
1701 let t2 = create_tab(&mut state, &src, "t2");
1702 let dst_existing = create_tab(&mut state, &dst, "existing");
1703 let events = update(
1704 &mut state,
1705 Command::MoveTab {
1706 tab_id: t1.clone(),
1707 src_workspace_id: src.clone(),
1708 dst_workspace_id: dst.clone(),
1709 dst_index: 0,
1710 },
1711 &ctx(99),
1712 );
1713 match &events[0] {
1714 Event::TabMoved {
1715 tab_id,
1716 src_workspace_id,
1717 dst_workspace_id,
1718 dst_index,
1719 new_src_active_tab_id,
1720 ..
1721 } => {
1722 assert_eq!(tab_id, &t1);
1723 assert_eq!(src_workspace_id, &src);
1724 assert_eq!(dst_workspace_id, &dst);
1725 assert_eq!(*dst_index, 0);
1726 assert_eq!(new_src_active_tab_id, &Some(t2.clone()));
1727 }
1728 other => panic!("expected TabMoved, got {:?}", other),
1729 }
1730 assert_eq!(state.workspaces[&src].tab_ids, vec![t2.clone()]);
1731 assert_eq!(state.workspaces[&dst].tab_ids, vec![t1.clone(), dst_existing]);
1732 assert_eq!(state.tabs[&t1].workspace_id, dst);
1733 assert_eq!(state.workspaces[&src].active_tab_id, Some(t2));
1734 }
1735
1736 #[test]
1737 fn move_tab_clamps_dst_index_to_dst_length() {
1738 let mut state = State::default();
1739 let src = create_workspace(&mut state, "src");
1740 let dst = create_workspace(&mut state, "dst");
1741 let t1 = create_tab(&mut state, &src, "t1");
1742 let _ = create_tab(&mut state, &src, "filler");
1743 let events = update(
1744 &mut state,
1745 Command::MoveTab {
1746 tab_id: t1.clone(),
1747 src_workspace_id: src,
1748 dst_workspace_id: dst.clone(),
1749 dst_index: 999,
1750 },
1751 &ctx(2),
1752 );
1753 match &events[0] {
1754 Event::TabMoved { dst_index, .. } => assert_eq!(*dst_index, 0),
1755 other => panic!("expected TabMoved, got {:?}", other),
1756 }
1757 assert_eq!(state.workspaces[&dst].tab_ids, vec![t1]);
1758 }
1759
1760 #[test]
1761 fn move_tab_src_active_clears_when_workspace_empties() {
1762 let mut state = State::default();
1763 let src = create_workspace(&mut state, "src");
1764 let dst = create_workspace(&mut state, "dst");
1765 let only_tab = create_tab(&mut state, &src, "only");
1766 let events = update(
1767 &mut state,
1768 Command::MoveTab {
1769 tab_id: only_tab,
1770 src_workspace_id: src.clone(),
1771 dst_workspace_id: dst,
1772 dst_index: 0,
1773 },
1774 &ctx(2),
1775 );
1776 match &events[0] {
1777 Event::TabMoved {
1778 new_src_active_tab_id,
1779 ..
1780 } => assert_eq!(new_src_active_tab_id, &None),
1781 other => panic!("expected TabMoved, got {:?}", other),
1782 }
1783 assert_eq!(state.workspaces[&src].active_tab_id, None);
1784 assert!(state.workspaces[&src].tab_ids.is_empty());
1785 }
1786
1787 #[test]
1788 fn move_tab_rejects_same_workspace() {
1789 let mut state = State::default();
1790 let ws = create_workspace(&mut state, "w");
1791 let t1 = create_tab(&mut state, &ws, "t1");
1792 let events = update(
1793 &mut state,
1794 Command::MoveTab {
1795 tab_id: t1,
1796 src_workspace_id: ws.clone(),
1797 dst_workspace_id: ws,
1798 dst_index: 0,
1799 },
1800 &ctx(2),
1801 );
1802 assert!(matches!(&events[0], Event::Error { .. }));
1803 }
1804
1805 #[test]
1806 fn move_tab_rejects_unknown_src_or_dst_or_tab() {
1807 let mut state = State::default();
1808 let src = create_workspace(&mut state, "src");
1809 let dst = create_workspace(&mut state, "dst");
1810 let t1 = create_tab(&mut state, &src, "t1");
1811
1812 let events = update(
1813 &mut state,
1814 Command::MoveTab {
1815 tab_id: t1.clone(),
1816 src_workspace_id: "no-such-src".into(),
1817 dst_workspace_id: dst.clone(),
1818 dst_index: 0,
1819 },
1820 &ctx(2),
1821 );
1822 assert!(matches!(&events[0], Event::Error { .. }));
1823
1824 let events = update(
1825 &mut state,
1826 Command::MoveTab {
1827 tab_id: t1.clone(),
1828 src_workspace_id: src.clone(),
1829 dst_workspace_id: "no-such-dst".into(),
1830 dst_index: 0,
1831 },
1832 &ctx(3),
1833 );
1834 assert!(matches!(&events[0], Event::Error { .. }));
1835
1836 let events = update(
1842 &mut state,
1843 Command::MoveTab {
1844 tab_id: "ghost-tab".into(),
1845 src_workspace_id: src,
1846 dst_workspace_id: dst,
1847 dst_index: 0,
1848 },
1849 &ctx(4),
1850 );
1851 assert!(matches!(&events[0], Event::Error { .. }));
1852 }
1853
1854 #[test]
1859 fn move_tab_unknown_tab_rejects() {
1860 let mut state = State::default();
1861 let src = create_workspace(&mut state, "src");
1862 let dst = create_workspace(&mut state, "dst");
1863 let unknown_id = "unknown-tab-xyz".to_string();
1864 assert!(!state.tabs.contains_key(&unknown_id));
1865 let events = update(
1866 &mut state,
1867 Command::MoveTab {
1868 tab_id: unknown_id.clone(),
1869 src_workspace_id: src,
1870 dst_workspace_id: dst,
1871 dst_index: 0,
1872 },
1873 &ctx(2),
1874 );
1875 match &events[0] {
1876 Event::Error { code, message, .. } => {
1877 assert_eq!(*code, ErrorCode::InvalidCommand);
1878 assert!(
1879 message.contains("tab not found"),
1880 "error should mention `tab not found`, got: {}",
1881 message
1882 );
1883 }
1884 other => panic!("expected Error event, got {:?}", other),
1885 }
1886 assert!(!state.tabs.contains_key(&unknown_id));
1888 }
1889
1890 #[test]
1896 fn move_tab_wrong_workspace_rejects() {
1897 let mut state = State::default();
1898 let real_src = create_workspace(&mut state, "real_src");
1899 let dst = create_workspace(&mut state, "dst");
1900 let other = create_workspace(&mut state, "other");
1901 let t1 = create_tab(&mut state, &real_src, "t1");
1902 let filler = create_tab(&mut state, &other, "filler");
1903 let events = update(
1906 &mut state,
1907 Command::MoveTab {
1908 tab_id: t1.clone(),
1909 src_workspace_id: other.clone(),
1910 dst_workspace_id: dst.clone(),
1911 dst_index: 0,
1912 },
1913 &ctx(2),
1914 );
1915 match &events[0] {
1916 Event::Error { code, message, .. } => {
1917 assert_eq!(*code, ErrorCode::InvalidCommand);
1918 assert!(
1919 message.contains("workspace_id mismatch"),
1920 "error should mention `workspace_id mismatch`, got: {}",
1921 message
1922 );
1923 }
1924 other => panic!("expected Error event, got {:?}", other),
1925 }
1926 assert_eq!(state.tabs[&t1].workspace_id, real_src);
1929 assert_eq!(state.workspaces[&real_src].tab_ids, vec![t1]);
1930 assert_eq!(state.workspaces[&other].tab_ids, vec![filler]);
1931 assert!(state.workspaces[&dst].tab_ids.is_empty());
1932 }
1933
1934 fn create_block(state: &mut State, tab_id: &str) -> String {
1937 let events = update(
1938 state,
1939 Command::CreateBlock {
1940 tab_id: tab_id.into(),
1941 meta: serde_json::Value::Null,
1942 },
1943 &ctx(1),
1944 );
1945 match &events[0] {
1946 Event::BlockCreated { block_id, .. } => block_id.clone(),
1947 _ => panic!("expected BlockCreated"),
1948 }
1949 }
1950
1951 #[test]
1952 fn move_block_cross_tab_updates_lists_and_parent() {
1953 let mut state = State::default();
1954 let ws = create_workspace(&mut state, "w");
1955 let src_tab = create_tab(&mut state, &ws, "src");
1956 let dst_tab = create_tab(&mut state, &ws, "dst");
1957 let block = create_block(&mut state, &src_tab);
1958 let dst_existing = create_block(&mut state, &dst_tab);
1959 let events = update(
1960 &mut state,
1961 Command::MoveBlock {
1962 block_id: block.clone(),
1963 src_tab_id: src_tab.clone(),
1964 dst_tab_id: dst_tab.clone(),
1965 dst_index: 0,
1966 },
1967 &ctx(2),
1968 );
1969 assert!(matches!(&events[0], Event::BlockMoved { .. }));
1970 assert_eq!(state.tabs[&src_tab].block_ids, Vec::<String>::new());
1971 assert_eq!(state.tabs[&dst_tab].block_ids, vec![block.clone(), dst_existing]);
1972 assert_eq!(state.blocks[&block].tab_id, dst_tab);
1973 }
1974
1975 #[test]
1976 fn move_block_intra_tab_repositions() {
1977 let mut state = State::default();
1978 let ws = create_workspace(&mut state, "w");
1979 let tab = create_tab(&mut state, &ws, "t");
1980 let b1 = create_block(&mut state, &tab);
1981 let b2 = create_block(&mut state, &tab);
1982 let b3 = create_block(&mut state, &tab);
1983 let events = update(
1985 &mut state,
1986 Command::MoveBlock {
1987 block_id: b1.clone(),
1988 src_tab_id: tab.clone(),
1989 dst_tab_id: tab.clone(),
1990 dst_index: 2,
1991 },
1992 &ctx(2),
1993 );
1994 match &events[0] {
1995 Event::BlockMoved { dst_index, .. } => assert_eq!(*dst_index, 2),
1996 other => panic!("expected BlockMoved, got {:?}", other),
1997 }
1998 assert_eq!(state.tabs[&tab].block_ids, vec![b2, b3, b1]);
1999 }
2000
2001 #[test]
2002 fn move_block_rejects_unknown_src_or_dst_or_block() {
2003 let mut state = State::default();
2004 let ws = create_workspace(&mut state, "w");
2005 let tab = create_tab(&mut state, &ws, "t");
2006 let other_tab = create_tab(&mut state, &ws, "other");
2007 let block = create_block(&mut state, &tab);
2008
2009 let events = update(
2010 &mut state,
2011 Command::MoveBlock {
2012 block_id: block.clone(),
2013 src_tab_id: "ghost-src".into(),
2014 dst_tab_id: other_tab.clone(),
2015 dst_index: 0,
2016 },
2017 &ctx(2),
2018 );
2019 assert!(matches!(&events[0], Event::Error { .. }));
2020
2021 let events = update(
2022 &mut state,
2023 Command::MoveBlock {
2024 block_id: block.clone(),
2025 src_tab_id: tab.clone(),
2026 dst_tab_id: "ghost-dst".into(),
2027 dst_index: 0,
2028 },
2029 &ctx(3),
2030 );
2031 assert!(matches!(&events[0], Event::Error { .. }));
2032
2033 let events = update(
2034 &mut state,
2035 Command::MoveBlock {
2036 block_id: "ghost-block".into(),
2037 src_tab_id: tab,
2038 dst_tab_id: other_tab,
2039 dst_index: 0,
2040 },
2041 &ctx(4),
2042 );
2043 assert!(matches!(&events[0], Event::Error { .. }));
2044 }
2045
2046 #[test]
2047 fn move_block_rejects_when_block_belongs_to_different_tab() {
2048 let mut state = State::default();
2049 let ws = create_workspace(&mut state, "w");
2050 let real_src = create_tab(&mut state, &ws, "real");
2051 let other = create_tab(&mut state, &ws, "other");
2052 let dst = create_tab(&mut state, &ws, "dst");
2053 let block = create_block(&mut state, &real_src);
2054 let events = update(
2055 &mut state,
2056 Command::MoveBlock {
2057 block_id: block,
2058 src_tab_id: other,
2059 dst_tab_id: dst,
2060 dst_index: 0,
2061 },
2062 &ctx(2),
2063 );
2064 match &events[0] {
2065 Event::Error { message, .. } => {
2066 assert!(message.contains("belongs to tab"), "got: {}", message);
2067 }
2068 other => panic!("expected Error, got {:?}", other),
2069 }
2070 }
2071
2072 use proptest::prelude::*;
2098
2099 #[derive(Debug, Clone)]
2104 enum PropOp {
2105 CreateWorkspace,
2106 CreateTab,
2107 CreateBlock,
2108 DeleteTab,
2109 DeleteBlock,
2110 DeleteWorkspace,
2111 }
2112
2113 fn op_strategy() -> impl Strategy<Value = PropOp> {
2114 prop_oneof![
2118 4 => Just(PropOp::CreateWorkspace),
2119 4 => Just(PropOp::CreateTab),
2120 3 => Just(PropOp::CreateBlock),
2121 1 => Just(PropOp::DeleteTab),
2122 1 => Just(PropOp::DeleteBlock),
2123 1 => Just(PropOp::DeleteWorkspace),
2124 ]
2125 }
2126
2127 fn apply_prop_op(state: &mut State, op: PropOp, conn_id: u64) -> Vec<Event> {
2130 match op {
2131 PropOp::CreateWorkspace => update(
2132 state,
2133 Command::CreateWorkspace { name: format!("ws-{}", conn_id) },
2134 &ctx(conn_id),
2135 ),
2136 PropOp::CreateTab => {
2137 let target_ws = state.workspaces.keys().next().cloned();
2138 match target_ws {
2139 Some(workspace_id) => update(
2140 state,
2141 Command::CreateTab {
2142 workspace_id,
2143 name: format!("tab-{}", conn_id),
2144 },
2145 &ctx(conn_id),
2146 ),
2147 None => Vec::new(),
2148 }
2149 }
2150 PropOp::CreateBlock => {
2151 let target_tab = state.tabs.keys().next().cloned();
2152 match target_tab {
2153 Some(tab_id) => update(
2154 state,
2155 Command::CreateBlock { tab_id, meta: serde_json::Value::Null },
2156 &ctx(conn_id),
2157 ),
2158 None => Vec::new(),
2159 }
2160 }
2161 PropOp::DeleteTab => {
2162 if let Some((tab_id, tab)) = state.tabs.iter().next() {
2163 let cmd = Command::DeleteTab {
2164 workspace_id: tab.workspace_id.clone(),
2165 tab_id: tab_id.clone(),
2166 force: true,
2171 };
2172 update(state, cmd, &ctx(conn_id))
2173 } else {
2174 Vec::new()
2175 }
2176 }
2177 PropOp::DeleteBlock => {
2178 if let Some((block_id, block)) = state.blocks.iter().next() {
2179 let cmd = Command::DeleteBlock {
2180 tab_id: block.tab_id.clone(),
2181 block_id: block_id.clone(),
2182 };
2183 update(state, cmd, &ctx(conn_id))
2184 } else {
2185 Vec::new()
2186 }
2187 }
2188 PropOp::DeleteWorkspace => {
2189 if let Some(workspace_id) = state.workspaces.keys().next().cloned() {
2190 update(
2191 state,
2192 Command::DeleteWorkspace { workspace_id, force: false },
2193 &ctx(conn_id),
2194 )
2195 } else {
2196 Vec::new()
2197 }
2198 }
2199 }
2200 }
2201
2202 fn assert_invariants(state: &State) {
2205 for (tab_id, tab) in &state.tabs {
2207 assert!(
2208 state.workspaces.contains_key(&tab.workspace_id),
2209 "tab {} references unknown workspace {}",
2210 tab_id,
2211 tab.workspace_id
2212 );
2213 }
2214 for (block_id, block) in &state.blocks {
2216 assert!(
2217 state.tabs.contains_key(&block.tab_id),
2218 "block {} references unknown tab {}",
2219 block_id,
2220 block.tab_id
2221 );
2222 }
2223 for (workspace_id, ws) in &state.workspaces {
2225 for tab_id in &ws.tab_ids {
2226 assert!(
2227 state.tabs.contains_key(tab_id),
2228 "workspace {} tab_ids contains unknown tab {}",
2229 workspace_id,
2230 tab_id
2231 );
2232 }
2233 }
2234 for (tab_id, tab) in &state.tabs {
2236 for block_id in &tab.block_ids {
2237 assert!(
2238 state.blocks.contains_key(block_id),
2239 "tab {} block_ids contains unknown block {}",
2240 tab_id,
2241 block_id
2242 );
2243 }
2244 }
2245 for (workspace_id, ws) in &state.workspaces {
2247 if let Some(active) = &ws.active_tab_id {
2248 assert!(
2249 ws.tab_ids.iter().any(|t| t == active),
2250 "workspace {} active_tab_id {} not in its tab_ids",
2251 workspace_id,
2252 active
2253 );
2254 }
2255 }
2256 }
2257
2258 proptest! {
2259 #![proptest_config(ProptestConfig {
2260 cases: 64,
2261 ..ProptestConfig::default()
2262 })]
2263
2264 #[test]
2269 fn invariants_hold_across_random_sequences(ops in prop::collection::vec(op_strategy(), 0..40)) {
2270 let mut state = State::default();
2271 let mut last_version: u64 = 0;
2272 for (i, op) in ops.into_iter().enumerate() {
2273 let events = apply_prop_op(&mut state, op, (i + 1) as u64);
2274 for ev in &events {
2275 let v = extract_version(ev);
2276 prop_assert!(
2277 v > last_version,
2278 "version {} not strictly greater than previous {} (event {:?})",
2279 v,
2280 last_version,
2281 ev
2282 );
2283 last_version = v;
2284 }
2285 assert_invariants(&state);
2286 }
2287 }
2288
2289 #[test]
2294 fn delete_workspace_cascades_cleanly(
2295 tab_count in 1usize..6,
2296 blocks_per_tab in 0usize..4,
2297 ) {
2298 let mut state = State::default();
2299 let ws_events = update(
2301 &mut state,
2302 Command::CreateWorkspace { name: "ws".into() },
2303 &ctx(1),
2304 );
2305 let ws_id = ws_events
2306 .iter()
2307 .find_map(|e| match e {
2308 Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
2309 _ => None,
2310 })
2311 .unwrap();
2312 for _ in 0..tab_count {
2317 let evs = update(
2318 &mut state,
2319 Command::CreateTab {
2320 workspace_id: ws_id.clone(),
2321 name: "t".into(),
2322 },
2323 &ctx(2),
2324 );
2325 let tid = evs
2326 .iter()
2327 .find_map(|e| match e {
2328 Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
2329 _ => None,
2330 })
2331 .unwrap();
2332 for _ in 0..blocks_per_tab {
2333 let _ = update(
2334 &mut state,
2335 Command::CreateBlock {
2336 tab_id: tid.clone(),
2337 meta: serde_json::Value::Null,
2338 },
2339 &ctx(3),
2340 );
2341 }
2342 }
2343 prop_assert_eq!(state.workspaces.len(), 1);
2345 prop_assert_eq!(state.tabs.len(), tab_count);
2346 prop_assert_eq!(state.blocks.len(), tab_count * blocks_per_tab);
2347 let _ = update(
2349 &mut state,
2350 Command::DeleteWorkspace { workspace_id: ws_id.clone(), force: false },
2351 &ctx(4),
2352 );
2353 prop_assert!(state.workspaces.is_empty());
2355 prop_assert!(state.tabs.is_empty());
2356 prop_assert!(
2357 state.blocks.is_empty(),
2358 "blocks should cascade-delete with their tabs; got {} survivors",
2359 state.blocks.len()
2360 );
2361 assert_invariants(&state);
2363 }
2364 }
2365
2366 fn leaf_node(id: &str, block_id: &str) -> agentmux_common::LayoutNode {
2375 agentmux_common::LayoutNode {
2376 id: id.to_string(),
2377 size: 1.0,
2378 data: Some(agentmux_common::LayoutNodeData {
2379 block_id: block_id.to_string(),
2380 ..Default::default()
2381 }),
2382 ..Default::default()
2383 }
2384 }
2385
2386 fn fresh_tab() -> (State, String) {
2387 let mut state = State::default();
2388 let ws = create_workspace(&mut state, "w");
2389 let tab = create_tab(&mut state, &ws, "t");
2390 (state, tab)
2391 }
2392
2393 #[test]
2394 fn layout_clear_wipes_rootnode_focus_magnify_and_emits_event() {
2395 let (mut state, tab_id) = fresh_tab();
2396 state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(leaf_node("n1", "b1"));
2398 state.tabs.get_mut(&tab_id).unwrap().focused_node_id = "n1".into();
2399 state.tabs.get_mut(&tab_id).unwrap().magnified_node_id = "n1".into();
2400
2401 let events = update(
2402 &mut state,
2403 Command::LayoutClear {
2404 tab_id: tab_id.clone(),
2405 correlation_id: "corr-1".into(),
2406 },
2407 &ctx(1),
2408 );
2409
2410 assert_eq!(events.len(), 1);
2411 assert!(matches!(
2412 &events[0],
2413 Event::LayoutCleared { correlation_id, .. } if correlation_id == "corr-1"
2414 ));
2415 let tab = &state.tabs[&tab_id];
2416 assert!(tab.rootnode.is_none(), "rootnode wiped");
2417 assert_eq!(tab.focused_node_id, "");
2418 assert_eq!(tab.magnified_node_id, "");
2419 }
2420
2421 #[test]
2422 fn layout_clear_unknown_tab_emits_error() {
2423 let mut state = State::default();
2424 let events = update(
2425 &mut state,
2426 Command::LayoutClear {
2427 tab_id: "nope".into(),
2428 correlation_id: "corr".into(),
2429 },
2430 &ctx(1),
2431 );
2432 assert!(matches!(&events[0], Event::Error { code: ErrorCode::InvalidCommand, .. }));
2433 }
2434
2435 #[test]
2436 fn layout_set_tree_replaces_rootnode_wholesale() {
2437 let (mut state, tab_id) = fresh_tab();
2438 state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(leaf_node("old", "b1"));
2439
2440 let new_tree = Some(leaf_node("new", "b2"));
2441 let events = update(
2442 &mut state,
2443 Command::LayoutSetTree {
2444 tab_id: tab_id.clone(),
2445 new_tree: new_tree.clone(),
2446 correlation_id: "corr-set".into(),
2447 },
2448 &ctx(1),
2449 );
2450
2451 assert!(matches!(&events[0], Event::LayoutTreeReplaced { .. }));
2452 assert_eq!(state.tabs[&tab_id].rootnode, new_tree);
2453 }
2454
2455 #[test]
2456 fn layout_set_tree_to_none_clears_rootnode() {
2457 let (mut state, tab_id) = fresh_tab();
2458 state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(leaf_node("n", "b"));
2459
2460 let _ = update(
2461 &mut state,
2462 Command::LayoutSetTree {
2463 tab_id: tab_id.clone(),
2464 new_tree: None,
2465 correlation_id: "corr".into(),
2466 },
2467 &ctx(1),
2468 );
2469 assert!(state.tabs[&tab_id].rootnode.is_none());
2470 }
2471
2472 #[test]
2473 fn layout_insert_node_into_empty_tree_promotes_to_root() {
2474 let (mut state, tab_id) = fresh_tab();
2475 let node = leaf_node("first", "b1");
2476 let events = update(
2477 &mut state,
2478 Command::LayoutInsertNode {
2479 tab_id: tab_id.clone(),
2480 node: node.clone(),
2481 parent_id: None,
2482 index: None,
2483 focus_after: false,
2484 magnify_after: false,
2485 correlation_id: "corr-ins".into(),
2486 },
2487 &ctx(1),
2488 );
2489 assert!(matches!(&events[0], Event::LayoutNodeInserted { .. }));
2490 assert_eq!(state.tabs[&tab_id].rootnode.as_ref().map(|n| n.id.as_str()), Some("first"));
2491 }
2492
2493 #[test]
2494 fn layout_insert_node_into_existing_tree_uses_helper() {
2495 let (mut state, tab_id) = fresh_tab();
2496 state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(leaf_node("root", "b1"));
2498 let new_node = leaf_node("added", "b2");
2499 let events = update(
2500 &mut state,
2501 Command::LayoutInsertNode {
2502 tab_id: tab_id.clone(),
2503 node: new_node,
2504 parent_id: None,
2505 index: None,
2506 focus_after: false,
2507 magnify_after: false,
2508 correlation_id: "corr".into(),
2509 },
2510 &ctx(1),
2511 );
2512 assert!(matches!(&events[0], Event::LayoutNodeInserted { .. }));
2513 let root = state.tabs[&tab_id].rootnode.as_ref().expect("rootnode set");
2517 let collected = collect_block_ids(root);
2518 assert!(collected.contains(&"b1".to_string()));
2519 assert!(collected.contains(&"b2".to_string()));
2520 }
2521
2522 fn collect_block_ids(node: &agentmux_common::LayoutNode) -> Vec<String> {
2523 let mut ids = Vec::new();
2524 if let Some(d) = &node.data {
2525 if !d.block_id.is_empty() {
2526 ids.push(d.block_id.clone());
2527 }
2528 }
2529 for c in &node.children {
2530 ids.extend(collect_block_ids(c));
2531 }
2532 ids
2533 }
2534
2535 #[test]
2536 fn layout_delete_node_on_empty_tree_is_noop() {
2537 let (mut state, tab_id) = fresh_tab();
2538 let events = update(
2539 &mut state,
2540 Command::LayoutDeleteNode {
2541 tab_id: tab_id.clone(),
2542 node_id: "ghost".into(),
2543 correlation_id: "corr".into(),
2544 },
2545 &ctx(1),
2546 );
2547 assert!(events.is_empty(), "no event for delete on empty tree");
2548 assert!(state.tabs[&tab_id].rootnode.is_none());
2549 }
2550
2551 #[test]
2552 fn layout_set_tree_to_none_also_clears_focused_and_magnified() {
2553 let (mut state, tab_id) = fresh_tab();
2557 state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(leaf_node("n", "b"));
2558 state.tabs.get_mut(&tab_id).unwrap().focused_node_id = "n".into();
2559 state.tabs.get_mut(&tab_id).unwrap().magnified_node_id = "n".into();
2560 let _ = update(
2561 &mut state,
2562 Command::LayoutSetTree {
2563 tab_id: tab_id.clone(),
2564 new_tree: None,
2565 correlation_id: "corr".into(),
2566 },
2567 &ctx(1),
2568 );
2569 let tab = &state.tabs[&tab_id];
2570 assert!(tab.rootnode.is_none());
2571 assert_eq!(tab.focused_node_id, "");
2572 assert_eq!(tab.magnified_node_id, "");
2573 }
2574
2575 #[test]
2576 fn layout_set_tree_with_some_preserves_focused_and_magnified() {
2577 let (mut state, tab_id) = fresh_tab();
2580 state.tabs.get_mut(&tab_id).unwrap().focused_node_id = "n".into();
2581 state.tabs.get_mut(&tab_id).unwrap().magnified_node_id = "n".into();
2582 let _ = update(
2583 &mut state,
2584 Command::LayoutSetTree {
2585 tab_id: tab_id.clone(),
2586 new_tree: Some(leaf_node("n", "b")),
2587 correlation_id: "corr".into(),
2588 },
2589 &ctx(1),
2590 );
2591 let tab = &state.tabs[&tab_id];
2592 assert_eq!(tab.focused_node_id, "n");
2593 assert_eq!(tab.magnified_node_id, "n");
2594 }
2595
2596 #[test]
2597 fn layout_insert_node_honours_focus_after() {
2598 let (mut state, tab_id) = fresh_tab();
2601 let node = leaf_node("new", "b1");
2602 let _ = update(
2603 &mut state,
2604 Command::LayoutInsertNode {
2605 tab_id: tab_id.clone(),
2606 node,
2607 parent_id: None,
2608 index: None,
2609 focus_after: true,
2610 magnify_after: false,
2611 correlation_id: "corr".into(),
2612 },
2613 &ctx(1),
2614 );
2615 assert_eq!(state.tabs[&tab_id].focused_node_id, "new");
2616 assert_eq!(state.tabs[&tab_id].magnified_node_id, "");
2617 }
2618
2619 #[test]
2620 fn layout_insert_node_magnify_after_implies_focus() {
2621 let (mut state, tab_id) = fresh_tab();
2626 state.tabs.get_mut(&tab_id).unwrap().focused_node_id = "prev".into();
2627 let node = leaf_node("new", "b1");
2628 let _ = update(
2629 &mut state,
2630 Command::LayoutInsertNode {
2631 tab_id: tab_id.clone(),
2632 node,
2633 parent_id: None,
2634 index: None,
2635 focus_after: false,
2636 magnify_after: true,
2637 correlation_id: "corr".into(),
2638 },
2639 &ctx(1),
2640 );
2641 assert_eq!(
2642 state.tabs[&tab_id].focused_node_id, "new",
2643 "magnify_after must imply focus_after"
2644 );
2645 assert_eq!(state.tabs[&tab_id].magnified_node_id, "new");
2646 }
2647
2648 #[test]
2649 fn layout_insert_node_honours_explicit_parent_id_and_index() {
2650 let (mut state, tab_id) = fresh_tab();
2654 let mut group = leaf_node("group", "");
2655 group.data = None;
2656 group.children = vec![leaf_node("a", "ba"), leaf_node("c", "bc")];
2657 state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(group);
2658
2659 let _ = update(
2660 &mut state,
2661 Command::LayoutInsertNode {
2662 tab_id: tab_id.clone(),
2663 node: leaf_node("b", "bb"),
2664 parent_id: Some("group".into()),
2665 index: Some(1), focus_after: false,
2667 magnify_after: false,
2668 correlation_id: "corr".into(),
2669 },
2670 &ctx(1),
2671 );
2672
2673 let root = state.tabs[&tab_id].rootnode.as_ref().expect("root");
2674 let ids: Vec<_> = root.children.iter().map(|c| c.id.as_str()).collect();
2675 assert_eq!(ids, vec!["a", "b", "c"], "explicit index honoured");
2676 }
2677
2678 #[test]
2679 fn layout_insert_node_index_clamps_when_out_of_range() {
2680 let (mut state, tab_id) = fresh_tab();
2683 let mut group = leaf_node("group", "");
2684 group.data = None;
2685 group.children = vec![leaf_node("a", "ba")];
2686 state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(group);
2687
2688 let _ = update(
2689 &mut state,
2690 Command::LayoutInsertNode {
2691 tab_id: tab_id.clone(),
2692 node: leaf_node("b", "bb"),
2693 parent_id: Some("group".into()),
2694 index: Some(99),
2695 focus_after: false,
2696 magnify_after: false,
2697 correlation_id: "corr".into(),
2698 },
2699 &ctx(1),
2700 );
2701 let root = state.tabs[&tab_id].rootnode.as_ref().expect("root");
2702 let ids: Vec<_> = root.children.iter().map(|c| c.id.as_str()).collect();
2703 assert_eq!(ids, vec!["a", "b"], "out-of-range index clamps to end");
2704 }
2705
2706 #[test]
2707 fn layout_insert_node_into_empty_tree_with_explicit_parent_id_emits_error() {
2708 let (mut state, tab_id) = fresh_tab();
2712 let events = update(
2713 &mut state,
2714 Command::LayoutInsertNode {
2715 tab_id: tab_id.clone(),
2716 node: leaf_node("first", "b1"),
2717 parent_id: Some("does-not-exist".into()),
2718 index: None,
2719 focus_after: false,
2720 magnify_after: false,
2721 correlation_id: "corr".into(),
2722 },
2723 &ctx(1),
2724 );
2725 assert!(matches!(
2726 &events[0],
2727 Event::Error { code: ErrorCode::InvalidCommand, .. }
2728 ));
2729 assert!(
2730 state.tabs[&tab_id].rootnode.is_none(),
2731 "tree must stay empty on rejection"
2732 );
2733 }
2734
2735 #[test]
2736 fn layout_insert_node_into_empty_tree_with_explicit_index_emits_error() {
2737 let (mut state, tab_id) = fresh_tab();
2740 let events = update(
2741 &mut state,
2742 Command::LayoutInsertNode {
2743 tab_id: tab_id.clone(),
2744 node: leaf_node("first", "b1"),
2745 parent_id: None,
2746 index: Some(0),
2747 focus_after: false,
2748 magnify_after: false,
2749 correlation_id: "corr".into(),
2750 },
2751 &ctx(1),
2752 );
2753 assert!(matches!(
2754 &events[0],
2755 Event::Error { code: ErrorCode::InvalidCommand, .. }
2756 ));
2757 assert!(state.tabs[&tab_id].rootnode.is_none());
2758 }
2759
2760 #[test]
2761 fn layout_insert_node_with_unknown_parent_id_emits_error() {
2762 let (mut state, tab_id) = fresh_tab();
2768 state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(leaf_node("only", "b1"));
2769
2770 let events = update(
2771 &mut state,
2772 Command::LayoutInsertNode {
2773 tab_id: tab_id.clone(),
2774 node: leaf_node("added", "b2"),
2775 parent_id: Some("does-not-exist".into()),
2776 index: None,
2777 focus_after: false,
2778 magnify_after: false,
2779 correlation_id: "corr".into(),
2780 },
2781 &ctx(1),
2782 );
2783 assert!(matches!(
2784 &events[0],
2785 Event::Error { code: ErrorCode::InvalidCommand, .. }
2786 ));
2787 let root = state.tabs[&tab_id].rootnode.as_ref().expect("root");
2789 assert_eq!(root.id, "only");
2790 assert!(root.children.is_empty());
2791 }
2792
2793 #[test]
2794 fn layout_insert_node_with_leaf_parent_id_emits_error() {
2795 let (mut state, tab_id) = fresh_tab();
2798 let mut root = leaf_node("group", "");
2799 root.data = None;
2800 root.children = vec![leaf_node("a", "ba")];
2801 state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(root);
2802
2803 let events = update(
2804 &mut state,
2805 Command::LayoutInsertNode {
2806 tab_id: tab_id.clone(),
2807 node: leaf_node("b", "bb"),
2808 parent_id: Some("a".into()), index: None,
2810 focus_after: false,
2811 magnify_after: false,
2812 correlation_id: "corr".into(),
2813 },
2814 &ctx(1),
2815 );
2816 assert!(matches!(
2817 &events[0],
2818 Event::Error { code: ErrorCode::InvalidCommand, .. }
2819 ));
2820 }
2821
2822 #[test]
2823 fn layout_insert_node_with_neither_flag_leaves_state_alone() {
2824 let (mut state, tab_id) = fresh_tab();
2828 state.tabs.get_mut(&tab_id).unwrap().focused_node_id = "prev".into();
2829 let _ = update(
2830 &mut state,
2831 Command::LayoutInsertNode {
2832 tab_id: tab_id.clone(),
2833 node: leaf_node("new", "b1"),
2834 parent_id: None,
2835 index: None,
2836 focus_after: false,
2837 magnify_after: false,
2838 correlation_id: "corr".into(),
2839 },
2840 &ctx(1),
2841 );
2842 assert_eq!(state.tabs[&tab_id].focused_node_id, "prev");
2843 }
2844
2845 #[test]
2846 fn layout_delete_node_on_root_clears_the_tree() {
2847 let (mut state, tab_id) = fresh_tab();
2852 state.tabs.get_mut(&tab_id).unwrap().rootnode =
2853 Some(leaf_node("solitary-root", "b1"));
2854 let events = update(
2855 &mut state,
2856 Command::LayoutDeleteNode {
2857 tab_id: tab_id.clone(),
2858 node_id: "solitary-root".into(),
2859 correlation_id: "corr".into(),
2860 },
2861 &ctx(1),
2862 );
2863 assert!(matches!(&events[0], Event::LayoutNodeDeleted { .. }));
2864 assert!(
2865 state.tabs[&tab_id].rootnode.is_none(),
2866 "root deletion must wipe the tree"
2867 );
2868 }
2869
2870 #[test]
2871 fn layout_delete_node_clears_magnified_when_target_was_magnified() {
2872 let (mut state, tab_id) = fresh_tab();
2875 let mut root = leaf_node("group", "");
2876 root.data = None;
2877 root.children = vec![leaf_node("a", "b1"), leaf_node("b", "b2")];
2878 state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(root);
2879 state.tabs.get_mut(&tab_id).unwrap().magnified_node_id = "a".into();
2880 let _ = update(
2881 &mut state,
2882 Command::LayoutDeleteNode {
2883 tab_id: tab_id.clone(),
2884 node_id: "a".into(),
2885 correlation_id: "corr".into(),
2886 },
2887 &ctx(1),
2888 );
2889 assert_eq!(state.tabs[&tab_id].magnified_node_id, "");
2890 }
2891
2892 #[test]
2893 fn layout_node_deleted_event_carries_was_magnified() {
2894 let (mut state, tab_id) = fresh_tab();
2898 let mut root = leaf_node("group", "");
2899 root.data = None;
2900 root.children = vec![leaf_node("a", "b1"), leaf_node("b", "b2")];
2901 state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(root);
2902 state.tabs.get_mut(&tab_id).unwrap().magnified_node_id = "a".into();
2903
2904 let events = update(
2905 &mut state,
2906 Command::LayoutDeleteNode {
2907 tab_id: tab_id.clone(),
2908 node_id: "a".into(),
2909 correlation_id: "corr".into(),
2910 },
2911 &ctx(1),
2912 );
2913 match &events[0] {
2914 Event::LayoutNodeDeleted { was_magnified, was_focused, .. } => {
2915 assert!(*was_magnified, "was_magnified must be true");
2916 assert!(!*was_focused, "was_focused stays false");
2917 }
2918 other => panic!("expected LayoutNodeDeleted, got {:?}", other),
2919 }
2920 }
2921
2922 #[test]
2923 fn layout_delete_node_clears_focused_when_collapse_replaces_parent_id() {
2924 let (mut state, tab_id) = fresh_tab();
2931 let mut group = leaf_node("group-id", "");
2932 group.data = None;
2933 group.children = vec![leaf_node("only-child", "b1"), leaf_node("sibling", "b2")];
2934 state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(group);
2935 state.tabs.get_mut(&tab_id).unwrap().focused_node_id = "group-id".into();
2939
2940 let events = update(
2941 &mut state,
2942 Command::LayoutDeleteNode {
2943 tab_id: tab_id.clone(),
2944 node_id: "sibling".into(),
2945 correlation_id: "corr".into(),
2946 },
2947 &ctx(1),
2948 );
2949 match &events[0] {
2950 Event::LayoutNodeDeleted { was_focused, .. } => {
2951 assert!(
2952 *was_focused,
2953 "collapse rewrote parent id; reducer must report focus loss"
2954 );
2955 }
2956 other => panic!("expected LayoutNodeDeleted, got {:?}", other),
2957 }
2958 assert_eq!(
2959 state.tabs[&tab_id].focused_node_id, "",
2960 "stale focus cleared post-collapse"
2961 );
2962 }
2963
2964 #[test]
2965 fn layout_delete_node_clears_focused_when_target_subtree_contains_focus() {
2966 let (mut state, tab_id) = fresh_tab();
2970 let mut leaf_x = leaf_node("leaf-x", "bx");
2974 leaf_x.data = Some(agentmux_common::LayoutNodeData {
2975 block_id: "bx".into(),
2976 ..Default::default()
2977 });
2978 let mut group_a = leaf_node("group-A", "");
2979 group_a.data = None;
2980 group_a.children = vec![leaf_x, leaf_node("leaf-y", "by")];
2981 let mut root = leaf_node("root-group", "");
2982 root.data = None;
2983 root.children = vec![group_a, leaf_node("leaf-z", "bz")];
2984 state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(root);
2985 state.tabs.get_mut(&tab_id).unwrap().focused_node_id = "leaf-x".into();
2987 state.tabs.get_mut(&tab_id).unwrap().magnified_node_id = "leaf-y".into();
2988
2989 let events = update(
2990 &mut state,
2991 Command::LayoutDeleteNode {
2992 tab_id: tab_id.clone(),
2993 node_id: "group-A".into(),
2994 correlation_id: "corr".into(),
2995 },
2996 &ctx(1),
2997 );
2998 match &events[0] {
2999 Event::LayoutNodeDeleted { was_focused, was_magnified, .. } => {
3000 assert!(*was_focused, "descendant focus must be cleared");
3001 assert!(*was_magnified, "descendant magnify must be cleared");
3002 }
3003 other => panic!("expected LayoutNodeDeleted, got {:?}", other),
3004 }
3005 assert_eq!(state.tabs[&tab_id].focused_node_id, "");
3006 assert_eq!(state.tabs[&tab_id].magnified_node_id, "");
3007 }
3008
3009 #[test]
3010 fn layout_insert_node_event_echoes_parent_id_and_index() {
3011 let (mut state, tab_id) = fresh_tab();
3016 let mut group = leaf_node("group", "");
3017 group.data = None;
3018 group.children = vec![leaf_node("a", "ba")];
3019 state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(group);
3020
3021 let events = update(
3022 &mut state,
3023 Command::LayoutInsertNode {
3024 tab_id: tab_id.clone(),
3025 node: leaf_node("new", "b1"),
3026 parent_id: Some("group".into()),
3027 index: Some(0),
3028 focus_after: false,
3029 magnify_after: false,
3030 correlation_id: "corr".into(),
3031 },
3032 &ctx(1),
3033 );
3034 match &events[0] {
3035 Event::LayoutNodeInserted { parent_id, index, .. } => {
3036 assert_eq!(parent_id.as_deref(), Some("group"));
3037 assert_eq!(*index, Some(0));
3038 }
3039 other => panic!("expected LayoutNodeInserted, got {:?}", other),
3040 }
3041 }
3042
3043 #[test]
3044 fn layout_delete_node_clears_focused_when_target_was_focused() {
3045 let (mut state, tab_id) = fresh_tab();
3046 let mut root = leaf_node("root-group", "");
3048 root.data = None;
3049 root.children = vec![leaf_node("a", "b1"), leaf_node("b", "b2")];
3050 state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(root);
3051 state.tabs.get_mut(&tab_id).unwrap().focused_node_id = "a".into();
3052
3053 let events = update(
3054 &mut state,
3055 Command::LayoutDeleteNode {
3056 tab_id: tab_id.clone(),
3057 node_id: "a".into(),
3058 correlation_id: "corr-del".into(),
3059 },
3060 &ctx(1),
3061 );
3062 assert!(matches!(
3063 &events[0],
3064 Event::LayoutNodeDeleted { was_focused: true, .. }
3065 ));
3066 assert_eq!(state.tabs[&tab_id].focused_node_id, "");
3067 }
3068}
3069