Files
IdeA/crates/app-tauri/tests/dto.rs
Blomios dcba76b871 feat(chat): livre la CLI custom de chat agent (#147) et corrige Cancel
Implémente la vue chat structurée par cellule agent (toggle TUI/CLI custom,
préférence persistée `preferred_view`, reattach live, composer + pièces
jointes) avec le socle backend AgentSession/ChatBridge (UserPrompt,
cancel_current_turn, routage interrupt_agent, commande cancel_agent_chat).

Corrige le bug bloquant relevé par QA : le bouton Cancel de
CustomAgentChatView interrompait tout le tour via closeAgentChat au lieu
de n'annuler que le tour courant via cancelAgentChat, ce qui tuait la
session contrairement au contrat produit validé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 11:59:39 +02:00

463 lines
15 KiB
Rust

//! 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, ReattachResultDto, ResizeTerminalRequestDto,
TerminalClosedDto, WriteTerminalRequestDto,
};
use app_tauri_lib::events::{DomainEventDto, DOMAIN_EVENT};
use application::{CloseTerminalOutput, LayoutOperation, LoadLayoutOutput, OpenTerminalInput};
use domain::{Direction, LayoutNode, LayoutTree, LeafCell, NodeId, PreferredView};
use application::{AppError, HealthInput};
use domain::events::DomainEvent;
use domain::ids::{AgentId, ProfileId, SessionId};
use domain::ProjectId;
use domain::TemplateId;
use domain::TemplateVersion;
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_agent_profile_changed() {
// LOT A0 : round-trip camelCase agentId/profileId du miroir DTO.
let aid = AgentId::from_uuid(Uuid::from_u128(1));
let pid = ProfileId::from_uuid(Uuid::from_u128(2));
let dto = DomainEventDto::from(&DomainEvent::AgentProfileChanged {
agent_id: aid,
profile_id: pid,
});
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(
v,
json!({
"type": "agentProfileChanged",
"agentId": aid.to_string(),
"profileId": pid.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]));
}
// ---------------------------------------------------------------------------
// M4 — orchestration source on `orchestratorRequestProcessed`
// ---------------------------------------------------------------------------
#[test]
fn orchestration_source_dto_serialises_lowercase() {
use app_tauri_lib::events::OrchestrationSourceDto;
use domain::events::OrchestrationSource;
// File → "file"; Mcp → "mcp" (camelCase rename on a unit enum = lowercase).
let file =
serde_json::to_value(OrchestrationSourceDto::from(OrchestrationSource::File)).unwrap();
assert_eq!(file, json!("file"));
let mcp = serde_json::to_value(OrchestrationSourceDto::from(OrchestrationSource::Mcp)).unwrap();
assert_eq!(mcp, json!("mcp"));
}
#[test]
fn domain_event_dto_maps_orchestrator_request_processed_with_file_source() {
use domain::events::OrchestrationSource;
let dto = DomainEventDto::from(&DomainEvent::OrchestratorRequestProcessed {
requester_id: "Main".into(),
action: "spawn_agent".into(),
ok: true,
source: OrchestrationSource::File,
});
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(
v,
json!({
"type": "orchestratorRequestProcessed",
"requesterId": "Main",
"action": "spawn_agent",
"ok": true,
"source": "file",
})
);
}
#[test]
fn domain_event_dto_maps_orchestrator_request_processed_with_mcp_source() {
use domain::events::OrchestrationSource;
let dto = DomainEventDto::from(&DomainEvent::OrchestratorRequestProcessed {
requester_id: "mcp".into(),
action: "idea_ask_agent".into(),
ok: false,
source: OrchestrationSource::Mcp,
});
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["type"], "orchestratorRequestProcessed");
assert_eq!(v["requesterId"], "mcp");
assert_eq!(v["action"], "idea_ask_agent");
assert_eq!(v["ok"], json!(false));
assert_eq!(v["source"], "mcp", "MCP door must badge as `mcp`");
}
// ---------------------------------------------------------------------------
// 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 reattach_result_dto_serialises_camel_case() {
let dto = ReattachResultDto {
session_id: "sess-1".into(),
scrollback: vec![104, 105],
};
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(
v,
json!({ "sessionId": "sess-1", "scrollback": [104, 105] })
);
}
#[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,
conversation_id: None,
engine_session_id: None,
agent_was_running: false,
preferred_view: domain::PreferredView::Tui,
});
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_set_cell_preferred_view_deserialises() {
let json = json!({
"type": "setCellPreferredView",
"target": nid(1).to_string(),
"preferredView": "chat",
});
let dto: LayoutOperationDto = serde_json::from_value(json).unwrap();
match dto.into_operation().unwrap() {
LayoutOperation::SetCellPreferredView {
target,
preferred_view,
} => {
assert_eq!(target, nid(1));
assert_eq!(preferred_view, PreferredView::Chat);
}
other => panic!("expected SetCellPreferredView, 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,
conversation_id: None,
engine_session_id: None,
agent_was_running: false,
preferred_view: domain::PreferredView::Tui,
})
.split(
nid(1),
Direction::Column,
LeafCell {
id: nid(2),
session: None,
agent: None,
conversation_id: None,
engine_session_id: None,
agent_was_running: false,
preferred_view: domain::PreferredView::Tui,
},
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(_)));
}