feat: add main features
Agents for developpement added + frontend add + backend added. Git viewer created + agent and template creator + layout and project creator
This commit is contained in:
320
crates/application/tests/profile_usecases.rs
Normal file
320
crates/application/tests/profile_usecases.rs
Normal file
@ -0,0 +1,320 @@
|
||||
//! 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, 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,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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,
|
||||
) -> 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}",
|
||||
)
|
||||
.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);
|
||||
assert_eq!(out.reference_profiles.len(), 4, "catalogue seeded");
|
||||
}
|
||||
|
||||
#[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_four() {
|
||||
let out = ReferenceProfiles::new().execute().await.unwrap();
|
||||
assert_eq!(out.profiles.len(), 4);
|
||||
}
|
||||
|
||||
#[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["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"));
|
||||
}
|
||||
Reference in New Issue
Block a user