Skip to main content

ironcalc/
compare.rs

1#![allow(clippy::unwrap_used, clippy::panic)]
2
3use std::borrow::Cow;
4use std::path::Path;
5
6use ironcalc_base::cell::CellValue;
7use ironcalc_base::types::*;
8use ironcalc_base::{expressions::utils::number_to_column, Model};
9
10use crate::export::save_to_xlsx;
11use crate::import::load_from_xlsx;
12
13use super::util::get_workbook_metadata;
14
15#[cfg(feature = "mock_time")]
16use crate::mock_time::set_mock_time_from_metadata;
17
18pub struct CompareError {
19    message: String,
20}
21
22type CompareResult<T> = std::result::Result<T, CompareError>;
23
24pub struct Diff {
25    pub sheet_name: String,
26    pub row: i32,
27    pub column: i32,
28    pub value1: Cell,
29    pub value2: Cell,
30    pub reason: String,
31}
32
33// TODO use f64::EPSILON
34const EPS: f64 = 5e-8;
35// const EPS: f64 = f64::EPSILON;
36
37fn numbers_are_close(x: f64, y: f64, eps: f64) -> bool {
38    let norm = (x * x + y * y).sqrt();
39    if norm == 0.0 {
40        return true;
41    }
42    let d = f64::abs(x - y);
43    if d < eps {
44        return true;
45    }
46    d / norm < eps
47}
48/// Compares two Models in the internal representation and returns a list of differences
49pub fn compare(model1: &Model, model2: &Model) -> CompareResult<Vec<Diff>> {
50    let ws1 = model1.workbook.get_worksheet_names();
51    let ws2 = model2.workbook.get_worksheet_names();
52    if ws1.len() != ws2.len() {
53        return Err(CompareError {
54            message: "Different number of sheets".to_string(),
55        });
56    }
57    let eps = if let Ok(CellValue::Number(v)) = model1.get_cell_value_by_ref("METADATA!A1") {
58        v
59    } else {
60        EPS
61    };
62    let mut diffs = Vec::new();
63    let cells = model1.get_all_cells();
64    for cell in cells {
65        let sheet = cell.index;
66        let row = cell.row;
67        let column = cell.column;
68        let cell1 = &model1
69            .workbook
70            .worksheet(sheet)
71            .unwrap()
72            .cell(row, column)
73            .map(Cow::into_owned)
74            .unwrap_or_default();
75        let cell2 = &model2
76            .workbook
77            .worksheet(sheet)
78            .unwrap()
79            .cell(row, column)
80            .map(Cow::into_owned)
81            .unwrap_or_default();
82        match (cell1, cell2) {
83            (Cell::EmptyCell { .. }, Cell::EmptyCell { .. }) => {}
84
85            (Cell::NumberCell { v: value1, .. }, Cell::NumberCell { v: value2, .. }) => {
86                if !numbers_are_close(*value1, *value2, eps) {
87                    diffs.push(Diff {
88                        sheet_name: ws1[cell.index as usize].clone(),
89                        row,
90                        column,
91                        value1: cell1.clone(),
92                        value2: cell2.clone(),
93                        reason: "Numbers are different".to_string(),
94                    });
95                }
96            }
97
98            (Cell::BooleanCell { v: value1, .. }, Cell::BooleanCell { v: value2, .. }) => {
99                if value1 != value2 {
100                    diffs.push(Diff {
101                        sheet_name: ws1[cell.index as usize].clone(),
102                        row,
103                        column,
104                        value1: cell1.clone(),
105                        value2: cell2.clone(),
106                        reason: "Booleans are different".to_string(),
107                    });
108                }
109            }
110
111            (Cell::ErrorCell { ei: value1, .. }, Cell::ErrorCell { ei: value2, .. }) => {
112                if value1 != value2 {
113                    diffs.push(Diff {
114                        sheet_name: ws1[cell.index as usize].clone(),
115                        row,
116                        column,
117                        value1: cell1.clone(),
118                        value2: cell2.clone(),
119                        reason: "Errors are different".to_string(),
120                    });
121                }
122            }
123
124            (Cell::SharedString { si: value1, .. }, Cell::SharedString { si: value2, .. }) => {
125                // FIXME: compare resolved shared-string contents, not indices,
126                // if the two workbooks can have different shared-string tables.
127                if value1 != value2 {
128                    diffs.push(Diff {
129                        sheet_name: ws1[cell.index as usize].clone(),
130                        row,
131                        column,
132                        value1: cell1.clone(),
133                        value2: cell2.clone(),
134                        reason: "Strings are different".to_string(),
135                    });
136                }
137            }
138
139            (
140                Cell::CellFormula { v: v1, .. } | Cell::ArrayFormula { v: v1, .. },
141                Cell::CellFormula { v: v2, .. } | Cell::ArrayFormula { v: v2, .. },
142            ) => {
143                let mismatch = match (v1, v2) {
144                    (FormulaValue::Unevaluated, FormulaValue::Unevaluated) => false,
145                    (FormulaValue::Boolean(a), FormulaValue::Boolean(b)) => a != b,
146                    (FormulaValue::Number(a), FormulaValue::Number(b)) => {
147                        !numbers_are_close(*a, *b, eps)
148                    }
149                    (FormulaValue::Text(a), FormulaValue::Text(b)) => a != b,
150                    (FormulaValue::Error { ei: e1, .. }, FormulaValue::Error { ei: e2, .. }) => {
151                        e1 != e2
152                    }
153                    // Some xlsx files store formula errors as t="str" with the error code as
154                    // text instead of the correct t="e". Treat them as equivalent.
155                    (FormulaValue::Text(s), FormulaValue::Error { ei, .. })
156                    | (FormulaValue::Error { ei, .. }, FormulaValue::Text(s)) => {
157                        s != &format!("{ei}")
158                    }
159                    _ => true,
160                };
161                if mismatch {
162                    diffs.push(Diff {
163                        sheet_name: ws1[cell.index as usize].clone(),
164                        row,
165                        column,
166                        value1: cell1.clone(),
167                        value2: cell2.clone(),
168                        reason: "Formula values are different".to_string(),
169                    });
170                }
171            }
172
173            (Cell::SpillCell { v: v1, .. }, Cell::SpillCell { v: v2, .. }) => {
174                let mismatch = match (v1, v2) {
175                    (SpillValue::Boolean(a), SpillValue::Boolean(b)) => a != b,
176                    (SpillValue::Number(a), SpillValue::Number(b)) => {
177                        !numbers_are_close(*a, *b, eps)
178                    }
179                    (SpillValue::Text(a), SpillValue::Text(b)) => a != b,
180                    (SpillValue::Error(a), SpillValue::Error(b)) => a != b,
181                    _ => true,
182                };
183                if mismatch {
184                    diffs.push(Diff {
185                        sheet_name: ws1[cell.index as usize].clone(),
186                        row,
187                        column,
188                        value1: cell1.clone(),
189                        value2: cell2.clone(),
190                        reason: "Spill values are different".to_string(),
191                    });
192                }
193            }
194
195            (_, _) => {
196                diffs.push(Diff {
197                    sheet_name: ws1[cell.index as usize].clone(),
198                    row,
199                    column,
200                    value1: cell1.clone(),
201                    value2: cell2.clone(),
202                    reason: "Types are different".to_string(),
203                });
204            }
205        }
206    }
207    Ok(diffs)
208}
209
210fn cell_display(cell: &Cell) -> String {
211    match cell {
212        Cell::EmptyCell { .. } => "(empty)".to_string(),
213        Cell::NumberCell { v, .. } => format!("{v}"),
214        Cell::BooleanCell { v, .. } => format!("{v}"),
215        Cell::ErrorCell { ei, .. } => format!("{ei} (error)"),
216        Cell::SharedString { si, .. } => format!("shared_string[{si}]"),
217        Cell::CellFormula {
218            v: FormulaValue::Unevaluated,
219            ..
220        } => "(unevaluated formula)".to_string(),
221        Cell::CellFormula {
222            v: FormulaValue::Boolean(v),
223            ..
224        }
225        | Cell::ArrayFormula {
226            v: FormulaValue::Boolean(v),
227            ..
228        } => format!("{v} (bool)"),
229        Cell::CellFormula {
230            v: FormulaValue::Number(v),
231            ..
232        }
233        | Cell::ArrayFormula {
234            v: FormulaValue::Number(v),
235            ..
236        } => format!("{v} (number)"),
237        Cell::CellFormula {
238            v: FormulaValue::Text(v),
239            ..
240        }
241        | Cell::ArrayFormula {
242            v: FormulaValue::Text(v),
243            ..
244        } => format!("\"{v}\" (string)"),
245        Cell::CellFormula {
246            v: FormulaValue::Error { ei, .. },
247            ..
248        }
249        | Cell::ArrayFormula {
250            v: FormulaValue::Error { ei, .. },
251            ..
252        } => format!("{ei} (error)"),
253        Cell::ArrayFormula {
254            v: FormulaValue::Unevaluated,
255            s,
256            r,
257            kind,
258            ..
259        } => {
260            format!("(unevaluated {kind:?} formula, size={s}, range={r:?})")
261        }
262        Cell::SpillCell {
263            v: SpillValue::Number(v),
264            s,
265            a,
266        } => {
267            format!("{v} (spill, size={s}, area={a:?})")
268        }
269        Cell::SpillCell {
270            v: SpillValue::Boolean(v),
271            s,
272            a,
273        } => {
274            format!("{v} (spill, size={s}, area={a:?})")
275        }
276        Cell::SpillCell {
277            v: SpillValue::Error(ei),
278            s,
279            a,
280        } => {
281            format!("{ei} (spill, size={s}, area={a:?})")
282        }
283        Cell::SpillCell {
284            v: SpillValue::Text(v),
285            s,
286            a,
287        } => {
288            format!("\"{v}\" (spill, size={s}, area={a:?})")
289        }
290    }
291}
292
293pub(crate) fn compare_models(m1: &Model, m2: &Model) -> Result<(), String> {
294    match compare(m1, m2) {
295        Ok(diffs) => {
296            if diffs.is_empty() {
297                Ok(())
298            } else {
299                let count = diffs.len();
300                let mut lines = format!(
301                    "Models are different ({count} diff{}):\n",
302                    if count == 1 { "" } else { "s" }
303                );
304                for diff in diffs {
305                    let col = number_to_column(diff.column).unwrap();
306                    let cell_ref = format!("{}!{}{}", diff.sheet_name, col, diff.row);
307                    let excel = cell_display(&diff.value1);
308                    let ironcalc = cell_display(&diff.value2);
309                    lines.push_str(&format!(
310                        "\n  {cell_ref:<16}  Excel: {excel:<30}  IronCalc: {ironcalc:<30}  [{}]",
311                        diff.reason
312                    ));
313                }
314                Err(lines)
315            }
316        }
317        Err(r) => Err(format!("Models are different: {}", r.message)),
318    }
319}
320
321/// Tests that file in file_path produces the same results in Excel and in IronCalc.
322pub fn test_file(file_path: &str) -> Result<(), String> {
323    // FIXME: we need to load the model twice :S
324    let model1 = load_from_xlsx(file_path, "en", "UTC", "en").unwrap();
325    let locale = get_workbook_metadata(&model1);
326    // Loading a model already evaluates some cells (conditional formatting),
327    // so the clock must be mocked before loading the models we compare.
328    #[cfg(feature = "mock_time")]
329    set_mock_time_from_metadata(&model1);
330    let model1 = load_from_xlsx(file_path, &locale, "UTC", "en").unwrap();
331    let mut model2 = load_from_xlsx(file_path, &locale, "UTC", "en").unwrap();
332    model2.evaluate();
333    compare_models(&model1, &model2)
334}
335
336/// Tests that file in file_path can be converted to xlsx and read again
337pub fn test_load_and_saving(file_path: &str, temp_dir_name: &Path) -> Result<(), String> {
338    // FIXME: we need to evaluate the model twice :S
339    let model1 = load_from_xlsx(file_path, "en", "UTC", "en").unwrap();
340    let locale = get_workbook_metadata(&model1);
341    // Loading a model already evaluates some cells (conditional formatting),
342    // so the clock must be mocked before loading the models we compare.
343    #[cfg(feature = "mock_time")]
344    set_mock_time_from_metadata(&model1);
345
346    let model1 = load_from_xlsx(file_path, &locale, "UTC", "en").unwrap();
347
348    let base_name = Path::new(file_path).file_name().unwrap().to_str().unwrap();
349
350    let temp_path_buff = temp_dir_name.join(base_name);
351    let temp_file_path = &format!("{}.xlsx", temp_path_buff.to_str().unwrap());
352    // test can save
353    save_to_xlsx(&model1, temp_file_path).unwrap();
354    // test can open
355    let mut model2 = load_from_xlsx(temp_file_path, &locale, "UTC", "en").unwrap();
356    model2.evaluate();
357    compare_models(&model1, &model2)
358}
359
360#[cfg(test)]
361mod tests {
362    use crate::compare::compare;
363    use ironcalc_base::Model;
364
365    #[test]
366    fn compare_different_sheets() {
367        let mut model1 = Model::new_empty("model", "en", "UTC", "en").unwrap();
368        model1.new_sheet();
369        let model2 = Model::new_empty("model", "en", "UTC", "en").unwrap();
370
371        assert!(compare(&model1, &model2).is_err());
372    }
373}