test(mc-tech): Add comprehensive test coverage for technology web functionality in tech_web_tests.rs

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-04-16 17:49:19 -07:00
parent fcd05ea572
commit 73ee09a904

View file

@ -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);
}