返回 CodeWhale
lifecycle_portable_tests.rs
根目录 / crates / tui / src / commands / groups / session / lifecycle_portable_tests.rs
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
534 lines RUST