gridcraft/crates/engine/src/fill.rs

656 lines
28 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Fill: copy, AutoFill series (numbers, dates, weekdays, months, text with numbers, custom
//! lists), Series dialog and Flash Fill.
use std::sync::Arc;
use gridcraft_core::date::{MONTHS, WEEKDAYS, datetime_from_serial, days_in_month, serial_from_ymd};
use gridcraft_core::{CellRef, RangeRef, Value};
use gridcraft_model::{Cell, Formula};
use gridcraft_numfmt::{FormatKind, NumberFormat};
use crate::Result;
use crate::cmd::Ctx;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FillMode {
Series,
/// The Ctrl+drag fill: the opposite of a plain drag. A lone number fills a
/// series stepping by 1; anything a plain drag would extend is copied.
Alternate,
Copy,
Formats,
ValuesOnly,
}
/// Built-in custom lists.
fn lists() -> Vec<Vec<String>> {
let short_days: Vec<String> = WEEKDAYS.iter().map(|d| d.get(..3).unwrap_or(d).to_string()).collect();
let short_months: Vec<String> = MONTHS.iter().map(|m| m.get(..3).unwrap_or(m).to_string()).collect();
vec![
short_days,
WEEKDAYS.iter().map(|s| s.to_string()).collect(),
short_months,
MONTHS.iter().map(|s| s.to_string()).collect(),
vec!["Q1".into(), "Q2".into(), "Q3".into(), "Q4".into()],
]
}
/// Fills `target` (which contains `src` at one edge) from the pattern in `src`.
pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: FillMode) -> Result<()> {
let custom = cx.wb.custom_lists.clone();
let Some(sh) = cx.wb.sheet(sheet) else { return Ok(()) };
// Direction: down/up if target extends rows, else right/left.
let vertical = target.height() != src.height() || target.width() == src.width();
let forward = if vertical {
target.end.row > src.end.row || target.start.row == src.start.row
} else {
target.end.col > src.end.col || target.start.col == src.start.col
};
let lanes: Vec<u32> = if vertical { (src.start.col..=src.end.col).collect() } else { (src.start.row..=src.end.row).collect() };
let src_len = if vertical { src.height() } else { src.width() };
let mut writes: Vec<(CellRef, Option<Cell>)> = Vec::new();
let mut pictures = Vec::new();
for lane in lanes {
let src_cells: Vec<(CellRef, Option<Cell>)> = (0..src_len)
.map(|i| {
let c = if vertical { CellRef::new(src.start.row + i, lane) } else { CellRef::new(lane, src.start.col + i) };
(c, sh.cell(c).cloned())
})
.collect();
let targets: Vec<CellRef> = if vertical {
let rows: Vec<u32> =
if forward { (src.end.row + 1..=target.end.row).collect() } else { (target.start.row..src.start.row).rev().collect() };
rows.into_iter().map(|r| CellRef::new(r, lane)).collect()
} else {
let cols: Vec<u32> =
if forward { (src.end.col + 1..=target.end.col).collect() } else { (target.start.col..src.start.col).rev().collect() };
cols.into_iter().map(|c| CellRef::new(lane, c)).collect()
};
let fmt = |c: &(CellRef, Option<Cell>)| cx.wb.styles.get(c.1.as_ref().map(|c| c.style).unwrap_or_default()).num_fmt.as_str();
let pattern = if matches!(mode, FillMode::Series | FillMode::Alternate) {
let detected = Pattern::detect(&src_cells, &custom, cx.wb.date_system, src_cells.first().map(fmt).unwrap_or_default());
if mode == FillMode::Alternate { detected.alternate(&src_cells) } else { detected }
} else {
Pattern::Copy
};
// A source with no common pattern (`Product 1`, `On backorder`): each cell continues its
// own series, one step per repeat of the source.
let per_cell: Vec<Pattern> = if mode == FillMode::Series && matches!(pattern, Pattern::Copy) && src_cells.len() > 1 {
src_cells.iter().map(|c| Pattern::detect(std::slice::from_ref(c), &custom, cx.wb.date_system, fmt(c))).collect()
} else {
Vec::new()
};
for (k, t) in targets.iter().enumerate() {
let step = k as i64 + 1;
let idx = k % src_cells.len().max(1);
let src_i = if forward { idx } else { src_cells.len() - 1 - idx };
let (sc, scell) = src_cells.get(src_i).cloned().unwrap_or((src.start, None));
let mut cell = scell.clone().unwrap_or_default();
match mode {
FillMode::Formats => {
let mut existing = sh.cell(*t).cloned().unwrap_or_default();
existing.style = cell.style;
writes.push((*t, Some(existing)));
continue;
}
FillMode::ValuesOnly | FillMode::Series | FillMode::Alternate | FillMode::Copy => {
let style = if mode == FillMode::ValuesOnly { sh.cell(*t).map(|c| c.style).unwrap_or_default() } else { cell.style };
if let Some(f) = &cell.formula {
if let Some(e) = f.expr() {
let shifted = gridcraft_formula::adjust::shift_relative(e, t.row as i64 - sc.row as i64, t.col as i64 - sc.col as i64);
cell.formula = Some(Arc::new(Formula::from_expr(shifted)));
cell.value = Value::Empty;
}
} else if let Some(v) = match per_cell.get(src_i) {
Some(p) => p.value(k as i64 / src_cells.len() as i64 + 1, forward, 1),
None => pattern.value(step, forward, src_cells.len()),
} {
cell.value = v;
}
cell.style = style;
}
}
if let Some(picture) = sh.cell_pictures.get(&sc) {
pictures.push((*t, picture.clone()));
}
writes.push((*t, if cell.is_blank() { None } else { Some(cell) }));
}
}
let sh = cx.sheet_mut(sheet)?;
for (c, cell) in &writes {
match cell {
Some(x) if mode == FillMode::Formats => sh.cells.set(*c, x.clone()),
Some(x) => sh.set_cell(*c, x.clone()),
None => {
sh.remove_cell(*c);
}
}
}
sh.cell_pictures.extend(pictures);
for (c, _) in writes {
cx.touch(sheet, c);
}
Ok(())
}
#[derive(Clone, Debug)]
enum Pattern {
Copy,
/// Linear numeric series: last value and step.
Linear {
last: f64,
first: f64,
step: f64,
},
/// Text with a trailing (or leading) number: `Item 1`, `Q1`.
TextNum {
prefix: String,
suffix: String,
last: i64,
first: i64,
step: i64,
width: usize,
},
/// A position in a list (days, months, custom).
List {
list: Vec<String>,
last: usize,
first: usize,
step: i64,
upper: bool,
lower: bool,
},
/// Dates stepping by months (same day each month) or years.
Months {
last: f64,
first: f64,
months: i64,
},
}
impl Pattern {
fn detect(cells: &[(CellRef, Option<Cell>)], custom: &[Vec<String>], sys: gridcraft_core::DateSystem, fmt: &str) -> Pattern {
let vals: Vec<Value> = cells.iter().map(|(_, c)| c.as_ref().map(|c| c.value.clone()).unwrap_or_default()).collect();
if cells.iter().any(|(_, c)| c.as_ref().is_some_and(|c| c.formula.is_some())) {
return Pattern::Copy;
}
// Numbers.
if !vals.is_empty() && vals.iter().all(|v| v.is_number()) {
let nums: Vec<f64> = vals.iter().filter_map(Value::as_f64).collect();
let first = nums.first().copied().unwrap_or(0.0);
let last = nums.last().copied().unwrap_or(0.0);
let is_date = fmt.contains('d') || fmt.contains('y') || (fmt.contains('m') && !fmt.contains('h'));
if nums.len() == 1 {
// A single number copies; a single date increments by a day, a single time by an hour.
return if is_date {
Pattern::Linear { last, first, step: 1.0 }
} else if NumberFormat::parse(fmt).kind() == FormatKind::Time {
Pattern::Linear { last, first, step: 1.0 / 24.0 }
} else {
Pattern::Copy
};
}
// Dates that differ by whole months.
if is_date && nums.len() >= 2 {
let d0 = datetime_from_serial(sys, first);
let d1 = datetime_from_serial(sys, nums.get(1).copied().unwrap_or(first));
if let (Some(a), Some(b)) = (d0, d1)
&& a.day == b.day
&& (a.month != b.month || a.year != b.year)
{
let months = (b.year as i64 - a.year as i64) * 12 + b.month as i64 - a.month as i64;
return Pattern::Months { last, first, months };
}
}
// Least-squares step for 2+ values (Excel uses a linear trend).
let n = nums.len() as f64;
let mean_x = (n - 1.0) / 2.0;
let mean_y = nums.iter().sum::<f64>() / n;
let mut num = 0.0;
let mut den = 0.0;
for (i, y) in nums.iter().enumerate() {
num += (i as f64 - mean_x) * (y - mean_y);
den += (i as f64 - mean_x).powi(2);
}
let step = if den == 0.0 { 0.0 } else { num / den };
let fitted_last = mean_y + step * (n - 1.0 - mean_x);
let fitted_first = mean_y - step * mean_x;
return Pattern::Linear { last: fitted_last, first: fitted_first, step };
}
// Text.
if !vals.is_empty() && vals.iter().all(|v| v.is_text()) {
let texts: Vec<String> = vals.iter().filter_map(|v| v.as_text().map(str::to_string)).collect();
let all_lists: Vec<Vec<String>> = custom.iter().cloned().chain(lists()).collect();
for list in &all_lists {
let idx: Vec<Option<usize>> = texts.iter().map(|t| list.iter().position(|l| l.eq_ignore_ascii_case(t))).collect();
if idx.iter().all(Option::is_some) {
let idx: Vec<usize> = idx.into_iter().flatten().collect();
let first = idx.first().copied().unwrap_or(0);
let last = idx.last().copied().unwrap_or(0);
let step = if idx.len() >= 2 { idx[1] as i64 - idx[0] as i64 } else { 1 };
let t0 = texts.first().cloned().unwrap_or_default();
let upper = t0.chars().all(|c| !c.is_lowercase()) && t0.chars().any(char::is_alphabetic) && t0.len() > 1;
let lower = t0.chars().all(|c| !c.is_uppercase());
return Pattern::List { list: list.clone(), last, first, step, upper, lower };
}
}
// Text with a number at the end (or start).
let split: Vec<Option<(String, i64, String, usize)>> = texts.iter().map(|t| split_num(t)).collect();
if split.iter().all(Option::is_some) {
let parts: Vec<(String, i64, String, usize)> = split.into_iter().flatten().collect();
if parts.iter().all(|p| p.0 == parts[0].0 && p.2 == parts[0].2) {
let first = parts.first().map(|p| p.1).unwrap_or(0);
let last = parts.last().map(|p| p.1).unwrap_or(0);
let step = if parts.len() >= 2 { parts[1].1 - parts[0].1 } else { 1 };
return Pattern::TextNum { prefix: parts[0].0.clone(), suffix: parts[0].2.clone(), last, first, step, width: parts[0].3 };
}
}
}
Pattern::Copy
}
/// The pattern of a Ctrl+drag, which swaps copying and extending: a single
/// number (which a plain drag copies) counts up by 1, and every series a
/// plain drag would extend (numbers, dates, lists, `Item 1`) is copied.
fn alternate(self, cells: &[(CellRef, Option<Cell>)]) -> Pattern {
match (self, cells) {
(Pattern::Copy, [(_, Some(cell))]) if cell.formula.is_none() => match cell.value.as_f64() {
Some(n) if cell.value.is_number() => Pattern::Linear { last: n, first: n, step: 1.0 },
_ => Pattern::Copy,
},
_ => Pattern::Copy,
}
}
/// The value `k` steps beyond the source (forward) or before it (backward).
fn value(&self, k: i64, forward: bool, _len: usize) -> Option<Value> {
let sign = if forward { 1 } else { -1 };
match self {
Pattern::Copy => None,
Pattern::Linear { last, first, step } => {
let base = if forward { *last } else { *first };
Some(Value::number(round15(base + step * (k * sign) as f64)))
}
Pattern::TextNum { prefix, suffix, last, first, step, width } => {
let base = if forward { *last } else { *first };
let n = base + step * k * sign;
// `Qtr3`, `Quarter 3` wrap after 4 like `Q3`.
let quarter = suffix.is_empty()
&& (1..=4).contains(&base)
&& ["q", "qtr", "quarter"].contains(&prefix.trim_end().to_ascii_lowercase().as_str());
let n = if quarter { (n - 1).rem_euclid(4) + 1 } else { n.abs() };
Some(Value::text(format!("{prefix}{n:0width$}{}", ordinal_suffix(n, suffix), width = *width)))
}
Pattern::List { list, last, first, step, upper, lower } => {
let len = list.len() as i64;
if len == 0 {
return None;
}
let base = if forward { *last } else { *first } as i64;
let i = (base + step * k * sign).rem_euclid(len) as usize;
let s = list.get(i)?.clone();
Some(Value::text(if *upper {
s.to_uppercase()
} else if *lower && !s.chars().next().is_some_and(char::is_uppercase) {
s.to_lowercase()
} else {
s
}))
}
Pattern::Months { last, first, months } => {
let base = if forward { *last } else { *first };
let s = add_months_clamped(gridcraft_core::DateSystem::D1900, base, months.checked_mul(k * sign)?)?;
Some(Value::number(s))
}
}
}
}
/// `serial` shifted by `months` calendar months, keeping the day of month but clamping it to the
/// target month's last day (Jan 31 + 1 month = Feb 28/29). Always measured from the original date,
/// so a series does not drift: Jan 31, Feb 28, Mar 31, Apr 30.
fn add_months_clamped(sys: gridcraft_core::DateSystem, serial: f64, months: i64) -> Option<f64> {
let d = datetime_from_serial(sys, serial)?;
let total = (d.year as i64).checked_mul(12)?.checked_add(d.month as i64 - 1)?.checked_add(months)?;
let year = total.div_euclid(12);
let month = total.rem_euclid(12) + 1;
let last = days_in_month(i32::try_from(year).ok()?, month as u32);
serial_from_ymd(sys, year, month, (d.day as i64).min(last as i64))
}
fn round15(x: f64) -> f64 {
if x == 0.0 || !x.is_finite() {
return x;
}
let mag = x.abs().log10().floor() as i32;
let p = 10f64.powi(14 - mag);
if !p.is_finite() || p == 0.0 { x } else { (x * p).round() / p }
}
/// `1st Period` → `2nd Period`: an English ordinal ending is rewritten to suit the new number.
fn ordinal_suffix(n: i64, suffix: &str) -> String {
let (Some(end), Some(rest)) = (suffix.get(..2), suffix.get(2..)) else { return suffix.to_string() };
if !["st", "nd", "rd", "th"].contains(&end.to_ascii_lowercase().as_str()) || rest.starts_with(char::is_alphanumeric) {
return suffix.to_string();
}
let new = match (n % 10, n % 100) {
(_, 11..=13) => "th",
(1, _) => "st",
(2, _) => "nd",
(3, _) => "rd",
_ => "th",
};
let new = if end.chars().all(|c| c.is_ascii_uppercase()) { new.to_ascii_uppercase() } else { new.to_string() };
format!("{new}{rest}")
}
/// `Item 007` → ("Item ", 7, "", 3); `3rd` style leading numbers → ("", 3, "rd", 1).
fn split_num(t: &str) -> Option<(String, i64, String, usize)> {
let chars: Vec<char> = t.chars().collect();
let end = chars.iter().rposition(|c| c.is_ascii_digit())?;
let mut start = end;
while start > 0 && chars.get(start - 1).is_some_and(|c| c.is_ascii_digit()) {
start -= 1;
}
let digits: String = chars.get(start..=end)?.iter().collect();
let n: i64 = digits.parse().ok()?;
let prefix: String = chars.get(..start)?.iter().collect();
let suffix: String = chars.get(end + 1..)?.iter().collect();
let width = if digits.starts_with('0') && digits.len() > 1 { digits.len() } else { 1 };
Some((prefix, n, suffix, width))
}
/// Home › Fill › Series.
pub fn series(cx: &mut Ctx, sheet: usize, r: RangeRef, rows: bool, kind: &str, step: f64, stop: Option<f64>, unit: &str) -> Result<()> {
let sys = cx.wb.date_system;
let Some(sh) = cx.wb.sheet(sheet) else { return Ok(()) };
let lanes: Vec<u32> = if rows { (r.start.row..=r.end.row).collect() } else { (r.start.col..=r.end.col).collect() };
let len = if rows { r.width() } else { r.height() };
let mut writes = Vec::new();
for lane in lanes {
let first_cell = if rows { CellRef::new(lane, r.start.col) } else { CellRef::new(r.start.row, lane) };
let Some(start) = sh.value(first_cell).as_f64() else { continue };
let style = sh.style_id(first_cell);
let mut v = start;
for i in 1..len {
let prev = v;
v = match kind {
"growth" => v * step,
"date" => {
let d = datetime_from_serial(sys, v);
match (unit, d) {
("month", Some(_)) => (step as i64).checked_mul(i as i64).and_then(|m| add_months_clamped(sys, start, m)).unwrap_or(v),
("year", Some(_)) => (step as i64).checked_mul(i as i64 * 12).and_then(|m| add_months_clamped(sys, start, m)).unwrap_or(v),
("weekday", Some(_)) => {
let mut n = v;
let mut left = step.abs() as i64;
while left > 0 {
n += step.signum();
let Some(day) = datetime_from_serial(sys, n) else {
return Err(crate::EngineError::Other("The weekday series runs past the supported date range.".into()));
};
if day.weekday != 0 && day.weekday != 6 {
left -= 1;
}
}
n
}
_ => v + step,
}
}
_ => v + step,
};
// The stop value bounds the series in the direction it runs (a growth by 0.5 falls).
if let Some(s) = stop
&& ((v >= prev && v > s) || (v < prev && v < s))
{
break;
}
let c = if rows { CellRef::new(lane, r.start.col + i) } else { CellRef::new(r.start.row + i, lane) };
writes.push((c, Cell { value: Value::number(round15(v)), formula: None, style }));
}
}
let sh = cx.sheet_mut(sheet)?;
for (c, cell) in &writes {
sh.set_cell(*c, cell.clone());
}
for (c, _) in writes {
cx.touch(sheet, c);
}
Ok(())
}
/// Flash Fill: learns a transformation from the examples typed in the column of `at` (rows
/// above it with values) from the column(s) to the left, and fills the remaining rows.
/// Supported transformations: a token (word) of the source by index, concatenations of tokens
/// with literal separators, upper/lower/proper case, and fixed-position substrings.
pub fn flash_fill(cx: &mut Ctx, sheet: usize, at: CellRef) -> Result<usize> {
let Some(sh) = cx.wb.sheet(sheet) else { return Ok(0) };
if at.col == 0 {
return Ok(0);
}
let src_col = at.col - 1;
// Rows of the data region in the source column.
let mut top = at.row;
while top > 0 && !sh.value(CellRef::new(top - 1, src_col)).is_empty() {
top -= 1;
}
let mut bottom = at.row;
while bottom + 1 < gridcraft_core::MAX_ROWS && !sh.value(CellRef::new(bottom + 1, src_col)).is_empty() && bottom - top < 1_000_000 {
bottom += 1;
}
// Header row: skip a first row whose target is text and source looks like a header? Keep simple.
let mut examples = Vec::new();
let mut todo = Vec::new();
for r in top..=bottom {
let src = sh.value(CellRef::new(r, src_col)).display();
let dst = sh.value(CellRef::new(r, at.col));
if dst.is_empty() {
todo.push((r, src));
} else {
examples.push((src, dst.display()));
}
}
if examples.is_empty() {
return Ok(0);
}
let Some(program) = learn(&examples) else { return Ok(0) };
let mut n = 0;
let style = sh.style_id(CellRef::new(top, at.col));
let mut writes = Vec::new();
for (r, src) in todo {
if let Some(out) = program.apply(&src) {
writes.push((CellRef::new(r, at.col), out));
n += 1;
}
}
let sh = cx.sheet_mut(sheet)?;
for (c, out) in &writes {
sh.set_cell(*c, Cell { value: Value::text(out.as_str()), formula: None, style });
}
for (c, _) in writes {
cx.touch(sheet, c);
}
Ok(n)
}
#[derive(Clone, Debug, PartialEq)]
enum Piece {
Lit(String),
/// Token index (negative from the end) with a case transform.
Tok(i32, Case),
/// First character of a token.
Initial(i32, Case),
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Case {
Same,
Upper,
Lower,
Proper,
}
#[derive(Clone, Debug)]
struct Program(Vec<Piece>);
fn tokens(s: &str) -> Vec<String> {
s.split(|c: char| !c.is_alphanumeric() && c != '\'').filter(|t| !t.is_empty()).map(str::to_string).collect()
}
fn apply_case(s: &str, c: Case) -> String {
match c {
Case::Same => s.to_string(),
Case::Upper => s.to_uppercase(),
Case::Lower => s.to_lowercase(),
Case::Proper => {
let mut out = String::new();
for (i, ch) in s.chars().enumerate() {
if i == 0 {
out.extend(ch.to_uppercase());
} else {
out.extend(ch.to_lowercase());
}
}
out
}
}
}
impl Program {
fn apply(&self, src: &str) -> Option<String> {
let toks = tokens(src);
let get = |i: i32| -> Option<&String> {
if i >= 0 { toks.get(i as usize) } else { toks.len().checked_sub(i.unsigned_abs() as usize).and_then(|j| toks.get(j)) }
};
let mut out = String::new();
for p in &self.0 {
match p {
Piece::Lit(l) => out.push_str(l),
Piece::Tok(i, c) => out.push_str(&apply_case(get(*i)?, *c)),
Piece::Initial(i, c) => out.push_str(&apply_case(&get(*i)?.chars().next()?.to_string(), *c)),
}
}
Some(out)
}
}
/// Finds a program consistent with every example.
fn learn(examples: &[(String, String)]) -> Option<Program> {
let (src, dst) = examples.first()?;
let candidates = synthesize(src, dst);
candidates.into_iter().find(|p| examples.iter().all(|(s, d)| p.apply(s).as_deref() == Some(d.as_str())))
}
/// Candidate programs for one example, by greedy segmentation of the output into token matches
/// and literals (tries several index conventions).
fn synthesize(src: &str, dst: &str) -> Vec<Program> {
let toks = tokens(src);
let n = toks.len() as i32;
let mut out = Vec::new();
for prefer_neg in [false, true] {
let mut pieces = Vec::new();
let mut rest = dst;
let mut guard = 0;
while !rest.is_empty() && guard < 64 {
guard += 1;
// Longest token (with any case) that prefixes `rest`.
let mut best: Option<(usize, Piece)> = None;
for (i, t) in toks.iter().enumerate() {
let idx = if prefer_neg { i as i32 - n } else { i as i32 };
for c in [Case::Same, Case::Upper, Case::Lower, Case::Proper] {
let cand = apply_case(t, c);
if !cand.is_empty() && rest.starts_with(&cand) && best.as_ref().is_none_or(|(l, _)| cand.len() > *l) {
best = Some((cand.len(), Piece::Tok(idx, c)));
}
}
}
if best.as_ref().is_none_or(|(l, _)| *l <= 1) {
// Initial letters (e.g. "J. Smith").
for (i, t) in toks.iter().enumerate() {
let idx = if prefer_neg { i as i32 - n } else { i as i32 };
if let Some(ch) = t.chars().next() {
for c in [Case::Same, Case::Upper, Case::Lower] {
let cand = apply_case(&ch.to_string(), c);
if rest.starts_with(&cand) && best.is_none() {
best = Some((cand.len(), Piece::Initial(idx, c)));
}
}
}
}
}
match best {
Some((len, piece)) => {
pieces.push(piece);
rest = rest.get(len..).unwrap_or("");
}
None => {
let ch = rest.chars().next().unwrap_or(' ');
match pieces.last_mut() {
Some(Piece::Lit(l)) => l.push(ch),
_ => pieces.push(Piece::Lit(ch.to_string())),
}
rest = rest.get(ch.len_utf8()..).unwrap_or("");
}
}
}
out.push(Program(pieces));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn flash_programs() {
let p = learn(&[("John Smith".into(), "Smith, John".into())]).unwrap();
assert_eq!(p.apply("Ada Lovelace").as_deref(), Some("Lovelace, Ada"));
let p = learn(&[("jane doe".into(), "JD".into())]).unwrap();
assert_eq!(p.apply("alan turing").as_deref(), Some("AT"));
let p = learn(&[("alice@example.com".into(), "alice".into())]).unwrap();
assert_eq!(p.apply("bob@example.com").as_deref(), Some("bob"));
}
#[test]
fn split_numbers() {
assert_eq!(split_num("Item 7"), Some(("Item ".into(), 7, "".into(), 1)));
assert_eq!(split_num("Q003x"), Some(("Q".into(), 3, "x".into(), 3)));
assert_eq!(split_num("abc"), None);
}
#[test]
fn times_quarters_ordinals_and_mixed_sources() {
let mut s = crate::Session::new();
s.new_workbook();
let mut fill = |values: serde_json::Value, source: &str, target: &str| {
s.execute("range.setValues", serde_json::json!({"range": source.get(..2).unwrap(), "values": values})).unwrap();
s.execute("edit.autoFill", serde_json::json!({"source": source, "target": target})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
let r = RangeRef::parse(target).unwrap();
r.iter().map(|c| crate::display::cell_text(&s.doc().unwrap().wb, sh, c)).collect::<Vec<_>>()
};
assert_eq!(fill(serde_json::json!([["9:00"]]), "A1", "A1:A3"), ["9:00", "10:00", "11:00"]);
assert_eq!(fill(serde_json::json!([["Qtr3"]]), "B1", "B1:B3"), ["Qtr3", "Qtr4", "Qtr1"]);
assert_eq!(fill(serde_json::json!([["Quarter 4"]]), "C1", "C1:C2"), ["Quarter 4", "Quarter 1"]);
assert_eq!(fill(serde_json::json!([["1st Period"]]), "D1", "D1:D4"), ["1st Period", "2nd Period", "3rd Period", "4th Period"]);
assert_eq!(
fill(serde_json::json!([["Product 1"], ["On backorder"]]), "E1:E2", "E1:E6"),
["Product 1", "On backorder", "Product 2", "On backorder", "Product 3", "On backorder"]
);
}
#[test]
fn falling_growth_series_stops_at_its_stop_value() {
let mut s = crate::Session::new();
s.new_workbook();
s.execute("cell.set", serde_json::json!({"cell": "A1", "input": "5"})).unwrap();
s.execute("edit.fillSeries", serde_json::json!({"range": "A1:A6", "type": "growth", "step": 0.5, "stop": 1})).unwrap();
let d = s.doc().unwrap();
let sh = d.wb.active().unwrap();
let growth: Vec<String> = (1..6).map(|r| crate::display::cell_text(&d.wb, sh, CellRef::new(r, 0))).collect();
assert_eq!(growth, ["2.5", "1.25", "", "", ""]);
}
}