Sprint « Modeles locaux », couche backend (domain/application/infra/app-tauri). #35 — Serveur de modèle local intégré (llama.cpp) : - domain: model_server.rs (agrégat + statut), ports ModelServerProbe / ManagedProcess / ModelServerRuntime / ModelServerRegistry, événements model_server_status_changed et agent_launch_failed. - application: use case EnsureLocalModelServer branché sur LaunchAgent. - infrastructure: adapters HttpOpenAiCompatibleProbe, LlamaCppRuntime, LocalManagedProcess, FsModelServerRegistry. - app-tauri: DTO plat LocalModelServerConfigDto, commandes list/save/delete_model_server avec garde model_server_in_use, wiring. #36 — Profils OpenCode locaux multiples : - domain: VO LocalModelServerId, OpenCodeConfig.local_model_server_id. - application: use case CloneOpenCodeProfileFromSeed. - app-tauri: commande clone_opencode_profile_from_seed, DTO/wiring. Tests verts (exécution réelle) : domain+application OK, app-tauri dto_model_servers 3/3 et dto_profiles 10/10, infra model_server 2/2, application model_server+profile_usecases 19/19, cargo build workspace Finished. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
177 lines
5.9 KiB
Rust
177 lines
5.9 KiB
Rust
//! 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, CloneOpenCodeProfileFromSeedRequestDto,
|
|
ConfigureProfilesRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto,
|
|
FirstRunStateDto, ProfileListDto, SaveProfileRequestDto,
|
|
};
|
|
use application::{
|
|
CloneOpenCodeProfileFromSeedInput, ConfigureProfilesInput, DetectProfilesInput,
|
|
DetectProfilesOutput, FirstRunStateOutput, ProfileAvailability, SaveProfileInput,
|
|
};
|
|
use domain::ids::{LocalModelServerId, ProfileId};
|
|
use domain::profile::{AgentProfile, ContextInjection, OpenCodeConfig};
|
|
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}",
|
|
None,
|
|
)
|
|
.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 clone_opencode_profile_from_seed_request_deserialises_camelcase_config() {
|
|
let server_id = LocalModelServerId::from_uuid(Uuid::from_u128(35));
|
|
let raw = json!({
|
|
"name": "Local Qwen",
|
|
"opencode": {
|
|
"baseURL": "http://localhost:8081/v1",
|
|
"model": "qwen3-coder-70b",
|
|
"reasoning": false,
|
|
"attachment": true,
|
|
"localModelServerId": server_id.to_string()
|
|
}
|
|
});
|
|
|
|
let dto: CloneOpenCodeProfileFromSeedRequestDto = serde_json::from_value(raw).unwrap();
|
|
let input: CloneOpenCodeProfileFromSeedInput = dto.into();
|
|
assert_eq!(input.name.as_deref(), Some("Local Qwen"));
|
|
let opencode = input.opencode.expect("config override");
|
|
assert_eq!(opencode.base_url, "http://localhost:8081/v1");
|
|
assert_eq!(opencode.api_key, None);
|
|
assert_eq!(opencode.model, "qwen3-coder-70b");
|
|
assert_eq!(opencode.reasoning, Some(false));
|
|
assert_eq!(opencode.attachment, Some(true));
|
|
assert_eq!(opencode.local_model_server_id, Some(server_id));
|
|
}
|
|
|
|
#[test]
|
|
fn opencode_config_dto_omits_local_model_server_id_when_none() {
|
|
let config = OpenCodeConfig::new(
|
|
"http://localhost:8080/v1",
|
|
None,
|
|
"qwen3-coder-30b",
|
|
None,
|
|
None,
|
|
)
|
|
.unwrap();
|
|
|
|
let value = serde_json::to_value(&config).unwrap();
|
|
assert!(value.get("localModelServerId").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));
|
|
}
|