Files
huskies/server/src/pipeline_state/tests.rs
T

856 lines
25 KiB
Rust
Raw Normal View History

2026-04-29 09:25:05 +00:00
//! Tests for the typed pipeline state machine.
2026-04-28 20:56:22 +00:00
use super::*;
use std::num::NonZeroU32;
fn nz(n: u32) -> NonZeroU32 {
NonZeroU32::new(n).unwrap()
}
fn fb(name: &str) -> BranchName {
BranchName(name.to_string())
}
fn sha(s: &str) -> GitSha {
GitSha(s.to_string())
}
fn sid(s: &str) -> StoryId {
StoryId(s.to_string())
}
#[test]
fn happy_path_backlog_through_archived() {
let s = Stage::Backlog;
let s = transition(s, PipelineEvent::DepsMet).unwrap();
assert!(matches!(s, Stage::Coding));
let s = transition(
s,
PipelineEvent::QaSkipped {
feature_branch: fb("feature/story-1"),
commits_ahead: nz(3),
},
)
.unwrap();
assert!(matches!(s, Stage::Merge { .. }));
let s = transition(
s,
PipelineEvent::MergeSucceeded {
merge_commit: sha("abc123"),
},
)
.unwrap();
assert!(matches!(s, Stage::Done { .. }));
let s = transition(s, PipelineEvent::Accepted).unwrap();
assert!(matches!(
s,
Stage::Archived {
reason: ArchiveReason::Completed,
..
}
));
}
#[test]
fn happy_path_with_qa() {
let s = Stage::Coding;
let s = transition(s, PipelineEvent::GatesStarted).unwrap();
assert!(matches!(s, Stage::Qa));
let s = transition(
s,
PipelineEvent::GatesPassed {
feature_branch: fb("feature/story-2"),
commits_ahead: nz(5),
},
)
.unwrap();
assert!(matches!(s, Stage::Merge { .. }));
}
#[test]
fn qa_retry_loop() {
let s = Stage::Coding;
let s = transition(s, PipelineEvent::GatesStarted).unwrap();
assert!(matches!(s, Stage::Qa));
let s = transition(
s,
PipelineEvent::GatesFailed {
reason: "tests failed".into(),
},
)
.unwrap();
assert!(matches!(s, Stage::Coding));
}
// ── Bug 519: Merge with zero commits is unrepresentable ─────────────
#[test]
fn merge_with_zero_commits_is_unrepresentable() {
assert!(NonZeroU32::new(0).is_none());
}
// ── Invalid transitions ─────────────────────────────────────────────
#[test]
fn cannot_jump_backlog_to_done() {
let result = transition(Stage::Backlog, PipelineEvent::Accepted);
assert!(matches!(
result,
Err(TransitionError::InvalidTransition { .. })
));
}
#[test]
fn cannot_unblock_done_story() {
let s = Stage::Done {
merged_at: chrono::Utc::now(),
merge_commit: sha("abc"),
};
let result = transition(s, PipelineEvent::Unblock);
assert!(matches!(
result,
Err(TransitionError::InvalidTransition { .. })
));
}
#[test]
fn cannot_unblock_review_held_story() {
let s = Stage::Archived {
archived_at: chrono::Utc::now(),
reason: ArchiveReason::ReviewHeld {
reason: "TBD".into(),
},
};
let result = transition(s, PipelineEvent::Unblock);
assert!(matches!(
result,
Err(TransitionError::InvalidTransition { .. })
));
}
#[test]
fn cannot_merge_from_backlog() {
let result = transition(
Stage::Backlog,
PipelineEvent::MergeSucceeded {
merge_commit: sha("abc"),
},
);
assert!(matches!(
result,
Err(TransitionError::InvalidTransition { .. })
));
}
#[test]
fn cannot_start_gates_from_backlog() {
let result = transition(Stage::Backlog, PipelineEvent::GatesStarted);
assert!(matches!(
result,
Err(TransitionError::InvalidTransition { .. })
));
}
#[test]
fn cannot_accept_from_coding() {
let result = transition(Stage::Coding, PipelineEvent::Accepted);
assert!(matches!(
result,
Err(TransitionError::InvalidTransition { .. })
));
}
// ── Block from any active stage ─────────────────────────────────────
#[test]
fn block_from_any_active_stage() {
for s in [Stage::Backlog, Stage::Coding, Stage::Qa] {
let result = transition(
s.clone(),
PipelineEvent::Block {
reason: "stuck".into(),
},
);
2026-04-29 22:42:59 +00:00
assert!(matches!(result, Ok(Stage::Blocked { .. })));
2026-04-28 20:56:22 +00:00
}
let m = Stage::Merge {
feature_branch: fb("f"),
commits_ahead: nz(1),
};
let result = transition(
m,
PipelineEvent::Block {
reason: "stuck".into(),
},
);
2026-04-29 22:42:59 +00:00
assert!(matches!(result, Ok(Stage::Blocked { .. })));
}
#[test]
fn unblock_returns_to_coding() {
let s = Stage::Blocked {
reason: "test".into(),
};
let result = transition(s, PipelineEvent::Unblock).unwrap();
assert!(matches!(result, Stage::Coding));
2026-04-28 20:56:22 +00:00
}
#[test]
fn blocked_demote_returns_to_backlog() {
// Stuck-story parking lane: `Blocked + Demote → Backlog` lets operators
// move a blocked story back to the backlog without losing it to
// Archived. Complements `Blocked + Unblock → Coding` which re-enters
// active work.
let s = Stage::Blocked {
reason: "waiting on dep".into(),
};
let result = transition(s, PipelineEvent::Demote).unwrap();
assert!(matches!(result, Stage::Backlog));
}
#[test]
fn cannot_demote_from_done() {
// Sanity: Demote remains illegal from terminal/archived stages — the
// new `Blocked + Demote → Backlog` rule must NOT broaden it further.
let s = Stage::Done {
merged_at: chrono::Utc::now(),
merge_commit: sha("x"),
};
assert!(matches!(
transition(s, PipelineEvent::Demote),
Err(TransitionError::InvalidTransition { .. })
));
}
#[test]
fn cannot_demote_from_upcoming() {
let s = Stage::Upcoming;
assert!(matches!(
transition(s, PipelineEvent::Demote),
Err(TransitionError::InvalidTransition { .. })
));
}
2026-04-28 20:56:22 +00:00
#[test]
2026-04-29 22:42:59 +00:00
fn legacy_unblock_archived_blocked_returns_to_backlog() {
2026-04-28 20:56:22 +00:00
let s = Stage::Archived {
archived_at: chrono::Utc::now(),
reason: ArchiveReason::Blocked {
reason: "test".into(),
},
};
let result = transition(s, PipelineEvent::Unblock).unwrap();
assert!(matches!(result, Stage::Backlog));
}
// ── Abandon / supersede ─────────────────────────────────────────────
#[test]
fn abandon_from_any_active_or_done() {
for s in [
Stage::Backlog,
Stage::Coding,
Stage::Qa,
Stage::Done {
merged_at: chrono::Utc::now(),
merge_commit: sha("x"),
},
] {
let result = transition(s, PipelineEvent::Abandon);
assert!(matches!(
result,
Ok(Stage::Archived {
reason: ArchiveReason::Abandoned,
..
})
));
}
}
#[test]
fn supersede_from_any_active_or_done() {
for s in [
Stage::Backlog,
Stage::Coding,
Stage::Qa,
Stage::Done {
merged_at: chrono::Utc::now(),
merge_commit: sha("x"),
},
] {
let result = transition(
s,
PipelineEvent::Supersede {
by: sid("999_story_new"),
},
);
assert!(matches!(
result,
Ok(Stage::Archived {
reason: ArchiveReason::Superseded { .. },
..
})
));
}
}
// ── Review hold ─────────────────────────────────────────────────────
#[test]
fn review_hold_from_active_stages() {
2026-05-13 06:05:01 +00:00
// Story 945: `ReviewHold` transitions to `Stage::ReviewHold { resume_to }`
// with the resume_to set to the originating stage, replacing the legacy
// boolean flag.
2026-04-28 20:56:22 +00:00
for s in [Stage::Backlog, Stage::Coding, Stage::Qa] {
let result = transition(
s.clone(),
PipelineEvent::ReviewHold {
reason: "review".into(),
},
);
2026-05-13 06:05:01 +00:00
let resumed = match result {
Ok(Stage::ReviewHold { resume_to, .. }) => *resume_to,
other => panic!("ReviewHold should produce Stage::ReviewHold; got {other:?}"),
};
assert_eq!(
resumed, s,
"resume_to should preserve the originating stage"
);
2026-04-28 20:56:22 +00:00
}
}
// ── Merge failed final ──────────────────────────────────────────────
#[test]
fn merge_failed_final() {
let s = Stage::Merge {
feature_branch: fb("f"),
commits_ahead: nz(1),
};
let result = transition(
s,
PipelineEvent::MergeFailedFinal {
reason: "conflicts".into(),
},
)
.unwrap();
assert!(matches!(
result,
Stage::Archived {
reason: ArchiveReason::MergeFailed { .. },
..
}
));
}
#[test]
fn merge_failed_only_from_merge() {
let result = transition(
Stage::Coding,
PipelineEvent::MergeFailedFinal {
reason: "conflicts".into(),
},
);
assert!(matches!(
result,
Err(TransitionError::InvalidTransition { .. })
));
}
// ── Execution state machine ─────────────────────────────────────────
#[test]
fn execution_happy_path() {
let e = ExecutionState::Idle;
let e = execution_transition(
e,
ExecutionEvent::SpawnRequested {
agent: AgentName("coder-1".into()),
},
)
.unwrap();
assert!(matches!(e, ExecutionState::Pending { .. }));
let e = execution_transition(e, ExecutionEvent::SpawnedSuccessfully).unwrap();
assert!(matches!(e, ExecutionState::Running { .. }));
let e = execution_transition(e, ExecutionEvent::Heartbeat).unwrap();
assert!(matches!(e, ExecutionState::Running { .. }));
let e = execution_transition(e, ExecutionEvent::Exited { exit_code: 0 }).unwrap();
assert!(matches!(e, ExecutionState::Completed { exit_code: 0, .. }));
}
#[test]
fn execution_rate_limit_then_resume() {
let e = ExecutionState::Running {
agent: AgentName("coder-1".into()),
started_at: chrono::Utc::now(),
last_heartbeat: chrono::Utc::now(),
};
let e = execution_transition(
e,
ExecutionEvent::HitRateLimit {
resume_at: chrono::Utc::now() + chrono::Duration::minutes(5),
},
)
.unwrap();
assert!(matches!(e, ExecutionState::RateLimited { .. }));
let e = execution_transition(e, ExecutionEvent::SpawnedSuccessfully).unwrap();
assert!(matches!(e, ExecutionState::Running { .. }));
}
#[test]
fn execution_stop_from_anywhere() {
let e = ExecutionState::Running {
agent: AgentName("coder-1".into()),
started_at: chrono::Utc::now(),
last_heartbeat: chrono::Utc::now(),
};
let e = execution_transition(e, ExecutionEvent::Stopped).unwrap();
assert!(matches!(e, ExecutionState::Idle));
}
// ── Projection tests ────────────────────────────────────────────────
#[test]
fn bug_502_agent_not_in_stage() {
// Bug 502 was caused by a coder agent being assigned to a story in
// Merge stage. In the typed system, Stage has no `agent` field at all.
// Agent assignment is per-node ExecutionState. This test documents that
// the old failure mode is structurally impossible.
let merge = Stage::Merge {
feature_branch: BranchName("feature/story-1".into()),
commits_ahead: NonZeroU32::new(3).unwrap(),
};
// Stage::Merge has exactly two fields: feature_branch and commits_ahead.
// There is no way to attach an agent name to it. The type system
// prevents bug 502 by construction.
assert!(matches!(merge, Stage::Merge { .. }));
}
// ── TransitionError Display ─────────────────────────────────────────
#[test]
fn transition_error_display() {
let err = TransitionError::InvalidTransition {
from_stage: "Backlog".into(),
event: "Accepted".into(),
};
assert_eq!(err.to_string(), "invalid transition: Backlog + Accepted");
}
2026-04-29 17:38:38 +00:00
// ── Upcoming / Triage ──────────────────────────────────────────────
#[test]
fn triage_upcoming_to_backlog() {
let s = Stage::Upcoming;
let s = transition(s, PipelineEvent::Triage).unwrap();
assert!(matches!(s, Stage::Backlog));
}
#[test]
fn cannot_triage_from_backlog() {
let result = transition(Stage::Backlog, PipelineEvent::Triage);
assert!(matches!(
result,
Err(TransitionError::InvalidTransition { .. })
));
}
#[test]
fn abandon_from_upcoming() {
let result = transition(Stage::Upcoming, PipelineEvent::Abandon).unwrap();
assert!(matches!(
result,
Stage::Archived {
reason: ArchiveReason::Abandoned,
..
}
));
}
#[test]
fn supersede_from_upcoming() {
let result = transition(
Stage::Upcoming,
PipelineEvent::Supersede {
by: sid("999_story_new"),
},
)
.unwrap();
assert!(matches!(
result,
Stage::Archived {
reason: ArchiveReason::Superseded { .. },
..
}
));
}
#[test]
fn cannot_deps_met_from_upcoming() {
let result = transition(Stage::Upcoming, PipelineEvent::DepsMet);
assert!(matches!(
result,
Err(TransitionError::InvalidTransition { .. })
));
}
// ── Reject ─────────────────────────────────────────────────────────
#[test]
fn reject_from_active_stages() {
for s in [Stage::Backlog, Stage::Coding, Stage::Qa] {
let result = transition(
s.clone(),
PipelineEvent::Reject {
reason: "not needed".into(),
},
);
assert!(matches!(
result,
Ok(Stage::Archived {
reason: ArchiveReason::Rejected { .. },
..
})
));
}
let m = Stage::Merge {
feature_branch: fb("f"),
commits_ahead: nz(1),
};
let result = transition(
m,
PipelineEvent::Reject {
reason: "not needed".into(),
},
);
assert!(matches!(
result,
Ok(Stage::Archived {
reason: ArchiveReason::Rejected { .. },
..
})
));
}
#[test]
fn cannot_reject_from_done() {
let s = Stage::Done {
merged_at: chrono::Utc::now(),
merge_commit: sha("abc"),
};
let result = transition(
s,
PipelineEvent::Reject {
reason: "too late".into(),
},
);
assert!(matches!(
result,
Err(TransitionError::InvalidTransition { .. })
));
}
#[test]
fn cannot_reject_from_archived() {
let s = Stage::Archived {
archived_at: chrono::Utc::now(),
reason: ArchiveReason::Completed,
};
let result = transition(
s,
PipelineEvent::Reject {
reason: "already done".into(),
},
);
assert!(matches!(
result,
Err(TransitionError::InvalidTransition { .. })
));
}
// ── Freeze / Unfreeze (story 934, stage 4: orthogonal flag) ────────────────
2026-04-29 22:12:23 +00:00
/// Freeze sets the `frozen` flag without changing the stage register.
/// Unfreeze clears the flag — the stage was never touched so there's nothing
/// to "restore". Tests the freeze/unfreeze API on the apply layer, since
/// freeze/unfreeze are no longer pure stage transitions.
2026-04-29 22:12:23 +00:00
#[test]
2026-05-13 06:05:01 +00:00
fn freeze_transitions_to_frozen_variant_with_resume_to() {
// Story 945: freeze/unfreeze move the typed stage to `Stage::Frozen
// { resume_to }` and back, replacing the orthogonal boolean flag.
2026-04-29 22:12:23 +00:00
crate::crdt_state::init_for_test();
crate::db::ensure_content_store();
let story_id = "9950_story_freeze_regression";
2026-04-30 22:23:21 +00:00
crate::db::write_item_with_content(
story_id,
"2_current",
"---\nname: Freeze Regression\n---\n# Story\n",
crate::db::ItemMeta::named("Freeze Regression"),
2026-04-30 22:23:21 +00:00
);
2026-04-29 22:12:23 +00:00
let item = read_typed(story_id).unwrap().unwrap();
assert!(matches!(item.stage, Stage::Coding));
assert!(!item.is_frozen());
2026-04-29 22:12:23 +00:00
super::apply::transition_to_frozen(story_id).expect("freeze should succeed");
let item = read_typed(story_id).unwrap().unwrap();
2026-05-13 06:05:01 +00:00
match &item.stage {
Stage::Frozen { resume_to } => assert!(
matches!(**resume_to, Stage::Coding),
"resume_to should preserve the previous stage; got {resume_to:?}"
),
other => panic!("stage should be Stage::Frozen after freeze; got {other:?}"),
}
assert!(item.is_frozen(), "is_frozen() should be true after freeze");
2026-04-29 22:12:23 +00:00
super::apply::transition_to_unfrozen(story_id).expect("unfreeze should succeed");
let item = read_typed(story_id).unwrap().unwrap();
assert!(
matches!(item.stage, Stage::Coding),
2026-05-13 06:05:01 +00:00
"stage should return to Coding after unfreeze: {:?}",
2026-04-29 22:12:23 +00:00
item.stage
);
assert!(
!item.is_frozen(),
2026-05-13 06:05:01 +00:00
"is_frozen() should be false after unfreeze"
);
2026-04-29 22:12:23 +00:00
}
2026-04-29 23:28:57 +00:00
// ── Story 868: MergeFailure regression ─────────────────────────────
/// Regression test (story 868): applying `PipelineEvent::MergeFailed` to a story
/// in `Stage::Merge` transitions it to `Stage::MergeFailure` and the emitted
/// `TransitionFired` event carries the full reason string in its payload.
#[test]
fn merge_failure_transition_emits_event_with_full_reason() {
crate::crdt_state::init_for_test();
crate::db::ensure_content_store();
let story_id = "99868_story_merge_failure_event";
crate::db::write_item_with_content(
story_id,
"4_merge",
"---\nname: Merge Failure Event Test\n---\n# Story\n",
crate::db::ItemMeta::named("Merge Failure Event Test"),
2026-04-29 23:28:57 +00:00
);
let reason = "Conflict in server/src/main.rs: both modified";
let fired = super::apply::apply_transition(
story_id,
PipelineEvent::MergeFailed {
reason: reason.to_string(),
},
None,
)
.expect("MergeFailed transition should succeed");
// The emitted event payload carries the full reason string.
match &fired.event {
PipelineEvent::MergeFailed { reason: r } => {
assert_eq!(r, reason, "emitted event should carry the full reason");
}
other => panic!("expected MergeFailed event, got: {other:?}"),
}
// The story transitioned to MergeFailure.
assert!(
matches!(fired.after, Stage::MergeFailure { .. }),
"after-stage should be MergeFailure: {:?}",
fired.after
);
// Verify CRDT reflects the new stage.
let item = read_typed(story_id)
.expect("CRDT read should succeed")
.expect("item should exist");
assert_eq!(
item.stage.dir_name(),
"merge_failure",
2026-04-29 23:28:57 +00:00
"CRDT stage should be 4_merge_failure"
);
}
2026-05-12 15:25:12 +00:00
// ── Story 913: MergeFailure + MergeFailed self-loop ────────────────
/// AC1: `MergeFailure + MergeFailed` is a valid self-transition — no error logged.
#[test]
fn merge_failure_plus_merge_failed_is_self_loop() {
let s = Stage::MergeFailure {
reason: "initial failure".into(),
};
let result = transition(
s,
PipelineEvent::MergeFailed {
reason: "second failure".into(),
},
);
assert!(
matches!(result, Ok(Stage::MergeFailure { .. })),
"MergeFailure + MergeFailed should self-loop to MergeFailure, got: {result:?}"
);
}
/// AC2 + AC3: applying `MergeFailed` to a story already in `MergeFailure` succeeds and
/// the `TransitionFired::before` is `MergeFailure`, allowing callers to suppress the
/// duplicate notification.
#[test]
fn repeated_merge_failure_apply_transition_no_error_no_duplicate_notification() {
crate::crdt_state::init_for_test();
crate::db::ensure_content_store();
let story_id = "99913_story_merge_failure_selfloop";
crate::db::write_item_with_content(
story_id,
"merge_failure",
2026-05-12 15:25:12 +00:00
"---\nname: MergeFailure Self-loop Test\n---\n# Story\n",
crate::db::ItemMeta::named("MergeFailure Self-loop Test"),
2026-05-12 15:25:12 +00:00
);
// Apply a second MergeFailed to a story already in MergeFailure.
let fired = super::apply::apply_transition(
story_id,
PipelineEvent::MergeFailed {
reason: "duplicate failure".into(),
},
None,
)
.expect("MergeFailed on already-failed story should succeed without error");
// The before-stage was MergeFailure: this was a self-loop.
// Callers check this to decide whether to suppress the chat notification.
assert!(
matches!(fired.before, Stage::MergeFailure { .. }),
"fired.before should be MergeFailure (self-loop): {:?}",
fired.before
);
assert!(
matches!(fired.after, Stage::MergeFailure { .. }),
"fired.after should remain MergeFailure: {:?}",
fired.after
);
// Verify the CRDT stage is still merge_failure.
2026-05-12 15:25:12 +00:00
let item = read_typed(story_id)
.expect("CRDT read should succeed")
.expect("item should still exist");
assert_eq!(
item.stage.dir_name(),
"merge_failure",
"CRDT stage should remain merge_failure after self-loop"
2026-05-12 15:25:12 +00:00
);
// Simulate the caller's de-dup logic: since fired.before is already MergeFailure,
// no notification should be dispatched.
let should_notify = !matches!(fired.before, Stage::MergeFailure { .. });
assert!(
!should_notify,
"should_notify must be false for a self-loop to prevent duplicate notification"
);
}
2026-05-12 22:42:04 +00:00
// ── Story 893: MergeFailure + Unblock → Coding (retry) ─────────────
/// AC1: `MergeFailure + Unblock` transitions to `Coding` (retry), not `Backlog`.
#[test]
fn merge_failure_unblock_returns_to_coding() {
let s = Stage::MergeFailure {
reason: "conflicts in server/src/main.rs".into(),
};
let result = transition(s, PipelineEvent::Unblock).unwrap();
assert!(
matches!(result, Stage::Coding),
"MergeFailure + Unblock should return to Coding for immediate retry, got: {result:?}"
);
}
/// AC1 (complement): `MergeFailure + Demote` still goes to `Backlog` for manual parking.
#[test]
fn merge_failure_demote_returns_to_backlog() {
let s = Stage::MergeFailure {
reason: "conflicts".into(),
};
let result = transition(s, PipelineEvent::Demote).unwrap();
assert!(
matches!(result, Stage::Backlog),
"MergeFailure + Demote should park the story in Backlog, got: {result:?}"
);
}
2026-05-12 16:46:37 +00:00
// ── Story 892: MergeFailure → Done (manual recovery) ───────────────
/// Regression test (story 892): `accept_story` on a story in `MergeFailure`
/// transitions it to `Done` and emits a `TransitionFired` event.
#[test]
fn merge_failure_accept_pure_transition() {
let s = Stage::MergeFailure {
reason: "conflicts unresolvable".into(),
};
let result = transition(s, PipelineEvent::Accepted).unwrap();
assert!(
matches!(result, Stage::Done { .. }),
"MergeFailure + Accepted should yield Done, got: {result:?}"
);
}
/// Regression test (story 892): `apply_transition` on a CRDT-stored `MergeFailure`
/// story moves it to `Done` and the emitted `TransitionFired` event is present.
#[test]
fn merge_failure_accept_moves_to_done_via_crdt() {
crate::crdt_state::init_for_test();
crate::db::ensure_content_store();
let story_id = "99892_story_merge_failure_accept";
crate::db::write_item_with_content(
story_id,
"merge_failure",
2026-05-12 16:46:37 +00:00
"---\nname: MergeFailure Accept Test\n---\n# Story\n",
crate::db::ItemMeta::named("MergeFailure Accept Test"),
2026-05-12 16:46:37 +00:00
);
let fired = super::apply::apply_transition(story_id, PipelineEvent::Accepted, None)
.expect("MergeFailure + Accepted should succeed");
// The before-stage was MergeFailure.
assert!(
matches!(fired.before, Stage::MergeFailure { .. }),
"fired.before should be MergeFailure: {:?}",
fired.before
);
// The after-stage is Done.
assert!(
matches!(fired.after, Stage::Done { .. }),
"fired.after should be Done: {:?}",
fired.after
);
// TransitionFired carries the Accepted event.
assert!(
matches!(fired.event, PipelineEvent::Accepted),
"fired.event should be Accepted: {:?}",
fired.event
);
// CRDT reflects done.
2026-05-12 16:46:37 +00:00
let item = read_typed(story_id)
.expect("CRDT read should succeed")
.expect("item should exist");
assert_eq!(
item.stage.dir_name(),
"done",
"CRDT stage should be done after MergeFailure + Accepted"
2026-05-12 16:46:37 +00:00
);
}
2026-04-28 20:56:22 +00:00
// ── ProjectionError Display ─────────────────────────────────────────