use crate::llm::types::{DxDocument, DxLlmValue, DxSection};
use indexmap::IndexMap;
use proptest::prelude::*;
pub fn arb_key() -> impl Strategy<Value = String> {
"[a-z][a-z0-9_]{1,8}".prop_map(|s| s)
}
pub fn arb_simple_value() -> impl Strategy<Value = DxLlmValue> {
prop_oneof![
Just(DxLlmValue::Bool(true)),
Just(DxLlmValue::Bool(false)),
Just(DxLlmValue::Null),
(-1000i64..1000i64).prop_map(|n| DxLlmValue::Num(n as f64)),
"[a-zA-Z][a-zA-Z0-9_]{0,10}".prop_map(DxLlmValue::Str),
]
}
pub fn arb_dx_llm_value() -> impl Strategy<Value = DxLlmValue> {
prop_oneof![
7 => arb_simple_value(),
1 => proptest::collection::vec(arb_simple_value(), 1..5)
.prop_map(DxLlmValue::Arr),
1 => proptest::collection::vec((arb_key(), arb_simple_value()), 1..4)
.prop_map(|v| DxLlmValue::Obj(v.into_iter().collect())),
1 => arb_key().prop_map(DxLlmValue::Ref),
]
}
pub fn arb_section_id() -> impl Strategy<Value = char> {
prop_oneof![Just('a'), Just('b'), Just('c'), Just('d'), Just('e'),]
}
pub fn arb_dx_section() -> impl Strategy<Value = DxSection> {
proptest::collection::vec(arb_key(), 1..4).prop_flat_map(|schema| {
let schema_len = schema.len();
let row_strategy = proptest::collection::vec(arb_simple_value(), schema_len..=schema_len);
let rows_strategy = proptest::collection::vec(row_strategy, 0..5);
rows_strategy.prop_map(move |rows| {
let mut section = DxSection::new(schema.clone());
for row in rows {
let _ = section.add_row(row);
}
section
})
})
}
pub fn arb_context() -> impl Strategy<Value = IndexMap<String, DxLlmValue>> {
proptest::collection::vec((arb_key(), arb_simple_value()), 0..5)
.prop_map(|v| v.into_iter().collect())
}
pub fn arb_refs() -> impl Strategy<Value = IndexMap<String, String>> {
proptest::collection::vec(
(
"[A-Z][A-Z0-9_]{0,4}".prop_map(|s| s), "[a-zA-Z0-9_]{1,20}".prop_map(|s| s), ),
0..3,
)
.prop_map(|v| v.into_iter().collect())
}
pub fn arb_sections() -> impl Strategy<Value = IndexMap<char, DxSection>> {
proptest::collection::vec((arb_section_id(), arb_dx_section()), 0..2)
.prop_map(|v| v.into_iter().collect())
}
pub fn arb_dx_document() -> impl Strategy<Value = DxDocument> {
(arb_context(), arb_sections(), arb_refs()).prop_map(|(context, sections, refs)| {
let mut doc = DxDocument::new();
doc.context = context;
doc.sections = sections;
doc.refs = refs;
doc
})
}
pub fn arb_document_context_only() -> impl Strategy<Value = DxDocument> {
arb_context().prop_map(|context| {
let mut doc = DxDocument::new();
doc.context = context;
doc
})
}
#[cfg(test)]
mod property_tests {
use super::*;
use crate::llm::parser::LlmParser;
use crate::llm::serializer::LlmSerializer;
fn values_equal(a: &DxLlmValue, b: &DxLlmValue) -> bool {
match (a, b) {
(DxLlmValue::Num(x), DxLlmValue::Num(y)) => (x - y).abs() < 0.0001,
(DxLlmValue::Str(x), DxLlmValue::Str(y)) => x == y,
(DxLlmValue::Bool(x), DxLlmValue::Bool(y)) => x == y,
(DxLlmValue::Null, DxLlmValue::Null) => true,
(DxLlmValue::Arr(x), DxLlmValue::Arr(y)) => {
x.len() == y.len() && x.iter().zip(y.iter()).all(|(a, b)| values_equal(a, b))
}
(DxLlmValue::Obj(x), DxLlmValue::Obj(y)) => {
x.len() == y.len()
&& x.iter()
.all(|(k, v)| y.get(k).is_some_and(|yv| values_equal(v, yv)))
}
(DxLlmValue::Ref(_), DxLlmValue::Str(_)) => true,
(DxLlmValue::Str(_), DxLlmValue::Ref(_)) => true,
(DxLlmValue::Arr(arr), DxLlmValue::Str(s)) if arr.is_empty() && s.is_empty() => true,
(DxLlmValue::Str(s), DxLlmValue::Arr(arr)) if arr.is_empty() && s.is_empty() => true,
_ => false,
}
}
fn sections_equal(a: &DxSection, b: &DxSection) -> bool {
if a.schema != b.schema {
return false;
}
if a.rows.len() != b.rows.len() {
return false;
}
for (row_a, row_b) in a.rows.iter().zip(b.rows.iter()) {
if row_a.len() != row_b.len() {
return false;
}
for (val_a, val_b) in row_a.iter().zip(row_b.iter()) {
if !values_equal(val_a, val_b) {
return false;
}
}
}
true
}
fn documents_equal(a: &DxDocument, b: &DxDocument) -> bool {
use crate::llm::abbrev::AbbrevDict;
let abbrev = AbbrevDict::new();
if a.context.len() != b.context.len() {
return false;
}
for (key_a, val_a) in &a.context {
let compressed_key = abbrev.compress(key_a);
let val_b = b
.context
.get(key_a)
.or_else(|| b.context.get(&compressed_key));
if let Some(val_b) = val_b {
if !values_equal(val_a, val_b) {
return false;
}
} else {
return false;
}
}
if a.sections.len() != b.sections.len() {
return false;
}
let sections_a: Vec<&DxSection> = a.sections.values().collect();
let sections_b: Vec<&DxSection> = b.sections.values().collect();
let mut matched_b: Vec<bool> = vec![false; sections_b.len()];
for section_a in §ions_a {
let mut found = false;
for (i, section_b) in sections_b.iter().enumerate() {
if !matched_b[i] && sections_equal(section_a, section_b) {
matched_b[i] = true;
found = true;
break;
}
}
if !found {
return false;
}
}
true
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(100))]
#[test]
fn prop_llm_round_trip(doc in arb_document_context_only()) {
let serializer = LlmSerializer::new();
let llm_string = serializer.serialize(&doc);
let parsed = LlmParser::parse(&llm_string);
prop_assert!(parsed.is_ok(), "Failed to parse serialized LLM: {}", llm_string);
let parsed_doc = parsed.unwrap();
prop_assert!(
documents_equal(&doc, &parsed_doc),
"Round-trip failed:\nOriginal: {:?}\nSerialized: {}\nParsed: {:?}",
doc, llm_string, parsed_doc
);
}
#[test]
fn prop_dx_document_round_trip(doc in arb_dx_document()) {
let serializer = LlmSerializer::new();
let llm_string = serializer.serialize(&doc);
let parsed = LlmParser::parse(&llm_string);
prop_assert!(parsed.is_ok(), "Failed to parse serialized LLM: {}", llm_string);
let parsed_doc = parsed.unwrap();
prop_assert!(
documents_equal(&doc, &parsed_doc),
"Round-trip failed:\nOriginal: {:?}\nSerialized: {}\nParsed: {:?}",
doc, llm_string, parsed_doc
);
}
#[test]
fn prop_all_value_variants_round_trip(value in arb_dx_llm_value()) {
let serializer = LlmSerializer::new();
let mut doc = DxDocument::new();
doc.context.insert("val".to_string(), value.clone());
let llm_string = serializer.serialize(&doc);
let parsed = LlmParser::parse(&llm_string);
prop_assert!(parsed.is_ok(), "Failed to parse: {}", llm_string);
let parsed_doc = parsed.unwrap();
let parsed_value = parsed_doc.context.get("val");
prop_assert!(parsed_value.is_some(), "Value not found after round-trip");
prop_assert!(
values_equal(&value, parsed_value.unwrap()),
"Value mismatch:\nOriginal: {:?}\nParsed: {:?}",
value, parsed_value
);
}
#[test]
fn prop_boolean_round_trip(b in proptest::bool::ANY) {
let serializer = LlmSerializer::new();
let mut doc = DxDocument::new();
doc.context.insert("flag".to_string(), DxLlmValue::Bool(b));
let llm_string = serializer.serialize(&doc);
let parsed = LlmParser::parse(&llm_string).unwrap();
let parsed_value = parsed.context.get("flag").unwrap();
prop_assert_eq!(parsed_value.as_bool(), Some(b));
}
#[test]
fn prop_null_round_trip(_dummy in Just(())) {
let serializer = LlmSerializer::new();
let mut doc = DxDocument::new();
doc.context.insert("empty".to_string(), DxLlmValue::Null);
let llm_string = serializer.serialize(&doc);
let parsed = LlmParser::parse(&llm_string).unwrap();
let parsed_value = parsed.context.get("empty").unwrap();
prop_assert!(parsed_value.is_null());
}
#[test]
fn prop_numeric_round_trip(n in -10000i64..10000i64) {
let serializer = LlmSerializer::new();
let mut doc = DxDocument::new();
doc.context.insert("num".to_string(), DxLlmValue::Num(n as f64));
let llm_string = serializer.serialize(&doc);
let parsed = LlmParser::parse(&llm_string).unwrap();
let parsed_value = parsed.context.get("num").unwrap();
prop_assert_eq!(parsed_value.as_num(), Some(n as f64));
}
#[test]
fn prop_string_round_trip(s in "[a-zA-Z][a-zA-Z0-9_]{0,20}") {
let serializer = LlmSerializer::new();
let mut doc = DxDocument::new();
doc.context.insert("txt".to_string(), DxLlmValue::Str(s.clone()));
let llm_string = serializer.serialize(&doc);
let parsed = LlmParser::parse(&llm_string).unwrap();
let parsed_value = parsed.context.get("txt").unwrap();
prop_assert_eq!(parsed_value.as_str(), Some(s.as_str()));
}
#[test]
fn prop_array_round_trip(items in proptest::collection::vec(arb_simple_value(), 1..5)) {
let serializer = LlmSerializer::new();
let mut doc = DxDocument::new();
doc.context.insert("arr".to_string(), DxLlmValue::Arr(items.clone()));
let llm_string = serializer.serialize(&doc);
let parsed = LlmParser::parse(&llm_string);
prop_assert!(parsed.is_ok(), "Failed to parse: {}", llm_string);
let parsed_doc = parsed.unwrap();
let parsed_value = parsed_doc.context.get("arr");
prop_assert!(parsed_value.is_some(), "Array not found after round-trip");
if let Some(DxLlmValue::Arr(parsed_items)) = parsed_value {
prop_assert_eq!(
items.len(), parsed_items.len(),
"Array length mismatch: {} vs {}",
items.len(), parsed_items.len()
);
for (orig, parsed) in items.iter().zip(parsed_items.iter()) {
prop_assert!(
values_equal(orig, parsed),
"Array item mismatch: {:?} vs {:?}",
orig, parsed
);
}
} else {
prop_assert!(false, "Expected array, got: {:?}", parsed_value);
}
}
#[test]
fn prop_section_round_trip(section in arb_dx_section()) {
let serializer = LlmSerializer::new();
let mut doc = DxDocument::new();
doc.sections.insert('a', section.clone());
let llm_string = serializer.serialize(&doc);
let parsed = LlmParser::parse(&llm_string);
prop_assert!(parsed.is_ok(), "Failed to parse: {}", llm_string);
let parsed_doc = parsed.unwrap();
if !section.rows.is_empty() {
prop_assert!(
!parsed_doc.sections.is_empty(),
"Section lost after round-trip. Original: {:?}, Serialized: {}",
section, llm_string
);
}
}
#[test]
fn prop_obj_round_trip(fields in proptest::collection::vec((arb_key(), arb_simple_value()), 1..4)) {
let serializer = LlmSerializer::new();
let mut doc = DxDocument::new();
let obj_value = DxLlmValue::Obj(fields.iter().cloned().collect());
doc.context.insert("obj".to_string(), obj_value.clone());
let llm_string = serializer.serialize(&doc);
let parsed = LlmParser::parse(&llm_string);
prop_assert!(parsed.is_ok(), "Failed to parse Obj: {}", llm_string);
let parsed_doc = parsed.unwrap();
let parsed_value = parsed_doc.context.get("obj");
prop_assert!(parsed_value.is_some(), "Obj not found after round-trip");
prop_assert!(
values_equal(&obj_value, parsed_value.unwrap()),
"Obj mismatch:\nOriginal: {:?}\nSerialized: {}\nParsed: {:?}",
obj_value, llm_string, parsed_value
);
}
}
#[test]
fn test_llm_round_trip_basic() {
let serializer = LlmSerializer::new();
let mut doc = DxDocument::new();
doc.context
.insert("name".to_string(), DxLlmValue::Str("Test".to_string()));
doc.context
.insert("count".to_string(), DxLlmValue::Num(42.0));
doc.context
.insert("active".to_string(), DxLlmValue::Bool(true));
let llm_string = serializer.serialize(&doc);
let parsed = LlmParser::parse(&llm_string).unwrap();
assert_eq!(parsed.context.get("name").unwrap().as_str(), Some("Test"));
assert_eq!(parsed.context.get("count").unwrap().as_num(), Some(42.0));
assert_eq!(parsed.context.get("active").unwrap().as_bool(), Some(true));
}
#[test]
fn test_special_values_round_trip() {
let serializer = LlmSerializer::new();
let mut doc = DxDocument::new();
doc.context
.insert("true_val".to_string(), DxLlmValue::Bool(true));
doc.context
.insert("false_val".to_string(), DxLlmValue::Bool(false));
doc.context.insert("null_val".to_string(), DxLlmValue::Null);
let llm_string = serializer.serialize(&doc);
assert!(
llm_string.contains("true"),
"Should contain true for boolean true"
);
assert!(
llm_string.contains("false"),
"Should contain false for boolean false"
);
assert!(
llm_string.contains("null"),
"Should contain null for null value"
);
let parsed = LlmParser::parse(&llm_string).unwrap();
assert_eq!(
parsed.context.get("true_val").unwrap().as_bool(),
Some(true)
);
assert_eq!(
parsed.context.get("false_val").unwrap().as_bool(),
Some(false)
);
assert!(parsed.context.get("null_val").unwrap().is_null());
}
}