//! L1 tests for the IPC DTO (de)serialisation contract: camelCase on the wire, //! stable `ErrorDto.code`, and the `DomainEvent -> DomainEventDto` mapping with //! its tagged, camelCase JSON shape. use app_tauri_lib::dto::{ parse_node_id, parse_session_id, ErrorDto, HealthRequestDto, HealthResponseDto, LayoutDto, LayoutOperationDto, OpenTerminalRequestDto, ResizeTerminalRequestDto, TerminalClosedDto, WriteTerminalRequestDto, }; use application::{CloseTerminalOutput, LayoutOperation, LoadLayoutOutput, OpenTerminalInput}; use domain::{Direction, LayoutNode, LayoutTree, LeafCell, NodeId}; use app_tauri_lib::events::{DomainEventDto, DOMAIN_EVENT}; use application::{AppError, HealthInput}; use domain::events::DomainEvent; use domain::ids::{AgentId, SessionId}; use domain::ProjectId; use domain::TemplateVersion; use domain::TemplateId; use serde_json::json; use uuid::Uuid; #[test] fn health_response_serialises_camel_case() { let dto = HealthResponseDto { version: "1.2.3".into(), alive: true, time_millis: 42, correlation_id: "abc".into(), note: Some("hi".into()), }; let v = serde_json::to_value(&dto).unwrap(); assert_eq!( v, json!({ "version": "1.2.3", "alive": true, "timeMillis": 42, "correlationId": "abc", "note": "hi", }) ); } #[test] fn health_request_deserialises_camel_case_and_defaults() { let dto: HealthRequestDto = serde_json::from_value(json!({ "note": "x" })).unwrap(); assert_eq!(HealthInput::from(dto).note.as_deref(), Some("x")); // Missing note defaults to None. let empty: HealthRequestDto = serde_json::from_value(json!({})).unwrap(); assert_eq!(HealthInput::from(empty).note, None); } #[test] fn error_dto_carries_stable_code_and_message() { let dto = ErrorDto::from(AppError::NotFound("project".into())); assert_eq!(dto.code, "NOT_FOUND"); assert!(dto.message.contains("project")); let v = serde_json::to_value(&dto).unwrap(); assert_eq!(v["code"], "NOT_FOUND"); assert!(v.get("message").is_some()); } #[test] fn domain_event_relay_name_is_frozen() { assert_eq!(DOMAIN_EVENT, "domain://event"); } #[test] fn domain_event_dto_is_tagged_and_camel_case() { let pid = ProjectId::from_uuid(Uuid::nil()); let dto = DomainEventDto::from(&DomainEvent::ProjectCreated { project_id: pid }); let v = serde_json::to_value(&dto).unwrap(); assert_eq!( v, json!({ "type": "projectCreated", "projectId": pid.to_string() }) ); } #[test] fn domain_event_dto_maps_agent_launched() { let aid = AgentId::from_uuid(Uuid::nil()); let sid = SessionId::from_uuid(Uuid::nil()); let dto = DomainEventDto::from(&DomainEvent::AgentLaunched { agent_id: aid, session_id: sid, }); let v = serde_json::to_value(&dto).unwrap(); assert_eq!(v["type"], "agentLaunched"); assert_eq!(v["agentId"], aid.to_string()); assert_eq!(v["sessionId"], sid.to_string()); } #[test] fn domain_event_dto_maps_template_updated_version() { let tid = TemplateId::from_uuid(Uuid::nil()); let dto = DomainEventDto::from(&DomainEvent::TemplateUpdated { template_id: tid, version: TemplateVersion::INITIAL, }); let v = serde_json::to_value(&dto).unwrap(); assert_eq!(v["type"], "templateUpdated"); assert_eq!(v["templateId"], tid.to_string()); assert!(v["version"].is_u64()); } #[test] fn domain_event_dto_maps_pty_output_bytes() { let sid = SessionId::from_uuid(Uuid::nil()); let dto = DomainEventDto::from(&DomainEvent::PtyOutput { session_id: sid, bytes: vec![1, 2, 3], }); let v = serde_json::to_value(&dto).unwrap(); assert_eq!(v["type"], "ptyOutput"); assert_eq!(v["bytes"], json!([1, 2, 3])); } // --------------------------------------------------------------------------- // Terminal DTOs (L3) // --------------------------------------------------------------------------- #[test] fn open_terminal_request_deserialises_camel_case_with_defaults() { // Minimal payload: command/args omitted → None / empty. let dto: OpenTerminalRequestDto = serde_json::from_value(json!({ "cwd": "/p", "rows": 24, "cols": 80 })).unwrap(); let input = OpenTerminalInput::from(dto); assert_eq!(input.cwd, "/p"); assert_eq!(input.rows, 24); assert_eq!(input.cols, 80); assert_eq!(input.command, None); assert!(input.args.is_empty()); assert_eq!(input.node_id, None); // Full payload with explicit command + args. let full: OpenTerminalRequestDto = serde_json::from_value(json!({ "cwd": "/q", "rows": 10, "cols": 20, "command": "/bin/zsh", "args": ["-l"], })) .unwrap(); let input = OpenTerminalInput::from(full); assert_eq!(input.command.as_deref(), Some("/bin/zsh")); assert_eq!(input.args, vec!["-l".to_owned()]); } #[test] fn write_terminal_request_deserialises_session_id_and_data() { let sid = SessionId::from_uuid(Uuid::nil()); let dto: WriteTerminalRequestDto = serde_json::from_value(json!({ "sessionId": sid.to_string(), "data": [104, 105], })) .unwrap(); let input = dto.into_input().expect("valid session id"); assert_eq!(input.session_id, sid); assert_eq!(input.data, vec![104u8, 105]); } #[test] fn write_terminal_request_rejects_bad_session_id() { let dto: WriteTerminalRequestDto = serde_json::from_value(json!({ "sessionId": "not-a-uuid", "data": [] })).unwrap(); let err = dto.into_input().expect_err("malformed id rejected"); assert_eq!(err.code, "INVALID"); } #[test] fn resize_terminal_request_deserialises_camel_case() { let sid = SessionId::from_uuid(Uuid::nil()); let dto: ResizeTerminalRequestDto = serde_json::from_value(json!({ "sessionId": sid.to_string(), "rows": 40, "cols": 120, })) .unwrap(); let input = dto.into_input().expect("valid"); assert_eq!(input.session_id, sid); assert_eq!(input.rows, 40); assert_eq!(input.cols, 120); } #[test] fn terminal_closed_dto_serialises_code_camel_case() { let dto = TerminalClosedDto::from(CloseTerminalOutput { code: Some(0) }); let v = serde_json::to_value(&dto).unwrap(); assert_eq!(v, json!({ "code": 0 })); // Signalled (None) round-trips as null. let none = TerminalClosedDto::from(CloseTerminalOutput { code: None }); assert_eq!(serde_json::to_value(&none).unwrap(), json!({ "code": null })); } #[test] fn parse_session_id_accepts_uuid_and_rejects_garbage() { let sid = SessionId::from_uuid(Uuid::nil()); assert_eq!(parse_session_id(&sid.to_string()).unwrap(), sid); assert_eq!(parse_session_id("nope").unwrap_err().code, "INVALID"); } // --------------------------------------------------------------------------- // Layout (L4) // --------------------------------------------------------------------------- fn nid(n: u128) -> NodeId { NodeId::from_uuid(Uuid::from_u128(n)) } #[test] fn layout_dto_serialises_camelcase_tagged_tree() { // A single-leaf tree → transparent over LayoutTree, tagged `{type,node}`. let tree = LayoutTree::single(LeafCell { id: nid(1), session: None, agent: None, }); let dto = LayoutDto::from(LoadLayoutOutput { layout_id: domain::LayoutId::new_random(), layout: tree, }); let v = serde_json::to_value(&dto).unwrap(); assert_eq!(v["root"]["type"], "leaf", "enum tagged on `type`"); assert_eq!(v["root"]["node"]["id"], nid(1).to_string()); // Empty session is skipped (skip_serializing_if). assert!(v["root"]["node"].get("session").is_none()); } #[test] fn layout_operation_dto_split_deserialises_camelcase() { let json = json!({ "type": "split", "target": nid(1).to_string(), "direction": "row", "newLeaf": nid(2).to_string(), "container": nid(9).to_string(), }); let dto: LayoutOperationDto = serde_json::from_value(json).unwrap(); match dto.into_operation().unwrap() { LayoutOperation::Split { target, direction, new_leaf, container, } => { assert_eq!(target, nid(1)); assert_eq!(direction, Direction::Row); assert_eq!(new_leaf, nid(2)); assert_eq!(container, nid(9)); } other => panic!("expected Split, got {other:?}"), } } #[test] fn layout_operation_dto_set_session_accepts_null_session() { // `session: null` → detach (None). let json = json!({ "type": "setSession", "target": nid(1).to_string(), "session": null, }); let dto: LayoutOperationDto = serde_json::from_value(json).unwrap(); match dto.into_operation().unwrap() { LayoutOperation::SetSession { target, session } => { assert_eq!(target, nid(1)); assert!(session.is_none()); } other => panic!("expected SetSession, got {other:?}"), } } #[test] fn layout_operation_dto_resize_carries_weights() { let json = json!({ "type": "resize", "container": nid(9).to_string(), "weights": [3.0, 1.0], }); let dto: LayoutOperationDto = serde_json::from_value(json).unwrap(); match dto.into_operation().unwrap() { LayoutOperation::Resize { container, weights } => { assert_eq!(container, nid(9)); assert_eq!(weights, vec![3.0, 1.0]); } other => panic!("expected Resize, got {other:?}"), } } #[test] fn layout_operation_dto_rejects_malformed_uuid_as_invalid() { let json = json!({ "type": "merge", "container": "not-a-uuid", "keepIndex": 0, }); let dto: LayoutOperationDto = serde_json::from_value(json).unwrap(); let err = dto.into_operation().expect_err("malformed uuid rejected"); assert_eq!(err.code, "INVALID", "got {err:?}"); } #[test] fn parse_node_id_accepts_uuid_and_rejects_garbage() { assert_eq!(parse_node_id(&nid(7).to_string()).unwrap(), nid(7)); assert_eq!(parse_node_id("garbage").unwrap_err().code, "INVALID"); } #[test] fn layout_dto_round_trips_a_split_tree_shape() { // Build a split tree, serialise via the DTO, and confirm the tagged shape // re-parses into an equivalent LayoutTree. let tree = LayoutTree::single(LeafCell { id: nid(1), session: None, agent: None, }) .split(nid(1), Direction::Column, LeafCell { id: nid(2), session: None, agent: None }, nid(9)) .unwrap(); let dto = LayoutDto::from(LoadLayoutOutput { layout_id: domain::LayoutId::new_random(), layout: tree.clone(), }); let v = serde_json::to_value(&dto).unwrap(); let back: LayoutTree = serde_json::from_value(v).unwrap(); assert_eq!(back, tree, "DTO serialisation round-trips the tree"); assert!(matches!(back.root, LayoutNode::Split(_))); }