use truecalc_core::{ErrorKind, Value};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use chrono::NaiveDate;
mod conformance_reporter;
use conformance_reporter::{collect_tsv_fixture_results, ConformanceReport, KNOWN_DEVIATIONS};
fn fixture_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/google_sheets")
}
fn fixture(name: &str) -> PathBuf {
fixture_dir().join(name)
}
fn lab_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/lab")
}
fn decode_xlsx_escapes(s: &str) -> String {
let mut result = String::new();
let mut rest = s;
while let Some(start) = rest.find("_x") {
result.push_str(&rest[..start]);
let after = &rest[start + 2..];
if let Some(end) = after.find('_') {
let hex = &after[..end];
if hex.len() == 4 && hex.chars().all(|c| c.is_ascii_hexdigit()) {
if let Ok(n) = u32::from_str_radix(hex, 16) {
if let Some(c) = char::from_u32(n) {
result.push(c);
rest = &after[end + 1..];
continue;
}
}
}
}
result.push_str("_x");
rest = after;
}
result.push_str(rest);
result
}
fn parse_error_string(s: &str) -> Option<ErrorKind> {
match s {
"#DIV/0!" => Some(ErrorKind::DivByZero),
"#VALUE!" => Some(ErrorKind::Value),
"#REF!" => Some(ErrorKind::Ref),
"#NAME?" => Some(ErrorKind::Name),
"#NUM!" => Some(ErrorKind::Num),
"#N/A" => Some(ErrorKind::NA),
"#NULL!" => Some(ErrorKind::Null),
"#ERROR!" => Some(ErrorKind::Value),
_ => None,
}
}
fn parse_array_literal(s: &str) -> Option<Vec<Value>> {
let s = s.trim();
if !s.starts_with('{') || !s.ends_with('}') {
return None;
}
let inner = &s[1..s.len() - 1];
let items: Vec<&str> = inner.split(|c| c == ',' || c == ';').collect();
let mut result = Vec::new();
for item in items {
let item = item.trim().trim_matches('"');
if let Some(kind) = parse_error_string(item) {
result.push(Value::Error(kind));
} else if item.eq_ignore_ascii_case("true") {
result.push(Value::Bool(true));
} else if item.eq_ignore_ascii_case("false") {
result.push(Value::Bool(false));
} else if let Ok(f) = item.parse::<f64>() {
result.push(Value::Number(f));
} else {
result.push(Value::Text(item.to_string()));
}
}
Some(result)
}
pub fn parse_expected(value: &str, expected_type: &str) -> Option<Value> {
match expected_type {
"number" => {
value.parse::<f64>().ok().map(Value::Number)
}
"boolean" => match value.to_uppercase().as_str() {
"TRUE" => Some(Value::Bool(true)),
"FALSE" => Some(Value::Bool(false)),
_ => None,
},
"error" => parse_error_string(value).map(Value::Error),
"string" => {
Some(Value::Text(decode_xlsx_escapes(value)))
}
"array" => {
Some(Value::Text(value.to_string()))
}
"date" => {
value.parse::<f64>().ok().map(Value::Date)
}
_ => Some(Value::Text(value.to_string())),
}
}
fn flatten_array(v: &Value) -> Vec<Value> {
match v {
Value::Array(items) => {
let mut flat = Vec::new();
for item in items {
match item {
Value::Array(inner) => flat.extend(inner.iter().cloned()),
other => flat.push(other.clone()),
}
}
flat
}
other => vec![other.clone()],
}
}
fn gas_iso_date_to_serial(s: &str) -> Option<f64> {
let date_part = s.split('T').next()?;
let date = NaiveDate::parse_from_str(date_part, "%Y-%m-%d").ok()?;
let epoch = NaiveDate::from_ymd_opt(1899, 12, 30)?;
Some(date.signed_duration_since(epoch).num_days() as f64)
}
fn top_left(v: &Value) -> &Value {
match v {
Value::Array(items) if !items.is_empty() => top_left(&items[0]),
other => other,
}
}
pub fn values_match(actual: &Value, expected: &Value, expected_type: &str) -> bool {
if expected_type == "array" {
let literal = match expected {
Value::Text(s) => s.as_str(),
_ => return false,
};
let expected_items = match parse_array_literal(literal) {
Some(items) => items,
None => return false,
};
let actual_items = flatten_array(actual);
if actual_items.len() != expected_items.len() {
return false;
}
return actual_items.iter().zip(expected_items.iter()).all(|(a, e)| {
values_match(a, e, infer_type(e))
});
}
let actual = top_left(actual);
match (actual, expected) {
(Value::Number(a), Value::Number(b)) => {
(a - b).abs() <= b.abs() * 1e-4 + 1e-10
}
(Value::Date(a), Value::Date(b)) => {
(a - b).abs() <= b.abs() * 1e-4 + 1e-10
}
(Value::Date(a), Value::Number(b)) => {
(a - b).abs() <= b.abs() * 1e-4 + 1e-10
}
(Value::Text(s), Value::Number(b)) => {
if let Ok(v) = s.trim().parse::<f64>() {
(v - b).abs() <= b.abs() * 1e-9 + 1e-10
} else {
false
}
}
(Value::Number(a), Value::Text(s)) => {
if let Some(serial) = gas_iso_date_to_serial(s) {
(a - serial).abs() <= 1.0
} else {
false
}
}
(Value::Text(s), Value::Text(e)) if e.is_empty() => {
s.chars().all(|c| (c as u32) < 32)
}
(Value::Empty, Value::Text(e)) | (Value::Sparkline(_), Value::Text(e)) => e.is_empty(),
(Value::Text(s), Value::Text(e)) => {
if s == e {
return true;
}
if let (Ok(sv), Ok(ev)) = (s.trim().parse::<f64>(), e.trim().parse::<f64>()) {
return (sv - ev).abs() <= ev.abs() * 1e-9 + 1e-15;
}
false
}
(Value::Error(a), Value::Error(b)) => a == b,
_ => actual == expected,
}
}
fn infer_type(v: &Value) -> &'static str {
match v {
Value::Number(_) | Value::Date(_) | Value::Zoned(_) => "number",
Value::Text(_) => "string",
Value::Bool(_) => "boolean",
Value::Error(_) | Value::ErrorMsg(_, _) => "error",
Value::Array(_) => "array",
Value::Empty => "string",
Value::Sparkline(_) => "sparkline",
}
}
fn needs_authored_input_cells(formula: &str) -> bool {
let Ok(expr) = truecalc_core::Engine::sheets().parse(formula) else {
return quotes_sheet_qualified_ref(formula);
};
has_sheet_qualified_ref(&expr) || quotes_sheet_qualified_ref(formula)
}
fn has_sheet_qualified_ref(expr: &truecalc_core::Expr) -> bool {
truecalc_core::extract_refs(expr).iter().any(|r| {
matches!(
r,
truecalc_core::Ref::Cell { sheet: Some(_), .. }
| truecalc_core::Ref::Range { sheet: Some(_), .. }
)
})
}
fn quotes_sheet_qualified_ref(formula: &str) -> bool {
formula.split('"').skip(1).step_by(2).any(|literal| {
literal.contains('!')
&& truecalc_core::Engine::sheets()
.parse(literal)
.is_ok_and(|expr| has_sheet_qualified_ref(&expr))
})
}
const KNOWN_ENGINE_GAPS: &[(&str, &str, &str)] = &[];
fn known_engine_gap(path: &Path, formula: &str) -> Option<&'static str> {
let name = path.file_name()?.to_str()?;
KNOWN_ENGINE_GAPS
.iter()
.find(|(file, f, _)| *file == name && *f == formula)
.map(|(_, _, issue)| *issue)
}
fn is_volatile_formula(formula: &str) -> bool {
let upper = formula.to_uppercase();
upper.contains("RAND()") || upper.contains("RANDBETWEEN(") || upper.contains("RANDARRAY(")
}
fn pinned_now_serial(path: &Path) -> Option<f64> {
match path.file_name().and_then(|n| n.to_str()) {
Some("workbook.tsv") => Some(46180.0 + (23.0 * 3600.0 + 50.0 * 60.0 + 56.808) / 86400.0),
_ => None,
}
}
fn is_recognized_expected_type(t: &str) -> bool {
matches!(t, "number" | "string" | "boolean" | "error" | "array" | "date")
}
fn tolerated_malformed_rows(path: &Path) -> usize {
match path.file_name().and_then(|n| n.to_str()) {
Some("text.tsv") => 3,
_ => 0,
}
}
#[derive(Default)]
struct RowTally {
rows: usize,
enforced: usize,
authored_cells: usize,
volatile: usize,
malformed: Vec<String>,
known_gaps: Vec<String>,
}
impl RowTally {
fn note_malformed(&mut self, row: usize, desc: &str, formula: &str, reason: &str) {
self.malformed
.push(format!(" row {row} {desc} [{reason}]\n formula: {formula}"));
}
fn summary(&self, path: &Path) -> String {
let name = path.file_name().unwrap_or_default().to_string_lossy();
let mut parts = vec![format!("{} enforced", self.enforced)];
for (count, reason) in [
(self.authored_cells, "reads authored cells"),
(self.volatile, "volatile"),
(self.malformed.len(), "malformed row"),
] {
if count > 0 {
parts.push(format!("{count} skipped ({reason})"));
}
}
if !self.known_gaps.is_empty() {
parts.push(format!("{} of them known engine gaps", self.known_gaps.len()));
}
format!("{name}: {} rows — {}", self.rows, parts.join(", "))
}
fn malformed_over_baseline(&self, path: &Path) -> Option<String> {
let allowed = tolerated_malformed_rows(path);
if self.malformed.len() <= allowed {
return None;
}
Some(format!(
" {} malformed rows (baseline {allowed}) — a row that is not a usable test case \
asserts nothing:\n{}",
self.malformed.len(),
self.malformed.join("\n"),
))
}
}
enum Row<'a> {
Enforce {
desc: &'a str,
formula: &'a str,
expected: Value,
expected_type: &'a str,
},
Skipped,
}
fn classify_row<'a>(record: &'a csv::StringRecord, row_no: usize, tally: &mut RowTally) -> Row<'a> {
let desc = record[0].trim();
let formula = record[1].trim();
let expected_str = &record[2];
let _test_category = record[3].trim();
let expected_type = record[4].trim();
if formula.is_empty() {
tally.note_malformed(row_no, desc, formula, "no formula");
return Row::Skipped;
}
if !formula.starts_with('=') {
tally.note_malformed(row_no, desc, formula, "formula column is not a formula");
return Row::Skipped;
}
if !is_recognized_expected_type(expected_type) {
tally.note_malformed(
row_no,
desc,
formula,
&format!("unrecognised expected_type {expected_type:?}"),
);
return Row::Skipped;
}
let Some(expected) = parse_expected(expected_str, expected_type) else {
tally.note_malformed(
row_no,
desc,
formula,
&format!("recorded value {expected_str:?} is not a valid {expected_type}"),
);
return Row::Skipped;
};
if is_volatile_formula(formula) {
tally.volatile += 1;
return Row::Skipped;
}
if needs_authored_input_cells(formula) {
tally.authored_cells += 1;
return Row::Skipped;
}
tally.enforced += 1;
Row::Enforce { desc, formula, expected, expected_type }
}
fn run_tsv_fixture(path: &Path) {
assert!(path.exists(), "fixture not found: {:?}", path);
let pinned_now = pinned_now_serial(path);
let vars: HashMap<String, Value> = HashMap::new();
let mut failures: Vec<String> = Vec::new();
let mut tally = RowTally::default();
let mut rdr = csv::ReaderBuilder::new()
.delimiter(b'\t')
.has_headers(true)
.from_path(path)
.unwrap_or_else(|e| panic!("failed to open {:?}: {}", path, e));
for (row_idx, result) in rdr.records().enumerate() {
let record = result.unwrap_or_else(|e| panic!("bad row {} in {:?}: {}", row_idx + 2, path, e));
if record.len() < 5 {
continue;
}
tally.rows += 1;
let Row::Enforce { desc, formula, expected, expected_type } =
classify_row(&record, row_idx + 2, &mut tally)
else {
continue;
};
let actual = match pinned_now {
Some(now) => truecalc_core::Engine::sheets().evaluate_at(formula, &vars, now),
None => evaluate(formula, &vars),
};
let matched = values_match(&actual, &expected, expected_type);
match (matched, known_engine_gap(path, formula)) {
(true, None) => {}
(false, Some(issue)) => tally.known_gaps.push(format!(
" row {} {desc} (known gap, {issue})\n formula: {formula}",
row_idx + 2,
)),
(true, Some(issue)) => failures.push(format!(
" STALE known-engine-gap entry ({issue}) — row {} now PASSES; delete it from \
KNOWN_ENGINE_GAPS\n formula: {formula}",
row_idx + 2,
)),
(false, None) => failures.push(format!(
" FAIL row {} {desc}\n formula: {formula}\n expected: {expected:?}\n actual: {actual:?}",
row_idx + 2,
)),
}
}
println!("{}", tally.summary(path));
for gap in &tally.known_gaps {
println!("{gap}");
}
assert!(
tally.enforced > 0,
"{} enforced no rows — fixture or header is broken",
path.file_name().unwrap().to_string_lossy(),
);
failures.extend(tally.malformed_over_baseline(path));
if !failures.is_empty() {
panic!(
"\n{}/{} conformance failures in {}:\n\n{}\n\n{}\n",
failures.len(),
tally.enforced,
path.file_name().unwrap().to_string_lossy(),
failures.join("\n\n"),
tally.summary(path),
);
}
}
fn run_tsv_fixture_report(path: &Path) {
assert!(path.exists(), "fixture not found: {:?}", path);
let pinned_now = pinned_now_serial(path);
let vars: HashMap<String, Value> = HashMap::new();
let mut pass = 0usize;
let mut fail = 0usize;
let mut tally = RowTally::default();
let mut rdr = csv::ReaderBuilder::new()
.delimiter(b'\t')
.has_headers(true)
.from_path(path)
.unwrap_or_else(|e| panic!("failed to open {:?}: {}", path, e));
for (row_idx, result) in rdr.records().enumerate() {
let record = result.unwrap_or_else(|e| panic!("bad row {} in {:?}: {}", row_idx + 2, path, e));
if record.len() < 5 {
continue;
}
tally.rows += 1;
let Row::Enforce { desc, formula, expected, expected_type } =
classify_row(&record, row_idx + 2, &mut tally)
else {
continue;
};
let actual = match pinned_now {
Some(now) => truecalc_core::Engine::sheets().evaluate_at(formula, &vars, now),
None => evaluate(formula, &vars),
};
if values_match(&actual, &expected, expected_type) {
pass += 1;
} else {
fail += 1;
println!(
" FAIL row {} {desc}\n formula: {formula}\n expected: {expected:?}\n actual: {actual:?}",
row_idx + 2,
);
}
}
let name = path.file_name().unwrap_or_default().to_string_lossy();
println!("{name}: {pass} passed, {fail} open");
println!("{}", tally.summary(path));
for row in &tally.malformed {
println!(" SKIPPED (malformed)\n{row}");
}
}
macro_rules! conformance_tsv_test {
($fn_name:ident, $file:literal) => {
#[test]
fn $fn_name() {
run_tsv_fixture(&fixture($file));
}
};
}
macro_rules! conformance_tsv_test_report {
($fn_name:ident, $file:literal) => {
#[test]
fn $fn_name() {
run_tsv_fixture_report(&fixture($file));
}
};
}
conformance_tsv_test!(math_conformance, "math.tsv");
conformance_tsv_test!(logical_conformance, "logical.tsv");
conformance_tsv_test!(info_conformance, "info.tsv");
conformance_tsv_test!(statistical_conformance, "statistical.tsv");
conformance_tsv_test!(operator_conformance, "operator.tsv");
conformance_tsv_test!(text_conformance, "text.tsv");
conformance_tsv_test!(date_conformance, "date.tsv");
conformance_tsv_test!(engineering_conformance, "engineering.tsv");
conformance_tsv_test!(lookup_conformance, "lookup.tsv");
conformance_tsv_test!(parser_conformance, "parser.tsv");
conformance_tsv_test!(database_conformance, "database.tsv");
conformance_tsv_test!(array_conformance, "array.tsv");
conformance_tsv_test!(filter_conformance, "filter.tsv");
conformance_tsv_test!(web_conformance, "web.tsv");
conformance_tsv_test!(financial_conformance, "financial.tsv");
conformance_tsv_test!(google_conformance, "google.tsv");
#[test]
fn bugs_conformance() {
run_tsv_fixture_report(&fixture("bugs.tsv"));
}
#[test]
fn known_engine_gaps_all_match_a_live_row() {
let mut orphans = Vec::new();
for (file, formula, issue) in KNOWN_ENGINE_GAPS {
let path = fixture(file);
let mut rdr = csv::ReaderBuilder::new()
.delimiter(b'\t')
.has_headers(true)
.from_path(&path)
.unwrap_or_else(|e| panic!("failed to open {path:?}: {e}"));
let found = rdr
.records()
.filter_map(|r| r.ok())
.any(|r| r.len() >= 2 && r[1].trim() == *formula);
if !found {
orphans.push(format!(" {file} {formula} ({issue})"));
}
}
assert!(
orphans.is_empty(),
"KNOWN_ENGINE_GAPS entries matching no fixture row — delete them or fix the formula \
text:\n{}",
orphans.join("\n"),
);
}
fn collect_tsv_files(dir: &Path) -> Vec<PathBuf> {
let mut result = Vec::new();
let Ok(entries) = std::fs::read_dir(dir) else { return result };
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path();
if path.is_dir() {
result.extend(collect_tsv_files(&path));
} else if path.extension().and_then(|s| s.to_str()) == Some("tsv") {
result.push(path);
}
}
result
}
#[test]
fn lab_conformance() {
let dir = lab_dir();
let mut entries = collect_tsv_files(&dir);
entries.sort();
if entries.is_empty() {
println!("lab: no .tsv files — nothing to report");
return;
}
for path in &entries {
run_tsv_fixture_report(path);
}
}
#[test]
fn generate_conformance_report() {
let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
let gdir = fixture_dir();
let mut report = ConformanceReport::default();
report.known_deviations = KNOWN_DEVIATIONS.to_vec();
let categories = [
"math", "logical", "info", "statistical", "operator", "text",
"date", "engineering", "lookup", "parser", "database",
"array", "filter", "web", "financial",
];
for cat in &categories {
let path = gdir.join(format!("{cat}.tsv"));
collect_tsv_fixture_results(&path, cat, &mut report);
}
let out_dir = manifest.join("../../target");
std::fs::create_dir_all(&out_dir).ok();
let out_path = out_dir.join("conformance-report.json");
std::fs::write(&out_path, report.to_json())
.expect("failed to write conformance-report.json");
println!("conformance-report.json written to {}", out_path.display());
println!(
"Total: {}/{} passed ({} failed)",
report.total_passed(),
report.total_tests(),
report.total_failed(),
);
}
#[test]
fn every_registered_function_has_conformance_coverage() {
use truecalc_core::Registry;
let registry = Registry::new();
let all_names = registry.metadata_names();
let volatile: std::collections::HashSet<&str> = Registry::VOLATILE_FUNCTIONS
.iter()
.copied()
.collect();
let context_limited: std::collections::HashSet<&str> = [
"OFFSET", "FORMULATEXT", "GETPIVOTDATA",
]
.iter()
.copied()
.collect();
let truecalc_only: std::collections::HashSet<String> = registry
.get_metadata()
.iter()
.filter(|e| e.meta.category == "timezone")
.map(|e| e.name.to_uppercase())
.collect();
let pending_fixture_verification: std::collections::HashSet<&str> = ["QUERY"].iter().copied().collect();
let gdir = fixture_dir();
let vars: HashMap<String, Value> = HashMap::new();
let mut covered = std::collections::HashSet::new();
let mut acknowledged = std::collections::HashSet::new();
fn extract_fn_names(formula: &str, set: &mut std::collections::HashSet<String>) {
let upper = formula.to_uppercase();
let mut rest = upper.as_str();
while let Some(idx) = rest.find('(') {
let before = &rest[..idx];
let name_start = before
.rfind(|c: char| !c.is_alphanumeric() && c != '.' && c != '_')
.map(|i| i + 1)
.unwrap_or(0);
let name = &before[name_start..];
if !name.is_empty() {
set.insert(name.to_string());
}
rest = &rest[idx + 1..];
}
}
let bugs_path = gdir.join("bugs.tsv");
for entry in std::fs::read_dir(&gdir).expect("cannot read fixture dir") {
let entry = entry.unwrap();
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("tsv") {
continue;
}
let is_bugs = path == bugs_path;
let mut rdr = csv::ReaderBuilder::new()
.delimiter(b'\t')
.has_headers(true)
.from_path(&path)
.unwrap();
for result in rdr.records() {
let record = match result {
Ok(r) => r,
Err(_) => continue,
};
if record.len() < 2 {
continue;
}
let formula = record[1].trim();
if formula.is_empty() {
continue;
}
if is_bugs {
extract_fn_names(formula, &mut acknowledged);
continue;
}
if record.len() < 5 {
continue;
}
let expected_str = &record[2];
let expected_type = record[4].trim();
if is_volatile_formula(formula) || needs_authored_input_cells(formula) {
continue;
}
let expected = match parse_expected(expected_str, expected_type) {
Some(v) => v,
None => continue,
};
let actual = evaluate(formula, &vars);
if values_match(&actual, &expected, expected_type) {
extract_fn_names(formula, &mut covered);
}
}
}
let mut missing = Vec::new();
for name in &all_names {
let upper = name.to_uppercase();
if volatile.contains(upper.as_str())
|| context_limited.contains(upper.as_str())
|| truecalc_only.contains(&upper)
|| pending_fixture_verification.contains(upper.as_str())
|| covered.contains(&upper)
|| acknowledged.contains(&upper)
{
continue;
}
missing.push(name.clone());
}
missing.sort();
assert!(
missing.is_empty(),
"Functions with no passing conformance row: {:?}",
missing
);
}
fn evaluate(formula: &str, variables: &std::collections::HashMap<String, Value>) -> Value {
truecalc_core::Engine::sheets().evaluate(formula, variables)
}