//! JSON round-trip (serde) of persisted domain types, plus camelCase / tagging //! checks (ARCHITECTURE §7.3, §9). mod helpers; use domain::{ Agent, AgentManifest, AgentOrigin, AgentProfile, AgentTemplate, ContextInjection, Direction, LayoutNode, LayoutTree, LeafCell, ManifestEntry, MarkdownDoc, Project, ProjectPath, RemoteRef, Skill, SkillId, SkillRef, SkillScope, SplitContainer, SshAuth, TemplateVersion, WeightedChild, }; use helpers::{node, session}; use uuid::Uuid; fn pid(n: u128) -> domain::ProjectId { domain::ProjectId::from_uuid(Uuid::from_u128(n)) } fn profid(n: u128) -> domain::ProfileId { domain::ProfileId::from_uuid(Uuid::from_u128(n)) } fn tid(n: u128) -> domain::TemplateId { domain::TemplateId::from_uuid(Uuid::from_u128(n)) } fn aid(n: u128) -> domain::AgentId { domain::AgentId::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") } // --------------------------------------------------------------------------- // Project // --------------------------------------------------------------------------- #[test] fn project_roundtrip() { let p = Project::new( pid(1), "demo", ProjectPath::new("/srv/demo").unwrap(), RemoteRef::ssh("h", 22, "u", SshAuth::Agent, "/srv").unwrap(), 123, ) .unwrap(); assert_eq!(roundtrip(&p), p); } #[test] fn project_uses_camel_case_and_tagged_remote() { let p = Project::new( pid(1), "demo", ProjectPath::new("/srv/demo").unwrap(), RemoteRef::local(), 123, ) .unwrap(); let json = serde_json::to_string(&p).unwrap(); assert!(json.contains("\"createdAt\":123"), "json was {json}"); // RemoteRef tagged with `kind`, camelCased "local". assert!(json.contains("\"kind\":\"local\""), "json was {json}"); } // --------------------------------------------------------------------------- // RemoteRef variants // --------------------------------------------------------------------------- #[test] fn remote_ssh_roundtrip_and_tags() { let r = RemoteRef::ssh("host", 2222, "me", SshAuth::Key { path: "/k".into() }, "/srv").unwrap(); assert_eq!(roundtrip(&r), r); let json = serde_json::to_string(&r).unwrap(); assert!(json.contains("\"kind\":\"ssh\""), "json was {json}"); // SshAuth tagged with `type`. assert!(json.contains("\"type\":\"key\""), "json was {json}"); // Enum-variant fields must be camelCased on the wire (ARCHITECTURE §9). assert!(json.contains("\"remoteRoot\""), "json was {json}"); assert!(!json.contains("\"remote_root\""), "json was {json}"); } #[test] fn remote_wsl_roundtrip() { let r = RemoteRef::wsl("Ubuntu").unwrap(); assert_eq!(roundtrip(&r), r); } // --------------------------------------------------------------------------- // AgentProfile + ContextInjection (tagged with `strategy`) // --------------------------------------------------------------------------- #[test] fn profile_roundtrip_all_injection_variants() { for ci in [ ContextInjection::convention_file("CLAUDE.md").unwrap(), ContextInjection::flag("-f {path}").unwrap(), ContextInjection::stdin(), ContextInjection::env("CTX").unwrap(), ] { let p = AgentProfile::new( profid(1), "Name", "claude", vec!["a".into(), "b".into()], ci, Some("claude --version".into()), "{projectRoot}", ) .unwrap(); assert_eq!(roundtrip(&p), p); } } #[test] fn context_injection_strategy_tag_is_camel_case() { let json = serde_json::to_string(&ContextInjection::convention_file("CLAUDE.md").unwrap()) .unwrap(); assert!(json.contains("\"strategy\":\"conventionFile\""), "json was {json}"); let json = serde_json::to_string(&ContextInjection::stdin()).unwrap(); assert!(json.contains("\"strategy\":\"stdin\""), "json was {json}"); } #[test] fn profile_cwd_template_is_camel_case() { let p = AgentProfile::new( profid(1), "n", "c", vec![], ContextInjection::stdin(), None, "{projectRoot}", ) .unwrap(); let json = serde_json::to_string(&p).unwrap(); assert!(json.contains("\"cwdTemplate\""), "json was {json}"); } // --------------------------------------------------------------------------- // AgentTemplate // --------------------------------------------------------------------------- #[test] fn template_roundtrip() { let t = AgentTemplate::new(tid(1), "T", MarkdownDoc::new("# hi"), profid(2)) .unwrap() .with_updated_content(MarkdownDoc::new("# bye")); assert_eq!(roundtrip(&t), t); let json = serde_json::to_string(&t).unwrap(); assert!(json.contains("\"contentMd\""), "json was {json}"); assert!(json.contains("\"defaultProfileId\""), "json was {json}"); } // --------------------------------------------------------------------------- // Agent + manifest // --------------------------------------------------------------------------- #[test] fn agent_roundtrip_from_template() { let a = Agent::new( aid(1), "Backend", "agents/backend.md", profid(2), AgentOrigin::FromTemplate { template_id: tid(3), synced_template_version: TemplateVersion(4), }, true, ) .unwrap(); assert_eq!(roundtrip(&a), a); let json = serde_json::to_string(&a).unwrap(); assert!(json.contains("\"contextPath\""), "json was {json}"); // AgentOrigin tagged with `type`, camelCased. assert!(json.contains("\"type\":\"fromTemplate\""), "json was {json}"); // Inner fields must be camelCased per ARCHITECTURE §9.1: // { "type":"fromTemplate", "templateId":"...", "syncedTemplateVersion":N }. assert!(json.contains("\"templateId\""), "json was {json}"); assert!(json.contains("\"syncedTemplateVersion\":4"), "json was {json}"); assert!(!json.contains("\"template_id\""), "json was {json}"); assert!(!json.contains("\"synced_template_version\""), "json was {json}"); assert!(!json.contains("\"synced_version\""), "json was {json}"); } // --------------------------------------------------------------------------- // SessionKind (tagged enum: `type`, camelCased variant fields) // --------------------------------------------------------------------------- #[test] fn session_kind_agent_roundtrip_and_camel_case() { use domain::SessionKind; let k = SessionKind::Agent { agent_id: aid(7) }; assert_eq!(roundtrip(&k), k); let json = serde_json::to_string(&k).unwrap(); assert!(json.contains("\"type\":\"agent\""), "json was {json}"); assert!(json.contains("\"agentId\""), "json was {json}"); assert!(!json.contains("\"agent_id\""), "json was {json}"); // Plain variant carries no fields. let plain = serde_json::to_string(&SessionKind::Plain).unwrap(); assert!(plain.contains("\"type\":\"plain\""), "json was {plain}"); } #[test] fn manifest_roundtrip_and_camel_case() { let e1 = ManifestEntry::new( aid(1), "Alpha", "agents/a.md", profid(9), Some(tid(2)), true, Some(TemplateVersion(5)), ) .unwrap(); let e2 = ManifestEntry::new(aid(3), "Beta", "agents/b.md", profid(9), None, false, None).unwrap(); let m = AgentManifest::new(1, vec![e1, e2]).unwrap(); assert_eq!(roundtrip(&m), m); let json = serde_json::to_string(&m).unwrap(); // entries are serialized under "agents". assert!(json.contains("\"agents\":["), "json was {json}"); assert!(json.contains("\"mdPath\""), "json was {json}"); assert!(json.contains("\"syncedTemplateVersion\":5"), "json was {json}"); // Non-synchronized entry omits optional template fields (skip_serializing_if). assert!(!json.contains("\"templateId\":null"), "json was {json}"); } // --------------------------------------------------------------------------- // Skill (L12) — round-trip, camelCase scope tag, manifest skills back-compat // --------------------------------------------------------------------------- fn sid(n: u128) -> SkillId { SkillId::from_uuid(Uuid::from_u128(n)) } #[test] fn skill_roundtrip_and_camel_case_scope() { let s = Skill::new(sid(1), "code-review", MarkdownDoc::new("body"), SkillScope::Global).unwrap(); assert_eq!(roundtrip(&s), s); let json = serde_json::to_string(&s).unwrap(); assert!(json.contains("\"scope\":\"global\""), "json was {json}"); assert!(json.contains("\"contentMd\""), "json was {json}"); let p = Skill::new(sid(2), "simplify", MarkdownDoc::new("b"), SkillScope::Project).unwrap(); let pj = serde_json::to_string(&p).unwrap(); assert!(pj.contains("\"scope\":\"project\""), "json was {pj}"); } #[test] fn manifest_entry_skills_roundtrip_and_camel_case() { let entry = ManifestEntry::from_agent( &Agent::new( aid(1), "dev", "agents/dev.md", profid(9), AgentOrigin::Scratch, false, ) .unwrap() .with_skills(vec![SkillRef::new(sid(5), SkillScope::Project)]), ); assert_eq!(roundtrip(&entry), entry); let json = serde_json::to_string(&entry).unwrap(); assert!(json.contains("\"skillId\""), "json was {json}"); assert!(json.contains("\"scope\":\"project\""), "json was {json}"); } #[test] fn manifest_entry_without_skills_omits_field_and_deserialises() { // An entry with no skills must not emit "skills" (skip_serializing_if), // and a pre-L12 manifest JSON (no skills key) must deserialise to empty. let entry = ManifestEntry::new(aid(1), "dev", "agents/dev.md", profid(9), None, false, None).unwrap(); let json = serde_json::to_string(&entry).unwrap(); assert!(!json.contains("\"skills\""), "json was {json}"); let legacy = r#"{"agentId":"00000000-0000-0000-0000-000000000001","name":"dev","mdPath":"agents/dev.md","profileId":"00000000-0000-0000-0000-000000000009","synchronized":false}"#; let parsed: ManifestEntry = serde_json::from_str(legacy).unwrap(); assert!(parsed.skills.is_empty()); } // --------------------------------------------------------------------------- // LayoutTree (tagged enum: type/node) // --------------------------------------------------------------------------- #[test] fn layout_roundtrip() { let tree = LayoutTree::new(LayoutNode::Split(SplitContainer { id: node(9), direction: Direction::Column, children: vec![ WeightedChild { node: LayoutNode::Leaf(LeafCell { id: node(1), session: Some(session(100)), agent: None, }), weight: 1.5, }, WeightedChild { node: LayoutNode::Leaf(LeafCell { id: node(2), session: None, agent: None, }), weight: 2.5, }, ], })); assert_eq!(roundtrip(&tree), tree); let json = serde_json::to_string(&tree).unwrap(); // enum adjacently tagged: type + node ; direction camelCase. assert!(json.contains("\"type\":\"split\""), "json was {json}"); assert!(json.contains("\"type\":\"leaf\""), "json was {json}"); assert!(json.contains("\"direction\":\"column\""), "json was {json}"); // empty session leaf omits the field. assert!(!json.contains("\"session\":null"), "json was {json}"); } #[test] fn leaf_with_agent_roundtrip_and_omits_null() { use domain::ids::AgentId; let agent_uuid = Uuid::from_u128(0xABC); let tree = LayoutTree::new(LayoutNode::Leaf(LeafCell { id: node(1), session: None, agent: Some(AgentId::from_uuid(agent_uuid)), })); let rt = roundtrip(&tree); match rt.root { LayoutNode::Leaf(l) => assert_eq!(l.agent, Some(AgentId::from_uuid(agent_uuid))), _ => panic!("expected leaf"), } let json = serde_json::to_string(&tree).unwrap(); // agent present when set assert!(json.contains("\"agent\""), "agent field should be present when set; json was {json}"); // null variant omitted let tree_no_agent = LayoutTree::new(LayoutNode::Leaf(LeafCell { id: node(2), session: None, agent: None, })); let json2 = serde_json::to_string(&tree_no_agent).unwrap(); assert!(!json2.contains("\"agent\""), "agent field should be omitted when None; json was {json2}"); }