indicatrix-cut 0.5.0

Desktop faceting-design editor: library browsing, spectral 3D rendering, material retargeting, and a solid inspection view.
Documentation
//! Unit tests for the pure helpers scattered across this module group.

use super::{
    materials_symmetry::ri_override_for_material_pick,
    new_design::install_new_design,
    solid_picking::letterbox_margin,
    tier_form::tier_form_error_field,
    tier_generation::{next_free_block_name, split_duplicate_suffix, unique_duplicate_name},
    viewport_material::{parse_printed_proportions_field, parse_printed_proportions_form},
};
use crate::gui::editor::{loading::parse_new_design_form, state::EditorState};
use indicatrix_cut_core::PreformSpec;

// --- ri_override_for_material_pick ---

#[test]
fn ri_override_for_material_pick_does_not_pin_when_the_design_already_had_a_material() {
    // Diamond -> Quartz on a design whose CURRENT material is already named
    // "Diamond": pinning the outgoing material's (Diamond's) RI onto the
    // incoming pick (Quartz) is exactly the bug this guard prevents.
    assert_eq!(
        ri_override_for_material_pick(Some("Quartz"), Some("Diamond"), 2.417),
        None
    );
}

#[test]
fn ri_override_for_material_pick_pins_the_legacy_ri_on_a_first_pick_that_drifts() {
    // No material named yet (fresh design, legacy schedule RI 1.54): picking
    // Diamond (n_D ~1.5442) should pin the legacy figure so the exported I
    // line does not silently move.
    assert_eq!(
        ri_override_for_material_pick(Some("Diamond"), None, 1.54),
        Some(1.54)
    );
}

#[test]
fn ri_override_for_material_pick_does_nothing_for_a_non_built_in_name() {
    assert_eq!(
        ri_override_for_material_pick(Some("Not A Real Material"), None, 1.54),
        None
    );
}

// --- next_free_block_name  ---

#[test]
fn next_free_block_name_starts_at_1_for_a_crown_angle() {
    assert_eq!(next_free_block_name(45.0, &[]), "C1");
}

#[test]
fn next_free_block_name_uses_p_for_a_negative_angle() {
    assert_eq!(next_free_block_name(-40.0, &[]), "P1");
}

#[test]
fn next_free_block_name_treats_non_negative_zero_as_crown() {
    assert_eq!(next_free_block_name(0.0, &[]), "C1");
}

#[test]
fn next_free_block_name_skips_names_already_in_use() {
    let existing = vec!["C1".to_string(), "C2".to_string()];
    assert_eq!(next_free_block_name(45.0, &existing), "C3");
}

#[test]
fn next_free_block_name_collision_check_is_case_insensitive() {
    let existing = vec!["c1".to_string()];
    assert_eq!(next_free_block_name(45.0, &existing), "C2");
}

// --- unique_duplicate_name / split_duplicate_suffix ---
#[test]
fn unique_duplicate_name_starts_at_2_when_nothing_collides() {
    assert_eq!(unique_duplicate_name("P1", &[]), "P1 (2)");
}

#[test]
fn unique_duplicate_name_skips_names_already_in_use() {
    let existing = vec!["P1".to_string(), "P1 (2)".to_string(), "P1 (3)".to_string()];
    assert_eq!(unique_duplicate_name("P1", &existing), "P1 (4)");
}

#[test]
fn unique_duplicate_name_collision_check_is_case_insensitive() {
    let existing = vec!["p1 (2)".to_string()];
    assert_eq!(unique_duplicate_name("P1", &existing), "P1 (3)");
}

#[test]
fn unique_duplicate_name_counts_up_instead_of_nesting() {
    // Duplicating a tier already named "P1 (2)" must produce "P1 (3)", not
    // "P1 (2) (2)" -- see `split_duplicate_suffix`'s own doc comment. The
    // empty `existing_names` is the point: this has to hold on the
    // function's own terms, not only because the real caller passes a list
    // that contains the source tier (which would make "P1 (2)" collide).
    assert_eq!(unique_duplicate_name("P1 (2)", &[]), "P1 (3)");
}

#[test]
fn unique_duplicate_name_continues_from_a_high_source_number() {
    assert_eq!(unique_duplicate_name("P1 (9)", &[]), "P1 (10)");
}

#[test]
fn unique_duplicate_name_treats_an_unreadable_suffix_as_part_of_the_name() {
    // Not reachable by duplicating, but a cutter can type any name they
    // like -- this must still produce something, never loop or panic.
    let huge = format!("P1 ({})", u128::from(u32::MAX) + 1);
    assert_eq!(unique_duplicate_name(&huge, &[]), format!("{huge} (2)"));
}

#[test]
fn unique_duplicate_name_falls_back_to_tier_for_an_unnamed_source() {
    assert_eq!(unique_duplicate_name("", &[]), "Tier (2)");
}

#[test]
fn split_duplicate_suffix_reads_a_trailing_parenthesized_number() {
    assert_eq!(split_duplicate_suffix("P1 (2)"), ("P1", Some(2)));
    assert_eq!(split_duplicate_suffix("P1 (12)"), ("P1", Some(12)));
}

#[test]
fn split_duplicate_suffix_leaves_unrelated_text_alone() {
    assert_eq!(split_duplicate_suffix("P1"), ("P1", None));
    assert_eq!(split_duplicate_suffix("P1 (a)"), ("P1 (a)", None));
    assert_eq!(split_duplicate_suffix("P1 (2"), ("P1 (2", None));
    assert_eq!(split_duplicate_suffix("P1/P2 (2)"), ("P1/P2", Some(2)));
}

// --- parse_printed_proportions_form/_field  ---

#[test]
fn parse_printed_proportions_field_treats_blank_text_as_none() {
    assert_eq!(parse_printed_proportions_field("L/W", ""), Ok(None));
    assert_eq!(parse_printed_proportions_field("L/W", "   "), Ok(None));
}

#[test]
fn parse_printed_proportions_field_parses_a_positive_number() {
    assert_eq!(
        parse_printed_proportions_field("L/W", "1.05"),
        Ok(Some(1.05))
    );
}

#[test]
fn parse_printed_proportions_field_rejects_unparsable_text() {
    assert!(parse_printed_proportions_field("L/W", "abc").is_err());
}

#[test]
fn parse_printed_proportions_field_rejects_zero_and_negative() {
    assert!(parse_printed_proportions_field("L/W", "0").is_err());
    assert!(parse_printed_proportions_field("L/W", "-1.0").is_err());
}

#[test]
fn parse_printed_proportions_form_builds_every_field() {
    let props = parse_printed_proportions_form("1.30", "1.05", "0.61", "0.43", "0.60").unwrap();
    assert_eq!(props.vol_w3, Some(1.30));
    assert_eq!(props.lw, Some(1.05));
    assert_eq!(props.cw, Some(0.61));
    assert_eq!(props.pw, Some(0.43));
    assert_eq!(props.hw, Some(0.60));
}

#[test]
fn parse_printed_proportions_form_allows_every_field_blank() {
    let props = parse_printed_proportions_form("", "", "", "", "").unwrap();
    assert_eq!(props.vol_w3, None);
    assert_eq!(props.lw, None);
    assert_eq!(props.cw, None);
    assert_eq!(props.pw, None);
    assert_eq!(props.hw, None);
}

#[test]
fn parse_printed_proportions_form_names_the_failing_field() {
    let err =
        parse_printed_proportions_form("1.30", "not a number", "0.61", "0.43", "0.60").unwrap_err();
    assert!(err.contains("L/W"), "error should name the field: {err}");
}

// --- letterbox_margin  ---

#[test]
fn letterbox_margin_is_zero_when_the_pick_buffer_fills_the_viewport() {
    assert_eq!(letterbox_margin((800, 600), (800, 600)), (0.0, 0.0));
}

#[test]
fn letterbox_margin_is_half_the_leftover_on_each_side() {
    // A 1920x1080 (16:9) render letterboxed into an 800x600 (4:3) viewport --
    // `contained_request_size` would fit the width and shrink the height.
    assert_eq!(letterbox_margin((800, 600), (800, 450)), (0.0, 75.0));
}

#[test]
fn letterbox_margin_never_goes_negative_if_the_pick_buffer_is_larger() {
    assert_eq!(letterbox_margin((800, 600), (900, 700)), (0.0, 0.0));
}

// --- tier_form_error_field  ---

#[test]
fn tier_form_error_field_classifies_every_documented_message_shape() {
    assert_eq!(
        tier_form_error_field("Angle '41x' is not a number."),
        "angle"
    );
    assert_eq!(
        tier_form_error_field(
            "Angle 91.00\u{b0} exceeds 90\u{b0} -- angles are measured from the girdle \
             plane, so no crown or pavilion facet can be steeper than that."
        ),
        "angle"
    );
    assert_eq!(
        tier_form_error_field(
            "Another tier is already named 'P1' -- facet names must be unique so meet \
             constraints resolve to the right tier."
        ),
        "name"
    );
    assert_eq!(
        tier_form_error_field("Index '99' is outside this design's 96-tooth gear."),
        "indices"
    );
    assert_eq!(
        tier_form_error_field("\"Meet named\" needs at least one facet name."),
        "constraint"
    );
    assert_eq!(
        tier_form_error_field("Scale reference 'x' is not a number."),
        "constraint"
    );
    assert_eq!(
        tier_form_error_field("No facet named 'C1' -- check the Meets field."),
        "constraint"
    );
    assert_eq!(
        tier_form_error_field("Unknown constraint kind 7."),
        "constraint"
    );
}

#[test]
fn tier_form_error_field_is_empty_for_an_unrecognized_message() {
    assert_eq!(
        tier_form_error_field("Failed to remap this design's indices to the new gear."),
        ""
    );
}

// --- install_new_design / the viewport overlay's two conditions ---

/// The worked example's New Design form: Cylinder 1.50/1.00/1.50, 96 teeth,
/// 8-fold, mirror on, no starting material.
fn worked_example_spec() -> indicatrix_cut_core::FreshDesignSpec {
    let preform = PreformSpec::cylinder(96, 1.5, 1.0, 1.5);
    parse_new_design_form(96, preform, "8", true, 0).expect("the worked example's form parses")
}

/// Mirror of `EditorViewportOverlay`'s card-grid condition
/// (`ui/components/app/viewport_overlay.slint`): `enabled && !has_design`.
const fn shows_empty_state(enabled: bool, has_design: bool) -> bool {
    enabled && !has_design
}

/// Mirror of `EditorViewportOverlay`'s zero-tier hint condition:
/// `enabled && has_design && tiers.length == 0`.
const fn shows_zero_tier_hint(enabled: bool, has_design: bool, tier_count: usize) -> bool {
    enabled && has_design && tier_count == 0
}

/// The "New Design never shows a design" bug: template 0 ("Empty") yields a
/// zero-tier design, which must still count as a design.
#[test]
fn creating_from_the_empty_template_marks_the_design_as_present() {
    let mut st = EditorState::fresh();
    assert!(!st.has_design, "the startup placeholder is not a design");
    install_new_design(&mut st, worked_example_spec(), 0);
    assert!(st.has_design);
    assert_eq!(st.design.tiers.len(), 0, "no template tiers were seeded");
    assert!(!st.is_dirty(), "a freshly created design starts clean");
}

#[test]
fn creating_from_a_template_seeds_its_tiers_and_marks_the_design_as_present() {
    for template_index in
        1..=i32::try_from(indicatrix_cut_core::templates::TEMPLATES.len()).unwrap()
    {
        let mut st = EditorState::fresh();
        install_new_design(&mut st, worked_example_spec(), template_index);
        assert!(st.has_design);
        assert!(
            !st.design.tiers.is_empty(),
            "template {template_index} seeded no tiers"
        );
    }
    // A stale index past the end is treated as Empty -- still a real design.
    let mut st = EditorState::fresh();
    install_new_design(&mut st, worked_example_spec(), 99);
    assert!(st.has_design);
    assert_eq!(st.design.tiers.len(), 0, "no template tiers were seeded");
}

/// The card grid and the zero-tier hint key off different things (a design
/// existing vs. it having tiers), so they never show together, and a fresh
/// Empty design gets the hint -- the preform stays visible -- not the grid.
#[test]
fn zero_tier_hint_condition_differs_from_the_empty_state_condition() {
    let mut st = EditorState::fresh();
    let placeholder = (st.has_design, st.design.tiers.len());
    assert!(shows_empty_state(true, placeholder.0));
    assert!(!shows_zero_tier_hint(true, placeholder.0, placeholder.1));

    install_new_design(&mut st, worked_example_spec(), 0);
    assert!(!shows_empty_state(true, st.has_design));
    assert!(shows_zero_tier_hint(
        true,
        st.has_design,
        st.design.tiers.len()
    ));

    for enabled in [false, true] {
        for has_design in [false, true] {
            for tier_count in [0, 8] {
                assert!(
                    !(shows_empty_state(enabled, has_design)
                        && shows_zero_tier_hint(enabled, has_design, tier_count)),
                    "both overlays at enabled={enabled} has_design={has_design} tiers={tier_count}"
                );
            }
        }
    }
    // A design with tiers shows neither.
    assert!(!shows_empty_state(true, true));
    assert!(!shows_zero_tier_hint(true, true, 8));
}