@@ -344,3 +344,157 @@ fn envelope_rejects_duplicate_new_slots_deterministically() {
344344 } ) ) ;
345345 assert ! ( result. is_err( ) , "duplicate media slot must assert" ) ;
346346}
347+
348+ // Project facet (FEAT-021 D1/D4)
349+ // ---------------------------------------------------------------------------
350+
351+ /// Deterministic fake project facet over portable values only.
352+ struct FakeProject {
353+ lsp_enabled : bool ,
354+ share : ProjectShareProjection ,
355+ goal : ProjectGoalState ,
356+ }
357+
358+ impl FakeProject {
359+ fn new ( ) -> Self {
360+ Self {
361+ lsp_enabled : false ,
362+ share : ProjectShareProjection {
363+ history_is_empty : true ,
364+ history_len : 0 ,
365+ model : "deepseek-chat" . to_string ( ) ,
366+ mode_label : "ACT" . to_string ( ) ,
367+ } ,
368+ goal : ProjectGoalState {
369+ objective : Some ( "Ship FEAT-021" . to_string ( ) ) ,
370+ status : ProjectGoalStatus :: Active ,
371+ pause_reason : None ,
372+ started_at_elapsed_seconds : Some ( 42 ) ,
373+ time_used_seconds : 42 ,
374+ token_budget : Some ( 50_000 ) ,
375+ tokens_used : 1_000 ,
376+ session_total_tokens : 2_000 ,
377+ continuation_count : 3 ,
378+ pending_controls : false ,
379+ last_known_objective : None ,
380+ last_known_status : None ,
381+ conversation_present : true ,
382+ is_loading : false ,
383+ goal_continuation_waiting : false ,
384+ } ,
385+ }
386+ }
387+ }
388+
389+ impl CommandProjectContext for FakeProject {
390+ fn lsp_enabled ( & self ) -> bool {
391+ self . lsp_enabled
392+ }
393+
394+ fn lsp_set ( & mut self , enabled : bool ) -> Result < ( ) , String > {
395+ self . lsp_enabled = enabled;
396+ Ok ( ( ) )
397+ }
398+
399+ fn share_projection ( & self ) -> ProjectShareProjection {
400+ self . share . clone ( )
401+ }
402+
403+ fn goal_state ( & self ) -> ProjectGoalState {
404+ self . goal . clone ( )
405+ }
406+ }
407+
408+ #[ test]
409+ fn project_facet_is_object_safe_and_typed ( ) {
410+ fn project ( _: & dyn CommandProjectContext ) { }
411+ project ( & FakeProject :: new ( ) ) ;
412+
413+ let mut project = FakeProject :: new ( ) ;
414+ assert ! ( !project. lsp_enabled( ) ) ;
415+ project. lsp_set ( true ) . unwrap ( ) ;
416+ assert ! ( project. lsp_enabled( ) ) ;
417+ project. lsp_set ( false ) . unwrap ( ) ;
418+ assert ! ( !project. lsp_enabled( ) ) ;
419+ }
420+
421+ #[ test]
422+ fn project_share_projection_preserves_semantic_values ( ) {
423+ let project = FakeProject :: new ( ) ;
424+ let share = project. share_projection ( ) ;
425+ assert ! ( share. history_is_empty) ;
426+ assert_eq ! ( share. history_len, 0 ) ;
427+ assert_eq ! ( share. model, "deepseek-chat" ) ;
428+ assert_eq ! ( share. mode_label, "ACT" ) ;
429+ }
430+
431+ #[ test]
432+ fn project_goal_state_preserves_semantic_values ( ) {
433+ let project = FakeProject :: new ( ) ;
434+ let goal = project. goal_state ( ) ;
435+ assert_eq ! ( goal. objective. as_deref( ) , Some ( "Ship FEAT-021" ) ) ;
436+ assert_eq ! ( goal. status, ProjectGoalStatus :: Active ) ;
437+ assert_eq ! ( goal. pause_reason, None ) ;
438+ assert_eq ! ( goal. started_at_elapsed_seconds, Some ( 42 ) ) ;
439+ assert_eq ! ( goal. time_used_seconds, 42 ) ;
440+ assert_eq ! ( goal. token_budget, Some ( 50_000 ) ) ;
441+ assert_eq ! ( goal. tokens_used, 1_000 ) ;
442+ assert_eq ! ( goal. session_total_tokens, 2_000 ) ;
443+ assert_eq ! ( goal. continuation_count, 3 ) ;
444+ assert ! ( !goal. pending_controls) ;
445+ assert_eq ! ( goal. last_known_objective, None ) ;
446+ assert_eq ! ( goal. last_known_status, None ) ;
447+ assert ! ( goal. conversation_present) ;
448+ assert ! ( !goal. is_loading) ;
449+ assert ! ( !goal. goal_continuation_waiting) ;
450+ }
451+
452+ #[ test]
453+ fn project_goal_status_variants_are_distinguishable ( ) {
454+ let paused = ProjectGoalState {
455+ status : ProjectGoalStatus :: Paused ,
456+ pause_reason : Some ( "user" . to_string ( ) ) ,
457+ ..FakeProject :: new ( ) . goal
458+ } ;
459+ assert_eq ! ( paused. status, ProjectGoalStatus :: Paused ) ;
460+ assert_eq ! ( paused. pause_reason. as_deref( ) , Some ( "user" ) ) ;
461+
462+ let complete = ProjectGoalState {
463+ status : ProjectGoalStatus :: Complete ,
464+ ..paused
465+ } ;
466+ assert_eq ! ( complete. status, ProjectGoalStatus :: Complete ) ;
467+ assert_ne ! ( complete. status, ProjectGoalStatus :: Blocked ) ;
468+ }
469+
470+ #[ test]
471+ fn project_facet_transports_through_envelope_when_declared ( ) {
472+ let mut project = FakeProject :: new ( ) ;
473+ let parts = CommandContexts :: empty ( )
474+ . with_project ( & mut project)
475+ . into_parts ( ) ;
476+ assert ! ( parts. project. is_some( ) ) ;
477+ assert ! ( parts. session. is_none( ) ) ;
478+
479+ // PROJECT combined with WORKSPACE (init) and PRESENTATION (goal).
480+ let mut workspace = Workspace ;
481+ let parts = CommandContexts :: empty ( )
482+ . with_project ( & mut project)
483+ . with_workspace ( & mut workspace)
484+ . into_parts ( ) ;
485+ assert ! ( parts. project. is_some( ) ) ;
486+ assert ! ( parts. workspace. is_some( ) ) ;
487+ assert ! ( parts. presentation. is_none( ) ) ;
488+ }
489+
490+ #[ test]
491+ fn envelope_rejects_duplicate_project_slot_deterministically ( ) {
492+ let mut a = FakeProject :: new ( ) ;
493+ let mut b = FakeProject :: new ( ) ;
494+ let result = std:: panic:: catch_unwind ( std:: panic:: AssertUnwindSafe ( || {
495+ CommandContexts :: empty ( )
496+ . with_project ( & mut a)
497+ . with_project ( & mut b) ;
498+ } ) ) ;
499+ assert ! ( result. is_err( ) , "duplicate project slot must assert" ) ;
500+ }
0 commit comments