use crate::diagnose::{observations_json, root_causes_json};
use crate::diagnostics::{json_string, variant_to_json};
use crate::testworkbook::FixtureResult;
pub fn to_json(result: &FixtureResult) -> String {
match result {
FixtureResult::Passed {
seed,
cases_run,
hidden_cells,
} => {
let observations_field = if hidden_cells.is_some() {
format!(
",\"observations\":{}",
observations_json(hidden_cells.as_deref())
)
} else {
String::new()
};
format!(
"{{\"schema_version\":1,\"ok\":true,\"seed\":{},\"cases_run\":{}{}}}",
seed, cases_run, observations_field
)
}
FixtureResult::Failed {
seed,
case_index,
inputs_used,
failure,
resolution_kind,
hidden_cells,
} => {
let inputs_json: Vec<String> = inputs_used
.iter()
.map(|iu| {
format!(
"{{\"address\":{},\"value\":{}}}",
json_string(&iu.address),
variant_to_json(&iu.value)
)
})
.collect();
let mut fields = vec![format!("\"rule\":{}", json_string(&failure.rule))];
if let Some(a) = &failure.address {
fields.push(format!("\"address\":{}", json_string(a)));
}
if let Some(a) = &failure.actual {
fields.push(format!("\"actual\":{}", json_string(a)));
}
if let Some(m) = &failure.message {
fields.push(format!("\"message\":{}", json_string(m)));
}
let observations_field = if hidden_cells.is_some() {
format!(
",\"observations\":{}",
observations_json(hidden_cells.as_deref())
)
} else {
String::new()
};
format!(
"{{\"schema_version\":1,\"ok\":false,\"seed\":{},\"case_index\":{},\"inputs\":[{}],\"failure\":{{{}}},\"root_causes\":{}{}}}",
seed,
case_index,
inputs_json.join(","),
fields.join(","),
root_causes_json(resolution_kind.as_deref()),
observations_field,
)
}
}
}
pub fn to_plain_text(result: &FixtureResult) -> String {
match result {
FixtureResult::Passed {
seed,
cases_run,
hidden_cells,
} => {
let mut line = format!("ok: {} case(s) passed (seed {})", cases_run, seed);
if let Some(obs) = hidden_cells {
line.push_str(&format!(
"\n observation: RANGE_CONTAINS_HIDDEN_CELLS ({} of {} cells visible in {})",
obs.visible_cells, obs.total_cells, obs.address
));
}
line
}
FixtureResult::Failed {
seed,
case_index,
failure,
resolution_kind,
hidden_cells,
..
} => {
let mut line = format!(
"FAIL: case {} (seed {}) - {}",
case_index, seed, failure.rule
);
if let Some(a) = &failure.address {
line.push_str(&format!(" at {}", a));
}
if let Some(a) = &failure.actual {
line.push_str(&format!(": {}", a));
}
if let Some(m) = &failure.message {
line.push_str(&format!(": {}", m));
}
if let Some(kind) = resolution_kind {
let rc = crate::diagnose::RootCause::from_kind((**kind).clone());
line.push_str(&format!("\n root cause: {}", rc.code));
}
if let Some(obs) = hidden_cells {
line.push_str(&format!(
"\n observation: RANGE_CONTAINS_HIDDEN_CELLS ({} of {} cells visible in {})",
obs.visible_cells, obs.total_cells, obs.address
));
}
line
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testworkbook::{FailureDetail, InputUsed};
use crate::vm::{ResolutionFailureKind, Variant};
#[test]
fn to_json_success_shape_matches_test_workbook() {
let json = to_json(&FixtureResult::Passed {
seed: 42,
cases_run: 100,
hidden_cells: None,
});
assert!(json.contains("\"ok\":true"));
assert!(json.contains("\"seed\":42"));
assert!(json.contains("\"cases_run\":100"));
}
#[test]
fn to_json_unclassified_failure_reports_empty_root_causes() {
let result = FixtureResult::Failed {
seed: 7,
case_index: 3,
inputs_used: vec![InputUsed {
address: "Input!B2".to_string(),
value: Variant::Integer(-1),
}],
failure: FailureDetail {
rule: "no_runtime_error".to_string(),
address: None,
actual: None,
message: Some("Division by zero".to_string()),
},
resolution_kind: None,
hidden_cells: None,
};
let json = to_json(&result);
assert!(json.contains("\"root_causes\":[]"));
assert!(json.contains("\"message\":\"Division by zero\""));
}
#[test]
fn to_json_classified_failure_reports_a_root_cause() {
let result = FixtureResult::Failed {
seed: 42,
case_index: 17,
inputs_used: vec![InputUsed {
address: "Input!B2".to_string(),
value: Variant::Integer(999_999_999),
}],
failure: FailureDetail {
rule: "no_runtime_error".to_string(),
address: None,
actual: None,
message: Some("array 'arr' index 999999999 out of bounds".to_string()),
},
resolution_kind: Some(Box::new(ResolutionFailureKind::ArrayIndexOutOfBounds {
name: "arr".to_string(),
index: 999_999_999,
lower: 0,
upper: 9,
})),
hidden_cells: None,
};
let json = to_json(&result);
assert!(json.contains("\"code\":\"ARRAY_INDEX_OUT_OF_BOUNDS\""));
assert!(json.contains("\"name\":\"arr\""));
assert!(json.contains("\"index\":999999999"));
}
#[test]
fn to_plain_text_appends_root_cause_code_when_classified() {
let result = FixtureResult::Failed {
seed: 42,
case_index: 17,
inputs_used: vec![],
failure: FailureDetail {
rule: "no_runtime_error".to_string(),
address: None,
actual: None,
message: Some("array 'arr' index 999999999 out of bounds".to_string()),
},
resolution_kind: Some(Box::new(ResolutionFailureKind::ArrayIndexOutOfBounds {
name: "arr".to_string(),
index: 999_999_999,
lower: 0,
upper: 9,
})),
hidden_cells: None,
};
let text = to_plain_text(&result);
assert!(text.contains("root cause: ARRAY_INDEX_OUT_OF_BOUNDS"));
}
#[test]
fn to_plain_text_omits_root_cause_line_when_unclassified() {
let result = FixtureResult::Failed {
seed: 42,
case_index: 17,
inputs_used: vec![],
failure: FailureDetail {
rule: "no_runtime_error".to_string(),
address: None,
actual: None,
message: Some("Division by zero".to_string()),
},
resolution_kind: None,
hidden_cells: None,
};
let text = to_plain_text(&result);
assert!(!text.contains("root cause"));
}
use crate::vm::{HiddenCellsObservation, Interval};
fn sample_observation() -> HiddenCellsObservation {
HiddenCellsObservation {
sheet: "sheet1".to_string(),
address: "A1:C10".to_string(),
rows: 10,
columns: 3,
hidden_rows: vec![Interval { start: 3, end: 5 }],
hidden_columns: vec![],
total_cells: 30,
visible_cells: 21,
}
}
#[test]
fn to_json_passing_fixture_includes_observations_when_present() {
let json = to_json(&FixtureResult::Passed {
seed: 42,
cases_run: 20,
hidden_cells: Some(Box::new(sample_observation())),
});
assert!(json.contains("\"ok\":true"));
assert!(json.contains("\"observations\":[{\"code\":\"RANGE_CONTAINS_HIDDEN_CELLS\""));
assert!(json.contains("\"visible_cells\":21"));
}
#[test]
fn to_json_passing_fixture_omits_observations_when_absent() {
let json = to_json(&FixtureResult::Passed {
seed: 42,
cases_run: 20,
hidden_cells: None,
});
assert!(!json.contains("observations"));
}
#[test]
fn to_json_failing_fixture_includes_observations_alongside_root_causes() {
let result = FixtureResult::Failed {
seed: 42,
case_index: 3,
inputs_used: vec![],
failure: FailureDetail {
rule: "no_runtime_error".to_string(),
address: None,
actual: None,
message: Some("array 'arr' index 999999999 out of bounds".to_string()),
},
resolution_kind: Some(Box::new(ResolutionFailureKind::ArrayIndexOutOfBounds {
name: "arr".to_string(),
index: 999_999_999,
lower: 0,
upper: 9,
})),
hidden_cells: Some(Box::new(sample_observation())),
};
let json = to_json(&result);
assert!(json.contains("\"code\":\"ARRAY_INDEX_OUT_OF_BOUNDS\""));
assert!(json.contains("\"observations\":[{\"code\":\"RANGE_CONTAINS_HIDDEN_CELLS\""));
}
#[test]
fn to_plain_text_passing_fixture_appends_the_observation_note() {
let text = to_plain_text(&FixtureResult::Passed {
seed: 42,
cases_run: 20,
hidden_cells: Some(Box::new(sample_observation())),
});
assert!(text.starts_with("ok: 20 case(s) passed"));
assert!(text.contains("RANGE_CONTAINS_HIDDEN_CELLS"));
assert!(text.contains("21 of 30"));
}
}