use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use crate::game::schema::compile::{
AliasCategory, CwtAlias, CwtRuleCondition, CwtRuleField, CwtRuleValue, CwtSchemaGraph,
CwtSubtype, SchemaRootDefinition,
};
use crate::game::schema::query::{CwtQuery, SchemaBinding};
use crate::game::schema::rule_path::SchemaDirectory;
use crate::game::schema::source::SchemaPack;
use crate::game::schema::syntax::ParadoxTree;
use crate::model::GamePath;
use serde::Serialize;
use serde_json::Value;
#[derive(Serialize)]
struct FixtureBaseline {
pack_id: String,
types: Vec<TypeBaseline>,
aliases: Vec<AliasBaseline>,
enums: BTreeMap<String, Vec<String>>,
value_sets: BTreeMap<String, Vec<String>>,
complex_enums: BTreeMap<String, ComplexEnumBaseline>,
scopes: Vec<String>,
bindings: Vec<BindingBaseline>,
}
#[derive(Serialize)]
struct VendorBaseline {
file_count: usize,
pack_id: String,
type_count: usize,
alias_count: usize,
enum_count: usize,
value_set_count: usize,
complex_enum_count: usize,
link_count: usize,
scope_count: usize,
selected_types: Vec<TypeBaseline>,
selected_aliases: Vec<AliasBaseline>,
selected_bindings: Vec<BindingBaseline>,
known_scopes: Vec<String>,
}
#[derive(Serialize)]
struct TypeBaseline {
name: String,
path: Option<String>,
path_file: Option<String>,
name_field: Option<String>,
localisation: Vec<RuleFieldBaseline>,
push_scope: Option<String>,
type_per_file: bool,
name_from_file: bool,
skip_root_keys: Vec<String>,
subtypes: Vec<SubtypeBaseline>,
rules: Vec<RuleFieldBaseline>,
}
#[derive(Serialize)]
struct SubtypeBaseline {
name: String,
push_scope: Option<String>,
scope: Vec<String>,
rules: Vec<RuleFieldBaseline>,
}
#[derive(Serialize)]
struct AliasBaseline {
category: String,
name: String,
rules: Vec<RuleFieldBaseline>,
}
#[derive(Serialize)]
struct ComplexEnumBaseline {
path: Option<String>,
path_file: Option<String>,
start_from_root: bool,
name_rules: Vec<RuleFieldBaseline>,
}
#[derive(Serialize)]
struct RuleFieldBaseline {
key: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
conditions: Vec<String>,
value: RuleValueBaseline,
}
#[derive(Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
enum RuleValueBaseline {
Scalar { value: String },
Marker { value: String },
Block { fields: Vec<RuleFieldBaseline> },
}
#[derive(Serialize)]
#[serde(tag = "status", rename_all = "snake_case")]
enum BindingBaseline {
Bound {
path: String,
type_id: String,
node_id: String,
},
Dynamic {
path: String,
reason: String,
},
Unbound {
path: String,
reason: String,
},
}
#[test]
fn fixture_schema_pack_matches_baseline() {
let root = fixture_schema_dir();
assert_schema_parses_cleanly(&root);
let actual = build_fixture_baseline(&root);
assert_json_fixture(&fixture_file("binding_baseline.json"), &actual);
}
#[test]
fn fixture_root_binding_prefers_exact_path_and_reports_ambiguity() {
let root = fixture_schema_dir();
let graph = CwtSchemaGraph::from_directory(&root).expect("load schema fixture");
let engine = CwtQuery::from_graph(&graph);
let event_binding = engine.root_binding(game_path("events/example.txt"));
let SchemaBinding::Bound { type_id, .. } = event_binding else {
panic!("expected bound events root, got {event_binding:?}");
};
assert_eq!(type_id.as_str(), "event");
let missions_binding = engine.root_binding(game_path("missions/example.txt"));
let SchemaBinding::Dynamic { reason } = missions_binding else {
panic!("expected dynamic missions root, got {missions_binding:?}");
};
assert_eq!(reason, "ambiguous-root-type");
let missing_binding = engine.root_binding(game_path("common/example.txt"));
let SchemaBinding::Unbound { reason } = missing_binding else {
panic!("expected unbound common root, got {missing_binding:?}");
};
assert_eq!(reason, "no root type matches `common/example.txt`");
}
#[test]
fn cwtools_schema_pack_matches_baseline() {
let root = vendor_schema_dir();
assert_schema_parses_cleanly(&root);
let actual = build_vendor_baseline(&root);
assert_json_fixture(&fixture_file("cwtools_binding_baseline.json"), &actual);
}
fn build_fixture_baseline(root: &Path) -> FixtureBaseline {
let pack = load_pack(root);
let graph = pack.graph.as_ref();
let engine = CwtQuery::from_graph(graph);
FixtureBaseline {
pack_id: pack.id.to_hex(),
types: sorted_type_baselines(graph),
aliases: sorted_alias_baselines(graph),
enums: sorted_string_map(&graph.enums),
value_sets: sorted_string_map(&graph.value_sets),
complex_enums: sorted_complex_enum_baselines(graph),
scopes: graph.scopes.clone(),
bindings: vec![
binding_baseline(
"events/example.txt",
engine.root_binding(game_path("events/example.txt")),
),
binding_baseline(
"missions/example.txt",
engine.root_binding(game_path("missions/example.txt")),
),
binding_baseline(
"common/example.txt",
engine.root_binding(game_path("common/example.txt")),
),
],
}
}
fn build_vendor_baseline(root: &Path) -> VendorBaseline {
let pack = load_pack(root);
let graph = pack.graph.as_ref();
let engine = CwtQuery::from_graph(graph);
VendorBaseline {
file_count: cwt_files(root).len(),
pack_id: pack.id.to_hex(),
type_count: graph.types.len(),
alias_count: graph.aliases.len(),
enum_count: graph.enums.len(),
value_set_count: graph.value_sets.len(),
complex_enum_count: graph.complex_enums.len(),
link_count: graph.links.len(),
scope_count: graph.scopes.len(),
selected_types: [
"achievement",
"decision",
"event",
"game_age_ability",
"mission",
"mission_series",
"opinion_modifier",
]
.into_iter()
.filter_map(|name| {
graph
.types
.values()
.find(|definition| definition.name.as_str() == name)
})
.map(type_baseline)
.collect(),
selected_aliases: [
(AliasCategory::Trigger, "is_year"),
(AliasCategory::Effect, "add_prestige"),
(
AliasCategory::Other("modifier_rule".to_string()),
"modifier",
),
]
.into_iter()
.flat_map(|(category, name)| {
graph
.aliases
.get(&(category, name.to_string()))
.into_iter()
.flatten()
})
.map(alias_baseline)
.collect(),
selected_bindings: vec![
binding_baseline(
"events/example.txt",
engine.root_binding(game_path("events/example.txt")),
),
binding_baseline(
"decisions/example.txt",
engine.root_binding(game_path("decisions/example.txt")),
),
binding_baseline(
"missions/example.txt",
engine.root_binding(game_path("missions/example.txt")),
),
binding_baseline(
"common/opinion_modifiers/example.txt",
engine.root_binding(game_path("common/opinion_modifiers/example.txt")),
),
binding_baseline(
"common/achievements.txt",
engine.root_binding(game_path("common/achievements.txt")),
),
],
known_scopes: graph
.scopes
.iter()
.filter(|scope| matches!(scope.as_str(), "country" | "province" | "trade_node"))
.cloned()
.collect(),
}
}
fn load_pack(root: &Path) -> SchemaPack {
SchemaPack::load_from_dir(root).expect("load schema pack")
}
fn sorted_type_baselines(graph: &CwtSchemaGraph) -> Vec<TypeBaseline> {
let mut types = graph.types.values().map(type_baseline).collect::<Vec<_>>();
types.sort_by(|lhs, rhs| lhs.name.cmp(&rhs.name));
types
}
fn sorted_alias_baselines(graph: &CwtSchemaGraph) -> Vec<AliasBaseline> {
let mut aliases = graph
.aliases
.values()
.flatten()
.map(alias_baseline)
.collect::<Vec<_>>();
aliases.sort_by(|lhs, rhs| {
lhs.category
.cmp(&rhs.category)
.then_with(|| lhs.name.cmp(&rhs.name))
});
aliases
}
fn sorted_complex_enum_baselines(graph: &CwtSchemaGraph) -> BTreeMap<String, ComplexEnumBaseline> {
graph
.complex_enums
.iter()
.map(|(name, complex_enum)| {
(
name.clone(),
ComplexEnumBaseline {
path: complex_enum.path.as_ref().map(schema_directory_text),
path_file: complex_enum.path_file.as_ref().map(ToString::to_string),
start_from_root: complex_enum.start_from_root,
name_rules: complex_enum
.name_rules
.iter()
.map(rule_field_baseline)
.collect(),
},
)
})
.collect()
}
fn schema_directory_text(directory: &SchemaDirectory) -> String {
directory
.as_game_path()
.map_or_else(String::new, |path| path.as_str().to_string())
}
fn game_path(text: &str) -> &GamePath {
GamePath::new(text).expect("valid game path")
}
fn type_baseline(definition: &SchemaRootDefinition) -> TypeBaseline {
TypeBaseline {
name: definition.name.as_str().to_string(),
path: definition.path.as_ref().map(schema_directory_text),
path_file: definition.path_file.as_ref().map(ToString::to_string),
name_field: definition.name_field.clone(),
localisation: definition
.localisation
.iter()
.map(rule_field_baseline)
.collect(),
push_scope: definition.push_scope.clone(),
type_per_file: definition.type_per_file,
name_from_file: definition.name_from_file,
skip_root_keys: definition.skip_root_keys.clone(),
subtypes: definition.subtypes.iter().map(subtype_baseline).collect(),
rules: definition.rules.iter().map(rule_field_baseline).collect(),
}
}
fn subtype_baseline(subtype: &CwtSubtype) -> SubtypeBaseline {
SubtypeBaseline {
name: subtype.name.clone(),
push_scope: subtype.attributes.push_scope.clone(),
scope: subtype.attributes.scope.clone(),
rules: subtype.rules.iter().map(rule_field_baseline).collect(),
}
}
fn alias_baseline(alias: &CwtAlias) -> AliasBaseline {
AliasBaseline {
category: alias_category_name(&alias.category).to_string(),
name: alias.name.clone(),
rules: alias.rules.iter().map(rule_field_baseline).collect(),
}
}
fn rule_field_baseline(field: &CwtRuleField) -> RuleFieldBaseline {
RuleFieldBaseline {
key: field.key.clone(),
conditions: field
.conditions
.iter()
.map(rule_condition_baseline)
.collect(),
value: rule_value_baseline(&field.value),
}
}
fn rule_condition_baseline(condition: &CwtRuleCondition) -> String {
match condition {
CwtRuleCondition::SubtypeActive(label) => format!("subtype:{label}"),
CwtRuleCondition::SubtypeInactive(label) => format!("subtype:!{label}"),
}
}
fn rule_value_baseline(value: &CwtRuleValue) -> RuleValueBaseline {
match value {
CwtRuleValue::Scalar(value) => RuleValueBaseline::Scalar {
value: value.clone(),
},
CwtRuleValue::Marker(value) => RuleValueBaseline::Marker {
value: value.clone(),
},
CwtRuleValue::Block(fields) => RuleValueBaseline::Block {
fields: fields.iter().map(rule_field_baseline).collect(),
},
}
}
fn binding_baseline(path: &str, binding: SchemaBinding) -> BindingBaseline {
match binding {
SchemaBinding::Bound { type_id, node_id } => BindingBaseline::Bound {
path: path.to_string(),
type_id: type_id.as_str().to_string(),
node_id: node_id.0,
},
SchemaBinding::Dynamic { reason } => BindingBaseline::Dynamic {
path: path.to_string(),
reason: reason.to_string(),
},
SchemaBinding::Unbound { reason } => BindingBaseline::Unbound {
path: path.to_string(),
reason,
},
}
}
fn sorted_string_map(
map: &std::collections::HashMap<String, Vec<String>>,
) -> BTreeMap<String, Vec<String>> {
map.iter()
.map(|(key, values)| (key.clone(), values.clone()))
.collect()
}
fn assert_schema_parses_cleanly(root: &Path) {
let failures = cwt_files(root)
.into_iter()
.filter_map(|path| {
let bytes = fs::read(&path).expect("read schema file");
let tree = ParadoxTree::parse(&bytes).expect("parse schema file");
tree.has_error().then(|| relative_display(root, &path))
})
.collect::<Vec<_>>();
assert!(
failures.is_empty(),
"tree-sitter produced parse errors for: {}",
failures.join(", ")
);
}
fn assert_json_fixture(path: &Path, actual: &impl Serialize) {
let actual = serde_json::to_value(actual).expect("serialize baseline");
if should_regenerate_baselines() || !path.exists() {
fs::write(
path,
serde_json::to_string_pretty(&actual).expect("format baseline json"),
)
.expect("write baseline json");
}
let expected =
serde_json::from_str::<Value>(&fs::read_to_string(path).expect("read baseline json"))
.expect("parse baseline json");
assert_eq!(expected, actual);
}
fn should_regenerate_baselines() -> bool {
std::env::var_os("FOCH_REGENERATE_BASELINES").is_some()
}
fn fixture_schema_dir() -> PathBuf {
fixture_file("schema-pack")
}
fn fixture_file(path: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("src/game/schema/tests/fixtures")
.join(path)
}
fn vendor_schema_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("vendor/cwtools-eu4-config")
}
fn cwt_files(root: &Path) -> Vec<PathBuf> {
crate::game::schema::source::cwt_files(root).expect("walk schema files")
}
fn relative_display(root: &Path, path: &Path) -> String {
path.strip_prefix(root)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/")
}
fn alias_category_name(category: &AliasCategory) -> &str {
match category {
AliasCategory::Trigger => "trigger",
AliasCategory::Effect => "effect",
AliasCategory::Modifier => "modifier",
AliasCategory::Link => "link",
AliasCategory::Other(name) => name.as_str(),
}
}