//! 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>, fail_save: bool, } impl FakeMemories { fn failing() -> Self { Self { saved: Mutex::new(Vec::new()), fail_save: true, } } fn slugs(&self) -> Vec { 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, MemoryError> { Ok(self.saved.lock().unwrap().clone()) } async fn get(&self, _root: &ProjectPath, slug: &MemorySlug) -> Result { 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, MemoryError> { Ok(Vec::new()) } async fn resolve_links( &self, _root: &ProjectPath, _slug: &MemorySlug, ) -> Result, MemoryError> { Ok(Vec::new()) } } #[derive(Default, Clone)] struct SpyBus(Arc>>); impl SpyBus { fn saved_slugs(&self) -> Vec { 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, Arc::clone(&bus) as Arc, ); 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, Arc::clone(&bus) as Arc, ); 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, Arc::clone(&bus) as Arc, ); // 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, Arc::clone(&bus) as Arc, ); 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, Arc::clone(&bus) as Arc, ); 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()); }