//! LOT D0 (fondation §17.1) — tests purs du domaine : //! - `StructuredAdapter` : round-trip serde camelCase (`"claude"` / `"codex"`) ; //! - `AgentProfile.structured_adapter` : zéro régression serde (omission via //! `skip_serializing_if`, défaut `None` à la désérialisation), round-trip avec //! adapter, et builder `with_structured_adapter` qui ne touche rien d'autre ; //! - types du port `AgentSession` : `ReplyEvent` (3 variantes + égalité), //! `AgentSessionError` (variantes + `Display`/`Error`), et un **fake in-module** //! prouvant que `AgentSession`/`AgentSessionFactory` sont implémentables (D0 = pas //! d'impl réelle, seulement la conformité de signature). //! //! Style calqué sur `serde_roundtrip.rs` / `agent_profile_a0.rs` (réutilise les //! constructeurs validants du domaine, pas de littéraux fragiles). use std::sync::Arc; use async_trait::async_trait; use domain::ids::{ProfileId, SessionId}; use domain::ports::{ AgentSession, AgentSessionError, AgentSessionFactory, PreparedContext, ReplyEvent, ReplyStream, SessionPlan, }; use domain::profile::{AgentProfile, ContextInjection, StructuredAdapter}; use domain::project::ProjectPath; use domain::MarkdownDoc; use uuid::Uuid; fn profid(n: u128) -> ProfileId { ProfileId::from_uuid(Uuid::from_u128(n)) } fn roundtrip(value: &T) -> T where T: serde::Serialize + serde::de::DeserializeOwned + PartialEq + std::fmt::Debug, { let json = serde_json::to_string(value).expect("serialize"); serde_json::from_str(&json).expect("deserialize") } // --------------------------------------------------------------------------- // StructuredAdapter — round-trip serde camelCase ("claude" / "codex") // --------------------------------------------------------------------------- #[test] fn structured_adapter_serialises_camel_case() { assert_eq!( serde_json::to_string(&StructuredAdapter::Claude).unwrap(), "\"claude\"" ); assert_eq!( serde_json::to_string(&StructuredAdapter::Codex).unwrap(), "\"codex\"" ); } #[test] fn structured_adapter_deserialises_from_camel_case() { let claude: StructuredAdapter = serde_json::from_str("\"claude\"").unwrap(); let codex: StructuredAdapter = serde_json::from_str("\"codex\"").unwrap(); assert_eq!(claude, StructuredAdapter::Claude); assert_eq!(codex, StructuredAdapter::Codex); } #[test] fn structured_adapter_roundtrips_both_variants() { for adapter in [StructuredAdapter::Claude, StructuredAdapter::Codex] { assert_eq!(roundtrip(&adapter), adapter); } } #[test] fn structured_adapter_rejects_unknown_variant() { // Un JSON hors vocabulaire ne doit pas se faufiler (pas de défaut silencieux). let parsed: Result = serde_json::from_str("\"gemini\""); assert!(parsed.is_err(), "unknown adapter must fail to deserialise"); } // --------------------------------------------------------------------------- // AgentProfile.structured_adapter — zéro régression serde // --------------------------------------------------------------------------- /// Profil minimal TUI/PTY (sans adapter), construit via le constructeur validant. fn pty_profile() -> AgentProfile { AgentProfile::new( profid(1), "Gemini CLI", "gemini", vec![], ContextInjection::convention_file("GEMINI.md").unwrap(), Some("gemini --version".to_owned()), "{agentRunDir}", None, ) .unwrap() } #[test] fn profile_without_adapter_omits_the_field() { // skip_serializing_if = Option::is_none ⇒ la clé `structuredAdapter` est ABSENTE. let p = pty_profile(); assert_eq!(p.structured_adapter, None); let json = serde_json::to_string(&p).unwrap(); assert!( !json.contains("structuredAdapter"), "field must be omitted when None; json was {json}" ); // Et le round-trip reste fidèle. assert_eq!(roundtrip(&p), p); } #[test] fn legacy_profile_without_adapter_deserialises_to_none() { // Un `profiles.json` produit AVANT que le champ existe : pas de clé // `structuredAdapter` ⇒ défaut `None` (zéro régression de désérialisation). let json = r#"{ "id": "00000000-0000-0000-0000-000000000001", "name": "Gemini CLI", "command": "gemini", "args": [], "contextInjection": { "strategy": "conventionFile", "target": "GEMINI.md" }, "detect": null, "cwdTemplate": "{agentRunDir}" }"#; let p: AgentProfile = serde_json::from_str(json).expect("legacy profile must deserialise"); assert_eq!(p.structured_adapter, None); } #[test] fn profile_with_adapter_roundtrips_and_uses_camel_case() { let p = pty_profile().with_structured_adapter(StructuredAdapter::Claude); let json = serde_json::to_string(&p).unwrap(); // Clé camelCase + valeur camelCase de la variante. assert!( json.contains("\"structuredAdapter\":\"claude\""), "json was {json}" ); assert!(!json.contains("structured_adapter"), "json was {json}"); assert_eq!(roundtrip(&p), p); // Codex aussi. let c = pty_profile().with_structured_adapter(StructuredAdapter::Codex); let json = serde_json::to_string(&c).unwrap(); assert!( json.contains("\"structuredAdapter\":\"codex\""), "json was {json}" ); assert_eq!(roundtrip(&c), c); } #[test] fn with_structured_adapter_sets_only_that_field() { let before = pty_profile(); let after = before.clone().with_structured_adapter(StructuredAdapter::Codex); // Le seul champ muté : assert_eq!(after.structured_adapter, Some(StructuredAdapter::Codex)); assert_ne!(after.structured_adapter, before.structured_adapter); // Tous les autres champs strictement inchangés : assert_eq!(after.id, before.id); assert_eq!(after.name, before.name); assert_eq!(after.command, before.command); assert_eq!(after.args, before.args); assert_eq!(after.context_injection, before.context_injection); assert_eq!(after.detect, before.detect); assert_eq!(after.cwd_template, before.cwd_template); assert_eq!(after.session, before.session); // Preuve d'équivalence : ne diffère du `before` que par l'adapter posé. let mut patched = before; patched.structured_adapter = Some(StructuredAdapter::Codex); assert_eq!(after, patched); } #[test] fn with_structured_adapter_is_last_write_wins() { // Re-poser un adapter écrase le précédent (idempotent par valeur). let p = pty_profile() .with_structured_adapter(StructuredAdapter::Claude) .with_structured_adapter(StructuredAdapter::Codex); assert_eq!(p.structured_adapter, Some(StructuredAdapter::Codex)); } #[test] fn new_defaults_structured_adapter_to_none() { // Le constructeur `new` reste stable : il ne pose jamais d'adapter. assert_eq!(pty_profile().structured_adapter, None); } // --------------------------------------------------------------------------- // ReplyEvent — 3 variantes construites, égalité, clone // --------------------------------------------------------------------------- #[test] fn reply_event_three_variants_construct_and_carry_payload() { let delta = ReplyEvent::TextDelta { text: "hel".to_owned(), }; let tool = ReplyEvent::ToolActivity { label: "reads a file".to_owned(), }; let final_ = ReplyEvent::Final { content: "hello world".to_owned(), }; match &delta { ReplyEvent::TextDelta { text } => assert_eq!(text, "hel"), other => panic!("expected TextDelta, got {other:?}"), } match &tool { ReplyEvent::ToolActivity { label } => assert_eq!(label, "reads a file"), other => panic!("expected ToolActivity, got {other:?}"), } match &final_ { ReplyEvent::Final { content } => assert_eq!(content, "hello world"), other => panic!("expected Final, got {other:?}"), } } #[test] fn reply_event_equality_and_clone() { let e = ReplyEvent::Final { content: "done".to_owned(), }; assert_eq!(e.clone(), e); // Même variante, payload différent ⇒ inégal. assert_ne!( e, ReplyEvent::Final { content: "other".to_owned() } ); // Variantes différentes ⇒ inégal. assert_ne!( ReplyEvent::TextDelta { text: "x".into() }, ReplyEvent::ToolActivity { label: "x".into() } ); } // --------------------------------------------------------------------------- // AgentSessionError — variantes + Display / std::error::Error // --------------------------------------------------------------------------- #[test] fn agent_session_error_display_messages() { assert_eq!( AgentSessionError::Start("cli missing".to_owned()).to_string(), "agent session start failed: cli missing" ); assert_eq!( AgentSessionError::Io("broken pipe".to_owned()).to_string(), "agent session io failed: broken pipe" ); assert_eq!( AgentSessionError::Decode("bad json".to_owned()).to_string(), "agent session decode failed: bad json" ); assert_eq!( AgentSessionError::Timeout.to_string(), "agent session reply timed out" ); } #[test] fn agent_session_error_is_std_error_and_equates() { // Conformité au trait std::error::Error (thiserror). fn assert_error(_e: &E) {} let e = AgentSessionError::Decode("x".to_owned()); assert_error(&e); // Clone + égalité par variante/payload. assert_eq!(e.clone(), e); assert_ne!( AgentSessionError::Start("a".into()), AgentSessionError::Start("b".into()) ); assert_ne!(AgentSessionError::Timeout, AgentSessionError::Io("t".into())); } // --------------------------------------------------------------------------- // AgentSession / AgentSessionFactory — fake in-module (conformité de signature) // // D0 ne fournit PAS d'impl réelle : ce fake prouve seulement que les traits sont // implémentables (object-safe, signatures cohérentes). Aucune logique réelle. // --------------------------------------------------------------------------- struct FakeSession { id: SessionId, conversation_id: Option, } #[async_trait] impl AgentSession for FakeSession { fn id(&self) -> SessionId { self.id } fn conversation_id(&self) -> Option { self.conversation_id.clone() } async fn send(&self, prompt: &str) -> Result { // Flux borné minimal : un delta puis le Final déterministe. let stream: ReplyStream = Box::new( vec![ ReplyEvent::TextDelta { text: prompt.to_owned(), }, ReplyEvent::Final { content: prompt.to_owned(), }, ] .into_iter(), ); Ok(stream) } async fn shutdown(&self) -> Result<(), AgentSessionError> { Ok(()) } } struct FakeFactory; #[async_trait] impl AgentSessionFactory for FakeFactory { fn supports(&self, profile: &AgentProfile) -> bool { profile.structured_adapter.is_some() } async fn start( &self, _profile: &AgentProfile, _ctx: &PreparedContext, _cwd: &ProjectPath, _session: &SessionPlan, ) -> Result, AgentSessionError> { Ok(Arc::new(FakeSession { id: SessionId::from_uuid(Uuid::from_u128(7)), conversation_id: None, })) } } #[tokio::test] async fn fake_session_proves_trait_is_implementable() { let sid = SessionId::from_uuid(Uuid::from_u128(42)); let session = FakeSession { id: sid, conversation_id: Some("conv-1".to_owned()), }; // Consommé comme trait-objet (object-safety du port via #[async_trait]). let dyn_session: Arc = Arc::new(session); assert_eq!(dyn_session.id(), sid); assert_eq!(dyn_session.conversation_id(), Some("conv-1".to_owned())); // send -> flux d'événements borné se terminant par Final (contrat §17.1). let events: Vec = dyn_session.send("ping").await.unwrap().collect(); assert_eq!( events, vec![ ReplyEvent::TextDelta { text: "ping".to_owned() }, ReplyEvent::Final { content: "ping".to_owned() }, ] ); assert!(matches!(events.last(), Some(ReplyEvent::Final { .. }))); dyn_session.shutdown().await.expect("shutdown ok"); } #[tokio::test] async fn fake_factory_supports_only_structured_profiles_and_starts() { let factory: Arc = Arc::new(FakeFactory); let structured = pty_profile().with_structured_adapter(StructuredAdapter::Claude); let pty = pty_profile(); assert!(factory.supports(&structured)); assert!(!factory.supports(&pty)); let ctx = PreparedContext { content: MarkdownDoc::new("# ctx"), relative_path: "CLAUDE.md".to_owned(), }; let cwd = ProjectPath::new("/srv/run").unwrap(); let session = factory .start(&structured, &ctx, &cwd, &SessionPlan::None) .await .expect("factory starts a session"); assert_eq!(session.id(), SessionId::from_uuid(Uuid::from_u128(7))); }