use crate::data_store::DataStore;
use crate::dependencies::{RelatesKind, Severity};
use crate::KanbanResult;
use kanban_core::Edge as _;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use super::{Command, CommandContext};
use crate::Card;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "action", rename_all = "snake_case")]
pub enum DependencyCommand {
AddSpawns(AddSpawns),
AddBlocks(AddBlocks),
AddRelates(AddRelates),
RemoveSpawns(RemoveSpawns),
RemoveBlocks(RemoveBlocks),
RemoveRelates(RemoveRelates),
CreateSubcard(CreateSubcardCommand),
}
impl DependencyCommand {
pub fn execute(&self, context: &CommandContext) -> KanbanResult<()> {
match self {
DependencyCommand::AddSpawns(c) => c.execute(context),
DependencyCommand::AddBlocks(c) => c.execute(context),
DependencyCommand::AddRelates(c) => c.execute(context),
DependencyCommand::RemoveSpawns(c) => c.execute(context),
DependencyCommand::RemoveBlocks(c) => c.execute(context),
DependencyCommand::RemoveRelates(c) => c.execute(context),
DependencyCommand::CreateSubcard(c) => c.execute(context),
}
}
pub fn description(&self) -> String {
match self {
DependencyCommand::AddSpawns(c) => c.description(),
DependencyCommand::AddBlocks(c) => c.description(),
DependencyCommand::AddRelates(c) => c.description(),
DependencyCommand::RemoveSpawns(c) => c.description(),
DependencyCommand::RemoveBlocks(c) => c.description(),
DependencyCommand::RemoveRelates(c) => c.description(),
DependencyCommand::CreateSubcard(c) => c.description(),
}
}
pub fn capture_inverse(&self, store: &dyn DataStore) -> KanbanResult<Vec<Command>> {
match self {
DependencyCommand::AddSpawns(c) => c.capture_inverse(store),
DependencyCommand::AddBlocks(c) => c.capture_inverse(store),
DependencyCommand::AddRelates(c) => c.capture_inverse(store),
DependencyCommand::RemoveSpawns(c) => c.capture_inverse(store),
DependencyCommand::RemoveBlocks(c) => c.capture_inverse(store),
DependencyCommand::RemoveRelates(c) => c.capture_inverse(store),
DependencyCommand::CreateSubcard(c) => c.capture_inverse(store),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AddSpawns {
pub source: Uuid,
pub target: Uuid,
#[serde(default)]
pub as_archived: bool,
}
impl AddSpawns {
pub fn execute(&self, context: &CommandContext) -> KanbanResult<()> {
let (source, target, as_archived) = (self.source, self.target, self.as_archived);
context.store.modify_graph(Box::new(move |graph| {
if as_archived {
graph.add_archived_spawns(source, target)
} else {
graph.set_parent(target, source)
}
}))
}
pub fn description(&self) -> String {
format!("Set parent: {} is parent of {}", self.source, self.target)
}
pub fn capture_inverse(&self, _store: &dyn DataStore) -> KanbanResult<Vec<Command>> {
Ok(vec![Command::Dependency(DependencyCommand::RemoveSpawns(
RemoveSpawns {
source: self.source,
target: self.target,
tolerate_missing: true,
},
))])
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AddBlocks {
pub source: Uuid,
pub target: Uuid,
#[serde(default)]
pub severity: Severity,
#[serde(default)]
pub as_archived: bool,
}
impl AddBlocks {
pub fn execute(&self, context: &CommandContext) -> KanbanResult<()> {
let (source, target, severity, as_archived) =
(self.source, self.target, self.severity, self.as_archived);
context.store.modify_graph(Box::new(move |graph| {
if as_archived {
graph.add_archived_blocks(source, target, severity)
} else {
graph.set_block_with_severity(source, target, severity)
}
}))
}
pub fn description(&self) -> String {
format!(
"Add blocks dependency ({:?}): {} blocks {}",
self.severity, self.source, self.target
)
}
pub fn capture_inverse(&self, _store: &dyn DataStore) -> KanbanResult<Vec<Command>> {
Ok(vec![Command::Dependency(DependencyCommand::RemoveBlocks(
RemoveBlocks {
source: self.source,
target: self.target,
tolerate_missing: true,
},
))])
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AddRelates {
pub source: Uuid,
pub target: Uuid,
#[serde(default)]
pub kind: RelatesKind,
#[serde(default)]
pub as_archived: bool,
}
impl AddRelates {
pub fn execute(&self, context: &CommandContext) -> KanbanResult<()> {
let (source, target, kind, as_archived) =
(self.source, self.target, self.kind, self.as_archived);
context.store.modify_graph(Box::new(move |graph| {
if as_archived {
graph.add_archived_relates(source, target, kind)
} else {
graph.relate_with_kind(source, target, kind)
}
}))
}
pub fn description(&self) -> String {
format!(
"Add relates-to dependency ({:?}): {} <-> {}",
self.kind, self.source, self.target
)
}
pub fn capture_inverse(&self, _store: &dyn DataStore) -> KanbanResult<Vec<Command>> {
Ok(vec![Command::Dependency(DependencyCommand::RemoveRelates(
RemoveRelates {
source: self.source,
target: self.target,
tolerate_missing: true,
},
))])
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RemoveSpawns {
pub source: Uuid,
pub target: Uuid,
#[serde(default)]
pub tolerate_missing: bool,
}
impl RemoveSpawns {
pub fn execute(&self, context: &CommandContext) -> KanbanResult<()> {
let (source, target, tolerate) = (self.source, self.target, self.tolerate_missing);
context.store.modify_graph(Box::new(move |graph| {
match graph.remove_parent(target, source) {
Ok(()) => Ok(()),
Err(e) if tolerate && e.is_edge_not_found() => Ok(()),
Err(e) => Err(e),
}
}))
}
pub fn description(&self) -> String {
format!(
"Remove parent: {} is no longer parent of {}",
self.source, self.target
)
}
pub fn capture_inverse(&self, _store: &dyn DataStore) -> KanbanResult<Vec<Command>> {
Ok(vec![Command::Dependency(DependencyCommand::AddSpawns(
AddSpawns {
source: self.source,
target: self.target,
as_archived: false,
},
))])
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RemoveBlocks {
pub source: Uuid,
pub target: Uuid,
#[serde(default)]
pub tolerate_missing: bool,
}
impl RemoveBlocks {
pub fn execute(&self, context: &CommandContext) -> KanbanResult<()> {
let (source, target, tolerate) = (self.source, self.target, self.tolerate_missing);
context
.store
.modify_graph(Box::new(move |graph| match graph.unblock(source, target) {
Ok(()) => Ok(()),
Err(e) if tolerate && e.is_edge_not_found() => Ok(()),
Err(e) => Err(e),
}))
}
pub fn description(&self) -> String {
format!(
"Remove blocks dependency: {} no longer blocks {}",
self.source, self.target
)
}
pub fn capture_inverse(&self, store: &dyn DataStore) -> KanbanResult<Vec<Command>> {
let graph = store.get_graph()?;
let severity = graph
.blocks_edges()
.iter()
.find(|e| e.source() == self.source && e.target() == self.target)
.map(|e| e.severity)
.unwrap_or_default();
Ok(vec![Command::Dependency(DependencyCommand::AddBlocks(
AddBlocks {
source: self.source,
target: self.target,
severity,
as_archived: false,
},
))])
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RemoveRelates {
pub source: Uuid,
pub target: Uuid,
#[serde(default)]
pub tolerate_missing: bool,
}
impl RemoveRelates {
pub fn execute(&self, context: &CommandContext) -> KanbanResult<()> {
let (source, target, tolerate) = (self.source, self.target, self.tolerate_missing);
context.store.modify_graph(Box::new(move |graph| {
match graph.dissociate(source, target) {
Ok(()) => Ok(()),
Err(e) if tolerate && e.is_edge_not_found() => Ok(()),
Err(e) => Err(e),
}
}))
}
pub fn description(&self) -> String {
format!(
"Remove relates-to dependency: {} <-> {}",
self.source, self.target
)
}
pub fn capture_inverse(&self, store: &dyn DataStore) -> KanbanResult<Vec<Command>> {
let graph = store.get_graph()?;
let (a, b) = (self.source, self.target);
let kind = graph
.relates_edges()
.iter()
.find(|e| (e.source() == a && e.target() == b) || (e.source() == b && e.target() == a))
.map(|e| e.kind)
.unwrap_or_default();
Ok(vec![Command::Dependency(DependencyCommand::AddRelates(
AddRelates {
source: self.source,
target: self.target,
kind,
as_archived: false,
},
))])
}
}
pub(super) fn edges_to_undo_commands<P>(
graph: &crate::DependencyGraph,
predicate: P,
) -> Vec<Command>
where
P: Fn(Uuid, Uuid) -> bool,
{
use kanban_core::Edge as _;
let mut out = Vec::new();
for e in graph.spawns_edges() {
if predicate(e.source(), e.target()) {
out.push(Command::Dependency(DependencyCommand::AddSpawns(
AddSpawns {
source: e.source(),
target: e.target(),
as_archived: !e.is_active(),
},
)));
}
}
for e in graph.blocks_edges() {
if predicate(e.source(), e.target()) {
out.push(Command::Dependency(DependencyCommand::AddBlocks(
AddBlocks {
source: e.source(),
target: e.target(),
severity: e.severity,
as_archived: !e.is_active(),
},
)));
}
}
for e in graph.relates_edges() {
if predicate(e.source(), e.target()) {
out.push(Command::Dependency(DependencyCommand::AddRelates(
AddRelates {
source: e.source(),
target: e.target(),
kind: e.kind,
as_archived: !e.is_active(),
},
)));
}
}
out
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateSubcardCommand {
#[serde(default = "Uuid::new_v4")]
pub id: Uuid,
pub parent_id: Uuid,
pub board_id: Uuid,
pub column_id: Uuid,
pub title: String,
pub description: Option<String>,
pub position: i32,
}
impl CreateSubcardCommand {
pub fn execute(&self, context: &CommandContext) -> KanbanResult<()> {
context.get_card(self.parent_id)?;
let mut board = context.get_board(self.board_id)?;
let mut card = Card::new(
&mut board,
self.column_id,
self.title.clone(),
self.position,
);
card.id = self.id;
if let Some(desc) = &self.description {
card.description = Some(desc.clone());
}
let card_id = card.id;
let parent_id = self.parent_id;
context.store.upsert_board(board)?;
context.store.upsert_card(card)?;
context
.store
.modify_graph(Box::new(move |graph| graph.set_parent(card_id, parent_id)))
}
pub fn description(&self) -> String {
format!(
"Create subcard '{}' under parent {}",
self.title, self.parent_id
)
}
pub fn capture_inverse(&self, _store: &dyn DataStore) -> KanbanResult<Vec<Command>> {
Ok(vec![Command::Card(
super::card_commands::CardCommand::Delete(super::card_commands::DeleteCard {
card_id: self.id,
}),
)])
}
}
#[cfg(test)]
mod tests {
use super::super::test_helpers::TestContext;
use super::*;
use crate::DataStore;
#[test]
fn test_add_spawns_executes() {
let tc = TestContext::new();
let context = tc.as_command_context();
let parent_id = Uuid::new_v4();
let child_id = Uuid::new_v4();
assert!(AddSpawns {
source: parent_id,
target: child_id,
as_archived: false,
}
.execute(&context)
.is_ok());
let graph = tc.store.get_graph().unwrap();
assert_eq!(graph.children(parent_id), vec![child_id]);
}
#[test]
fn test_add_spawns_prevents_cycle() {
let tc = TestContext::new();
let context = tc.as_command_context();
let a = Uuid::new_v4();
let b = Uuid::new_v4();
assert!(AddSpawns {
source: a,
target: b,
as_archived: false,
}
.execute(&context)
.is_ok());
assert!(AddSpawns {
source: b,
target: a,
as_archived: false,
}
.execute(&context)
.is_err());
}
#[test]
fn test_add_blocks_preserves_severity() {
let tc = TestContext::new();
let context = tc.as_command_context();
let blocker = Uuid::new_v4();
let blocked = Uuid::new_v4();
AddBlocks {
source: blocker,
target: blocked,
severity: Severity::High,
as_archived: false,
}
.execute(&context)
.unwrap();
let graph = tc.store.get_graph().unwrap();
assert_eq!(graph.blocks_edges()[0].severity, Severity::High);
}
#[test]
fn test_add_relates_preserves_kind() {
let tc = TestContext::new();
let context = tc.as_command_context();
let a = Uuid::new_v4();
let b = Uuid::new_v4();
AddRelates {
source: a,
target: b,
kind: RelatesKind::Duplicates,
as_archived: false,
}
.execute(&context)
.unwrap();
let graph = tc.store.get_graph().unwrap();
assert_eq!(graph.relates_edges()[0].kind, RelatesKind::Duplicates);
}
#[test]
fn test_remove_spawns_executes() {
let tc = TestContext::new();
let parent_id = Uuid::new_v4();
let child_id = Uuid::new_v4();
{
let mut graph = tc.store.get_graph().unwrap();
graph.set_parent(child_id, parent_id).unwrap();
tc.store.set_graph(graph).unwrap();
}
let context = tc.as_command_context();
assert!(RemoveSpawns {
source: parent_id,
target: child_id,
tolerate_missing: false,
}
.execute(&context)
.is_ok());
let graph = tc.store.get_graph().unwrap();
assert_eq!(graph.children(parent_id).len(), 0);
}
#[test]
fn test_remove_blocks_inverse_captures_severity_from_pre_remove_graph() {
let tc = TestContext::new();
let blocker = Uuid::new_v4();
let blocked = Uuid::new_v4();
{
let mut graph = tc.store.get_graph().unwrap();
graph
.set_block_with_severity(blocker, blocked, Severity::Critical)
.unwrap();
tc.store.set_graph(graph).unwrap();
}
let cmd = RemoveBlocks {
source: blocker,
target: blocked,
tolerate_missing: false,
};
let inverse = cmd.capture_inverse(&tc.store).unwrap();
match &inverse[0] {
Command::Dependency(DependencyCommand::AddBlocks(a)) => {
assert_eq!(a.severity, Severity::Critical);
}
other => panic!("expected AddBlocks inverse, got {other:?}"),
}
}
#[test]
fn test_remove_relates_inverse_captures_kind_from_pre_remove_graph() {
let tc = TestContext::new();
let a = Uuid::new_v4();
let b = Uuid::new_v4();
{
let mut graph = tc.store.get_graph().unwrap();
graph
.relate_with_kind(a, b, RelatesKind::Duplicates)
.unwrap();
tc.store.set_graph(graph).unwrap();
}
let cmd = RemoveRelates {
source: a,
target: b,
tolerate_missing: false,
};
let inverse = cmd.capture_inverse(&tc.store).unwrap();
match &inverse[0] {
Command::Dependency(DependencyCommand::AddRelates(a)) => {
assert_eq!(a.kind, RelatesKind::Duplicates);
}
other => panic!("expected AddRelates inverse, got {other:?}"),
}
}
#[test]
fn test_remove_blocks_inverse_preserves_severity_across_all_variants() {
for severity in [
Severity::Low,
Severity::Medium,
Severity::High,
Severity::Critical,
] {
let tc = TestContext::new();
let blocker = Uuid::new_v4();
let blocked = Uuid::new_v4();
{
let mut graph = tc.store.get_graph().unwrap();
graph
.set_block_with_severity(blocker, blocked, severity)
.unwrap();
tc.store.set_graph(graph).unwrap();
}
let cmd = RemoveBlocks {
source: blocker,
target: blocked,
tolerate_missing: false,
};
let inverse = cmd.capture_inverse(&tc.store).unwrap();
match &inverse[0] {
Command::Dependency(DependencyCommand::AddBlocks(a)) => {
assert_eq!(
a.severity, severity,
"severity {severity:?} must round-trip through capture_inverse"
);
}
other => panic!("expected AddBlocks inverse for {severity:?}, got {other:?}"),
}
}
}
#[test]
fn test_remove_relates_inverse_preserves_kind_across_all_variants() {
for kind in [
RelatesKind::General,
RelatesKind::Duplicates,
RelatesKind::MentionedIn,
] {
let tc = TestContext::new();
let a = Uuid::new_v4();
let b = Uuid::new_v4();
{
let mut graph = tc.store.get_graph().unwrap();
graph.relate_with_kind(a, b, kind).unwrap();
tc.store.set_graph(graph).unwrap();
}
let cmd = RemoveRelates {
source: a,
target: b,
tolerate_missing: false,
};
let inverse = cmd.capture_inverse(&tc.store).unwrap();
match &inverse[0] {
Command::Dependency(DependencyCommand::AddRelates(a)) => {
assert_eq!(
a.kind, kind,
"kind {kind:?} must round-trip through capture_inverse"
);
}
other => panic!("expected AddRelates inverse for {kind:?}, got {other:?}"),
}
}
}
#[test]
fn test_add_spawns_with_as_archived_true_inserts_archived_edge() {
use kanban_core::Edge as _;
let tc = TestContext::new();
let context = tc.as_command_context();
let parent = Uuid::new_v4();
let child = Uuid::new_v4();
AddSpawns {
source: parent,
target: child,
as_archived: true,
}
.execute(&context)
.unwrap();
let graph = tc.store.get_graph().unwrap();
assert!(graph.children(parent).is_empty(), "active children empty");
let edges = graph.spawns_edges();
assert_eq!(edges.len(), 1, "edge present in history");
assert!(!edges[0].is_active(), "edge is archived");
}
#[test]
fn test_add_blocks_with_as_archived_true_inserts_archived_edge_with_severity() {
use kanban_core::Edge as _;
let tc = TestContext::new();
let context = tc.as_command_context();
let blocker = Uuid::new_v4();
let blocked = Uuid::new_v4();
AddBlocks {
source: blocker,
target: blocked,
severity: Severity::Critical,
as_archived: true,
}
.execute(&context)
.unwrap();
let graph = tc.store.get_graph().unwrap();
assert!(graph.blocked(blocker).is_empty(), "active blocked empty");
let edges = graph.blocks_edges();
assert_eq!(edges.len(), 1);
assert!(!edges[0].is_active(), "edge archived");
assert_eq!(
edges[0].severity,
Severity::Critical,
"severity preserved through archived insert"
);
}
#[test]
fn test_add_relates_with_as_archived_true_inserts_archived_edge_with_kind() {
use kanban_core::Edge as _;
let tc = TestContext::new();
let context = tc.as_command_context();
let a = Uuid::new_v4();
let b = Uuid::new_v4();
AddRelates {
source: a,
target: b,
kind: RelatesKind::Duplicates,
as_archived: true,
}
.execute(&context)
.unwrap();
let graph = tc.store.get_graph().unwrap();
assert!(graph.related(a).is_empty(), "active related empty");
let edges = graph.relates_edges();
assert_eq!(edges.len(), 1);
assert!(!edges[0].is_active(), "edge archived");
assert_eq!(
edges[0].kind,
RelatesKind::Duplicates,
"kind preserved through archived insert"
);
}
#[test]
fn test_add_spawns_legacy_json_without_as_archived_defaults_to_false() {
let source = Uuid::nil();
let target = Uuid::from_u128(0x42);
let legacy: DependencyCommand = serde_json::from_value(serde_json::json!({
"action": "add_spawns",
"source": source,
"target": target,
}))
.expect("legacy add_spawns without as_archived must deserialise");
match legacy {
DependencyCommand::AddSpawns(a) => {
assert!(
!a.as_archived,
"default must be active for backwards-compat"
);
}
other => panic!("expected AddSpawns, got {other:?}"),
}
}
#[test]
fn test_remove_relates_inverse_finds_edge_in_reversed_orientation() {
let tc = TestContext::new();
let a = Uuid::new_v4();
let b = Uuid::new_v4();
{
let mut graph = tc.store.get_graph().unwrap();
graph
.relate_with_kind(a, b, RelatesKind::MentionedIn)
.unwrap();
tc.store.set_graph(graph).unwrap();
}
let cmd = RemoveRelates {
source: b,
target: a,
tolerate_missing: false,
};
let inverse = cmd.capture_inverse(&tc.store).unwrap();
match &inverse[0] {
Command::Dependency(DependencyCommand::AddRelates(restored)) => {
assert_eq!(
restored.kind,
RelatesKind::MentionedIn,
"reversed-orientation remove must still recover the original kind"
);
}
other => panic!("expected AddRelates inverse, got {other:?}"),
}
}
#[test]
fn test_add_spawns_inverse_is_tolerant_remove_spawns() {
let tc = TestContext::new();
let cmd = AddSpawns {
source: Uuid::new_v4(),
target: Uuid::new_v4(),
as_archived: false,
};
let inverse = cmd.capture_inverse(&tc.store).unwrap();
assert_eq!(inverse.len(), 1);
match &inverse[0] {
Command::Dependency(DependencyCommand::RemoveSpawns(r)) => {
assert!(r.tolerate_missing, "undo inverse must tolerate missing");
}
other => panic!("expected RemoveSpawns inverse, got {other:?}"),
}
}
#[test]
fn test_add_blocks_inverse_is_tolerant_remove_blocks() {
let tc = TestContext::new();
let cmd = AddBlocks {
source: Uuid::new_v4(),
target: Uuid::new_v4(),
severity: Severity::High,
as_archived: false,
};
let inverse = cmd.capture_inverse(&tc.store).unwrap();
assert_eq!(inverse.len(), 1);
match &inverse[0] {
Command::Dependency(DependencyCommand::RemoveBlocks(r)) => {
assert!(r.tolerate_missing, "undo inverse must tolerate missing");
}
other => panic!("expected RemoveBlocks inverse, got {other:?}"),
}
}
#[test]
fn test_add_relates_inverse_is_tolerant_remove_relates() {
let tc = TestContext::new();
let cmd = AddRelates {
source: Uuid::new_v4(),
target: Uuid::new_v4(),
kind: RelatesKind::Duplicates,
as_archived: false,
};
let inverse = cmd.capture_inverse(&tc.store).unwrap();
assert_eq!(inverse.len(), 1);
match &inverse[0] {
Command::Dependency(DependencyCommand::RemoveRelates(r)) => {
assert!(r.tolerate_missing, "undo inverse must tolerate missing");
}
other => panic!("expected RemoveRelates inverse, got {other:?}"),
}
}
#[test]
fn test_remove_spawns_tolerant_succeeds_on_missing_edge() {
let tc = TestContext::new();
let context = tc.as_command_context();
let result = RemoveSpawns {
source: Uuid::new_v4(),
target: Uuid::new_v4(),
tolerate_missing: true,
}
.execute(&context);
assert!(result.is_ok(), "tolerant remove must swallow EdgeNotFound");
}
#[test]
fn test_remove_spawns_strict_errors_on_missing_edge() {
let tc = TestContext::new();
let context = tc.as_command_context();
let result = RemoveSpawns {
source: Uuid::new_v4(),
target: Uuid::new_v4(),
tolerate_missing: false,
}
.execute(&context);
assert!(
result.unwrap_err().is_edge_not_found(),
"strict remove must propagate EdgeNotFound"
);
}
#[test]
fn test_remove_blocks_tolerant_succeeds_on_missing_edge() {
let tc = TestContext::new();
let context = tc.as_command_context();
let result = RemoveBlocks {
source: Uuid::new_v4(),
target: Uuid::new_v4(),
tolerate_missing: true,
}
.execute(&context);
assert!(result.is_ok());
}
#[test]
fn test_remove_relates_tolerant_succeeds_on_missing_edge() {
let tc = TestContext::new();
let context = tc.as_command_context();
let result = RemoveRelates {
source: Uuid::new_v4(),
target: Uuid::new_v4(),
tolerate_missing: true,
}
.execute(&context);
assert!(result.is_ok());
}
#[test]
fn test_dependency_command_serialization_shape_is_stable() {
let source = Uuid::nil();
let target = Uuid::from_u128(0x42);
let add_spawns = DependencyCommand::AddSpawns(AddSpawns {
source,
target,
as_archived: false,
});
let json = serde_json::to_value(&add_spawns).unwrap();
assert_eq!(json["action"], "add_spawns");
let add_blocks = DependencyCommand::AddBlocks(AddBlocks {
source,
target,
severity: Severity::High,
as_archived: false,
});
let json = serde_json::to_value(&add_blocks).unwrap();
assert_eq!(json["action"], "add_blocks");
assert_eq!(json["severity"], "High");
let add_relates = DependencyCommand::AddRelates(AddRelates {
source,
target,
kind: RelatesKind::Duplicates,
as_archived: false,
});
let json = serde_json::to_value(&add_relates).unwrap();
assert_eq!(json["action"], "add_relates");
assert_eq!(json["kind"], "Duplicates");
let remove_blocks = DependencyCommand::RemoveBlocks(RemoveBlocks {
source,
target,
tolerate_missing: false,
});
let json = serde_json::to_value(&remove_blocks).unwrap();
assert_eq!(json["action"], "remove_blocks");
assert_eq!(json["tolerate_missing"], false);
let legacy: DependencyCommand = serde_json::from_value(serde_json::json!({
"action": "remove_spawns",
"source": source,
"target": target
}))
.expect("legacy remove_spawns without tolerate_missing must deserialise");
match legacy {
DependencyCommand::RemoveSpawns(r) => {
assert!(!r.tolerate_missing, "default must be strict");
}
other => panic!("expected RemoveSpawns, got {other:?}"),
}
let round: DependencyCommand =
serde_json::from_value(serde_json::to_value(&add_blocks).unwrap()).unwrap();
assert!(matches!(
round,
DependencyCommand::AddBlocks(AddBlocks {
severity: Severity::High,
..
})
));
}
#[test]
fn test_create_subcard_command() {
use crate::Board;
let tc = TestContext::new();
let column_id = Uuid::new_v4();
let mut board = Board::new("Test Board", None::<String>);
board.card_prefix = Some("TEST".to_string());
let board_id = board.id;
let parent = crate::Card::new(&mut board, column_id, "Parent", 0);
let parent_id = parent.id;
tc.store.upsert_board(board).unwrap();
tc.store.upsert_card(parent).unwrap();
let context = tc.as_command_context();
let cmd = CreateSubcardCommand {
id: Uuid::new_v4(),
parent_id,
board_id,
column_id,
title: "Test Subcard".to_string(),
description: Some("Test description".to_string()),
position: 0,
};
assert!(cmd.execute(&context).is_ok());
let cards = tc.store.list_all_cards().unwrap();
assert_eq!(cards.len(), 2);
let card = cards.iter().find(|c| c.title == "Test Subcard").unwrap();
assert_eq!(card.description, Some("Test description".to_string()));
assert_eq!(card.column_id, column_id);
let graph = tc.store.get_graph().unwrap();
assert_eq!(graph.children(parent_id).len(), 1);
assert!(graph.children(parent_id).contains(&card.id));
}
#[test]
fn test_create_subcard_with_nonexistent_parent_returns_not_found() {
let tc = TestContext::new();
let board = crate::Board::new("B", Some("TST"));
let col = crate::Column::new(board.id, "Col", 0);
let board_id = board.id;
let column_id = col.id;
tc.store.upsert_board(board).unwrap();
tc.store.upsert_column(col).unwrap();
let context = tc.as_command_context();
let cmd = CreateSubcardCommand {
id: Uuid::new_v4(),
parent_id: Uuid::new_v4(),
board_id,
column_id,
title: "Subcard".to_string(),
description: None,
position: 0,
};
let result = cmd.execute(&context);
assert!(result.is_err(), "Expected error for nonexistent parent");
assert!(result.unwrap_err().is_not_found());
}
}