diff --git a/crates/solverforge-cli/src/commands/generate_constraint/tests.rs b/crates/solverforge-cli/src/commands/generate_constraint/tests.rs index 55432b02..4a433a4f 100644 --- a/crates/solverforge-cli/src/commands/generate_constraint/tests.rs +++ b/crates/solverforge-cli/src/commands/generate_constraint/tests.rs @@ -1,223 +1,216 @@ -// ─── Tests ──────────────────────────────────────────────────────────────────── +use super::{ + domain::{ + find_annotated_struct, find_score_type, parse_vec_field, DomainModel, EntityInfo, FactInfo, + }, + mod_rewriter::{extend_tuple, extract_types, insert_mod_decl_assemble}, + skeleton::{generate_skeleton, Pattern}, + utils::{snake_to_title, validate_name}, +}; -#[cfg(test)] -mod tests { - use super::super::{ - domain::{ - find_annotated_struct, find_score_type, parse_vec_field, DomainModel, EntityInfo, - FactInfo, - }, - mod_rewriter::{extend_tuple, extract_types, insert_mod_decl_assemble}, - skeleton::{generate_skeleton, Pattern}, - utils::{snake_to_title, validate_name}, - }; - - #[test] - fn test_validate_name() { - assert!(validate_name("max_hours").is_ok()); - assert!(validate_name("required_skill").is_ok()); - assert!(validate_name("a").is_ok()); - assert!(validate_name("MaxHours").is_err()); - assert!(validate_name("1bad").is_err()); - assert!(validate_name("bad-name").is_err()); - assert!(validate_name("").is_err()); - } +#[test] +fn test_validate_name() { + assert!(validate_name("max_hours").is_ok()); + assert!(validate_name("required_skill").is_ok()); + assert!(validate_name("a").is_ok()); + assert!(validate_name("MaxHours").is_err()); + assert!(validate_name("1bad").is_err()); + assert!(validate_name("bad-name").is_err()); + assert!(validate_name("").is_err()); +} - #[test] - fn test_snake_to_title() { - assert_eq!(snake_to_title("max_hours"), "Max Hours"); - assert_eq!(snake_to_title("required_skill"), "Required Skill"); - assert_eq!(snake_to_title("all_assigned"), "All Assigned"); - assert_eq!(snake_to_title("capacity"), "Capacity"); - } +#[test] +fn test_snake_to_title() { + assert_eq!(snake_to_title("max_hours"), "Max Hours"); + assert_eq!(snake_to_title("required_skill"), "Required Skill"); + assert_eq!(snake_to_title("all_assigned"), "All Assigned"); + assert_eq!(snake_to_title("capacity"), "Capacity"); +} - #[test] - fn test_extract_types_assemble() { - let src = r#"mod assemble { +#[test] +fn test_extract_types_assemble() { + let src = r#"mod assemble { pub fn create_constraints() -> impl ConstraintSet { (all_assigned::constraint(),) } }"#; - let (s, t) = extract_types(src); - assert_eq!(s, "Plan"); - assert_eq!(t, "HardSoftScore"); - } + let (s, t) = extract_types(src); + assert_eq!(s, "Plan"); + assert_eq!(t, "HardSoftScore"); +} - #[test] - fn test_extract_types_flat() { - let src = r#"pub fn create_constraints() -> impl ConstraintSet { +#[test] +fn test_extract_types_flat() { + let src = r#"pub fn create_constraints() -> impl ConstraintSet { (capacity, distance) }"#; - let (s, t) = extract_types(src); - assert_eq!(s, "VrpPlan"); - assert_eq!(t, "HardSoftScore"); - } + let (s, t) = extract_types(src); + assert_eq!(s, "VrpPlan"); + assert_eq!(t, "HardSoftScore"); +} - #[test] - fn test_extend_tuple_single_trailing_comma() { - let src = r#"mod assemble { +#[test] +fn test_extend_tuple_single_trailing_comma() { + let src = r#"mod assemble { pub fn create_constraints() -> impl ConstraintSet { (all_assigned::constraint(),) } }"#; - let result = extend_tuple(src, "max_hours::constraint()"); - assert!(result.contains("all_assigned::constraint(), max_hours::constraint()")); - } + let result = extend_tuple(src, "max_hours::constraint()"); + assert!(result.contains("all_assigned::constraint(), max_hours::constraint()")); +} - #[test] - fn test_extend_tuple_flat_no_trailing_comma() { - let src = r#"pub fn create_constraints() -> impl ConstraintSet { +#[test] +fn test_extend_tuple_flat_no_trailing_comma() { + let src = r#"pub fn create_constraints() -> impl ConstraintSet { (capacity, distance) }"#; - let result = extend_tuple(src, "max_hours::constraint()"); - assert!(result.contains("distance, max_hours::constraint()")); - } + let result = extend_tuple(src, "max_hours::constraint()"); + assert!(result.contains("distance, max_hours::constraint()")); +} - #[test] - fn test_insert_mod_decl_assemble() { - let src = "mod all_assigned;\n\npub use self::assemble::create_constraints;\n"; - let result = insert_mod_decl_assemble(src, "mod max_hours;"); - assert!( - result.contains("mod all_assigned;\n\nmod max_hours;"), - "got: {:?}", - result - ); - assert!(result.contains("pub use self::assemble::create_constraints;")); - } +#[test] +fn test_insert_mod_decl_assemble() { + let src = "mod all_assigned;\n\npub use self::assemble::create_constraints;\n"; + let result = insert_mod_decl_assemble(src, "mod max_hours;"); + assert!( + result.contains("mod all_assigned;\n\nmod max_hours;"), + "got: {:?}", + result + ); + assert!(result.contains("pub use self::assemble::create_constraints;")); +} - #[test] - fn test_parse_vec_field() { - assert_eq!( - parse_vec_field(" pub shifts: Vec,"), - Some(("shifts".to_string(), "Shift".to_string())) - ); - assert_eq!( - parse_vec_field(" employees: Vec"), - Some(("employees".to_string(), "Employee".to_string())) - ); - } +#[test] +fn test_parse_vec_field() { + assert_eq!( + parse_vec_field(" pub shifts: Vec,"), + Some(("shifts".to_string(), "Shift".to_string())) + ); + assert_eq!( + parse_vec_field(" employees: Vec"), + Some(("employees".to_string(), "Employee".to_string())) + ); +} - #[test] - fn test_find_annotated_struct() { - let src = "#[planning_solution]\npub struct EmployeeSchedule {\n pub score: Option,\n}\n"; - assert_eq!( - find_annotated_struct(src, "planning_solution"), - Some("EmployeeSchedule".to_string()) - ); - } +#[test] +fn test_find_annotated_struct() { + let src = "#[planning_solution]\npub struct EmployeeSchedule {\n pub score: Option,\n}\n"; + assert_eq!( + find_annotated_struct(src, "planning_solution"), + Some("EmployeeSchedule".to_string()) + ); +} - #[test] - fn test_find_score_type() { - let src = - "#[planning_solution]\npub struct Plan {\n pub score: Option,\n}\n"; - assert_eq!( - find_score_type(src, "Plan"), - Some("HardSoftScore".to_string()) - ); - } +#[test] +fn test_find_score_type() { + let src = "#[planning_solution]\npub struct Plan {\n pub score: Option,\n}\n"; + assert_eq!( + find_score_type(src, "Plan"), + Some("HardSoftScore".to_string()) + ); +} - #[test] - fn test_generate_skeleton_unary_hard() { - let domain = DomainModel { - solution_type: "EmployeeSchedule".to_string(), - score_type: "HardSoftDecimalScore".to_string(), - entities: vec![EntityInfo { - field_name: "shifts".to_string(), - item_type: "Shift".to_string(), - planning_vars: vec!["employee_idx".to_string()], - }], - facts: vec![], - }; - let result = generate_skeleton( - "no_overlap", - Pattern::Unary, - false, - "EmployeeSchedule", - "HardSoftDecimalScore", - "No Overlap", - Some(&domain), - ); - assert!(result.contains("for_each(|s: &EmployeeSchedule| s.shifts.as_slice())")); - assert!(result.contains("HardSoftDecimalScore::ONE_HARD")); - assert!(result.contains("HARD:")); - } +#[test] +fn test_generate_skeleton_unary_hard() { + let domain = DomainModel { + solution_type: "EmployeeSchedule".to_string(), + score_type: "HardSoftDecimalScore".to_string(), + entities: vec![EntityInfo { + field_name: "shifts".to_string(), + item_type: "Shift".to_string(), + planning_vars: vec!["employee_idx".to_string()], + }], + facts: vec![], + }; + let result = generate_skeleton( + "no_overlap", + Pattern::Unary, + false, + "EmployeeSchedule", + "HardSoftDecimalScore", + "No Overlap", + Some(&domain), + ); + assert!(result.contains("for_each(|s: &EmployeeSchedule| s.shifts.as_slice())")); + assert!(result.contains("HardSoftDecimalScore::ONE_HARD")); + assert!(result.contains("HARD:")); +} - #[test] - fn test_generate_skeleton_pair_hard() { - let domain = DomainModel { - solution_type: "EmployeeSchedule".to_string(), - score_type: "HardSoftDecimalScore".to_string(), - entities: vec![EntityInfo { - field_name: "shifts".to_string(), - item_type: "Shift".to_string(), - planning_vars: vec!["employee_idx".to_string()], - }], - facts: vec![], - }; - let result = generate_skeleton( - "no_overlap", - Pattern::Pair, - false, - "EmployeeSchedule", - "HardSoftDecimalScore", - "No Overlap", - Some(&domain), - ); - assert!(result.contains("for_each_unique_pair")); - assert!(result.contains("joiner::equal(|e: &Shift| e.employee_idx)")); - } +#[test] +fn test_generate_skeleton_pair_hard() { + let domain = DomainModel { + solution_type: "EmployeeSchedule".to_string(), + score_type: "HardSoftDecimalScore".to_string(), + entities: vec![EntityInfo { + field_name: "shifts".to_string(), + item_type: "Shift".to_string(), + planning_vars: vec!["employee_idx".to_string()], + }], + facts: vec![], + }; + let result = generate_skeleton( + "no_overlap", + Pattern::Pair, + false, + "EmployeeSchedule", + "HardSoftDecimalScore", + "No Overlap", + Some(&domain), + ); + assert!(result.contains("for_each_unique_pair")); + assert!(result.contains("joiner::equal(|e: &Shift| e.employee_idx)")); +} - #[test] - fn test_generate_skeleton_join_hard() { - let domain = DomainModel { - solution_type: "EmployeeSchedule".to_string(), - score_type: "HardSoftDecimalScore".to_string(), - entities: vec![EntityInfo { - field_name: "shifts".to_string(), - item_type: "Shift".to_string(), - planning_vars: vec!["employee_idx".to_string()], - }], - facts: vec![FactInfo { - field_name: "employees".to_string(), - item_type: "Employee".to_string(), - }], - }; - let result = generate_skeleton( - "required_skill", - Pattern::Join, - false, - "EmployeeSchedule", - "HardSoftDecimalScore", - "Required Skill", - Some(&domain), - ); - assert!(result.contains("equal_bi")); - assert!(result.contains("employees.as_slice()")); - assert!(result.contains("Employee")); - } +#[test] +fn test_generate_skeleton_join_hard() { + let domain = DomainModel { + solution_type: "EmployeeSchedule".to_string(), + score_type: "HardSoftDecimalScore".to_string(), + entities: vec![EntityInfo { + field_name: "shifts".to_string(), + item_type: "Shift".to_string(), + planning_vars: vec!["employee_idx".to_string()], + }], + facts: vec![FactInfo { + field_name: "employees".to_string(), + item_type: "Employee".to_string(), + }], + }; + let result = generate_skeleton( + "required_skill", + Pattern::Join, + false, + "EmployeeSchedule", + "HardSoftDecimalScore", + "Required Skill", + Some(&domain), + ); + assert!(result.contains("equal_bi")); + assert!(result.contains("employees.as_slice()")); + assert!(result.contains("Employee")); +} - #[test] - fn test_generate_skeleton_balance_soft() { - let domain = DomainModel { - solution_type: "EmployeeSchedule".to_string(), - score_type: "HardSoftDecimalScore".to_string(), - entities: vec![EntityInfo { - field_name: "shifts".to_string(), - item_type: "Shift".to_string(), - planning_vars: vec!["employee_idx".to_string()], - }], - facts: vec![], - }; - let result = generate_skeleton( - "balance", - Pattern::Balance, - true, - "EmployeeSchedule", - "HardSoftDecimalScore", - "Balance", - Some(&domain), - ); - assert!(result.contains(".balance(|e: &Shift| e.employee_idx)")); - assert!(result.contains("SOFT:")); - } +#[test] +fn test_generate_skeleton_balance_soft() { + let domain = DomainModel { + solution_type: "EmployeeSchedule".to_string(), + score_type: "HardSoftDecimalScore".to_string(), + entities: vec![EntityInfo { + field_name: "shifts".to_string(), + item_type: "Shift".to_string(), + planning_vars: vec!["employee_idx".to_string()], + }], + facts: vec![], + }; + let result = generate_skeleton( + "balance", + Pattern::Balance, + true, + "EmployeeSchedule", + "HardSoftDecimalScore", + "Balance", + Some(&domain), + ); + assert!(result.contains(".balance(|e: &Shift| e.employee_idx)")); + assert!(result.contains("SOFT:")); } diff --git a/crates/solverforge-cli/src/commands/generate_domain/tests.rs b/crates/solverforge-cli/src/commands/generate_domain/tests.rs index fb66a2e5..00b28327 100644 --- a/crates/solverforge-cli/src/commands/generate_domain/tests.rs +++ b/crates/solverforge-cli/src/commands/generate_domain/tests.rs @@ -1,153 +1,140 @@ -// ─── Tests ──────────────────────────────────────────────────────────────────── - -#[cfg(test)] -mod tests { - use super::super::{ - generators::{generate_entity, generate_fact, generate_solution}, - utils::{pluralize, snake_to_pascal, validate_score_type}, - wiring::{add_import, replace_score_type}, - }; - - #[test] - fn test_snake_to_pascal() { - assert_eq!(snake_to_pascal("shift"), "Shift"); - assert_eq!(snake_to_pascal("employee_schedule"), "EmployeeSchedule"); - assert_eq!( - snake_to_pascal("vehicle_routing_plan"), - "VehicleRoutingPlan" - ); - assert_eq!(snake_to_pascal("plan"), "Plan"); - } - - #[test] - fn test_pluralize() { - assert_eq!(pluralize("shift"), "shifts"); - assert_eq!(pluralize("employee"), "employees"); - assert_eq!(pluralize("bus"), "buses"); - assert_eq!(pluralize("category"), "categories"); - assert_eq!(pluralize("day"), "days"); - assert_eq!(pluralize("key"), "keys"); - assert_eq!(pluralize("task"), "tasks"); - } - - #[test] - fn test_validate_score_type() { - assert!(validate_score_type("HardSoftScore").is_ok()); - assert!(validate_score_type("HardSoftDecimalScore").is_ok()); - assert!(validate_score_type("HardMediumSoftScore").is_ok()); - assert!(validate_score_type("SimpleScore").is_ok()); - assert!(validate_score_type("BendableScore").is_ok()); - assert!(validate_score_type("FakeScore").is_err()); - } - - #[test] - fn test_generate_entity_no_var() { - let src = generate_entity("Shift", None, &[]); - assert!(src.contains("#[planning_entity]")); - assert!(src.contains("pub struct Shift")); - assert!(src.contains("#[planning_id]")); - assert!(src.contains("pub id: String")); - assert!(!src.contains("#[planning_variable]")); - } - - #[test] - fn test_generate_entity_with_var() { - let src = generate_entity("Shift", Some("employee_idx"), &[]); - assert!(src.contains("#[planning_variable(allows_unassigned = true)]")); - assert!(src.contains("pub employee_idx: Option")); - assert!(src.contains("employee_idx: None")); - } - - #[test] - fn test_generate_fact() { - let src = generate_fact("Employee", &[]); - assert!(src.contains("#[problem_fact]")); - assert!(src.contains("pub struct Employee")); - assert!(src.contains("pub index: usize")); - assert!(src.contains("pub name: String")); - } - - #[test] - fn test_generate_solution() { - let src = generate_solution("Schedule", "HardSoftDecimalScore"); - assert!(src.contains("#[planning_solution]")); - assert!(src.contains("pub struct Schedule")); - assert!(src.contains("#[planning_score]")); - assert!(src.contains("pub score: Option")); - } - - #[test] - fn test_add_import_new() { - let src = "use solverforge::prelude::*;\n\nstruct Foo;\n"; - let result = add_import(src, "use super::Bar;"); - assert!(result.contains("use super::Bar;")); - // Import should come after the use line - let use_pos = result.find("use solverforge").unwrap(); - let bar_pos = result.find("use super::Bar;").unwrap(); - assert!(bar_pos > use_pos); - } - - #[test] - fn test_add_import_idempotent() { - let src = "use super::Bar;\nstruct Foo;\n"; - let result = add_import(src, "use super::Bar;"); - assert_eq!(result.matches("use super::Bar;").count(), 1); - } - - #[test] - fn test_replace_score_type() { - let src = "pub score: Option,\n"; - let result = replace_score_type(src, "HardSoftScore", "HardSoftDecimalScore").unwrap(); - assert!(result.contains("HardSoftDecimalScore")); - assert!(!result.contains("HardSoftScore")); - } - - #[test] - fn test_replace_score_type_missing() { - let src = "pub score: Option,\n"; - let result = replace_score_type(src, "SimpleScore", "HardSoftScore"); - assert!(result.is_err()); - } - - #[test] - fn test_inject_second_planning_variable() { - // Reproduces the bug: adding a second planning variable to an entity - // that was generated with one variable already. - use super::super::wiring::inject_planning_variable; - - let src = generate_entity("Surgery", Some("room_idx"), &[]); - // Inject a second variable - let result = - inject_planning_variable(&src, "Surgery", "slot_idx").expect("inject should succeed"); - - // The result must be valid Rust — slot_idx must be inside Self { } - // and the Self literal must not have a stray field after the closing brace - assert!( - result.contains("slot_idx: None"), - "slot_idx: None not found in output" - ); - - // The Self { ... } initializer must contain both fields - let self_start = result.find("Self {").expect("Self { not found"); - let self_block = &result[self_start..]; - let close = self_block.find('}').expect("} not found after Self {"); - let self_literal = &self_block[..=close]; - assert!( - self_literal.contains("room_idx: None"), - "room_idx: None not inside Self {{ }}: got: {self_literal}" - ); - assert!( - self_literal.contains("slot_idx: None"), - "slot_idx: None not inside Self {{ }}: got:\n{result}" - ); - } - - #[test] - fn test_update_domain_mod_format() { - // Verify the mod line format - let mod_line = format!("mod {};", "shift"); - let use_line = format!("pub use {}::{};", "shift", "Shift"); - assert_eq!(mod_line, "mod shift;"); - assert_eq!(use_line, "pub use shift::Shift;"); - } +use super::{ + generators::{generate_entity, generate_fact, generate_solution}, + utils::{pluralize, snake_to_pascal, validate_score_type}, + wiring::{add_import, replace_score_type}, +}; + +#[test] +fn test_snake_to_pascal() { + assert_eq!(snake_to_pascal("shift"), "Shift"); + assert_eq!(snake_to_pascal("employee_schedule"), "EmployeeSchedule"); + assert_eq!( + snake_to_pascal("vehicle_routing_plan"), + "VehicleRoutingPlan" + ); + assert_eq!(snake_to_pascal("plan"), "Plan"); +} + +#[test] +fn test_pluralize() { + assert_eq!(pluralize("shift"), "shifts"); + assert_eq!(pluralize("employee"), "employees"); + assert_eq!(pluralize("bus"), "buses"); + assert_eq!(pluralize("category"), "categories"); + assert_eq!(pluralize("day"), "days"); + assert_eq!(pluralize("key"), "keys"); + assert_eq!(pluralize("task"), "tasks"); +} + +#[test] +fn test_validate_score_type() { + assert!(validate_score_type("HardSoftScore").is_ok()); + assert!(validate_score_type("HardSoftDecimalScore").is_ok()); + assert!(validate_score_type("HardMediumSoftScore").is_ok()); + assert!(validate_score_type("SimpleScore").is_ok()); + assert!(validate_score_type("BendableScore").is_ok()); + assert!(validate_score_type("FakeScore").is_err()); +} + +#[test] +fn test_generate_entity_no_var() { + let src = generate_entity("Shift", None, &[]); + assert!(src.contains("#[planning_entity]")); + assert!(src.contains("pub struct Shift")); + assert!(src.contains("#[planning_id]")); + assert!(src.contains("pub id: String")); + assert!(!src.contains("#[planning_variable]")); +} + +#[test] +fn test_generate_entity_with_var() { + let src = generate_entity("Shift", Some("employee_idx"), &[]); + assert!(src.contains("#[planning_variable(allows_unassigned = true)]")); + assert!(src.contains("pub employee_idx: Option")); + assert!(src.contains("employee_idx: None")); +} + +#[test] +fn test_generate_fact() { + let src = generate_fact("Employee", &[]); + assert!(src.contains("#[problem_fact]")); + assert!(src.contains("pub struct Employee")); + assert!(src.contains("pub index: usize")); + assert!(src.contains("pub name: String")); +} + +#[test] +fn test_generate_solution() { + let src = generate_solution("Schedule", "HardSoftDecimalScore"); + assert!(src.contains("#[planning_solution]")); + assert!(src.contains("pub struct Schedule")); + assert!(src.contains("#[planning_score]")); + assert!(src.contains("pub score: Option")); +} + +#[test] +fn test_add_import_new() { + let src = "use solverforge::prelude::*;\n\nstruct Foo;\n"; + let result = add_import(src, "use super::Bar;"); + assert!(result.contains("use super::Bar;")); + let use_pos = result.find("use solverforge").unwrap(); + let bar_pos = result.find("use super::Bar;").unwrap(); + assert!(bar_pos > use_pos); +} + +#[test] +fn test_add_import_idempotent() { + let src = "use super::Bar;\nstruct Foo;\n"; + let result = add_import(src, "use super::Bar;"); + assert_eq!(result.matches("use super::Bar;").count(), 1); +} + +#[test] +fn test_replace_score_type() { + let src = "pub score: Option,\n"; + let result = replace_score_type(src, "HardSoftScore", "HardSoftDecimalScore").unwrap(); + assert!(result.contains("HardSoftDecimalScore")); + assert!(!result.contains("HardSoftScore")); +} + +#[test] +fn test_replace_score_type_missing() { + let src = "pub score: Option,\n"; + let result = replace_score_type(src, "SimpleScore", "HardSoftScore"); + assert!(result.is_err()); +} + +#[test] +fn test_inject_second_planning_variable() { + use super::wiring::inject_planning_variable; + + let src = generate_entity("Surgery", Some("room_idx"), &[]); + let result = + inject_planning_variable(&src, "Surgery", "slot_idx").expect("inject should succeed"); + + assert!( + result.contains("slot_idx: None"), + "slot_idx: None not found in output" + ); + + let self_start = result.find("Self {").expect("Self { not found"); + let self_block = &result[self_start..]; + let close = self_block.find('}').expect("} not found after Self {"); + let self_literal = &self_block[..=close]; + assert!( + self_literal.contains("room_idx: None"), + "room_idx: None not inside Self {{ }}: got: {self_literal}" + ); + assert!( + self_literal.contains("slot_idx: None"), + "slot_idx: None not inside Self {{ }}: got:\n{result}" + ); +} + +#[test] +fn test_update_domain_mod_format() { + let mod_line = format!("mod {};", "shift"); + let use_line = format!("pub use {}::{};", "shift", "Shift"); + assert_eq!(mod_line, "mod shift;"); + assert_eq!(use_line, "pub use shift::Shift;"); }