feat: add main features

Agents for developpement added + frontend add + backend added. Git viewer created + agent and template creator + layout and project creator
This commit is contained in:
2026-06-06 01:27:01 +02:00
parent 55b3bee2c8
commit 307ae71857
273 changed files with 48740 additions and 0 deletions

View File

@ -0,0 +1,326 @@
//! 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(_)));
}

View File

@ -0,0 +1,184 @@
//! L6 tests for the agent DTO (de)serialisation contract: camelCase on the
//! wire, embedded [`Agent`] shape preserved, `parse_agent_id` error behaviour,
//! and `From<LaunchAgentOutput>` for [`TerminalSessionDto`].
use app_tauri_lib::dto::{
parse_agent_id, AgentDto, AgentListDto, CreateAgentRequestDto, LaunchAgentRequestDto,
TerminalSessionDto, UpdateAgentContextRequestDto,
};
use application::{CreateAgentOutput, LaunchAgentOutput, ListAgentsOutput};
use domain::ids::{AgentId, NodeId, ProfileId, SessionId};
use domain::terminal::{PtySize, SessionKind, SessionStatus, TerminalSession};
use domain::{Agent, AgentOrigin, ProjectPath};
use serde_json::json;
use uuid::Uuid;
/// Helper: build a minimal validated [`Agent`].
fn make_agent(agent_uuid: u128, profile_uuid: u128) -> Agent {
Agent::new(
AgentId::from_uuid(Uuid::from_u128(agent_uuid)),
"My Agent",
"agents/my-agent.md",
ProfileId::from_uuid(Uuid::from_u128(profile_uuid)),
AgentOrigin::Scratch,
false,
)
.expect("valid agent")
}
// ---------------------------------------------------------------------------
// AgentDto / AgentListDto serialisation
// ---------------------------------------------------------------------------
#[test]
fn agent_dto_serialises_camelcase() {
let agent = make_agent(1, 2);
let dto = AgentDto(agent.clone());
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["id"], agent.id.to_string());
assert_eq!(v["name"], "My Agent");
assert_eq!(v["contextPath"], "agents/my-agent.md", "camelCase key");
assert_eq!(v["profileId"], ProfileId::from_uuid(Uuid::from_u128(2)).to_string());
assert_eq!(v["synchronized"], false);
// origin: tagged `{ "type": "scratch" }`
assert_eq!(v["origin"]["type"], "scratch");
// no snake_case leak
assert!(v.get("context_path").is_none());
assert!(v.get("profile_id").is_none());
}
#[test]
fn agent_list_dto_is_transparent_array() {
let out = ListAgentsOutput {
agents: vec![make_agent(1, 2), make_agent(3, 4)],
};
let dto = AgentListDto::from(out);
let v = serde_json::to_value(&dto).unwrap();
let arr = v.as_array().expect("transparent array");
assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["name"], "My Agent");
}
#[test]
fn create_agent_output_maps_to_agent_dto() {
let agent = make_agent(5, 6);
let out = CreateAgentOutput { agent: agent.clone() };
let dto = AgentDto::from(out);
assert_eq!(dto.0.id, agent.id);
}
// ---------------------------------------------------------------------------
// Request DTO deserialisation
// ---------------------------------------------------------------------------
#[test]
fn create_agent_request_deserialises_camelcase() {
let project_id = Uuid::from_u128(10).to_string();
let profile_id = Uuid::from_u128(20).to_string();
let raw = json!({
"projectId": project_id,
"name": "Backend Dev",
"profileId": profile_id,
"initialContent": "# Hello"
});
let dto: CreateAgentRequestDto = serde_json::from_value(raw).unwrap();
assert_eq!(dto.project_id, project_id);
assert_eq!(dto.name, "Backend Dev");
assert_eq!(dto.profile_id, profile_id);
assert_eq!(dto.initial_content.as_deref(), Some("# Hello"));
}
#[test]
fn create_agent_request_initial_content_defaults_to_none() {
let raw = json!({
"projectId": Uuid::nil().to_string(),
"name": "Agent",
"profileId": Uuid::nil().to_string()
});
let dto: CreateAgentRequestDto = serde_json::from_value(raw).unwrap();
assert!(dto.initial_content.is_none());
}
#[test]
fn update_agent_context_request_deserialises_camelcase() {
let raw = json!({
"projectId": Uuid::nil().to_string(),
"agentId": Uuid::nil().to_string(),
"content": "# Updated"
});
let dto: UpdateAgentContextRequestDto = serde_json::from_value(raw).unwrap();
assert_eq!(dto.content, "# Updated");
}
#[test]
fn launch_agent_request_deserialises_camelcase() {
let project_id = Uuid::from_u128(1).to_string();
let agent_id = Uuid::from_u128(2).to_string();
let raw = json!({
"projectId": project_id,
"agentId": agent_id,
"rows": 24,
"cols": 80
});
let dto: LaunchAgentRequestDto = serde_json::from_value(raw).unwrap();
assert_eq!(dto.rows, 24);
assert_eq!(dto.cols, 80);
assert_eq!(dto.project_id, project_id);
assert_eq!(dto.agent_id, agent_id);
}
// ---------------------------------------------------------------------------
// parse_agent_id
// ---------------------------------------------------------------------------
#[test]
fn parse_agent_id_accepts_uuid() {
let id = Uuid::from_u128(99);
assert_eq!(
parse_agent_id(&id.to_string()).unwrap(),
AgentId::from_uuid(id)
);
}
#[test]
fn parse_agent_id_rejects_garbage() {
let err = parse_agent_id("not-a-uuid").expect_err("garbage rejected");
assert_eq!(err.code, "INVALID");
}
// ---------------------------------------------------------------------------
// From<LaunchAgentOutput> for TerminalSessionDto
// ---------------------------------------------------------------------------
#[test]
fn launch_agent_output_maps_to_terminal_session_dto() {
let session_id = SessionId::from_uuid(Uuid::from_u128(7));
let node_id = NodeId::from_uuid(Uuid::from_u128(8));
let agent_id = AgentId::from_uuid(Uuid::from_u128(9));
let cwd = ProjectPath::new("/tmp/project".to_owned()).expect("valid path");
let size = PtySize::new(24, 80).unwrap();
let mut session = TerminalSession::starting(
session_id,
node_id,
cwd,
SessionKind::Agent { agent_id },
size,
);
session.status = SessionStatus::Running;
let out = LaunchAgentOutput { session };
let dto = TerminalSessionDto::from(out);
assert_eq!(dto.session_id, session_id.to_string());
assert_eq!(dto.cwd, "/tmp/project");
assert_eq!(dto.rows, 24);
assert_eq!(dto.cols, 80);
// Also verify camelCase serialisation.
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["sessionId"], session_id.to_string());
assert_eq!(v["cwd"], "/tmp/project");
assert!(v.get("session_id").is_none(), "no snake_case leak");
}

View File

@ -0,0 +1,262 @@
//! L8 tests for the Git DTO (de)serialisation contract: camelCase on the
//! wire, transparent list shapes, `From<…Output>` conversions, and request
//! DTO deserialisation.
use app_tauri_lib::dto::{
GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto,
GitFileStatusDto, GitStageRequestDto, GitStatusListDto, GraphCommitDto, GraphCommitListDto,
};
use application::{GitBranchesOutput, GitCommitOutput, GitGraphOutput, GitLogOutput, GitStatusOutput};
use domain::ports::{GitCommitInfo, GitFileStatus, GraphCommit};
use serde_json::json;
use uuid::Uuid;
// ---------------------------------------------------------------------------
// GitFileStatusDto serialisation
// ---------------------------------------------------------------------------
#[test]
fn git_file_status_dto_serialises_camelcase() {
let dto = GitFileStatusDto {
path: "src/main.rs".to_owned(),
staged: true,
};
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["path"], "src/main.rs");
assert_eq!(v["staged"], true);
// no snake_case leak — both fields are single words so no renaming needed,
// but verify no extra/unexpected keys.
assert!(v.get("path").is_some());
assert!(v.get("staged").is_some());
}
#[test]
fn git_file_status_from_domain_type() {
let domain = GitFileStatus {
path: "README.md".to_owned(),
staged: false,
};
let dto = GitFileStatusDto::from(domain);
assert_eq!(dto.path, "README.md");
assert!(!dto.staged);
}
// ---------------------------------------------------------------------------
// GitStatusListDto — transparent array
// ---------------------------------------------------------------------------
#[test]
fn git_status_list_dto_is_transparent_array() {
let out = GitStatusOutput {
entries: vec![
GitFileStatus {
path: "a.rs".to_owned(),
staged: true,
},
GitFileStatus {
path: "b.rs".to_owned(),
staged: false,
},
],
};
let dto = GitStatusListDto::from(out);
let v = serde_json::to_value(&dto).unwrap();
let arr = v.as_array().expect("transparent array");
assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["path"], "a.rs");
assert_eq!(arr[0]["staged"], true);
assert_eq!(arr[1]["path"], "b.rs");
assert_eq!(arr[1]["staged"], false);
}
// ---------------------------------------------------------------------------
// GitCommitDto serialisation & From impls
// ---------------------------------------------------------------------------
#[test]
fn git_commit_dto_serialises_camelcase() {
let dto = GitCommitDto {
hash: "abc123".to_owned(),
summary: "feat: add git support".to_owned(),
};
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["hash"], "abc123");
assert_eq!(v["summary"], "feat: add git support");
}
#[test]
fn git_commit_dto_from_commit_info() {
let info = GitCommitInfo {
hash: "deadbeef".to_owned(),
summary: "fix: something".to_owned(),
};
let dto = GitCommitDto::from(info);
assert_eq!(dto.hash, "deadbeef");
assert_eq!(dto.summary, "fix: something");
}
#[test]
fn git_commit_dto_from_commit_output() {
let out = GitCommitOutput {
commit: GitCommitInfo {
hash: "cafebabe".to_owned(),
summary: "chore: cleanup".to_owned(),
},
};
let dto = GitCommitDto::from(out);
assert_eq!(dto.hash, "cafebabe");
assert_eq!(dto.summary, "chore: cleanup");
}
// ---------------------------------------------------------------------------
// GitCommitListDto — transparent array
// ---------------------------------------------------------------------------
#[test]
fn git_commit_list_dto_is_transparent_array() {
let out = GitLogOutput {
commits: vec![
GitCommitInfo {
hash: "aaa".to_owned(),
summary: "first".to_owned(),
},
GitCommitInfo {
hash: "bbb".to_owned(),
summary: "second".to_owned(),
},
],
};
let dto = GitCommitListDto::from(out);
let v = serde_json::to_value(&dto).unwrap();
let arr = v.as_array().expect("transparent array");
assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["hash"], "aaa");
assert_eq!(arr[0]["summary"], "first");
}
// ---------------------------------------------------------------------------
// GitBranchesDto serialisation (incl. current: null)
// ---------------------------------------------------------------------------
#[test]
fn git_branches_dto_serialises_with_current() {
let out = GitBranchesOutput {
branches: vec!["main".to_owned(), "feature/x".to_owned()],
current: Some("main".to_owned()),
};
let dto = GitBranchesDto::from(out);
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["branches"][0], "main");
assert_eq!(v["branches"][1], "feature/x");
assert_eq!(v["current"], "main");
}
#[test]
fn git_branches_dto_serialises_null_current() {
let out = GitBranchesOutput {
branches: vec![],
current: None,
};
let dto = GitBranchesDto::from(out);
let v = serde_json::to_value(&dto).unwrap();
assert!(v["current"].is_null(), "current must be null when None");
}
// ---------------------------------------------------------------------------
// GraphCommitDto serialisation & From impls
// ---------------------------------------------------------------------------
#[test]
fn graph_commit_dto_serialises_camelcase() {
let commit = GraphCommit {
hash: "abc123".to_owned(),
summary: "feat: graph".to_owned(),
parents: vec!["deadbeef".to_owned()],
refs: vec!["main".to_owned(), "tag: v1.0".to_owned()],
author: "Alice".to_owned(),
timestamp: 1_700_000_000,
};
let dto = GraphCommitDto::from(commit);
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["hash"], "abc123");
assert_eq!(v["summary"], "feat: graph");
assert_eq!(v["parents"][0], "deadbeef");
assert_eq!(v["refs"][0], "main");
assert_eq!(v["refs"][1], "tag: v1.0");
assert_eq!(v["author"], "Alice");
assert_eq!(v["timestamp"], 1_700_000_000_i64);
// No snake_case leaks for multi-word fields — none here but verify shape.
assert!(v.get("hash").is_some());
}
#[test]
fn graph_commit_list_dto_is_transparent_array() {
let out = GitGraphOutput {
commits: vec![
GraphCommit {
hash: "aaa".to_owned(),
summary: "first".to_owned(),
parents: vec![],
refs: vec!["main".to_owned()],
author: "Bob".to_owned(),
timestamp: 1000,
},
GraphCommit {
hash: "bbb".to_owned(),
summary: "second".to_owned(),
parents: vec!["aaa".to_owned()],
refs: vec![],
author: "Bob".to_owned(),
timestamp: 999,
},
],
};
let dto = GraphCommitListDto::from(out);
let v = serde_json::to_value(&dto).unwrap();
let arr = v.as_array().expect("transparent array");
assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["hash"], "aaa");
assert_eq!(arr[0]["refs"][0], "main");
assert_eq!(arr[1]["hash"], "bbb");
assert!(arr[1]["parents"][0] == "aaa");
}
// ---------------------------------------------------------------------------
// Request DTO deserialisation (camelCase)
// ---------------------------------------------------------------------------
#[test]
fn git_stage_request_deserialises_camelcase() {
let project_id = Uuid::from_u128(1).to_string();
let raw = json!({
"projectId": project_id,
"path": "src/lib.rs"
});
let dto: GitStageRequestDto = serde_json::from_value(raw).unwrap();
assert_eq!(dto.project_id, project_id);
assert_eq!(dto.path, "src/lib.rs");
}
#[test]
fn git_commit_request_deserialises_camelcase() {
let project_id = Uuid::from_u128(2).to_string();
let raw = json!({
"projectId": project_id,
"message": "feat: initial commit"
});
let dto: GitCommitRequestDto = serde_json::from_value(raw).unwrap();
assert_eq!(dto.project_id, project_id);
assert_eq!(dto.message, "feat: initial commit");
}
#[test]
fn git_checkout_request_deserialises_camelcase() {
let project_id = Uuid::from_u128(3).to_string();
let raw = json!({
"projectId": project_id,
"branch": "feature/awesome"
});
let dto: GitCheckoutRequestDto = serde_json::from_value(raw).unwrap();
assert_eq!(dto.project_id, project_id);
assert_eq!(dto.branch, "feature/awesome");
}

View File

@ -0,0 +1,247 @@
//! Tests for the layout-management (#4) and per-cell-agent (#3) DTOs:
//! camelCase wire shape, `parse_layout_id` error behaviour, `ListLayoutsDto` /
//! `CreateLayoutResultDto` / `DeleteLayoutResultDto` mappings, and
//! `setCellAgent` operation deserialisation.
use app_tauri_lib::dto::{
parse_layout_id, CreateLayoutRequestDto, CreateLayoutResultDto, DeleteLayoutRequestDto,
DeleteLayoutResultDto, LayoutInfoDto, LayoutOperationDto, ListLayoutsDto, RenameLayoutRequestDto,
SetActiveLayoutRequestDto,
};
use application::{
CreateLayoutOutput, DeleteLayoutOutput, LayoutInfo, LayoutKind, ListLayoutsOutput,
};
use domain::ids::{AgentId, LayoutId, NodeId};
use serde_json::json;
use uuid::Uuid;
fn lid(n: u128) -> LayoutId {
LayoutId::from_uuid(Uuid::from_u128(n))
}
fn nid(n: u128) -> NodeId {
NodeId::from_uuid(Uuid::from_u128(n))
}
fn aid(n: u128) -> AgentId {
AgentId::from_uuid(Uuid::from_u128(n))
}
// ---------------------------------------------------------------------------
// LayoutInfoDto
// ---------------------------------------------------------------------------
#[test]
fn layout_info_dto_serialises_camelcase() {
let info = LayoutInfo {
id: lid(1),
name: "Default".to_owned(),
kind: LayoutKind::Terminal,
};
let dto = LayoutInfoDto::from(info);
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["id"], lid(1).to_string());
assert_eq!(v["name"], "Default");
assert_eq!(v["kind"], "terminal");
}
#[test]
fn layout_info_dto_git_graph_kind() {
let info = LayoutInfo {
id: lid(2),
name: "Graph".to_owned(),
kind: LayoutKind::GitGraph,
};
let dto = LayoutInfoDto::from(info);
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["kind"], "gitGraph");
}
// ---------------------------------------------------------------------------
// ListLayoutsDto
// ---------------------------------------------------------------------------
#[test]
fn list_layouts_dto_from_output() {
let out = ListLayoutsOutput {
layouts: vec![
LayoutInfo { id: lid(1), name: "Default".to_owned(), kind: LayoutKind::Terminal },
LayoutInfo { id: lid(2), name: "Backend".to_owned(), kind: LayoutKind::GitGraph },
],
active_id: lid(1),
};
let dto = ListLayoutsDto::from(out);
let v = serde_json::to_value(&dto).unwrap();
let layouts = v["layouts"].as_array().unwrap();
assert_eq!(layouts.len(), 2);
assert_eq!(layouts[0]["name"], "Default");
assert_eq!(layouts[0]["kind"], "terminal");
assert_eq!(layouts[1]["name"], "Backend");
assert_eq!(layouts[1]["kind"], "gitGraph");
assert_eq!(v["activeId"], lid(1).to_string(), "camelCase activeId");
assert!(v.get("active_id").is_none(), "no snake_case leak");
}
// ---------------------------------------------------------------------------
// CreateLayoutResultDto
// ---------------------------------------------------------------------------
#[test]
fn create_layout_result_dto_from_output() {
let out = CreateLayoutOutput { layout_id: lid(42) };
let dto = CreateLayoutResultDto::from(out);
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["layoutId"], lid(42).to_string(), "camelCase layoutId");
assert!(v.get("layout_id").is_none(), "no snake_case leak");
}
// ---------------------------------------------------------------------------
// DeleteLayoutResultDto
// ---------------------------------------------------------------------------
#[test]
fn delete_layout_result_dto_from_output() {
let out = DeleteLayoutOutput { active_id: lid(1) };
let dto = DeleteLayoutResultDto::from(out);
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["activeId"], lid(1).to_string(), "camelCase activeId");
}
// ---------------------------------------------------------------------------
// Request DTO deserialisation
// ---------------------------------------------------------------------------
#[test]
fn create_layout_request_deserialises_camelcase() {
let project_id = Uuid::from_u128(10).to_string();
let raw = json!({ "projectId": project_id, "name": "Backend" });
let dto: CreateLayoutRequestDto = serde_json::from_value(raw).unwrap();
assert_eq!(dto.project_id, project_id);
assert_eq!(dto.name, "Backend");
assert!(dto.kind.is_none(), "kind defaults to None when absent");
}
#[test]
fn create_layout_request_with_git_graph_kind() {
let project_id = Uuid::from_u128(11).to_string();
let raw = json!({ "projectId": project_id, "name": "Graph", "kind": "gitGraph" });
let dto: CreateLayoutRequestDto = serde_json::from_value(raw).unwrap();
assert_eq!(dto.kind.as_deref(), Some("gitGraph"));
let kind = dto.parse_kind().unwrap();
assert_eq!(kind, application::LayoutKind::GitGraph);
}
#[test]
fn create_layout_request_unknown_kind_is_invalid() {
let project_id = Uuid::from_u128(12).to_string();
let raw = json!({ "projectId": project_id, "name": "X", "kind": "unknown" });
let dto: CreateLayoutRequestDto = serde_json::from_value(raw).unwrap();
let err = dto.parse_kind().unwrap_err();
assert_eq!(err.code, "INVALID");
}
#[test]
fn rename_layout_request_deserialises_camelcase() {
let raw = json!({
"projectId": Uuid::nil().to_string(),
"layoutId": Uuid::nil().to_string(),
"name": "Renamed"
});
let dto: RenameLayoutRequestDto = serde_json::from_value(raw).unwrap();
assert_eq!(dto.name, "Renamed");
}
#[test]
fn delete_layout_request_deserialises_camelcase() {
let project_id = Uuid::from_u128(1).to_string();
let layout_id = Uuid::from_u128(2).to_string();
let raw = json!({ "projectId": project_id, "layoutId": layout_id });
let dto: DeleteLayoutRequestDto = serde_json::from_value(raw).unwrap();
assert_eq!(dto.project_id, project_id);
assert_eq!(dto.layout_id, layout_id);
}
#[test]
fn set_active_layout_request_deserialises_camelcase() {
let raw = json!({
"projectId": Uuid::nil().to_string(),
"layoutId": Uuid::nil().to_string()
});
let _dto: SetActiveLayoutRequestDto = serde_json::from_value(raw).unwrap();
}
// ---------------------------------------------------------------------------
// parse_layout_id
// ---------------------------------------------------------------------------
#[test]
fn parse_layout_id_accepts_uuid() {
let id = Uuid::from_u128(5);
assert_eq!(
parse_layout_id(&id.to_string()).unwrap(),
LayoutId::from_uuid(id)
);
}
#[test]
fn parse_layout_id_rejects_garbage() {
let err = parse_layout_id("not-a-uuid").expect_err("garbage rejected");
assert_eq!(err.code, "INVALID");
}
// ---------------------------------------------------------------------------
// setCellAgent operation deserialisation (#3)
// ---------------------------------------------------------------------------
#[test]
fn set_cell_agent_op_deserialises_with_agent() {
let target = nid(1);
let agent = aid(99);
let raw = json!({
"type": "setCellAgent",
"target": target.to_string(),
"agent": agent.to_string()
});
let dto: LayoutOperationDto = serde_json::from_value(raw).unwrap();
let op = dto.into_operation().unwrap();
match op {
application::LayoutOperation::SetCellAgent { target: t, agent: a } => {
assert_eq!(t, target);
assert_eq!(a, Some(agent));
}
_ => panic!("expected SetCellAgent"),
}
}
#[test]
fn set_cell_agent_op_deserialises_with_null_agent() {
let target = nid(1);
let raw = json!({
"type": "setCellAgent",
"target": target.to_string(),
"agent": null
});
let dto: LayoutOperationDto = serde_json::from_value(raw).unwrap();
let op = dto.into_operation().unwrap();
match op {
application::LayoutOperation::SetCellAgent { target: t, agent: None } => {
assert_eq!(t, target);
}
_ => panic!("expected SetCellAgent with None agent"),
}
}
#[test]
fn set_cell_agent_op_deserialises_with_absent_agent_defaults_to_none() {
let target = nid(2);
// omitting `agent` should default to None
let raw = json!({
"type": "setCellAgent",
"target": target.to_string()
});
let dto: LayoutOperationDto = serde_json::from_value(raw).unwrap();
let op = dto.into_operation().unwrap();
match op {
application::LayoutOperation::SetCellAgent { agent: None, .. } => {}
_ => panic!("expected SetCellAgent with None agent"),
}
}

View File

@ -0,0 +1,133 @@
//! L5 tests for the profile/first-run DTO (de)serialisation contract: camelCase
//! on the wire, embedded [`AgentProfile`] shape preserved, and `parse_profile_id`
//! error behaviour.
use app_tauri_lib::dto::{
parse_delete_profile, parse_profile_id, ConfigureProfilesRequestDto, DetectProfilesRequestDto,
DetectProfilesResponseDto, FirstRunStateDto, ProfileListDto, SaveProfileRequestDto,
};
use application::{
ConfigureProfilesInput, DetectProfilesInput, DetectProfilesOutput, FirstRunStateOutput,
ProfileAvailability, SaveProfileInput,
};
use domain::ids::ProfileId;
use domain::profile::{AgentProfile, ContextInjection};
use serde_json::json;
use uuid::Uuid;
fn profile(id: u128, name: &str, command: &str) -> AgentProfile {
AgentProfile::new(
ProfileId::from_uuid(Uuid::from_u128(id)),
name,
command,
Vec::new(),
ContextInjection::convention_file("CLAUDE.md").unwrap(),
Some(format!("{command} --version")),
"{projectRoot}",
)
.unwrap()
}
#[test]
fn profile_list_dto_serialises_camelcase_profiles() {
let dto: ProfileListDto = vec![profile(1, "Claude", "claude")].into();
let v = serde_json::to_value(&dto).unwrap();
let arr = v.as_array().expect("transparent array");
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["command"], "claude");
assert_eq!(arr[0]["cwdTemplate"], "{projectRoot}");
assert_eq!(arr[0]["contextInjection"]["strategy"], "conventionFile");
}
#[test]
fn detect_request_deserialises_candidates() {
let raw = json!({
"candidates": [{
"id": Uuid::from_u128(1).to_string(),
"name": "Claude",
"command": "claude",
"args": [],
"contextInjection": { "strategy": "stdin" },
"detect": "claude --version",
"cwdTemplate": "{projectRoot}"
}]
});
let dto: DetectProfilesRequestDto = serde_json::from_value(raw).unwrap();
let input: DetectProfilesInput = dto.into();
assert_eq!(input.candidates.len(), 1);
assert_eq!(input.candidates[0].command, "claude");
}
#[test]
fn detect_response_serialises_available_flag_camelcase() {
let out = DetectProfilesOutput {
results: vec![ProfileAvailability {
profile: profile(1, "Claude", "claude"),
available: true,
}],
};
let dto: DetectProfilesResponseDto = out.into();
let v = serde_json::to_value(&dto).unwrap();
let arr = v.as_array().unwrap();
assert_eq!(arr[0]["available"], true);
assert_eq!(arr[0]["profile"]["command"], "claude");
}
#[test]
fn save_request_deserialises_profile() {
let raw = json!({
"profile": {
"id": Uuid::from_u128(2).to_string(),
"name": "Codex",
"command": "codex",
"args": ["--foo"],
"contextInjection": { "strategy": "conventionFile", "target": "AGENTS.md" },
"detect": null,
"cwdTemplate": ""
}
});
let dto: SaveProfileRequestDto = serde_json::from_value(raw).unwrap();
let input: SaveProfileInput = dto.into();
assert_eq!(input.profile.command, "codex");
assert_eq!(input.profile.args, vec!["--foo"]);
assert!(input.profile.detect.is_none());
}
#[test]
fn configure_request_deserialises_profiles() {
let raw = json!({ "profiles": [] });
let dto: ConfigureProfilesRequestDto = serde_json::from_value(raw).unwrap();
let input: ConfigureProfilesInput = dto.into();
assert!(input.profiles.is_empty());
}
#[test]
fn first_run_state_dto_serialises_camelcase() {
let out = FirstRunStateOutput {
is_first_run: true,
reference_profiles: vec![profile(1, "Claude", "claude")],
};
let dto: FirstRunStateDto = out.into();
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["isFirstRun"], true);
assert!(v.get("is_first_run").is_none(), "no snake_case leak");
assert_eq!(v["referenceProfiles"][0]["command"], "claude");
}
#[test]
fn parse_profile_id_accepts_uuid_and_rejects_garbage() {
let id = Uuid::from_u128(7);
assert_eq!(
parse_profile_id(&id.to_string()).unwrap(),
ProfileId::from_uuid(id)
);
let err = parse_profile_id("not-a-uuid").expect_err("garbage rejected");
assert_eq!(err.code, "INVALID");
}
#[test]
fn parse_delete_profile_builds_input() {
let id = Uuid::from_u128(9);
let input = parse_delete_profile(&id.to_string()).unwrap();
assert_eq!(input.id, ProfileId::from_uuid(id));
}

View File

@ -0,0 +1,275 @@
//! L7 tests for template & sync DTO (de)serialisation contract: camelCase on
//! the wire, `TemplateDto`/`TemplateListDto` shapes, `AgentDriftDto`/
//! `AgentDriftListDto`, `SyncResultDto`, request DTO deserialisation, and
//! `parse_template_id` error behaviour. No Tauri runtime required.
use app_tauri_lib::dto::{
parse_template_id, AgentDriftDto, AgentDriftListDto, CreateAgentFromTemplateRequestDto,
CreateTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto,
UpdateTemplateRequestDto,
};
use application::{
AgentDrift, CreateTemplateOutput, DetectAgentDriftOutput, ListTemplatesOutput,
SyncAgentWithTemplateOutput, UpdateTemplateOutput,
};
use domain::ids::{AgentId, ProfileId, TemplateId};
use domain::markdown::MarkdownDoc;
use domain::template::{AgentTemplate, TemplateVersion};
use serde_json::json;
use uuid::Uuid;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn make_template(template_uuid: u128, profile_uuid: u128) -> AgentTemplate {
AgentTemplate::new(
TemplateId::from_uuid(Uuid::from_u128(template_uuid)),
"My Template",
MarkdownDoc::new("# Hello".to_owned()),
ProfileId::from_uuid(Uuid::from_u128(profile_uuid)),
)
.expect("valid template")
}
// ---------------------------------------------------------------------------
// TemplateDto serialisation
// ---------------------------------------------------------------------------
#[test]
fn template_dto_serialises_camelcase() {
let tmpl = make_template(1, 2);
let dto = TemplateDto(tmpl.clone());
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["id"], tmpl.id.to_string());
assert_eq!(v["name"], "My Template");
// contentMd is the camelCase field on AgentTemplate; MarkdownDoc is transparent → plain string
assert_eq!(v["contentMd"], "# Hello");
// version is a transparent number
assert_eq!(v["version"], 1u64);
assert_eq!(
v["defaultProfileId"],
ProfileId::from_uuid(Uuid::from_u128(2)).to_string()
);
// no snake_case leak
assert!(v.get("content_md").is_none(), "no snake_case leak for contentMd");
assert!(v.get("default_profile_id").is_none(), "no snake_case leak for defaultProfileId");
}
#[test]
fn template_dto_version_is_number() {
let tmpl = make_template(3, 4);
let dto = TemplateDto(tmpl);
let v = serde_json::to_value(&dto).unwrap();
assert!(v["version"].is_number(), "version should be a JSON number");
assert_eq!(v["version"].as_u64().unwrap(), 1);
}
// ---------------------------------------------------------------------------
// TemplateListDto
// ---------------------------------------------------------------------------
#[test]
fn template_list_dto_is_transparent_array() {
let out = ListTemplatesOutput {
templates: vec![make_template(1, 2), make_template(3, 4)],
};
let dto = TemplateListDto::from(out);
let v = serde_json::to_value(&dto).unwrap();
let arr = v.as_array().expect("transparent array");
assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["name"], "My Template");
assert_eq!(arr[1]["name"], "My Template");
}
#[test]
fn template_list_dto_empty() {
let out = ListTemplatesOutput { templates: vec![] };
let dto = TemplateListDto::from(out);
let v = serde_json::to_value(&dto).unwrap();
assert!(v.as_array().unwrap().is_empty());
}
// ---------------------------------------------------------------------------
// From<CreateTemplateOutput> / From<UpdateTemplateOutput>
// ---------------------------------------------------------------------------
#[test]
fn create_template_output_maps_to_template_dto() {
let tmpl = make_template(5, 6);
let out = CreateTemplateOutput { template: tmpl.clone() };
let dto = TemplateDto::from(out);
assert_eq!(dto.0.id, tmpl.id);
}
#[test]
fn update_template_output_maps_to_template_dto() {
let tmpl = make_template(7, 8);
let bumped = tmpl.with_updated_content(MarkdownDoc::new("# Updated".to_owned()));
let out = UpdateTemplateOutput { template: bumped.clone() };
let dto = TemplateDto::from(out);
assert_eq!(dto.0.version, TemplateVersion(2));
assert_eq!(dto.0.id, bumped.id);
}
// ---------------------------------------------------------------------------
// AgentDriftDto / AgentDriftListDto serialisation
// ---------------------------------------------------------------------------
#[test]
fn agent_drift_dto_serialises_camelcase() {
let agent_id = AgentId::from_uuid(Uuid::from_u128(10));
let drift = AgentDrift {
agent_id,
from: TemplateVersion(1),
to: TemplateVersion(3),
};
let dto = AgentDriftDto::from(drift);
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["agentId"], agent_id.to_string());
assert_eq!(v["from"], 1u64);
assert_eq!(v["to"], 3u64);
// no snake_case leak
assert!(v.get("agent_id").is_none(), "no snake_case leak for agentId");
}
#[test]
fn agent_drift_list_dto_is_transparent_array() {
let agent_id = AgentId::from_uuid(Uuid::from_u128(11));
let out = DetectAgentDriftOutput {
drifts: vec![AgentDrift {
agent_id,
from: TemplateVersion(2),
to: TemplateVersion(5),
}],
};
let dto = AgentDriftListDto::from(out);
let v = serde_json::to_value(&dto).unwrap();
let arr = v.as_array().expect("transparent array");
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["from"], 2u64);
assert_eq!(arr[0]["to"], 5u64);
}
#[test]
fn agent_drift_list_dto_empty() {
let out = DetectAgentDriftOutput { drifts: vec![] };
let dto = AgentDriftListDto::from(out);
let v = serde_json::to_value(&dto).unwrap();
assert!(v.as_array().unwrap().is_empty());
}
// ---------------------------------------------------------------------------
// SyncResultDto
// ---------------------------------------------------------------------------
#[test]
fn sync_result_dto_synced_with_version() {
let out = SyncAgentWithTemplateOutput {
synced: true,
version: Some(TemplateVersion(4)),
};
let dto = SyncResultDto::from(out);
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["synced"], true);
assert_eq!(v["version"], 4u64);
}
#[test]
fn sync_result_dto_not_synced_version_null() {
let out = SyncAgentWithTemplateOutput {
synced: false,
version: None,
};
let dto = SyncResultDto::from(out);
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["synced"], false);
assert!(v["version"].is_null(), "version should be null when None");
}
// ---------------------------------------------------------------------------
// Request DTO deserialisation
// ---------------------------------------------------------------------------
#[test]
fn create_template_request_deserialises_camelcase() {
let profile_id = Uuid::from_u128(20).to_string();
let raw = json!({
"name": "Backend Template",
"content": "# My context",
"defaultProfileId": profile_id
});
let dto: CreateTemplateRequestDto = serde_json::from_value(raw).unwrap();
assert_eq!(dto.name, "Backend Template");
assert_eq!(dto.content, "# My context");
assert_eq!(dto.default_profile_id, profile_id);
}
#[test]
fn update_template_request_deserialises_camelcase() {
let template_id = Uuid::from_u128(30).to_string();
let raw = json!({
"templateId": template_id,
"content": "# Updated content"
});
let dto: UpdateTemplateRequestDto = serde_json::from_value(raw).unwrap();
assert_eq!(dto.template_id, template_id);
assert_eq!(dto.content, "# Updated content");
}
#[test]
fn create_agent_from_template_request_deserialises_camelcase() {
let project_id = Uuid::from_u128(40).to_string();
let template_id = Uuid::from_u128(41).to_string();
let raw = json!({
"projectId": project_id,
"templateId": template_id,
"name": "My Agent",
"synchronized": true
});
let dto: CreateAgentFromTemplateRequestDto = serde_json::from_value(raw).unwrap();
assert_eq!(dto.project_id, project_id);
assert_eq!(dto.template_id, template_id);
assert_eq!(dto.name.as_deref(), Some("My Agent"));
assert!(dto.synchronized);
}
#[test]
fn create_agent_from_template_request_name_defaults_to_none() {
let raw = json!({
"projectId": Uuid::nil().to_string(),
"templateId": Uuid::nil().to_string(),
"synchronized": false
});
let dto: CreateAgentFromTemplateRequestDto = serde_json::from_value(raw).unwrap();
assert!(dto.name.is_none());
}
// ---------------------------------------------------------------------------
// parse_template_id
// ---------------------------------------------------------------------------
#[test]
fn parse_template_id_accepts_uuid() {
let id = Uuid::from_u128(99);
assert_eq!(
parse_template_id(&id.to_string()).unwrap(),
TemplateId::from_uuid(id)
);
}
#[test]
fn parse_template_id_rejects_garbage() {
let err = parse_template_id("INVALID").expect_err("garbage rejected");
assert_eq!(err.code, "INVALID");
}
#[test]
fn parse_template_id_rejects_empty() {
let err = parse_template_id("").expect_err("empty string rejected");
assert_eq!(err.code, "INVALID");
}

View File

@ -0,0 +1,28 @@
//! L10 DTO tests: `move_tab_to_new_window` result mapping + tab-id parsing.
use app_tauri_lib::dto::{parse_tab_id, MoveTabResultDto};
use application::MoveTabToNewWindowOutput;
use domain::ids::WindowId;
use domain::layout::Workspace;
use uuid::Uuid;
#[test]
fn move_tab_result_serializes_new_window_id_camel_case() {
let out = MoveTabToNewWindowOutput {
new_window_id: WindowId::from_uuid(Uuid::from_u128(42)),
workspace: Workspace::default(),
};
let dto = MoveTabResultDto::from(out);
let json = serde_json::to_string(&dto).unwrap();
assert!(json.contains("\"newWindowId\""), "json was {json}");
assert!(!json.contains("new_window_id"), "no snake_case leak: {json}");
}
#[test]
fn parse_tab_id_accepts_uuid_and_rejects_garbage() {
let uuid = Uuid::from_u128(7).to_string();
assert!(parse_tab_id(&uuid).is_ok());
let err = parse_tab_id("not-a-uuid").unwrap_err();
assert_eq!(err.code, "INVALID");
}

View File

@ -0,0 +1,90 @@
//! L1 tests for [`PtyBridge`] — the PTY↔Channel registry — exercised with a
//! real [`tauri::ipc::Channel`] built from a capturing closure (no Tauri runtime
//! and no real PTY needed).
use std::sync::{Arc, Mutex};
use tauri::ipc::{Channel, InvokeResponseBody};
use app_tauri_lib::pty::PtyBridge;
use domain::ids::SessionId;
use uuid::Uuid;
/// Builds a `Channel<Vec<u8>>` whose sent chunks are recorded into `sink`.
///
/// `Vec<u8>` is `Serialize`, so chunks arrive as a JSON array string in an
/// `InvokeResponseBody::Json`; we parse them back to bytes for assertions.
fn capturing_channel(sink: Arc<Mutex<Vec<Vec<u8>>>>) -> Channel<Vec<u8>> {
Channel::new(move |body: InvokeResponseBody| {
let bytes: Vec<u8> = match body {
InvokeResponseBody::Json(s) => serde_json::from_str(&s).unwrap(),
InvokeResponseBody::Raw(b) => b,
};
sink.lock().unwrap().push(bytes);
Ok(())
})
}
fn sid() -> SessionId {
SessionId::from_uuid(Uuid::new_v4())
}
#[test]
fn register_increases_active_sessions() {
let bridge = PtyBridge::new();
assert_eq!(bridge.active_sessions(), 0);
let sink = Arc::new(Mutex::new(Vec::new()));
bridge.register(sid(), capturing_channel(sink));
assert_eq!(bridge.active_sessions(), 1);
}
#[test]
fn send_output_delivers_bytes_to_registered_channel() {
let bridge = PtyBridge::new();
let session = sid();
let sink = Arc::new(Mutex::new(Vec::new()));
bridge.register(session, capturing_channel(Arc::clone(&sink)));
let delivered = bridge.send_output(&session, vec![104, 105]);
assert!(delivered, "send_output should return true for a live session");
let captured = sink.lock().unwrap();
assert_eq!(captured.as_slice(), &[vec![104, 105]]);
}
#[test]
fn send_output_to_unknown_session_returns_false() {
let bridge = PtyBridge::new();
assert!(!bridge.send_output(&sid(), vec![0]));
}
#[test]
fn unregister_removes_session_and_stops_delivery() {
let bridge = PtyBridge::new();
let session = sid();
let sink = Arc::new(Mutex::new(Vec::new()));
bridge.register(session, capturing_channel(Arc::clone(&sink)));
assert_eq!(bridge.active_sessions(), 1);
bridge.unregister(&session);
assert_eq!(bridge.active_sessions(), 0);
assert!(!bridge.send_output(&session, vec![1]));
assert!(sink.lock().unwrap().is_empty());
}
#[test]
fn register_same_session_twice_replaces_channel() {
let bridge = PtyBridge::new();
let session = sid();
let first = Arc::new(Mutex::new(Vec::new()));
let second = Arc::new(Mutex::new(Vec::new()));
bridge.register(session, capturing_channel(Arc::clone(&first)));
bridge.register(session, capturing_channel(Arc::clone(&second)));
assert_eq!(bridge.active_sessions(), 1, "same id is replaced, not added");
bridge.send_output(&session, vec![9]);
assert!(first.lock().unwrap().is_empty(), "old channel no longer used");
assert_eq!(second.lock().unwrap().as_slice(), &[vec![9]]);
}