Skip to main content

ravenrs_sheet/engine/
cf.rs

1//! Conditional formatting evaluation: what extra style, data bar, colour scale or icon a cell
2//! gets.
3
4use std::collections::HashMap;
5
6use crate::base::{CellRef, RangeRef, Value, compare};
7use crate::model::{CfOperator, CfRule, CfValueKind, Color, CondFormat, PatternType, Sheet, Style, Underline, Workbook, default_icon_thresholds};
8
9/// The conditional look of one cell.
10#[derive(Clone, Debug, Default, PartialEq)]
11pub struct CfLook {
12    /// Differential style (only non-default parts apply).
13    pub style: Option<Style>,
14    /// Fill from a colour scale (RGB).
15    pub scale_fill: Option<[u8; 3]>,
16    /// Data bar: fraction 0..=1 of the cell width, colour, gradient.
17    pub bar: Option<(f32, [u8; 3], bool)>,
18    /// Icon: set name and index (0 = lowest).
19    pub icon: Option<(String, usize)>,
20    pub hide_value: bool,
21}
22
23/// Precomputed statistics per rule (min/max/percentiles/top-N thresholds/duplicates).
24#[derive(Default)]
25pub struct CfCache {
26    stats: HashMap<usize, RuleStats>,
27}
28
29#[derive(Default, Clone)]
30struct RuleStats {
31    sorted: Vec<f64>,
32    avg: f64,
33    std: f64,
34    counts: HashMap<String, usize>,
35}
36
37/// Which statistics a rule reads. Scanning the rule's whole range for them costs O(range), so
38/// rules that read none (cell value, formula, text, blanks, errors, dates) never scan.
39#[derive(Clone, Copy, PartialEq, Eq, Debug)]
40enum StatsNeed {
41    /// Sorted numbers, their mean and deviation (top/bottom N, above average, scales, bars, icons).
42    Numbers,
43    /// How often each displayed text occurs (duplicate / unique values).
44    Counts,
45}
46
47fn stats_need(rule: &CfRule) -> Option<StatsNeed> {
48    match rule {
49        CfRule::Duplicate { .. } | CfRule::Unique { .. } => Some(StatsNeed::Counts),
50        CfRule::Top10 { .. } | CfRule::AboveAverage { .. } | CfRule::ColorScale { .. } | CfRule::DataBar { .. } | CfRule::IconSet { .. } => {
51            Some(StatsNeed::Numbers)
52        }
53        _ => None,
54    }
55}
56
57impl RuleStats {
58    fn build(sh: &Sheet, ranges: &[RangeRef], need: StatsNeed) -> RuleStats {
59        let (sorted, counts) = numbers_in(sh, ranges, need);
60        let n = sorted.len().max(1) as f64;
61        let avg = sorted.iter().sum::<f64>() / n;
62        let std = (sorted.iter().map(|x| (x - avg).powi(2)).sum::<f64>() / n).sqrt();
63        RuleStats { sorted, avg, std, counts }
64    }
65}
66
67#[cfg(test)]
68thread_local! {
69    /// Cells `numbers_in` visited on this thread (tests count the work a lookup does).
70    static SCANNED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
71}
72
73fn numbers_in(sh: &Sheet, ranges: &[RangeRef], need: StatsNeed) -> (Vec<f64>, HashMap<String, usize>) {
74    let mut nums = Vec::new();
75    let mut counts = HashMap::new();
76    for r in ranges {
77        let r = match sh.used_range().and_then(|u| r.intersection(&u)) {
78            Some(x) => x,
79            None => continue,
80        };
81        for c in r.iter().take(2_000_000) {
82            #[cfg(test)]
83            SCANNED.with(|n| n.set(n.get() + 1));
84            let v = sh.value(c);
85            match need {
86                StatsNeed::Numbers => {
87                    if let Value::Number(n) = v {
88                        nums.push(n);
89                    }
90                }
91                StatsNeed::Counts => {
92                    if !v.is_empty() {
93                        *counts.entry(v.display().to_lowercase()).or_insert(0) += 1;
94                    }
95                }
96            }
97        }
98    }
99    nums.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
100    (nums, counts)
101}
102
103fn percentile(sorted: &[f64], p: f64) -> f64 {
104    if sorted.is_empty() {
105        return 0.0;
106    }
107    let rank = (p / 100.0).clamp(0.0, 1.0) * (sorted.len() - 1) as f64;
108    let lo = rank.floor() as usize;
109    let hi = rank.ceil() as usize;
110    let a = sorted.get(lo).copied().unwrap_or(0.0);
111    let b = sorted.get(hi).copied().unwrap_or(a);
112    a + (b - a) * (rank - lo as f64)
113}
114
115fn threshold(wb: &Workbook, sheet: usize, at: CellRef, st: &RuleStats, k: &CfValueKind) -> f64 {
116    let min = st.sorted.first().copied().unwrap_or(0.0);
117    let max = st.sorted.last().copied().unwrap_or(0.0);
118    match k {
119        CfValueKind::Min => min,
120        CfValueKind::Max => max,
121        CfValueKind::Number(n) => *n,
122        CfValueKind::Percent(p) => min + (max - min) * p / 100.0,
123        CfValueKind::Percentile(p) => percentile(&st.sorted, *p),
124        CfValueKind::Formula(f) => crate::calc::evaluate(wb, sheet, at, f).to_number().unwrap_or(0.0),
125    }
126}
127
128fn lerp(a: [u8; 3], b: [u8; 3], t: f64) -> [u8; 3] {
129    let t = t.clamp(0.0, 1.0);
130    [
131        (a[0] as f64 + (b[0] as f64 - a[0] as f64) * t).round() as u8,
132        (a[1] as f64 + (b[1] as f64 - a[1] as f64) * t).round() as u8,
133        (a[2] as f64 + (b[2] as f64 - a[2] as f64) * t).round() as u8,
134    ]
135}
136
137pub use crate::model::icon_count;
138
139struct RuleEval<'a> {
140    wb: &'a Workbook,
141    sheet: usize,
142    cell: CellRef,
143    first: CellRef,
144    value: &'a Value,
145    text: &'a str,
146    stats: &'a RuleStats,
147}
148
149fn simple_rule_style<'a>(ctx: &RuleEval<'_>, rule: &'a CfRule) -> Option<&'a Style> {
150    let RuleEval { wb, sheet, cell, first, value, text, stats } = ctx;
151    match rule {
152        CfRule::CellIs { op, a, b, style } => {
153            let eval = |f: &str| crate::calc::evaluate(wb, *sheet, *cell, &relative(f, *first, *cell));
154            let av = eval(a);
155            let matches = match op {
156                CfOperator::Between | CfOperator::NotBetween => {
157                    let bv = b.as_deref().map(eval).unwrap_or_default();
158                    let (lo, hi) = if compare(&av, &bv) == std::cmp::Ordering::Greater { (bv, av) } else { (av, bv) };
159                    let within = compare(value, &lo) != std::cmp::Ordering::Less && compare(value, &hi) != std::cmp::Ordering::Greater;
160                    if *op == CfOperator::Between { within } else { !within }
161                }
162                CfOperator::Equal => compare(value, &av) == std::cmp::Ordering::Equal,
163                CfOperator::NotEqual => compare(value, &av) != std::cmp::Ordering::Equal,
164                CfOperator::Greater => compare(value, &av) == std::cmp::Ordering::Greater,
165                CfOperator::Less => compare(value, &av) == std::cmp::Ordering::Less,
166                CfOperator::GreaterOrEqual => compare(value, &av) != std::cmp::Ordering::Less,
167                CfOperator::LessOrEqual => compare(value, &av) != std::cmp::Ordering::Greater,
168            };
169            (matches && !value.is_error()).then_some(style.as_ref())
170        }
171        CfRule::Expression { formula, style } => {
172            crate::calc::evaluate(wb, *sheet, *cell, &relative(formula, *first, *cell)).to_bool().unwrap_or(false).then_some(style.as_ref())
173        }
174        CfRule::ContainsText { text: needle, style } => {
175            (!value.is_error() && text.to_lowercase().contains(&needle.to_lowercase())).then_some(style.as_ref())
176        }
177        CfRule::NotContainsText { text: needle, style } => {
178            (value.is_error() || !text.to_lowercase().contains(&needle.to_lowercase())).then_some(style.as_ref())
179        }
180        CfRule::BeginsWith { text: needle, style } => {
181            (!value.is_error() && text.to_lowercase().starts_with(&needle.to_lowercase())).then_some(style.as_ref())
182        }
183        CfRule::EndsWith { text: needle, style } => {
184            (!value.is_error() && text.to_lowercase().ends_with(&needle.to_lowercase())).then_some(style.as_ref())
185        }
186        CfRule::Blanks { style } => (value.is_empty() || text.trim().is_empty()).then_some(style.as_ref()),
187        CfRule::NoBlanks { style } => (!value.is_empty() && !text.trim().is_empty()).then_some(style.as_ref()),
188        CfRule::Errors { style } => value.is_error().then_some(style.as_ref()),
189        CfRule::NoErrors { style } => (!value.is_error()).then_some(style.as_ref()),
190        CfRule::Duplicate { style } => {
191            (!value.is_empty() && stats.counts.get(&text.to_lowercase()).copied().unwrap_or(0) > 1).then_some(style.as_ref())
192        }
193        CfRule::Unique { style } => {
194            (!value.is_empty() && stats.counts.get(&text.to_lowercase()).copied().unwrap_or(0) == 1).then_some(style.as_ref())
195        }
196        _ => None,
197    }
198}
199
200fn time_period_matches(wb: &Workbook, period: &str, serial: f64) -> bool {
201    let today = crate::base::date::serial_in_system(wb.date_system, crate::calc::now_serial()).floor();
202    let day = serial.floor();
203    let date = |serial| crate::base::date::datetime_from_serial(wb.date_system, serial);
204    let week_start = today - date(today).map(|value| value.weekday as f64).unwrap_or(0.0);
205    let (year, month) = date(today).map(|value| (value.year, value.month)).unwrap_or((2026, 1));
206    let year_month = date(day).map(|value| (value.year, value.month));
207    let month_matches = |offset: i32| {
208        let shifted_month = month as i32 + offset;
209        let shifted_year = year + (shifted_month - 1).div_euclid(12);
210        let shifted_month = (shifted_month - 1).rem_euclid(12) + 1;
211        year_month == Some((shifted_year, shifted_month as u32))
212    };
213    match period {
214        "yesterday" => day == today - 1.0,
215        "today" => day == today,
216        "tomorrow" => day == today + 1.0,
217        "last7Days" => day > today - 7.0 && day <= today,
218        "lastWeek" => day >= week_start - 7.0 && day < week_start,
219        "thisWeek" => day >= week_start && day < week_start + 7.0,
220        "nextWeek" => day >= week_start + 7.0 && day < week_start + 14.0,
221        "lastMonth" => month_matches(-1),
222        "thisMonth" => month_matches(0),
223        "nextMonth" => month_matches(1),
224        _ => false,
225    }
226}
227
228impl CfCache {
229    pub fn new() -> Self {
230        Self::default()
231    }
232
233    /// Conditional look of `c`, or `None` when no rule applies.
234    pub fn look(&mut self, wb: &Workbook, sheet: usize, c: CellRef) -> Option<CfLook> {
235        let sh = wb.sheet(sheet)?;
236        if sh.cond_formats.is_empty() {
237            return None;
238        }
239        let mut out = CfLook::default();
240        let mut any = false;
241        let mut rules: Vec<(usize, &CondFormat)> =
242            sh.cond_formats.iter().enumerate().filter(|(_, cf)| cf.ranges.iter().any(|r| r.contains(c))).collect();
243        rules.sort_by_key(|(_, cf)| cf.priority);
244        let v = sh.value(c);
245        let no_stats = RuleStats::default();
246        for (idx, cf) in rules {
247            let first = RangeRef::top_left(&cf.ranges).unwrap_or(c);
248            let st: &RuleStats = match stats_need(&cf.rule) {
249                Some(need) => &*self.stats.entry(idx).or_insert_with(|| RuleStats::build(sh, &cf.ranges, need)),
250                None => &no_stats,
251            };
252            let text = v.display();
253            let context = RuleEval { wb, sheet, cell: c, first, value: &v, text: &text, stats: st };
254            let matched_style = simple_rule_style(&context, &cf.rule).or_else(|| match &cf.rule {
255                CfRule::Top10 { bottom, percent, rank, style } => match v.as_f64() {
256                    Some(n) if !st.sorted.is_empty() => {
257                        let k = if *percent { ((st.sorted.len() as f64) * (*rank as f64) / 100.0).floor().max(1.0) as usize } else { *rank as usize };
258                        let k = k.clamp(1, st.sorted.len());
259                        let ok = if *bottom {
260                            n <= st.sorted.get(k - 1).copied().unwrap_or(n)
261                        } else {
262                            n >= st.sorted.get(st.sorted.len() - k).copied().unwrap_or(n)
263                        };
264                        ok.then_some(style.as_ref())
265                    }
266                    _ => None,
267                },
268                CfRule::AboveAverage { below, equal, std_dev, style } => match v.as_f64() {
269                    Some(n) => {
270                        let thr = st.avg + if *below { -1.0 } else { 1.0 } * st.std * *std_dev as f64;
271                        let ok = if *below { n < thr || (*equal && n == thr) } else { n > thr || (*equal && n == thr) };
272                        ok.then_some(style.as_ref())
273                    }
274                    None => None,
275                },
276                CfRule::TimePeriod { period, style } => match v.as_f64() {
277                    Some(n) if time_period_matches(wb, period, n) => Some(style.as_ref()),
278                    _ => None,
279                },
280                CfRule::ColorScale { stops } => {
281                    if let Some(n) = v.as_f64() {
282                        let pts: Vec<(f64, [u8; 3])> = stops
283                            .iter()
284                            .map(|(k, col)| (threshold(wb, sheet, c, st, k), col.resolve(&wb.theme).unwrap_or([255, 255, 255])))
285                            .collect();
286                        let rgb = match pts.as_slice() {
287                            [(a, ca), (b, cb)] => lerp(*ca, *cb, if b > a { (n - a) / (b - a) } else { 0.0 }),
288                            [(a, ca), (m, cm), (b, cb)] => {
289                                if n <= *m {
290                                    lerp(*ca, *cm, if m > a { (n - a) / (m - a) } else { 0.0 })
291                                } else {
292                                    lerp(*cm, *cb, if b > m { (n - m) / (b - m) } else { 1.0 })
293                                }
294                            }
295                            _ => [255, 255, 255],
296                        };
297                        if out.scale_fill.is_none() {
298                            out.scale_fill = Some(rgb);
299                            any = true;
300                        }
301                    }
302                    None
303                }
304                CfRule::DataBar { min, max, color, gradient, show_value } => {
305                    if let Some(n) = v.as_f64() {
306                        let lo = threshold(wb, sheet, c, st, min);
307                        let hi = threshold(wb, sheet, c, st, max);
308                        let lo = if lo > 0.0 { 0.0 } else { lo };
309                        let frac = if hi > lo { ((n - lo) / (hi - lo)).clamp(0.0, 1.0) } else { 1.0 };
310                        if out.bar.is_none() {
311                            out.bar = Some((
312                                (frac as f32) * 0.9 + 0.1 * (frac > 0.0) as u8 as f32,
313                                color.resolve(&wb.theme).unwrap_or([0x63, 0x8E, 0xC6]),
314                                *gradient,
315                            ));
316                            out.hide_value |= !show_value;
317                            any = true;
318                        }
319                    }
320                    None
321                }
322                CfRule::IconSet { set, thresholds, reverse, show_value } => {
323                    if let Some(n) = v.as_f64() {
324                        let k = icon_count(set);
325                        // One threshold per icon, the first being the lower bound (as in the file);
326                        // also accept the lower bound left out, else Excel's defaults.
327                        let defaults;
328                        let lower = match thresholds.len() {
329                            n if n == k => thresholds.get(1..).unwrap_or_default(),
330                            n if n + 1 == k => thresholds.as_slice(),
331                            _ => {
332                                defaults = default_icon_thresholds(set);
333                                defaults.get(1..).unwrap_or_default()
334                            }
335                        };
336                        let cuts: Vec<f64> = lower.iter().map(|t| threshold(wb, sheet, c, st, t)).collect();
337                        let mut idx = cuts.iter().filter(|cut| n >= **cut).count();
338                        if *reverse {
339                            idx = k - 1 - idx.min(k - 1);
340                        }
341                        if out.icon.is_none() {
342                            out.icon = Some((set.clone(), idx.min(k - 1)));
343                            out.hide_value |= !show_value;
344                            any = true;
345                        }
346                    }
347                    None
348                }
349                _ => None,
350            });
351            if let Some(st) = matched_style {
352                any = true;
353                // Every matching rule applies; where two set the same part, the rule with the
354                // higher priority (seen first) wins.
355                match &mut out.style {
356                    None => out.style = Some(st.clone()),
357                    Some(prev) => layer_under(prev, st),
358                }
359                if cf.stop_if_true {
360                    break;
361                }
362            }
363        }
364        any.then_some(out)
365    }
366}
367
368/// Adds the parts `lower` sets that `style` leaves unset (differential styles: default parts
369/// are unset).
370fn layer_under(style: &mut Style, lower: &Style) {
371    if style.fill.pattern == PatternType::None {
372        style.fill = lower.fill;
373    }
374    if style.font.color == Color::Auto {
375        style.font.color = lower.font.color;
376    }
377    style.font.bold |= lower.font.bold;
378    style.font.italic |= lower.font.italic;
379    style.font.strike |= lower.font.strike;
380    if style.font.underline == Underline::None {
381        style.font.underline = lower.font.underline;
382    }
383    let b = &mut style.borders;
384    for (dst, src) in [
385        (&mut b.left, lower.borders.left),
386        (&mut b.right, lower.borders.right),
387        (&mut b.top, lower.borders.top),
388        (&mut b.bottom, lower.borders.bottom),
389        (&mut b.diag_down, lower.borders.diag_down),
390        (&mut b.diag_up, lower.borders.diag_up),
391    ] {
392        if dst.is_none() {
393            *dst = src;
394        }
395    }
396    if style.num_fmt.as_str() == "General" {
397        style.num_fmt = lower.num_fmt.clone();
398    }
399}
400
401/// Shifts a rule formula written for the top-left cell of the range to `c`.
402fn relative(f: &str, first: CellRef, c: CellRef) -> String {
403    if first == c {
404        return f.to_string();
405    }
406    match crate::formula::parse(f.trim_start_matches('=')) {
407        Ok(e) => crate::formula::print(&crate::formula::adjust::shift_relative(e, c.row as i64 - first.row as i64, c.col as i64 - first.col as i64)),
408        Err(_) => f.to_string(),
409    }
410}
411
412#[cfg(test)]
413mod tests {
414    use serde_json::json;
415
416    use super::*;
417
418    #[test]
419    fn every_matching_rule_applies() {
420        let mut s = crate::engine::Session::new();
421        s.new_workbook();
422        s.execute("range.setValues", json!({"range": "A1", "values": [[200], [400], [510]]})).unwrap();
423        let rule = |op: &str, value: &str, preset: &str| json!({"range": "A1:A3", "rule": {"type": "cellIs", "operator": op, "value": value, "preset": preset}});
424        s.execute("home.conditionalFormat", rule("greaterThan", "300", "lightRedFill")).unwrap();
425        s.execute("home.conditionalFormat", rule("greaterThan", "500", "redBorder")).unwrap();
426        let d = s.doc().unwrap();
427        let mut cache = CfCache::new();
428        let mut look = |a: &str| cache.look(&d.wb, 0, CellRef::parse(a).unwrap()).and_then(|l| l.style);
429        assert!(look("A1").is_none());
430        let only_fill = look("A2").unwrap();
431        assert_eq!(only_fill.fill.pattern, PatternType::Solid);
432        assert!(only_fill.borders.top.is_none());
433        let both = look("A3").unwrap();
434        assert_eq!(both.fill, only_fill.fill);
435        assert!(!both.borders.top.is_none());
436    }
437
438    #[test]
439    fn cell_value_rules_on_blanks_and_errors() {
440        let mut s = crate::engine::Session::new();
441        s.new_workbook();
442        s.execute("range.setValues", json!({"range": "A1", "values": [[3, "=NA()"], [null, 7], ["=NA()", null], [" ", null], [7, null]]})).unwrap();
443        let rule = |range: &str, r: serde_json::Value| json!({"range": range, "rule": r});
444        s.execute("home.conditionalFormat", rule("A1:A5", json!({"type": "cellIs", "operator": "less", "value": "5", "preset": "lightRedFill"})))
445            .unwrap();
446        s.execute("home.conditionalFormat", rule("A1:A5", json!({"type": "noBlanks", "preset": "redBorder"}))).unwrap();
447        s.execute("home.conditionalFormat", rule("B1:B2", json!({"type": "cellIs", "operator": "greater", "value": "5", "preset": "lightRedFill"})))
448            .unwrap();
449        let d = s.doc().unwrap();
450        let mut cache = CfCache::new();
451        let mut look = |a: &str| cache.look(&d.wb, 0, CellRef::parse(a).unwrap()).and_then(|l| l.style);
452        let fill = |st: Option<Style>| st.is_some_and(|st| st.fill.pattern == PatternType::Solid);
453        let border = |st: Option<Style>| st.is_some_and(|st| !st.borders.top.is_none());
454        // Less than 5: 3 and the blank cell (as 0), not #N/A.
455        assert_eq!([look("A1"), look("A2"), look("A3"), look("A5")].map(fill), [true, true, false, false]);
456        // Greater than 5: 7, not #N/A.
457        assert_eq!([look("B1"), look("B2")].map(fill), [false, true]);
458        // No blanks: neither the empty cell nor the one holding a space.
459        assert_eq!([look("A1"), look("A2"), look("A4")].map(border), [true, false, false]);
460    }
461
462    /// The statistics as every rule used to get them: one pass collecting both parts.
463    fn all_stats_before(sh: &Sheet, ranges: &[RangeRef]) -> RuleStats {
464        let mut nums = Vec::new();
465        let mut counts = HashMap::new();
466        for r in ranges {
467            let Some(r) = sh.used_range().and_then(|u| r.intersection(&u)) else { continue };
468            for c in r.iter() {
469                let v = sh.value(c);
470                if let Value::Number(n) = v {
471                    nums.push(n);
472                }
473                if !v.is_empty() {
474                    *counts.entry(v.display().to_lowercase()).or_insert(0) += 1;
475                }
476            }
477        }
478        nums.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
479        let n = nums.len().max(1) as f64;
480        let avg = nums.iter().sum::<f64>() / n;
481        let std = (nums.iter().map(|x| (x - avg).powi(2)).sum::<f64>() / n).sqrt();
482        RuleStats { sorted: nums, avg, std, counts }
483    }
484
485    #[test]
486    fn needed_statistics_equal_the_full_ones() {
487        let mut s = crate::engine::Session::new();
488        s.new_workbook();
489        s.execute("range.setValues", json!({"range": "A1", "values": [[3, "x"], [7, "X"], [3, null], ["=NA()", 2.5], [" ", 9], [-1, "y"]]})).unwrap();
490        let d = s.doc().unwrap();
491        let sh = d.wb.sheet(0).unwrap();
492        for ranges in ["A1:B6", "A1:A3", "B2:B9", "D1:E4", "A1:A2"].map(|r| vec![RangeRef::parse(r).unwrap()]) {
493            let full = all_stats_before(sh, &ranges);
494            let numbers = RuleStats::build(sh, &ranges, StatsNeed::Numbers);
495            assert_eq!((numbers.sorted, numbers.avg.to_bits(), numbers.std.to_bits()), (full.sorted, full.avg.to_bits(), full.std.to_bits()));
496            assert_eq!(RuleStats::build(sh, &ranges, StatsNeed::Counts).counts, full.counts);
497        }
498    }
499
500    #[test]
501    fn rules_that_read_statistics_still_match() {
502        let mut s = crate::engine::Session::new();
503        s.new_workbook();
504        s.execute("range.setValues", json!({"range": "A1", "values": [[1, "a"], [2, "A"], [3, "b"], [10, "c"], [2, null]]})).unwrap();
505        let rule = |range: &str, r: serde_json::Value| json!({"range": range, "rule": r});
506        s.execute("home.conditionalFormat", rule("B1:B5", json!({"type": "duplicate", "preset": "lightRedFill"}))).unwrap();
507        s.execute("home.conditionalFormat", rule("A1:A5", json!({"type": "top10", "rank": 1, "preset": "redBorder"}))).unwrap();
508        s.execute("home.conditionalFormat", rule("A1:A5", json!({"type": "colorScale"}))).unwrap();
509        let d = s.doc().unwrap();
510        let mut cache = CfCache::new();
511        let mut look = |a: &str| cache.look(&d.wb, 0, CellRef::parse(a).unwrap());
512        let fill = |l: Option<CfLook>| l.and_then(|l| l.style).is_some_and(|st| st.fill.pattern == PatternType::Solid);
513        // Duplicates are case-insensitive texts: "a" and "A".
514        assert_eq!(["B1", "B2", "B3", "B4", "B5"].map(|a| fill(look(a))), [true, true, false, false, false]);
515        // The top value gets the border, the scale runs from the minimum to the maximum.
516        assert!(look("A4").and_then(|l| l.style).is_some_and(|st| !st.borders.top.is_none()));
517        assert!(look("A1").and_then(|l| l.style).is_none_or(|st| st.borders.top.is_none()));
518        assert_eq!(look("A1").and_then(|l| l.scale_fill), Some([0xF8, 0x69, 0x6B]));
519        assert_eq!(look("A4").and_then(|l| l.scale_fill), Some([0x63, 0xBE, 0x7B]));
520    }
521
522    /// Looking cells up does work in proportion to the cells asked for, not the rule's range: the
523    /// statistics are only gathered for rules that read them, and once per rule.
524    #[test]
525    fn lookups_do_not_scan_the_range_for_rules_without_statistics() {
526        let mut s = crate::engine::Session::new();
527        s.new_workbook();
528        let rows: Vec<serde_json::Value> = (1..=10_000).map(|i| json!([i, i % 7])).collect();
529        s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap();
530        s.execute(
531            "home.conditionalFormat",
532            json!({"range": "A1:B10000", "rule": {"type": "expression", "formula": "=MOD(ROW(),2)=0", "preset": "lightRedFill"}}),
533        )
534        .unwrap();
535        s.execute(
536            "home.conditionalFormat",
537            json!({"range": "A1:B10000", "rule": {"type": "cellIs", "operator": "greater", "value": "5", "preset": "redBorder"}}),
538        )
539        .unwrap();
540        s.execute("home.conditionalFormat", json!({"range": "A1:B10000", "rule": {"type": "noBlanks", "preset": "redBorder"}})).unwrap();
541        let d = s.doc().unwrap();
542        let mut cache = CfCache::new();
543        SCANNED.with(|n| n.set(0));
544        for r in 1..=50 {
545            for col in ["A", "B"] {
546                cache.look(&d.wb, 0, CellRef::parse(&format!("{col}{r}")).unwrap());
547            }
548        }
549        assert_eq!(SCANNED.with(|n| n.get()), 0);
550        // A rule that does read them scans its range once, however many cells are looked up.
551        s.execute("home.conditionalFormat", json!({"range": "A1:A10000", "rule": {"type": "top10", "preset": "redBorder"}})).unwrap();
552        let d = s.doc().unwrap();
553        let mut cache = CfCache::new();
554        SCANNED.with(|n| n.set(0));
555        for r in 1..=50 {
556            cache.look(&d.wb, 0, CellRef::parse(&format!("A{r}")).unwrap());
557        }
558        assert_eq!(SCANNED.with(|n| n.get()), 10_000);
559    }
560
561    #[test]
562    fn cell_value_operator_names() {
563        let mut s = crate::engine::Session::new();
564        s.new_workbook();
565        s.execute("range.setValues", json!({"range": "A1", "values": [[3], [7]]})).unwrap();
566        let rule = |op: &str| json!({"range": "A1:A2", "rule": {"type": "cellIs", "operator": op, "value": "5", "preset": "lightRedFill"}});
567        s.execute("home.conditionalFormat", rule("lessThan")).unwrap();
568        assert!(s.execute("home.conditionalFormat", rule("smaller")).is_err());
569        let d = s.doc().unwrap();
570        let mut cache = CfCache::new();
571        let mut look = |a: &str| cache.look(&d.wb, 0, CellRef::parse(a).unwrap()).and_then(|l| l.style).is_some();
572        assert_eq!([look("A1"), look("A2")], [true, false]);
573    }
574
575    #[test]
576    fn text_rules_skip_error_values() {
577        let mut s = crate::engine::Session::new();
578        s.new_workbook();
579        let values = json!([["=NA()", "=NA()", "=1/0", "=NA()"], ["abc", "#x", "x!", "abc"], ["xyz", "y", "y", "xyz"]]);
580        s.execute("range.setValues", json!({"range": "A1", "values": values})).unwrap();
581        let rule = |range: &str, kind: &str, text: &str| json!({"range": range, "rule": {"type": kind, "text": text, "preset": "lightRedFill"}});
582        s.execute("home.conditionalFormat", rule("A1:A3", "containsText", "a")).unwrap();
583        s.execute("home.conditionalFormat", rule("B1:B3", "beginsWith", "#")).unwrap();
584        s.execute("home.conditionalFormat", rule("C1:C3", "endsWith", "!")).unwrap();
585        s.execute("home.conditionalFormat", rule("D1:D3", "notContainsText", "a")).unwrap();
586        let d = s.doc().unwrap();
587        let mut cache = CfCache::new();
588        let mut look = |a: &str| cache.look(&d.wb, 0, CellRef::parse(a).unwrap()).and_then(|l| l.style).is_some();
589        // Contains "a": abc, never #N/A although its text holds an "a".
590        assert_eq!([look("A1"), look("A2"), look("A3")], [false, true, false]);
591        // Begins with "#": #x, never #N/A.
592        assert_eq!([look("B1"), look("B2"), look("B3")], [false, true, false]);
593        // Ends with "!": x!, never #DIV/0!.
594        assert_eq!([look("C1"), look("C2"), look("C3")], [false, true, false]);
595        // Does not contain "a": #N/A and xyz, not abc.
596        assert_eq!([look("D1"), look("D2"), look("D3")], [true, false, true]);
597    }
598}