| 1 | //! FEAT-023 Phase 4/6: portable lifecycle handler tests (Tasks 4.2/4.4/4.6). |
| 2 | //! |
| 3 | //! Deterministic composition tests: canned lifecycle outcomes drive each |
| 4 | //! portable handler and the exact baseline messages/actions are asserted |
| 5 | //! byte-for-byte. The public dispatch seam integration (real bundle) is |
| 6 | //! exercised in Phase 6 and the end-to-end parity matrix in Phase 7. |
| 7 | |
| 8 | use codewhale_command_contract::facets::{ |
| 9 | SessionArchiveReceipt, SessionBranchOutcome, SessionForkFromReceipt, SessionForkReceipt, |
| 10 | SessionNewReceipt, SessionSaveReceipt, TreeBodyProjection, |
| 11 | }; |
| 12 | use codewhale_command_contract::handler::CommandContexts; |
| 13 | use std::path::PathBuf; |
| 14 | |
| 15 | use crate::tui::app::AppAction; |
| 16 | |
| 17 | use super::lifecycle_test_support::{CannedLifecycle, sync_payload}; |
| 18 | |
| 19 | fn missing_lifecycle() -> CommandContexts<'static> { |
| 20 | CommandContexts::empty() |
| 21 | } |
| 22 | |
| 23 | // ---- /branch (Task 4.5) ---- |
| 24 | |
| 25 | #[test] |
| 26 | fn every_contextual_lifecycle_handler_fails_safely_without_its_facet() { |
| 27 | type Handler = fn(CommandContexts<'_>, Option<&str>) -> super::CommandResult; |
| 28 | let handlers: [(&str, Handler, Option<&str>); 7] = [ |
| 29 | ("branch", super::branch::branch_contextual, Some("entry-1")), |
| 30 | ("fork", super::fork::fork_contextual, Some("session-1")), |
| 31 | ("load", super::load::load_contextual, Some("session.json")), |
| 32 | ("new", super::new::new_contextual, None), |
| 33 | ("save", super::save::save_contextual, None), |
| 34 | ("sessions", super::sessions::sessions_contextual, None), |
| 35 | ("tree", super::tree::tree_contextual, None), |
| 36 | ]; |
| 37 | |
| 38 | for (name, handler, arg) in handlers { |
| 39 | let result = handler(missing_lifecycle(), arg); |
| 40 | assert!(result.is_error, "/{name}: {result:?}"); |
| 41 | assert_eq!( |
| 42 | result.message.as_deref(), |
| 43 | Some("Error: Command capability unavailable: session_lifecycle"), |
| 44 | "/{name}" |
| 45 | ); |
| 46 | assert!(result.action.is_none(), "/{name}"); |
| 47 | } |
| 48 | } |
| 49 | |
| 50 | #[test] |
| 51 | fn branch_composes_exact_baseline_messages() { |
| 52 | // Blocked transition first. |
| 53 | let mut canned = CannedLifecycle { |
| 54 | blocked: true, |
| 55 | ..CannedLifecycle::default() |
| 56 | }; |
| 57 | let result = super::branch::branch_portable(&mut canned, Some("entry-1")); |
| 58 | assert_eq!( |
| 59 | result.message.as_deref(), |
| 60 | Some( |
| 61 | "Error: Cannot branch while runtime work is active. Wait for the turn to finish, or cancel it first." |
| 62 | ) |
| 63 | ); |
| 64 | assert_eq!(canned.transition_checks.get(), 1); |
| 65 | assert!(canned.branch_entries.is_empty()); |
| 66 | |
| 67 | // No-arg with an active leaf hint. |
| 68 | let mut canned = CannedLifecycle { |
| 69 | leaf_hint: Some("entry-7".to_string()), |
| 70 | ..CannedLifecycle::default() |
| 71 | }; |
| 72 | let result = super::branch::branch_portable(&mut canned, None); |
| 73 | assert_eq!( |
| 74 | result.message.as_deref(), |
| 75 | Some( |
| 76 | "Current leaf: entry-7\nUse `/branch <entry_id>` to move the leaf (history is never rewritten).\nUse `/tree` to list entry ids." |
| 77 | ) |
| 78 | ); |
| 79 | |
| 80 | // No-arg without a leaf -> usage fallback. |
| 81 | let mut canned = CannedLifecycle::default(); |
| 82 | let result = super::branch::branch_portable(&mut canned, None); |
| 83 | assert!( |
| 84 | result |
| 85 | .message |
| 86 | .as_deref() |
| 87 | .is_some_and(|m| m.starts_with("Usage: /branch <entry_id>")), |
| 88 | "{result:?}" |
| 89 | ); |
| 90 | |
| 91 | // Success message uses deterministic receipt fields. |
| 92 | let mut canned = CannedLifecycle { |
| 93 | branch: Ok(SessionBranchOutcome { |
| 94 | leaf_display: "entry-3".to_string(), |
| 95 | journal_entries_before: 5, |
| 96 | sync: sync_payload("branched-session"), |
| 97 | }), |
| 98 | ..CannedLifecycle::default() |
| 99 | }; |
| 100 | let result = super::branch::branch_portable(&mut canned, Some("entry-3")); |
| 101 | assert_eq!( |
| 102 | result.message.as_deref(), |
| 103 | Some( |
| 104 | "Branched to entry entry-3 (leaf now entry-3); journal entries 5 (history preserved, leaf moved only)" |
| 105 | ) |
| 106 | ); |
| 107 | assert!( |
| 108 | matches!(result.action, Some(AppAction::SyncSession { session_id: Some(ref id), .. }) if id == "branched-session") |
| 109 | ); |
| 110 | assert_eq!(canned.branch_entries, ["entry-3"]); |
| 111 | |
| 112 | // Host stage error passes through unchanged. |
| 113 | let mut canned = CannedLifecycle { |
| 114 | branch: Err("could not load session x: boom".to_string()), |
| 115 | ..CannedLifecycle::default() |
| 116 | }; |
| 117 | let result = super::branch::branch_portable(&mut canned, Some("x")); |
| 118 | assert!(result.is_error); |
| 119 | assert_eq!( |
| 120 | result.message.as_deref(), |
| 121 | Some("Error: could not load session x: boom") |
| 122 | ); |
| 123 | } |
| 124 | |
| 125 | // ---- /fork (Task 4.3) ---- |
| 126 | |
| 127 | #[test] |
| 128 | fn fork_composes_exact_baseline_messages_and_actions() { |
| 129 | // Picker aliases push the picker and return the baseline message. |
| 130 | let mut canned = CannedLifecycle::default(); |
| 131 | let result = super::fork::fork_portable(&mut canned, Some("picker")); |
| 132 | assert_eq!( |
| 133 | result.message.as_deref(), |
| 134 | Some("Fork picker: select a session and then run `/fork <id>` to fork it.") |
| 135 | ); |
| 136 | assert_eq!( |
| 137 | canned.picker_calls, |
| 138 | [None], |
| 139 | "bare picker must be opened without preselection" |
| 140 | ); |
| 141 | assert_eq!( |
| 142 | canned.transition_checks.get(), |
| 143 | 0, |
| 144 | "picker aliases bypass the transition gate" |
| 145 | ); |
| 146 | |
| 147 | // Blocked active fork. |
| 148 | let mut canned = CannedLifecycle { |
| 149 | blocked: true, |
| 150 | ..CannedLifecycle::default() |
| 151 | }; |
| 152 | let result = super::fork::fork_portable(&mut canned, None); |
| 153 | assert!(result.is_error); |
| 154 | assert!( |
| 155 | result |
| 156 | .message |
| 157 | .as_deref() |
| 158 | .unwrap_or_default() |
| 159 | .contains("runtime work is active"), |
| 160 | "{result:?}" |
| 161 | ); |
| 162 | assert_eq!(canned.transition_checks.get(), 1); |
| 163 | |
| 164 | // Active fork success -> message + SyncSession action from the receipt. |
| 165 | let mut canned = CannedLifecycle { |
| 166 | fork_active: Ok(SessionForkReceipt { |
| 167 | parent_label: "parent1".to_string(), |
| 168 | fork_label: "child2".to_string(), |
| 169 | sync: sync_payload("child2"), |
| 170 | }), |
| 171 | ..CannedLifecycle::default() |
| 172 | }; |
| 173 | let result = super::fork::fork_portable(&mut canned, None); |
| 174 | assert_eq!( |
| 175 | result.message.as_deref(), |
| 176 | Some("Forked session parent1 -> child2") |
| 177 | ); |
| 178 | assert!(matches!( |
| 179 | result.action, |
| 180 | Some(AppAction::SyncSession { session_id: Some(ref id), .. }) if id == "child2" |
| 181 | )); |
| 182 | assert_eq!(canned.transition_checks.get(), 1); |
| 183 | |
| 184 | // Explicit fork success appends spawn_depth. |
| 185 | let mut canned = CannedLifecycle { |
| 186 | fork_from: Ok(SessionForkFromReceipt { |
| 187 | parent_label: "aaaa".to_string(), |
| 188 | fork_label: "bbbb".to_string(), |
| 189 | spawn_depth: 2, |
| 190 | sync: sync_payload("bbbb"), |
| 191 | }), |
| 192 | ..CannedLifecycle::default() |
| 193 | }; |
| 194 | let result = super::fork::fork_portable(&mut canned, Some("aaaa")); |
| 195 | assert_eq!( |
| 196 | result.message.as_deref(), |
| 197 | Some("Forked session aaaa -> bbbb (spawn_depth 2)") |
| 198 | ); |
| 199 | assert_eq!(canned.fork_sources, ["aaaa"]); |
| 200 | assert_eq!(canned.transition_checks.get(), 1); |
| 201 | } |
| 202 | |
| 203 | // ---- /load (Task 4.3) ---- |
| 204 | |
| 205 | #[test] |
| 206 | fn load_composes_exact_baseline_outcomes() { |
| 207 | let mut canned = CannedLifecycle { |
| 208 | blocked: true, |
| 209 | ..CannedLifecycle::default() |
| 210 | }; |
| 211 | let result = super::load::load_portable(&mut canned, Some("x.json")); |
| 212 | assert!(result.is_error); |
| 213 | assert!( |
| 214 | result |
| 215 | .message |
| 216 | .as_deref() |
| 217 | .unwrap_or_default() |
| 218 | .contains("runtime work is active") |
| 219 | ); |
| 220 | assert_eq!(canned.transition_checks.get(), 1); |
| 221 | assert!(canned.load_paths.is_empty()); |
| 222 | |
| 223 | let mut canned = CannedLifecycle::default(); |
| 224 | let result = super::load::load_portable(&mut canned, None); |
| 225 | assert_eq!( |
| 226 | result.message.as_deref(), |
| 227 | Some("Error: Usage: /load <path>") |
| 228 | ); |
| 229 | assert_eq!(canned.transition_checks.get(), 1); |
| 230 | assert!(canned.load_paths.is_empty()); |
| 231 | |
| 232 | let mut canned = CannedLifecycle { |
| 233 | load: Ok(PathBuf::from("/tmp/loaded.json")), |
| 234 | ..CannedLifecycle::default() |
| 235 | }; |
| 236 | let result = super::load::load_portable(&mut canned, Some("/tmp/loaded.json")); |
| 237 | assert!(result.message.is_none(), "no premature receipt: {result:?}"); |
| 238 | assert!(matches!( |
| 239 | result.action, |
| 240 | Some(AppAction::LoadSession(ref p)) if p == &PathBuf::from("/tmp/loaded.json") |
| 241 | )); |
| 242 | assert_eq!(canned.load_paths, ["/tmp/loaded.json"]); |
| 243 | assert_eq!(canned.transition_checks.get(), 1); |
| 244 | |
| 245 | let mut canned = CannedLifecycle { |
| 246 | load: Err("Failed to read session file: nope".to_string()), |
| 247 | ..CannedLifecycle::default() |
| 248 | }; |
| 249 | let result = super::load::load_portable(&mut canned, Some("missing.json")); |
| 250 | assert_eq!( |
| 251 | result.message.as_deref(), |
| 252 | Some("Error: Failed to read session file: nope") |
| 253 | ); |
| 254 | } |
| 255 | |
| 256 | // ---- /new (Task 4.3) ---- |
| 257 | |
| 258 | #[test] |
| 259 | fn new_composes_exact_baseline_outcomes() { |
| 260 | // Unknown argument usage. |
| 261 | let mut canned = CannedLifecycle::default(); |
| 262 | let result = super::new::new_portable(&mut canned, Some("bogus")); |
| 263 | assert!(result.is_error); |
| 264 | assert!( |
| 265 | result |
| 266 | .message |
| 267 | .as_deref() |
| 268 | .unwrap_or_default() |
| 269 | .contains("Unknown argument: bogus"), |
| 270 | "{result:?}" |
| 271 | ); |
| 272 | assert_eq!( |
| 273 | canned.transition_checks.get(), |
| 274 | 0, |
| 275 | "argument validation precedes the transition gate" |
| 276 | ); |
| 277 | |
| 278 | // Blocked. |
| 279 | let mut canned = CannedLifecycle { |
| 280 | blocked: true, |
| 281 | ..CannedLifecycle::default() |
| 282 | }; |
| 283 | let result = super::new::new_portable(&mut canned, None); |
| 284 | assert!(result.is_error); |
| 285 | assert!( |
| 286 | result |
| 287 | .message |
| 288 | .as_deref() |
| 289 | .unwrap_or_default() |
| 290 | .contains("only discards draft or queued input") |
| 291 | ); |
| 292 | assert_eq!(canned.transition_checks.get(), 1); |
| 293 | assert!(canned.fresh_forces.is_empty()); |
| 294 | |
| 295 | // Success -> message + empty SyncSession action. |
| 296 | let mut canned = CannedLifecycle { |
| 297 | fresh: Ok(SessionNewReceipt { |
| 298 | truncated_id: "new-123".to_string(), |
| 299 | sync: sync_payload("new-123"), |
| 300 | }), |
| 301 | ..CannedLifecycle::default() |
| 302 | }; |
| 303 | let result = super::new::new_portable(&mut canned, Some("--force")); |
| 304 | assert_eq!( |
| 305 | result.message.as_deref(), |
| 306 | Some( |
| 307 | "Started new session new-123 (New Session). Previous sessions remain available via /resume." |
| 308 | ) |
| 309 | ); |
| 310 | assert!(matches!( |
| 311 | result.action, |
| 312 | Some(AppAction::SyncSession { session_id: Some(ref id), .. }) if id == "new-123" |
| 313 | )); |
| 314 | assert_eq!(canned.fresh_forces, [true]); |
| 315 | assert_eq!(canned.transition_checks.get(), 1); |
| 316 | |
| 317 | // Host blocker error passes through. |
| 318 | let mut canned = CannedLifecycle { |
| 319 | fresh: Err("Cannot start a new session while the composer has unsent text. Run `/new --force` to discard pending work and start a fresh session.".to_string()), |
| 320 | ..CannedLifecycle::default() |
| 321 | }; |
| 322 | let result = super::new::new_portable(&mut canned, None); |
| 323 | assert!(result.is_error); |
| 324 | assert!( |
| 325 | result |
| 326 | .message |
| 327 | .as_deref() |
| 328 | .unwrap_or_default() |
| 329 | .contains("/new --force") |
| 330 | ); |
| 331 | } |
| 332 | |
| 333 | // ---- /save (Task 4.3) ---- |
| 334 | |
| 335 | #[test] |
| 336 | fn save_composes_exact_baseline_receipt() { |
| 337 | let mut canned = CannedLifecycle { |
| 338 | save: Ok(SessionSaveReceipt { |
| 339 | display_path: "/tmp/abc.json".to_string(), |
| 340 | truncated_id: "abc123".to_string(), |
| 341 | }), |
| 342 | ..CannedLifecycle::default() |
| 343 | }; |
| 344 | let result = super::save::save_portable(&mut canned, Some("/tmp/abc.json")); |
| 345 | assert_eq!( |
| 346 | result.message.as_deref(), |
| 347 | Some("Session saved to /tmp/abc.json (ID: abc123)") |
| 348 | ); |
| 349 | assert!(result.action.is_none()); |
| 350 | assert_eq!(canned.save_paths, [Some("/tmp/abc.json".to_string())]); |
| 351 | |
| 352 | let mut canned = CannedLifecycle { |
| 353 | save: Err("Failed to save session: boom".to_string()), |
| 354 | ..CannedLifecycle::default() |
| 355 | }; |
| 356 | let result = super::save::save_portable(&mut canned, None); |
| 357 | assert_eq!( |
| 358 | result.message.as_deref(), |
| 359 | Some("Error: Failed to save session: boom") |
| 360 | ); |
| 361 | } |
| 362 | |
| 363 | // ---- /sessions (Task 4.5) ---- |
| 364 | |
| 365 | #[test] |
| 366 | fn sessions_composes_exact_baseline_outcomes() { |
| 367 | // Bare -> picker push, no message/action. |
| 368 | let mut canned = CannedLifecycle::default(); |
| 369 | let result = super::sessions::sessions_portable(&mut canned, None); |
| 370 | assert_eq!(result.message, None); |
| 371 | assert_eq!(result.action, None); |
| 372 | assert_eq!(canned.picker_calls, [None]); |
| 373 | |
| 374 | // show/list/picker aliases. |
| 375 | for alias in ["show", "list", "picker"] { |
| 376 | let mut canned = CannedLifecycle::default(); |
| 377 | let result = super::sessions::sessions_portable(&mut canned, Some(alias)); |
| 378 | assert_eq!(result.message, None, "{alias}"); |
| 379 | assert_eq!(canned.picker_calls, [None], "{alias}"); |
| 380 | } |
| 381 | |
| 382 | // open with preselection. |
| 383 | let mut canned = CannedLifecycle::default(); |
| 384 | let _result = super::sessions::sessions_portable(&mut canned, Some("open abc123")); |
| 385 | assert_eq!(canned.picker_calls, [Some("abc123".to_string())]); |
| 386 | |
| 387 | // open without id -> usage. |
| 388 | let result = super::sessions::sessions_portable(&mut canned, Some("open")); |
| 389 | assert_eq!( |
| 390 | result.message.as_deref(), |
| 391 | Some("Error: usage: /sessions open <session-id>") |
| 392 | ); |
| 393 | |
| 394 | // archive/unarchive messages. |
| 395 | let mut canned = CannedLifecycle { |
| 396 | archived: Ok(SessionArchiveReceipt { |
| 397 | truncated_id: "zzz".to_string(), |
| 398 | title: "My Session".to_string(), |
| 399 | }), |
| 400 | ..CannedLifecycle::default() |
| 401 | }; |
| 402 | let result = super::sessions::sessions_portable(&mut canned, Some("archive zzz")); |
| 403 | assert_eq!( |
| 404 | result.message.as_deref(), |
| 405 | Some("Archived session zzz (My Session)") |
| 406 | ); |
| 407 | assert_eq!(canned.archive_calls, [("zzz".to_string(), true)]); |
| 408 | let mut canned = CannedLifecycle { |
| 409 | archived: Ok(SessionArchiveReceipt { |
| 410 | truncated_id: "zzz".to_string(), |
| 411 | title: "My Session".to_string(), |
| 412 | }), |
| 413 | ..CannedLifecycle::default() |
| 414 | }; |
| 415 | let result = super::sessions::sessions_portable(&mut canned, Some("restore zzz")); |
| 416 | assert_eq!( |
| 417 | result.message.as_deref(), |
| 418 | Some("Restored session zzz (My Session)") |
| 419 | ); |
| 420 | assert_eq!(canned.archive_calls, [("zzz".to_string(), false)]); |
| 421 | |
| 422 | // prune parsing + messages. |
| 423 | let result = super::sessions::sessions_portable(&mut canned, Some("prune")); |
| 424 | assert!( |
| 425 | result |
| 426 | .message |
| 427 | .as_deref() |
| 428 | .unwrap_or_default() |
| 429 | .contains("usage: /sessions prune <days>") |
| 430 | ); |
| 431 | let result = super::sessions::sessions_portable(&mut canned, Some("prune abc")); |
| 432 | assert_eq!( |
| 433 | result.message.as_deref(), |
| 434 | Some("Error: expected a positive integer number of days, got `abc`") |
| 435 | ); |
| 436 | let mut canned = CannedLifecycle { |
| 437 | prune: Ok(0), |
| 438 | ..CannedLifecycle::default() |
| 439 | }; |
| 440 | let result = super::sessions::sessions_portable(&mut canned, Some("prune 30")); |
| 441 | assert_eq!( |
| 442 | result.message.as_deref(), |
| 443 | Some("no sessions older than 30d to prune") |
| 444 | ); |
| 445 | assert_eq!(canned.prune_days, [30]); |
| 446 | let mut canned = CannedLifecycle { |
| 447 | prune: Ok(2), |
| 448 | ..CannedLifecycle::default() |
| 449 | }; |
| 450 | let result = super::sessions::sessions_portable(&mut canned, Some("prune 30")); |
| 451 | assert_eq!( |
| 452 | result.message.as_deref(), |
| 453 | Some("pruned 2 sessions older than 30d") |
| 454 | ); |
| 455 | assert_eq!(canned.prune_days, [30]); |
| 456 | |
| 457 | // Unknown subcommand. |
| 458 | let result = super::sessions::sessions_portable(&mut canned, Some("teleport")); |
| 459 | assert!( |
| 460 | result |
| 461 | .message |
| 462 | .as_deref() |
| 463 | .unwrap_or_default() |
| 464 | .contains("unknown subcommand `teleport`") |
| 465 | ); |
| 466 | } |
| 467 | |
| 468 | // ---- /tree (Task 4.5) ---- |
| 469 | |
| 470 | #[test] |
| 471 | fn tree_composes_exact_baseline_messages() { |
| 472 | let mut canned = CannedLifecycle { |
| 473 | tree: Ok(TreeBodyProjection::Journal { |
| 474 | rendered: "journal body".to_string(), |
| 475 | }), |
| 476 | ..CannedLifecycle::default() |
| 477 | }; |
| 478 | let result = super::tree::tree_portable(&mut canned, None); |
| 479 | assert_eq!( |
| 480 | result.message.as_deref(), |
| 481 | Some( |
| 482 | "journal body\nUse `/branch <entry_id>` to branch (moves leaf only, never rewrites history).\nUse `/fork [session_id]` to fork this session at any node.\n" |
| 483 | ) |
| 484 | ); |
| 485 | |
| 486 | let mut canned = CannedLifecycle { |
| 487 | tree: Ok(TreeBodyProjection::Linear { |
| 488 | rendered: "linear body".to_string(), |
| 489 | }), |
| 490 | ..CannedLifecycle::default() |
| 491 | }; |
| 492 | let result = super::tree::tree_portable(&mut canned, None); |
| 493 | assert_eq!( |
| 494 | result.message.as_deref(), |
| 495 | Some("linear body\nUse `/branch <n>` with entry id after journal is saved.\n") |
| 496 | ); |
| 497 | |
| 498 | let mut canned = CannedLifecycle { |
| 499 | tree: Ok(TreeBodyProjection::EmptySession), |
| 500 | ..CannedLifecycle::default() |
| 501 | }; |
| 502 | let result = super::tree::tree_portable(&mut canned, None); |
| 503 | assert!( |
| 504 | result |
| 505 | .message |
| 506 | .as_deref() |
| 507 | .unwrap_or_default() |
| 508 | .contains("(empty session — no entries yet)") |
| 509 | ); |
| 510 | |
| 511 | let mut canned = CannedLifecycle { |
| 512 | tree: Ok(TreeBodyProjection::NoSession), |
| 513 | ..CannedLifecycle::default() |
| 514 | }; |
| 515 | let result = super::tree::tree_portable(&mut canned, None); |
| 516 | assert!( |
| 517 | result |
| 518 | .message |
| 519 | .as_deref() |
| 520 | .unwrap_or_default() |
| 521 | .contains("No active session") |
| 522 | ); |
| 523 | |
| 524 | let mut canned = CannedLifecycle { |
| 525 | tree: Err("could not open sessions directory: boom".to_string()), |
| 526 | ..CannedLifecycle::default() |
| 527 | }; |
| 528 | let result = super::tree::tree_portable(&mut canned, None); |
| 529 | assert_eq!( |
| 530 | result.message.as_deref(), |
| 531 | Some("Error: could not open sessions directory: boom") |
| 532 | ); |
| 533 | } |
| 534 |