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,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 { .. }
));
}

View 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());
}

View 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))
}

View 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"),
}
}

View 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}");
}

View 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);
}