feat(sprints): modèle de sprints — domaine, use-cases, persistance et surfaces (backend)
Ticket #10 — introduction du modèle de sprints côté backend. - Domaine : nouvel agrégat Sprint (sprint.rs), IDs, événements et invariants ; rattachement des issues à un sprint (issue.rs) et ports associés. - Application : use-cases sprints (application/src/sprints) + erreurs dédiées. - Infrastructure : store de sprints (infrastructure/src/sprints.rs), adaptation du store d'issues et exposition MCP via orchestrator/mcp/tickets.rs. - app-tauri : commandes, state et events pour piloter les sprints depuis l'UI. Tests domaine/application/infra/app-tauri verts (sprint_usecases, sprint_store, issue_store, mcp_server). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -2,10 +2,13 @@
|
||||
//! presentation layer (ARCHITECTURE §3.2).
|
||||
|
||||
use crate::conversation::ConversationParty;
|
||||
use crate::ids::{AgentId, IssueId, ProfileId, ProjectId, SessionId, SkillId, TaskId, TemplateId};
|
||||
use crate::ids::{
|
||||
AgentId, IssueId, ProfileId, ProjectId, SessionId, SkillId, SprintId, TaskId, TemplateId,
|
||||
};
|
||||
use crate::issue::{IssueLinkKind, IssuePriority, IssueRef, IssueStatus, IssueVersion};
|
||||
use crate::mailbox::TicketId;
|
||||
use crate::memory::MemorySlug;
|
||||
use crate::sprint::{SprintOrder, SprintVersion};
|
||||
use crate::template::TemplateVersion;
|
||||
|
||||
/// Which entry door a processed orchestration request arrived through.
|
||||
@ -367,6 +370,47 @@ pub enum DomainEvent {
|
||||
/// New optimistic version.
|
||||
version: IssueVersion,
|
||||
},
|
||||
/// A sprint was created.
|
||||
SprintCreated {
|
||||
/// Stable sprint id.
|
||||
sprint_id: SprintId,
|
||||
/// Reorderable sprint order.
|
||||
order: SprintOrder,
|
||||
},
|
||||
/// A sprint was renamed.
|
||||
SprintRenamed {
|
||||
/// Stable sprint id.
|
||||
sprint_id: SprintId,
|
||||
/// New sprint name.
|
||||
name: String,
|
||||
/// New optimistic version.
|
||||
version: SprintVersion,
|
||||
},
|
||||
/// Sprint orders changed.
|
||||
SprintReordered {
|
||||
/// Stable sprint id.
|
||||
sprint_id: SprintId,
|
||||
/// New sprint order.
|
||||
order: SprintOrder,
|
||||
/// New optimistic version.
|
||||
version: SprintVersion,
|
||||
},
|
||||
/// A sprint was deleted.
|
||||
SprintDeleted {
|
||||
/// Stable sprint id.
|
||||
sprint_id: SprintId,
|
||||
},
|
||||
/// An issue was moved into, out of, or between sprints.
|
||||
IssueSprintChanged {
|
||||
/// Human-friendly issue reference.
|
||||
issue_ref: IssueRef,
|
||||
/// Previous sprint.
|
||||
from: Option<SprintId>,
|
||||
/// New sprint.
|
||||
to: Option<SprintId>,
|
||||
/// New optimistic version.
|
||||
version: IssueVersion,
|
||||
},
|
||||
/// A project's memory grew past the recall budget while no embedder is
|
||||
/// configured (strategy `none`): the first moment a semantic embedder would
|
||||
/// help (ARCHITECTURE §14.5.5, LOT C3). Published **at most once per session per
|
||||
|
||||
@ -76,6 +76,10 @@ typed_id!(
|
||||
/// Identifies a [`crate::issue::Issue`].
|
||||
IssueId
|
||||
);
|
||||
typed_id!(
|
||||
/// Identifies a [`crate::sprint::Sprint`].
|
||||
SprintId
|
||||
);
|
||||
typed_id!(
|
||||
/// Identifies a [`crate::terminal::TerminalSession`].
|
||||
SessionId
|
||||
|
||||
@ -8,7 +8,7 @@ use std::str::FromStr;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::ids::{AgentId, IssueId};
|
||||
use crate::ids::{AgentId, IssueId, SprintId};
|
||||
use crate::markdown::MarkdownDoc;
|
||||
|
||||
/// Sequential per-project issue number.
|
||||
@ -255,6 +255,8 @@ pub struct Issue {
|
||||
pub status: IssueStatus,
|
||||
/// Priority.
|
||||
pub priority: IssuePriority,
|
||||
/// Sprint membership. `None` means backlog / no sprint.
|
||||
pub sprint: Option<SprintId>,
|
||||
/// Editable issue-local knowledge.
|
||||
pub carnet: MarkdownDoc,
|
||||
/// Links to other issues.
|
||||
@ -299,6 +301,7 @@ impl Issue {
|
||||
description,
|
||||
status,
|
||||
priority,
|
||||
sprint: None,
|
||||
carnet,
|
||||
links,
|
||||
agent_refs,
|
||||
@ -392,6 +395,8 @@ pub struct IssueIndexEntry {
|
||||
pub status: IssueStatus,
|
||||
/// Priority.
|
||||
pub priority: IssuePriority,
|
||||
/// Sprint membership.
|
||||
pub sprint: Option<SprintId>,
|
||||
/// Assigned agent ids.
|
||||
pub assigned_agent_ids: Vec<AgentId>,
|
||||
/// Last update time.
|
||||
@ -406,6 +411,7 @@ impl From<&Issue> for IssueIndexEntry {
|
||||
title: issue.title.clone(),
|
||||
status: issue.status,
|
||||
priority: issue.priority,
|
||||
sprint: issue.sprint,
|
||||
assigned_agent_ids: issue
|
||||
.agent_refs
|
||||
.iter()
|
||||
@ -426,6 +432,8 @@ pub struct IssueListFilter {
|
||||
pub priority: Option<IssuePriority>,
|
||||
/// Optional assigned agent filter.
|
||||
pub assigned_agent_id: Option<AgentId>,
|
||||
/// Optional sprint membership filter.
|
||||
pub sprint: Option<SprintId>,
|
||||
/// Optional case-insensitive text query.
|
||||
pub text: Option<String>,
|
||||
}
|
||||
|
||||
@ -58,6 +58,7 @@ pub mod remote;
|
||||
pub mod sandbox;
|
||||
pub mod session_limit;
|
||||
pub mod skill;
|
||||
pub mod sprint;
|
||||
pub mod template;
|
||||
pub mod terminal;
|
||||
|
||||
@ -71,7 +72,7 @@ pub use error::DomainError;
|
||||
|
||||
pub use ids::{
|
||||
AgentId, IssueId, LayoutId, NodeId, ProfileId, ProjectId, ScheduleId, SessionId, SkillId,
|
||||
TabId, TaskId, TemplateId, WindowId,
|
||||
SprintId, TabId, TaskId, TemplateId, WindowId,
|
||||
};
|
||||
|
||||
pub use project::{Project, ProjectPath};
|
||||
@ -116,6 +117,11 @@ pub use issue::{
|
||||
IssueVersion,
|
||||
};
|
||||
|
||||
pub use sprint::{
|
||||
validate_sprint_ordering, Sprint, SprintError, SprintIndexEntry, SprintOrder, SprintStatus,
|
||||
SprintVersion,
|
||||
};
|
||||
|
||||
pub use live_state::{LiveEntry, LiveState, WorkStatus, FIELD_MAX_BYTES, FIELD_PREVIEW_MAX_CHARS};
|
||||
|
||||
pub use readiness::{ReadinessPolicy, ReadinessSignal};
|
||||
@ -183,5 +189,5 @@ pub use ports::{
|
||||
IssueStoreError, LiveStateStore, MemoryError, MemoryQuery, MemoryRecall, MemoryStore, Output,
|
||||
OutputStream, PermissionStore, PreparedContext, ProcessError, ProcessSpawner, ProfileStore,
|
||||
ProjectStore, PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError,
|
||||
ScheduledTask, Scheduler, SpawnSpec, StoreError, TemplateStore,
|
||||
ScheduledTask, Scheduler, SpawnSpec, SprintStore, SprintStoreError, StoreError, TemplateStore,
|
||||
};
|
||||
|
||||
@ -32,7 +32,7 @@ use crate::background_task::{
|
||||
BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskWakePolicy,
|
||||
};
|
||||
use crate::events::DomainEvent;
|
||||
use crate::ids::{AgentId, NodeId, ProjectId, ScheduleId, SessionId, TaskId};
|
||||
use crate::ids::{AgentId, NodeId, ProjectId, ScheduleId, SessionId, SprintId, TaskId};
|
||||
use crate::issue::{
|
||||
Issue, IssueCarnet, IssueIndexEntry, IssueListFilter, IssueNumber, IssueRef, IssueVersion,
|
||||
};
|
||||
@ -43,6 +43,7 @@ use crate::profile::{AgentProfile, EmbedderProfile};
|
||||
use crate::project::{Project, ProjectPath};
|
||||
use crate::remote::RemoteKind;
|
||||
use crate::skill::{Skill, SkillScope};
|
||||
use crate::sprint::{Sprint, SprintIndexEntry, SprintVersion};
|
||||
use crate::template::AgentTemplate;
|
||||
use crate::terminal::PtySize;
|
||||
|
||||
@ -596,6 +597,28 @@ pub enum IssueStoreError {
|
||||
#[error("issue store failed: {0}")]
|
||||
Store(String),
|
||||
}
|
||||
|
||||
/// Errors from the sprint store.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum SprintStoreError {
|
||||
/// The requested sprint does not exist.
|
||||
#[error("sprint not found")]
|
||||
NotFound,
|
||||
/// Optimistic concurrency conflict.
|
||||
#[error("sprint version conflict: expected {expected}, actual {actual}")]
|
||||
VersionConflict {
|
||||
/// Expected version.
|
||||
expected: SprintVersion,
|
||||
/// Actual persisted version.
|
||||
actual: SprintVersion,
|
||||
},
|
||||
/// The sprint payload is invalid.
|
||||
#[error("sprint invalid: {0}")]
|
||||
Invalid(String),
|
||||
/// Store I/O or serialization failed.
|
||||
#[error("sprint store failed: {0}")]
|
||||
Store(String),
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Git port support types
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -1507,6 +1530,50 @@ pub trait IssueStore: Send + Sync {
|
||||
) -> Result<IssueCarnet, IssueStoreError>;
|
||||
}
|
||||
|
||||
/// Persistence port for project-scoped sprints.
|
||||
#[async_trait]
|
||||
pub trait SprintStore: Send + Sync {
|
||||
/// Creates a new sprint.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`SprintStoreError`] when the sprint already exists or cannot be stored.
|
||||
async fn create(&self, root: &ProjectPath, sprint: &Sprint) -> Result<(), SprintStoreError>;
|
||||
|
||||
/// Reads a sprint by stable id.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`SprintStoreError::NotFound`] when absent.
|
||||
async fn get(
|
||||
&self,
|
||||
root: &ProjectPath,
|
||||
sprint_id: SprintId,
|
||||
) -> Result<Sprint, SprintStoreError>;
|
||||
|
||||
/// Lists sprint index entries.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`SprintStoreError`] on persistence failure.
|
||||
async fn list(&self, root: &ProjectPath) -> Result<Vec<SprintIndexEntry>, SprintStoreError>;
|
||||
|
||||
/// Replaces a sprint after checking its expected version.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`SprintStoreError::VersionConflict`] on optimistic-concurrency conflict.
|
||||
async fn update(
|
||||
&self,
|
||||
root: &ProjectPath,
|
||||
sprint: &Sprint,
|
||||
expected_version: SprintVersion,
|
||||
) -> Result<(), SprintStoreError>;
|
||||
|
||||
/// Deletes a sprint by id.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`SprintStoreError::NotFound`] when absent.
|
||||
async fn delete(&self, root: &ProjectPath, sprint_id: SprintId)
|
||||
-> Result<(), SprintStoreError>;
|
||||
}
|
||||
|
||||
/// Git operations for a project. Named `GitPort` to avoid clashing with the
|
||||
/// [`crate::git::GitRepository`] *entity* (state image).
|
||||
#[async_trait]
|
||||
|
||||
350
crates/domain/src/sprint.rs
Normal file
350
crates/domain/src/sprint.rs
Normal file
@ -0,0 +1,350 @@
|
||||
//! Sprint domain model.
|
||||
//!
|
||||
//! A sprint is a project-scoped planning aggregate. Ticket membership remains
|
||||
//! authoritative on [`crate::issue::Issue`].
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::ids::SprintId;
|
||||
use crate::issue::IssueActor;
|
||||
|
||||
/// Reorderable execution position. Values start at 1.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct SprintOrder(u32);
|
||||
|
||||
impl SprintOrder {
|
||||
/// Builds a sprint order.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`SprintError::InvalidOrder`] when `value == 0`.
|
||||
pub fn new(value: u32) -> Result<Self, SprintError> {
|
||||
if value == 0 {
|
||||
return Err(SprintError::InvalidOrder);
|
||||
}
|
||||
Ok(Self(value))
|
||||
}
|
||||
|
||||
/// Returns the raw order.
|
||||
#[must_use]
|
||||
pub const fn get(self) -> u32 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SprintOrder {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Optimistic-concurrency version.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct SprintVersion(u64);
|
||||
|
||||
impl SprintVersion {
|
||||
/// Initial version assigned to a newly-created sprint.
|
||||
pub const INITIAL: Self = Self(1);
|
||||
|
||||
/// Builds a version.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`SprintError::InvalidVersion`] when `value == 0`.
|
||||
pub fn new(value: u64) -> Result<Self, SprintError> {
|
||||
if value == 0 {
|
||||
return Err(SprintError::InvalidVersion);
|
||||
}
|
||||
Ok(Self(value))
|
||||
}
|
||||
|
||||
/// Returns the raw version.
|
||||
#[must_use]
|
||||
pub const fn get(self) -> u64 {
|
||||
self.0
|
||||
}
|
||||
|
||||
/// Returns the next optimistic version.
|
||||
#[must_use]
|
||||
pub const fn next(self) -> Self {
|
||||
Self(self.0 + 1)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SprintVersion {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Sprint lifecycle status.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum SprintStatus {
|
||||
/// Planned and not active yet.
|
||||
Planned,
|
||||
/// Current sprint.
|
||||
Active,
|
||||
/// Completed sprint.
|
||||
Done,
|
||||
}
|
||||
|
||||
impl Default for SprintStatus {
|
||||
fn default() -> Self {
|
||||
Self::Planned
|
||||
}
|
||||
}
|
||||
|
||||
/// Sprint aggregate.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Sprint {
|
||||
/// Stable UUID.
|
||||
pub id: SprintId,
|
||||
/// Reorderable execution position.
|
||||
pub order: SprintOrder,
|
||||
/// Human-readable name. Empty means UI may display `Sprint {order}`.
|
||||
pub name: String,
|
||||
/// Lifecycle status.
|
||||
pub status: SprintStatus,
|
||||
/// Creator.
|
||||
pub created_by: IssueActor,
|
||||
/// Last updater.
|
||||
pub updated_by: IssueActor,
|
||||
/// Creation time, epoch milliseconds.
|
||||
pub created_at: u64,
|
||||
/// Last update time, epoch milliseconds.
|
||||
pub updated_at: u64,
|
||||
/// Optimistic-concurrency version.
|
||||
pub version: SprintVersion,
|
||||
}
|
||||
|
||||
impl Sprint {
|
||||
/// Builds a new sprint with version 1.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`SprintError`] when invariants are violated.
|
||||
pub fn new(
|
||||
id: SprintId,
|
||||
order: SprintOrder,
|
||||
name: impl Into<String>,
|
||||
status: Option<SprintStatus>,
|
||||
actor: IssueActor,
|
||||
now_ms: u64,
|
||||
) -> Result<Self, SprintError> {
|
||||
let sprint = Self {
|
||||
id,
|
||||
order,
|
||||
name: name.into(),
|
||||
status: status.unwrap_or_default(),
|
||||
created_by: actor.clone(),
|
||||
updated_by: actor,
|
||||
created_at: now_ms,
|
||||
updated_at: now_ms,
|
||||
version: SprintVersion::INITIAL,
|
||||
};
|
||||
sprint.validate()?;
|
||||
Ok(sprint)
|
||||
}
|
||||
|
||||
/// Rehydrates a persisted sprint.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`SprintError`] when persisted data violates invariants.
|
||||
pub fn rehydrate(sprint: Self) -> Result<Self, SprintError> {
|
||||
sprint.validate()?;
|
||||
Ok(sprint)
|
||||
}
|
||||
|
||||
/// Validates invariants local to one sprint.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`SprintError`] when an invariant is violated.
|
||||
pub fn validate(&self) -> Result<(), SprintError> {
|
||||
SprintOrder::new(self.order.get())?;
|
||||
SprintVersion::new(self.version.get())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Applies a mutation and increments the version.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`SprintError`] when the resulting sprint violates invariants.
|
||||
pub fn mutate(
|
||||
mut self,
|
||||
actor: IssueActor,
|
||||
now_ms: u64,
|
||||
f: impl FnOnce(&mut Self),
|
||||
) -> Result<Self, SprintError> {
|
||||
f(&mut self);
|
||||
self.updated_by = actor;
|
||||
self.updated_at = now_ms;
|
||||
self.version = self.version.next();
|
||||
self.validate()?;
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// Index row used by stores and list use cases.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SprintIndexEntry {
|
||||
/// Stable sprint id.
|
||||
pub id: SprintId,
|
||||
/// Relative path to the sprint folder.
|
||||
pub path: String,
|
||||
/// Reorderable position.
|
||||
pub order: SprintOrder,
|
||||
/// Human-readable name.
|
||||
pub name: String,
|
||||
/// Lifecycle status.
|
||||
pub status: SprintStatus,
|
||||
/// Last update time.
|
||||
pub updated_at: u64,
|
||||
/// Current optimistic version.
|
||||
pub version: SprintVersion,
|
||||
}
|
||||
|
||||
impl From<&Sprint> for SprintIndexEntry {
|
||||
fn from(sprint: &Sprint) -> Self {
|
||||
Self {
|
||||
id: sprint.id,
|
||||
path: sprint.id.to_string(),
|
||||
order: sprint.order,
|
||||
name: sprint.name.clone(),
|
||||
status: sprint.status,
|
||||
updated_at: sprint.updated_at,
|
||||
version: sprint.version,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates that sprint orders are unique and contiguous from 1.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`SprintError::DuplicateOrder`] or [`SprintError::NonContiguousOrder`].
|
||||
pub fn validate_sprint_ordering<'a>(
|
||||
sprints: impl IntoIterator<Item = &'a Sprint>,
|
||||
) -> Result<(), SprintError> {
|
||||
let mut orders: Vec<u32> = sprints
|
||||
.into_iter()
|
||||
.map(|sprint| sprint.order.get())
|
||||
.collect();
|
||||
orders.sort_unstable();
|
||||
let mut seen = HashSet::new();
|
||||
for order in &orders {
|
||||
if !seen.insert(*order) {
|
||||
return Err(SprintError::DuplicateOrder(*order));
|
||||
}
|
||||
}
|
||||
for (idx, order) in orders.into_iter().enumerate() {
|
||||
let expected = (idx as u32) + 1;
|
||||
if order != expected {
|
||||
return Err(SprintError::NonContiguousOrder {
|
||||
expected,
|
||||
actual: order,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Domain errors for sprint invariants and value objects.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum SprintError {
|
||||
/// Sprint order is strictly positive.
|
||||
#[error("sprint order must be greater than zero")]
|
||||
InvalidOrder,
|
||||
/// Sprint versions are strictly positive.
|
||||
#[error("sprint version must be greater than zero")]
|
||||
InvalidVersion,
|
||||
/// Two sprints share the same order.
|
||||
#[error("duplicate sprint order {0}")]
|
||||
DuplicateOrder(u32),
|
||||
/// Sprint ordering must be contiguous from 1.
|
||||
#[error("non-contiguous sprint order: expected {expected}, actual {actual}")]
|
||||
NonContiguousOrder {
|
||||
/// Expected contiguous value.
|
||||
expected: u32,
|
||||
/// Actual encountered value.
|
||||
actual: u32,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ids::SprintId;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn sid(n: u128) -> SprintId {
|
||||
SprintId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn sprint(order: u32) -> Sprint {
|
||||
Sprint::new(
|
||||
sid(order as u128),
|
||||
SprintOrder::new(order).unwrap(),
|
||||
"",
|
||||
None,
|
||||
IssueActor::User,
|
||||
10,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sprint_defaults_to_planned_and_version_one() {
|
||||
let sprint = sprint(1);
|
||||
assert_eq!(sprint.status, SprintStatus::Planned);
|
||||
assert_eq!(sprint.version, SprintVersion::INITIAL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn order_and_version_reject_zero() {
|
||||
assert_eq!(SprintOrder::new(0), Err(SprintError::InvalidOrder));
|
||||
assert_eq!(SprintVersion::new(0), Err(SprintError::InvalidVersion));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_ordering_accepts_contiguous_unique_orders() {
|
||||
let a = sprint(1);
|
||||
let b = sprint(2);
|
||||
assert_eq!(validate_sprint_ordering([&b, &a]), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_ordering_rejects_duplicates_and_gaps() {
|
||||
let a = sprint(1);
|
||||
let duplicate = sprint(1);
|
||||
assert_eq!(
|
||||
validate_sprint_ordering([&a, &duplicate]),
|
||||
Err(SprintError::DuplicateOrder(1))
|
||||
);
|
||||
|
||||
let c = sprint(3);
|
||||
assert_eq!(
|
||||
validate_sprint_ordering([&a, &c]),
|
||||
Err(SprintError::NonContiguousOrder {
|
||||
expected: 2,
|
||||
actual: 3,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mutate_increments_version_and_tracks_actor() {
|
||||
let updated = sprint(1)
|
||||
.mutate(IssueActor::System, 20, |sprint| {
|
||||
sprint.name = "Delivery".to_owned();
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(updated.name, "Delivery");
|
||||
assert_eq!(updated.updated_by, IssueActor::System);
|
||||
assert_eq!(updated.updated_at, 20);
|
||||
assert_eq!(updated.version.get(), 2);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user