1use 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#[derive(Clone, Debug, Default, PartialEq)]
11pub struct CfLook {
12 pub style: Option<Style>,
14 pub scale_fill: Option<[u8; 3]>,
16 pub bar: Option<(f32, [u8; 3], bool)>,
18 pub icon: Option<(String, usize)>,
20 pub hide_value: bool,
21}
22
23#[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#[derive(Clone, Copy, PartialEq, Eq, Debug)]
40enum StatsNeed {
41 Numbers,
43 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 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 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 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 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
368fn 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
401fn 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 assert_eq!([look("A1"), look("A2"), look("A3"), look("A5")].map(fill), [true, true, false, false]);
456 assert_eq!([look("B1"), look("B2")].map(fill), [false, true]);
458 assert_eq!([look("A1"), look("A2"), look("A4")].map(border), [true, false, false]);
460 }
461
462 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 assert_eq!(["B1", "B2", "B3", "B4", "B5"].map(|a| fill(look(a))), [true, true, false, false, false]);
515 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 #[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 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 assert_eq!([look("A1"), look("A2"), look("A3")], [false, true, false]);
591 assert_eq!([look("B1"), look("B2"), look("B3")], [false, true, false]);
593 assert_eq!([look("C1"), look("C2"), look("C3")], [false, true, false]);
595 assert_eq!([look("D1"), look("D2"), look("D3")], [true, false, true]);
597 }
598}