//! LS6 tests — `RotateConversationLog` (rotation best-effort, plancher = `up_to`) et //! `ReadConversationPage` (DTO humain riche, texte complet, mapping source), avec des //! fakes in-memory des ports (aucune I/O réelle ; celle-ci est couverte côté infra). use std::sync::{Arc, Mutex}; use async_trait::async_trait; use domain::input::InputSource; use domain::ports::StoreError; use domain::project::ProjectPath; use domain::{ AgentId, ConversationArchive, ConversationId, ConversationTurn, Handoff, HandoffStore, PageCursor, PageDirection, SegmentStats, TurnId, TurnRole, TurnSlice, }; use application::{ ConversationArchiveProvider, HandoffProvider, ReadConversationPage, ReadConversationPageInput, RotateConversationLog, RotateConversationLogInput, TurnSource, }; // --------------------------------------------------------------------------- // Constructeurs déterministes // --------------------------------------------------------------------------- fn conv_id(n: u128) -> ConversationId { ConversationId::from_uuid(uuid::Uuid::from_u128(n)) } fn turn_id(n: u128) -> TurnId { TurnId::from_uuid(uuid::Uuid::from_u128(n)) } fn agent_id(n: u128) -> AgentId { AgentId::from_uuid(uuid::Uuid::from_u128(n)) } fn root() -> ProjectPath { ProjectPath::new("/home/me/proj").unwrap() } fn turn(id: TurnId, source: InputSource, role: TurnRole, text: &str) -> ConversationTurn { ConversationTurn::new(id, conv_id(1), 1_700_000_000_000, source, role, text) } // --------------------------------------------------------------------------- // Fakes des ports LS6 // --------------------------------------------------------------------------- /// [`ConversationArchive`] in-memory : `stats` configurables, `page` servie depuis un /// Vec, et **enregistrement** des appels `rotate` (pour prouver `keep_from = up_to`). /// Optionnellement, force une erreur store pour prouver la propagation typée. struct FakeArchive { stats: Mutex, turns: Mutex>, rotate_calls: Mutex>, fail: Mutex, } impl FakeArchive { fn build(stats: SegmentStats, turns: Vec, fail: bool) -> Self { Self { stats: Mutex::new(stats), turns: Mutex::new(turns), rotate_calls: Mutex::new(Vec::new()), fail: Mutex::new(fail), } } fn with_stats(turns: usize, bytes: u64) -> Self { Self::build( SegmentStats { active_turns: turns, active_bytes: bytes, }, Vec::new(), false, ) } fn with_page(turns: Vec) -> Self { Self::build( SegmentStats { active_turns: 0, active_bytes: 0, }, turns, false, ) } fn failing() -> Self { Self::build( SegmentStats { active_turns: 10_000, active_bytes: 0, }, Vec::new(), true, ) } fn rotate_calls(&self) -> Vec { self.rotate_calls.lock().unwrap().clone() } } #[async_trait] impl ConversationArchive for FakeArchive { async fn stats(&self, _c: ConversationId) -> Result { if *self.fail.lock().unwrap() { return Err(StoreError::Io("forced".into())); } Ok(*self.stats.lock().unwrap()) } async fn rotate(&self, _c: ConversationId, keep_from: TurnId) -> Result<(), StoreError> { if *self.fail.lock().unwrap() { return Err(StoreError::Io("forced".into())); } self.rotate_calls.lock().unwrap().push(keep_from); Ok(()) } async fn page( &self, _c: ConversationId, cursor: PageCursor, limit: usize, ) -> Result { if *self.fail.lock().unwrap() { return Err(StoreError::Io("forced".into())); } // Pagination minimale Forward-depuis-début suffisante pour les tests DTO. let all = self.turns.lock().unwrap().clone(); let _ = cursor; let end = limit.min(all.len()); Ok(TurnSlice { turns: all[..end].to_vec(), has_more: end < all.len(), }) } } struct FakeArchiveProvider(Arc); impl ConversationArchiveProvider for FakeArchiveProvider { fn conversation_archive_for( &self, _root: &ProjectPath, ) -> Option> { Some(Arc::clone(&self.0)) } } /// Provider qui ne fournit **aucune** archive (best-effort absent). struct NoArchiveProvider; impl ConversationArchiveProvider for NoArchiveProvider { fn conversation_archive_for( &self, _root: &ProjectPath, ) -> Option> { None } } #[derive(Default)] struct FakeHandoffStore(Mutex>); #[async_trait] impl HandoffStore for FakeHandoffStore { async fn load(&self, _c: ConversationId) -> Result, StoreError> { Ok(self.0.lock().unwrap().clone()) } async fn save(&self, _c: ConversationId, handoff: Handoff) -> Result<(), StoreError> { *self.0.lock().unwrap() = Some(handoff); Ok(()) } } struct FakeHandoffProvider(Arc); impl HandoffProvider for FakeHandoffProvider { fn handoff_store_for(&self, _root: &ProjectPath) -> Option> { Some(Arc::clone(&self.0)) } } fn rotate_uc( archive: Arc, handoff: Option, ) -> RotateConversationLog { let store = Arc::new(FakeHandoffStore(Mutex::new(handoff))) as Arc; RotateConversationLog::new( Arc::new(FakeArchiveProvider(archive)), Arc::new(FakeHandoffProvider(store)), ) } fn input() -> RotateConversationLogInput { RotateConversationLogInput { project_root: root(), conversation: conv_id(1), } } // --------------------------------------------------------------------------- // RotateConversationLog // --------------------------------------------------------------------------- /// Plancher lu du handoff + stats au-dessus du seuil ⇒ `rotate(keep_from = up_to)`. #[tokio::test] async fn rotate_uses_handoff_up_to_as_floor_and_calls_rotate() { // Stats au-dessus du seuil de tours (défaut 500). let archive = Arc::new(FakeArchive::with_stats(10_000, 0)); let handoff = Handoff::new("résumé", turn_id(42), Some("but".to_owned())); let uc = rotate_uc( archive.clone() as Arc, Some(handoff), ); uc.execute(input()).await.expect("rotate ok"); assert_eq!( archive.rotate_calls(), vec![turn_id(42)], "rotate appelé avec keep_from = up_to du handoff" ); } /// Pas de handoff ⇒ `floor = None` ⇒ skip : `rotate` jamais appelé même au-dessus du seuil. #[tokio::test] async fn rotate_skips_without_handoff() { let archive = Arc::new(FakeArchive::with_stats(10_000, u64::MAX)); let uc = rotate_uc(archive.clone() as Arc, None); uc.execute(input()).await.expect("skip ok"); assert!( archive.rotate_calls().is_empty(), "sans handoff ⇒ aucun rotate" ); } /// Handoff présent mais stats SOUS les deux seuils ⇒ skip (pas de rotation). #[tokio::test] async fn rotate_skips_under_thresholds() { let archive = Arc::new(FakeArchive::with_stats(10, 1_000)); let handoff = Handoff::new("r", turn_id(5), None); let uc = rotate_uc( archive.clone() as Arc, Some(handoff), ); uc.execute(input()).await.expect("skip ok"); assert!(archive.rotate_calls().is_empty(), "sous les seuils ⇒ skip"); } /// Provider d'archive absent ⇒ no-op silencieux (best-effort), jamais une erreur. #[tokio::test] async fn rotate_without_archive_provider_is_noop() { let store = Arc::new(FakeHandoffStore(Mutex::new(Some(Handoff::new( "r", turn_id(5), None, ))))) as Arc; let uc = RotateConversationLog::new( Arc::new(NoArchiveProvider), Arc::new(FakeHandoffProvider(store)), ); uc.execute(input()).await.expect("no provider ⇒ Ok(())"); } /// Une erreur store est **propagée typée** (`AppError::Store`) par le use case ; le /// **swallowing best-effort** est la responsabilité du câblage (cf. test app-tauri). #[tokio::test] async fn rotate_propagates_typed_store_error() { let archive = Arc::new(FakeArchive::failing()); let handoff = Handoff::new("r", turn_id(9), None); let uc = rotate_uc(archive as Arc, Some(handoff)); let err = uc.execute(input()).await.expect_err("store error propagée"); assert!( matches!(err, application::AppError::Store(_)), "erreur store typée, got {err:?}" ); } // --------------------------------------------------------------------------- // ReadConversationPage — DTO humain riche // --------------------------------------------------------------------------- /// La page mappe un DTO riche : texte **complet** (non borné), `text_len` correct, /// mapping source Human/Agent, `has_more` et `next_anchor` (= dernier id de la page). #[tokio::test] async fn read_page_maps_rich_dto_with_full_text_and_sources() { let big = "Z".repeat(30_000); let turns = vec![ turn(turn_id(1), InputSource::Human, TurnRole::Prompt, &big), turn( turn_id(2), InputSource::agent(agent_id(7)), TurnRole::Response, "réponse agent", ), turn(turn_id(3), InputSource::Human, TurnRole::Prompt, "suite"), ]; let archive = Arc::new(FakeArchive::with_page(turns)); let uc = ReadConversationPage::new(Arc::new(FakeArchiveProvider(archive))); // limit 2 ⇒ page [1,2], has_more (il reste le tour 3). let page = uc .execute(ReadConversationPageInput { project_root: root(), conversation: conv_id(1), cursor: PageCursor { anchor: None, direction: PageDirection::Forward, }, limit: 2, }) .await .expect("page ok"); assert_eq!(page.turns.len(), 2); // Texte complet préservé + text_len. assert_eq!(page.turns[0].text.chars().count(), 30_000); assert_eq!(page.turns[0].text_len, 30_000); // Mapping source : Human puis Agent{7}. assert_eq!(page.turns[0].source, TurnSource::Human); assert_eq!( page.turns[1].source, TurnSource::Agent { agent_id: agent_id(7) } ); // has_more + next_anchor = dernier id de la page (tour 2). assert!(page.has_more, "il reste le tour 3"); assert_eq!(page.next_anchor, Some(turn_id(2))); } /// Provider d'archive absent ⇒ page **vide** (best-effort), `next_anchor = None`. #[tokio::test] async fn read_page_without_provider_is_empty() { let uc = ReadConversationPage::new(Arc::new(NoArchiveProvider)); let page = uc .execute(ReadConversationPageInput { project_root: root(), conversation: conv_id(1), cursor: PageCursor { anchor: None, direction: PageDirection::Forward, }, limit: 50, }) .await .expect("page ok"); assert!(page.turns.is_empty() && !page.has_more && page.next_anchor.is_none()); }