Files
IdeA/crates/application/tests/memory_harvest.rs
Blomios b12081be18 feat(memory): récolte automatique contrôlée de la mémoire (Lot E1)
Câble la récolte automatique de mémoire de bout en bout, sous contrôle
explicite, du domaine jusqu'à l'UI :

- domain : modèle `memory_harvest` (candidats de récolte, décision) +
  exports `lib.rs`.
- application : use case `memory/harvest` branché dans `memory/mod`,
  `lib.rs`, le cycle de vie d'agent (`agent/lifecycle`) et
  l'orchestrateur (`orchestrator/service`).
- app-tauri : wiring runtime dans `state`.
- frontend : DTO `domain/index` + hook `useMemory`.

Couverture : domain `memory_harvest` (14), application `memory_harvest`
(5) et `orchestrator_service` (récolte, 4), plus patch test-only du mock
gateway `workState` (`mock.test.ts`) et UI `memory.test`.

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

243 lines
7.9 KiB
Rust

//! Tests for the Lot E1 auto-memory harvest use case (`HarvestMemoryFromTurn`).
//!
//! Best-effort, port-faked: a `Response` turn carrying valid ` ```idea-memory `
//! blocks is persisted + announced; non-`Response` turns are no-ops; invalid blocks
//! are skipped individually; a store failure is reported, never propagated.
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use domain::conversation_log::TurnRole;
use domain::events::DomainEvent;
use domain::ports::{EventBus, EventStream, MemoryError, MemoryStore};
use domain::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug, ProjectPath};
use application::HarvestMemoryFromTurn;
// ---------------------------------------------------------------------------
// Fakes
// ---------------------------------------------------------------------------
/// In-memory store keyed by slug, with an optional forced `save` failure.
#[derive(Default)]
struct FakeMemories {
saved: Mutex<Vec<Memory>>,
fail_save: bool,
}
impl FakeMemories {
fn failing() -> Self {
Self {
saved: Mutex::new(Vec::new()),
fail_save: true,
}
}
fn slugs(&self) -> Vec<String> {
self.saved
.lock()
.unwrap()
.iter()
.map(|m| m.slug().as_str().to_owned())
.collect()
}
}
#[async_trait]
impl MemoryStore for FakeMemories {
async fn list(&self, _root: &ProjectPath) -> Result<Vec<Memory>, MemoryError> {
Ok(self.saved.lock().unwrap().clone())
}
async fn get(&self, _root: &ProjectPath, slug: &MemorySlug) -> Result<Memory, MemoryError> {
self.saved
.lock()
.unwrap()
.iter()
.find(|m| m.slug() == slug)
.cloned()
.ok_or(MemoryError::NotFound)
}
async fn save(&self, _root: &ProjectPath, memory: &Memory) -> Result<(), MemoryError> {
if self.fail_save {
return Err(MemoryError::Frontmatter("forced failure".to_owned()));
}
self.saved.lock().unwrap().push(memory.clone());
Ok(())
}
async fn delete(&self, _root: &ProjectPath, _slug: &MemorySlug) -> Result<(), MemoryError> {
Ok(())
}
async fn read_index(&self, _root: &ProjectPath) -> Result<Vec<MemoryIndexEntry>, MemoryError> {
Ok(Vec::new())
}
async fn resolve_links(
&self,
_root: &ProjectPath,
_slug: &MemorySlug,
) -> Result<Vec<MemoryLink>, MemoryError> {
Ok(Vec::new())
}
}
#[derive(Default, Clone)]
struct SpyBus(Arc<Mutex<Vec<DomainEvent>>>);
impl SpyBus {
fn saved_slugs(&self) -> Vec<String> {
self.0
.lock()
.unwrap()
.iter()
.filter_map(|e| match e {
DomainEvent::MemorySaved { slug } => Some(slug.as_str().to_owned()),
_ => None,
})
.collect()
}
}
impl EventBus for SpyBus {
fn publish(&self, event: DomainEvent) {
self.0.lock().unwrap().push(event);
}
fn subscribe(&self) -> EventStream {
Box::new(std::iter::empty())
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn root() -> ProjectPath {
ProjectPath::new("/home/me/demo").unwrap()
}
fn block(slug: &str, body: &str) -> String {
format!(
"```idea-memory\nslug: {slug}\ntitle: Title\ntype: project\ndescription: hook\n---\n{body}\n```"
)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[tokio::test]
async fn ignores_non_response_turns() {
let store = Arc::new(FakeMemories::default());
let bus = Arc::new(SpyBus::default());
let harvester = HarvestMemoryFromTurn::new(
Arc::clone(&store) as Arc<dyn MemoryStore>,
Arc::clone(&bus) as Arc<dyn EventBus>,
);
let text = block("note-a", "Body");
for role in [TurnRole::Prompt, TurnRole::ToolActivity] {
let outcome = harvester.harvest(&root(), role, &text).await;
assert_eq!(outcome.found, 0, "non-response turns are not parsed");
assert!(outcome.saved.is_empty());
}
assert!(store.slugs().is_empty(), "nothing persisted");
assert!(bus.saved_slugs().is_empty(), "nothing announced");
}
#[tokio::test]
async fn response_with_valid_block_saves_and_announces() {
let store = Arc::new(FakeMemories::default());
let bus = Arc::new(SpyBus::default());
let harvester = HarvestMemoryFromTurn::new(
Arc::clone(&store) as Arc<dyn MemoryStore>,
Arc::clone(&bus) as Arc<dyn EventBus>,
);
let text = format!(
"Sure, noting that.\n\n{}",
block("api-url", "Use the staging URL.")
);
let outcome = harvester.harvest(&root(), TurnRole::Response, &text).await;
assert_eq!(outcome.found, 1);
assert_eq!(outcome.saved.len(), 1);
assert_eq!(outcome.saved[0].as_str(), "api-url");
assert_eq!(outcome.skipped, 0);
assert!(outcome.errors.is_empty());
assert_eq!(store.slugs(), vec!["api-url"], "persisted via the store");
assert_eq!(bus.saved_slugs(), vec!["api-url"], "MemorySaved announced");
}
#[tokio::test]
async fn partial_invalid_blocks_are_skipped_individually() {
let store = Arc::new(FakeMemories::default());
let bus = Arc::new(SpyBus::default());
let harvester = HarvestMemoryFromTurn::new(
Arc::clone(&store) as Arc<dyn MemoryStore>,
Arc::clone(&bus) as Arc<dyn EventBus>,
);
// One valid, one with an invalid slug.
let text = format!(
"{}\n{}",
block("good-one", "Body"),
block("Bad Slug", "Body")
);
let outcome = harvester.harvest(&root(), TurnRole::Response, &text).await;
assert_eq!(outcome.found, 2);
assert_eq!(outcome.saved.len(), 1, "only the valid one persisted");
assert_eq!(outcome.saved[0].as_str(), "good-one");
assert_eq!(outcome.skipped, 1);
assert_eq!(outcome.errors.len(), 1);
assert_eq!(store.slugs(), vec!["good-one"]);
assert_eq!(bus.saved_slugs(), vec!["good-one"]);
}
#[tokio::test]
async fn store_failure_is_reported_not_propagated() {
let store = Arc::new(FakeMemories::failing());
let bus = Arc::new(SpyBus::default());
let harvester = HarvestMemoryFromTurn::new(
Arc::clone(&store) as Arc<dyn MemoryStore>,
Arc::clone(&bus) as Arc<dyn EventBus>,
);
let text = block("doomed", "Body");
// No panic, no Result — the call simply returns an outcome carrying the error.
let outcome = harvester.harvest(&root(), TurnRole::Response, &text).await;
assert_eq!(outcome.found, 1);
assert!(outcome.saved.is_empty(), "save failed ⇒ nothing saved");
assert_eq!(outcome.skipped, 1);
assert_eq!(outcome.errors.len(), 1);
assert!(
outcome.errors[0].contains("store failed"),
"{:?}",
outcome.errors
);
// A failed save must not announce a MemorySaved event.
assert!(bus.saved_slugs().is_empty());
}
#[tokio::test]
async fn parses_only_current_text_no_full_log() {
// The use case takes only the turn's `text`; it has no `ConversationLog`
// dependency, so it structurally cannot read the full thread. We assert it
// acts purely on the text passed in: a response with no directive saves
// nothing even though the store already holds (unrelated) notes.
let store = Arc::new(FakeMemories::default());
let bus = Arc::new(SpyBus::default());
let harvester = HarvestMemoryFromTurn::new(
Arc::clone(&store) as Arc<dyn MemoryStore>,
Arc::clone(&bus) as Arc<dyn EventBus>,
);
let outcome = harvester
.harvest(
&root(),
TurnRole::Response,
"A plain reply with no directive.",
)
.await;
assert_eq!(outcome.found, 0);
assert!(outcome.saved.is_empty());
assert!(store.slugs().is_empty());
}