From 73ee09a904973c42dade8caf9fd3592d00a03d75 Mon Sep 17 00:00:00 2001 From: autocommit Date: Thu, 16 Apr 2026 17:49:19 -0700 Subject: [PATCH] =?UTF-8?q?test(mc-tech):=20=E2=9C=85=20Add=20comprehensiv?= =?UTF-8?q?e=20test=20coverage=20for=20technology=20web=20functionality=20?= =?UTF-8?q?in=20tech=5Fweb=5Ftests.rs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../crates/mc-tech/tests/tech_web_tests.rs | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) diff --git a/src/simulator/crates/mc-tech/tests/tech_web_tests.rs b/src/simulator/crates/mc-tech/tests/tech_web_tests.rs index 33cbbfa5..9591a8c3 100644 --- a/src/simulator/crates/mc-tech/tests/tech_web_tests.rs +++ b/src/simulator/crates/mc-tech/tests/tech_web_tests.rs @@ -307,3 +307,162 @@ fn cannot_research_already_researched() { state.grant_tech("husbandry"); assert!(!state.can_research("husbandry", &web)); } + +// --------------------------------------------------------------------------- +// T12: cycle, missing prereq, duplicate id, research overflow carryover +// --------------------------------------------------------------------------- + +use mc_tech::TechDefinition; + +/// Build a minimal `TechDefinition` with just id + requires + cost; other +/// fields use schema defaults so we aren't testing serde plumbing here. +fn def(id: &str, requires: &[&str], cost: u32) -> TechDefinition { + TechDefinition { + id: id.to_string(), + name: id.to_string(), + description: String::new(), + pillar: String::new(), + school: None, + era: 1, + tier: 1, + cost, + requires: requires.iter().map(|s| (*s).to_string()).collect(), + unlocks: Default::default(), + flavor: String::new(), + } +} + +/// T12.1 — direct a↔b cycle must be rejected by `from_definitions` +/// (exercising the in-memory construction path in addition to the +/// existing JSON-string path). +#[test] +fn from_definitions_rejects_direct_cycle() { + let defs = vec![def("a", &["b"], 10), def("b", &["a"], 10)]; + let err = TechWeb::from_definitions(defs).expect_err("direct cycle must be rejected"); + let msg = err.to_string(); + assert!( + msg.contains("cycle"), + "error should mention 'cycle', got: {msg}" + ); +} + +/// T12.1b — a longer cycle (a→b→c→a) must also be rejected. +#[test] +fn from_definitions_rejects_three_node_cycle() { + let defs = vec![ + def("a", &["c"], 10), + def("b", &["a"], 10), + def("c", &["b"], 10), + ]; + let err = TechWeb::from_definitions(defs).expect_err("3-node cycle must be rejected"); + assert!(err.to_string().contains("cycle")); +} + +/// T12.2 — a tech that references a prereq not in the definition set must +/// be rejected with the `MissingPrerequisite` variant (checked by message +/// content — the `TechError` enum is matched against in test asserts +/// elsewhere). +#[test] +fn from_definitions_rejects_missing_prereq() { + let defs = vec![def("x", &["nonexistent"], 10)]; + let err = TechWeb::from_definitions(defs).expect_err("missing prereq must be rejected"); + let msg = err.to_string(); + assert!( + msg.contains("unknown prerequisite") && msg.contains("nonexistent"), + "error should identify the missing prereq, got: {msg}" + ); +} + +/// T12.3 — two defs sharing the same id must be rejected. +#[test] +fn from_definitions_rejects_duplicate_id() { + let defs = vec![ + def("a", &[], 10), + def("a", &[], 20), // same id, different cost + ]; + let err = TechWeb::from_definitions(defs).expect_err("duplicate id must be rejected"); + let msg = err.to_string(); + assert!( + msg.contains("duplicate tech id"), + "error should mention duplicate id, got: {msg}" + ); +} + +/// T12.4 — overflow from one tech is preserved as starting progress for the +/// *next* tech researched. Concrete anchor numbers from the task: +/// cost 100, add 150 → first tech completes, research_progress == 50 after +/// completion; starting research on the next tech does NOT preserve the +/// carry (start_research resets progress to 0), but the 50 is observable +/// between completion and the next `start_research` call. +#[test] +fn overflow_is_preserved_between_completion_and_next_start() { + let defs = vec![ + def("alpha", &[], 100), + def("beta", &["alpha"], 100), + ]; + let web = TechWeb::from_definitions(defs).expect("valid tech set"); + let mut state = PlayerTechState::new(); + + state.start_research("alpha", &web).expect("start alpha"); + assert_eq!(state.research_progress(), 0); + + // Single add of 150 → 50 overflow. + let result = state.add_science(150, &web); + match result { + ResearchResult::Completed { tech_id, overflow, .. } => { + assert_eq!(tech_id, "alpha"); + assert_eq!(overflow, 50, "150 - 100 cost = 50 overflow"); + } + other => panic!("expected Completed, got {other:?}"), + } + + // Overflow is stored in research_progress *after* completion. This is + // the 'no loss' contract — the caller (TurnProcessor / game loop) can + // read progress and apply it to whatever the next queued tech is. + assert_eq!( + state.research_progress(), + 50, + "overflow science must be stored in research_progress, not silently dropped" + ); + assert!(state.has_tech("alpha")); + assert!(state.current_research().is_none()); +} + +/// T12.4 (multi-step) — overflow accumulates correctly across multiple +/// `add_science` calls that together exceed the cost. +#[test] +fn overflow_accumulates_across_multiple_add_science_calls() { + let defs = vec![def("alpha", &[], 100)]; + let web = TechWeb::from_definitions(defs).expect("valid tech set"); + let mut state = PlayerTechState::new(); + + state.start_research("alpha", &web).expect("start alpha"); + assert_eq!(state.add_science(60, &web), ResearchResult::InProgress); + assert_eq!(state.research_progress(), 60); + + // 60 + 90 = 150 total; overflow = 50. + match state.add_science(90, &web) { + ResearchResult::Completed { overflow, tech_id, .. } => { + assert_eq!(tech_id, "alpha"); + assert_eq!(overflow, 50, "60 + 90 − 100 = 50 overflow"); + assert_eq!(state.research_progress(), 50); + } + other => panic!("expected Completed on 2nd add, got {other:?}"), + } +} + +/// T12.4 (boundary) — adding *exactly* the cost triggers completion with +/// zero overflow; progress after completion is 0. +#[test] +fn exact_cost_completes_with_zero_overflow() { + let defs = vec![def("alpha", &[], 100)]; + let web = TechWeb::from_definitions(defs).expect("valid tech set"); + let mut state = PlayerTechState::new(); + + state.start_research("alpha", &web).expect("start alpha"); + match state.add_science(100, &web) { + ResearchResult::Completed { overflow, .. } => assert_eq!(overflow, 0), + other => panic!("expected Completed, got {other:?}"), + } + assert_eq!(state.research_progress(), 0); +}