use super::*;
use crate::model::{ConfigData, RowData, TableData, Value};
use sora_ir::normalize::normalize_schema;
use sora_schema::model::SchemaFile;
use std::collections::BTreeMap;
#[test]
fn validates_simple_table_data() {
let ir = example_ir();
let data = ConfigData {
tables: vec![TableData {
name: "Item".to_owned(),
rows: vec![RowData {
values: BTreeMap::from([
("id".to_owned(), Value::Integer(1001)),
("name".to_owned(), Value::String("Iron Sword".to_owned())),
("item_type".to_owned(), Value::String("weapon".to_owned())),
("max_stack".to_owned(), Value::Integer(1)),
]),
}],
}],
};
validate_config_data(&ir, &data).unwrap();
}
#[test]
fn rejects_invalid_data() {
assert_validation_error(
BTreeMap::from([
("id".to_owned(), Value::Integer(1001)),
("item_type".to_owned(), Value::String("Weapon".to_owned())),
("max_stack".to_owned(), Value::Integer(1)),
]),
|error| matches!(error, SoraError::MissingRequiredField { field, .. } if field == "name"),
);
assert_validation_error(
BTreeMap::from([
("id".to_owned(), Value::Integer(1001)),
("name".to_owned(), Value::String("Iron Sword".to_owned())),
("unknown".to_owned(), Value::Integer(1)),
("item_type".to_owned(), Value::String("Weapon".to_owned())),
("max_stack".to_owned(), Value::Integer(1)),
]),
|error| matches!(error, SoraError::UnknownField { field, .. } if field == "unknown"),
);
assert_validation_error(
BTreeMap::from([
("id".to_owned(), Value::Integer(1001)),
("name".to_owned(), Value::String("Iron Sword".to_owned())),
("item_type".to_owned(), Value::String("Invalid".to_owned())),
("max_stack".to_owned(), Value::Integer(1)),
]),
|error| matches!(error, SoraError::InvalidEnumValue { value, .. } if value == "Invalid"),
);
assert_validation_error(
BTreeMap::from([
("id".to_owned(), Value::Integer(1001)),
("name".to_owned(), Value::String("Iron Sword".to_owned())),
("item_type".to_owned(), Value::String("Weapon".to_owned())),
("max_stack".to_owned(), Value::String("one".to_owned())),
]),
|error| matches!(error, SoraError::TypeMismatch { field, .. } if field == "max_stack"),
);
}
#[test]
fn aggregates_multiple_validation_errors() {
let ir = example_ir();
let data = ConfigData {
tables: vec![TableData {
name: "Item".to_owned(),
rows: vec![
RowData {
values: BTreeMap::from([
("id".to_owned(), Value::Integer(1001)),
("item_type".to_owned(), Value::String("Invalid".to_owned())),
("max_stack".to_owned(), Value::Integer(1)),
]),
},
RowData {
values: BTreeMap::from([
("id".to_owned(), Value::Integer(1002)),
("name".to_owned(), Value::String("Magic Stone".to_owned())),
("item_type".to_owned(), Value::String("Material".to_owned())),
("max_stack".to_owned(), Value::String("many".to_owned())),
]),
},
],
}],
};
let error = validate_config_data_all(&ir, &data).unwrap_err();
let SoraError::ValidationErrors { errors, .. } = error else {
panic!("expected aggregated validation errors");
};
assert_eq!(errors.len(), 2);
assert!(matches!(
&errors[0],
SoraError::MissingRequiredField { field, .. } if field == "name"
));
assert!(matches!(
&errors[1],
SoraError::TypeMismatch { field, .. } if field == "max_stack"
));
}
#[test]
fn rejects_duplicate_and_missing_map_keys() {
let ir = example_ir();
let duplicate_data = ConfigData {
tables: vec![TableData {
name: "Item".to_owned(),
rows: vec![
RowData {
values: valid_row(1001),
},
RowData {
values: valid_row(1001),
},
],
}],
};
let error = validate_config_data(&ir, &duplicate_data).unwrap_err();
assert!(matches!(error, SoraError::DuplicateKey { key, .. } if key == "1001"));
let missing_key_data = ConfigData {
tables: vec![TableData {
name: "Item".to_owned(),
rows: vec![RowData {
values: BTreeMap::from([
("name".to_owned(), Value::String("Iron Sword".to_owned())),
("item_type".to_owned(), Value::String("Weapon".to_owned())),
("max_stack".to_owned(), Value::Integer(1)),
]),
}],
}],
};
let error = validate_config_data(&ir, &missing_key_data).unwrap_err();
assert!(matches!(
error,
SoraError::MissingRequiredField { field, .. } if field == "id"
));
}
#[test]
fn rejects_duplicate_unique_index_keys() {
let ir = index_ir();
let data = ConfigData {
tables: vec![TableData {
name: "Item".to_owned(),
rows: vec![
RowData {
values: BTreeMap::from([
("id".to_owned(), Value::Integer(1001)),
("item_type".to_owned(), Value::String("Weapon".to_owned())),
("name".to_owned(), Value::String("Iron Sword".to_owned())),
]),
},
RowData {
values: BTreeMap::from([
("id".to_owned(), Value::Integer(1002)),
("item_type".to_owned(), Value::String("Weapon".to_owned())),
("name".to_owned(), Value::String("Iron Sword".to_owned())),
]),
},
],
}],
};
let error = validate_config_data(&ir, &data).unwrap_err();
assert!(matches!(
error,
SoraError::DuplicateIndexKey { table, index, key }
if table == "Item" && index == "by_type_name" && key == "item_type=Weapon,name=Iron Sword"
));
}
#[test]
fn validates_ranges_and_struct_fields() {
let ir = complex_ir();
let data = ConfigData {
tables: vec![
TableData {
name: "Item".to_owned(),
rows: vec![RowData {
values: BTreeMap::from([
("id".to_owned(), Value::Integer(1001)),
(
"reward".to_owned(),
Value::Object(BTreeMap::from([
("item_id".to_owned(), Value::Integer(1001)),
("count".to_owned(), Value::Integer(2)),
])),
),
(
"rolls".to_owned(),
Value::List(vec![Value::Integer(1), Value::Integer(3)]),
),
]),
}],
},
TableData {
name: "RewardSource".to_owned(),
rows: vec![RowData {
values: BTreeMap::from([("id".to_owned(), Value::Integer(1001))]),
}],
},
TableData {
name: "Settings".to_owned(),
rows: vec![RowData {
values: BTreeMap::from([("id".to_owned(), Value::Integer(1))]),
}],
},
],
};
validate_config_data(&ir, &data).unwrap();
}
#[test]
fn validates_string_and_collection_lengths() {
let ir = length_ir();
let valid = ConfigData {
tables: vec![TableData {
name: "Item".to_owned(),
rows: vec![RowData {
values: BTreeMap::from([
("id".to_owned(), Value::Integer(1001)),
("name".to_owned(), Value::String("Sword".to_owned())),
(
"tags".to_owned(),
Value::List(vec![
Value::String("sharp".to_owned()),
Value::String("rare".to_owned()),
]),
),
]),
}],
}],
};
validate_config_data(&ir, &valid).unwrap();
let invalid_name = ConfigData {
tables: vec![TableData {
name: "Item".to_owned(),
rows: vec![RowData {
values: BTreeMap::from([
("id".to_owned(), Value::Integer(1001)),
("name".to_owned(), Value::String("A".to_owned())),
(
"tags".to_owned(),
Value::List(vec![Value::String("x".to_owned())]),
),
]),
}],
}],
};
let error = validate_config_data(&ir, &invalid_name).unwrap_err();
assert!(matches!(
error,
SoraError::LengthOutOfBounds { field, actual: 1, min: 2, max: 8, .. }
if field == "name"
));
let invalid_tags = ConfigData {
tables: vec![TableData {
name: "Item".to_owned(),
rows: vec![RowData {
values: BTreeMap::from([
("id".to_owned(), Value::Integer(1001)),
("name".to_owned(), Value::String("Sword".to_owned())),
(
"tags".to_owned(),
Value::List(vec![
Value::String("a".to_owned()),
Value::String("b".to_owned()),
Value::String("c".to_owned()),
]),
),
]),
}],
}],
};
let error = validate_config_data(&ir, &invalid_tags).unwrap_err();
assert!(matches!(
error,
SoraError::LengthOutOfBounds { field, actual: 3, min: 1, max: 2, .. }
if field == "tags"
));
}
#[test]
fn validates_integer_width_bounds() {
let ir = integer_width_ir();
let valid = ConfigData {
tables: vec![TableData {
name: "Stats".to_owned(),
rows: vec![RowData {
values: BTreeMap::from([
("signed_byte_min".to_owned(), Value::Integer(i8::MIN as i64)),
("signed_byte_max".to_owned(), Value::Integer(i8::MAX as i64)),
("byte_max".to_owned(), Value::Integer(u8::MAX as i64)),
("short_min".to_owned(), Value::Integer(i16::MIN as i64)),
("ushort_max".to_owned(), Value::Integer(u16::MAX as i64)),
("uint_max".to_owned(), Value::Integer(u32::MAX as i64)),
]),
}],
}],
};
validate_config_data(&ir, &valid).unwrap();
let negative_u8 = ConfigData {
tables: vec![TableData {
name: "Stats".to_owned(),
rows: vec![RowData {
values: BTreeMap::from([
("signed_byte_min".to_owned(), Value::Integer(0)),
("signed_byte_max".to_owned(), Value::Integer(0)),
("byte_max".to_owned(), Value::Integer(-1)),
("short_min".to_owned(), Value::Integer(0)),
("ushort_max".to_owned(), Value::Integer(0)),
("uint_max".to_owned(), Value::Integer(0)),
]),
}],
}],
};
let error = validate_config_data(&ir, &negative_u8).unwrap_err();
assert!(matches!(error, SoraError::TypeMismatch { field, .. } if field == "byte_max"));
let overflowing_u32 = ConfigData {
tables: vec![TableData {
name: "Stats".to_owned(),
rows: vec![RowData {
values: BTreeMap::from([
("signed_byte_min".to_owned(), Value::Integer(0)),
("signed_byte_max".to_owned(), Value::Integer(0)),
("byte_max".to_owned(), Value::Integer(0)),
("short_min".to_owned(), Value::Integer(0)),
("ushort_max".to_owned(), Value::Integer(0)),
("uint_max".to_owned(), Value::Integer(u32::MAX as i64 + 1)),
]),
}],
}],
};
let error = validate_config_data(&ir, &overflowing_u32).unwrap_err();
assert!(matches!(error, SoraError::TypeMismatch { field, .. } if field == "uint_max"));
}
#[test]
fn rejects_range_struct_ref_and_singleton_errors() {
let ir = complex_ir();
let range_error = validate_config_data(
&ir,
&ConfigData {
tables: vec![
TableData {
name: "Item".to_owned(),
rows: vec![RowData {
values: BTreeMap::from([
("id".to_owned(), Value::Integer(1001)),
(
"reward".to_owned(),
Value::Object(BTreeMap::from([
("item_id".to_owned(), Value::Integer(1001)),
("count".to_owned(), Value::Integer(99)),
])),
),
(
"rolls".to_owned(),
Value::List(vec![Value::Integer(1), Value::Integer(3)]),
),
]),
}],
},
reward_source_table(),
singleton_table(1),
],
},
)
.unwrap_err();
assert!(matches!(
range_error,
SoraError::RangeOutOfBounds { field, .. } if field == "reward.count"
));
let ref_error = validate_config_data(
&ir,
&ConfigData {
tables: vec![
TableData {
name: "Item".to_owned(),
rows: vec![RowData {
values: BTreeMap::from([
("id".to_owned(), Value::Integer(1001)),
(
"reward".to_owned(),
Value::Object(BTreeMap::from([
("item_id".to_owned(), Value::Integer(404)),
("count".to_owned(), Value::Integer(2)),
])),
),
(
"rolls".to_owned(),
Value::List(vec![Value::Integer(1), Value::Integer(3)]),
),
]),
}],
},
reward_source_table(),
singleton_table(1),
],
},
)
.unwrap_err();
assert!(matches!(
ref_error,
SoraError::MissingReference { field, value, .. } if field == "reward.item_id" && value == "404"
));
let singleton_error = validate_config_data(
&ir,
&ConfigData {
tables: vec![
valid_complex_item_table(),
reward_source_table(),
TableData {
name: "Settings".to_owned(),
rows: vec![],
},
],
},
)
.unwrap_err();
assert!(matches!(
singleton_error,
SoraError::InvalidTableRowCount { table, actual: 0, .. } if table == "Settings"
));
}
fn assert_validation_error(
values: BTreeMap<String, Value>,
predicate: impl FnOnce(SoraError) -> bool,
) {
let ir = example_ir();
let data = ConfigData {
tables: vec![TableData {
name: "Item".to_owned(),
rows: vec![RowData { values }],
}],
};
let error = validate_config_data(&ir, &data).unwrap_err();
assert!(predicate(error));
}
fn valid_row(id: i64) -> BTreeMap<String, Value> {
BTreeMap::from([
("id".to_owned(), Value::Integer(id)),
("name".to_owned(), Value::String("Iron Sword".to_owned())),
("item_type".to_owned(), Value::String("Weapon".to_owned())),
("max_stack".to_owned(), Value::Integer(1)),
])
}
fn valid_complex_item_table() -> TableData {
TableData {
name: "Item".to_owned(),
rows: vec![RowData {
values: BTreeMap::from([
("id".to_owned(), Value::Integer(1001)),
(
"reward".to_owned(),
Value::Object(BTreeMap::from([
("item_id".to_owned(), Value::Integer(1001)),
("count".to_owned(), Value::Integer(2)),
])),
),
(
"rolls".to_owned(),
Value::List(vec![Value::Integer(1), Value::Integer(3)]),
),
]),
}],
}
}
fn reward_source_table() -> TableData {
TableData {
name: "RewardSource".to_owned(),
rows: vec![RowData {
values: BTreeMap::from([("id".to_owned(), Value::Integer(1001))]),
}],
}
}
fn singleton_table(id: i64) -> TableData {
TableData {
name: "Settings".to_owned(),
rows: vec![RowData {
values: BTreeMap::from([("id".to_owned(), Value::Integer(id))]),
}],
}
}
fn example_ir() -> ConfigIr {
let schema: SchemaFile = toml::from_str(
r#"
package = "game_config"
[[enums]]
name = "ItemType"
values = ["Weapon", "Armor", "Material", "Consumable"]
[[enums.aliases]]
name = "Weapon"
alias = "weapon"
[[tables]]
name = "Item"
mode = "map"
key = "id"
[tables.source]
format = "toml"
file = "items.toml"
[[tables.fields]]
name = "id"
type = "i32"
[[tables.fields]]
name = "name"
type = "string"
[[tables.fields]]
name = "item_type"
type = "enum<ItemType>"
[[tables.fields]]
name = "max_stack"
type = "i32"
"#,
)
.unwrap();
normalize_schema(schema).unwrap()
}
fn complex_ir() -> ConfigIr {
let schema: SchemaFile = toml::from_str(
r#"
package = "game_config"
[[structs]]
name = "Reward"
[[structs.fields]]
name = "item_id"
type = "ref<RewardSource.id>"
[[structs.fields]]
name = "count"
type = "i32"
range = [1, 10]
[[tables]]
name = "RewardSource"
mode = "map"
key = "id"
[[tables.fields]]
name = "id"
type = "i32"
[[tables]]
name = "Item"
mode = "map"
key = "id"
[[tables.fields]]
name = "id"
type = "i32"
[[tables.fields]]
name = "reward"
type = "struct<Reward>"
[[tables.fields]]
name = "rolls"
type = "array<i32,2>"
range = [1, 6]
[[tables]]
name = "Settings"
mode = "singleton"
[[tables.fields]]
name = "id"
type = "i32"
"#,
)
.unwrap();
normalize_schema(schema).unwrap()
}
fn index_ir() -> ConfigIr {
let schema: SchemaFile = toml::from_str(
r#"
package = "game_config"
[[enums]]
name = "ItemType"
values = ["Weapon", "Armor", "Material", "Consumable"]
[[tables]]
name = "Item"
mode = "map"
key = "id"
[[tables.fields]]
name = "id"
type = "i32"
[[tables.fields]]
name = "item_type"
type = "enum<ItemType>"
[[tables.fields]]
name = "name"
type = "string"
[[tables.indexes]]
name = "by_type_name"
fields = ["item_type", "name"]
unique = true
"#,
)
.unwrap();
normalize_schema(schema).unwrap()
}
fn length_ir() -> ConfigIr {
let schema: SchemaFile = toml::from_str(
r#"
package = "game_config"
[[tables]]
name = "Item"
mode = "map"
key = "id"
[[tables.fields]]
name = "id"
type = "i32"
[[tables.fields]]
name = "name"
type = "string"
length = [2, 8]
[[tables.fields]]
name = "tags"
type = "list<string>"
length = [1, 2]
"#,
)
.unwrap();
normalize_schema(schema).unwrap()
}
fn integer_width_ir() -> ConfigIr {
let schema: SchemaFile = toml::from_str(
r#"
package = "game_config"
[[tables]]
name = "Stats"
mode = "list"
[[tables.fields]]
name = "signed_byte_min"
type = "i8"
[[tables.fields]]
name = "signed_byte_max"
type = "i8"
[[tables.fields]]
name = "byte_max"
type = "u8"
[[tables.fields]]
name = "short_min"
type = "i16"
[[tables.fields]]
name = "ushort_max"
type = "u16"
[[tables.fields]]
name = "uint_max"
type = "u32"
"#,
)
.unwrap();
normalize_schema(schema).unwrap()
}