#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use turnframe_core::case::CaseRef;
use turnframe_core::error::DomainRejection;
use turnframe_core::flow::{PhaseOwnership, WorkflowDefinition, check_view};
use turnframe_core::ids::CaseRevision;
use turnframe_core::locale::Locale;
use turnframe_test::explore::{ExplorationLimits, explore, reachable_states};
use turnframe_test::workflows::traveler::{
DeclineReason, FieldState, SAMPLE_LOYALTY_NUMBER, TravelerCommand, TravelerModel,
TravelerObligation, TravelerPhase, TravelerState, TravelerWorkflow, awaiting_activation,
declined_loyalty_number, incomplete_draft, rejection, validate,
};
fn case(revision: u64) -> CaseRef {
CaseRef::new("traveler", "c-1", CaseRevision(revision))
}
fn view_of(state: &TravelerState) -> turnframe_core::flow::ViewOf<TravelerWorkflow> {
TravelerWorkflow::new()
.with_cards()
.project(case(3), Some(state))
}
#[test]
fn a_declined_field_closes_its_obligation() {
let untouched = incomplete_draft();
assert!(
untouched
.open_obligations()
.contains(&TravelerObligation::SetLoyaltyNumber),
"an unanswered field is an open obligation"
);
for reason in DeclineReason::ALL {
let declined = TravelerState {
loyalty_number: FieldState::declined(reason),
..incomplete_draft()
};
assert!(
!declined
.open_obligations()
.contains(&TravelerObligation::SetLoyaltyNumber),
"{reason:?} left the obligation open: the assistant would ask for ever"
);
assert!(
!view_of(&declined)
.obligations
.contains(&TravelerObligation::SetLoyaltyNumber),
"{reason:?} left the obligation in the projected view"
);
}
}
#[test]
fn a_declined_field_counts_as_settled_for_completeness() {
let mut draft = awaiting_activation();
draft.loyalty_number = FieldState::Untouched;
assert!(!draft.is_complete(), "an unanswered field is not settled");
assert_eq!(
validate(Some(&draft), &TravelerCommand::Activate)
.unwrap_err()
.code
.as_str(),
rejection::INCOMPLETE
);
let declined = declined_loyalty_number(DeclineReason::NotApplicable);
assert!(declined.is_complete(), "a declined field is settled");
assert!(validate(Some(&declined), &TravelerCommand::Activate).is_ok());
assert_eq!(
TravelerWorkflow::phase_of(&declined),
TravelerPhase::AwaitingActivation,
"a traveler with nothing left to answer is waiting for the activation card"
);
}
#[test]
fn the_view_still_distinguishes_a_declined_field_from_an_answered_one() {
let answered = view_of(&awaiting_activation());
assert!(
answered
.notices
.iter()
.all(|notice| !notice.code.starts_with("traveler.loyalty_number_declined")),
"an answered field must not claim it was declined"
);
for reason in DeclineReason::ALL {
let declined = view_of(&declined_loyalty_number(reason));
assert_eq!(declined.phase, answered.phase);
assert_eq!(declined.obligations, answered.obligations);
let notice = declined
.notices
.iter()
.find(|notice| notice.code.starts_with("traveler.loyalty_number_declined."))
.unwrap_or_else(|| panic!("{reason:?} left no trace in the view"));
assert_eq!(
notice.code,
format!("traveler.loyalty_number_declined.{}", reason.code()),
"the reason must be readable from the code, not only from the prose"
);
assert!(!notice.text.resolve(&Locale::from("it")).is_empty());
assert_ne!(
notice.text.resolve(&Locale::from("en")),
notice.text.resolve(&Locale::from("it")),
"the notice is translated, so a reader is not shown English by accident"
);
check_view(&TravelerWorkflow::new().with_cards(), &declined).expect("the view holds ยง8.4");
}
}
#[test]
fn only_a_reason_that_can_change_is_worth_asking_again() {
assert!(DeclineReason::Unknown.worth_asking_again());
assert!(!DeclineReason::NotApplicable.worth_asking_again());
assert!(!DeclineReason::Withheld.worth_asking_again());
let recoverable = declined_loyalty_number(DeclineReason::Unknown);
assert!(
validate(
Some(&recoverable),
&TravelerCommand::SetLoyaltyNumber {
value: SAMPLE_LOYALTY_NUMBER.to_owned(),
}
)
.is_ok(),
"a declined field can still be answered once the user knows the value"
);
}
#[test]
fn declining_a_field_the_user_already_answered_is_refused() {
let answered = awaiting_activation();
let refused: DomainRejection = validate(
Some(&answered),
&TravelerCommand::DeclineLoyaltyNumber {
reason: DeclineReason::Withheld,
},
)
.unwrap_err();
assert_eq!(
refused.code.as_str(),
rejection::LOYALTY_NUMBER_ALREADY_ANSWERED
);
}
#[test]
fn the_state_space_with_a_declined_field_stays_clean() {
let report = explore(
&TravelerWorkflow::new().with_cards(),
&TravelerModel::of(TravelerWorkflow::new().with_cards()),
ExplorationLimits::generous(),
);
assert!(report.is_clean(), "{}", report.describe());
assert!(!report.truncated, "{}", report.describe());
let states: Vec<TravelerState> = reachable_states(
&TravelerModel::of(TravelerWorkflow::new().with_cards()),
ExplorationLimits::generous(),
)
.into_iter()
.flatten()
.collect();
for reason in [DeclineReason::NotApplicable, DeclineReason::Unknown] {
assert!(
states
.iter()
.any(|state| state.loyalty_number.decline_reason() == Some(reason)),
"exploration never reached a traveler whose loyalty number was declined as {reason:?}"
);
}
assert!(
states
.iter()
.any(|state| state.loyalty_number.is_answered() && state.is_complete()),
"exploration never reached a traveler who answered"
);
assert!(
states.iter().any(|state| {
state.loyalty_number.decline_reason().is_some()
&& TravelerWorkflow::phase_of(state) == TravelerPhase::Active
}),
"a traveler activated after declining must be reachable: that is the whole \
point of a decline settling the field"
);
assert_eq!(
TravelerWorkflow::new()
.with_cards()
.phase_ownership(&TravelerPhase::AwaitingActivation),
PhaseOwnership::User
);
}