Files
IdeA/crates/application/tests/profile_usecases.rs
Blomios 287681c198 feat(orchestrator): modèle de désignation d'orchestrateur + sink de diagnostic
Introduit le modèle AgentManifest { version, entries, orchestrator } et la
garde d'écriture directe may_write_directly(..., &OrchestratorDesignation) :
seul l'orchestrateur désigné peut écrire directement, les autres passent par
le rendez-vous médié. Câble la désignation à travers domain → application →
infrastructure → app-tauri (context_guard, service, lifecycle, ports).

Ajoute crates/application/src/diag.rs : sink de diagnostic best-effort, sans
dépendance, qui miroite les traces du rendez-vous inter-agents de
l'orchestrateur vers un fichier de log persistant (utile au lancement via
AppImage où stderr est jeté), avec la même discipline « zéro dépendance,
ne casse jamais le rendez-vous ».

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 08:56:39 +02:00

435 lines
14 KiB
Rust

//! 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<AgentProfile>,
configured: bool,
}
#[derive(Default, Clone)]
struct FakeProfileStore(Arc<Mutex<FakeStoreInner>>);
#[async_trait]
impl ProfileStore for FakeProfileStore {
async fn list(&self) -> Result<Vec<AgentProfile>, 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<bool, StoreError> {
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<String, DetectResult>,
}
#[async_trait]
impl AgentRuntime for StubRuntime {
fn detect(&self, profile: &AgentProfile) -> Result<bool, RuntimeError> {
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<SpawnSpec, RuntimeError> {
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<dyn AgentRuntime> = 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<dyn AgentRuntime> = 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"
);
}
// ---------------------------------------------------------------------------
// 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 — not the full 4-profile catalogue.
assert_eq!(
out.reference_profiles.len(),
2,
"selectable catalogue seeded"
);
let commands: Vec<&str> = out
.reference_profiles
.iter()
.map(|p| p.command.as_str())
.collect();
assert_eq!(
commands,
vec!["claude", "codex"],
"only Claude/Codex 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). 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(), 2);
let commands: Vec<&str> = out.profiles.iter().map(|p| p.command.as_str()).collect();
assert_eq!(commands, vec!["claude", "codex"]);
assert!(out.profiles.iter().all(AgentProfile::is_selectable));
}
/// §17.3/D7 — non-regression: the *raw* catalogue still carries the four profiles
/// (data intact). Only the selection-facing use case is filtered.
#[test]
fn raw_catalogue_still_has_all_four_profiles() {
let commands: Vec<String> = reference_profiles()
.iter()
.map(|p| p.command.clone())
.collect();
assert_eq!(commands, vec!["claude", "codex", "gemini", "aider"]);
}
/// §17.3/D7 — `is_selectable` is the single selection predicate: true for the two
/// 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_claude_and_codex() {
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["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);
}