//! L5 tests for the profile/first-run use cases and the reference catalogue. //! //! Ports are faked in-memory so the use cases run without any I/O: //! - [`FakeProfileStore`] — an in-memory [`ProfileStore`] tracking a `configured` //! flag (mirrors `profiles.json` existence), //! - [`StubRuntime`] — an [`AgentRuntime`] whose `detect` is driven by a map from //! command → result (including an error case to prove graceful degradation). use std::collections::HashMap; use std::sync::{Arc, Mutex}; use async_trait::async_trait; use domain::ids::ProfileId; use domain::ports::{ AgentRuntime, PreparedContext, ProfileStore, RuntimeError, SessionPlan, SpawnSpec, StoreError, }; use domain::profile::{AgentProfile, ContextInjection}; use domain::project::ProjectPath; use application::{ reference_profile_id, reference_profiles, ConfigureProfiles, ConfigureProfilesInput, DeleteProfile, DeleteProfileInput, DetectProfiles, DetectProfilesInput, FirstRunState, ListProfiles, ReferenceProfiles, SaveProfile, SaveProfileInput, CODEX_SUBMIT_DELAY_MS, }; // --------------------------------------------------------------------------- // Fakes // --------------------------------------------------------------------------- #[derive(Default)] struct FakeStoreInner { profiles: Vec, configured: bool, } #[derive(Default, Clone)] struct FakeProfileStore(Arc>); #[async_trait] impl ProfileStore for FakeProfileStore { async fn list(&self) -> Result, StoreError> { Ok(self.0.lock().unwrap().profiles.clone()) } async fn save(&self, profile: &AgentProfile) -> Result<(), StoreError> { let mut inner = self.0.lock().unwrap(); inner.configured = true; if let Some(slot) = inner.profiles.iter_mut().find(|p| p.id == profile.id) { *slot = profile.clone(); } else { inner.profiles.push(profile.clone()); } Ok(()) } async fn delete(&self, id: ProfileId) -> Result<(), StoreError> { let mut inner = self.0.lock().unwrap(); let before = inner.profiles.len(); inner.profiles.retain(|p| p.id != id); if inner.profiles.len() == before { return Err(StoreError::NotFound); } Ok(()) } async fn is_configured(&self) -> Result { Ok(self.0.lock().unwrap().configured) } async fn mark_configured(&self) -> Result<(), StoreError> { self.0.lock().unwrap().configured = true; Ok(()) } } /// Detection outcomes keyed by command. Missing keys ⇒ `false`. #[derive(Clone)] enum DetectResult { Available, Missing, Error, } struct StubRuntime { by_command: HashMap, } struct YieldingRuntime; #[async_trait] impl AgentRuntime for StubRuntime { async fn detect(&self, profile: &AgentProfile) -> Result { match self.by_command.get(&profile.command) { Some(DetectResult::Available) => Ok(true), Some(DetectResult::Missing) | None => Ok(false), Some(DetectResult::Error) => Err(RuntimeError::Detection("boom".to_owned())), } } fn prepare_invocation( &self, _profile: &AgentProfile, _ctx: &PreparedContext, _cwd: &ProjectPath, _session: &SessionPlan, ) -> Result { unreachable!("not used in these tests") } } #[async_trait] impl AgentRuntime for YieldingRuntime { async fn detect(&self, _profile: &AgentProfile) -> Result { tokio::task::yield_now().await; Ok(true) } fn prepare_invocation( &self, _profile: &AgentProfile, _ctx: &PreparedContext, _cwd: &ProjectPath, _session: &SessionPlan, ) -> Result { unreachable!("not used in these tests") } } fn profile(id: u128, name: &str, command: &str) -> AgentProfile { AgentProfile::new( ProfileId::from_uuid(uuid::Uuid::from_u128(id)), name, command, Vec::new(), ContextInjection::stdin(), Some(format!("{command} --version")), "{projectRoot}", None, ) .unwrap() } // --------------------------------------------------------------------------- // DetectProfiles // --------------------------------------------------------------------------- #[tokio::test] async fn detect_maps_candidates_to_availability_in_order() { let mut map = HashMap::new(); map.insert("claude".to_owned(), DetectResult::Available); map.insert("codex".to_owned(), DetectResult::Missing); let runtime: Arc = Arc::new(StubRuntime { by_command: map }); let detect = DetectProfiles::new(runtime); let out = detect .execute(DetectProfilesInput { candidates: vec![profile(1, "Claude", "claude"), profile(2, "Codex", "codex")], }) .await .unwrap(); assert_eq!(out.results.len(), 2); assert_eq!(out.results[0].profile.command, "claude"); assert!(out.results[0].available); assert_eq!(out.results[1].profile.command, "codex"); assert!(!out.results[1].available); } #[tokio::test] async fn detect_error_degrades_to_unavailable_not_hard_failure() { let mut map = HashMap::new(); map.insert("aider".to_owned(), DetectResult::Error); let runtime: Arc = Arc::new(StubRuntime { by_command: map }); let detect = DetectProfiles::new(runtime); let out = detect .execute(DetectProfilesInput { candidates: vec![profile(1, "Aider", "aider")], }) .await .expect("detection error must not fail the use case"); assert!( !out.results[0].available, "errored detection ⇒ available:false" ); } #[tokio::test(flavor = "multi_thread")] async fn detect_execute_awaits_async_runtime_without_nested_runtime_panic() { let detect = DetectProfiles::new(Arc::new(YieldingRuntime)); let out = detect .execute(DetectProfilesInput { candidates: vec![profile(1, "Claude", "claude")], }) .await .expect("async detection must complete inside a multi-thread Tokio runtime"); assert_eq!(out.results.len(), 1); assert!(out.results[0].available); } // --------------------------------------------------------------------------- // ConfigureProfiles // --------------------------------------------------------------------------- #[tokio::test] async fn configure_persists_chosen_profiles_and_closes_first_run() { let store = FakeProfileStore::default(); let configure = ConfigureProfiles::new(Arc::new(store.clone())); let out = configure .execute(ConfigureProfilesInput { profiles: vec![profile(1, "Claude", "claude"), profile(2, "Codex", "codex")], }) .await .unwrap(); assert_eq!(out.profiles.len(), 2); assert!(store.is_configured().await.unwrap()); assert_eq!(store.list().await.unwrap().len(), 2); } #[tokio::test] async fn configure_empty_list_still_marks_configured() { let store = FakeProfileStore::default(); let configure = ConfigureProfiles::new(Arc::new(store.clone())); configure .execute(ConfigureProfilesInput { profiles: vec![] }) .await .unwrap(); assert!( store.is_configured().await.unwrap(), "empty configure closes the first run" ); assert!(store.list().await.unwrap().is_empty()); } // --------------------------------------------------------------------------- // FirstRunState // --------------------------------------------------------------------------- #[tokio::test] async fn first_run_true_when_not_configured_with_reference_catalogue() { let store = FakeProfileStore::default(); let uc = FirstRunState::new(Arc::new(store)); let out = uc.execute().await.unwrap(); assert!(out.is_first_run); // §17.3/D7: the wizard is seeded only with the *selectable* (structured- // drivable) profiles — Claude + Codex + OpenAI-compatible — not the full // catalogue. assert_eq!( out.reference_profiles.len(), 3, "selectable catalogue seeded" ); let commands: Vec<&str> = out .reference_profiles .iter() .map(|p| p.command.as_str()) .collect(); assert_eq!( commands, vec!["claude", "codex", "openai-compatible"], "only structured profiles offered" ); // Every seeded profile is selectable (the gate the menu relies on). assert!( out.reference_profiles .iter() .all(AgentProfile::is_selectable), "seeded profiles must all be selectable" ); } #[tokio::test] async fn first_run_false_after_configuration() { let store = FakeProfileStore::default(); store.mark_configured().await.unwrap(); let uc = FirstRunState::new(Arc::new(store)); let out = uc.execute().await.unwrap(); assert!(!out.is_first_run); } // --------------------------------------------------------------------------- // ListProfiles / SaveProfile / DeleteProfile // --------------------------------------------------------------------------- #[tokio::test] async fn save_then_list_then_delete() { let store = FakeProfileStore::default(); let save = SaveProfile::new(Arc::new(store.clone())); let list = ListProfiles::new(Arc::new(store.clone())); let delete = DeleteProfile::new(Arc::new(store.clone())); let p = profile(1, "Claude", "claude"); let saved = save .execute(SaveProfileInput { profile: p.clone() }) .await .unwrap(); assert_eq!(saved.profile, p); assert_eq!(list.execute().await.unwrap().profiles, vec![p.clone()]); delete .execute(DeleteProfileInput { id: p.id }) .await .unwrap(); assert!(list.execute().await.unwrap().profiles.is_empty()); } #[tokio::test] async fn delete_unknown_is_not_found_error() { let store = FakeProfileStore::default(); let delete = DeleteProfile::new(Arc::new(store)); let err = delete .execute(DeleteProfileInput { id: ProfileId::from_uuid(uuid::Uuid::from_u128(123)), }) .await .expect_err("deleting unknown id errors"); assert_eq!(err.code(), "NOT_FOUND", "got {err:?}"); } // --------------------------------------------------------------------------- // ReferenceProfiles / catalogue // --------------------------------------------------------------------------- #[tokio::test] async fn reference_profiles_use_case_returns_only_selectable() { // §17.3/D7: the selection use case exposes only structured-drivable profiles // (Claude + Codex + OpenAI-compatible). Gemini/Aider stay in the raw catalogue (see // `catalogue_*` tests below) but are not offered to selection/creation. let out = ReferenceProfiles::new().execute().await.unwrap(); assert_eq!(out.profiles.len(), 3); let commands: Vec<&str> = out.profiles.iter().map(|p| p.command.as_str()).collect(); assert_eq!(commands, vec!["claude", "codex", "openai-compatible"]); assert!(out.profiles.iter().all(AgentProfile::is_selectable)); } /// §17.3/D7 — non-regression: the *raw* catalogue still carries all reference profiles /// (data intact). Only the selection-facing use case is filtered. #[test] fn raw_catalogue_still_has_all_profiles() { let commands: Vec = reference_profiles() .iter() .map(|p| p.command.clone()) .collect(); assert_eq!( commands, vec!["claude", "codex", "openai-compatible", "gemini", "aider"] ); } #[test] fn claude_and_codex_reference_profiles_roundtrip_with_byte_identity() { let profiles = reference_profiles(); let by_command: HashMap<&str, &AgentProfile> = profiles.iter().map(|p| (p.command.as_str(), p)).collect(); for command in ["claude", "codex"] { let before = serde_json::to_vec(by_command[command]).expect("serialize reference profile"); let back: AgentProfile = serde_json::from_slice(&before).expect("deserialize reference profile"); let after = serde_json::to_vec(&back).expect("serialize round-tripped profile"); assert_eq!( after, before, "{command} profile JSON bytes must be strictly stable across a serde round-trip" ); assert!( !String::from_utf8_lossy(&after).contains("\"chatHttp\""), "{command} is a historical non-HTTP profile and must not grow chatHttp" ); } } /// §17.3/D7 — `is_selectable` is the single selection predicate: true for the /// structured-drivable profiles, false for the two PTY-only ones. This assertion /// would flip (and fail) the moment Gemini/Aider gained an adapter or Claude/Codex /// lost theirs — i.e. it actually constrains behaviour. #[test] fn is_selectable_is_true_only_for_structured_profiles() { let profiles = reference_profiles(); let by_command: HashMap<&str, &AgentProfile> = profiles.iter().map(|p| (p.command.as_str(), p)).collect(); assert!( by_command["claude"].is_selectable(), "Claude carries a structured adapter ⇒ selectable" ); assert!( by_command["codex"].is_selectable(), "Codex carries a structured adapter ⇒ selectable" ); assert!( by_command["openai-compatible"].is_selectable(), "OpenAI-compatible carries a structured adapter ⇒ selectable" ); assert!( !by_command["gemini"].is_selectable(), "Gemini has no adapter ⇒ not selectable" ); assert!( !by_command["aider"].is_selectable(), "Aider has no adapter ⇒ not selectable" ); } #[test] fn catalogue_has_expected_commands_and_injection() { let profiles = reference_profiles(); let by_command: HashMap<&str, &AgentProfile> = profiles.iter().map(|p| (p.command.as_str(), p)).collect(); let claude = by_command["claude"]; assert_eq!( claude.context_injection, ContextInjection::ConventionFile { target: "CLAUDE.md".to_owned() } ); assert_eq!( by_command["codex"].context_injection, ContextInjection::ConventionFile { target: "AGENTS.md".to_owned() } ); assert_eq!( by_command["codex"].submit_delay_ms, Some(CODEX_SUBMIT_DELAY_MS), "Codex TUI needs a conservative text→submit delay for delegated prompts" ); assert_eq!( by_command["gemini"].context_injection, ContextInjection::ConventionFile { target: "GEMINI.md".to_owned() } ); assert_eq!( by_command["aider"].context_injection, ContextInjection::Flag { flag: "--message-file {path}".to_owned() } ); } #[test] fn catalogue_ids_are_stable_across_calls() { let first = reference_profiles(); let second = reference_profiles(); let ids_a: Vec<_> = first.iter().map(|p| p.id).collect(); let ids_b: Vec<_> = second.iter().map(|p| p.id).collect(); assert_eq!(ids_a, ids_b, "reference ids are deterministic"); // And match the slug-derived id helper. assert_eq!(first[0].id, reference_profile_id("claude")); } // --------------------------------------------------------------------------- // LOT D0 (§17.3) — structured_adapter sur les profils de référence // --------------------------------------------------------------------------- #[test] fn catalogue_claude_and_codex_carry_their_structured_adapter() { use domain::profile::StructuredAdapter; let profiles = reference_profiles(); let by_command: HashMap<&str, &AgentProfile> = profiles.iter().map(|p| (p.command.as_str(), p)).collect(); // Claude / Codex sont pilotés en mode structuré (cellule chat + AgentSession). assert_eq!( by_command["claude"].structured_adapter, Some(StructuredAdapter::Claude), "Claude reference profile must declare the Claude structured adapter" ); assert_eq!( by_command["codex"].structured_adapter, Some(StructuredAdapter::Codex), "Codex reference profile must declare the Codex structured adapter" ); } #[test] fn catalogue_gemini_and_aider_stay_pty_without_adapter() { // §17.3 : les profils non encore couverts restent TUI/PTY (pas d'adapter). let profiles = reference_profiles(); let by_command: HashMap<&str, &AgentProfile> = profiles.iter().map(|p| (p.command.as_str(), p)).collect(); assert_eq!(by_command["gemini"].structured_adapter, None); assert_eq!(by_command["aider"].structured_adapter, None); }