Les sondes de détection de profils étaient exécutées séquentiellement, portant le coût total au pire à N×800 ms (N × timeout par candidat). Chaque candidat est désormais sondé dans une tâche Tokio dédiée (tokio::spawn), les JoinHandle étant attendus dans l'ordre de création : le coût total tombe à ~1×timeout tout en préservant un ordre de sortie déterministe. Aucune dépendance ajoutée, aucun changement de contrat. Test : crates/application/tests/profile_usecases.rs (concurrence multi-thread + ordre déterministe). profile_usecases 20/20, suite application verte. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
703 lines
23 KiB
Rust
703 lines
23 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::atomic::{AtomicUsize, Ordering};
|
|
use std::sync::{Arc, Mutex};
|
|
use std::time::{Duration, Instant};
|
|
|
|
use async_trait::async_trait;
|
|
|
|
use domain::ids::ProfileId;
|
|
use domain::ports::{
|
|
AgentRuntime, IdGenerator, PreparedContext, ProfileStore, RuntimeError, SessionPlan, SpawnSpec,
|
|
StoreError,
|
|
};
|
|
use domain::profile::{AgentProfile, ContextInjection, OpenCodeConfig, StructuredAdapter};
|
|
use domain::project::ProjectPath;
|
|
|
|
use application::{
|
|
reference_profile_id, reference_profiles, CloneOpenCodeProfileFromSeed,
|
|
CloneOpenCodeProfileFromSeedInput, 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>,
|
|
}
|
|
|
|
struct YieldingRuntime;
|
|
|
|
struct SlowRuntime {
|
|
delay: Duration,
|
|
started: Arc<AtomicUsize>,
|
|
active: Arc<AtomicUsize>,
|
|
max_active: Arc<AtomicUsize>,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl AgentRuntime for StubRuntime {
|
|
async 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")
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl AgentRuntime for YieldingRuntime {
|
|
async fn detect(&self, _profile: &AgentProfile) -> Result<bool, RuntimeError> {
|
|
tokio::task::yield_now().await;
|
|
Ok(true)
|
|
}
|
|
|
|
fn prepare_invocation(
|
|
&self,
|
|
_profile: &AgentProfile,
|
|
_ctx: &PreparedContext,
|
|
_cwd: &ProjectPath,
|
|
_session: &SessionPlan,
|
|
) -> Result<SpawnSpec, RuntimeError> {
|
|
unreachable!("not used in these tests")
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl AgentRuntime for SlowRuntime {
|
|
async fn detect(&self, _profile: &AgentProfile) -> Result<bool, RuntimeError> {
|
|
self.started.fetch_add(1, Ordering::SeqCst);
|
|
let active = self.active.fetch_add(1, Ordering::SeqCst) + 1;
|
|
self.max_active.fetch_max(active, Ordering::SeqCst);
|
|
tokio::time::sleep(self.delay).await;
|
|
self.active.fetch_sub(1, Ordering::SeqCst);
|
|
Ok(false)
|
|
}
|
|
|
|
fn prepare_invocation(
|
|
&self,
|
|
_profile: &AgentProfile,
|
|
_ctx: &PreparedContext,
|
|
_cwd: &ProjectPath,
|
|
_session: &SessionPlan,
|
|
) -> Result<SpawnSpec, RuntimeError> {
|
|
unreachable!("not used in these tests")
|
|
}
|
|
}
|
|
|
|
struct SeqIds(Mutex<Vec<uuid::Uuid>>);
|
|
|
|
impl SeqIds {
|
|
fn new(ids: Vec<uuid::Uuid>) -> Self {
|
|
Self(Mutex::new(ids))
|
|
}
|
|
}
|
|
|
|
impl IdGenerator for SeqIds {
|
|
fn new_uuid(&self) -> uuid::Uuid {
|
|
self.0.lock().unwrap().remove(0)
|
|
}
|
|
}
|
|
|
|
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"
|
|
);
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn detect_profiles_runs_candidate_probes_concurrently_and_keeps_order() {
|
|
let started = Arc::new(AtomicUsize::new(0));
|
|
let active = Arc::new(AtomicUsize::new(0));
|
|
let max_active = Arc::new(AtomicUsize::new(0));
|
|
let delay = Duration::from_millis(100);
|
|
let runtime: Arc<dyn AgentRuntime> = Arc::new(SlowRuntime {
|
|
delay,
|
|
started: Arc::clone(&started),
|
|
active,
|
|
max_active: Arc::clone(&max_active),
|
|
});
|
|
let candidates = vec![
|
|
profile(1, "One", "one"),
|
|
profile(2, "Two", "two"),
|
|
profile(3, "Three", "three"),
|
|
profile(4, "Four", "four"),
|
|
profile(5, "Five", "five"),
|
|
];
|
|
let detect = DetectProfiles::new(runtime);
|
|
|
|
let started_at = Instant::now();
|
|
let out = detect
|
|
.execute(DetectProfilesInput { candidates })
|
|
.await
|
|
.unwrap();
|
|
let elapsed = started_at.elapsed();
|
|
|
|
assert_eq!(started.load(Ordering::SeqCst), 5, "all candidates probed");
|
|
assert_eq!(
|
|
max_active.load(Ordering::SeqCst),
|
|
5,
|
|
"all probes should overlap instead of running sequentially"
|
|
);
|
|
assert!(
|
|
elapsed < Duration::from_millis(300),
|
|
"parallel probes should take roughly one probe duration, got {elapsed:?}"
|
|
);
|
|
assert_eq!(
|
|
out.results
|
|
.iter()
|
|
.map(|entry| entry.profile.command.as_str())
|
|
.collect::<Vec<_>>(),
|
|
vec!["one", "two", "three", "four", "five"],
|
|
"result order stays aligned with candidate order"
|
|
);
|
|
assert!(out.results.iter().all(|entry| !entry.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 + OpenCode — 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", "opencode"],
|
|
"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:?}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn clone_opencode_profile_from_seed_creates_distinct_open_code_instance() {
|
|
let store = FakeProfileStore::default();
|
|
let clone = CloneOpenCodeProfileFromSeed::new(
|
|
Arc::new(store.clone()),
|
|
Arc::new(SeqIds::new(vec![uuid::Uuid::from_u128(3601)])),
|
|
);
|
|
|
|
let out = clone
|
|
.execute(CloneOpenCodeProfileFromSeedInput {
|
|
name: Some("OpenCode llama.cpp 2".to_owned()),
|
|
opencode: Some(
|
|
OpenCodeConfig::new(
|
|
"http://localhost:8081/v1",
|
|
None,
|
|
"qwen3-coder-70b",
|
|
Some(false),
|
|
Some(true),
|
|
)
|
|
.unwrap(),
|
|
),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let seed_id = reference_profile_id("opencode-llamacpp");
|
|
assert_ne!(out.profile.id, seed_id);
|
|
assert_eq!(
|
|
out.profile.id,
|
|
ProfileId::from_uuid(uuid::Uuid::from_u128(3601))
|
|
);
|
|
assert_eq!(out.profile.name, "OpenCode llama.cpp 2");
|
|
assert_eq!(
|
|
out.profile.structured_adapter,
|
|
Some(StructuredAdapter::OpenCode)
|
|
);
|
|
assert_eq!(
|
|
out.profile
|
|
.opencode
|
|
.as_ref()
|
|
.map(|config| config.base_url.as_str()),
|
|
Some("http://localhost:8081/v1")
|
|
);
|
|
|
|
let profiles = store.0.lock().unwrap().profiles.clone();
|
|
assert_eq!(profiles.len(), 1);
|
|
assert_eq!(profiles[0].id, out.profile.id);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn clone_opencode_profile_prefers_persisted_seed_without_recreating_it() {
|
|
let store = FakeProfileStore::default();
|
|
let save = SaveProfile::new(Arc::new(store.clone()));
|
|
let mut seed = reference_profiles()
|
|
.into_iter()
|
|
.find(|profile| profile.id == reference_profile_id("opencode-llamacpp"))
|
|
.expect("seed exists");
|
|
seed.name = "Edited local OpenCode seed".to_owned();
|
|
save.execute(SaveProfileInput {
|
|
profile: seed.clone(),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let clone = CloneOpenCodeProfileFromSeed::new(
|
|
Arc::new(store.clone()),
|
|
Arc::new(SeqIds::new(vec![uuid::Uuid::from_u128(3602)])),
|
|
);
|
|
let out = clone
|
|
.execute(CloneOpenCodeProfileFromSeedInput {
|
|
name: None,
|
|
opencode: None,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(out.profile.name, "Edited local OpenCode seed copy");
|
|
assert_eq!(out.profile.opencode, seed.opencode);
|
|
let profiles = store.0.lock().unwrap().profiles.clone();
|
|
assert_eq!(
|
|
profiles
|
|
.iter()
|
|
.filter(|profile| profile.id == reference_profile_id("opencode-llamacpp"))
|
|
.count(),
|
|
1,
|
|
"the canonical seed must be preserved, not recreated as a duplicate"
|
|
);
|
|
assert_eq!(
|
|
profiles
|
|
.iter()
|
|
.filter(|profile| profile.structured_adapter == Some(StructuredAdapter::OpenCode))
|
|
.count(),
|
|
2,
|
|
"ProfileId is the identity: multiple OpenCode profiles can coexist"
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 + OpenCode). 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", "opencode"]);
|
|
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<String> = reference_profiles()
|
|
.iter()
|
|
.map(|p| p.command.clone())
|
|
.collect();
|
|
assert_eq!(
|
|
commands,
|
|
vec!["claude", "codex", "opencode", "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["opencode"].is_selectable(),
|
|
"OpenCode 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["opencode"].context_injection,
|
|
ContextInjection::ConventionFile {
|
|
target: "AGENTS.md".to_owned()
|
|
}
|
|
);
|
|
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"
|
|
);
|
|
assert_eq!(
|
|
by_command["opencode"].structured_adapter,
|
|
Some(StructuredAdapter::OpenCode),
|
|
"OpenCode reference profile must declare the OpenCode 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);
|
|
}
|