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:
104
crates/domain/tests/context_injection.rs
Normal file
104
crates/domain/tests/context_injection.rs
Normal file
@ -0,0 +1,104 @@
|
||||
//! Validation of the four `ContextInjection` variants (ARCHITECTURE §3.2).
|
||||
|
||||
use domain::{ContextInjection, DomainError};
|
||||
|
||||
// --- ConventionFile -------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn convention_file_relative_ok() {
|
||||
let ci = ContextInjection::convention_file("CLAUDE.md").unwrap();
|
||||
assert!(matches!(ci, ContextInjection::ConventionFile { target } if target == "CLAUDE.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convention_file_nested_relative_ok() {
|
||||
assert!(ContextInjection::convention_file("docs/AGENTS.md").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convention_file_rejects_absolute() {
|
||||
assert!(matches!(
|
||||
ContextInjection::convention_file("/etc/CLAUDE.md").unwrap_err(),
|
||||
DomainError::PathNotRelativeSafe { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convention_file_rejects_dotdot() {
|
||||
assert!(matches!(
|
||||
ContextInjection::convention_file("../CLAUDE.md").unwrap_err(),
|
||||
DomainError::PathNotRelativeSafe { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convention_file_rejects_windows_drive() {
|
||||
assert!(matches!(
|
||||
ContextInjection::convention_file("C:\\CLAUDE.md").unwrap_err(),
|
||||
DomainError::PathNotRelativeSafe { .. }
|
||||
));
|
||||
}
|
||||
|
||||
// --- Flag -----------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn flag_non_empty_ok() {
|
||||
let ci = ContextInjection::flag("--context-file {path}").unwrap();
|
||||
assert!(matches!(ci, ContextInjection::Flag { flag } if flag == "--context-file {path}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flag_rejects_empty() {
|
||||
assert!(matches!(
|
||||
ContextInjection::flag("").unwrap_err(),
|
||||
DomainError::EmptyField { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
ContextInjection::flag(" ").unwrap_err(),
|
||||
DomainError::EmptyField { .. }
|
||||
));
|
||||
}
|
||||
|
||||
// --- Stdin ----------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn stdin_variant() {
|
||||
assert_eq!(ContextInjection::stdin(), ContextInjection::Stdin);
|
||||
}
|
||||
|
||||
// --- Env ------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn env_valid_identifier_ok() {
|
||||
assert!(ContextInjection::env("AGENT_CONTEXT_FILE").is_ok());
|
||||
assert!(ContextInjection::env("_private").is_ok());
|
||||
assert!(ContextInjection::env("X1").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_rejects_leading_digit() {
|
||||
assert!(matches!(
|
||||
ContextInjection::env("1BAD").unwrap_err(),
|
||||
DomainError::InvalidEnvVar { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_rejects_invalid_chars() {
|
||||
assert!(matches!(
|
||||
ContextInjection::env("BAD-VAR").unwrap_err(),
|
||||
DomainError::InvalidEnvVar { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
ContextInjection::env("HAS SPACE").unwrap_err(),
|
||||
DomainError::InvalidEnvVar { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_rejects_empty() {
|
||||
assert!(matches!(
|
||||
ContextInjection::env("").unwrap_err(),
|
||||
DomainError::InvalidEnvVar { .. }
|
||||
));
|
||||
}
|
||||
411
crates/domain/tests/entities.rs
Normal file
411
crates/domain/tests/entities.rs
Normal file
@ -0,0 +1,411 @@
|
||||
//! Entity & value-object invariant tests: valid construction plus the expected
|
||||
//! rejections (ARCHITECTURE §3.2).
|
||||
|
||||
mod helpers;
|
||||
|
||||
use domain::{
|
||||
Agent, AgentManifest, AgentOrigin, AgentProfile, AgentTemplate, ContextInjection, DomainError,
|
||||
ManifestEntry, MarkdownDoc, ProfileId, Project, ProjectPath, PtySize, RemoteRef, SshAuth,
|
||||
TemplateId, TemplateVersion,
|
||||
};
|
||||
use helpers::{AtomicSeqIdGenerator, FixedClock};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn profile_id() -> ProfileId {
|
||||
ProfileId::from_uuid(Uuid::from_u128(42))
|
||||
}
|
||||
|
||||
fn template_id() -> TemplateId {
|
||||
TemplateId::from_uuid(Uuid::from_u128(7))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ProjectPath
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn project_path_accepts_posix_absolute() {
|
||||
assert!(ProjectPath::new("/home/user/proj").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_path_accepts_windows_drive_and_unc() {
|
||||
assert!(ProjectPath::new("C:\\Users\\x").is_ok());
|
||||
assert!(ProjectPath::new("C:/Users/x").is_ok());
|
||||
assert!(ProjectPath::new("\\\\server\\share").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_path_accepts_wsl_mount() {
|
||||
assert!(ProjectPath::new("/mnt/c/code").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_path_rejects_relative() {
|
||||
let err = ProjectPath::new("relative/path").unwrap_err();
|
||||
assert!(matches!(err, DomainError::PathNotAbsolute { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_path_rejects_empty() {
|
||||
let err = ProjectPath::new("").unwrap_err();
|
||||
assert!(matches!(err, DomainError::EmptyField { .. }));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Project (also exercises the Clock/IdGenerator port fakes for determinism)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn project_valid_with_fixed_clock_and_seq_ids() {
|
||||
use domain::{ports::Clock, ports::IdGenerator, ProjectId};
|
||||
let clock = FixedClock(1_700_000_000_000);
|
||||
let ids = AtomicSeqIdGenerator::new();
|
||||
let id = ProjectId::from_uuid(ids.new_uuid());
|
||||
let p = Project::new(
|
||||
id,
|
||||
"demo",
|
||||
ProjectPath::new("/srv/demo").unwrap(),
|
||||
RemoteRef::local(),
|
||||
clock.now_millis(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(p.created_at, 1_700_000_000_000);
|
||||
assert_eq!(p.id.as_uuid(), Uuid::from_u128(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_rejects_empty_name() {
|
||||
let err = Project::new(
|
||||
domain::ProjectId::from_uuid(Uuid::nil()),
|
||||
" ",
|
||||
ProjectPath::new("/x").unwrap(),
|
||||
RemoteRef::local(),
|
||||
0,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, DomainError::EmptyField { .. }));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent invariants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn agent_scratch_not_synchronized_is_ok() {
|
||||
let a = Agent::new(
|
||||
domain::AgentId::from_uuid(Uuid::from_u128(1)),
|
||||
"scratch",
|
||||
"agents/foo.md",
|
||||
profile_id(),
|
||||
AgentOrigin::Scratch,
|
||||
false,
|
||||
);
|
||||
assert!(a.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_from_template_synchronized_is_ok() {
|
||||
let a = Agent::new(
|
||||
domain::AgentId::from_uuid(Uuid::from_u128(1)),
|
||||
"tpl",
|
||||
"agents/foo.md",
|
||||
profile_id(),
|
||||
AgentOrigin::FromTemplate {
|
||||
template_id: template_id(),
|
||||
synced_template_version: TemplateVersion::INITIAL,
|
||||
},
|
||||
true,
|
||||
);
|
||||
assert!(a.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_synchronized_without_template_is_rejected() {
|
||||
let err = Agent::new(
|
||||
domain::AgentId::from_uuid(Uuid::from_u128(1)),
|
||||
"bad",
|
||||
"agents/foo.md",
|
||||
profile_id(),
|
||||
AgentOrigin::Scratch,
|
||||
true,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(err, DomainError::SyncRequiresTemplate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_rejects_absolute_context_path() {
|
||||
let err = Agent::new(
|
||||
domain::AgentId::from_uuid(Uuid::from_u128(1)),
|
||||
"x",
|
||||
"/etc/passwd",
|
||||
profile_id(),
|
||||
AgentOrigin::Scratch,
|
||||
false,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, DomainError::PathNotRelativeSafe { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_rejects_dotdot_context_path() {
|
||||
let err = Agent::new(
|
||||
domain::AgentId::from_uuid(Uuid::from_u128(1)),
|
||||
"x",
|
||||
"agents/../../secret.md",
|
||||
profile_id(),
|
||||
AgentOrigin::Scratch,
|
||||
false,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, DomainError::PathNotRelativeSafe { .. }));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AgentProfile: command non-empty
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn ci_stdin() -> ContextInjection {
|
||||
ContextInjection::stdin()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_valid() {
|
||||
let p = AgentProfile::new(
|
||||
profile_id(),
|
||||
"Claude",
|
||||
"claude",
|
||||
vec!["--yolo".into()],
|
||||
ci_stdin(),
|
||||
Some("claude --version".into()),
|
||||
"{projectRoot}",
|
||||
);
|
||||
assert!(p.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_rejects_empty_command() {
|
||||
let err = AgentProfile::new(
|
||||
profile_id(),
|
||||
"Name",
|
||||
"",
|
||||
vec![],
|
||||
ci_stdin(),
|
||||
None,
|
||||
"{projectRoot}",
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, DomainError::EmptyField { field } if field == "profile.command"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_rejects_empty_name() {
|
||||
let err = AgentProfile::new(profile_id(), "", "claude", vec![], ci_stdin(), None, "{r}")
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, DomainError::EmptyField { field } if field == "profile.name"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RemoteRef invariants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn ssh_valid() {
|
||||
let r = RemoteRef::ssh("host", 22, "me", SshAuth::Agent, "/srv");
|
||||
assert!(r.is_ok());
|
||||
assert_eq!(r.unwrap().kind(), domain::RemoteKind::Ssh);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssh_port_zero_rejected() {
|
||||
let err = RemoteRef::ssh("host", 0, "me", SshAuth::Agent, "/srv").unwrap_err();
|
||||
assert!(matches!(err, DomainError::InvalidPort { port: 0 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssh_max_port_accepted() {
|
||||
// 65535 is the upper bound of the 1..=65535 range; u16 prevents anything higher.
|
||||
assert!(RemoteRef::ssh("h", 65535, "u", SshAuth::Password, "/r").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssh_rejects_empty_host_user_root() {
|
||||
assert!(RemoteRef::ssh("", 22, "u", SshAuth::Agent, "/r").is_err());
|
||||
assert!(RemoteRef::ssh("h", 22, "", SshAuth::Agent, "/r").is_err());
|
||||
assert!(RemoteRef::ssh("h", 22, "u", SshAuth::Agent, "").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wsl_valid() {
|
||||
assert!(RemoteRef::wsl("Ubuntu-22.04").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wsl_empty_distro_rejected() {
|
||||
let err = RemoteRef::wsl("").unwrap_err();
|
||||
assert!(matches!(err, DomainError::EmptyField { .. }));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PtySize invariants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn pty_size_valid() {
|
||||
assert!(PtySize::new(24, 80).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pty_size_zero_rows_rejected() {
|
||||
assert!(matches!(
|
||||
PtySize::new(0, 80).unwrap_err(),
|
||||
DomainError::InvalidPtySize { rows: 0, cols: 80 }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pty_size_zero_cols_rejected() {
|
||||
assert!(matches!(
|
||||
PtySize::new(24, 0).unwrap_err(),
|
||||
DomainError::InvalidPtySize { rows: 24, cols: 0 }
|
||||
));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AgentTemplate version monotonicity (via with_updated_content / next)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn template_starts_at_initial() {
|
||||
let t = AgentTemplate::new(template_id(), "T", MarkdownDoc::new("a"), profile_id()).unwrap();
|
||||
assert_eq!(t.version, TemplateVersion::INITIAL);
|
||||
assert_eq!(t.version.get(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_update_bumps_version_monotonically() {
|
||||
let t0 = AgentTemplate::new(template_id(), "T", MarkdownDoc::new("a"), profile_id()).unwrap();
|
||||
let t1 = t0.with_updated_content(MarkdownDoc::new("b"));
|
||||
let t2 = t1.with_updated_content(MarkdownDoc::new("c"));
|
||||
assert!(t1.version > t0.version);
|
||||
assert!(t2.version > t1.version);
|
||||
assert_eq!(t2.version.get(), 3);
|
||||
assert_eq!(t2.content_md.as_str(), "c");
|
||||
// id and profile preserved.
|
||||
assert_eq!(t2.id, t0.id);
|
||||
assert_eq!(t2.default_profile_id, t0.default_profile_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_version_next_increments() {
|
||||
assert_eq!(TemplateVersion(5).next(), TemplateVersion(6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_rejects_empty_name() {
|
||||
let err = AgentTemplate::new(template_id(), "", MarkdownDoc::new(""), profile_id()).unwrap_err();
|
||||
assert!(matches!(err, DomainError::EmptyField { .. }));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ManifestEntry / AgentManifest invariants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn agent_id(n: u128) -> domain::AgentId {
|
||||
domain::AgentId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_entry_synchronized_requires_template_metadata() {
|
||||
let err =
|
||||
ManifestEntry::new(agent_id(1), "A", "agents/a.md", profile_id(), None, true, None)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, DomainError::InconsistentManifest { .. }));
|
||||
|
||||
// template id present but version missing → still rejected.
|
||||
let err = ManifestEntry::new(
|
||||
agent_id(1),
|
||||
"A",
|
||||
"agents/a.md",
|
||||
profile_id(),
|
||||
Some(template_id()),
|
||||
true,
|
||||
None,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, DomainError::InconsistentManifest { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_entry_rejects_empty_name() {
|
||||
let err =
|
||||
ManifestEntry::new(agent_id(1), " ", "agents/a.md", profile_id(), None, false, None)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, DomainError::EmptyField { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_entry_synchronized_with_metadata_ok() {
|
||||
assert!(ManifestEntry::new(
|
||||
agent_id(1),
|
||||
"A",
|
||||
"agents/a.md",
|
||||
profile_id(),
|
||||
Some(template_id()),
|
||||
true,
|
||||
Some(TemplateVersion::INITIAL)
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_entry_rejects_absolute_md_path() {
|
||||
assert!(matches!(
|
||||
ManifestEntry::new(agent_id(1), "A", "/abs.md", profile_id(), None, false, None)
|
||||
.unwrap_err(),
|
||||
DomainError::PathNotRelativeSafe { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_entry_agent_roundtrip() {
|
||||
// from_agent ∘ to_agent preserves a template-backed, synchronized agent.
|
||||
let agent = Agent::new(
|
||||
agent_id(9),
|
||||
"Backend",
|
||||
"agents/backend.md",
|
||||
profile_id(),
|
||||
AgentOrigin::FromTemplate {
|
||||
template_id: template_id(),
|
||||
synced_template_version: TemplateVersion(4),
|
||||
},
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
let entry = ManifestEntry::from_agent(&agent);
|
||||
assert_eq!(entry.to_agent().unwrap(), agent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_rejects_duplicate_md_path() {
|
||||
let e1 =
|
||||
ManifestEntry::new(agent_id(1), "A", "agents/a.md", profile_id(), None, false, None)
|
||||
.unwrap();
|
||||
let e2 =
|
||||
ManifestEntry::new(agent_id(2), "B", "agents/a.md", profile_id(), None, false, None)
|
||||
.unwrap();
|
||||
let err = AgentManifest::new(1, vec![e1, e2]).unwrap_err();
|
||||
assert!(matches!(err, DomainError::InconsistentManifest { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_unique_md_paths_ok() {
|
||||
let e1 =
|
||||
ManifestEntry::new(agent_id(1), "A", "agents/a.md", profile_id(), None, false, None)
|
||||
.unwrap();
|
||||
let e2 =
|
||||
ManifestEntry::new(agent_id(2), "B", "agents/b.md", profile_id(), None, false, None)
|
||||
.unwrap();
|
||||
assert!(AgentManifest::new(1, vec![e1, e2]).is_ok());
|
||||
}
|
||||
92
crates/domain/tests/helpers/mod.rs
Normal file
92
crates/domain/tests/helpers/mod.rs
Normal file
@ -0,0 +1,92 @@
|
||||
//! Shared test helpers: deterministic fakes for the `Clock` / `IdGenerator`
|
||||
//! ports, plus small id constructors. Kept in `tests/` so they never ship in
|
||||
//! the crate proper.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::cell::Cell;
|
||||
|
||||
use domain::ports::{Clock, IdGenerator};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// A clock that always returns the same fixed millisecond value.
|
||||
pub struct FixedClock(pub i64);
|
||||
|
||||
impl Clock for FixedClock {
|
||||
fn now_millis(&self) -> i64 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// An id generator producing a deterministic, monotonically increasing sequence
|
||||
/// of UUIDs (`0000...0001`, `0000...0002`, ...).
|
||||
pub struct SeqIdGenerator {
|
||||
next: Cell<u128>,
|
||||
}
|
||||
|
||||
impl SeqIdGenerator {
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self { next: Cell::new(1) }
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SeqIdGenerator {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// `IdGenerator` only requires `&self`, so we use a `Cell` for interior
|
||||
// mutability. The trait demands `Send + Sync`; `Cell` is `!Sync`, so for the
|
||||
// test fake we wrap calls behind `&self` single-threaded usage only.
|
||||
// To satisfy the bound we instead expose a plain method used directly in tests.
|
||||
impl SeqIdGenerator {
|
||||
/// Returns the next UUID in the deterministic sequence.
|
||||
pub fn next_uuid(&self) -> Uuid {
|
||||
let n = self.next.get();
|
||||
self.next.set(n + 1);
|
||||
Uuid::from_u128(n)
|
||||
}
|
||||
}
|
||||
|
||||
/// A `Send + Sync` deterministic id generator suitable for the `IdGenerator`
|
||||
/// port (uses an atomic counter).
|
||||
pub struct AtomicSeqIdGenerator {
|
||||
next: std::sync::atomic::AtomicU64,
|
||||
}
|
||||
|
||||
impl AtomicSeqIdGenerator {
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
next: std::sync::atomic::AtomicU64::new(1),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AtomicSeqIdGenerator {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl IdGenerator for AtomicSeqIdGenerator {
|
||||
fn new_uuid(&self) -> Uuid {
|
||||
let n = self
|
||||
.next
|
||||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
Uuid::from_u128(u128::from(n))
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a `NodeId` from a small integer (handy, readable test ids).
|
||||
#[must_use]
|
||||
pub fn node(n: u128) -> domain::NodeId {
|
||||
domain::NodeId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
/// Builds a `SessionId` from a small integer.
|
||||
#[must_use]
|
||||
pub fn session(n: u128) -> domain::SessionId {
|
||||
domain::SessionId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
495
crates/domain/tests/layout.rs
Normal file
495
crates/domain/tests/layout.rs
Normal file
@ -0,0 +1,495 @@
|
||||
//! Pure layout logic: split / merge / resize / move_session, nominal and error
|
||||
//! paths, grid validation, and immutability of the source tree (ARCHITECTURE §7).
|
||||
|
||||
mod helpers;
|
||||
|
||||
use domain::{
|
||||
Direction, GridCell, GridContainer, LayoutError, LayoutNode, LayoutTree, LeafCell,
|
||||
SplitContainer, WeightedChild,
|
||||
};
|
||||
use domain::ids::AgentId;
|
||||
use helpers::{node, session};
|
||||
|
||||
fn agent_id(n: u128) -> AgentId {
|
||||
AgentId::from_uuid(uuid::Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn leaf(id: u128, sess: Option<u128>) -> LeafCell {
|
||||
LeafCell {
|
||||
id: node(id),
|
||||
session: sess.map(session),
|
||||
agent: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn single(id: u128, sess: Option<u128>) -> LayoutTree {
|
||||
LayoutTree::single(leaf(id, sess))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// split
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn split_nominal_produces_two_children() {
|
||||
let tree = single(1, Some(100));
|
||||
let out = tree
|
||||
.split(node(1), Direction::Row, leaf(2, None), node(9))
|
||||
.unwrap();
|
||||
match out.root {
|
||||
LayoutNode::Split(s) => {
|
||||
assert_eq!(s.id, node(9));
|
||||
assert_eq!(s.direction, Direction::Row);
|
||||
assert_eq!(s.children.len(), 2);
|
||||
assert!(s.children.iter().all(|c| c.weight > 0.0));
|
||||
}
|
||||
_ => panic!("expected a split at the root"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_is_immutable_source_unchanged() {
|
||||
let tree = single(1, Some(100));
|
||||
let before = tree.clone();
|
||||
let _ = tree
|
||||
.split(node(1), Direction::Column, leaf(2, None), node(9))
|
||||
.unwrap();
|
||||
assert_eq!(tree, before, "source tree must not be mutated");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_missing_target_is_node_not_found() {
|
||||
let tree = single(1, None);
|
||||
let err = tree
|
||||
.split(node(404), Direction::Row, leaf(2, None), node(9))
|
||||
.unwrap_err();
|
||||
assert_eq!(err, LayoutError::NodeNotFound(node(404)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_into_a_session_already_present_elsewhere_is_duplicate() {
|
||||
// root leaf 1 holds session 100; we split it adding a NEW leaf that reuses
|
||||
// the same session id → duplicate must be rejected by validation.
|
||||
let tree = single(1, Some(100));
|
||||
let err = tree
|
||||
.split(node(1), Direction::Row, leaf(2, Some(100)), node(9))
|
||||
.unwrap_err();
|
||||
assert_eq!(err, LayoutError::DuplicateSession(session(100)));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// merge
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn merge_keeps_selected_child() {
|
||||
let tree = single(1, Some(100))
|
||||
.split(node(1), Direction::Row, leaf(2, Some(200)), node(9))
|
||||
.unwrap();
|
||||
// keep index 1 (the new leaf with session 200).
|
||||
let merged = tree.merge(node(9), 1).unwrap();
|
||||
assert_eq!(merged.root, LayoutNode::Leaf(leaf(2, Some(200))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_is_immutable() {
|
||||
let tree = single(1, Some(100))
|
||||
.split(node(1), Direction::Row, leaf(2, None), node(9))
|
||||
.unwrap();
|
||||
let before = tree.clone();
|
||||
let _ = tree.merge(node(9), 0).unwrap();
|
||||
assert_eq!(tree, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_unknown_container_is_node_not_found() {
|
||||
let tree = single(1, None)
|
||||
.split(node(1), Direction::Row, leaf(2, None), node(9))
|
||||
.unwrap();
|
||||
let err = tree.merge(node(404), 0).unwrap_err();
|
||||
assert_eq!(err, LayoutError::NodeNotFound(node(404)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_index_out_of_range_is_cross_container() {
|
||||
let tree = single(1, None)
|
||||
.split(node(1), Direction::Row, leaf(2, None), node(9))
|
||||
.unwrap();
|
||||
let err = tree.merge(node(9), 5).unwrap_err();
|
||||
assert_eq!(err, LayoutError::CrossContainer);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resize
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn resize_nominal_updates_weights() {
|
||||
let tree = single(1, None)
|
||||
.split(node(1), Direction::Row, leaf(2, None), node(9))
|
||||
.unwrap();
|
||||
let out = tree.resize(node(9), &[2.0, 3.0]).unwrap();
|
||||
match out.root {
|
||||
LayoutNode::Split(s) => {
|
||||
assert_eq!(s.children[0].weight, 2.0);
|
||||
assert_eq!(s.children[1].weight, 3.0);
|
||||
}
|
||||
_ => panic!("expected split"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_is_immutable() {
|
||||
let tree = single(1, None)
|
||||
.split(node(1), Direction::Row, leaf(2, None), node(9))
|
||||
.unwrap();
|
||||
let before = tree.clone();
|
||||
let _ = tree.resize(node(9), &[2.0, 3.0]).unwrap();
|
||||
assert_eq!(tree, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_nonpositive_weight_rejected() {
|
||||
let tree = single(1, None)
|
||||
.split(node(1), Direction::Row, leaf(2, None), node(9))
|
||||
.unwrap();
|
||||
let err = tree.resize(node(9), &[0.0, 1.0]).unwrap_err();
|
||||
assert_eq!(err, LayoutError::NonPositiveWeight { weight: 0.0 });
|
||||
|
||||
let err = tree.resize(node(9), &[-1.0, 1.0]).unwrap_err();
|
||||
assert_eq!(err, LayoutError::NonPositiveWeight { weight: -1.0 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_wrong_arity_is_cross_container() {
|
||||
let tree = single(1, None)
|
||||
.split(node(1), Direction::Row, leaf(2, None), node(9))
|
||||
.unwrap();
|
||||
let err = tree.resize(node(9), &[1.0, 2.0, 3.0]).unwrap_err();
|
||||
assert_eq!(err, LayoutError::CrossContainer);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_unknown_container_is_node_not_found() {
|
||||
let tree = single(1, None)
|
||||
.split(node(1), Direction::Row, leaf(2, None), node(9))
|
||||
.unwrap();
|
||||
let err = tree.resize(node(404), &[1.0, 2.0]).unwrap_err();
|
||||
assert_eq!(err, LayoutError::NodeNotFound(node(404)));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// move_session
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn two_leaves(from_sess: Option<u128>, to_sess: Option<u128>) -> LayoutTree {
|
||||
LayoutTree::new(LayoutNode::Split(SplitContainer {
|
||||
id: node(9),
|
||||
direction: Direction::Row,
|
||||
children: vec![
|
||||
WeightedChild {
|
||||
node: LayoutNode::Leaf(leaf(1, from_sess)),
|
||||
weight: 1.0,
|
||||
},
|
||||
WeightedChild {
|
||||
node: LayoutNode::Leaf(leaf(2, to_sess)),
|
||||
weight: 1.0,
|
||||
},
|
||||
],
|
||||
}))
|
||||
}
|
||||
|
||||
/// Looks up the session held by the leaf `id` in a tree (test-only helper that
|
||||
/// walks the public structure, since the domain's lookup is private).
|
||||
fn session_for(tree: &LayoutTree, id: domain::NodeId) -> Option<domain::SessionId> {
|
||||
fn walk(n: &LayoutNode, id: domain::NodeId) -> Option<Option<domain::SessionId>> {
|
||||
match n {
|
||||
LayoutNode::Leaf(l) if l.id == id => Some(l.session),
|
||||
LayoutNode::Leaf(_) => None,
|
||||
LayoutNode::Split(s) => s.children.iter().find_map(|c| walk(&c.node, id)),
|
||||
LayoutNode::Grid(g) => g.cells.iter().find_map(|c| walk(&c.node, id)),
|
||||
}
|
||||
}
|
||||
walk(&tree.root, id).flatten()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_session_nominal() {
|
||||
let tree = two_leaves(Some(100), None);
|
||||
let out = tree.move_session(node(1), node(2)).unwrap();
|
||||
assert_eq!(session_for(&out, node(1)), None);
|
||||
assert_eq!(session_for(&out, node(2)), Some(session(100)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_session_is_immutable() {
|
||||
let tree = two_leaves(Some(100), None);
|
||||
let before = tree.clone();
|
||||
let _ = tree.move_session(node(1), node(2)).unwrap();
|
||||
assert_eq!(tree, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_session_from_empty_rejected() {
|
||||
let tree = two_leaves(None, None);
|
||||
let err = tree.move_session(node(1), node(2)).unwrap_err();
|
||||
assert_eq!(err, LayoutError::CrossContainer);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_session_to_occupied_rejected() {
|
||||
let tree = two_leaves(Some(100), Some(200));
|
||||
let err = tree.move_session(node(1), node(2)).unwrap_err();
|
||||
assert_eq!(err, LayoutError::CrossContainer);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_session_missing_leaf_is_node_not_found() {
|
||||
let tree = two_leaves(Some(100), None);
|
||||
assert_eq!(
|
||||
tree.move_session(node(404), node(2)).unwrap_err(),
|
||||
LayoutError::NodeNotFound(node(404))
|
||||
);
|
||||
assert_eq!(
|
||||
tree.move_session(node(1), node(404)).unwrap_err(),
|
||||
LayoutError::NodeNotFound(node(404))
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// validate(): grid invariants & duplicate sessions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn grid(col_w: Vec<f32>, row_w: Vec<f32>, cells: Vec<GridCell>) -> LayoutTree {
|
||||
LayoutTree::new(LayoutNode::Grid(GridContainer {
|
||||
id: node(50),
|
||||
col_weights: col_w,
|
||||
row_weights: row_w,
|
||||
cells,
|
||||
}))
|
||||
}
|
||||
|
||||
fn gcell(id: u128, row: u16, col: u16, rs: u16, cs: u16) -> GridCell {
|
||||
GridCell {
|
||||
node: LayoutNode::Leaf(leaf(id, None)),
|
||||
row,
|
||||
col,
|
||||
row_span: rs,
|
||||
col_span: cs,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_fully_covered_2x2_ok() {
|
||||
let cells = vec![
|
||||
gcell(1, 0, 0, 1, 1),
|
||||
gcell(2, 0, 1, 1, 1),
|
||||
gcell(3, 1, 0, 1, 1),
|
||||
gcell(4, 1, 1, 1, 1),
|
||||
];
|
||||
let t = grid(vec![1.0, 1.0], vec![1.0, 1.0], cells);
|
||||
assert!(t.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_merged_span_full_coverage_ok() {
|
||||
// one cell spanning the whole top row + two cells on the bottom row.
|
||||
let cells = vec![
|
||||
gcell(1, 0, 0, 1, 2), // top row merged
|
||||
gcell(2, 1, 0, 1, 1),
|
||||
gcell(3, 1, 1, 1, 1),
|
||||
];
|
||||
let t = grid(vec![1.0, 1.0], vec![1.0, 1.0], cells);
|
||||
assert!(t.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_overlap_rejected() {
|
||||
let cells = vec![
|
||||
gcell(1, 0, 0, 1, 2),
|
||||
gcell(2, 0, 1, 1, 1), // overlaps column 1 of the spanning cell
|
||||
gcell(3, 1, 0, 1, 1),
|
||||
gcell(4, 1, 1, 1, 1),
|
||||
];
|
||||
let t = grid(vec![1.0, 1.0], vec![1.0, 1.0], cells);
|
||||
assert!(matches!(
|
||||
t.validate().unwrap_err(),
|
||||
LayoutError::OverlappingCells { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_uncovered_surface_rejected() {
|
||||
// 2x2 grid but only one cell → three cells uncovered.
|
||||
let t = grid(vec![1.0, 1.0], vec![1.0, 1.0], vec![gcell(1, 0, 0, 1, 1)]);
|
||||
assert!(matches!(
|
||||
t.validate().unwrap_err(),
|
||||
LayoutError::UncoveredCell { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_span_out_of_bounds_rejected() {
|
||||
let t = grid(vec![1.0], vec![1.0], vec![gcell(1, 0, 0, 2, 1)]);
|
||||
assert!(matches!(
|
||||
t.validate().unwrap_err(),
|
||||
LayoutError::SpanOutOfBounds { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_zero_span_rejected() {
|
||||
let t = grid(vec![1.0], vec![1.0], vec![gcell(1, 0, 0, 0, 1)]);
|
||||
assert_eq!(t.validate().unwrap_err(), LayoutError::InvalidSpan);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_nonpositive_weight_rejected() {
|
||||
let t = grid(vec![0.0], vec![1.0], vec![gcell(1, 0, 0, 1, 1)]);
|
||||
assert_eq!(
|
||||
t.validate().unwrap_err(),
|
||||
LayoutError::NonPositiveWeight { weight: 0.0 }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_session_across_leaves_rejected() {
|
||||
let tree = two_leaves(Some(100), Some(100));
|
||||
assert_eq!(
|
||||
tree.validate().unwrap_err(),
|
||||
LayoutError::DuplicateSession(session(100))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_split_rejected() {
|
||||
let t = LayoutTree::new(LayoutNode::Split(SplitContainer {
|
||||
id: node(9),
|
||||
direction: Direction::Row,
|
||||
children: vec![],
|
||||
}));
|
||||
assert_eq!(t.validate().unwrap_err(), LayoutError::EmptySplit);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// set_session (L4: cell ↔ terminal binding bridge)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn set_session_attaches_to_leaf() {
|
||||
let tree = single(1, None);
|
||||
let out = tree.set_session(node(1), Some(session(100))).unwrap();
|
||||
match out.root {
|
||||
LayoutNode::Leaf(l) => {
|
||||
assert_eq!(l.id, node(1));
|
||||
assert_eq!(l.session, Some(session(100)));
|
||||
}
|
||||
_ => panic!("expected a leaf at the root"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_session_detaches_with_none() {
|
||||
let tree = single(1, Some(100));
|
||||
let out = tree.set_session(node(1), None).unwrap();
|
||||
match out.root {
|
||||
LayoutNode::Leaf(l) => assert_eq!(l.session, None),
|
||||
_ => panic!("expected a leaf at the root"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_session_is_immutable_source_unchanged() {
|
||||
let tree = single(1, None);
|
||||
let before = tree.clone();
|
||||
let _ = tree.set_session(node(1), Some(session(100))).unwrap();
|
||||
assert_eq!(tree, before, "source tree must not be mutated");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_session_reaches_nested_leaf() {
|
||||
// Attach onto the second leaf of a split, leaving the first empty.
|
||||
let tree = two_leaves(None, None);
|
||||
let out = tree.set_session(node(2), Some(session(7))).unwrap();
|
||||
match out.root {
|
||||
LayoutNode::Split(s) => match &s.children[1].node {
|
||||
LayoutNode::Leaf(l) => assert_eq!(l.session, Some(session(7))),
|
||||
_ => panic!("expected a leaf child"),
|
||||
},
|
||||
_ => panic!("expected a split root"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_session_missing_leaf_is_node_not_found() {
|
||||
let tree = single(1, None);
|
||||
let err = tree.set_session(node(404), Some(session(100))).unwrap_err();
|
||||
assert_eq!(err, LayoutError::NodeNotFound(node(404)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_session_duplicate_across_leaves_rejected() {
|
||||
// Leaf 1 already hosts session 100; attaching the same session to leaf 2
|
||||
// must fail validation rather than producing a duplicate.
|
||||
let tree = two_leaves(Some(100), None);
|
||||
let err = tree.set_session(node(2), Some(session(100))).unwrap_err();
|
||||
assert_eq!(err, LayoutError::DuplicateSession(session(100)));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// set_cell_agent (#3: per-cell agent)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn set_cell_agent_attaches_agent_to_leaf() {
|
||||
let tree = single(1, None);
|
||||
let out = tree.set_cell_agent(node(1), Some(agent_id(42))).unwrap();
|
||||
match out.root {
|
||||
LayoutNode::Leaf(l) => {
|
||||
assert_eq!(l.id, node(1));
|
||||
assert_eq!(l.agent, Some(agent_id(42)));
|
||||
}
|
||||
_ => panic!("expected a leaf at root"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_cell_agent_detaches_with_none() {
|
||||
// First attach, then detach.
|
||||
let tree = single(1, None);
|
||||
let with_agent = tree.set_cell_agent(node(1), Some(agent_id(42))).unwrap();
|
||||
let out = with_agent.set_cell_agent(node(1), None).unwrap();
|
||||
match out.root {
|
||||
LayoutNode::Leaf(l) => assert_eq!(l.agent, None),
|
||||
_ => panic!("expected a leaf at root"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_cell_agent_is_immutable_source_unchanged() {
|
||||
let tree = single(1, None);
|
||||
let before = tree.clone();
|
||||
let _ = tree.set_cell_agent(node(1), Some(agent_id(99))).unwrap();
|
||||
assert_eq!(tree, before, "source tree must not be mutated");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_cell_agent_missing_leaf_is_node_not_found() {
|
||||
let tree = single(1, None);
|
||||
let err = tree.set_cell_agent(node(404), Some(agent_id(1))).unwrap_err();
|
||||
assert_eq!(err, LayoutError::NodeNotFound(node(404)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_cell_agent_preserves_session() {
|
||||
// Session must survive an agent attachment.
|
||||
let tree = single(1, Some(100));
|
||||
let out = tree.set_cell_agent(node(1), Some(agent_id(7))).unwrap();
|
||||
match out.root {
|
||||
LayoutNode::Leaf(l) => {
|
||||
assert_eq!(l.session, Some(session(100)));
|
||||
assert_eq!(l.agent, Some(agent_id(7)));
|
||||
}
|
||||
_ => panic!("expected leaf"),
|
||||
}
|
||||
}
|
||||
295
crates/domain/tests/serde_roundtrip.rs
Normal file
295
crates/domain/tests/serde_roundtrip.rs
Normal file
@ -0,0 +1,295 @@
|
||||
//! JSON round-trip (serde) of persisted domain types, plus camelCase / tagging
|
||||
//! checks (ARCHITECTURE §7.3, §9).
|
||||
|
||||
mod helpers;
|
||||
|
||||
use domain::{
|
||||
Agent, AgentManifest, AgentOrigin, AgentProfile, AgentTemplate, ContextInjection, Direction,
|
||||
LayoutNode, LayoutTree, LeafCell, ManifestEntry, MarkdownDoc, Project, ProjectPath, RemoteRef,
|
||||
SplitContainer, SshAuth, TemplateVersion, WeightedChild,
|
||||
};
|
||||
use helpers::{node, session};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn pid(n: u128) -> domain::ProjectId {
|
||||
domain::ProjectId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
fn profid(n: u128) -> domain::ProfileId {
|
||||
domain::ProfileId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
fn tid(n: u128) -> domain::TemplateId {
|
||||
domain::TemplateId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
fn aid(n: u128) -> domain::AgentId {
|
||||
domain::AgentId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn roundtrip<T>(value: &T) -> T
|
||||
where
|
||||
T: serde::Serialize + serde::de::DeserializeOwned + PartialEq + std::fmt::Debug,
|
||||
{
|
||||
let json = serde_json::to_string(value).expect("serialize");
|
||||
serde_json::from_str(&json).expect("deserialize")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Project
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn project_roundtrip() {
|
||||
let p = Project::new(
|
||||
pid(1),
|
||||
"demo",
|
||||
ProjectPath::new("/srv/demo").unwrap(),
|
||||
RemoteRef::ssh("h", 22, "u", SshAuth::Agent, "/srv").unwrap(),
|
||||
123,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(roundtrip(&p), p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_uses_camel_case_and_tagged_remote() {
|
||||
let p = Project::new(
|
||||
pid(1),
|
||||
"demo",
|
||||
ProjectPath::new("/srv/demo").unwrap(),
|
||||
RemoteRef::local(),
|
||||
123,
|
||||
)
|
||||
.unwrap();
|
||||
let json = serde_json::to_string(&p).unwrap();
|
||||
assert!(json.contains("\"createdAt\":123"), "json was {json}");
|
||||
// RemoteRef tagged with `kind`, camelCased "local".
|
||||
assert!(json.contains("\"kind\":\"local\""), "json was {json}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RemoteRef variants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn remote_ssh_roundtrip_and_tags() {
|
||||
let r = RemoteRef::ssh("host", 2222, "me", SshAuth::Key { path: "/k".into() }, "/srv").unwrap();
|
||||
assert_eq!(roundtrip(&r), r);
|
||||
let json = serde_json::to_string(&r).unwrap();
|
||||
assert!(json.contains("\"kind\":\"ssh\""), "json was {json}");
|
||||
// SshAuth tagged with `type`.
|
||||
assert!(json.contains("\"type\":\"key\""), "json was {json}");
|
||||
// Enum-variant fields must be camelCased on the wire (ARCHITECTURE §9).
|
||||
assert!(json.contains("\"remoteRoot\""), "json was {json}");
|
||||
assert!(!json.contains("\"remote_root\""), "json was {json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_wsl_roundtrip() {
|
||||
let r = RemoteRef::wsl("Ubuntu").unwrap();
|
||||
assert_eq!(roundtrip(&r), r);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AgentProfile + ContextInjection (tagged with `strategy`)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn profile_roundtrip_all_injection_variants() {
|
||||
for ci in [
|
||||
ContextInjection::convention_file("CLAUDE.md").unwrap(),
|
||||
ContextInjection::flag("-f {path}").unwrap(),
|
||||
ContextInjection::stdin(),
|
||||
ContextInjection::env("CTX").unwrap(),
|
||||
] {
|
||||
let p = AgentProfile::new(
|
||||
profid(1),
|
||||
"Name",
|
||||
"claude",
|
||||
vec!["a".into(), "b".into()],
|
||||
ci,
|
||||
Some("claude --version".into()),
|
||||
"{projectRoot}",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(roundtrip(&p), p);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_injection_strategy_tag_is_camel_case() {
|
||||
let json = serde_json::to_string(&ContextInjection::convention_file("CLAUDE.md").unwrap())
|
||||
.unwrap();
|
||||
assert!(json.contains("\"strategy\":\"conventionFile\""), "json was {json}");
|
||||
let json = serde_json::to_string(&ContextInjection::stdin()).unwrap();
|
||||
assert!(json.contains("\"strategy\":\"stdin\""), "json was {json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_cwd_template_is_camel_case() {
|
||||
let p = AgentProfile::new(
|
||||
profid(1),
|
||||
"n",
|
||||
"c",
|
||||
vec![],
|
||||
ContextInjection::stdin(),
|
||||
None,
|
||||
"{projectRoot}",
|
||||
)
|
||||
.unwrap();
|
||||
let json = serde_json::to_string(&p).unwrap();
|
||||
assert!(json.contains("\"cwdTemplate\""), "json was {json}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AgentTemplate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn template_roundtrip() {
|
||||
let t = AgentTemplate::new(tid(1), "T", MarkdownDoc::new("# hi"), profid(2))
|
||||
.unwrap()
|
||||
.with_updated_content(MarkdownDoc::new("# bye"));
|
||||
assert_eq!(roundtrip(&t), t);
|
||||
let json = serde_json::to_string(&t).unwrap();
|
||||
assert!(json.contains("\"contentMd\""), "json was {json}");
|
||||
assert!(json.contains("\"defaultProfileId\""), "json was {json}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent + manifest
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn agent_roundtrip_from_template() {
|
||||
let a = Agent::new(
|
||||
aid(1),
|
||||
"Backend",
|
||||
"agents/backend.md",
|
||||
profid(2),
|
||||
AgentOrigin::FromTemplate {
|
||||
template_id: tid(3),
|
||||
synced_template_version: TemplateVersion(4),
|
||||
},
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(roundtrip(&a), a);
|
||||
let json = serde_json::to_string(&a).unwrap();
|
||||
assert!(json.contains("\"contextPath\""), "json was {json}");
|
||||
// AgentOrigin tagged with `type`, camelCased.
|
||||
assert!(json.contains("\"type\":\"fromTemplate\""), "json was {json}");
|
||||
// Inner fields must be camelCased per ARCHITECTURE §9.1:
|
||||
// { "type":"fromTemplate", "templateId":"...", "syncedTemplateVersion":N }.
|
||||
assert!(json.contains("\"templateId\""), "json was {json}");
|
||||
assert!(json.contains("\"syncedTemplateVersion\":4"), "json was {json}");
|
||||
assert!(!json.contains("\"template_id\""), "json was {json}");
|
||||
assert!(!json.contains("\"synced_template_version\""), "json was {json}");
|
||||
assert!(!json.contains("\"synced_version\""), "json was {json}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SessionKind (tagged enum: `type`, camelCased variant fields)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn session_kind_agent_roundtrip_and_camel_case() {
|
||||
use domain::SessionKind;
|
||||
let k = SessionKind::Agent { agent_id: aid(7) };
|
||||
assert_eq!(roundtrip(&k), k);
|
||||
let json = serde_json::to_string(&k).unwrap();
|
||||
assert!(json.contains("\"type\":\"agent\""), "json was {json}");
|
||||
assert!(json.contains("\"agentId\""), "json was {json}");
|
||||
assert!(!json.contains("\"agent_id\""), "json was {json}");
|
||||
|
||||
// Plain variant carries no fields.
|
||||
let plain = serde_json::to_string(&SessionKind::Plain).unwrap();
|
||||
assert!(plain.contains("\"type\":\"plain\""), "json was {plain}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_roundtrip_and_camel_case() {
|
||||
let e1 = ManifestEntry::new(
|
||||
aid(1),
|
||||
"Alpha",
|
||||
"agents/a.md",
|
||||
profid(9),
|
||||
Some(tid(2)),
|
||||
true,
|
||||
Some(TemplateVersion(5)),
|
||||
)
|
||||
.unwrap();
|
||||
let e2 = ManifestEntry::new(aid(3), "Beta", "agents/b.md", profid(9), None, false, None).unwrap();
|
||||
let m = AgentManifest::new(1, vec![e1, e2]).unwrap();
|
||||
assert_eq!(roundtrip(&m), m);
|
||||
let json = serde_json::to_string(&m).unwrap();
|
||||
// entries are serialized under "agents".
|
||||
assert!(json.contains("\"agents\":["), "json was {json}");
|
||||
assert!(json.contains("\"mdPath\""), "json was {json}");
|
||||
assert!(json.contains("\"syncedTemplateVersion\":5"), "json was {json}");
|
||||
// Non-synchronized entry omits optional template fields (skip_serializing_if).
|
||||
assert!(!json.contains("\"templateId\":null"), "json was {json}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LayoutTree (tagged enum: type/node)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn layout_roundtrip() {
|
||||
let tree = LayoutTree::new(LayoutNode::Split(SplitContainer {
|
||||
id: node(9),
|
||||
direction: Direction::Column,
|
||||
children: vec![
|
||||
WeightedChild {
|
||||
node: LayoutNode::Leaf(LeafCell {
|
||||
id: node(1),
|
||||
session: Some(session(100)),
|
||||
agent: None,
|
||||
}),
|
||||
weight: 1.5,
|
||||
},
|
||||
WeightedChild {
|
||||
node: LayoutNode::Leaf(LeafCell {
|
||||
id: node(2),
|
||||
session: None,
|
||||
agent: None,
|
||||
}),
|
||||
weight: 2.5,
|
||||
},
|
||||
],
|
||||
}));
|
||||
assert_eq!(roundtrip(&tree), tree);
|
||||
let json = serde_json::to_string(&tree).unwrap();
|
||||
// enum adjacently tagged: type + node ; direction camelCase.
|
||||
assert!(json.contains("\"type\":\"split\""), "json was {json}");
|
||||
assert!(json.contains("\"type\":\"leaf\""), "json was {json}");
|
||||
assert!(json.contains("\"direction\":\"column\""), "json was {json}");
|
||||
// empty session leaf omits the field.
|
||||
assert!(!json.contains("\"session\":null"), "json was {json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaf_with_agent_roundtrip_and_omits_null() {
|
||||
use domain::ids::AgentId;
|
||||
let agent_uuid = Uuid::from_u128(0xABC);
|
||||
let tree = LayoutTree::new(LayoutNode::Leaf(LeafCell {
|
||||
id: node(1),
|
||||
session: None,
|
||||
agent: Some(AgentId::from_uuid(agent_uuid)),
|
||||
}));
|
||||
let rt = roundtrip(&tree);
|
||||
match rt.root {
|
||||
LayoutNode::Leaf(l) => assert_eq!(l.agent, Some(AgentId::from_uuid(agent_uuid))),
|
||||
_ => panic!("expected leaf"),
|
||||
}
|
||||
let json = serde_json::to_string(&tree).unwrap();
|
||||
// agent present when set
|
||||
assert!(json.contains("\"agent\""), "agent field should be present when set; json was {json}");
|
||||
// null variant omitted
|
||||
let tree_no_agent = LayoutTree::new(LayoutNode::Leaf(LeafCell {
|
||||
id: node(2),
|
||||
session: None,
|
||||
agent: None,
|
||||
}));
|
||||
let json2 = serde_json::to_string(&tree_no_agent).unwrap();
|
||||
assert!(!json2.contains("\"agent\""), "agent field should be omitted when None; json was {json2}");
|
||||
}
|
||||
93
crates/domain/tests/window.rs
Normal file
93
crates/domain/tests/window.rs
Normal file
@ -0,0 +1,93 @@
|
||||
//! L10 tests for the pure `Workspace::move_tab_to_new_window` operation
|
||||
//! (ARCHITECTURE §10): a tab is *moved*, never duplicated; an emptied source
|
||||
//! window is dropped; an active moved tab hands activity back to a sibling.
|
||||
|
||||
use domain::{
|
||||
LayoutNode, LayoutTree, LayoutError, LeafCell, NodeId, ProjectId, Tab, TabId, Window,
|
||||
WindowId, Workspace,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn tid(n: u128) -> TabId {
|
||||
TabId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
fn wid(n: u128) -> WindowId {
|
||||
WindowId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
fn leaf_tree() -> LayoutTree {
|
||||
LayoutTree::new(LayoutNode::Leaf(LeafCell {
|
||||
id: NodeId::from_uuid(Uuid::from_u128(900)),
|
||||
session: None,
|
||||
agent: None,
|
||||
}))
|
||||
}
|
||||
fn tab(n: u128) -> Tab {
|
||||
Tab {
|
||||
id: tid(n),
|
||||
project_id: ProjectId::from_uuid(Uuid::from_u128(1000 + n)),
|
||||
layout: leaf_tree(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Count how many windows contain a tab with the given id.
|
||||
fn occurrences(ws: &Workspace, tab: TabId) -> usize {
|
||||
ws.windows
|
||||
.iter()
|
||||
.filter(|w| w.tabs.iter().any(|t| t.id == tab))
|
||||
.count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_tab_from_multi_tab_window_keeps_source_and_creates_new() {
|
||||
let src = Window::new(wid(1), vec![tab(1), tab(2)], tid(1)).unwrap();
|
||||
let ws = Workspace { windows: vec![src] };
|
||||
|
||||
let next = ws.move_tab_to_new_window(tid(1), wid(99)).unwrap();
|
||||
|
||||
assert_eq!(next.windows.len(), 2, "source kept + new window");
|
||||
// The moved tab appears exactly once (moved, not duplicated).
|
||||
assert_eq!(occurrences(&next, tid(1)), 1);
|
||||
// Source window kept tab 2 and fell back its active tab to it.
|
||||
let source = next.windows.iter().find(|w| w.id == wid(1)).unwrap();
|
||||
assert_eq!(source.tabs.len(), 1);
|
||||
assert_eq!(source.active_tab, tid(2));
|
||||
// New window holds the moved tab, active.
|
||||
let detached = next.windows.iter().find(|w| w.id == wid(99)).unwrap();
|
||||
assert_eq!(detached.tabs.len(), 1);
|
||||
assert_eq!(detached.active_tab, tid(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_only_tab_removes_the_emptied_source_window() {
|
||||
let src = Window::new(wid(1), vec![tab(1)], tid(1)).unwrap();
|
||||
let ws = Workspace { windows: vec![src] };
|
||||
|
||||
let next = ws.move_tab_to_new_window(tid(1), wid(99)).unwrap();
|
||||
|
||||
assert_eq!(next.windows.len(), 1, "emptied source dropped");
|
||||
assert_eq!(next.windows[0].id, wid(99));
|
||||
assert_eq!(occurrences(&next, tid(1)), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_unknown_tab_is_rejected() {
|
||||
let ws = Workspace {
|
||||
windows: vec![Window::new(wid(1), vec![tab(1)], tid(1)).unwrap()],
|
||||
};
|
||||
assert!(matches!(
|
||||
ws.move_tab_to_new_window(tid(404), wid(99)).unwrap_err(),
|
||||
LayoutError::TabNotFound(t) if t == tid(404)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_non_active_tab_leaves_source_active_unchanged() {
|
||||
let src = Window::new(wid(1), vec![tab(1), tab(2)], tid(1)).unwrap();
|
||||
let ws = Workspace { windows: vec![src] };
|
||||
|
||||
let next = ws.move_tab_to_new_window(tid(2), wid(99)).unwrap();
|
||||
|
||||
let source = next.windows.iter().find(|w| w.id == wid(1)).unwrap();
|
||||
assert_eq!(source.active_tab, tid(1), "active tab unchanged");
|
||||
assert_eq!(source.tabs.len(), 1);
|
||||
}
|
||||
Reference in New Issue
Block a user