agentmux_srv/
reducer.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4// Phase E — srv reducer.
5//
6// Pure functional core: `update(&mut State, Command, &Ctx) -> Vec<Event>`.
7// Never blocks, never awaits, never does I/O. Same discipline as
8// `agentmux-launcher::reducer`. Mutex held only during dispatch
9// (sub-millisecond).
10//
11// Arms by phase:
12//   * E.1b — Register / Goodbye / Ping / GetSrvSnapshot / GetEvents
13//   * E.2  — CreateWorkspace / DeleteWorkspace
14//   * E.2b — CreateTab / DeleteTab / SetActiveTab / ReorderTab
15//   * E.3  — CreateBlock / DeleteBlock
16//   * E.5  — CreateWindow / CloseWindowInternal / SwitchWorkspace
17//             (window↔workspace mapping for sagas)
18//   * E.5+ — saga-driven multi-step commands (TearOff/Restore/Move)
19//             land via the saga coordinator dispatching atomic arms
20//
21// `Command::GetEvents` is intercepted by the IPC server before
22// reaching the reducer (server queries the event log; reducer
23// stays pure). The reducer's arm exists only for match
24// exhaustiveness; same pattern as the launcher reducer.
25
26
27mod 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// Test-only imports: tests construct fixtures that mention these types
38// directly, but the dispatch+Ctx in this file goes through the
39// per-domain submodules.
40#[cfg(test)]
41use agentmux_common::ipc::{ClientKind, LifecyclePhase};
42#[cfg(test)]
43use crate::state::ProcessState;
44
45/// Per-dispatch context. Currently just an RFC3339 timestamp + the
46/// originating connection's `conn_id` for log correlation.
47#[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(), // intercepted by server; unreachable
64        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        // Phase E.4.B Phase 5 — layout tree mutation arms. Currently
87        // dormant scaffolding (no production callers; Phase 7 migrates
88        // the wcore-direct writers to dispatch through these). 4 of 11
89        // arms shipped in this PR; remaining 7 (insert_at_index, move,
90        // swap, resize, replace, split_horizontal, split_vertical) are
91        // structurally identical and follow in subsequent PRs.
92        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        // Anything else is a non-fatal protocol error. Future
172        // phases (E.2b tabs, E.3 blocks, E.4 layouts) extend this
173        // match by adding new arms above.
174        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            // Phase E.4.B — layout tree events.
260            | 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        // ProcessSpawned + Registered, no LifecyclePhaseChanged.
324        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        // Original record preserved.
359        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        // Re-register with same PID — allowed because prior is Exited.
376        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        // Sorted by id; verify ordering deterministic (ascending).
538        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        // First event: TabCreated; second: ActiveTabChanged.
581        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        // Only TabCreated — second tab does not become active.
611        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        // tab2 wasn't active, so no ActiveTabChanged.
649        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        // tab1 was created first → it's active. Delete it.
677        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        // tab2 should now be active.
692        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        // Reducer accepts last-tab delete (round 2 of PR #633 walked
698        // back the guard). User-facing flows gate at the call site
699        // (close button + keymodel both check `tab_ids.len() <= 1`);
700        // internal compensation paths rely on this acceptance to
701        // roll back failed CreateTab persists.
702        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                // Last-tab delete needs force=true post-round-4
719                // (codex P2 #633). Test asserts the
720                // ActiveTabChanged-to-None behavior still works
721                // when compensation paths force a last-tab delete.
722                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        // Wrong workspace.
764        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        // Right workspace, wrong tab.
774        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        // Already active (auto-activated on first tab create).
799        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        // Move first tab to index 2 (last).
826        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        // Asking for index 99 should clamp to 1 (len-1).
864        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    /// codex P1 #620 carryover: `ReorderTabsBulk` must accept a list
932    /// containing tab_ids the reducer hasn't seen yet (because they
933    /// arrived via wcore-direct paths like `MoveTabToWorkspace`).
934    /// Strict permutation validation against the reducer's stale view
935    /// would falsely reject the canonical SQLite ordering during the
936    /// migration window.
937    #[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        // Simulate a wcore-direct move that landed a new tab in this
943        // workspace's SQLite list without going through the reducer.
944        // The reducer's `workspace.tab_ids` is now stale: it knows
945        // about `known` only, but SQLite has both `known` and
946        // `imported` (and the latter belongs to an entirely different
947        // workspace from the reducer's perspective).
948        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    /// codex P1 #620 carryover: a duplicate tab_id in the new list
967    /// is still rejected — that would corrupt the persisted ordering
968    /// in a way the subscriber can't recover from.
969    #[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    /// codex P2 #622: empty name auto-generates `tabN`, mirroring
1059    /// `wcore::create_tab`'s default-naming behaviour. Without this,
1060    /// CreateWindow's "fresh workspace" path + TearOffBlock's new tab
1061    /// would land with blank titles — a user-visible regression.
1062    #[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        // Second empty-name CreateTab → "tab2".
1082        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        // Explicit non-empty name passes through verbatim.
1095        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    // ---------------------------------------------------------------
1181    // Phase E.4 (Option A) — SetFocusedNode / SetMagnifiedNode
1182    // ---------------------------------------------------------------
1183
1184    #[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        // Magnify a node first.
1310        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        // Now clear with empty node_id (toggle-off semantics).
1320        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                // Single-tab workspace; force=true bypasses last-tab
1373                // guard so we can test the block cascade.
1374                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    // ---- E.5: window↔workspace mapping arms ----
1424
1425    #[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        // Unknown window.
1552        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        // Known window, unknown workspace.
1562        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        // Delete workspace A; only win-a should be dropped + emit
1626        // SrvWindowClosed; win-b survives.
1627        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        // Verify win-b was NOT closed.
1638        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    // ---- Phase E.5.5 — MoveTab tests ----
1694
1695    #[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        // Phase E.4 strict-mode flip: unknown tabs are now REJECTED.
1837        // The migration-tolerant lazy-import fallback was removed once
1838        // the soak window closed without `lazy-import` warnings being
1839        // observed in production. See `move_tab_unknown_tab_rejects`
1840        // for the dedicated test.
1841        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    /// Phase E.4 strict-mode flip: an unknown tab id (not present in
1855    /// `state.tabs`) is rejected with a clear "tab not found" error
1856    /// rather than being lazy-imported. Replaces the migration-window
1857    /// `move_tab_lazy_imports_unknown_tab` test.
1858    #[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        // No lazy import side-effect.
1887        assert!(!state.tabs.contains_key(&unknown_id));
1888    }
1889
1890    /// Phase E.4 strict-mode flip: a known tab whose reducer-state
1891    /// `workspace_id` doesn't match `src_workspace_id` is rejected.
1892    /// Replaces the migration-window
1893    /// `move_tab_tolerates_workspace_id_mismatch_during_migration`
1894    /// test.
1895    #[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        // Claim the tab lives in `other` even though it actually
1904        // belongs to `real_src` per reducer state.
1905        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        // Reducer state untouched: t1 still in real_src, filler still
1927        // in other, dst empty.
1928        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    // ---- Phase E.5.5 — MoveBlock tests ----
1935
1936    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        // Move b1 to position 2 (end after removal).
1984        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    // ---- Phase E.7 — property tests for reducer arm invariants ----
2073    //
2074    // Drives randomized sequences of valid commands through `update`
2075    // and asserts cross-arm invariants the unit tests above only
2076    // touch on per-arm. Catches regressions where an individual arm
2077    // looks correct in isolation but interacts with sibling arms in
2078    // a way that violates the reducer's whole-state contract.
2079    //
2080    // Invariants asserted:
2081    //   1. Version monotonicity: every event's version strictly
2082    //      increases across the sequence (no duplicates, no gaps in
2083    //      the wrong direction).
2084    //   2. Referential integrity: every tab in `state.tabs` has a
2085    //      `workspace_id` that exists in `state.workspaces`; every
2086    //      block in `state.blocks` has a `tab_id` that exists in
2087    //      `state.tabs`; every workspace's `tab_ids` references real
2088    //      tabs; every tab's `block_ids` references real blocks.
2089    //   3. Cascade integrity: after a `DeleteWorkspace`, no tab
2090    //      remains in `state.tabs` with that workspace_id, and no
2091    //      block remains in `state.blocks` whose tab was in that
2092    //      workspace.
2093    //   4. Active-tab validity: every workspace's `active_tab_id`
2094    //      is either `None` or points at a tab present in its own
2095    //      `tab_ids`.
2096
2097    use proptest::prelude::*;
2098
2099    /// Higher-level operations the property tests pick from. Each
2100    /// resolves to one or more `Command` invocations against the
2101    /// current state. We can't generate `Command`s directly because
2102    /// IDs are reducer-generated; pick from existing IDs instead.
2103    #[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        // Bias toward "constructive" ops so sequences accumulate
2115        // state rather than churn empty. Each Just is one variant;
2116        // proptest weights via `prop_oneof![weight => strat, …]`.
2117        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    /// Apply one PropOp; returns the events produced (which may be
2128    /// empty if the op was a no-op like "delete from empty pool").
2129    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                        // Proptest exercises both guarded + unguarded
2167                        // paths; force=true here ensures cascade
2168                        // invariants are tested without the guard
2169                        // short-circuiting the operation.
2170                        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    /// Verify all four reducer-state invariants at once. Panics
2203    /// (proptest catches and shrinks) if any is violated.
2204    fn assert_invariants(state: &State) {
2205        // (2) Tabs reference real workspaces.
2206        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        // (2) Blocks reference real tabs.
2215        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        // (2) Workspace.tab_ids references real tabs.
2224        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        // (2) Tab.block_ids references real blocks.
2235        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        // (4) Active-tab validity.
2246        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        /// Apply a random sequence of valid ops. After each op,
2265        /// referential integrity + active-tab validity hold; across
2266        /// the whole sequence, version is strictly monotonic for any
2267        /// emitted events.
2268        #[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        /// Cascade integrity — explicit setup-then-delete pattern.
2290        /// Build a non-trivial graph (workspace + tabs + blocks),
2291        /// delete the workspace, assert NO surviving entities
2292        /// reference the deleted workspace.
2293        #[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            // Create workspace.
2300            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            // Create tabs + blocks under it. We don't keep the tab
2313            // IDs around — the cascade-after-delete assertions below
2314            // check the WHOLE-state collections (state.tabs.is_empty()
2315            // etc.), so per-tab IDs aren't needed. (reagent P2 #627.)
2316            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            // Sanity: counts match.
2344            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            // Delete the workspace.
2348            let _ = update(
2349                &mut state,
2350                Command::DeleteWorkspace { workspace_id: ws_id.clone(), force: false },
2351                &ctx(4),
2352            );
2353            // Cascade — workspaces, tabs, blocks should all be empty.
2354            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            // And invariants hold on the empty state.
2362            assert_invariants(&state);
2363        }
2364    }
2365
2366    // ── Phase E.4.B Phase 5 — layout reducer arms ─────────────────
2367    //
2368    // Tests for the 4 arms shipped in this PR. All arms share the
2369    // same shape (lookup tab → mutate `tab.rootnode` via pure helper
2370    // → emit Event::Layout*); the unit tests below verify state
2371    // mutation and event shape per arm. The pure helpers themselves
2372    // have their own ~40 tests in `agentmux-srv/src/backend/layout/`.
2373
2374    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        // Pre-load some state.
2397        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        // Pre-load a single-leaf tree.
2497        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        // Helper turned the leaf root into a group with both leaves;
2514        // exact shape is the helper's contract — we just assert the
2515        // tree changed and contains both block ids.
2516        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        // empty-tree set must match
2554        // `LayoutClear`'s contract — focused/magnified ids would
2555        // otherwise dangle past the wipe.
2556        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        // Symmetry guard: Some(new_tree) must NOT clear focused/
2578        // magnified — caller may have set them deliberately.
2579        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        // focus_after=true must update
2599        // focused_node_id so the snapshot matches the event.
2600        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        // magnify-implies-focus. Even when
2622        // focus_after=false, setting magnify_after=true must also
2623        // update focused_node_id so it doesn't dangle on the prior
2624        // pane (UI invariant: a magnified pane is the focused pane).
2625        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        // with parent_id given, insert at
2651        // that node at the requested index instead of running the
2652        // heuristic helper.
2653        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), // between a and c
2666                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        // Out-of-range index clamps to the end (matches frontend
2681        // `findNextInsertLocation` defensive semantics).
2682        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        // empty-tree
2709        // promotion must reject explicit parent_id — otherwise the
2710        // event echoes a target that subscribers can't resolve.
2711        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        // Same rationale but with `index` only — the spec §7.1
2738        // requires both fields be `None` for empty-tree promote.
2739        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        // silent fallback to heuristic
2763        // diverges the event from the actual mutation. Reject
2764        // explicit-but-invalid parent_id with Event::Error so
2765        // subscribers (especially the persist subscriber, future)
2766        // see a consistent record.
2767        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        // Tree must be unchanged.
2788        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        // parent_id resolves to a leaf (has data) — leaf can't host
2796        // children, so treat as invalid the same as a missing parent.
2797        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()), // leaf, not a group
2809                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        // Anti-vacuity guard: confirm the false-flag path is the
2825        // baseline (otherwise the focus_after/magnify_after tests
2826        // wouldn't be measuring anything).
2827        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        // backend::layout::delete_node leaves
2848        // root deletion to the caller. Without the root-detection
2849        // branch, we'd emit LayoutNodeDeleted while rootnode still
2850        // contains the supposedly-deleted tree.
2851        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        // magnified must be cleared
2873        // alongside focused; same staleness concern.
2874        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        // subscribers need
2895        // the was_magnified field to refresh their UI when the
2896        // magnified node is deleted.
2897        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        // `backend::layout::delete_node`'s collapse-sole-child path
2925        // promotes the surviving child and rewrites the parent's id
2926        // to the child's id. If focused/magnified pointed at the
2927        // ORIGINAL parent id, that id is gone from the tree even
2928        // though the same physical layout slot exists. Reducer must
2929        // clear the dangling reference.
2930        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        // Focus the group (the parent that will get its id rewritten
2936        // when "sibling" is deleted and "only-child" is the sole
2937        // survivor of the now-1-child group).
2938        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        // deleting a container
2967        // wipes its descendants, but a direct-id-match check on
2968        // focused/magnified misses descendants — they stay dangling.
2969        let (mut state, tab_id) = fresh_tab();
2970        // Tree:
2971        //   root-group (children: group-A, leaf-z)
2972        //     group-A (children: leaf-x, leaf-y)
2973        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        // Focus a descendant of group-A, then delete group-A.
2986        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        // The emitted event must echo the command's parent_id /
3012        // index so subscribers see what was requested. Tree pre-
3013        // populated with a group so the explicit-parent path
3014        // doesn't take the empty-tree rejection branch.
3015        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        // Tree: group with two leaves.
3047        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