use std::collections::HashMap;
use std::collections::HashSet;
use crate::data::ResourceLoader;
use crate::game_params::ttx::labels::TtxStat;
use crate::game_params::ttx::labels::stat_display_label;
use crate::game_params::ttx::model::StatRow;
use crate::game_params::ttx::model::StatValue;
#[derive(Clone, Debug, PartialEq)]
pub struct StatLine {
pub stat: TtxStat,
pub qualifier: Option<String>,
pub label: String,
pub value: String,
}
pub fn render_stat_rows(rows: &[StatRow], loader: &dyn ResourceLoader) -> Vec<StatLine> {
rows.iter()
.map(|row| StatLine {
stat: row.stat,
qualifier: row.qualifier.clone(),
label: stat_display_label(row.stat, loader),
value: row.value.to_string(),
})
.collect()
}
#[derive(Clone, Debug, PartialEq)]
pub struct StatDelta {
pub stat: TtxStat,
pub qualifier: Option<String>,
pub label: String,
pub from: Option<String>,
pub to: Option<String>,
}
pub fn diff_stat_rows(baseline: &[StatRow], candidate: &[StatRow], loader: &dyn ResourceLoader) -> Vec<StatDelta> {
let base: HashMap<(TtxStat, Option<String>), StatValue> =
baseline.iter().map(|r| ((r.stat, r.qualifier.clone()), r.value)).collect();
let candidate_keys: HashSet<(TtxStat, Option<String>)> =
candidate.iter().map(|r| (r.stat, r.qualifier.clone())).collect();
let mut out = Vec::new();
for row in candidate {
let key = (row.stat, row.qualifier.clone());
let from = base.get(&key);
if from == Some(&row.value) {
continue;
}
out.push(StatDelta {
stat: row.stat,
qualifier: row.qualifier.clone(),
label: stat_display_label(row.stat, loader),
from: from.map(|v| v.to_string()),
to: Some(row.value.to_string()),
});
}
for row in baseline {
let key = (row.stat, row.qualifier.clone());
if candidate_keys.contains(&key) {
continue;
}
out.push(StatDelta {
stat: row.stat,
qualifier: row.qualifier.clone(),
label: stat_display_label(row.stat, loader),
from: Some(row.value.to_string()),
to: None,
});
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Rc;
use crate::game_params::types::Km;
use crate::game_params::types::Param;
struct EchoLoader;
impl ResourceLoader for EchoLoader {
fn localized_name_from_param(&self, _p: &Param) -> Option<String> {
None
}
fn localized_name_from_id(&self, id: &crate::data::TranslationKey) -> Option<String> {
Some(id.as_str().to_string())
}
fn game_param_by_id(&self, _id: crate::game_types::GameParamId) -> Option<Rc<Param>> {
None
}
fn entity_specs(&self) -> &[crate::rpc::entitydefs::EntitySpec] {
&[]
}
}
fn row(stat: TtxStat, qualifier: Option<&str>, value: StatValue) -> StatRow {
StatRow { stat, qualifier: qualifier.map(|s| s.to_string()), value }
}
#[test]
fn render_uses_label_and_display_value() {
let loader = EchoLoader;
let rows = vec![
row(TtxStat::SeaDetection, None, StatValue::Km(Km::from(7.3))),
row(TtxStat::ArtilleryDispersionVertical, None, StatValue::Km(Km::from(0.27))),
];
let lines = render_stat_rows(&rows, &loader);
assert_eq!(lines.len(), 2);
assert_eq!(lines[0].label, TtxStat::SeaDetection.label_key().unwrap());
assert_eq!(lines[0].value, StatValue::Km(Km::from(7.3)).to_string());
assert_eq!(lines[1].label, "Artillery Dispersion Vertical");
}
#[test]
fn diff_reports_changed_added_removed_and_skips_equal() {
let loader = EchoLoader;
let baseline = vec![
row(TtxStat::Health, None, StatValue::Hp(crate::game_params::ttx::model::Hp::from(19400.0))),
row(TtxStat::Speed, None, StatValue::Knots(crate::game_params::ttx::model::Knots::from(36.0))),
row(TtxStat::SeaDetection, None, StatValue::Km(Km::from(7.3))),
];
let candidate = vec![
row(TtxStat::Health, None, StatValue::Hp(crate::game_params::ttx::model::Hp::from(21000.0))),
row(TtxStat::Speed, None, StatValue::Knots(crate::game_params::ttx::model::Knots::from(36.0))),
row(TtxStat::TurningRadius, None, StatValue::Meters(crate::game_params::types::Meters::from(640.0))),
];
let deltas = diff_stat_rows(&baseline, &candidate, &loader);
assert_eq!(deltas.len(), 3);
let health = deltas.iter().find(|d| d.stat == TtxStat::Health).expect("health");
assert!(health.from.is_some() && health.to.is_some() && health.from != health.to);
let added = deltas.iter().find(|d| d.stat == TtxStat::TurningRadius).expect("added");
assert_eq!(added.from, None);
assert!(added.to.is_some());
let removed = deltas.iter().find(|d| d.stat == TtxStat::SeaDetection).expect("removed");
assert!(removed.from.is_some());
assert_eq!(removed.to, None);
assert!(deltas.iter().all(|d| d.stat != TtxStat::Speed), "equal row must be skipped");
}
#[test]
fn identical_cards_yield_empty_diff() {
let loader = EchoLoader;
let rows = vec![row(TtxStat::SeaDetection, None, StatValue::Km(Km::from(7.3)))];
assert!(diff_stat_rows(&rows, &rows, &loader).is_empty());
}
#[test]
fn diff_keys_on_qualifier_distinctly() {
use crate::game_params::ttx::model::Hp;
let loader = EchoLoader;
let baseline = vec![
row(TtxStat::ShellDamage, Some("HE"), StatValue::Hp(Hp::from(1800.0))),
row(TtxStat::ShellDamage, Some("AP"), StatValue::Hp(Hp::from(5000.0))),
];
let candidate = vec![
row(TtxStat::ShellDamage, Some("HE"), StatValue::Hp(Hp::from(2000.0))),
row(TtxStat::ShellDamage, Some("AP"), StatValue::Hp(Hp::from(5000.0))),
];
let deltas = diff_stat_rows(&baseline, &candidate, &loader);
assert_eq!(deltas.len(), 1);
assert_eq!(deltas[0].stat, TtxStat::ShellDamage);
assert_eq!(deltas[0].qualifier.as_deref(), Some("HE"));
assert!(deltas[0].from.is_some() && deltas[0].to.is_some() && deltas[0].from != deltas[0].to);
}
}