1use std::collections::BTreeMap;
42
43use vla_lang::calc::{agrees, Calc, CellId, Computed, Extent, Library, Names, Reason, Shapes};
44use vla_lang::rows::{datum, quoted, Row, Sink, Value};
45use vla_lang::sheet::{cell_ref, parse_a1_range, Cell, Content, Sheet, Workbook};
46
47use super::{open, Discard};
48use crate::messages::Refusal;
49
50#[derive(Debug, Default)]
52pub struct Collected {
53 pub model: Workbook,
54 pub cached: BTreeMap<CellId, Value>,
56}
57
58impl Collected {
59 fn place(&mut self, sheet: &str, addr: &str) -> Option<CellId> {
60 let i = self.model.ensure_sheet(sheet);
61 let r = parse_a1_range(addr)?;
62 Some((i, r.top, r.left))
63 }
64}
65
66impl Sink for Collected {
67 fn row(&mut self, row: &Row<'_>) {
68 match row {
69 Row::Sheet { name, .. } => {
70 self.model.ensure_sheet(name);
71 }
72 Row::Name { name, refers_to } => {
73 self.model
74 .defined_names
75 .push((name.to_string(), refers_to.to_string()));
76 }
77 Row::Table { .. } | Row::Refers { .. } => {}
78 Row::Cell { sheet, addr, value } => {
79 let Some(id) = self.place(sheet, addr) else {
80 return;
81 };
82 let content = match value {
83 Value::Number(text) => match text.trim().parse::<f64>() {
84 Ok(n) if n.is_finite() => Content::Number(n),
85 _ => Content::Text(text.clone()),
86 },
87 Value::Text(t) => Content::Text(t.clone()),
88 Value::Bool(b) => Content::Bool(*b),
89 Value::Error(e) => Content::Error(e.clone()),
90 Value::Date(d) => Content::Text(d.clone()),
91 };
92 self.model.sheets[id.0].set(id.1, id.2, Cell { content, style: 0 });
93 self.cached.insert(id, (*value).clone());
94 }
95 Row::Formula { sheet, addr, text } => {
96 let Some(id) = self.place(sheet, addr) else {
97 return;
98 };
99 let body = text.strip_prefix('=').unwrap_or(text);
102 self.model.sheets[id.0].set(
103 id.1,
104 id.2,
105 Cell {
106 content: Content::Formula(body.to_string()),
107 style: 0,
108 },
109 );
110 }
111 }
112 }
113}
114
115#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
117pub struct Counts {
118 pub formulas: u64,
119 pub computed: u64,
120 pub not_computed: u64,
121 pub cycles: u64,
122 pub agree: u64,
123 pub differ: u64,
124 pub unchecked: u64,
125}
126
127impl Counts {
128 pub fn line(&self) -> String {
130 format!(
131 "formulas {} computed {} not-computed {} cycles {} agree {} differ {} unchecked {}",
132 self.formulas,
133 self.computed,
134 self.not_computed,
135 self.cycles,
136 self.agree,
137 self.differ,
138 self.unchecked
139 )
140 }
141}
142
143pub fn collect(bytes: &[u8], label: &str) -> Result<Collected, Refusal> {
147 let package = open(bytes, label)?;
148 let mut collected = Collected::default();
149 package.header_rows(&mut collected);
150 for i in 0..package.sheets().len() {
152 package.walk_sheet(i, &mut collected)?;
153 }
154 let _ = Discard;
155 Ok(collected)
156}
157
158pub fn census_of(collected: &Collected, library: &Library) -> String {
166 let model = &collected.model;
167 let extents: Vec<Extent> = model.sheets.iter().map(Sheet::extent).collect();
168 let plan = Shapes::of(
169 model,
170 model.sheets.len(),
171 library,
172 &Names::of(model),
173 &extents,
174 );
175 plan.census(model, library)
176}
177
178pub fn rows_of(collected: &Collected, library: &Library) -> (String, Counts) {
180 let calc = Calc::run(&collected.model, library);
181 let mut out = String::new();
182 let mut counts = Counts::default();
183 for cycle in calc.cycles() {
184 counts.cycles += 1;
185 let cells: Vec<String> = cycle
186 .iter()
187 .map(|id| quoted(&calc.spell_cell(*id)))
188 .collect();
189 out.push_str(&format!("(cycle {})\n", cells.join(" ")));
190 }
191 for id in calc.formula_cells() {
192 counts.formulas += 1;
193 let sheet = &collected.model.sheets[id.0].name;
194 let addr = cell_ref(id.1, id.2);
195 let formula = format!("={}", calc.formula(id).unwrap_or(""));
196 let computed = calc
197 .computed(id)
198 .cloned()
199 .unwrap_or(Computed::NotComputed(Reason::Cycle));
200 let cached = collected.cached.get(&id);
201 let verdict = match (&computed, cached) {
202 (Computed::Value(v), Some(c)) => match agrees(v, c) {
203 Some(true) => "agree",
204 Some(false) => "differ",
205 None => "unchecked",
206 },
207 _ => "unchecked",
208 };
209 match &computed {
210 Computed::Value(_) => counts.computed += 1,
211 Computed::NotComputed(_) => counts.not_computed += 1,
212 }
213 match verdict {
214 "agree" => counts.agree += 1,
215 "differ" => counts.differ += 1,
216 _ => counts.unchecked += 1,
217 }
218 out.push_str(&format!(
219 "(calc {} {} {} {} {} {})\n",
220 quoted(sheet),
221 quoted(&addr),
222 quoted(&formula),
223 computed.spell(),
224 cached.map(datum).unwrap_or_else(|| "none".to_string()),
225 verdict
226 ));
227 }
228 (out, counts)
229}
230
231pub fn calc_text(bytes: &[u8], label: &str, library: &Library) -> Result<String, Refusal> {
234 let collected = collect(bytes, label)?;
235 Ok(rows_of(&collected, library).0)
236}
237
238pub fn calc_counts(bytes: &[u8], label: &str, library: &Library) -> Result<Counts, Refusal> {
240 let collected = collect(bytes, label)?;
241 Ok(rows_of(&collected, library).1)
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247 use crate::excel;
248
249 const SAVED: &[u8] = include_bytes!("../../../scripts/reflect/saved.xlsx");
250 const FIXTURE: &[u8] = include_bytes!("../../../scripts/reflect/fixture.xlsx");
251 const BUILT: &[u8] = include_bytes!("../../../scripts/build/fixture_golden.xlsx");
252
253 #[test]
254 fn the_file_excel_saved_agrees_on_every_formula() {
255 let lib = excel::library();
256 let text = calc_text(SAVED, "saved.xlsx", &lib).unwrap();
257 let rows: Vec<&str> = text.lines().collect();
258 assert_eq!(rows.len(), 5, "{text}");
259 for row in &rows {
260 assert!(row.ends_with(" agree)"), "{row}");
261 }
262 assert!(
263 rows.contains(&"(calc \"Output\" \"C2\" \"=B2+B3\" 15 15 agree)"),
264 "{text}"
265 );
266 assert!(
267 rows.contains(&"(calc \"Output\" \"D2\" \"=IFS(B2>3,\\\"big\\\",TRUE,\\\"small\\\")\" \"big\" \"big\" agree)"),
268 "{text}"
269 );
270 let counts = calc_counts(SAVED, "saved.xlsx", &lib).unwrap();
271 assert_eq!(
272 counts,
273 Counts {
274 formulas: 5,
275 computed: 5,
276 not_computed: 0,
277 cycles: 0,
278 agree: 5,
279 differ: 0,
280 unchecked: 0
281 }
282 );
283 }
284
285 #[test]
288 fn the_core_s_own_build_holds_the_values_it_computes_and_agrees() {
289 let lib = excel::library();
290 let text = calc_text(BUILT, "fixture_golden.xlsx", &lib).unwrap();
291 for row in text.lines() {
292 assert!(row.ends_with(" agree)"), "{row}");
293 }
294 assert!(
295 text.contains("(calc \"Output\" \"B3\" \"=B2*2\" 10 10 agree)"),
296 "{text}"
297 );
298 }
299
300 #[test]
301 fn the_reflect_fixture_names_what_this_slice_does_not_compute() {
302 let lib = excel::library();
303 let text = calc_text(FIXTURE, "fixture.xlsx", &lib).unwrap();
304 let has = |s: &str| assert!(text.contains(s), "missing {s} in\n{text}");
305 has("(calc \"Model\" \"C1\" \"=B1*2\" 2400 2400 agree)");
306 has("(calc \"Model\" \"D1\" \"=SUM(B:B)\"");
307 has("(not-computed \"Sales[Amount]\")");
308 has("(not-computed \"SEQUENCE\")");
309 has("(not-computed \"INDIRECT\")");
310 has("(not-computed \"OFFSET\")");
311 has("(not-computed \"[Rates.xlsx]Sheet1!A1\")");
312 has("(not-computed \"B1:B2\")");
313 has("(calc \"Model\" \"E5\" \"=1/0\" (error \"#DIV/0!\") (error \"#DIV/0!\") agree)");
314 has("(calc \"Model\" \"B4\" \"=B3*Rate\" 80 80 agree)");
315 has("(calc \"Model\" \"G2\" \"=Local\" 800 800 agree)");
316 has("(calc \"Model\" \"D4\" \"=SUM(Model:Scratch!B1)\"");
317 let counts = calc_counts(FIXTURE, "fixture.xlsx", &lib).unwrap();
318 assert_eq!(counts.differ, 0, "{text}");
319 assert_eq!(counts.cycles, 0);
320 assert!(counts.agree >= 15, "{}", counts.line());
321 }
322}