- Tauri : commande list_resumable_agents (projectId) + ResumableAgentListDto
{ resumable } / ResumableAgentDto (camelCase, conversationId omis si None),
câblage state.rs (réutilise stores + ProfileStore, aucun nouveau port).
- Front : gateway listResumableAgents, ResumeProjectPanel monté à l'ouverture
de projet (opt-in, FR) : Reprendre (launch_agent nodeId+conversationId),
Nouvelle conversation (setCellConversation(null) puis launch), Ignorer,
Tout reprendre/ignorer. resumeSupported=false ⇒ « relance à neuf ».
- doc : §15.2 coquille agents→resumable (alignée use case/back/front).
Tests : app-tauri dto 9/9 ; vitest 324 (+10) ; workspace Rust 0 échec. 0 régression.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
193 lines
7.3 KiB
Rust
193 lines
7.3 KiB
Rust
//! B2 tests for the `list_resumable_agents` DTO contract (ARCHITECTURE §15.2):
|
|
//! - `From<ResumableAgent> for ResumableAgentDto` maps every field, surfaces
|
|
//! `conversationId` only when present (omitted via `skip_serializing_if` when
|
|
//! `None`), and serialises ids as strings + camelCase keys.
|
|
//! - `From<ListResumableAgentsOutput> for ResumableAgentListDto` preserves the
|
|
//! `resumable` list and its order.
|
|
//! - Full + minimal `ResumableAgentDto` serde round-trips on the wire shape.
|
|
|
|
use app_tauri_lib::dto::{ResumableAgentDto, ResumableAgentListDto};
|
|
use application::{ListResumableAgentsOutput, ResumableAgent};
|
|
use domain::ids::{AgentId, NodeId};
|
|
use uuid::Uuid;
|
|
|
|
/// Helper: a resumable agent with a conversation id (resume-capable).
|
|
fn full(agent_uuid: u128, node_uuid: u128) -> ResumableAgent {
|
|
ResumableAgent {
|
|
agent_id: AgentId::from_uuid(Uuid::from_u128(agent_uuid)),
|
|
name: "Architect".to_owned(),
|
|
node_id: NodeId::from_uuid(Uuid::from_u128(node_uuid)),
|
|
conversation_id: Some("conv-123".to_owned()),
|
|
was_running: true,
|
|
resume_supported: true,
|
|
}
|
|
}
|
|
|
|
/// Helper: a minimal resumable agent without a conversation id.
|
|
fn minimal(agent_uuid: u128, node_uuid: u128) -> ResumableAgent {
|
|
ResumableAgent {
|
|
agent_id: AgentId::from_uuid(Uuid::from_u128(agent_uuid)),
|
|
name: "Scratch".to_owned(),
|
|
node_id: NodeId::from_uuid(Uuid::from_u128(node_uuid)),
|
|
conversation_id: None,
|
|
was_running: false,
|
|
resume_supported: false,
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// From<ResumableAgent> for ResumableAgentDto — field mapping
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn from_resumable_agent_maps_every_field() {
|
|
let r = full(1, 2);
|
|
let dto = ResumableAgentDto::from(r.clone());
|
|
|
|
assert_eq!(dto.agent_id, r.agent_id.to_string());
|
|
assert_eq!(dto.name, "Architect");
|
|
assert_eq!(dto.node_id, r.node_id.to_string());
|
|
assert_eq!(dto.conversation_id.as_deref(), Some("conv-123"));
|
|
assert!(dto.was_running);
|
|
assert!(dto.resume_supported);
|
|
}
|
|
|
|
#[test]
|
|
fn from_resumable_agent_ids_are_strings() {
|
|
let r = full(7, 8);
|
|
let dto = ResumableAgentDto::from(r.clone());
|
|
// The ids cross the wire as UUID strings (not raw bytes / numbers).
|
|
assert_eq!(dto.agent_id, Uuid::from_u128(7).to_string());
|
|
assert_eq!(dto.node_id, Uuid::from_u128(8).to_string());
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// camelCase wire shape + conversationId omission
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn full_dto_serialises_camelcase_with_conversation() {
|
|
let dto = ResumableAgentDto::from(full(1, 2));
|
|
let v = serde_json::to_value(&dto).unwrap();
|
|
|
|
assert_eq!(v["agentId"], Uuid::from_u128(1).to_string());
|
|
assert_eq!(v["name"], "Architect");
|
|
assert_eq!(v["nodeId"], Uuid::from_u128(2).to_string());
|
|
assert_eq!(v["conversationId"], "conv-123");
|
|
assert_eq!(v["wasRunning"], true);
|
|
assert_eq!(v["resumeSupported"], true);
|
|
|
|
// No snake_case leak.
|
|
assert!(v.get("agent_id").is_none(), "no snake_case leak: {v}");
|
|
assert!(v.get("node_id").is_none());
|
|
assert!(v.get("conversation_id").is_none());
|
|
assert!(v.get("was_running").is_none());
|
|
assert!(v.get("resume_supported").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn minimal_dto_omits_conversation_id_when_none() {
|
|
let dto = ResumableAgentDto::from(minimal(3, 4));
|
|
let v = serde_json::to_value(&dto).unwrap();
|
|
|
|
// conversationId must be OMITTED (absent, not null) when None.
|
|
assert!(
|
|
v.get("conversationId").is_none(),
|
|
"None conversation ⇒ conversationId omitted, got: {v}"
|
|
);
|
|
// Required flags are still present.
|
|
assert_eq!(v["wasRunning"], false);
|
|
assert_eq!(v["resumeSupported"], false);
|
|
assert_eq!(v["name"], "Scratch");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Round-trip serde (Serialize → Deserialize) on the camelCase shape
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// A `Deserialize` twin matching the wire shape, used to prove the serialised
|
|
/// JSON is exactly the camelCase contract the frontend `ResumableAgent` mirror
|
|
/// consumes (`ResumableAgentDto` is serialise-only on the prod side).
|
|
#[derive(serde::Deserialize)]
|
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
|
struct WireResumableAgent {
|
|
agent_id: String,
|
|
name: String,
|
|
node_id: String,
|
|
#[serde(default)]
|
|
conversation_id: Option<String>,
|
|
was_running: bool,
|
|
resume_supported: bool,
|
|
}
|
|
|
|
#[test]
|
|
fn full_dto_round_trips_camelcase() {
|
|
let dto = ResumableAgentDto::from(full(1, 2));
|
|
let json = serde_json::to_string(&dto).unwrap();
|
|
let back: WireResumableAgent = serde_json::from_str(&json).unwrap();
|
|
|
|
assert_eq!(back.agent_id, Uuid::from_u128(1).to_string());
|
|
assert_eq!(back.name, "Architect");
|
|
assert_eq!(back.node_id, Uuid::from_u128(2).to_string());
|
|
assert_eq!(back.conversation_id.as_deref(), Some("conv-123"));
|
|
assert!(back.was_running);
|
|
assert!(back.resume_supported);
|
|
}
|
|
|
|
#[test]
|
|
fn minimal_dto_round_trips_camelcase_without_conversation() {
|
|
let dto = ResumableAgentDto::from(minimal(3, 4));
|
|
let json = serde_json::to_string(&dto).unwrap();
|
|
// The key is absent from the JSON entirely.
|
|
assert!(
|
|
!json.contains("conversationId"),
|
|
"minimal ⇒ no conversationId key, got: {json}"
|
|
);
|
|
let back: WireResumableAgent = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(back.conversation_id, None);
|
|
assert!(!back.was_running);
|
|
assert!(!back.resume_supported);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// From<ListResumableAgentsOutput> for ResumableAgentListDto — list + order
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn from_output_preserves_list_and_order() {
|
|
let out = ListResumableAgentsOutput {
|
|
resumable: vec![full(10, 11), minimal(20, 21), full(30, 31)],
|
|
};
|
|
let dto = ResumableAgentListDto::from(out);
|
|
|
|
assert_eq!(dto.resumable.len(), 3);
|
|
// Order preserved.
|
|
assert_eq!(dto.resumable[0].agent_id, Uuid::from_u128(10).to_string());
|
|
assert_eq!(dto.resumable[1].agent_id, Uuid::from_u128(20).to_string());
|
|
assert_eq!(dto.resumable[2].agent_id, Uuid::from_u128(30).to_string());
|
|
// Middle entry is the minimal one (no conversation).
|
|
assert!(dto.resumable[1].conversation_id.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn empty_output_serialises_empty_resumable_array() {
|
|
let dto = ResumableAgentListDto::from(ListResumableAgentsOutput::default());
|
|
let v = serde_json::to_value(&dto).unwrap();
|
|
assert_eq!(v["resumable"], serde_json::json!([]));
|
|
}
|
|
|
|
#[test]
|
|
fn list_dto_serialises_camelcase_wrapper() {
|
|
let out = ListResumableAgentsOutput {
|
|
resumable: vec![full(10, 11)],
|
|
};
|
|
let dto = ResumableAgentListDto::from(out);
|
|
let v = serde_json::to_value(&dto).unwrap();
|
|
|
|
// Wrapper key is `resumable`; nested entries are camelCase.
|
|
let arr = v["resumable"].as_array().expect("resumable is an array");
|
|
assert_eq!(arr.len(), 1);
|
|
assert_eq!(arr[0]["agentId"], Uuid::from_u128(10).to_string());
|
|
assert_eq!(arr[0]["nodeId"], Uuid::from_u128(11).to_string());
|
|
}
|