Backend uniquement (UI React repoussée à LS7) : - domain : port ConversationArchive + structs SegmentStats/PageCursor/PageDirection/ TurnSlice/RotationDecision/RotationThresholds, fn pures rotation_plan/clamp_page_limit + consts ; re-exports lib. - infrastructure : impl ConversationArchive pour FsConversationLog (stats/rotate/page + helpers), archive segmentée hors chemin chaud. - application : ReadConversationPage + DTO + ConversationArchiveProvider (conversation/paginate), RotateConversationLog (conversation/rotate), exports mod/lib. - app-tauri : AppConversationArchiveProvider + wiring (state), rotation détachée dans launch_agent + commande read_conversation_page (commands), DTOs (dto), commande enregistrée (generate_handler!). - tests (QA, verts) : conversation_log, conversation_rotate_paginate (nouveau), dto (module test). Pivots INV-LS6 et cohérence fold-après-rotation verts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
344 lines
11 KiB
Rust
344 lines
11 KiB
Rust
//! 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<SegmentStats>,
|
|
turns: Mutex<Vec<ConversationTurn>>,
|
|
rotate_calls: Mutex<Vec<TurnId>>,
|
|
fail: Mutex<bool>,
|
|
}
|
|
impl FakeArchive {
|
|
fn build(stats: SegmentStats, turns: Vec<ConversationTurn>, 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<ConversationTurn>) -> 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<TurnId> {
|
|
self.rotate_calls.lock().unwrap().clone()
|
|
}
|
|
}
|
|
#[async_trait]
|
|
impl ConversationArchive for FakeArchive {
|
|
async fn stats(&self, _c: ConversationId) -> Result<SegmentStats, StoreError> {
|
|
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<TurnSlice, StoreError> {
|
|
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<dyn ConversationArchive>);
|
|
impl ConversationArchiveProvider for FakeArchiveProvider {
|
|
fn conversation_archive_for(
|
|
&self,
|
|
_root: &ProjectPath,
|
|
) -> Option<Arc<dyn ConversationArchive>> {
|
|
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<Arc<dyn ConversationArchive>> {
|
|
None
|
|
}
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct FakeHandoffStore(Mutex<Option<Handoff>>);
|
|
#[async_trait]
|
|
impl HandoffStore for FakeHandoffStore {
|
|
async fn load(&self, _c: ConversationId) -> Result<Option<Handoff>, 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<dyn HandoffStore>);
|
|
impl HandoffProvider for FakeHandoffProvider {
|
|
fn handoff_store_for(&self, _root: &ProjectPath) -> Option<Arc<dyn HandoffStore>> {
|
|
Some(Arc::clone(&self.0))
|
|
}
|
|
}
|
|
|
|
fn rotate_uc(
|
|
archive: Arc<dyn ConversationArchive>,
|
|
handoff: Option<Handoff>,
|
|
) -> RotateConversationLog {
|
|
let store = Arc::new(FakeHandoffStore(Mutex::new(handoff))) as Arc<dyn HandoffStore>;
|
|
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<dyn ConversationArchive>,
|
|
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<dyn ConversationArchive>, 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<dyn ConversationArchive>,
|
|
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<dyn HandoffStore>;
|
|
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<dyn ConversationArchive>, 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());
|
|
}
|