use indicatrix_cut_core::Design;
use indicatrix_editor::{
solve_policy::SolveCostEstimate,
sweep::{
DEFAULT_SWEEP_STEP_DEG, SweepError, SweepPlan, default_range, estimate_seconds,
format_angle_input, format_duration_estimate, parse_sweep_range, plan_summary, plan_sweep,
sweepable_tiers,
},
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct TierChoice {
pub tier: usize,
pub label: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct Fields {
pub from: String,
pub to: String,
pub step: String,
}
#[derive(Debug, Clone, PartialEq)]
pub(super) enum Checked {
Ready { plan: SweepPlan, summary: String },
Refused(String),
}
#[derive(Debug, Clone, PartialEq)]
pub(super) struct Opening {
pub choices: Vec<TierChoice>,
pub chosen: usize,
pub fields: Fields,
pub notice: String,
pub checked: Checked,
}
pub(super) fn open(
design: &Design,
selected_tier: Option<usize>,
tilt_average: bool,
workers: usize,
) -> Option<Opening> {
let choices = tier_choices(design);
let chosen = default_choice(&choices, selected_tier);
let tier = choices.get(chosen)?.tier;
let fields = default_fields(design.tiers.get(tier)?.angle_deg);
let checked = check(design, tier, &fields, tilt_average, workers);
Some(Opening {
choices,
chosen,
fields,
notice: left_out_notice(design),
checked,
})
}
pub(super) fn retier(
design: &Design,
tier: usize,
tilt_average: bool,
workers: usize,
) -> Option<(Fields, Checked)> {
let fields = default_fields(design.tiers.get(tier)?.angle_deg);
let checked = check(design, tier, &fields, tilt_average, workers);
Some((fields, checked))
}
pub(super) fn tier_choices(design: &Design) -> Vec<TierChoice> {
sweepable_tiers(design)
.into_iter()
.map(|tier| TierChoice {
tier,
label: choice_label(design, tier),
})
.collect()
}
fn choice_label(design: &Design, tier: usize) -> String {
let angle_deg = design.tiers.get(tier).map_or(0.0, |t| t.angle_deg.abs());
format!(
"{}. {} ({angle_deg:.2}\u{b0})",
tier + 1,
indicatrix_editor::retarget::plan::tier_display_name(design, tier)
)
}
pub(super) fn default_choice(choices: &[TierChoice], selected_tier: Option<usize>) -> usize {
selected_tier
.and_then(|tier| choices.iter().position(|choice| choice.tier == tier))
.unwrap_or(0)
}
pub(super) fn default_fields(angle_deg: f64) -> Fields {
let (low, high) = default_range(angle_deg);
Fields {
from: format_angle_input(low),
to: format_angle_input(high),
step: format_angle_input(DEFAULT_SWEEP_STEP_DEG),
}
}
pub(super) fn left_out_notice(design: &Design) -> String {
let driven: Vec<String> = (0..design.tiers.len())
.filter(|&tier| design.is_tier_driven(tier))
.map(|tier| indicatrix_editor::retarget::plan::tier_display_name(design, tier))
.collect();
if driven.is_empty() {
String::new()
} else {
format!(
"Not listed, because their angles follow a relation: {}.",
driven.join(", ")
)
}
}
pub(super) fn check(
design: &Design,
tier: usize,
fields: &Fields,
tilt_average: bool,
workers: usize,
) -> Checked {
if tier >= design.tiers.len() {
return Checked::Refused(
SweepError::NoSuchTier {
tier,
tier_count: design.tiers.len(),
}
.to_string(),
);
}
let range = match parse_sweep_range(&fields.from, &fields.to, &fields.step) {
Ok(range) => range,
Err(error) => return Checked::Refused(error.to_string()),
};
match plan_sweep(design, tier, range) {
Ok(plan) => {
let seconds = estimate_seconds(
plan.angles.len(),
tilt_average,
workers,
SolveCostEstimate::of(design),
);
let summary = plan_summary(&plan, &format_duration_estimate(seconds));
Checked::Ready { plan, summary }
}
Err(error) => Checked::Refused(error.to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use indicatrix_cut_core::{ConstraintTier, PreformSpec, ScheduleMeta};
const CROWN_MAIN: usize = 2;
const PAVILION_MAIN: usize = 5;
fn round_brilliant() -> Design {
let mut design = Design::new(
PreformSpec::block(2.0, 1.0, 2.0),
ScheduleMeta::standard_round_brilliant(),
ConstraintTier::standard_round_brilliant(),
);
design.ensure_tier_ids();
design
}
fn fields(from: &str, to: &str, step: &str) -> Fields {
Fields {
from: from.to_owned(),
to: to.to_owned(),
step: step.to_owned(),
}
}
#[test]
fn the_combo_lists_the_sweepable_tiers_with_their_row_numbers_and_angles() {
let choices = tier_choices(&round_brilliant());
let tiers: Vec<usize> = choices.iter().map(|choice| choice.tier).collect();
assert_eq!(tiers, vec![1, CROWN_MAIN, 3, PAVILION_MAIN, 6]);
let crown = choices
.iter()
.find(|choice| choice.tier == CROWN_MAIN)
.expect("the crown main");
assert_eq!(crown.label, "3. Crown Main (34.50\u{b0})");
let pavilion = choices
.iter()
.find(|choice| choice.tier == PAVILION_MAIN)
.expect("the pavilion main");
assert_eq!(
pavilion.label, "6. Pavilion Main (41.00\u{b0})",
"the stored -41 reads as 41"
);
}
#[test]
fn the_dialog_opens_on_the_selected_tier_when_it_can_be_swept() {
let choices = tier_choices(&round_brilliant());
assert_eq!(
choices[default_choice(&choices, Some(PAVILION_MAIN))].tier,
PAVILION_MAIN
);
assert_eq!(default_choice(&choices, Some(0)), 0);
assert_eq!(default_choice(&choices, None), 0);
assert_eq!(default_choice(&[], Some(3)), 0);
}
#[test]
fn a_tier_starts_with_five_degrees_either_side_as_positive_numbers() {
assert_eq!(
default_fields(34.5),
fields("29.5", "39.5", "0.5"),
"a crown tier"
);
assert_eq!(
default_fields(-41.0),
fields("36", "46", "0.5"),
"a pavilion tier stored at -41 starts with positive numbers"
);
}
#[test]
fn a_relation_is_named_in_the_hint_under_the_form() {
let design = round_brilliant();
assert_eq!(left_out_notice(&design), "");
let mut session = indicatrix_editor::EditorSession::with_history(
design,
indicatrix_cut_core::History::new(),
);
session
.set_tier_relation(3, "[Crown Main] + 6.5")
.expect("a relation");
let notice = left_out_notice(&session.design);
assert_eq!(
notice,
"Not listed, because their angles follow a relation: Upper Girdle."
);
}
#[test]
fn opening_starts_on_the_selected_tier_with_its_range_and_a_ready_form() {
let design = round_brilliant();
let opening = open(&design, Some(PAVILION_MAIN), false, 4).expect("tiers to sweep");
assert_eq!(opening.choices[opening.chosen].tier, PAVILION_MAIN);
assert_eq!(opening.fields, fields("36", "46", "0.5"));
assert_eq!(opening.notice, "");
assert!(matches!(opening.checked, Checked::Ready { .. }));
let (fields_for_crown, checked) =
retier(&design, CROWN_MAIN, false, 4).expect("the crown main");
assert_eq!(fields_for_crown, fields("29.5", "39.5", "0.5"));
assert!(matches!(checked, Checked::Ready { .. }));
assert!(retier(&design, 99, false, 4).is_none());
}
#[test]
fn a_design_with_nothing_to_sweep_does_not_open() {
let mut design = round_brilliant();
design.tiers.clear();
assert!(open(&design, None, false, 4).is_none());
}
#[test]
fn a_good_form_gives_the_plan_and_a_summary_line() {
let design = round_brilliant();
let Checked::Ready { plan, summary } =
check(&design, PAVILION_MAIN, &fields("39", "43", "1"), false, 4)
else {
panic!("the form is fine");
};
assert_eq!(plan.angles, vec![-39.0, -40.0, -41.0, -42.0, -43.0]);
assert!(
summary.starts_with("5 angles from 39.00 to 43.00 degrees."),
"{summary}"
);
assert!(summary.contains("This takes"), "{summary}");
}
#[test]
fn a_signed_entry_and_an_unsigned_entry_make_the_same_sweep() {
let design = round_brilliant();
let ready = |from: &str, to: &str| match check(
&design,
PAVILION_MAIN,
&fields(from, to, "1"),
false,
4,
) {
Checked::Ready { plan, summary } => (plan, summary),
Checked::Refused(message) => panic!("the form should run: {message}"),
};
assert_eq!(ready("39", "43"), ready("-39", "-43"));
assert_eq!(ready("39", "43"), ready("43", "39"));
assert!(matches!(
check(&design, CROWN_MAIN, &fields("-33", "-36", "0.5"), false, 4),
Checked::Ready { .. }
));
}
#[test]
fn a_form_that_cannot_run_says_why_in_words() {
let design = round_brilliant();
let refusal = |tier: usize, from: &str, to: &str, step: &str| match check(
&design,
tier,
&fields(from, to, step),
false,
4,
) {
Checked::Refused(message) => message,
Checked::Ready { .. } => panic!("the form should be refused"),
};
assert!(refusal(PAVILION_MAIN, "abc", "-39", "1").starts_with("From"));
assert_eq!(
refusal(PAVILION_MAIN, "-43", "-39", "0"),
"The step must be more than 0."
);
assert!(refusal(PAVILION_MAIN, "-43", "-39", "0.001").contains("at most 200"));
assert!(refusal(CROWN_MAIN, "95", "39", "1").contains("outside what a sweep takes"));
assert!(refusal(99, "-43", "-39", "1").contains("not in the design"));
}
}