//! LOT D1 (ARCHITECTURE §17.5 / §17.9 ligne D1) — tests unitaires des registres //! structurés et de l'agrégateur de liveness, **100 % fakes**. //! //! Couvre : //! - [`StructuredSessions`] : `insert` + résolution `session_for_agent` / //! `session_id_for_agent` / `node_for_agent` / `session` (`None` si inconnu) ; //! invariant « 1 session vivante par agent » ; `rebind_agent_node` (change la //! cellule, pas la session) ; `remove` (retire + renvoie la session) ; //! `live_agents` (triplets `(AgentId, NodeId, SessionId)`) ; impl //! [`LiveAgentRegistry`] (`is_agent_live` / `is_node_live`). //! - [`LiveSessions`] (agrégateur) : OR des deux registres pour `is_agent_live` / //! `is_node_live` ; `live_agents` agrège (PTY puis structuré) ; //! `node_for_agent` / `session_id_for_agent` trouvent dans l'un ou l'autre. //! //! Style calqué sur `terminal_usecases.rs` (constructeurs validants du domaine, //! pas de littéraux fragiles). Le fake `AgentSession` est local et minimal : //! ces tests n'exercent QUE le registre, pas `send`. use std::sync::Arc; use async_trait::async_trait; use application::{LiveAgentRegistry, LiveSessions, StructuredSessions, TerminalSessions}; use domain::ports::{AgentSession, AgentSessionError, PtyHandle, ReplyStream}; use domain::{AgentId, NodeId, ProjectPath, PtySize, SessionId, SessionKind, TerminalSession}; use uuid::Uuid; // --- petits constructeurs déterministes ------------------------------------ fn sid(n: u128) -> SessionId { SessionId::from_uuid(Uuid::from_u128(n)) } fn aid(n: u128) -> AgentId { AgentId::from_uuid(Uuid::from_u128(n)) } fn nid(n: u128) -> NodeId { NodeId::from_uuid(Uuid::from_u128(n)) } /// Fake minimal d'`AgentSession` : porte juste l'id (ce que le registre clé). /// `send`/`shutdown` ne sont pas exercés ici. struct FakeSession { id: SessionId, } #[async_trait] impl AgentSession for FakeSession { fn id(&self) -> SessionId { self.id } fn conversation_id(&self) -> Option { None } async fn send(&self, _prompt: &str) -> Result { let stream: ReplyStream = Box::new(std::iter::empty()); Ok(stream) } async fn shutdown(&self) -> Result<(), AgentSessionError> { Ok(()) } } fn fake(id: SessionId) -> Arc { Arc::new(FakeSession { id }) } // =========================================================================== // StructuredSessions // =========================================================================== #[test] fn structured_insert_resolve_and_remove() { let reg = StructuredSessions::new(); assert!(reg.is_empty()); assert_eq!(reg.len(), 0); let s = sid(1); let a = aid(10); let n = nid(100); reg.insert(fake(s), a, n); assert_eq!(reg.len(), 1); assert!(!reg.is_empty()); // Résolution par id de session. assert!(reg.session(&s).is_some()); assert_eq!(reg.session(&s).unwrap().id(), s); // Résolution par agent. assert_eq!(reg.session_id_for_agent(&a), Some(s)); assert_eq!(reg.node_for_agent(&a), Some(n)); assert!(reg.session_for_agent(&a).is_some()); assert_eq!(reg.session_for_agent(&a).unwrap().id(), s); // Inconnu ⇒ None partout. assert!(reg.session(&sid(999)).is_none()); assert!(reg.session_for_agent(&aid(999)).is_none()); assert!(reg.session_id_for_agent(&aid(999)).is_none()); assert!(reg.node_for_agent(&aid(999)).is_none()); // remove retire ET renvoie la session. let removed = reg.remove(&s).expect("remove returns the session"); assert_eq!(removed.id(), s); assert!(reg.is_empty()); assert!(reg.remove(&s).is_none(), "second remove is a no-op"); assert!(reg.session_for_agent(&a).is_none()); } #[test] fn structured_live_agents_lists_triples() { let reg = StructuredSessions::new(); reg.insert(fake(sid(1)), aid(10), nid(100)); reg.insert(fake(sid(2)), aid(20), nid(200)); let mut live = reg.live_agents(); live.sort_by_key(|(a, _, _)| a.as_uuid()); assert_eq!( live, vec![(aid(10), nid(100), sid(1)), (aid(20), nid(200), sid(2)),] ); } #[test] fn structured_one_live_session_per_agent_invariant() { // L'agent n'a pas de session vivante avant insertion. let reg = StructuredSessions::new(); let a = aid(10); assert!(!reg.is_agent_live(&a)); assert!(reg.session_for_agent(&a).is_none()); reg.insert(fake(sid(1)), a, nid(100)); assert!(reg.is_agent_live(&a)); // `session_for_agent` est non ambigu : il rend LA session de l'agent. let resolved = reg.session_for_agent(&a).unwrap().id(); assert_eq!(resolved, sid(1)); // Et un seul triplet figure pour cet agent dans live_agents. let count = reg.live_agents().iter().filter(|(x, _, _)| *x == a).count(); assert_eq!(count, 1, "one live session per agent"); } #[test] fn structured_rebind_changes_cell_not_session() { let reg = StructuredSessions::new(); let a = aid(10); reg.insert(fake(sid(1)), a, nid(100)); let new_cell = nid(200); let rebound = reg .rebind_agent_node(&a, new_cell) .expect("rebind returns the session"); // Session inchangée (même id), cellule mise à jour. assert_eq!(rebound.id(), sid(1)); assert_eq!(reg.node_for_agent(&a), Some(new_cell)); assert_eq!(reg.session_id_for_agent(&a), Some(sid(1))); // Toujours une seule entrée (pas de duplication). assert_eq!(reg.len(), 1); // Rebind d'un agent inconnu ⇒ None. assert!(reg.rebind_agent_node(&aid(999), nid(300)).is_none()); } #[test] fn structured_live_agent_registry_impl() { let reg = StructuredSessions::new(); let a = aid(10); let n = nid(100); assert!(!reg.is_agent_live(&a)); assert!(!reg.is_node_live(&n)); reg.insert(fake(sid(1)), a, n); assert!(reg.is_agent_live(&a)); assert!(reg.is_node_live(&n)); assert!(!reg.is_agent_live(&aid(999))); assert!(!reg.is_node_live(&nid(999))); // is_node_live suit le rebind (la cellule vivante change). let _ = reg.rebind_agent_node(&a, nid(200)); assert!(!reg.is_node_live(&n), "old cell no longer live"); assert!(reg.is_node_live(&nid(200)), "new cell is live"); } #[test] fn structured_sessions_snapshot_for_global_shutdown() { let reg = StructuredSessions::new(); reg.insert(fake(sid(1)), aid(10), nid(100)); reg.insert(fake(sid(2)), aid(20), nid(200)); let mut ids: Vec = reg.sessions().iter().map(|s| s.id()).collect(); ids.sort_by_key(|s| s.as_uuid()); assert_eq!(ids, vec![sid(1), sid(2)]); } // =========================================================================== // LiveSessions — agrégateur (PTY OR structuré) // =========================================================================== /// Insère un agent PTY dans `TerminalSessions`. fn insert_pty(pty: &TerminalSessions, s: SessionId, a: AgentId, n: NodeId) { let session = TerminalSession::starting( s, n, ProjectPath::new("/p").unwrap(), SessionKind::Agent { agent_id: a }, PtySize::new(24, 80).unwrap(), ); pty.insert(PtyHandle { session_id: s }, session); } #[test] fn aggregator_agent_live_via_structured_only() { let pty = Arc::new(TerminalSessions::new()); let structured = Arc::new(StructuredSessions::new()); let agg = LiveSessions::new(Arc::clone(&pty), Arc::clone(&structured)); let a = aid(10); structured.insert(fake(sid(1)), a, nid(100)); assert!(agg.is_agent_live(&a), "live in structured ⇒ true"); assert!(agg.is_node_live(&nid(100))); assert_eq!(agg.session_id_for_agent(&a), Some(sid(1))); assert_eq!(agg.node_for_agent(&a), Some(nid(100))); } #[test] fn aggregator_agent_live_via_pty_only() { let pty = Arc::new(TerminalSessions::new()); let structured = Arc::new(StructuredSessions::new()); let agg = LiveSessions::new(Arc::clone(&pty), Arc::clone(&structured)); let a = aid(20); insert_pty(&pty, sid(2), a, nid(200)); assert!(agg.is_agent_live(&a), "live in PTY ⇒ true"); assert!(agg.is_node_live(&nid(200))); assert_eq!(agg.session_id_for_agent(&a), Some(sid(2))); assert_eq!(agg.node_for_agent(&a), Some(nid(200))); } #[test] fn aggregator_agent_absent_from_both_is_not_live() { let pty = Arc::new(TerminalSessions::new()); let structured = Arc::new(StructuredSessions::new()); let agg = LiveSessions::new(pty, structured); let a = aid(30); assert!(!agg.is_agent_live(&a), "absent from both ⇒ false"); assert!(!agg.is_node_live(&nid(300))); assert!(agg.session_id_for_agent(&a).is_none()); assert!(agg.node_for_agent(&a).is_none()); assert!(agg.live_agents().is_empty()); } #[test] fn aggregator_live_agents_concatenates_pty_then_structured() { let pty = Arc::new(TerminalSessions::new()); let structured = Arc::new(StructuredSessions::new()); let agg = LiveSessions::new(Arc::clone(&pty), Arc::clone(&structured)); insert_pty(&pty, sid(1), aid(10), nid(100)); structured.insert(fake(sid(2)), aid(20), nid(200)); let all = agg.live_agents(); assert_eq!(all.len(), 2, "both registries contribute"); // PTY d'abord, structuré ensuite (ordre documenté de l'agrégateur). assert_eq!(all[0], (aid(10), nid(100), sid(1))); assert_eq!(all[1], (aid(20), nid(200), sid(2))); } #[test] fn aggregator_resolution_prefers_pty_then_falls_back_to_structured() { // Deux agents distincts, un par registre : la résolution trouve chacun // dans le bon registre (OR / fallback). let pty = Arc::new(TerminalSessions::new()); let structured = Arc::new(StructuredSessions::new()); let agg = LiveSessions::new(Arc::clone(&pty), Arc::clone(&structured)); let pty_agent = aid(10); let struct_agent = aid(20); insert_pty(&pty, sid(1), pty_agent, nid(100)); structured.insert(fake(sid(2)), struct_agent, nid(200)); // Agent PTY : résolu par le registre PTY. assert_eq!(agg.session_id_for_agent(&pty_agent), Some(sid(1))); assert_eq!(agg.node_for_agent(&pty_agent), Some(nid(100))); // Agent structuré : fallback sur le registre structuré. assert_eq!(agg.session_id_for_agent(&struct_agent), Some(sid(2))); assert_eq!(agg.node_for_agent(&struct_agent), Some(nid(200))); // is_node_live : OR sur les deux. assert!(agg.is_node_live(&nid(100))); assert!(agg.is_node_live(&nid(200))); assert!(!agg.is_node_live(&nid(999))); }