Files
IdeA/crates/application/tests/embedder_usecases.rs
Blomios 785e9935fd feat(memory): config embedders (LOT C2) + suggestion contextuelle (LOT C3) + contexte projet partagé
- LOT C2 (§14.5.3) : use cases de configuration des embedders déclaratifs
  (List/Save/Delete + DescribeEmbedderEngines : modèles ONNX recommandés,
  environnement local détecté, stratégies compilées). UI EmbedderSettings.
- LOT C3 (§14.5.5) : suggestion contextuelle best-effort à l'activation quand la
  mémoire dépasse le budget de recall sans embedder configuré (event
  EmbedderSuggested, anti-spam 1×/session, « ne plus demander »).
- Contexte projet partagé .ideai/CONTEXT.md (model-agnostic) injecté à tous les
  agents/profils au lancement, avant la persona. UI ProjectContextPanel.

Tests : backend workspace vert (0 échec) ; frontend 306/306.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 09:24:51 +02:00

257 lines
8.6 KiB
Rust

//! L5 tests for the embedder-configuration use cases (LOT C2).
//!
//! Ports are faked in-memory so the use cases run without any I/O:
//! - [`InMemoryEmbedderProfileStore`] — an [`EmbedderProfileStore`] backed by a
//! `Vec` with the same upsert/delete-NotFound semantics as the real
//! `FsEmbedderProfileStore`,
//! - [`FixedEnvInspector`] — an [`EmbedderEnvInspector`] returning a constant
//! [`EmbedderEnvReport`] (best-effort port, never errors).
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use domain::ports::{EmbedderEnvInspector, EmbedderEnvReport, EmbedderProfileStore, StoreError};
use domain::profile::{EmbedderProfile, EmbedderStrategy};
use application::{
DeleteEmbedderProfile, DeleteEmbedderProfileInput, DescribeEmbedderEngines,
ListEmbedderProfiles, OnnxModelView, SaveEmbedderProfile, SaveEmbedderProfileInput,
};
// ---------------------------------------------------------------------------
// Fakes
// ---------------------------------------------------------------------------
/// In-memory [`EmbedderProfileStore`] mirroring the real store's semantics:
/// upsert-by-id (no duplicate), delete-absent ⇒ [`StoreError::NotFound`].
#[derive(Default, Clone)]
struct InMemoryEmbedderProfileStore(Arc<Mutex<Vec<EmbedderProfile>>>);
#[async_trait]
impl EmbedderProfileStore for InMemoryEmbedderProfileStore {
async fn list(&self) -> Result<Vec<EmbedderProfile>, StoreError> {
Ok(self.0.lock().unwrap().clone())
}
async fn save(&self, profile: &EmbedderProfile) -> Result<(), StoreError> {
let mut v = self.0.lock().unwrap();
if let Some(slot) = v.iter_mut().find(|p| p.id == profile.id) {
*slot = profile.clone();
} else {
v.push(profile.clone());
}
Ok(())
}
async fn delete(&self, id: &str) -> Result<(), StoreError> {
let mut v = self.0.lock().unwrap();
let before = v.len();
v.retain(|p| p.id != id);
if v.len() == before {
return Err(StoreError::NotFound);
}
Ok(())
}
}
/// An [`EmbedderEnvInspector`] returning a fixed report (best-effort, infallible).
struct FixedEnvInspector(EmbedderEnvReport);
#[async_trait]
impl EmbedderEnvInspector for FixedEnvInspector {
async fn inspect(&self) -> EmbedderEnvReport {
self.0.clone()
}
}
fn save_input(id: &str, name: &str, dimension: usize) -> SaveEmbedderProfileInput {
SaveEmbedderProfileInput {
id: id.to_owned(),
name: name.to_owned(),
strategy: EmbedderStrategy::LocalOnnx,
model: Some("multilingual-e5-small".to_owned()),
endpoint: None,
api_key_env: None,
dimension,
}
}
// ---------------------------------------------------------------------------
// ListEmbedderProfiles / SaveEmbedderProfile
// ---------------------------------------------------------------------------
#[tokio::test]
async fn list_is_empty_then_contains_saved_profile() {
let store = InMemoryEmbedderProfileStore::default();
let list = ListEmbedderProfiles::new(Arc::new(store.clone()));
let save = SaveEmbedderProfile::new(Arc::new(store.clone()));
assert!(
list.execute().await.unwrap().profiles.is_empty(),
"no profile configured ⇒ empty list (default `none` posture)"
);
let saved = save
.execute(save_input("local-onnx", "Local ONNX", 384))
.await
.unwrap();
assert_eq!(saved.profile.id, "local-onnx");
assert_eq!(saved.profile.dimension, 384);
let listed = list.execute().await.unwrap().profiles;
assert_eq!(listed.len(), 1);
assert_eq!(listed[0], saved.profile);
}
#[tokio::test]
async fn save_upserts_by_id_without_duplicating() {
let store = InMemoryEmbedderProfileStore::default();
let save = SaveEmbedderProfile::new(Arc::new(store.clone()));
let list = ListEmbedderProfiles::new(Arc::new(store.clone()));
save.execute(save_input("e", "before", 384)).await.unwrap();
let updated = save.execute(save_input("e", "after", 768)).await.unwrap();
let listed = list.execute().await.unwrap().profiles;
assert_eq!(listed.len(), 1, "same id replaces, no duplicate");
assert_eq!(listed[0], updated.profile);
assert_eq!(listed[0].name, "after");
assert_eq!(listed[0].dimension, 768);
}
#[tokio::test]
async fn save_invalid_dimension_is_invalid_and_writes_nothing() {
let store = InMemoryEmbedderProfileStore::default();
let save = SaveEmbedderProfile::new(Arc::new(store.clone()));
let list = ListEmbedderProfiles::new(Arc::new(store.clone()));
let err = save
.execute(save_input("bad", "Bad", 0))
.await
.expect_err("dimension 0 must be rejected");
assert_eq!(err.code(), "INVALID", "got {err:?}");
assert!(
list.execute().await.unwrap().profiles.is_empty(),
"a rejected profile must not be persisted"
);
}
#[tokio::test]
async fn save_empty_name_is_invalid_and_writes_nothing() {
let store = InMemoryEmbedderProfileStore::default();
let save = SaveEmbedderProfile::new(Arc::new(store.clone()));
let list = ListEmbedderProfiles::new(Arc::new(store.clone()));
let err = save
.execute(save_input("id", "", 384))
.await
.expect_err("empty name must be rejected");
assert_eq!(err.code(), "INVALID", "got {err:?}");
assert!(
list.execute().await.unwrap().profiles.is_empty(),
"a rejected profile must not be persisted"
);
}
// ---------------------------------------------------------------------------
// DeleteEmbedderProfile
// ---------------------------------------------------------------------------
#[tokio::test]
async fn delete_removes_existing_profile() {
let store = InMemoryEmbedderProfileStore::default();
let save = SaveEmbedderProfile::new(Arc::new(store.clone()));
let delete = DeleteEmbedderProfile::new(Arc::new(store.clone()));
let list = ListEmbedderProfiles::new(Arc::new(store.clone()));
save.execute(save_input("a", "A", 384)).await.unwrap();
save.execute(save_input("b", "B", 384)).await.unwrap();
delete
.execute(DeleteEmbedderProfileInput { id: "a".to_owned() })
.await
.unwrap();
let listed = list.execute().await.unwrap().profiles;
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].id, "b");
}
#[tokio::test]
async fn delete_unknown_id_is_not_found() {
let store = InMemoryEmbedderProfileStore::default();
let delete = DeleteEmbedderProfile::new(Arc::new(store));
let err = delete
.execute(DeleteEmbedderProfileInput {
id: "ghost".to_owned(),
})
.await
.expect_err("deleting an unknown id errors");
assert_eq!(
err.code(),
"NOT_FOUND",
"StoreError::NotFound ⇒ AppError::NotFound, got {err:?}"
);
}
// ---------------------------------------------------------------------------
// DescribeEmbedderEngines
// ---------------------------------------------------------------------------
fn onnx_view() -> OnnxModelView {
OnnxModelView {
id: "multilingual-e5-small".to_owned(),
display_name: "Multilingual E5 Small".to_owned(),
dimension: 384,
approx_size_mb: 118,
recommended: true,
}
}
#[tokio::test]
async fn describe_engines_merges_catalogue_report_and_flags() {
let report = EmbedderEnvReport {
ollama_detected: true,
onnx_cached_models: vec!["multilingual-e5-small".to_owned()],
};
let inspector = Arc::new(FixedEnvInspector(report));
let describe = DescribeEmbedderEngines::new(
inspector,
vec![onnx_view()],
/* vector_http_enabled */ true,
/* vector_onnx_enabled */ false,
);
let view = describe.execute().await.unwrap();
// Catalogue injected verbatim.
assert_eq!(view.recommended_onnx, vec![onnx_view()]);
// Report fields surfaced.
assert!(view.ollama_detected);
assert_eq!(view.onnx_cached_models, vec!["multilingual-e5-small"]);
// Compiled-capability flags surfaced as injected.
assert!(view.vector_http_enabled);
assert!(!view.vector_onnx_enabled);
}
#[tokio::test]
async fn describe_engines_with_nothing_detected() {
let inspector = Arc::new(FixedEnvInspector(EmbedderEnvReport {
ollama_detected: false,
onnx_cached_models: vec![],
}));
let describe = DescribeEmbedderEngines::new(inspector, vec![], false, true);
let view = describe.execute().await.unwrap();
assert!(view.recommended_onnx.is_empty());
assert!(!view.ollama_detected);
assert!(view.onnx_cached_models.is_empty());
assert!(!view.vector_http_enabled);
assert!(view.vector_onnx_enabled);
}