mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-29 13:16:50 -06:00
Merge #2144 (2215934c, with #2151 at 44a2f4e2, #465 at 673af7ec, #462 at 712bb766 and main at 530a8cc8) into #2145
# Conflicts: # crates/engine/src/tests/macros.rs # docs/macros.md
This commit is contained in:
+13
-33
@@ -1,8 +1,7 @@
|
||||
# The AGENTS.md quality gate (`cargo xtask ci`: fmt, clippy -D warnings, tests, assets, layers,
|
||||
# wasm, parity --check) on every pull request and every push to main, so a merge burst can't land
|
||||
# a red tree that only the next agent session discovers.
|
||||
name: CI
|
||||
|
||||
# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the
|
||||
# suite's shared gate workflow in craft-common-actions.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
@@ -12,37 +11,18 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# A newer push to a PR branch cancels its older run; every push to main runs to the end.
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
name: cargo xtask ci
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: "0"
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Install Linux build dependencies (winit, wgpu, rfd)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev \
|
||||
libxi-dev libgtk-3-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
|
||||
|
||||
- name: Install Rust (stable + clippy, rustfmt, wasm32)
|
||||
run: |
|
||||
rustup toolchain install stable --profile minimal --component clippy,rustfmt \
|
||||
--target wasm32-unknown-unknown
|
||||
rustup default stable
|
||||
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Quality gate
|
||||
run: cargo xtask ci
|
||||
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
|
||||
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
|
||||
with:
|
||||
# The same craft-fonts commit as release.yml and freebsd.yml, so the gate tests the fonts that ship.
|
||||
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
packages: >-
|
||||
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev libxi-dev
|
||||
libgtk-3-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
|
||||
name: FreeBSD
|
||||
|
||||
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull
|
||||
# request: a drifted dependency still shows up within a day, and always before a release ships.
|
||||
# PRs that change the FreeBSD workflow or packaging itself still run it.
|
||||
on:
|
||||
push:
|
||||
branches: [release]
|
||||
@@ -10,6 +13,8 @@ on:
|
||||
- .github/workflows/freebsd.yml
|
||||
- packaging/freebsd/**
|
||||
- packaging/env.sh
|
||||
schedule:
|
||||
- cron: "57 2 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
|
||||
@@ -26,7 +26,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
|
||||
- **Input-derived numbers are hostile** (files, formulas, commands, MCP/control params): `get()` instead of indexing, checked/saturating arithmetic, cap input-sized allocations (huge ranges, `REPT`, `SEQUENCE`, whole-column references).
|
||||
- **Bound recursion** (formula nesting, dependency chains, names referring to names).
|
||||
- **Last-resort guard:** `Session::execute` catches escaped panics and restores the workbook. It's a safety net, not a licence.
|
||||
- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command; `crates/engine/tests/command_sequence_fuzz.rs` fuzzes random command sequences against undo/redo, XLSX round-trip and full-recalc invariants; soak it with `GRIDCRAFT_FUZZ_SEEDS`/`GRIDCRAFT_FUZZ_STEPS`, replay with `GRIDCRAFT_FUZZ_SEED`).
|
||||
- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command; `crates/engine/tests/it/command_sequence_fuzz.rs` fuzzes random command sequences against undo/redo, XLSX round-trip and full-recalc invariants; soak it with `GRIDCRAFT_FUZZ_SEEDS`/`GRIDCRAFT_FUZZ_STEPS`, replay with `GRIDCRAFT_FUZZ_SEED`).
|
||||
- Crates carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
|
||||
|
||||
## Non-negotiables
|
||||
@@ -37,6 +37,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
|
||||
- **Programmatic calls never open dialogs**: a command with empty params runs with defaults or returns an error; only menu-style invocation opens a dialog (`UiRequest::Dialog`).
|
||||
- **Layering** is enforced by `cargo xtask layers`: `core`/`numfmt` (L0) → `formula`/`functions` (L1) → `model`/`calc`/`xlsx` (L2) → `chart` (L3) → `engine` (L5) → `ui-egui`/`mcp` (L6) → `apps/*` (L7). Nothing below L6 depends on egui/eframe/winit/rfd. **The UI crate is swappable.**
|
||||
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
|
||||
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math). A crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
|
||||
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
|
||||
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). One task id per commit (`M2.1: borders gallery`).
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
|
||||
- **Input-derived numbers are hostile** (files, formulas, commands, MCP/control params): `get()` instead of indexing, checked/saturating arithmetic, cap input-sized allocations (huge ranges, `REPT`, `SEQUENCE`, whole-column references).
|
||||
- **Bound recursion** (formula nesting, dependency chains, names referring to names).
|
||||
- **Last-resort guard:** `Session::execute` catches escaped panics and restores the workbook. It's a safety net, not a licence.
|
||||
- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command; `crates/engine/tests/command_sequence_fuzz.rs` fuzzes random command sequences against undo/redo, XLSX round-trip and full-recalc invariants; soak it with `GRIDCRAFT_FUZZ_SEEDS`/`GRIDCRAFT_FUZZ_STEPS`, replay with `GRIDCRAFT_FUZZ_SEED`).
|
||||
- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command; `crates/engine/tests/it/command_sequence_fuzz.rs` fuzzes random command sequences against undo/redo, XLSX round-trip and full-recalc invariants; soak it with `GRIDCRAFT_FUZZ_SEEDS`/`GRIDCRAFT_FUZZ_STEPS`, replay with `GRIDCRAFT_FUZZ_SEED`).
|
||||
- Crates carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
|
||||
|
||||
## Non-negotiables
|
||||
|
||||
Generated
+27
-16
@@ -619,6 +619,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.5.2"
|
||||
@@ -1496,9 +1505,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft"
|
||||
version = "0.10.0"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
"craft-print",
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -1518,7 +1528,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-calc"
|
||||
version = "0.10.0"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"gridcraft-formula",
|
||||
@@ -1530,7 +1540,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-chart"
|
||||
version = "0.10.0"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"gridcraft-calc",
|
||||
"gridcraft-core",
|
||||
@@ -1541,7 +1551,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-cli"
|
||||
version = "0.10.0"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"gridcraft-engine",
|
||||
"gridcraft-mcp",
|
||||
@@ -1553,8 +1563,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-core"
|
||||
version = "0.10.0"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"js-sys",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1563,7 +1574,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-engine"
|
||||
version = "0.10.0"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"gridcraft-calc",
|
||||
"gridcraft-chart",
|
||||
@@ -1584,7 +1595,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-formula"
|
||||
version = "0.10.0"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"serde",
|
||||
@@ -1593,7 +1604,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-functions"
|
||||
version = "0.10.0"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"regex",
|
||||
@@ -1601,7 +1612,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-mcp"
|
||||
version = "0.10.0"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"gridcraft-engine",
|
||||
"log",
|
||||
@@ -1610,7 +1621,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-model"
|
||||
version = "0.10.0"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"gridcraft-formula",
|
||||
@@ -1622,18 +1633,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-numfmt"
|
||||
version = "0.10.0"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-pdf"
|
||||
version = "0.10.0"
|
||||
version = "0.11.1"
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-ui-egui"
|
||||
version = "0.10.0"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"egui",
|
||||
@@ -1655,7 +1666,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-web"
|
||||
version = "0.10.0"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -1672,7 +1683,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-xlsx"
|
||||
version = "0.10.0"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"gridcraft-core",
|
||||
@@ -4526,7 +4537,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.10.0"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"gridcraft-engine",
|
||||
"serde_json",
|
||||
|
||||
+5
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.10.0"
|
||||
version = "0.11.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
@@ -30,6 +30,8 @@ thiserror = "2"
|
||||
serde = { version = "1", features = ["derive", "rc"] }
|
||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
log = "0.4"
|
||||
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
|
||||
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
|
||||
rayon = "1"
|
||||
regex = { version = "1", default-features = false, features = ["std", "unicode", "perf"] }
|
||||
quick-xml = "0.41"
|
||||
@@ -66,9 +68,11 @@ type_complexity = "allow"
|
||||
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2
|
||||
debug = false
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 1
|
||||
debug = "line-tables-only"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.gridcraft.png" width="128" height="128" alt="GridCraft icon: a spreadsheet page on a green tile">
|
||||
</p>
|
||||
|
||||
<h1 align="center">GridCraft</h1>
|
||||
|
||||
<p align="center">
|
||||
|
||||
+3
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-21 · **Change:** minor (Page Setup paper sizes: JIS B4/B5/B6, the Japanese postcards and envelopes, and A6; long flat operator chains up to Excel's formula limit) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-22 · **Change:** trivial (progress log: formula hardening, #2163/#2162; previously minor: Page Setup paper sizes: JIS B4/B5/B6, the Japanese postcards and envelopes, and A6; long flat operator chains up to Excel's formula limit) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
|
||||
GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX
|
||||
is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.
|
||||
@@ -135,6 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-22 | Formula hardening: digits too many for a finite number are text, so `"1 <400 digits>/<400 digits>"` no longer coerces to NaN; a NaN column index in VLOOKUP, HLOOKUP and the D-functions is `#VALUE!` instead of an index underflow; REGEXREPLACE stops building a result as soon as it can only be too long (a worst case went from 1 GB to 7 MB) (#2163, #2162) |
|
||||
| 2026-10-21 | Page Setup paper sizes match Japanese Excel: `B4`/`B5`/`B6` are the JIS sizes (ISO B4/B5/B6 stay reachable by name), so a Japanese B5 worksheet prints at 182×257 mm; Japanese Postcard (100×148 mm), Double Postcard (148×200 mm), Envelope #3/#4 (120×235/90×205 mm), Kaku #2/#3 (240×332/216×277 mm) and A6 are added, both sizes reach the PDF page (`file.exportPdf`/`file.print` MediaBox) and the size survives an XLSX round-trip; the Page Layout Size menu lists them. `B4`/`B5` used to be the ISO sizes, which is changed here |
|
||||
| 2026-10-20 | Japanese interface catalog covers every key the Brazilian Portuguese catalog translates: the 398-label shared core plus 545 dialog, message, Backstage and menu strings (943 total, ~43% of ~2,200). A test parses `pt.rs`'s source, so the two catalogs stay in step |
|
||||
| 2026-10-20 | Interface translated into Dutch (518 labels: ribbon, context menus, File view, dialog titles, Options); `nl`, `nl-NL`, `nl-BE` locale tags resolve to Dutch |
|
||||
@@ -220,6 +221,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-22 | trivial | Progress log: formula hardening (#2163, #2162) |
|
||||
| 2026-10-21 | minor | Page Setup paper sizes: JIS B4/B5/B6 (ISO by name), Japanese postcards and envelopes, A6; PDF page size follows, XLSX round-trips |
|
||||
| 2026-10-20 | minor | Dutch (nl) listed under other shipped languages; progress log row |
|
||||
| 2026-10-19 | minor | Progress log: long flat operator chains (#916) |
|
||||
|
||||
@@ -43,6 +43,8 @@ const USAGE: &str = "usage:
|
||||
cached-value calc oracle + XLSX round-trip fidelity (exit 1 on any difference)
|
||||
gridcraft-cli run [--in FILE | --sample NAME] [--cmd 'id={json}']... [--script FILE.jsonl]
|
||||
[--out FILE] [--print RANGE] [--quiet]
|
||||
a script line is id={json} or {\"command\": \"id\", \"params\": {…}};
|
||||
blank lines and lines starting with # or // are skipped
|
||||
gridcraft-cli commands [--json] [--search X]
|
||||
gridcraft-cli functions [--json] [--search X] [--category C]
|
||||
gridcraft-cli mcp [--connect PORT] [--in FILE | --sample NAME]
|
||||
@@ -375,6 +377,17 @@ fn parse_command(text: &str) -> Result<Option<(String, Value)>, String> {
|
||||
}
|
||||
if t.starts_with('{') {
|
||||
let v: Value = serde_json::from_str(t).map_err(|e| format!("bad JSON: {e}"))?;
|
||||
// A key this does not read is a mistake, not a comment: `{"command": …, "args": {…}}` and
|
||||
// `{"command": …, "name": "Report"}` ran the command with no parameters at all and the
|
||||
// script still exited 0, so a typo made a valid, wrong workbook (#2256).
|
||||
if let Some(o) = v.as_object() {
|
||||
let mut unknown: Vec<&str> = o.keys().map(String::as_str).filter(|k| !matches!(*k, "command" | "id" | "params")).collect();
|
||||
if !unknown.is_empty() {
|
||||
unknown.sort_unstable();
|
||||
let (plural, list) = (if unknown.len() == 1 { "key" } else { "keys" }, unknown.join("`, `"));
|
||||
return Err(format!("unknown {plural} `{list}`: a line is {{\"command\": \"id\", \"params\": {{…}}}}"));
|
||||
}
|
||||
}
|
||||
let id = v.get("command").or_else(|| v.get("id")).and_then(Value::as_str).ok_or("JSON command needs a `command` field")?;
|
||||
return Ok(Some((id.to_string(), v.get("params").cloned().unwrap_or_else(|| json!({})))));
|
||||
}
|
||||
@@ -567,6 +580,26 @@ mod tests {
|
||||
assert_eq!(parse_command("{\"command\":\"edit.redo\"}").unwrap(), Some(("edit.redo".into(), json!({}))));
|
||||
assert_eq!(parse_command(" # comment").unwrap(), None);
|
||||
assert!(parse_command("x={bad").is_err());
|
||||
// A key this does not read is a mistake: the command used to run with no parameters and
|
||||
// the script still exited 0, so a typo made a valid, wrong workbook (#2256).
|
||||
for (line, named) in [
|
||||
(r#"{"command":"home.insertSheet","args":{"name":"R"}}"#, "`args`"),
|
||||
(r#"{"command":"home.insertSheet","name":"R"}"#, "`name`"),
|
||||
(r#"{"command":"home.insertSheet","params":{},"note":"why"}"#, "`note`"),
|
||||
] {
|
||||
let e = parse_command(line).expect_err("refused");
|
||||
assert!(e.contains("unknown key") && e.contains(named), "{line}: {e}");
|
||||
assert!(e.contains(r#"{"command": "id", "params": {…}}"#), "{line}: {e}");
|
||||
}
|
||||
// Several at once are listed in one message, sorted.
|
||||
let e = parse_command(r#"{"command":"x","name":"R","args":{}}"#).expect_err("refused");
|
||||
assert!(e.contains("unknown keys `args`, `name`"), "{e}");
|
||||
// `id` is still the accepted alternative to `command`, and `params` still carries them.
|
||||
assert_eq!(parse_command(r#"{"id":"edit.undo"}"#).unwrap(), Some(("edit.undo".into(), json!({}))));
|
||||
assert_eq!(
|
||||
parse_command(r#"{"command":"home.insertSheet","params":{"name":"R"}}"#).unwrap(),
|
||||
Some(("home.insertSheet".into(), json!({"name": "R"})))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Output, Stdio};
|
||||
@@ -206,3 +204,41 @@ fn check_reports_a_depth_limited_scan_while_checking_readable_files() {
|
||||
assert!(report["files"].as_array().unwrap().iter().any(|r| r["error"].as_str().is_some_and(|e| e.contains("depth limit"))));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// A script line whose parameters are under a key `run` does not read used to run the command
|
||||
/// with no parameters at all, and the run still exited 0: three mistaken lines made Sheet2,
|
||||
/// Sheet3 and Sheet4 instead of the named sheets, and nothing said so (#2256).
|
||||
#[test]
|
||||
fn a_script_line_with_an_unknown_key_stops_the_run() {
|
||||
let dir = tmpdir("script-keys");
|
||||
let script = dir.join("s.jsonl");
|
||||
let out = dir.join("t.xlsx");
|
||||
let write = |lines: &[&str]| std::fs::write(&script, lines.join("\n")).unwrap();
|
||||
|
||||
write(&[r#"{"command":"file.new"}"#, r#"{"command":"home.insertSheet","args":{"name":"Report"}}"#]);
|
||||
let o = run(&["run", "--script", script.to_str().unwrap(), "--out", out.to_str().unwrap()]);
|
||||
let err = String::from_utf8_lossy(&o.stderr);
|
||||
assert!(!o.status.success(), "the run must stop: {err}");
|
||||
assert!(err.contains("s.jsonl:2"), "it names the line: {err}");
|
||||
assert!(err.contains("unknown key `args`"), "and the key: {err}");
|
||||
assert!(!out.exists(), "and writes no workbook: {err}");
|
||||
|
||||
// The forms that are read still work, in one script: `params`, `id=json`, and a comment.
|
||||
write(&[
|
||||
r#"{"command":"file.new"}"#,
|
||||
r#"{"command":"home.insertSheet","params":{"name":"Report"}}"#,
|
||||
"# a comment",
|
||||
r#"home.insertSheet={"name":"Report2"}"#,
|
||||
]);
|
||||
let o = run(&["run", "--script", script.to_str().unwrap(), "--out", out.to_str().unwrap()]);
|
||||
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
let info = ok(&["info", out.to_str().unwrap()]);
|
||||
assert!(info.contains("Report:") && info.contains("Report2:"), "the sheets are named: {info}");
|
||||
assert!(!info.contains("Sheet2"), "and no sheet was added with a default name: {info}");
|
||||
|
||||
// The usage text says what a line looks like, which is how this was found.
|
||||
let usage = String::from_utf8_lossy(&run(&[]).stderr).into_owned() + &String::from_utf8_lossy(&run(&["--help"]).stdout);
|
||||
assert!(usage.contains(r#"{"command": "id", "params": {…}}"#), "{usage}");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//! The crate's integration tests, one module per file, built as one test binary.
|
||||
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod cli;
|
||||
mod text_to_columns_quotes;
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>GridCraft</title>
|
||||
<link data-trunk rel="icon" href="../../assets/app-icon/gridcraft.ico" />
|
||||
<link data-trunk rel="rust" data-bin="gridcraft-web" data-wasm-opt="z" data-weak-refs />
|
||||
|
||||
@@ -18,6 +18,7 @@ egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
image = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
|
||||
[build-dependencies]
|
||||
# Icon + VERSIONINFO for gridcraft.exe; build.rs only uses it when the target is Windows.
|
||||
|
||||
@@ -92,29 +92,35 @@ fn matches(name: &str, target: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
|
||||
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch; a clock
|
||||
/// set absurdly far ahead keeps its real year (years far outside chrono's own range are preserved)
|
||||
/// instead of panicking.
|
||||
pub fn timestamp(t: SystemTime) -> String {
|
||||
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
|
||||
let secs = since.as_secs();
|
||||
let (days, rem) = (secs / 86_400, secs % 86_400);
|
||||
let (y, m, d) = civil_from_days(days);
|
||||
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
|
||||
let (y, dt) = utc_parts(since.as_secs(), since.subsec_nanos());
|
||||
// `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
|
||||
format!("{y:04}-{}", dt.format("%m-%dT%H:%M:%S%.3fZ"))
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
|
||||
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch). Saturating, so no clock
|
||||
/// value can overflow it.
|
||||
fn civil_from_days(days: u64) -> (u64, u64, u64) {
|
||||
let z = days.saturating_add(719_468);
|
||||
let era = z / 146_097;
|
||||
let doe = z % 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
|
||||
(y, m, d)
|
||||
/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into
|
||||
/// chrono's range by whole 400-year cycles). The Gregorian calendar repeats every 400 years
|
||||
/// (146_097 days), so month, day and time of day come from chrono and only the year is carried,
|
||||
/// which keeps every input exact instead of collapsing out-of-range years to the epoch. `secs` is
|
||||
/// `u64` (the clock is clamped at the epoch before 1970), so `days` always fits an `i64`.
|
||||
fn utc_parts(secs: u64, nanos: u32) -> (i64, chrono::NaiveDateTime) {
|
||||
use chrono::Datelike;
|
||||
let (days, sod) = ((secs / 86_400) as i64, (secs % 86_400) as i64);
|
||||
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
|
||||
// `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
|
||||
let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
|
||||
let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999)));
|
||||
match (date, time) {
|
||||
(Some(d), Some(t)) => {
|
||||
let y = i128::from(d.year()) + i128::from(cycles) * 400;
|
||||
(i64::try_from(y).unwrap_or(i64::MAX), d.and_time(t))
|
||||
}
|
||||
_ => (1970, chrono::NaiveDateTime::default()), // unreachable
|
||||
}
|
||||
}
|
||||
|
||||
fn numbered(dir: &Path, n: usize) -> PathBuf {
|
||||
@@ -393,6 +399,17 @@ mod tests {
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
|
||||
// Before the epoch (a clock set wrong) clamps instead of panicking.
|
||||
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
|
||||
// A clock set absurdly far ahead (past chrono's own range) keeps its real year rather than
|
||||
// collapsing to the epoch. Windows' SystemTime can't hold such an instant, so it is built
|
||||
// with a checked add and only asserted where it exists.
|
||||
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
|
||||
assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
|
||||
}
|
||||
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
|
||||
assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
|
||||
}
|
||||
// Years past 9999 keep the plain zero-padded year (no `+`).
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -125,7 +125,7 @@ fn tree() -> Vec<(&'static str, Vec<Entry>)> {
|
||||
("Unhide Sheet…", "sheet.unhide", None),
|
||||
("-", "", None),
|
||||
("Conditional Formatting…", "dialog:manageRules", None),
|
||||
("Format as Table", "insert.table", None),
|
||||
("Format as Table", "home.formatAsTable", None),
|
||||
],
|
||||
),
|
||||
(
|
||||
|
||||
@@ -93,3 +93,6 @@ sync_github_name = false
|
||||
|
||||
[people.bilalmughal]
|
||||
sync_github_name = true
|
||||
|
||||
[people.tyrel]
|
||||
real_name = "Tyrel Haveman"
|
||||
|
||||
@@ -1768,3 +1768,16 @@ fn a_dynamic_array_of_one_value_still_anchors_a_spill() {
|
||||
t.set("E11", "=ROWS(D1#)");
|
||||
assert_eq!(t.get("E11"), Value::Error(CellError::Ref));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_before_after_empty_instance_defaults_to_one() {
|
||||
let mut t = T::new();
|
||||
t.set("A1", "=TEXTBEFORE(\"Little Red Riding Hood\",\"red\",,1)");
|
||||
assert_eq!(t.get("A1"), Value::Text("Little ".into()));
|
||||
t.set("A2", "=TEXTAFTER(\"a-b\",\"-\",,,,\"nf\")");
|
||||
assert_eq!(t.get("A2"), Value::Text("b".into()));
|
||||
t.set("A3", "=TEXTAFTER(\"a-b\",\"x\",,,,\"nf\")");
|
||||
assert_eq!(t.get("A3"), Value::Text("nf".into()));
|
||||
t.set("A4", "=TEXTBEFORE(\"a-b\",\"-\",0)");
|
||||
assert_eq!(t.get("A4"), Value::Error(CellError::Value));
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ workspace = true
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
js-sys = "0.3"
|
||||
|
||||
+111
-24
@@ -28,15 +28,22 @@ pub struct DateTime {
|
||||
pub weekday: u32,
|
||||
}
|
||||
|
||||
/// Days from 0000-03-01 style civil algorithm (Howard Hinnant), days since 1970-01-01.
|
||||
/// Days since 1970-01-01 for a proleptic Gregorian date, exact for every `i32` year.
|
||||
///
|
||||
/// `NaiveDate` only covers about ±262143 years, but the Gregorian calendar repeats every 400
|
||||
/// years (146_097 days): chrono converts the year within its cycle and whole cycles are added
|
||||
/// back as days (an i128 so the i32 extremes can't overflow). 1970-01-01 is `num_days_from_ce`
|
||||
/// 719_163; the result matches the proleptic Gregorian count the old hand-rolled code gave.
|
||||
pub fn days_from_civil(y: i32, m: u32, d: u32) -> i64 {
|
||||
let y = if m <= 2 { y as i64 - 1 } else { y as i64 };
|
||||
let era = if y >= 0 { y } else { y - 399 } / 400;
|
||||
let yoe = y - era * 400;
|
||||
let m = m as i64;
|
||||
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d as i64 - 1;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
era * 146097 + doe - 719468
|
||||
let (cycles, year) = (i128::from(i64::from(y).div_euclid(400)), i64::from(y).rem_euclid(400));
|
||||
i32::try_from(year)
|
||||
.ok()
|
||||
.and_then(|year| chrono::NaiveDate::from_ymd_opt(year, m, d))
|
||||
.and_then(|date| {
|
||||
let days = i128::from(chrono::Datelike::num_days_from_ce(&date)) - 719_163 + cycles * 146_097;
|
||||
i64::try_from(days).ok()
|
||||
})
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Milliseconds since the Unix epoch (UTC) from the system clock; `None` when the clock is unavailable.
|
||||
@@ -67,32 +74,50 @@ pub fn serial_in_system(sys: DateSystem, serial_1900: f64) -> f64 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Proleptic Gregorian `(year, month, day)` for a day count `z` since 1970-01-01 (the inverse of
|
||||
/// [`days_from_civil`]).
|
||||
///
|
||||
/// The calendar repeats every 400 years (146_097 days): chrono converts the day within its cycle
|
||||
/// and whole cycles go back into the year (an i128 so no `z` overflows), which matches the
|
||||
/// proleptic Gregorian answer the old hand-rolled code gave past `NaiveDate`'s ±262143 range.
|
||||
/// 1970-01-01 is `num_days_from_ce` 719_163.
|
||||
pub fn civil_from_days(z: i64) -> (i32, u32, u32) {
|
||||
let z = z + 719468;
|
||||
let era = if z >= 0 { z } else { z - 146096 } / 146097;
|
||||
let doe = z - era * 146097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
|
||||
((if m <= 2 { y + 1 } else { y }) as i32, m, d)
|
||||
use chrono::Datelike;
|
||||
let (cycles, rest) = (z.div_euclid(146_097), z.rem_euclid(146_097));
|
||||
match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
|
||||
Some(d) => {
|
||||
let y = i128::from(d.year()) + i128::from(cycles) * 400;
|
||||
(i32::try_from(y).unwrap_or(if y < 0 { i32::MIN } else { i32::MAX }), d.month(), d.day())
|
||||
}
|
||||
// Unreachable: `rest + 719_163` is always a valid CE day.
|
||||
None => (1970, 1, 1),
|
||||
}
|
||||
}
|
||||
|
||||
const UNIX_1899_12_30: i64 = -25569; // days_from_civil(1899, 12, 30)
|
||||
const UNIX_1904_01_01: i64 = -24107;
|
||||
|
||||
/// Whether `y` is a Gregorian leap year, for every `i32` year.
|
||||
pub fn is_leap(y: i32) -> bool {
|
||||
(y % 4 == 0 && y % 100 != 0) || y % 400 == 0
|
||||
// A year is a leap year iff 29 February exists in it. Leap years repeat every 400 years, so
|
||||
// the year within its Gregorian cycle (`rem_euclid(400)`) decides it and always fits chrono.
|
||||
let y = i64::from(y).rem_euclid(400) as i32;
|
||||
chrono::NaiveDate::from_ymd_opt(y, 2, 29).is_some()
|
||||
}
|
||||
|
||||
/// Number of days in month `m` of year `y` (1–31), or `0` for an invalid month number, for every
|
||||
/// `i32` year.
|
||||
pub fn days_in_month(y: i32, m: u32) -> u32 {
|
||||
match m {
|
||||
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
|
||||
4 | 6 | 9 | 11 => 30,
|
||||
2 if is_leap(y) => 29,
|
||||
2 => 28,
|
||||
// The length of a month is the day before the first of the next month. Month lengths repeat
|
||||
// every 400 years, so the year within its Gregorian cycle (`rem_euclid(400)`) is enough and
|
||||
// always fits chrono (so December can't overflow `y + 1`). An invalid month keeps the old `0`.
|
||||
if !(1..=12).contains(&m) {
|
||||
return 0;
|
||||
}
|
||||
let y = i64::from(y).rem_euclid(400) as i32;
|
||||
let first = |y: i32, m: u32| chrono::NaiveDate::from_ymd_opt(y, m, 1);
|
||||
match (first(y, m), if m == 12 { first(y + 1, 1) } else { first(y, m + 1) }) {
|
||||
(Some(a), Some(b)) => u32::try_from((b - a).num_days()).unwrap_or(0),
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
@@ -196,6 +221,68 @@ pub const WEEKDAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Th
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn leap_and_month_length_outside_chrono_range() {
|
||||
// In range, the ordinary Gregorian rule holds.
|
||||
assert!(is_leap(2000));
|
||||
assert!(!is_leap(1900));
|
||||
assert!(is_leap(2024));
|
||||
assert!(!is_leap(2023));
|
||||
// Outside chrono's NaiveDate range the Gregorian rule is still applied.
|
||||
assert!(is_leap(400_000)); // divisible by 400
|
||||
assert!(!is_leap(300_100)); // century not divisible by 400
|
||||
assert!(is_leap(i32::MAX - 3)); // 2147483644 is divisible by 4, not a century
|
||||
assert!(!is_leap(i32::MAX)); // 2147483647 is odd
|
||||
// days_in_month keeps calendar lengths out of range and 0 for invalid months.
|
||||
assert_eq!(days_in_month(400_000, 2), 29);
|
||||
assert_eq!(days_in_month(300_100, 2), 28);
|
||||
assert_eq!(days_in_month(400_000, 1), 31);
|
||||
assert_eq!(days_in_month(400_000, 4), 30);
|
||||
assert_eq!(days_in_month(2024, 2), 29);
|
||||
assert_eq!(days_in_month(2023, 2), 28);
|
||||
assert_eq!(days_in_month(2023, 0), 0);
|
||||
assert_eq!(days_in_month(2023, 13), 0);
|
||||
// December at i32::MAX does not overflow and still reports 31 days.
|
||||
assert_eq!(days_in_month(i32::MAX, 12), 31);
|
||||
assert_eq!(days_in_month(i32::MAX, 2), 28); // 2147483647 is not a leap year
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn civil_conversions_exact_beyond_chrono_range() {
|
||||
assert_eq!(days_from_civil(1970, 1, 1), 0);
|
||||
assert_eq!(civil_from_days(0), (1970, 1, 1));
|
||||
// Past `NaiveDate`'s ±262143 the 400-year cycles carry the result, exactly, both sides of
|
||||
// the epoch; values round-trip through the proleptic Gregorian converters.
|
||||
for (y, m, d, z) in [
|
||||
(262_144i32, 1u32, 1u32, 95_026_602i64),
|
||||
(300_000, 1, 1, 108_853_222),
|
||||
(400_000, 2, 29, 145_377_531),
|
||||
(-262_145, 3, 1, -96_465_964),
|
||||
(-300_000, 6, 15, -110_292_112),
|
||||
] {
|
||||
assert_eq!(days_from_civil(y, m, d), z, "{y}-{m}-{d}");
|
||||
assert_eq!(civil_from_days(z), (y, m, d), "{z}");
|
||||
}
|
||||
// The extremes must not overflow or panic (i128 intermediates).
|
||||
let _ = (days_from_civil(i32::MAX, 12, 31), days_from_civil(i32::MIN, 1, 1), civil_from_days(i64::MAX), civil_from_days(i64::MIN));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serials_unchanged_through_cycle_conversion() {
|
||||
// The 1900 leap bug and 1904 semantics must stay exact; the converters only ever see
|
||||
// in-range years here, so the serial outputs are byte-for-byte the pre-chrono ones.
|
||||
let s = DateSystem::D1900;
|
||||
assert_eq!(serial_from_ymd(s, 1900, 2, 29), Some(60.0)); // Excel's fictitious leap day
|
||||
assert_eq!(serial_from_ymd(s, 1900, 3, 1), Some(61.0));
|
||||
assert_eq!(serial_from_ymd(s, 9999, 12, 31), Some(2958465.0));
|
||||
assert_eq!(days_in_month_in(s, 1900, 2), 29);
|
||||
let s4 = DateSystem::D1904;
|
||||
assert_eq!(serial_from_ymd(s4, 1904, 1, 1), Some(0.0)); // serial 0 is 1904-01-01
|
||||
assert_eq!(serial_from_ymd(s4, 1904, 2, 29), Some(59.0)); // 1904 is a real leap year
|
||||
assert_eq!(serial_from_ymd(s4, 1900, 2, 29), None); // no fictitious day in 1904 system
|
||||
assert_eq!(days_in_month_in(s4, 1900, 2), 28);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serial_from_unix_time() {
|
||||
assert_eq!(serial_from_unix_ms(0.0), 25569.0);
|
||||
|
||||
@@ -167,7 +167,8 @@ fn parse_plain(t: &str) -> Option<(f64, Option<&'static str>)> {
|
||||
}
|
||||
|
||||
/// A plain ASCII number: digits, with an optional `.digits` fraction when `frac` is set. Rejects
|
||||
/// `nan`, `inf`, signs and exponents that `f64::from_str` would accept.
|
||||
/// `nan`, `inf`, signs and exponents that `f64::from_str` would accept, and digits too many for a
|
||||
/// finite number (#2163).
|
||||
fn plain_number(s: &str, frac: bool) -> Option<f64> {
|
||||
let (int, dec) = match s.split_once('.') {
|
||||
Some((i, d)) if frac => (i, Some(d)),
|
||||
@@ -177,7 +178,7 @@ fn plain_number(s: &str, frac: bool) -> Option<f64> {
|
||||
if !digits(int) || dec.is_some_and(|d| !digits(d)) {
|
||||
return None;
|
||||
}
|
||||
s.parse().ok()
|
||||
s.parse().ok().filter(|x: &f64| x.is_finite())
|
||||
}
|
||||
|
||||
/// `1/2` (only with a whole part, else it's a date), `1 1/2`, `0 3/4`.
|
||||
@@ -192,6 +193,9 @@ fn parse_fraction(t: &str) -> Option<f64> {
|
||||
return None;
|
||||
}
|
||||
let v = w + a / b;
|
||||
if !v.is_finite() {
|
||||
return None;
|
||||
}
|
||||
Some(if neg { -v } else { v })
|
||||
}
|
||||
|
||||
@@ -366,6 +370,17 @@ mod tests {
|
||||
parse_input(s, DateSystem::D1900)
|
||||
}
|
||||
|
||||
/// #2163: digits too many for a finite number don't make one (∞/∞ was NaN).
|
||||
#[test]
|
||||
fn overlong_digits_stay_text() {
|
||||
let big = "9".repeat(400);
|
||||
for s in [format!("1 {big}/{big}"), format!("1 {big}/2"), format!("1 1/{big}0"), format!("{big}:00"), format!("1:00:{big}.5")] {
|
||||
assert_eq!(p(&s).value, Value::text(s.as_str()), "{s}");
|
||||
assert_eq!(parse_number_text(&s), None, "{s}");
|
||||
}
|
||||
assert_eq!(parse_number_text("1 1/2"), Some(1.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_digit_time_and_fraction_parts_stay_text() {
|
||||
for s in ["1:nan", "nan:30", "1:30:NaN", "1 nan/2", "1 1/nan", "inf:00", "1e1:00", "+5:00"] {
|
||||
|
||||
@@ -273,6 +273,7 @@ pub const CATALOG: &[(&str, &str, &str, &str)] = &[
|
||||
("automate.newScript", "Automate", "Scripting Tools", "New Script"),
|
||||
("automate.allScripts", "Automate", "Scripting Tools", "All Scripts"),
|
||||
("automate.recordActions", "Automate", "Scripting Tools", "Record Actions"),
|
||||
("automate.trustScripts", "Automate", "Scripting Tools", "Trust Scripts"),
|
||||
// Table Design
|
||||
("table.rename", "Table Design", "Properties", "Table Name"),
|
||||
("table.resize", "Table Design", "Properties", "Resize Table"),
|
||||
|
||||
@@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Set Values",
|
||||
[],
|
||||
None,
|
||||
"{range?: \"A1\", values: [[...]] (rows of numbers/strings/bools/null; strings starting with = are formulas)}",
|
||||
"{range?: \"A1\", values: [[...]] (rows of numbers/strings/bools/null; strings starting with = are formulas), parse?: bool (default true: a string goes through typed input, so \"05.04.2026\" becomes a date and \"2.500\" a number, by en-US rules; false stores every other string as the text it is, so a dd.mm.yyyy date or a thousands separator survives)}",
|
||||
has_doc,
|
||||
range_set_values
|
||||
),
|
||||
@@ -299,6 +299,15 @@ fn range_set_values(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let start = target_range(s, p)?.start;
|
||||
let Some(rows) = p.get("values").and_then(Json::as_array) else { return Err(bad("range.setValues", "missing `values` (array of rows)")) };
|
||||
// A string is run through typed input by default, which reads it with en-US rules: a caller
|
||||
// sending "05.04.2026" from a dd.mm.yyyy export got 4 May, and "2.500" became 2.5, in silence
|
||||
// (#2255). `parse: false` stores a string as the text it is; numbers, booleans and formulas
|
||||
// are unaffected either way.
|
||||
let parse = match p.get("parse") {
|
||||
None | Some(Json::Null) => true,
|
||||
Some(Json::Bool(b)) => *b,
|
||||
Some(other) => return Err(bad("range.setValues", format!("parse {other}: true or false"))),
|
||||
};
|
||||
if rows.len() > MAX_ROWS as usize {
|
||||
return Err(bad("range.setValues", "too many rows"));
|
||||
}
|
||||
@@ -321,6 +330,17 @@ fn range_set_values(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
Json::Bool(b) => {
|
||||
Some(Cell { value: Value::Bool(*b), formula: None, style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default() })
|
||||
}
|
||||
// Text as given: no date or number reading, and a leading `'` is part of the
|
||||
// text rather than the marker that makes it text. A formula is still a
|
||||
// formula, which is what the ticket asks for and what `=` means in a cell.
|
||||
Json::String(text) if !parse && !text.starts_with('=') => {
|
||||
let mut style = cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default();
|
||||
// Line breaks still turn on Wrap Text, as typed input does.
|
||||
if text.contains('\n') && !cx.wb.styles.get(style).align.wrap {
|
||||
style = cx.wb.styles.derive(style, |s| s.align.wrap = true);
|
||||
}
|
||||
Some(Cell { value: Value::Text(text.as_str().into()), formula: None, style })
|
||||
}
|
||||
_ => input_to_cell(&input, sheet, at, &mut cx.wb)?,
|
||||
};
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
@@ -1242,9 +1262,10 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
})?;
|
||||
// Formulas and validation from an untrusted file leave the destination untrusted; plain values,
|
||||
// formats (cell styles and merged areas, no conditional formats), column widths and notes
|
||||
// carry nothing active (a linked paste of values still writes formulas).
|
||||
// carry nothing active. A linked paste writes a formula into every cell whatever `what` says,
|
||||
// except for column widths and notes, which write no cells at all.
|
||||
let passive =
|
||||
matches!(what, What::Formats | What::ColumnWidths | What::Comments) || (!link && matches!(what, What::Values | What::ValuesAndNumberFormats));
|
||||
matches!(what, What::ColumnWidths | What::Comments) || (!link && matches!(what, What::Formats | What::Values | What::ValuesAndNumberFormats));
|
||||
if !same_doc && !passive {
|
||||
let d = s.doc_mut()?;
|
||||
d.trust = d.trust.receive(clip.trust);
|
||||
@@ -1778,6 +1799,55 @@ mod tests {
|
||||
assert!(s.execute("cell.set", json!({"cell": "A5"})).is_err());
|
||||
}
|
||||
|
||||
/// Every string in `values` went through typed input, read with en-US rules, in silence: a
|
||||
/// caller sending "05.04.2026" from a dd.mm.yyyy export stored 4 May, "2.500" became 2.5 and
|
||||
/// "4.25 %" became a percentage (#2255). `parse: false` stores a string as the text it is.
|
||||
#[test]
|
||||
fn set_values_can_store_strings_as_the_text_they_are() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
let strings = ["05.04.2026", "02/11/2026", "2.500", "4.25 %", "=1+1", "'keep"];
|
||||
let row: Vec<Json> = strings.iter().map(|v| json!(v)).collect();
|
||||
let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap();
|
||||
|
||||
// The default is unchanged: the dates swap, the thousands separator is a decimal point.
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [row.clone()]})).unwrap();
|
||||
assert_eq!(get(&mut s, "A1")["text"], "5/4/2026", "dd.mm.yyyy read as mm.dd.yyyy");
|
||||
assert_eq!(get(&mut s, "C1")["value"], json!(2.5), "\"2.500\" read as a decimal");
|
||||
assert_eq!(get(&mut s, "D1")["value"], json!(0.0425), "\"4.25 %\" read as a percentage");
|
||||
|
||||
// With `parse: false` each string is the text it was given, and a formula is still a
|
||||
// formula.
|
||||
s.execute("range.setValues", json!({"range": "A3", "parse": false, "values": [row.clone()]})).unwrap();
|
||||
for (i, want) in strings.iter().enumerate() {
|
||||
let cell = format!("{}3", (b'A' + i as u8) as char);
|
||||
let got = get(&mut s, &cell);
|
||||
if *want == "=1+1" {
|
||||
assert_eq!(got["value"], json!(2.0), "{cell} is still a formula: {got}");
|
||||
assert_eq!(got["formula"], "=1+1", "{got}");
|
||||
} else {
|
||||
assert_eq!(got["value"], json!(*want), "{cell} is the text it was given: {got}");
|
||||
assert!(got["formula"].is_null(), "{cell} is not a formula: {got}");
|
||||
}
|
||||
}
|
||||
// Numbers and booleans are untouched either way.
|
||||
s.execute("range.setValues", json!({"range": "A5", "parse": false, "values": [[12.5, true, null]]})).unwrap();
|
||||
assert_eq!(get(&mut s, "A5")["value"], json!(12.5));
|
||||
assert_eq!(get(&mut s, "B5")["value"], json!(true));
|
||||
assert_eq!(get(&mut s, "C5")["text"], "");
|
||||
// `parse` is a flag, and anything else is a usage error rather than a quiet default.
|
||||
s.execute("range.setValues", json!({"range": "A7", "parse": true, "values": [["2.500"]]})).unwrap();
|
||||
assert_eq!(get(&mut s, "A7")["value"], json!(2.5));
|
||||
assert!(s.execute("range.setValues", json!({"range": "A8", "parse": "no", "values": [["x"]]})).is_err());
|
||||
assert!(s.execute("range.setValues", json!({"range": "A8", "parse": 0, "values": [["x"]]})).is_err());
|
||||
// A line break still turns on Wrap Text.
|
||||
s.execute("range.setValues", json!({"range": "A9", "parse": false, "values": [["one\ntwo"]]})).unwrap();
|
||||
assert_eq!(get(&mut s, "A9")["value"], json!("one\ntwo"));
|
||||
let d = s.doc().unwrap();
|
||||
let sh = d.wb.active().unwrap();
|
||||
assert!(d.wb.styles.get(sh.style_id(CellRef::parse("A9").unwrap())).align.wrap);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_array_entry() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -193,7 +193,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
new_script
|
||||
),
|
||||
cmd!(query "automate.allScripts", "All Scripts", ["Automate", "Scripting Tools"], None, "{} → saved scripts", has_doc, all_scripts),
|
||||
cmd!("automate.runScript", "Run Script", [], None, "{name}", has_doc, run_script),
|
||||
cmd!(
|
||||
"automate.runScript",
|
||||
"Run Script",
|
||||
[],
|
||||
None,
|
||||
"{name, trusted?: bool (runs a script from an untrusted workbook this once)} — refuses scripts in a workbook opened from a file until it is trusted, and steps that reach outside the workbook (file.*, link.*, any `path` param)",
|
||||
has_doc,
|
||||
run_script
|
||||
),
|
||||
cmd!(
|
||||
noundo "automate.trustScripts",
|
||||
"Trust Scripts",
|
||||
["Automate", "Scripting Tools"],
|
||||
None,
|
||||
"{trusted?: bool (default true)} → {trusted}: lets this workbook's scripts run for the rest of the session",
|
||||
has_doc,
|
||||
trust_scripts
|
||||
),
|
||||
cmd!("automate.deleteScript", "Delete Script", [], None, "{name}", has_doc, delete_script),
|
||||
// Cell controls, themes, borders, styles
|
||||
cmd!(
|
||||
@@ -785,6 +802,7 @@ fn new_window(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
let d = s.doc()?.clone();
|
||||
let mut copy = crate::DocState::new((*d.wb).clone(), d.path.clone(), format!("{}:2", d.display_title()));
|
||||
copy.selection = d.selection.clone();
|
||||
copy.scripts_trusted = d.scripts_trusted;
|
||||
copy.trust = d.trust;
|
||||
let i = s.add_document(copy);
|
||||
Ok(json!({"index": i}))
|
||||
@@ -977,10 +995,13 @@ fn new_script(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
|
||||
fn all_scripts(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
let wb = &s.doc()?.wb;
|
||||
let d = s.doc()?;
|
||||
let (wb, trusted) = (&d.wb, d.scripts_trusted);
|
||||
let all = stash_get(wb, SCRIPTS_NAME);
|
||||
let mut list: Vec<Json> =
|
||||
all.as_object().map(|o| o.iter().map(|(k, v)| json!({"name": k, "steps": v.as_array().map_or(0, Vec::len)})).collect()).unwrap_or_default();
|
||||
let mut list: Vec<Json> = all
|
||||
.as_object()
|
||||
.map(|o| o.iter().map(|(k, v)| json!({"name": k, "steps": v.as_array().map_or(0, Vec::len), "trusted": trusted})).collect())
|
||||
.unwrap_or_default();
|
||||
// A preserved VBA project is reported alongside GridCraft's own scripts; it is carried on
|
||||
// save but never executed here, so it has no step count.
|
||||
if let Some(bytes) = &wb.vba_project {
|
||||
@@ -989,17 +1010,79 @@ fn all_scripts(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
Ok(Json::Array(list))
|
||||
}
|
||||
|
||||
/// Whether `wb` carries GridCraft scripts (a workbook opened with them starts untrusted).
|
||||
pub(crate) fn has_scripts(wb: &Workbook) -> bool {
|
||||
stash_map(wb, SCRIPTS_NAME).as_object().is_some_and(|o| !o.is_empty())
|
||||
}
|
||||
|
||||
/// Why a script step is refused, whatever the workbook's trust: scripts stay inside the
|
||||
/// workbook. Anything that reads or writes files (`file.*`, or any `path` parameter: Get Data,
|
||||
/// From Text/CSV, Insert Picture) or leaves the app (`link.*` opens URLs) is the caller's to run
|
||||
/// directly, not a stored script's. (Nested `automate.*` steps are skipped.) This check goes by
|
||||
/// command id and parameter name, so it is only the up-front refusal: the steps also run inside
|
||||
/// an [`crate::io::ScriptSandbox`] (file access refused whatever the parameter is called) and
|
||||
/// [`run_script`] drops any `OpenUrl` request a step raises.
|
||||
fn denied_script_step(id: &str, params: &Json) -> Option<String> {
|
||||
if id.starts_with("file.") || id.starts_with("link.") {
|
||||
return Some(format!("scripts can't run `{id}`: it reaches outside the workbook"));
|
||||
}
|
||||
if params.get("path").is_some() {
|
||||
return Some(format!("scripts can't pass a file `path` to `{id}`"));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn trust_scripts(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
// Only a missing `trusted` means "trust"; `"false"`, `0` or `null` are mistakes, not consent.
|
||||
let trusted = match p.get("trusted") {
|
||||
None => true,
|
||||
Some(Json::Bool(b)) => *b,
|
||||
Some(_) => return Err(bad("automate.trustScripts", "`trusted` must be true or false")),
|
||||
};
|
||||
s.doc_mut()?.scripts_trusted = trusted;
|
||||
Ok(json!({"trusted": trusted}))
|
||||
}
|
||||
|
||||
/// Runs a stored script. Scripts in a workbook opened from a file run only once the workbook is
|
||||
/// trusted for this session (`automate.trustScripts`, or `trusted: true` on this call); steps that
|
||||
/// reach outside the workbook ([`denied_script_step`]) are refused before any step runs.
|
||||
fn run_script(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("automate.runScript", "missing `name`"))?;
|
||||
let all = stash_get(&s.doc()?.wb, SCRIPTS_NAME);
|
||||
let d = s.doc()?;
|
||||
let all = stash_get(&d.wb, SCRIPTS_NAME);
|
||||
let steps = all.get(name).and_then(Json::as_array).cloned().ok_or_else(|| bad("automate.runScript", "no such script"))?;
|
||||
if !d.scripts_trusted && !bool_param(p, "trusted").unwrap_or(false) {
|
||||
return Err(bad(
|
||||
"automate.runScript",
|
||||
"scripts in this workbook came from a file and are disabled; trust the workbook first (Trust Scripts in View › Macros, Alt+F8; `automate.trustScripts`; or `trusted: true`)",
|
||||
));
|
||||
}
|
||||
let steps: Vec<Json> = steps.into_iter().take(100_000).collect();
|
||||
for st in &steps {
|
||||
let id = st["command"].as_str().unwrap_or("");
|
||||
if let Some(why) = denied_script_step(id, &st["params"]) {
|
||||
return Err(bad("automate.runScript", why));
|
||||
}
|
||||
}
|
||||
let _sandbox = crate::io::ScriptSandbox::enter();
|
||||
let mut n = 0;
|
||||
for st in steps.iter().take(100_000) {
|
||||
for st in &steps {
|
||||
let id = st["command"].as_str().unwrap_or("");
|
||||
if id.starts_with("automate.") {
|
||||
continue;
|
||||
}
|
||||
s.execute(id, st["params"].clone())?;
|
||||
let before = s.ui_requests.len();
|
||||
let ran = s.execute(id, st["params"].clone());
|
||||
// A step that asks the host to open a URL leaves the app: drop the request and stop.
|
||||
if s.ui_requests.get(before..).is_some_and(|new| new.iter().any(|r| matches!(r, crate::UiRequest::OpenUrl(_)))) {
|
||||
let mut i = 0;
|
||||
s.ui_requests.retain(|r| {
|
||||
i += 1;
|
||||
i <= before || !matches!(r, crate::UiRequest::OpenUrl(_))
|
||||
});
|
||||
return Err(bad("automate.runScript", format!("scripts can't open links (`{id}`)")));
|
||||
}
|
||||
ran?;
|
||||
n += 1;
|
||||
}
|
||||
Ok(json!({"ran": n}))
|
||||
|
||||
@@ -26,9 +26,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Format as Table",
|
||||
["Home", "Styles"],
|
||||
None,
|
||||
"{range?, style: \"TableStyleMedium2\", header?}",
|
||||
"{range?, style: \"TableStyleMedium2\", header?} (inside a table: restyles it)",
|
||||
has_doc,
|
||||
insert_table
|
||||
format_as_table
|
||||
),
|
||||
cmd!("table.totalRow", "Total Row", ["Table", "Table Style Options"], None, "{table?, on?: bool}", has_doc, |s, p| table_flag(
|
||||
s, p, "totals"
|
||||
@@ -351,6 +351,21 @@ fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Format as Table inside a table restyles that table, as in Excel; elsewhere it makes a new one.
|
||||
fn format_as_table(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
// Without a range, the active area decides (as in insert_table), not the first one.
|
||||
let r = if p.get("range").is_some() { target_range(s, p)? } else { s.doc()?.selection.current() };
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let inside = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.tables.iter().find(|t| t.range.contains_range(&r))).map(|t| (t.name.clone(), t.range));
|
||||
match inside {
|
||||
Some((table, range)) => {
|
||||
table_style(s, &with(p, "table", json!(table)))?;
|
||||
Ok(json!({"table": table, "range": range.a1()}))
|
||||
}
|
||||
None => insert_table(s, p),
|
||||
}
|
||||
}
|
||||
|
||||
fn find_table(s: &Session, p: &Json) -> Result<(usize, usize)> {
|
||||
let d = s.doc()?;
|
||||
if let Some(n) = str_param(p, "table") {
|
||||
|
||||
@@ -401,7 +401,9 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
|
||||
}
|
||||
|
||||
/// The most written cells a macro's footprint check records.
|
||||
const TOUCH_LOG_LIMIT: usize = 1_000_000;
|
||||
/// The most cells a dry run's touch log keeps; a footprint whose log is full may be missing writes
|
||||
/// that kept a cell's value, so a shift is refused then.
|
||||
pub(crate) const TOUCH_LOG_LIMIT: usize = 1_000_000;
|
||||
|
||||
/// Excel's message for an edit of a locked cell on a protected sheet.
|
||||
pub(crate) const PROTECTED: &str = "The cell or chart you're trying to change is on a protected sheet.";
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
//! one name each, `_sc_<key>`; reading accepts that form as well, so those items can move to this
|
||||
//! format later, and a write of the same key replaces it.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
|
||||
use gridcraft_model::{DefinedName, Workbook};
|
||||
use serde_json::Value as Json;
|
||||
@@ -59,25 +59,40 @@ pub fn get(wb: &Workbook, key: &str) -> Option<String> {
|
||||
|
||||
/// Stores `text` under `key`, replacing what was there (in either format). Empty text removes it.
|
||||
pub fn set(wb: &mut Workbook, key: &str, text: &str) -> Result<()> {
|
||||
check_key(key)?;
|
||||
if text.chars().count() > MAX_ITEM_CHARS {
|
||||
return Err(EngineError::Other(format!("The data is too large to keep in the workbook ({} MB at most).", MAX_ITEM_CHARS / (1024 * 1024))));
|
||||
}
|
||||
let formulas = chunk_formulas(text);
|
||||
// A visible name of the item's shape is someone's own (see `item_of`); a hidden name beside
|
||||
// it with the same name would be the one an xlsx file drops. Refuse before changing anything.
|
||||
for i in 0..formulas.len() {
|
||||
let name = format!("{PREFIX}{key}_{i}");
|
||||
if wb.names.iter().any(|n| n.scope.is_none() && !n.hidden && n.name.eq_ignore_ascii_case(&name)) {
|
||||
set_many(wb, &[(key.to_string(), text.to_string())])
|
||||
}
|
||||
|
||||
/// Stores every `(key, text)` of `items` as [`set`] would, with the checks done before anything
|
||||
/// changes and one pass over the names for all of them, so writing a thousand items into a
|
||||
/// workbook with many thousands of names stays near-linear.
|
||||
pub fn set_many(wb: &mut Workbook, items: &[(String, String)]) -> Result<()> {
|
||||
let mut planned = Vec::new();
|
||||
let mut keys: HashSet<String> = HashSet::new();
|
||||
for (key, text) in items {
|
||||
check_key(key)?;
|
||||
if text.chars().count() > MAX_ITEM_CHARS {
|
||||
return Err(EngineError::Other(format!(
|
||||
"The workbook has a defined name '{name}', which GridCraft needs for its own data. Rename it first."
|
||||
"The data is too large to keep in the workbook ({} MB at most).",
|
||||
MAX_ITEM_CHARS / (1024 * 1024)
|
||||
)));
|
||||
}
|
||||
keys.insert(key.to_ascii_lowercase());
|
||||
for (i, formula) in chunk_formulas(text).into_iter().enumerate() {
|
||||
planned.push(DefinedName { name: format!("{PREFIX}{key}_{i}"), scope: None, formula, comment: "GridCraft data".into(), hidden: true });
|
||||
}
|
||||
}
|
||||
remove(wb, key);
|
||||
for (i, formula) in formulas.into_iter().enumerate() {
|
||||
wb.names.push(DefinedName { name: format!("{PREFIX}{key}_{i}"), scope: None, formula, comment: "GridCraft data".into(), hidden: true });
|
||||
// A visible name of an item's shape is someone's own (see `item_of`); a hidden name beside
|
||||
// it with the same name would be the one an xlsx file drops. Refuse before changing anything.
|
||||
let names: HashSet<String> = planned.iter().map(|n| n.name.to_ascii_lowercase()).collect();
|
||||
if let Some(n) = wb.names.iter().find(|n| n.scope.is_none() && !n.hidden && names.contains(&n.name.to_ascii_lowercase())) {
|
||||
return Err(EngineError::Other(format!(
|
||||
"The workbook has a defined name '{}', which GridCraft needs for its own data. Rename it first.",
|
||||
n.name
|
||||
)));
|
||||
}
|
||||
// What was there under these keys goes, in either format, then the new chunks come.
|
||||
wb.names.retain(|n| !item_of(n).is_some_and(|(k, _)| keys.contains(&k.to_ascii_lowercase())));
|
||||
wb.names.extend(planned);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -100,7 +115,8 @@ pub fn keys(wb: &Workbook, prefix: &str) -> Vec<String> {
|
||||
let mut keys: Vec<String> = wb
|
||||
.names
|
||||
.iter()
|
||||
.filter_map(|n| item_of(n).map(|(k, _)| k.to_ascii_lowercase()))
|
||||
// A later chunk on its own is no item, as `get` reads it.
|
||||
.filter_map(|n| item_of(n).filter(|(_, chunk)| chunk.is_none_or(|i| i == 0)).map(|(k, _)| k.to_ascii_lowercase()))
|
||||
.filter(|k| k.starts_with(&prefix.to_ascii_lowercase()))
|
||||
.collect();
|
||||
keys.sort();
|
||||
@@ -127,8 +143,10 @@ fn item_of(n: &DefinedName) -> Option<(&str, Option<u64>)> {
|
||||
fn parse_name(name: &str) -> Option<(&str, Option<u64>)> {
|
||||
if let Some(rest) = strip_prefix_ci(name, PREFIX) {
|
||||
let (key, k) = rest.rsplit_once('_')?;
|
||||
let all_digits = !k.is_empty() && k.len() <= 9 && k.bytes().all(|b| b.is_ascii_digit());
|
||||
if key.is_empty() || !all_digits {
|
||||
// Only the chunk number as `set` writes it: `_gc_watch_00` is someone else's name, not
|
||||
// chunk 0 under another spelling, so it is neither read nor in the way of `_gc_watch_0`.
|
||||
let canonical = !k.is_empty() && k.len() <= 9 && k.bytes().all(|b| b.is_ascii_digit()) && (k == "0" || !k.starts_with('0'));
|
||||
if key.is_empty() || !canonical {
|
||||
return None;
|
||||
}
|
||||
return k.parse().ok().map(|i| (key, Some(i)));
|
||||
@@ -289,11 +307,13 @@ mod tests {
|
||||
wb.names.push(name("_gc_gap_0", "\"a\""));
|
||||
wb.names.push(name("_gc_gap_2", "\"c\""));
|
||||
assert_eq!(get(&wb, "gap").as_deref(), Some("a"));
|
||||
// Only a later chunk: no item, and the legacy name is still read.
|
||||
// Only a later chunk: no item, not listed, and the legacy name is still read.
|
||||
wb.names.push(name("_gc_orphan_2", "\"c\""));
|
||||
assert_eq!(get(&wb, "orphan"), None);
|
||||
assert_eq!(keys(&wb, "orphan"), Vec::<String>::new());
|
||||
wb.names.push(name("_sc_orphan", "\"old\""));
|
||||
assert_eq!(get(&wb, "orphan").as_deref(), Some("old"));
|
||||
assert_eq!(keys(&wb, "orphan"), ["orphan"]);
|
||||
// Chunks out of order, a duplicate, junk around the literals, an unterminated literal.
|
||||
wb.names.push(name("_gc_mix_1", "\"b\"&A1&"));
|
||||
wb.names.push(name("_gc_mix_0", "SUM(\"a\")"));
|
||||
@@ -355,6 +375,23 @@ mod tests {
|
||||
assert_eq!(wb.names, vec![own]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chunk_number_with_leading_zeros_is_another_name() {
|
||||
// `_gc_watch_00` is not chunk 0 under another spelling: a hidden one is not read, and a
|
||||
// visible one is not in the way of writing `_gc_watch_0`.
|
||||
let mut wb = Workbook::new();
|
||||
wb.names.push(name("_gc_watch_00", "\"theirs\""));
|
||||
assert_eq!(get(&wb, "watch"), None);
|
||||
assert!(keys(&wb, "").is_empty());
|
||||
let own = DefinedName { hidden: false, ..name("_gc_watch_00", "\"mine\"") };
|
||||
wb.names.push(own.clone());
|
||||
set(&mut wb, "watch", "ours").unwrap();
|
||||
assert_eq!(get(&wb, "watch").as_deref(), Some("ours"));
|
||||
assert_eq!(wb.names.len(), 3, "both other names stay");
|
||||
remove(&mut wb, "watch");
|
||||
assert_eq!(wb.names, vec![name("_gc_watch_00", "\"theirs\""), own]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_list_items_by_prefix() {
|
||||
let mut wb = Workbook::new();
|
||||
|
||||
@@ -35,6 +35,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("view.gridlines", "Gridlines", ["View", "Show"], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "gridlines")),
|
||||
cmd!("view.headings", "Headings", ["View", "Show"], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "headings")),
|
||||
cmd!("view.showZeros", "Show Zeros", [], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "zeros")),
|
||||
cmd!("view.showOutlineSymbols", "Show Outline Symbols", [], Some("Ctrl+8"), "{on?}", has_doc, |s, p| sheet_flag(s, p, "outlineSymbols")),
|
||||
cmd!("view.showObjects", "Show Objects", [], Some("Ctrl+6"), "{on?}", has_doc, show_objects),
|
||||
cmd!("view.rightToLeft", "Sheet Right-to-Left", ["Page Layout", "Sheet Options"], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "rtl")),
|
||||
cmd!("view.zoom", "Zoom", ["View", "Zoom"], None, "{percent: 10..400}", has_doc, zoom),
|
||||
@@ -166,6 +167,7 @@ fn sheet_flag(s: &mut Session, p: &Json, which: &str) -> Result<Json> {
|
||||
"gridlines" => &mut sh.show_gridlines,
|
||||
"headings" => &mut sh.show_headings,
|
||||
"zeros" => &mut sh.show_zeros,
|
||||
"outlineSymbols" => &mut sh.show_outline_symbols,
|
||||
"rtl" => &mut sh.right_to_left,
|
||||
"printGridlines" => &mut sh.print.gridlines,
|
||||
_ => &mut sh.print.headings,
|
||||
|
||||
@@ -565,8 +565,41 @@ pub fn to_html(wb: &Workbook, sheet: usize) -> Result<String> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// How many [`ScriptSandbox`]es are alive on this thread.
|
||||
static SCRIPT_SANDBOX: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// While one is alive, [`read_file`] and [`write_file`] on this thread refuse every path: a
|
||||
/// stored script's steps run inside one (#2178), so no command reaches the file system from a
|
||||
/// script whatever its parameters are called. Dropping it (also on unwind) lifts the refusal.
|
||||
pub(crate) struct ScriptSandbox(());
|
||||
|
||||
impl ScriptSandbox {
|
||||
pub(crate) fn enter() -> Self {
|
||||
let _ = SCRIPT_SANDBOX.try_with(|c| c.set(c.get().saturating_add(1)));
|
||||
ScriptSandbox(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ScriptSandbox {
|
||||
fn drop(&mut self) {
|
||||
let _ = SCRIPT_SANDBOX.try_with(|c| c.set(c.get().saturating_sub(1)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Refuses file access while a [`ScriptSandbox`] is alive (and, failing closed, while the
|
||||
/// thread is being torn down).
|
||||
fn script_sandbox_check(path: &str) -> Result<()> {
|
||||
if SCRIPT_SANDBOX.try_with(|c| c.get() > 0).unwrap_or(true) {
|
||||
return Err(EngineError::Other(format!("{path}: scripts can't read or write files")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn read_file(path: &str) -> Result<Vec<u8>> {
|
||||
script_sandbox_check(path)?;
|
||||
let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
if meta.len() > 2 * 1024 * 1024 * 1024 {
|
||||
return Err(EngineError::Other("files larger than 2 GB aren't supported".into()));
|
||||
@@ -576,11 +609,13 @@ pub fn read_file(path: &str) -> Result<Vec<u8>> {
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn read_file(path: &str) -> Result<Vec<u8>> {
|
||||
script_sandbox_check(path)?;
|
||||
Err(EngineError::Other(format!("{path}: no file system in the browser")))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
script_sandbox_check(path)?;
|
||||
// Write to a temporary file, then rename: a crash never leaves a half-written workbook.
|
||||
let tmp = format!("{path}.sctmp");
|
||||
std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, path)).map_err(|e| {
|
||||
@@ -593,6 +628,7 @@ pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn write_file(path: &str, _bytes: &[u8]) -> Result<()> {
|
||||
script_sandbox_check(path)?;
|
||||
Err(EngineError::Other(format!("{path}: no file system in the browser")))
|
||||
}
|
||||
|
||||
@@ -911,6 +947,32 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// While a script runs (#2178), file reads and writes are refused whatever command or
|
||||
/// parameter asked for them; nesting and dropping the sandbox restore access.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn script_sandbox_refuses_file_access_until_dropped() {
|
||||
let dir = std::env::temp_dir().join(format!("gridcraft-sandbox-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let existing = dir.join("in.csv");
|
||||
std::fs::write(&existing, b"a,b\n").unwrap();
|
||||
let (existing, out) = (existing.to_string_lossy().to_string(), dir.join("out.xlsx").to_string_lossy().to_string());
|
||||
{
|
||||
let _outer = ScriptSandbox::enter();
|
||||
{
|
||||
let _inner = ScriptSandbox::enter();
|
||||
assert!(read_file(&existing).is_err());
|
||||
}
|
||||
assert!(read_file(&existing).is_err(), "the outer sandbox still holds");
|
||||
assert!(write_file(&out, b"leak").is_err());
|
||||
assert!(!std::path::Path::new(&out).exists());
|
||||
}
|
||||
assert_eq!(read_file(&existing).unwrap(), b"a,b\n");
|
||||
write_file(&out, b"ok").unwrap();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A minimal compound (CFB/OLE2) file — FAT in sector 0, directory in sector 1 — holding a
|
||||
/// root entry and the given stream names.
|
||||
fn cfb(streams: &[&str]) -> Vec<u8> {
|
||||
|
||||
+180
-27
@@ -104,6 +104,21 @@ struct OpenGroup {
|
||||
redo: Vec<HistoryEntry>,
|
||||
}
|
||||
|
||||
/// Where the last [`Session::group_end`] or [`Session::group_rollback`] left its document: the
|
||||
/// workbook and selection it ended at, the undo length then, and whether it made a step. A
|
||||
/// command that ended the group tells its own later changes from the group's step by it, and
|
||||
/// keeps the group's name only when the group's step is exactly its own (the entry holds the
|
||||
/// workbook the step started from). Dropped when the outermost command finishes, so it keeps no
|
||||
/// workbook alive between commands.
|
||||
#[derive(Debug)]
|
||||
struct GroupStep {
|
||||
doc: u64,
|
||||
after: Arc<Workbook>,
|
||||
selection: Selection,
|
||||
len: usize,
|
||||
stepped: bool,
|
||||
}
|
||||
|
||||
/// The clipboard: a block of cells (with formulas and styles) or marching-ants reference.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Clipboard {
|
||||
@@ -180,7 +195,15 @@ pub struct DocState {
|
||||
/// The format `path`'s content was recognised as when its extension doesn't name one (an
|
||||
/// XLSX called `budget.dat`): Save writes it back in that format.
|
||||
pub content_kind: Option<io::FileKind>,
|
||||
/// Whether the workbook's active content may run without asking.
|
||||
/// Whether the scripts stored in this workbook may run (`automate.runScript`). A workbook
|
||||
/// that arrives already carrying scripts (opened from a file) starts untrusted until the
|
||||
/// user trusts it in this session (`automate.trustScripts`); one with no scripts starts
|
||||
/// trusted, since any script it gains later was written here.
|
||||
pub scripts_trusted: bool,
|
||||
/// Where the workbook's content came from: whether its active content may run without
|
||||
/// asking. Unlike `scripts_trusted`, this is provenance, not consent: it is set when the
|
||||
/// workbook is opened from a file, carried by content pasted from such a workbook, and never
|
||||
/// changed by a trust decision.
|
||||
pub trust: Trust,
|
||||
/// While a macro's footprint is measured: every cell a command wrote, by sheet name, even
|
||||
/// where it wrote what was already there.
|
||||
@@ -210,6 +233,10 @@ fn history_retained_bytes(older: &Workbook, newer: &Workbook) -> usize {
|
||||
let next = newer.sheets.get(index).map_or(&empty, |next| &next.cells);
|
||||
bytes = bytes.saturating_add(sheet.cells.retained_bytes_against(next));
|
||||
}
|
||||
// Snapshots share the macro list until a change to it; a snapshot with its own list keeps it.
|
||||
if !older.macros.shares(&newer.macros) {
|
||||
bytes = bytes.saturating_add(older.macros.estimated_bytes());
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
@@ -249,6 +276,7 @@ impl DocState {
|
||||
pub fn new(mut wb: Workbook, path: Option<String>, title: String) -> DocState {
|
||||
let mut calc = Calc::new();
|
||||
calc.recalc_all(&mut wb);
|
||||
let scripts_trusted = !cmd::extra::has_scripts(&wb);
|
||||
let wb = Arc::new(wb);
|
||||
let sel = Selection::at(wb.active().map(|s| s.view_active).unwrap_or_default());
|
||||
DocState {
|
||||
@@ -265,6 +293,7 @@ impl DocState {
|
||||
sheet_selections: vec![],
|
||||
unlocked_ranges: vec![],
|
||||
content_kind: None,
|
||||
scripts_trusted,
|
||||
trust: Trust::Local,
|
||||
touched: None,
|
||||
}
|
||||
@@ -376,6 +405,9 @@ pub struct Session {
|
||||
/// Serialized size of each journal entry, measured once when it is pushed, and their sum.
|
||||
journal_sizes: Vec<usize>,
|
||||
journal_bytes: usize,
|
||||
/// Entries the journal has dropped from its front to stay within its limits, ever; a dry run
|
||||
/// that puts the journal back needs to know how many went while it ran.
|
||||
journal_dropped: u64,
|
||||
pub ui_requests: Vec<UiRequest>,
|
||||
/// Format painter source style (sticky when double-clicked).
|
||||
pub format_painter: Option<(Arc<Workbook>, usize, RangeRef, bool)>,
|
||||
@@ -411,8 +443,12 @@ pub struct Session {
|
||||
next_group: u64,
|
||||
/// Counts group begins and ends, so `execute` can tell that a command opened or closed one.
|
||||
group_epoch: u64,
|
||||
/// The step the last group made: document, its undo length after the step, and the epoch.
|
||||
group_step: Option<(u64, usize, u64)>,
|
||||
/// Where the last group ended, so `execute` can tell a command's own changes from its group's.
|
||||
group_step: Option<GroupStep>,
|
||||
/// For each running command, innermost last: its document and that document's undo length
|
||||
/// when it began. Steps before that length are not touched while the command runs (see
|
||||
/// [`Session::bound_tail`]).
|
||||
marks: Vec<(u64, usize)>,
|
||||
/// Measures cell text for AutoFit Column Width. Without it (CLI, MCP) AutoFit uses
|
||||
/// [`cmd::format::approx_text_width`].
|
||||
pub text_width: Option<TextWidth>,
|
||||
@@ -502,10 +538,26 @@ impl Session {
|
||||
self.journal.drain(..n);
|
||||
self.journal_sizes.drain(..n);
|
||||
self.journal_bytes = kept;
|
||||
self.journal_dropped = self.journal_dropped.saturating_add(n as u64);
|
||||
self.recording = self.recording.map(|r| r.saturating_sub(n));
|
||||
}
|
||||
}
|
||||
|
||||
/// Entries dropped from the journal's front so far, for a caller that puts the journal back.
|
||||
pub(crate) fn journal_dropped(&self) -> u64 {
|
||||
self.journal_dropped
|
||||
}
|
||||
|
||||
/// Cuts the journal back to its first `keep` entries, with its size bookkeeping.
|
||||
pub(crate) fn truncate_journal(&mut self, keep: usize) {
|
||||
if self.journal_sizes.len() == self.journal.len() {
|
||||
self.journal_sizes.truncate(keep);
|
||||
self.journal_bytes = self.journal_sizes.iter().sum();
|
||||
}
|
||||
self.journal.truncate(keep);
|
||||
self.recording = self.recording.map(|r| r.min(self.journal.len()));
|
||||
}
|
||||
|
||||
pub fn commands(&self) -> Vec<CommandInfo> {
|
||||
command_specs().iter().map(|c| c.info(self)).collect()
|
||||
}
|
||||
@@ -534,31 +586,65 @@ impl Session {
|
||||
let (first_group, group_epoch) = (self.next_group, self.group_epoch);
|
||||
// Whether a group was open on the document before the command, and how long its history was.
|
||||
let start = before.as_ref().and_then(|(uid, ..)| self.docs.iter().find(|d| d.uid == *uid).map(|d| (self.in_group(*uid), d.undo.len())));
|
||||
// A command that panics leaves the history as it found it: the steps commands nested in it
|
||||
// made or undid, and the redo history they cleared, come back. A length is not enough,
|
||||
// because a nested `edit.undo` removes entries.
|
||||
let history_before = before.as_ref().and_then(|(uid, ..)| self.docs.iter().find(|d| d.uid == *uid).map(|d| (d.undo.clone(), d.redo.clone())));
|
||||
// The groups open on the document before the command (usually none), for the same reason.
|
||||
let groups_before: Vec<(u64, String, Arc<Workbook>, Selection)> = match &before {
|
||||
Some((uid, ..)) => {
|
||||
self.groups.iter().filter(|g| g.doc == *uid).map(|g| (g.id, g.label.clone(), g.wb.clone(), g.selection.clone())).collect()
|
||||
}
|
||||
None => Vec::new(),
|
||||
};
|
||||
// The recorder keeps top-level calls with the place they ran in.
|
||||
let place = (self.depth == 0 && self.recorder.is_some()).then(|| match self.active() {
|
||||
Some(d) => (d.uid, d.wb.active_sheet, d.wb.active().map(|sh| sh.name.clone()).unwrap_or_default(), d.selection.clone()),
|
||||
None => (0, 0, String::new(), Selection::default()),
|
||||
});
|
||||
let doc_uid = before.as_ref().map(|(uid, ..)| *uid);
|
||||
let marked = doc_uid.zip(start).map(|(uid, (_, len))| (uid, len));
|
||||
self.marks.extend(marked);
|
||||
self.depth = self.depth.saturating_add(1);
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, ¶ms)));
|
||||
self.depth = self.depth.saturating_sub(1);
|
||||
if marked.is_some() {
|
||||
self.marks.pop();
|
||||
}
|
||||
let result = match result {
|
||||
Ok(r) => r,
|
||||
Err(p) => {
|
||||
let msg =
|
||||
p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into());
|
||||
// Restore the workbook as it was, and drop the undo groups the command opened.
|
||||
// Restore the workbook and its history as they were, and drop the undo groups the
|
||||
// command opened.
|
||||
self.groups.retain(|g| g.id < first_group);
|
||||
let group_open = before.as_ref().is_some_and(|(uid, ..)| self.groups.iter().any(|g| g.doc == *uid));
|
||||
if let Some((uid, wb, sel)) = &before
|
||||
&& let Some(d) = self.docs.iter_mut().find(|d| d.uid == *uid)
|
||||
{
|
||||
d.wb = wb.clone();
|
||||
d.selection = sel.clone();
|
||||
if let Some((undo, redo)) = history_before {
|
||||
d.undo = undo;
|
||||
d.redo = redo;
|
||||
}
|
||||
// A group open before the command and ended or rolled back inside it stays
|
||||
// ended (its token went with the command), and keeps a step from its start to
|
||||
// the restored workbook, so Undo still returns to the state before the group.
|
||||
if !group_open
|
||||
&& let Some((_, label, wb, selection)) = groups_before.into_iter().min_by_key(|g| g.0)
|
||||
&& !Arc::ptr_eq(&wb, &d.wb)
|
||||
{
|
||||
push_entry(&mut d.undo, label, wb, &d.wb, selection);
|
||||
d.redo.clear();
|
||||
}
|
||||
d.calc.rebuild(&d.wb);
|
||||
}
|
||||
macros::debug::recover(self);
|
||||
if self.depth == 0 {
|
||||
self.trim_history();
|
||||
self.group_step = None;
|
||||
}
|
||||
return Err(EngineError::Internal(id.to_string(), msg));
|
||||
}
|
||||
@@ -573,28 +659,53 @@ impl Session {
|
||||
}
|
||||
self.push_journal(id, cmd::journal_params(¶ms));
|
||||
}
|
||||
// Inside an undo group, or when the command ended one that was open before it, the group
|
||||
// owns the step. A command that opened and closed its own groups makes one step of its
|
||||
// own, which replaces the steps those groups made.
|
||||
// Inside an undo group the group owns the step. A command that ended a group open before
|
||||
// it leaves the group its step and makes one of its own for what it changed after the end.
|
||||
// A command that opened and closed its own groups makes one step of its own, which
|
||||
// replaces the steps those groups made.
|
||||
let epoch_changed = self.group_epoch != group_epoch;
|
||||
let in_group_now = before.as_ref().is_some_and(|(uid, ..)| self.in_group(*uid));
|
||||
let own_groups = epoch_changed && !in_group_now && start.is_some_and(|(was_open, _)| !was_open);
|
||||
let grouped = !own_groups && (epoch_changed || in_group_now);
|
||||
let was_open = start.is_some_and(|(was_open, _)| was_open);
|
||||
if spec.undoable
|
||||
&& let Some((uid, wb, sel)) = before
|
||||
&& let Some(d) = self.docs.iter_mut().find(|d| d.uid == uid)
|
||||
&& !Arc::ptr_eq(&d.wb, &wb)
|
||||
{
|
||||
if !grouped {
|
||||
let mut label = spec.label.trim_end_matches('…').to_string();
|
||||
if own_groups && let Some((_, len)) = start {
|
||||
let label = spec.label.trim_end_matches('…').to_string();
|
||||
if in_group_now {
|
||||
// A group this command opened and left open (a debugger's start) takes the
|
||||
// command's start as its own, so what the command changed before opening it is
|
||||
// part of the group's step and not a step of its own.
|
||||
if !was_open
|
||||
&& let Some((_, len)) = start
|
||||
&& let Some(g) = self.groups.iter_mut().filter(|g| g.doc == uid && g.id >= first_group).min_by_key(|g| g.id)
|
||||
{
|
||||
g.start = len;
|
||||
g.wb = wb;
|
||||
g.selection = sel;
|
||||
}
|
||||
} else if epoch_changed && was_open {
|
||||
// The group it ended made its step; changes after the end, whether direct or by
|
||||
// nested commands, are the command's own step from where the group ended.
|
||||
if let Some(step) = self.group_step.as_ref().filter(|g| g.doc == uid)
|
||||
&& (d.undo.len() > step.len || !Arc::ptr_eq(&step.after, &d.wb))
|
||||
{
|
||||
d.undo.truncate(step.len);
|
||||
push_entry(&mut d.undo, label, step.after.clone(), &d.wb, step.selection.clone());
|
||||
d.redo.clear();
|
||||
}
|
||||
} else {
|
||||
let mut label = label;
|
||||
if epoch_changed && let Some((_, len)) = start {
|
||||
// A command whose only step is its group's keeps that name ("Run Macro: Totals").
|
||||
// The step must have been made by a group this command ended (its epoch is
|
||||
// past the one the command started with): an older record at the same length
|
||||
// belongs to an earlier command, undone since.
|
||||
// That is so only when the one entry the command added is a group's step from
|
||||
// the command's own start to its own result: an entry a nested command made,
|
||||
// a group step of an earlier command (undone since), or a group's step that
|
||||
// the command edited past all name the step after the command instead.
|
||||
if d.undo.len() == len + 1
|
||||
&& self.group_step.is_some_and(|(doc, n, epoch)| doc == uid && n == len + 1 && epoch > group_epoch)
|
||||
&& let Some(last) = d.undo.last()
|
||||
&& Arc::ptr_eq(&last.wb, &wb)
|
||||
&& self.group_step.as_ref().is_some_and(|g| g.doc == uid && g.stepped && Arc::ptr_eq(&g.after, &d.wb))
|
||||
{
|
||||
label = last.label.clone();
|
||||
}
|
||||
@@ -608,9 +719,12 @@ impl Session {
|
||||
d.revision += 1;
|
||||
}
|
||||
// The history limit applies once the outermost command has collapsed its steps, so the
|
||||
// lengths it compared stay true.
|
||||
// lengths it compared stay true. Meanwhile the steps made inside it are bounded on their own.
|
||||
if self.depth == 0 {
|
||||
self.trim_history();
|
||||
self.group_step = None;
|
||||
} else if let Some(uid) = doc_uid {
|
||||
self.bound_tail(uid);
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -620,17 +734,27 @@ impl Session {
|
||||
/// is rolled back and its error is returned. Groups nest; the outermost one makes the step.
|
||||
pub fn grouped<R>(&mut self, label: &str, f: impl FnOnce(&mut Session) -> Result<R>) -> Result<R> {
|
||||
let token = self.group_begin(label)?;
|
||||
match f(self) {
|
||||
Ok(r) => {
|
||||
self.group_end(token)?;
|
||||
Ok(r)
|
||||
}
|
||||
Err(e) => {
|
||||
let doc = token.doc();
|
||||
// A panic in `f` rolls the group back before it goes on, so a caller that catches it
|
||||
// (outside `execute`, which drops a command's groups itself) finds no group left open.
|
||||
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(self))) {
|
||||
Ok(Ok(r)) => match self.group_end(token) {
|
||||
Ok(_) => Ok(r),
|
||||
// `f` closed the group's document (`file.close`, or `file.open` taking the place of
|
||||
// an untouched new workbook), and the group went with it: nothing is left to undo.
|
||||
Err(_) if !self.docs.iter().any(|d| d.uid == doc) => Ok(r),
|
||||
Err(e) => Err(e),
|
||||
},
|
||||
Ok(Err(e)) => {
|
||||
// The error from `f` is the one to report. A rollback fails only when the group
|
||||
// has already ended, and then there is nothing left to undo.
|
||||
let _ = self.group_rollback(token);
|
||||
Err(e)
|
||||
}
|
||||
Err(panic) => {
|
||||
let _ = self.group_rollback(token);
|
||||
std::panic::resume_unwind(panic)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -668,10 +792,13 @@ impl Session {
|
||||
let changed = !Arc::ptr_eq(&d.wb, &g.wb);
|
||||
if changed && !outer {
|
||||
push_entry(&mut d.undo, g.label, g.wb, &d.wb, g.selection);
|
||||
self.group_step = Some((g.doc, d.undo.len(), self.group_epoch));
|
||||
d.redo.clear();
|
||||
d.revision += 1;
|
||||
}
|
||||
if !outer {
|
||||
self.group_step =
|
||||
Some(GroupStep { doc: g.doc, after: d.wb.clone(), selection: d.selection.clone(), len: d.undo.len(), stepped: changed });
|
||||
}
|
||||
// Inside a command, the outermost command applies the history limit when it finishes.
|
||||
if self.depth == 0 {
|
||||
self.trim_history();
|
||||
@@ -679,6 +806,23 @@ impl Session {
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
/// Bounds the undo steps pushed while a command runs (a script of many steps, a subtotal per
|
||||
/// group): at most [`UNDO_LIMIT`] of them within [`UNDO_BYTES_LIMIT`], dropping the oldest.
|
||||
/// Only steps after the latest mark go: a running command's start, an open group's start and
|
||||
/// the end of the last group are positions the command or group collapses its steps by when
|
||||
/// it finishes, so what is before them stays as it is until [`Session::trim_history`].
|
||||
fn bound_tail(&mut self, uid: u64) {
|
||||
let Some(d) = self.docs.iter_mut().find(|d| d.uid == uid) else { return };
|
||||
let marks = self.marks.iter().filter(|(doc, _)| *doc == uid).map(|(_, len)| *len);
|
||||
let groups = self.groups.iter().filter(|g| g.doc == uid).map(|g| g.start);
|
||||
let step = self.group_step.as_ref().filter(|g| g.doc == uid).map(|g| g.len);
|
||||
let mark = marks.chain(groups).chain(step).max().unwrap_or(0).min(d.undo.len());
|
||||
let mut bytes = d.undo.iter().skip(mark).fold(0usize, |sum, e| sum.saturating_add(e.retained_bytes));
|
||||
while d.undo.len() > mark + 1 && (d.undo.len() - mark > UNDO_LIMIT || bytes > UNDO_BYTES_LIMIT) {
|
||||
bytes = bytes.saturating_sub(d.undo.remove(mark).retained_bytes);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drops the oldest undo steps past [`UNDO_LIMIT`] or [`UNDO_BYTES_LIMIT`]. A document with
|
||||
/// an open group waits for the group to end, because the group remembers where its steps start.
|
||||
fn trim_history(&mut self) {
|
||||
@@ -701,9 +845,11 @@ impl Session {
|
||||
d.undo.truncate(g.start);
|
||||
d.redo = g.redo;
|
||||
d.revision += 1;
|
||||
// A step the rollback dropped is no longer anyone's to name.
|
||||
if self.group_step.is_some_and(|(doc, n, _)| doc == g.doc && n > g.start) {
|
||||
self.group_step = None;
|
||||
// The record of the last group step stays when the document is back exactly where that
|
||||
// step left it (this group began there); otherwise the rollback is where the group ended.
|
||||
let kept = self.group_step.as_ref().is_some_and(|st| st.doc == g.doc && st.len == g.start && Arc::ptr_eq(&st.after, &d.wb));
|
||||
if !kept && !self.groups.iter().any(|o| o.doc == g.doc) {
|
||||
self.group_step = Some(GroupStep { doc: g.doc, after: d.wb.clone(), selection: d.selection.clone(), len: g.start, stepped: false });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -758,10 +904,17 @@ impl Session {
|
||||
/// queued a file dialog because it got no file (`file.open`, `file.saveAs`, `file.save` on a workbook with no
|
||||
/// file yet, `data.getData`) is an error instead of a silent success.
|
||||
pub fn execute_headless(&mut self, id: &str, params: Json) -> Result<Json> {
|
||||
let recorded = self.recorder.as_ref().map(|r| (r.steps.len(), r.dropped));
|
||||
let r = self.execute(id, params);
|
||||
let requests = self.take_ui_requests();
|
||||
let asked_for_file = requests.iter().any(|q| matches!(q, UiRequest::Dialog(name, _) if name == "open" || name == "saveAs"));
|
||||
if r.is_ok() && asked_for_file {
|
||||
// The caller gets an error, so the recording keeps no step for the command.
|
||||
if let Some((steps, dropped)) = recorded
|
||||
&& let Some(rec) = self.recorder.as_mut()
|
||||
{
|
||||
rec.truncate(steps, dropped);
|
||||
}
|
||||
let what = match id {
|
||||
"file.open" => "`path` or `base64`",
|
||||
"data.getData" | "data.fromTextCsv" => "`path` or `text`",
|
||||
|
||||
@@ -40,11 +40,18 @@ pub fn parse_macro(v: &Json) -> Result<Macro, String> {
|
||||
}
|
||||
let mut m: Macro = serde_json::from_value(v).map_err(|e| format!("This isn't a valid macro: {e}"))?;
|
||||
m.name = m.name.trim().to_string();
|
||||
m.check_limits()?;
|
||||
check_size(&m)?;
|
||||
check(&m)?;
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
/// Every check a macro must pass before it is kept in a workbook: the model's limits
|
||||
/// ([`Macro::check_limits`]) and the size an xlsx file can hold. Every path that adds a macro,
|
||||
/// from JSON, from a file or built in code, goes through this one check.
|
||||
pub fn check(m: &Macro) -> Result<(), String> {
|
||||
m.check_limits()?;
|
||||
check_size(m)
|
||||
}
|
||||
|
||||
/// Refuses a macro too large to keep in an xlsx file ([`stash::MAX_ITEM_CHARS`] characters of
|
||||
/// JSON), so saving a workbook never fails because of one of its macros.
|
||||
fn check_size(m: &Macro) -> Result<(), String> {
|
||||
@@ -58,9 +65,6 @@ fn check_size(m: &Macro) -> Result<(), String> {
|
||||
/// before the steps are deserialized.
|
||||
fn normalize_steps(steps: &mut Json, level: usize) -> Result<(), String> {
|
||||
let Some(list) = steps.as_array_mut() else { return Err("`steps` must be a list.".into()) };
|
||||
if level > gridcraft_model::MAX_MACRO_DEPTH {
|
||||
return Err(format!("Repeat and If steps can nest at most {} deep.", gridcraft_model::MAX_MACRO_DEPTH));
|
||||
}
|
||||
for st in list {
|
||||
let Some(o) = st.as_object_mut() else { return Err("Every step must be an object.".into()) };
|
||||
if !o.contains_key("type")
|
||||
@@ -69,6 +73,11 @@ fn normalize_steps(steps: &mut Json, level: usize) -> Result<(), String> {
|
||||
o.insert("type".into(), json!("command"));
|
||||
o.insert("id".into(), id);
|
||||
}
|
||||
// A step with a body is a Repeat or an If: one at the top is nested 1 deep.
|
||||
let nested = ["body", "then", "else"].iter().any(|key| o.contains_key(*key));
|
||||
if nested && level > gridcraft_model::MAX_MACRO_DEPTH {
|
||||
return Err(format!("Repeat and If steps can nest at most {} deep.", gridcraft_model::MAX_MACRO_DEPTH));
|
||||
}
|
||||
for key in ["body", "then", "else"] {
|
||||
if let Some(body) = o.get_mut(key) {
|
||||
normalize_steps(body, level + 1)?;
|
||||
@@ -99,17 +108,18 @@ pub fn fingerprint(m: &Macro) -> String {
|
||||
/// A copy of `wb` for xlsx with each macro in hidden names (`_gc_macro_<n>_<k>`) instead of the
|
||||
/// field, or `None` when there is nothing to change.
|
||||
pub fn with_names(wb: &Workbook) -> crate::Result<Option<Workbook>> {
|
||||
let stale = !stash::keys(wb, MACRO_KEY).is_empty();
|
||||
if wb.macros.is_empty() && !stale {
|
||||
// Only the numbered items are this build's: a hidden `_gc_macro_<x>_<k>` name with some
|
||||
// other key stays, as `load` leaves it.
|
||||
let stored = |n: &DefinedName| macro_item(n).is_some_and(|(_, i)| i.is_some());
|
||||
if wb.macros.is_empty() && !wb.names.iter().any(stored) {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut out = wb.clone();
|
||||
for key in stash::keys(&out, MACRO_KEY) {
|
||||
stash::remove(&mut out, &key);
|
||||
}
|
||||
for (i, m) in wb.macros.iter().enumerate() {
|
||||
stash::set(&mut out, &format!("{MACRO_KEY}{i}"), ¯o_json(m).to_string())?;
|
||||
}
|
||||
// One pass drops the items of the last save, and one writes the macros (a file can hold a
|
||||
// thousand macros and many thousands of names).
|
||||
out.names.retain(|n| !stored(n));
|
||||
let items: Vec<(String, String)> = wb.macros.iter().enumerate().map(|(i, m)| (format!("{MACRO_KEY}{i}"), macro_json(m).to_string())).collect();
|
||||
stash::set_many(&mut out, &items)?;
|
||||
out.macros.clear();
|
||||
Ok(Some(out))
|
||||
}
|
||||
@@ -135,8 +145,9 @@ pub fn load(wb: &mut Workbook) -> Vec<String> {
|
||||
match macro_item(&n) {
|
||||
None => kept.push(n),
|
||||
Some((key, Some(i))) => stored.entry((i, key)).or_default().push(n),
|
||||
// A `macro_` item that isn't numbered isn't one this build wrote; it goes unread.
|
||||
Some((_, None)) => {}
|
||||
// A `macro_` item that isn't numbered isn't one this build wrote: it stays as it is,
|
||||
// unread, and a save keeps it.
|
||||
Some((_, None)) => kept.push(n),
|
||||
}
|
||||
}
|
||||
wb.names = kept;
|
||||
@@ -164,7 +175,7 @@ pub fn load(wb: &mut Workbook) -> Vec<String> {
|
||||
let checked = m.check_limits().and_then(|()| {
|
||||
m.name = taken.unique(m.name.trim());
|
||||
// The macro as it is kept, renamed, must still pass, or the next save would lose it.
|
||||
m.check_limits().and_then(|()| check_size(&m))
|
||||
check(&m)
|
||||
});
|
||||
match checked {
|
||||
Ok(()) => {
|
||||
@@ -196,8 +207,9 @@ fn macro_item(n: &DefinedName) -> Option<(String, Option<u64>)> {
|
||||
Some((key, i))
|
||||
}
|
||||
|
||||
/// `name`, or `name (2)`, `name (3)`… if a macro already has it. A long name is shortened to make
|
||||
/// room for the suffix, so the result stays within [`gridcraft_model::MAX_MACRO_NAME`] characters.
|
||||
/// `name`, or `name (2)`, `name (3)`… if a macro already has it. A free name comes back as it is;
|
||||
/// a renamed one is shortened to make room for the suffix, so it stays within
|
||||
/// [`gridcraft_model::MAX_MACRO_NAME`] characters. The result still goes through [`check`].
|
||||
pub fn unique_name(wb: &Workbook, name: &str) -> String {
|
||||
let mut taken = Taken::default();
|
||||
for m in &wb.macros {
|
||||
|
||||
@@ -109,6 +109,19 @@ pub fn is_ref_key(key: &str) -> bool {
|
||||
REF_KEYS.iter().any(|r| r.eq_ignore_ascii_case(key)) || ["cell", "cells", "range", "ranges", "ref"].iter().any(|end| k.ends_with(end))
|
||||
}
|
||||
|
||||
/// Reference keys that other commands use for plain words: `to` is a replacement word in
|
||||
/// Review › Change Spelling, `area` is `row` or `column` in a pivot field move. Under these a
|
||||
/// bare `Row` or `Column` stays text; `Row.col(2)`, `{{Row}}` or `{"$ref": "Row"}` is the token.
|
||||
fn is_general_key(key: &str) -> bool {
|
||||
["area", "areas", "at", "destination", "from", "location", "source", "sources", "target", "to"].iter().any(|k| k.eq_ignore_ascii_case(key))
|
||||
}
|
||||
|
||||
/// `Row` or `Column` on its own, with no method or sheet after it.
|
||||
fn is_bare_item_word(text: &str) -> bool {
|
||||
let t = text.trim();
|
||||
t.eq_ignore_ascii_case("row") || t.eq_ignore_ascii_case("column")
|
||||
}
|
||||
|
||||
/// The most nesting [`resolve_params`] walks into.
|
||||
const MAX_JSON_DEPTH: usize = 64;
|
||||
|
||||
@@ -159,13 +172,15 @@ fn plain_ref(t: &str, env: &Env) -> R<Place> {
|
||||
/// A defined name (by its formula) or a table (its data rows).
|
||||
fn named(name: &str, env: &Env) -> R<Place> {
|
||||
if let Some(n) = env.wb.name(name, env.active_sheet) {
|
||||
let f = n.formula.trim().trim_start_matches('=').replace('$', "");
|
||||
let (sheet, body) = crate::cmd::split_sheet(&f);
|
||||
// The sheet part keeps its characters (a sheet may be called `US$`); only the reference
|
||||
// loses its `$` anchors.
|
||||
let f = n.formula.trim().trim_start_matches('=');
|
||||
let (sheet, body) = crate::cmd::split_sheet(f);
|
||||
let si = match sheet {
|
||||
Some(s) => env.wb.sheet_index(&s),
|
||||
None => n.scope.or(Some(env.active_sheet)),
|
||||
};
|
||||
if let (Some(sheet), Some(range)) = (si, RangeRef::parse(body)) {
|
||||
if let (Some(sheet), Some(range)) = (si, RangeRef::parse(&body.replace('$', ""))) {
|
||||
return Ok(Place { sheet, range });
|
||||
}
|
||||
return bad(format!("The name '{name}' doesn't refer to a range."));
|
||||
@@ -173,7 +188,12 @@ fn named(name: &str, env: &Env) -> R<Place> {
|
||||
if let Some((si, ti)) = env.wb.table(name)
|
||||
&& let Some(t) = env.wb.sheet(si).and_then(|sh| sh.tables.get(ti))
|
||||
{
|
||||
return Ok(Place { sheet: si, range: t.data_range().unwrap_or(t.range) });
|
||||
// A table stands for its data rows; one with none (a header, perhaps a totals row) has
|
||||
// nothing to give.
|
||||
let Some(range) = t.data_range() else {
|
||||
return bad(format!("The table '{name}' has no data rows."));
|
||||
};
|
||||
return Ok(Place { sheet: si, range });
|
||||
}
|
||||
bad(format!("There is no name or table called '{name}'."))
|
||||
}
|
||||
@@ -244,19 +264,23 @@ pub fn resolve_token(text: &str, env: &Env) -> R<Place> {
|
||||
RangeRef::new(r.start, shift(r.start, h - 1, w - 1, t)?)
|
||||
}
|
||||
("data", []) => {
|
||||
if header && r.height() > 1 {
|
||||
if header {
|
||||
// A header row with nothing under it has no data rows to give.
|
||||
if r.height() < 2 {
|
||||
return bad(format!("'{t}': the range is only a header row, with no data under it."));
|
||||
}
|
||||
RangeRef::new(CellRef::new(r.start.row + 1, r.start.col), r.end)
|
||||
} else {
|
||||
r
|
||||
}
|
||||
}
|
||||
("below", []) | ("below", [Arg::Int(_)]) => {
|
||||
let n = arg_or_one(&args);
|
||||
let n = count_arg(&args, t)?;
|
||||
let top = shift(CellRef::new(r.end.row, r.start.col), 1, 0, t)?;
|
||||
RangeRef::new(top, shift(CellRef::new(r.end.row, r.end.col), n, 0, t)?)
|
||||
}
|
||||
("right", []) | ("right", [Arg::Int(_)]) => {
|
||||
let n = arg_or_one(&args);
|
||||
let n = count_arg(&args, t)?;
|
||||
let left = shift(CellRef::new(r.start.row, r.end.col), 0, 1, t)?;
|
||||
RangeRef::new(left, shift(r.end, 0, n, t)?)
|
||||
}
|
||||
@@ -279,10 +303,12 @@ pub fn resolve_token(text: &str, env: &Env) -> R<Place> {
|
||||
Ok(place)
|
||||
}
|
||||
|
||||
fn arg_or_one(args: &[Arg]) -> i64 {
|
||||
/// The count `below(n)` and `right(n)` take: 1 when left out, and never less than 1.
|
||||
fn count_arg(args: &[Arg], t: &str) -> R<i64> {
|
||||
match args.first() {
|
||||
Some(Arg::Int(n)) => (*n).max(1),
|
||||
_ => 1,
|
||||
Some(Arg::Int(n)) if *n < 1 => bad(format!("'{t}': the count must be at least 1.")),
|
||||
Some(Arg::Int(n)) => Ok(*n),
|
||||
_ => Ok(1),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -453,11 +479,9 @@ pub fn a1(place: Place, env: &Env) -> String {
|
||||
|
||||
/// `Sheet1` or `'My Sheet'` (quotes doubled).
|
||||
pub fn quote_sheet(name: &str) -> String {
|
||||
if !name.is_empty() && name.chars().all(|c| c.is_alphanumeric() || c == '_') && !name.starts_with(|c: char| c.is_ascii_digit()) {
|
||||
name.to_string()
|
||||
} else {
|
||||
format!("'{}'", name.replace('\'', "''"))
|
||||
}
|
||||
// The engine's one rule: a name that reads as a cell reference, an R1C1 token or a boolean
|
||||
// is quoted too, or `Q1!A1` would be read as a reference to cell Q1.
|
||||
gridcraft_core::quote_sheet(name)
|
||||
}
|
||||
|
||||
/// Step parameters with every token replaced by A1 text.
|
||||
@@ -485,7 +509,7 @@ fn walk(v: &Json, key: Option<&str>, env: &Env, depth: usize) -> R<Json> {
|
||||
// Items of a list keep their key's kind (`ranges: ["Input", …]`).
|
||||
Json::Array(a) => Json::Array(a.iter().map(|x| walk(x, key, env, depth + 1)).collect::<R<Vec<_>>>()?),
|
||||
Json::String(s) => {
|
||||
if key.is_some_and(is_ref_key) && is_token(s, env) {
|
||||
if key.is_some_and(|k| is_ref_key(k) && !(is_general_key(k) && is_bare_item_word(s))) && is_token(s, env) {
|
||||
Json::String(a1(resolve_token(s, env)?, env))
|
||||
} else {
|
||||
Json::String(interpolate(s, env)?)
|
||||
@@ -495,7 +519,8 @@ fn walk(v: &Json, key: Option<&str>, env: &Env, depth: usize) -> R<Json> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Replaces each `{{token}}` in `s`.
|
||||
/// Replaces each `{{token}}` in `s`. `{{{{` is a literal `{{`, for text that needs the braces
|
||||
/// themselves (`"Dear {{{{name}}"` writes `Dear {{name}}`).
|
||||
pub fn interpolate(s: &str, env: &Env) -> R<String> {
|
||||
if !s.contains("{{") {
|
||||
return Ok(s.to_string());
|
||||
@@ -504,6 +529,11 @@ pub fn interpolate(s: &str, env: &Env) -> R<String> {
|
||||
let mut rest = s;
|
||||
while let Some(i) = rest.find("{{") {
|
||||
out.push_str(rest.get(..i).unwrap_or(""));
|
||||
if rest.get(i..i + 4) == Some("{{{{") {
|
||||
out.push_str("{{");
|
||||
rest = rest.get(i + 4..).unwrap_or("");
|
||||
continue;
|
||||
}
|
||||
let after = rest.get(i + 2..).unwrap_or("");
|
||||
let Some(j) = after.find("}}") else {
|
||||
out.push_str(rest.get(i..).unwrap_or(""));
|
||||
@@ -793,8 +823,107 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn sheet_names_are_quoted_when_needed() {
|
||||
for (name, want) in [("Sheet1", "Sheet1"), ("My Data", "'My Data'"), ("O'Hara", "'O''Hara'"), ("2024", "'2024'"), ("", "''")] {
|
||||
assert_eq!(quote_sheet(name), want);
|
||||
// The engine's rule: a name that reads as a cell reference, an R1C1 token or a boolean
|
||||
// is quoted too, and a period on its own is not a reason to quote.
|
||||
for (name, want) in [
|
||||
("Sheet1", "Sheet1"),
|
||||
("My Data", "'My Data'"),
|
||||
("O'Hara", "'O''Hara'"),
|
||||
("2024", "'2024'"),
|
||||
("", "''"),
|
||||
("Q1", "'Q1'"),
|
||||
("R1C1", "'R1C1'"),
|
||||
("TRUE", "'TRUE'"),
|
||||
("Sales.Q1", "Sales.Q1"),
|
||||
] {
|
||||
assert_eq!(quote_sheet(name), want, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bare_row_or_column_under_a_general_key_is_text() {
|
||||
// `to` is a replacement word in Change Spelling and `area` is `row` or `column` in a
|
||||
// pivot field move, so a bare `Row` or `Column` there stays text, inside a Repeat or not;
|
||||
// the token forms still resolve there, and the bare word resolves under `range`.
|
||||
let wb = wb();
|
||||
let inputs = vec![("Input".to_string(), place("B2:D11"), true)];
|
||||
for item in [None, Some((RepeatOf::Rows, place("B5:D5")))] {
|
||||
let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: Some(place("F2")), item };
|
||||
let p = json!({"word": "Colum", "to": "Column", "area": "row", "from": "Input", "at": "Output"});
|
||||
let r = resolve_params(&p, &env).unwrap();
|
||||
assert_eq!(r, json!({"word": "Colum", "to": "Column", "area": "row", "from": "B2:D11", "at": "F2"}), "{item:?}");
|
||||
}
|
||||
let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: None, item: Some((RepeatOf::Rows, place("B5:D5"))) };
|
||||
let p = json!({"to": "Row.col(2)", "range": "Row", "area": {"$ref": "Row"}, "text": "{{Row}}"});
|
||||
let r = resolve_params(&p, &env).unwrap();
|
||||
assert_eq!(r, json!({"to": "C5", "range": "B5:D5", "area": "B5:D5", "text": "B5:D5"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_header_row_alone_and_a_table_without_data_rows_are_refused() {
|
||||
let mut wb = wb();
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
sh.tables.push(gridcraft_model::Table {
|
||||
id: 1,
|
||||
name: "Empty".into(),
|
||||
range: RangeRef::parse("H1:J1").unwrap(),
|
||||
header_row: true,
|
||||
totals_row: false,
|
||||
columns: Vec::new(),
|
||||
style: String::new(),
|
||||
banded_rows: false,
|
||||
banded_cols: false,
|
||||
first_col: false,
|
||||
last_col: false,
|
||||
filter_button: false,
|
||||
filter: Vec::new(),
|
||||
table_type: None,
|
||||
connection_id: None,
|
||||
});
|
||||
let inputs = vec![("Input".to_string(), place("B2:D2"), true), ("Plain".to_string(), place("B2:D2"), false)];
|
||||
let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: None, item: None };
|
||||
assert!(resolve_ref("Input.data", &env).unwrap_err().to_string().contains("only a header row"));
|
||||
assert!(resolve_ref("Input.data.col(2)", &env).unwrap_err().to_string().contains("only a header row"));
|
||||
check(&env, "Plain.data", "B2:D2");
|
||||
check(&env, "Input.col(2)", "C2");
|
||||
assert!(resolve_ref("Name:Empty", &env).unwrap_err().to_string().contains("no data rows"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doubled_braces_are_literal_braces() {
|
||||
let wb = wb();
|
||||
let inputs = vec![("Input".to_string(), place("B2:D11"), true)];
|
||||
let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: None, item: None };
|
||||
assert_eq!(interpolate("Dear {{{{name}},", &env).unwrap(), "Dear {{name}},");
|
||||
assert_eq!(interpolate("{{{{ {{Input.cell(1,1)}} }}", &env).unwrap(), "{{ B2 }}");
|
||||
assert!(interpolate("Dear {{name}},", &env).is_err(), "an unknown token is still an error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_defined_name_on_a_sheet_with_a_dollar_sign_resolves() {
|
||||
let mut wb = wb();
|
||||
wb.sheets.push(std::sync::Arc::new(gridcraft_model::Sheet::new("US$")));
|
||||
wb.names.push(gridcraft_model::DefinedName {
|
||||
name: "Dollars".into(),
|
||||
scope: None,
|
||||
formula: "'US$'!$F$20".into(),
|
||||
comment: String::new(),
|
||||
hidden: false,
|
||||
});
|
||||
let env = Env { wb: &wb, active_sheet: 0, inputs: &[], output: None, item: None };
|
||||
check(&env, "Name:Dollars", "'US$'!F20");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn below_and_right_refuse_a_count_under_one() {
|
||||
let wb = wb();
|
||||
let inputs = vec![("Input".to_string(), place("B2:D11"), true)];
|
||||
let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: None, item: None };
|
||||
check(&env, "Input.below", "B12:D12");
|
||||
check(&env, "Input.below(2)", "B12:D13");
|
||||
check(&env, "Input.right(1)", "E2:E11");
|
||||
for bad in ["Input.below(0)", "Input.below(-3)", "Input.right(0)"] {
|
||||
assert!(resolve_ref(bad, &env).unwrap_err().to_string().contains("at least 1"), "{bad}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -812,6 +812,13 @@ fn compare(v: &Value, op: CompareOp, want: &Json) -> bool {
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Footprint {
|
||||
/// Steps executed by the end of the dry run that made this footprint (the run's one budget).
|
||||
#[serde(skip)]
|
||||
pub executed: u64,
|
||||
/// The dry run wrote more cells than the touch log keeps, so writes that kept a cell's value
|
||||
/// may be missing from `cells`: a shift cannot trust the block.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub log_full: bool,
|
||||
pub sheets: Vec<SheetFootprint>,
|
||||
pub sheets_added: Vec<String>,
|
||||
pub sheets_removed: Vec<String>,
|
||||
@@ -882,13 +889,19 @@ impl Footprint {
|
||||
struct Saved {
|
||||
active: usize,
|
||||
docs: Vec<u64>,
|
||||
/// The workbook as last saved: `sheet.activate` and the view commands move it to the
|
||||
/// current workbook on a clean document, and the rollback puts the workbook back but not this.
|
||||
saved: Option<Arc<Workbook>>,
|
||||
sheet_selections: Vec<Selection>,
|
||||
unlocked_ranges: Vec<(String, String)>,
|
||||
trust: Trust,
|
||||
clipboard: Option<crate::Clipboard>,
|
||||
prefs: crate::Prefs,
|
||||
mode: crate::Mode,
|
||||
/// The journal's length and how many entries it had dropped from its front; a dry run that
|
||||
/// pushes past the journal's limits drops more, which a length alone cannot see.
|
||||
journal: usize,
|
||||
journal_dropped: u64,
|
||||
recording: Option<usize>,
|
||||
ui_requests: usize,
|
||||
format_painter: Option<(Arc<Workbook>, usize, RangeRef, bool)>,
|
||||
@@ -906,6 +919,7 @@ impl Saved {
|
||||
Saved {
|
||||
active: s.active,
|
||||
docs: s.docs.iter().map(|d| d.uid).collect(),
|
||||
saved: d.map(|d| d.saved.clone()),
|
||||
sheet_selections: d.map(|d| d.sheet_selections.clone()).unwrap_or_default(),
|
||||
unlocked_ranges: d.map(|d| d.unlocked_ranges.clone()).unwrap_or_default(),
|
||||
trust: d.map_or(Trust::Local, |d| d.trust),
|
||||
@@ -913,6 +927,7 @@ impl Saved {
|
||||
prefs: s.prefs.clone(),
|
||||
mode: s.mode,
|
||||
journal: s.journal.len(),
|
||||
journal_dropped: s.journal_dropped(),
|
||||
recording: s.recording,
|
||||
ui_requests: s.ui_requests.len(),
|
||||
format_painter: s.format_painter.clone(),
|
||||
@@ -928,6 +943,9 @@ impl Saved {
|
||||
fn restore(self, s: &mut Session, doc: u64) {
|
||||
s.docs.retain(|d| self.docs.contains(&d.uid));
|
||||
if let Some(d) = s.docs.iter_mut().find(|d| d.uid == doc) {
|
||||
if let Some(saved) = self.saved {
|
||||
d.saved = saved;
|
||||
}
|
||||
d.sheet_selections = self.sheet_selections;
|
||||
d.unlocked_ranges = self.unlocked_ranges;
|
||||
d.trust = self.trust;
|
||||
@@ -936,8 +954,11 @@ impl Saved {
|
||||
s.clipboard = self.clipboard;
|
||||
s.prefs = self.prefs;
|
||||
s.mode = self.mode;
|
||||
s.journal.truncate(self.journal);
|
||||
s.recording = self.recording;
|
||||
// Entries the dry run pushed go; entries the journal dropped meanwhile are gone for good,
|
||||
// and the recording's start moves back with them.
|
||||
let dropped = usize::try_from(s.journal_dropped().saturating_sub(self.journal_dropped)).unwrap_or(usize::MAX);
|
||||
s.truncate_journal(self.journal.saturating_sub(dropped));
|
||||
s.recording = self.recording.map(|r| r.saturating_sub(dropped));
|
||||
s.ui_requests.truncate(self.ui_requests);
|
||||
s.format_painter = self.format_painter;
|
||||
s.last_find = self.last_find;
|
||||
@@ -951,7 +972,7 @@ impl Saved {
|
||||
|
||||
/// Runs the macro in dry-run mode on its workbook, puts everything back, and says what it would
|
||||
/// change. The dry run stops once `time_limit_ms` has passed on `clock`.
|
||||
pub fn footprint(s: &mut Session, p: &Prepared, clock: Stopwatch, time_limit_ms: f64) -> Result<Footprint> {
|
||||
pub fn footprint(s: &mut Session, p: &Prepared, clock: Stopwatch, time_limit_ms: f64, executed: u64) -> Result<Footprint> {
|
||||
let i = s.docs.iter().position(|d| d.uid == p.doc).ok_or(EngineError::NoDocument)?;
|
||||
s.set_active(i);
|
||||
let saved = Saved::take(s, p.doc);
|
||||
@@ -961,7 +982,7 @@ pub fn footprint(s: &mut Session, p: &Prepared, clock: Stopwatch, time_limit_ms:
|
||||
d.touched = Some(Vec::new());
|
||||
}
|
||||
let mut m = Machine::new(p, true);
|
||||
(m.clock, m.time_limit_ms) = (clock, time_limit_ms);
|
||||
(m.clock, m.time_limit_ms, m.executed) = (clock, time_limit_ms, executed);
|
||||
let outcome = m.run_to_end(s);
|
||||
let (after, touched) = match s.docs.iter_mut().find(|d| d.uid == p.doc) {
|
||||
Some(d) => (d.wb.clone(), d.touched.take().unwrap_or_default()),
|
||||
@@ -972,6 +993,8 @@ pub fn footprint(s: &mut Session, p: &Prepared, clock: Stopwatch, time_limit_ms:
|
||||
rolled?;
|
||||
let (after, touched) = if matches!(m.stopped, Some((_, true))) { (before.clone(), Vec::new()) } else { (after, touched) };
|
||||
let mut fp = diff(&before, &after, &p.inputs, &touched);
|
||||
fp.log_full = touched.len() >= crate::cmd::TOUCH_LOG_LIMIT;
|
||||
fp.executed = m.executed;
|
||||
fp.error = outcome.err();
|
||||
fp.stopped = m.stopped.map(|(msg, _)| msg);
|
||||
fp.partial = m.partial.map(|(step, reason)| json!({"step": step, "reason": reason}));
|
||||
@@ -1112,16 +1135,41 @@ pub(crate) fn ranges(cells: &mut [CellRef]) -> Vec<String> {
|
||||
}
|
||||
|
||||
/// Inserts blank cells where the footprint lands (moving what is there down or right), so the
|
||||
/// macro writes into empty cells.
|
||||
fn make_room(s: &mut Session, fp: &Footprint, down: bool) -> Result<()> {
|
||||
/// macro writes into empty cells. The block is every written cell's, so a row the macro writes
|
||||
/// stays together. A shift that would move one of the macro's inputs is refused: the inputs were
|
||||
/// checked and bound where they are, and only the block's columns (or rows) would move.
|
||||
fn make_room(s: &mut Session, fp: &Footprint, p: &Prepared, down: bool) -> Result<()> {
|
||||
if !fp.sheets_removed.is_empty() || fp.objects_removed > 0 {
|
||||
return Err(EngineError::Other("Shifting can't make room: the macro deletes sheets or objects.".into()));
|
||||
}
|
||||
if fp.log_full {
|
||||
return Err(EngineError::Other("Shifting can't make room: the macro writes more cells than the footprint can follow.".into()));
|
||||
}
|
||||
let wb = s.doc()?.wb.clone();
|
||||
let env = Env { wb: &wb, active_sheet: usize::MAX, inputs: &[], output: None, item: None };
|
||||
let mut moves = Vec::new();
|
||||
for sh in fp.sheets.iter().filter(|s| s.conflict_count > 0) {
|
||||
let Some(bounds) = sh.bounds_range else { continue };
|
||||
let env = Env { wb: &wb, active_sheet: usize::MAX, inputs: &[], output: None, item: None };
|
||||
let range = resolve::a1(Place { sheet: sh.index, range: bounds }, &env);
|
||||
let Some(block) = sh.bounds_range else { continue };
|
||||
// What the insertion moves: the block's columns from its top row down, or its rows from
|
||||
// its left column rightwards.
|
||||
let moved = |r: &RangeRef| {
|
||||
if down {
|
||||
r.start.col <= block.end.col && r.end.col >= block.start.col && r.end.row >= block.start.row
|
||||
} else {
|
||||
r.start.row <= block.end.row && r.end.row >= block.start.row && r.end.col >= block.start.col
|
||||
}
|
||||
};
|
||||
// The output is where the macro writes, so moving what is there is the point; the second
|
||||
// footprint catches data the shift moved into it.
|
||||
if let Some((name, pl)) = p.inputs.iter().map(|(n, pl, _)| (n.as_str(), *pl)).find(|(_, pl)| pl.sheet == sh.index && moved(&pl.range)) {
|
||||
return Err(EngineError::Other(format!(
|
||||
"Shifting the data would move {name} ({}). Run with onConflict overwrite or fail, or make room first.",
|
||||
resolve::a1(pl, &env)
|
||||
)));
|
||||
}
|
||||
moves.push(resolve::a1(Place { sheet: sh.index, range: block }, &env));
|
||||
}
|
||||
for range in moves {
|
||||
s.execute("home.insertCells", json!({"range": range, "shift": if down { "down" } else { "right" }}))?;
|
||||
}
|
||||
Ok(())
|
||||
@@ -1170,7 +1218,9 @@ pub fn make_way(s: &mut Session, b: &Begun) -> Result<(Machine, bool)> {
|
||||
if rule == OnConflict::Overwrite {
|
||||
return Ok((machine, false));
|
||||
}
|
||||
let mut fp = footprint(s, &b.prepared, machine.clock, b.time_limit_ms)?;
|
||||
// The dry runs and the run share one step budget as they share one clock.
|
||||
let mut fp = footprint(s, &b.prepared, machine.clock, b.time_limit_ms, machine.executed)?;
|
||||
machine.executed = fp.executed;
|
||||
if let Some(e) = fp.error.take() {
|
||||
return Err(e.into_engine(&m.name));
|
||||
}
|
||||
@@ -1188,8 +1238,9 @@ pub fn make_way(s: &mut Session, b: &Begun) -> Result<(Machine, bool)> {
|
||||
return Err(EngineError::Other(format!("Macro '{}' would write over cells that hold data ({what}). Nothing was changed.", m.name)));
|
||||
}
|
||||
OnConflict::ShiftDown | OnConflict::ShiftRight => {
|
||||
make_room(s, &fp, rule == OnConflict::ShiftDown)?;
|
||||
fp = footprint(s, &b.prepared, machine.clock, b.time_limit_ms)?;
|
||||
make_room(s, &fp, &b.prepared, rule == OnConflict::ShiftDown)?;
|
||||
fp = footprint(s, &b.prepared, machine.clock, b.time_limit_ms, machine.executed)?;
|
||||
machine.executed = fp.executed;
|
||||
if let Some(e) = fp.error.take() {
|
||||
return Err(e.into_engine(&m.name));
|
||||
}
|
||||
@@ -1219,11 +1270,13 @@ pub fn run(s: &mut Session, name: &str, p: &Json) -> Result<Json> {
|
||||
s.grouped(&label, |s| {
|
||||
let (mut machine, shifted) = make_way(s, &b)?;
|
||||
let name = &b.mac.name;
|
||||
// `ran` counts the run's own steps; the budget also holds the dry runs'.
|
||||
let before_run = machine.executed;
|
||||
machine.run_to_end(s).map_err(|e| e.into_engine(name))?;
|
||||
match machine.stopped {
|
||||
Some((msg, true)) => Err(EngineError::Other(format!("Macro '{name}' stopped: {msg} Nothing was changed."))),
|
||||
stopped => {
|
||||
let mut out = json!({"macro": name, "ran": machine.executed, "inputs": b.inputs});
|
||||
let mut out = json!({"macro": name, "ran": machine.executed.saturating_sub(before_run), "inputs": b.inputs});
|
||||
if let Some((msg, _)) = stopped {
|
||||
out["stopped"] = json!(msg);
|
||||
}
|
||||
@@ -1241,17 +1294,18 @@ fn time_limit(p: &Json) -> f64 {
|
||||
p.get("timeLimit").and_then(Json::as_f64).map(|t| (t * 1000.0).clamp(1.0, 600_000.0)).unwrap_or(DEFAULT_TIME_LIMIT_MS)
|
||||
}
|
||||
|
||||
/// The conflict rule for this run: `onConflict`, or `overwrite: true` (the answer to the
|
||||
/// question), else the macro's own.
|
||||
/// The conflict rule for this run: `onConflict`, else the macro's own. `overwrite: true` is the
|
||||
/// answer to the question, so it turns `ask` into `overwrite` whichever way `ask` was given.
|
||||
fn conflict_rule(p: &Json, m: &Macro) -> Result<OnConflict> {
|
||||
if let Some(t) = p.get("onConflict").and_then(Json::as_str) {
|
||||
return OnConflict::parse(t)
|
||||
.ok_or_else(|| EngineError::Other(format!("onConflict must be ask, fail, overwrite, shiftDown or shiftRight, not '{t}'.")));
|
||||
}
|
||||
if p.get("overwrite").and_then(Json::as_bool) == Some(true) {
|
||||
let rule = match p.get("onConflict").and_then(Json::as_str) {
|
||||
Some(t) => OnConflict::parse(t)
|
||||
.ok_or_else(|| EngineError::Other(format!("onConflict must be ask, fail, overwrite, shiftDown or shiftRight, not '{t}'.")))?,
|
||||
None => m.output.on_conflict,
|
||||
};
|
||||
if rule == OnConflict::Ask && p.get("overwrite").and_then(Json::as_bool) == Some(true) {
|
||||
return Ok(OnConflict::Overwrite);
|
||||
}
|
||||
Ok(m.output.on_conflict)
|
||||
Ok(rule)
|
||||
}
|
||||
|
||||
/// `macro.check`: what running the macro would do, without changing anything.
|
||||
@@ -1290,7 +1344,7 @@ pub fn check(s: &mut Session, name: &str, p: &Json) -> Result<Json> {
|
||||
if !asks.is_empty() && !approved {
|
||||
return Ok(out);
|
||||
}
|
||||
let fp = footprint(s, &prepared, Stopwatch::start(), time_limit(p))?;
|
||||
let fp = footprint(s, &prepared, Stopwatch::start(), time_limit(p), 0)?;
|
||||
out["ok"] = json!(fp.error.is_none());
|
||||
out["conflicts"] = json!(fp.conflict_count());
|
||||
if let Some(e) = &fp.error {
|
||||
|
||||
@@ -55,16 +55,25 @@ fn macros_over_the_limits_are_refused() {
|
||||
let flat = |n: usize| json!({"name": "Big", "steps": vec![json!({"command": "cell.set"}); n]});
|
||||
assert!(parse_macro(&flat(MAX_MACRO_STEPS)).is_ok());
|
||||
assert!(parse_macro(&flat(MAX_MACRO_STEPS + 1)).unwrap_err().contains("10000 steps"));
|
||||
// Nested Repeat steps: 4 levels are fine, 5 are not.
|
||||
let nest = |levels: usize| {
|
||||
// Nested Repeat steps: 4 Repeats one inside the other are fine (the innermost holds a
|
||||
// command, or nothing), a 5th is not.
|
||||
let nest = |repeats: usize| {
|
||||
let mut step = json!({"command": "cell.set"});
|
||||
for _ in 1..levels {
|
||||
for _ in 0..repeats {
|
||||
step = json!({"type": "repeat", "over": "Input", "body": [step]});
|
||||
}
|
||||
json!({"name": "Deep", "steps": [step]})
|
||||
};
|
||||
assert!(parse_macro(&nest(4)).is_ok());
|
||||
assert!(parse_macro(&nest(5)).unwrap_err().contains("4 deep"));
|
||||
assert!(parse_macro(&nest(MAX_MACRO_DEPTH)).is_ok());
|
||||
assert_eq!(parse_macro(&nest(MAX_MACRO_DEPTH)).unwrap().step_count(), MAX_MACRO_DEPTH + 1);
|
||||
assert!(parse_macro(&nest(MAX_MACRO_DEPTH + 1)).unwrap_err().contains("4 deep"));
|
||||
let mut empty = json!({"type": "repeat", "over": "Input", "body": []});
|
||||
for _ in 1..MAX_MACRO_DEPTH {
|
||||
empty = json!({"type": "if", "cell": "A1", "op": "eq", "value": 1, "then": [empty], "else": []});
|
||||
}
|
||||
let deep = parse_macro(&json!({"name": "Deep", "steps": [empty]})).unwrap();
|
||||
assert_eq!(deep.step_count(), MAX_MACRO_DEPTH);
|
||||
assert!(deep.check_limits().is_ok(), "the model's check agrees with the parser");
|
||||
// Far deeper than serde would allow is refused before it is read.
|
||||
assert!(parse_macro(&nest(200)).is_err());
|
||||
for bad in [
|
||||
@@ -140,6 +149,90 @@ fn a_visible_name_that_looks_like_macro_storage_is_left_alone() {
|
||||
assert_eq!(wb.names, vec![name]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_names_that_read_as_cell_references_are_refused() {
|
||||
// A recorded step holds plain A1 text; an input called `Q1` would take its place.
|
||||
let with_input = |name: &str| json!({"name": "M", "inputs": [{"name": name}]});
|
||||
for name in ["Q1", "AB12", "Sum1", "a1"] {
|
||||
let err = parse_macro(&with_input(name)).unwrap_err();
|
||||
assert!(err.contains("reads as a cell reference"), "{name}: {err}");
|
||||
}
|
||||
for name in ["Amount", "Q", "Sales_Q1", "Total1a"] {
|
||||
assert!(parse_macro(&with_input(name)).is_ok(), "{name}");
|
||||
}
|
||||
assert!(parse_macro(&with_input("Row")).unwrap_err().contains("token of its own"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hidden_macro_name_this_build_did_not_write_is_kept() {
|
||||
// `_gc_macro_index_0` is not a numbered item: it is neither read nor dropped on open, and a
|
||||
// save with macros keeps it beside the items written.
|
||||
let mut wb = Workbook::new();
|
||||
let theirs = hidden("_gc_macro_index_0", "{}");
|
||||
wb.names.push(theirs.clone());
|
||||
wb.macros.push(sample_macro("Totals"));
|
||||
let (raw, _) = gridcraft_xlsx::read_xlsx(&crate::io::save_bytes(&wb, "book.xlsx").unwrap()).unwrap();
|
||||
let names = names_of(&raw);
|
||||
assert!(names.contains(&"_gc_macro_index_0".to_string()) && names.contains(&"_gc_macro_0_0".to_string()), "{names:?}");
|
||||
let (back, warnings) = crate::io::open_bytes("book.xlsx", &crate::io::save_bytes(&wb, "book.xlsx").unwrap()).unwrap();
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
assert_eq!(back.macros, wb.macros);
|
||||
assert_eq!(names_of(&back), ["_gc_macro_index_0"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workbook_snapshots_share_the_macro_list_until_it_changes() {
|
||||
// An undo snapshot or a copy made for a cell edit holds the same list, not a copy of every
|
||||
// step; the undo budget charges a list only once it is a snapshot's own.
|
||||
let mut wb = Workbook::new();
|
||||
wb.macros.push(sample_macro("Totals"));
|
||||
let snapshot = wb.clone();
|
||||
assert!(wb.macros.shares(&snapshot.macros));
|
||||
assert_eq!(crate::history_retained_bytes(&snapshot, &wb), std::mem::size_of::<Workbook>());
|
||||
wb.macros.push(sample_macro("More"));
|
||||
assert!(!wb.macros.shares(&snapshot.macros), "a write copies the list first");
|
||||
assert_eq!(snapshot.macros.len(), 1);
|
||||
assert!(crate::history_retained_bytes(&snapshot, &wb) > std::mem::size_of::<Workbook>());
|
||||
// The copy is per list, not per workbook copy: cloning again shares the new list.
|
||||
let again = wb.clone();
|
||||
assert!(wb.macros.shares(&again.macros));
|
||||
let rebuilt: Macros = wb.macros.iter().cloned().collect();
|
||||
assert_eq!(rebuilt, wb.macros);
|
||||
assert_eq!(rebuilt.into_iter().count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_macro_built_in_code_goes_through_the_size_check_too() {
|
||||
// `check` is the one check for every path, so a macro that passes it never makes a save fail.
|
||||
let big = Macro {
|
||||
name: "Big".into(),
|
||||
steps: vec![Step::command("cell.set", json!({"input": "x".repeat(crate::cmd::stash::MAX_ITEM_CHARS)}))],
|
||||
..Macro::default()
|
||||
};
|
||||
assert!(big.check_limits().is_ok(), "the model's limits say nothing about the size");
|
||||
assert!(check(&big).unwrap_err().contains("too large"));
|
||||
let mut wb = Workbook::new();
|
||||
wb.macros.push(big);
|
||||
assert!(crate::io::save_bytes(&wb, "book.xlsx").is_err(), "a macro that fails `check` fails the save");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_save_refuses_a_clashing_visible_name_before_changing_anything() {
|
||||
let mut wb = Workbook::new();
|
||||
wb.macros.push(sample_macro("A"));
|
||||
wb.macros.push(sample_macro("B"));
|
||||
let own = DefinedName { hidden: false, ..hidden("_GC_MACRO_1_0", "mine") };
|
||||
wb.names.push(own.clone());
|
||||
wb.names.push(hidden("_gc_macro_7_0", "stale"));
|
||||
let err = crate::io::save_bytes(&wb, "book.xlsx").unwrap_err().to_string();
|
||||
assert!(err.contains("_GC_MACRO_1_0"), "{err}");
|
||||
assert_eq!(wb.names.len(), 2, "nothing changed");
|
||||
wb.names.remove(0);
|
||||
let (raw, _) = gridcraft_xlsx::read_xlsx(&crate::io::save_bytes(&wb, "book.xlsx").unwrap()).unwrap();
|
||||
let names = names_of(&raw);
|
||||
assert_eq!(names, ["_gc_macro_0_0", "_gc_macro_1_0"], "the stale item went in the same pass");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scripts_and_vba_stay_as_they_are() {
|
||||
// Automate scripts keep their own storage, and a VBA project is carried through unchanged.
|
||||
|
||||
@@ -13,6 +13,10 @@ use crate::Selection;
|
||||
|
||||
/// The most steps one recording keeps; later ones are counted in [`Recorder::dropped`].
|
||||
pub const RECORD_LIMIT: usize = 100_000;
|
||||
/// The most bytes (steps serialized as JSON) one recording keeps. A step keeps its full
|
||||
/// parameters so it can be played back, and a `file.open` holds the whole file as base64, so
|
||||
/// without a size cap a recording of a few opens of a large workbook keeps tens of MB alive.
|
||||
pub const RECORD_MAX_BYTES: usize = 64 * 1024 * 1024;
|
||||
|
||||
/// One recorded command and where it ran.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
@@ -34,17 +38,33 @@ pub struct RecordedStep {
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Recorder {
|
||||
pub steps: Vec<RecordedStep>,
|
||||
/// Steps not kept because the recording reached [`RECORD_LIMIT`].
|
||||
/// Steps not kept because the recording reached [`RECORD_LIMIT`] or [`RECORD_MAX_BYTES`].
|
||||
/// Once a step is dropped, every later one is too, so `steps` is always the beginning of
|
||||
/// what ran, never a sequence with gaps.
|
||||
pub dropped: usize,
|
||||
/// The size of `steps` serialized as JSON.
|
||||
bytes: usize,
|
||||
}
|
||||
|
||||
impl Recorder {
|
||||
/// Adds a step, or counts it as dropped once the buffer is full.
|
||||
pub fn push(&mut self, step: RecordedStep) {
|
||||
if self.steps.len() < RECORD_LIMIT {
|
||||
let size = serde_json::to_vec(&step).map_or(0, |v| v.len());
|
||||
let fits = self.steps.len() < RECORD_LIMIT && self.bytes.saturating_add(size) <= RECORD_MAX_BYTES;
|
||||
if fits && self.dropped == 0 {
|
||||
self.bytes = self.bytes.saturating_add(size);
|
||||
self.steps.push(step);
|
||||
} else {
|
||||
self.dropped = self.dropped.saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Forgets the steps after the first `steps` and puts the dropped count back to `dropped`,
|
||||
/// for a command the caller received as failed after it was recorded.
|
||||
pub fn truncate(&mut self, steps: usize, dropped: usize) {
|
||||
for step in self.steps.drain(steps.min(self.steps.len())..) {
|
||||
self.bytes = self.bytes.saturating_sub(serde_json::to_vec(&step).map_or(0, |v| v.len()));
|
||||
}
|
||||
self.dropped = dropped;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1093,6 +1093,131 @@ fn protection_passwords_stay_out_of_the_journal_and_recorded_scripts() {
|
||||
s.execute("automate.runScript", json!({"name": "Lock"})).unwrap();
|
||||
}
|
||||
|
||||
/// A session holding a workbook opened from file bytes whose `Fill` script sets B1 (#2178).
|
||||
fn opened_with_script() -> Session {
|
||||
let mut author = s();
|
||||
author
|
||||
.execute("automate.newScript", json!({"name": "Fill", "commands": [{"command": "cell.set", "params": {"cell": "B1", "input": "ran"}}]}))
|
||||
.unwrap();
|
||||
let bytes = crate::io::save_bytes(&author.doc().unwrap().wb, "book.xlsx").unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.open", json!({"name": "downloaded.xlsx", "base64": crate::io::base64_encode(&bytes)})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scripts_written_in_this_session_run_without_a_trust_step() {
|
||||
let mut s = s();
|
||||
assert!(s.doc().unwrap().scripts_trusted);
|
||||
s.execute("automate.newScript", json!({"name": "Fill", "commands": [{"command": "cell.set", "params": {"cell": "B1", "input": "ran"}}]}))
|
||||
.unwrap();
|
||||
assert_eq!(s.execute("automate.runScript", json!({"name": "Fill"})).unwrap()["ran"], 1);
|
||||
assert_eq!(v(&s, "B1"), Value::Text("ran".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untrusted_workbook_scripts_are_refused_until_trusted() {
|
||||
let mut s = opened_with_script();
|
||||
s.ui_requests.clear();
|
||||
assert!(!s.doc().unwrap().scripts_trusted, "a workbook opened with scripts starts untrusted");
|
||||
let listed = s.execute("automate.allScripts", json!({})).unwrap();
|
||||
assert_eq!(listed[0]["trusted"], false, "{listed}");
|
||||
let err = s.execute("automate.runScript", json!({"name": "Fill"})).unwrap_err().to_string();
|
||||
assert!(err.contains("trust"), "{err}");
|
||||
assert_eq!(v(&s, "B1"), Value::Empty, "no step of an untrusted script runs");
|
||||
// Trust can't be smuggled in as a string, and nothing opens a dialog.
|
||||
assert!(s.execute("automate.runScript", json!({"name": "Fill", "trusted": "yes"})).is_err());
|
||||
assert!(s.ui_requests.is_empty(), "{:?}", s.ui_requests);
|
||||
|
||||
// An explicit `trusted: true` runs it this once without trusting the workbook.
|
||||
s.execute("automate.runScript", json!({"name": "Fill", "trusted": true})).unwrap();
|
||||
assert_eq!(v(&s, "B1"), Value::Text("ran".into()));
|
||||
assert!(!s.doc().unwrap().scripts_trusted);
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": ""})).unwrap();
|
||||
assert!(s.execute("automate.runScript", json!({"name": "Fill"})).is_err());
|
||||
|
||||
// Trusting the workbook (the UI's Trust Scripts action) lets its scripts run this session.
|
||||
assert_eq!(s.execute("automate.trustScripts", json!({})).unwrap()["trusted"], true);
|
||||
s.execute("automate.runScript", json!({"name": "Fill"})).unwrap();
|
||||
assert_eq!(v(&s, "B1"), Value::Text("ran".into()));
|
||||
// A second window on the same workbook keeps the decision; withdrawing it disables them again.
|
||||
s.execute("view.newWindow", json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().scripts_trusted);
|
||||
s.execute("automate.trustScripts", json!({"trusted": false})).unwrap();
|
||||
assert!(s.execute("automate.runScript", json!({"name": "Fill"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trust_scripts_only_defaults_to_trusted_when_the_flag_is_missing() {
|
||||
let mut s = opened_with_script();
|
||||
// A `trusted` that isn't a boolean is an error and leaves the workbook untrusted.
|
||||
for wrong in [json!("false"), json!("true"), json!(0), json!(1), json!(null), json!([true])] {
|
||||
let err = s.execute("automate.trustScripts", json!({"trusted": wrong})).unwrap_err().to_string();
|
||||
assert!(err.contains("trusted"), "{wrong}: {err}");
|
||||
assert!(!s.doc().unwrap().scripts_trusted, "{wrong} must not trust the workbook");
|
||||
assert!(s.execute("automate.runScript", json!({"name": "Fill"})).is_err());
|
||||
}
|
||||
// The refusal names where the Trust Scripts button actually is.
|
||||
let err = s.execute("automate.runScript", json!({"name": "Fill"})).unwrap_err().to_string();
|
||||
assert!(err.contains("View › Macros") && err.contains("automate.trustScripts"), "{err}");
|
||||
assert_eq!(s.execute("automate.trustScripts", json!({"trusted": false})).unwrap()["trusted"], false);
|
||||
assert!(!s.doc().unwrap().scripts_trusted);
|
||||
assert_eq!(s.execute("automate.trustScripts", json!({})).unwrap()["trusted"], true);
|
||||
assert!(s.doc().unwrap().scripts_trusted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scripts_leave_file_access_working_after_they_run() {
|
||||
// The sandbox a script runs in is lifted afterwards, also when a step fails.
|
||||
let dir = std::env::temp_dir().join(format!("gridcraft-script-sandbox-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let csv = dir.join("in.csv");
|
||||
std::fs::write(&csv, b"a,b\n1,2\n").unwrap();
|
||||
let csv = csv.to_string_lossy().to_string();
|
||||
let mut s = s();
|
||||
let bad_step = json!({"command": "cell.set", "params": {"cell": "not a cell", "input": "x"}});
|
||||
s.execute("automate.newScript", json!({"name": "Bad", "commands": [bad_step]})).unwrap();
|
||||
assert!(s.execute("automate.runScript", json!({"name": "Bad"})).is_err());
|
||||
s.execute("automate.newScript", json!({"name": "Ok", "commands": [{"command": "cell.set", "params": {"cell": "A1", "input": "1"}}]})).unwrap();
|
||||
s.execute("automate.runScript", json!({"name": "Ok"})).unwrap();
|
||||
s.execute("data.fromTextCsv", json!({"path": csv})).unwrap();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scripts_cannot_reach_outside_the_workbook_even_when_trusted() {
|
||||
let dir = std::env::temp_dir().join(format!("gridcraft-script-deny-{}", std::process::id()));
|
||||
let leak = dir.join("leak.xlsx");
|
||||
let leak = leak.to_string_lossy().to_string();
|
||||
let mut s = opened_with_script();
|
||||
s.ui_requests.clear();
|
||||
s.execute("automate.trustScripts", json!({})).unwrap();
|
||||
let denied = [
|
||||
json!({"command": "link.open", "params": {"url": "https://example.com/?d=secret"}}),
|
||||
json!({"command": "file.saveAs", "params": {"path": leak}}),
|
||||
json!({"command": "file.save", "params": {}}),
|
||||
json!({"command": "file.exportPdf", "params": {"path": leak}}),
|
||||
json!({"command": "file.open", "params": {"path": leak}}),
|
||||
json!({"command": "data.getData", "params": {"path": leak}}),
|
||||
json!({"command": "data.fromTextCsv", "params": {"path": leak}}),
|
||||
json!({"command": "insert.picture", "params": {"path": leak}}),
|
||||
];
|
||||
for step in denied {
|
||||
// The harmless first step proves the whole script is refused before anything runs.
|
||||
let first = json!({"command": "cell.set", "params": {"cell": "C1", "input": "partial"}});
|
||||
s.execute("automate.newScript", json!({"name": "Leak", "commands": [first, step]})).unwrap();
|
||||
let r = s.execute("automate.runScript", json!({"name": "Leak", "trusted": true}));
|
||||
assert!(r.is_err(), "{step} must be refused");
|
||||
assert_eq!(v(&s, "C1"), Value::Empty, "{step}: no step runs");
|
||||
}
|
||||
assert!(!s.ui_requests.iter().any(|r| matches!(r, crate::UiRequest::OpenUrl(_))), "{:?}", s.ui_requests);
|
||||
assert!(!std::path::Path::new(&leak).exists());
|
||||
// Data a script supplies itself is still fine.
|
||||
s.execute("automate.newScript", json!({"name": "Csv", "commands": [{"command": "data.fromTextCsv", "params": {"text": "a,b\n1,2"}}]})).unwrap();
|
||||
s.execute("automate.runScript", json!({"name": "Csv"})).unwrap();
|
||||
}
|
||||
|
||||
/// A sheet with locked data in A1:B3, unlocked cells in D1:D3 and row 10, then protected.
|
||||
fn protected(extra: serde_json::Value) -> Session {
|
||||
let mut s = s();
|
||||
@@ -2595,6 +2720,21 @@ fn file_options_reports_settings_and_asks_for_the_options_page() {
|
||||
assert_eq!(s.active().unwrap().wb, before, "Options never changes the workbook");
|
||||
}
|
||||
|
||||
/// Ctrl+8 hides and shows the outline symbols; it's undoable and a sheet setting.
|
||||
#[test]
|
||||
fn show_outline_symbols_toggles() {
|
||||
let mut s = s();
|
||||
let on = |s: &Session| s.active().unwrap().wb.active().unwrap().show_outline_symbols;
|
||||
assert!(on(&s));
|
||||
assert_eq!(s.execute("view.showOutlineSymbols", json!({})).unwrap(), json!({"on": false}));
|
||||
assert!(!on(&s));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(on(&s));
|
||||
s.execute("view.showOutlineSymbols", json!({"on": false})).unwrap();
|
||||
reopen(&mut s);
|
||||
assert!(!on(&s));
|
||||
}
|
||||
|
||||
fn reopen(s: &mut Session) {
|
||||
let r = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap();
|
||||
let b64 = r["base64"].as_str().unwrap().to_string();
|
||||
|
||||
@@ -252,6 +252,162 @@ fn closing_the_debugged_workbook_ends_the_debugger() {
|
||||
s.execute("macro.debug", json!({"action": "stop"})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shift_that_would_move_an_input_is_refused() {
|
||||
// A shift inserts cells over the conflicting block, which moves the block's columns (or rows)
|
||||
// from there on. When one of the macro's own inputs lies in the way, its data would move and
|
||||
// shear while the run keeps the old coordinates, so the shift is refused and nothing changes.
|
||||
let mut s = s();
|
||||
data(&mut s);
|
||||
set(&mut s, "B1", "Old title");
|
||||
put(
|
||||
&mut s,
|
||||
json!({"name": "Title", "inputs": [{"name": "Input", "header": true}], "steps": [
|
||||
{"command": "cell.set", "params": {"cell": "Input.cell(0,1)", "input": "Report"}}
|
||||
]}),
|
||||
);
|
||||
let start = s.doc().unwrap().wb.clone();
|
||||
let err = s.execute("macro.run", json!({"name": "Title", "inputs": "B2:C5", "onConflict": "shiftDown"})).unwrap_err().to_string();
|
||||
assert!(err.contains("would move Input (") && err.contains("B2:C5"), "{err}");
|
||||
assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start), "nothing changed");
|
||||
assert_eq!((v(&s, "B1").display(), v(&s, "B3").display()), ("Old title".into(), "Ann".into()));
|
||||
// Writing left of the input and shifting right would move it too.
|
||||
put(
|
||||
&mut s,
|
||||
json!({"name": "Left", "inputs": [{"name": "Input", "header": true}], "steps": [
|
||||
{"command": "cell.set", "params": {"cell": "Input.offset(1,-1).cell(1,1)", "input": "x"}}
|
||||
]}),
|
||||
);
|
||||
set(&mut s, "A3", "in the way");
|
||||
let err = s.execute("macro.run", json!({"name": "Left", "inputs": "B2:C5", "onConflict": "shiftRight"})).unwrap_err().to_string();
|
||||
assert!(err.contains("would move Input"), "{err}");
|
||||
// The output counts as well.
|
||||
put(
|
||||
&mut s,
|
||||
json!({"name": "Out", "inputs": [{"name": "Input", "header": true}], "output": {"anchor": "Input.below(1)"}, "steps": [
|
||||
{"command": "cell.set", "params": {"cell": "Input.cell(0,1)", "input": "Report"}}
|
||||
]}),
|
||||
);
|
||||
let err = s.execute("macro.run", json!({"name": "Out", "inputs": "B2:C5", "onConflict": "shiftDown"})).unwrap_err().to_string();
|
||||
assert!(err.contains("would move Input"), "{err}");
|
||||
// A shift that moves nothing of the macro's own still works, over the block of every written
|
||||
// cell, so a row the macro writes stays together.
|
||||
set(&mut s, "H1", "elsewhere");
|
||||
put(
|
||||
&mut s,
|
||||
json!({"name": "Wide", "inputs": [{"name": "Input", "header": true}], "steps": [
|
||||
{"command": "cell.set", "params": {"cell": "H1", "input": "over"}},
|
||||
{"command": "cell.set", "params": {"cell": "H9", "input": "far"}}
|
||||
]}),
|
||||
);
|
||||
s.execute("macro.run", json!({"name": "Wide", "inputs": "B2:C5", "onConflict": "shiftDown"})).unwrap();
|
||||
assert_eq!((v(&s, "H1").display(), v(&s, "H9").display(), v(&s, "H10").display()), ("over".into(), "far".into(), "elsewhere".into()));
|
||||
assert_eq!(v(&s, "B3").display(), "Ann", "the input did not move");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shift_is_refused_when_the_footprint_cannot_follow_every_write() {
|
||||
// The touch log keeps at most a million writes; past that, a write that kept a cell's value
|
||||
// is missing from the footprint, so a shift could split a row the macro writes. The footprint
|
||||
// says so and the shift is refused (overwrite, with no dry run, still runs).
|
||||
let mut s = s();
|
||||
set(&mut s, "A1", "x");
|
||||
set(&mut s, "E2", "old");
|
||||
put(
|
||||
&mut s,
|
||||
json!({"name": "Flood", "steps": [
|
||||
{"command": "edit.fillDown", "params": {"range": "A1:A1000001"}},
|
||||
{"command": "cell.set", "params": {"cell": "E2", "input": "new"}}
|
||||
]}),
|
||||
);
|
||||
let c = s.execute("macro.check", json!({"name": "Flood"})).unwrap();
|
||||
assert_eq!(c["footprint"]["logFull"], true, "{c}");
|
||||
let err = s.execute("macro.run", json!({"name": "Flood", "onConflict": "shiftDown"})).unwrap_err().to_string();
|
||||
assert!(err.contains("more cells than the footprint can follow"), "{err}");
|
||||
assert_eq!(v(&s, "E2").display(), "old", "nothing changed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dry_run_leaves_a_clean_workbook_clean() {
|
||||
// `view.zoom` and `sheet.activate` move the saved mark to the current workbook on a clean
|
||||
// document; the dry run's rollback puts the workbook back, so the mark must come back too.
|
||||
let mut s = s();
|
||||
data(&mut s);
|
||||
let d = s.doc_mut().unwrap();
|
||||
d.saved = d.wb.clone();
|
||||
assert!(!s.doc().unwrap().is_dirty());
|
||||
put(&mut s, json!({"name": "Look", "steps": [{"command": "view.zoom", "params": {"percent": 120}}]}));
|
||||
s.execute("macro.check", json!({"name": "Look"})).unwrap();
|
||||
assert!(!s.doc().unwrap().is_dirty(), "a dry run changed nothing");
|
||||
// A run refused by the conflict rule changed nothing either.
|
||||
put(&mut s, totals());
|
||||
set(&mut s, "E3", "taken");
|
||||
let d = s.doc_mut().unwrap();
|
||||
d.saved = d.wb.clone();
|
||||
assert!(s.execute("macro.run", json!({"name": "Totals", "inputs": "B2:C5", "onConflict": "fail"})).is_err());
|
||||
assert!(!s.doc().unwrap().is_dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dry_run_puts_the_journal_back_even_after_it_trimmed_it() {
|
||||
// Every nested command a dry run executes is journaled, so a long macro pushes the journal
|
||||
// past its limit and the journal drops its oldest entries. Putting the journal back by its
|
||||
// old length would then keep entries from the dry run and point a recording at the wrong
|
||||
// place; the restore counts what was dropped.
|
||||
let mut s = s();
|
||||
data(&mut s);
|
||||
s.execute("selection.set", json!({"range": "A1"})).unwrap();
|
||||
let journal = s.journal.clone();
|
||||
s.recording = Some(journal.len().saturating_sub(1));
|
||||
put(
|
||||
&mut s,
|
||||
json!({"name": "Long", "steps": [{"type": "repeat", "over": "A1:A200", "body": [
|
||||
{"type": "repeat", "over": "A1:A60", "body": [{"command": "cell.set", "params": {"cell": "Row.cell(1,1)", "input": "x"}}]}
|
||||
]}]}),
|
||||
);
|
||||
s.execute("macro.check", json!({"name": "Long"})).unwrap();
|
||||
let sets = |j: &[(String, Json)]| j.iter().filter(|(id, _)| id == "cell.set").count();
|
||||
assert!(sets(&s.journal) <= sets(&journal), "no dry-run entry stays");
|
||||
assert!(s.journal.len() <= journal.len(), "{} entries, had {}", s.journal.len(), journal.len());
|
||||
assert!(journal.ends_with(&s.journal), "what stays is the journal's own tail");
|
||||
assert!(s.recording.is_some_and(|r| r <= s.journal.len()), "{:?}", s.recording);
|
||||
// A short dry run leaves the journal exactly as it was.
|
||||
put(&mut s, json!({"name": "Short", "steps": [{"command": "cell.set", "params": {"cell": "H1", "input": "x"}}]}));
|
||||
let (journal, recording) = (s.journal.clone(), s.recording);
|
||||
s.execute("macro.check", json!({"name": "Short"})).unwrap();
|
||||
assert_eq!((&s.journal, s.recording), (&journal, recording));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_answer_to_the_question_counts_however_ask_was_given() {
|
||||
let mut s = s();
|
||||
data(&mut s);
|
||||
put(&mut s, totals());
|
||||
set(&mut s, "E3", "taken");
|
||||
let p = json!({"name": "Totals", "inputs": "B2:C5", "onConflict": "ask"});
|
||||
assert!(matches!(s.execute("macro.run", p.clone()), Err(EngineError::Confirm(_))));
|
||||
let mut answered = p;
|
||||
answered["overwrite"] = json!(true);
|
||||
s.execute("macro.run", answered).unwrap();
|
||||
assert_eq!(v(&s, "E3").display(), "400");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_step_limit_is_one_budget_across_the_dry_run_and_the_run() {
|
||||
// 300 x 300 passes is under the limit for one phase; the dry run and the run together are not.
|
||||
let mut s = s();
|
||||
put(
|
||||
&mut s,
|
||||
json!({"name": "Spin", "steps": [{"type": "repeat", "over": "A1:A300", "body": [
|
||||
{"type": "repeat", "over": "A1:A300", "body": [{"type": "if", "cell": "A1", "op": "empty"}]}
|
||||
]}]}),
|
||||
);
|
||||
let err = s.execute("macro.run", json!({"name": "Spin"})).unwrap_err().to_string();
|
||||
assert!(err.contains("more than 100000 steps"), "{err}");
|
||||
// With no dry run (overwrite), the same macro fits.
|
||||
s.execute("macro.run", json!({"name": "Spin", "onConflict": "overwrite"})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_footprint_covers_other_sheets_objects_and_edges() {
|
||||
let mut s = s();
|
||||
|
||||
@@ -170,6 +170,27 @@ fn a_long_group_keeps_the_history_before_it() {
|
||||
assert_eq!(d.undo.iter().filter(|e| e.label == "Enter Cell").count(), 99);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_grouped_action_that_closes_its_document_succeeds() {
|
||||
// The group goes with the document; the action did what it was asked.
|
||||
let mut s = s();
|
||||
let uid = s.doc().unwrap().uid;
|
||||
s.grouped("Close", |s| s.execute("file.close", json!({}))).unwrap();
|
||||
assert!(s.docs.iter().all(|d| d.uid != uid) && !s.in_group(uid));
|
||||
// Opening a file into an untouched new workbook takes its place the same way.
|
||||
s.new_workbook();
|
||||
let uid = s.doc().unwrap().uid;
|
||||
let bytes = serde_json::to_vec(&gridcraft_model::Workbook::new()).unwrap();
|
||||
s.grouped("Open", |s| s.execute("file.open", json!({"name": "theirs.json", "base64": crate::io::base64_encode(&bytes)}))).unwrap();
|
||||
assert!(s.docs.iter().all(|d| d.uid != uid) && !s.in_group(uid) && s.groups.is_empty());
|
||||
// A group ended by someone else while its document is still open is still an error.
|
||||
let err = s.grouped("Ended", |s| {
|
||||
let token = s.groups.last().map(|g| crate::GroupToken { id: g.id, doc: g.doc });
|
||||
s.group_end(token.ok_or_else(|| EngineError::Other("no group".into()))?).map(|_| ())
|
||||
});
|
||||
assert!(err.unwrap_err().to_string().contains("already ended"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_a_document_drops_its_groups() {
|
||||
let mut s = s();
|
||||
@@ -226,6 +247,27 @@ fn register_group_commands() {
|
||||
});
|
||||
// A command that is one group keeps the group's name.
|
||||
register("test.onlyGroup", |s, _| s.grouped("Named Step", |s| put(s, "D1", "x")).map(|_| json!({})));
|
||||
// A group, then an edit the command makes itself (no nested command, so no undo entry).
|
||||
register("test.groupThenEdit", |s, _| {
|
||||
s.grouped("Named Step", |s| put(s, "D1", "x"))?;
|
||||
crate::cmd::edit(s, |cx| {
|
||||
let at = gridcraft_core::CellRef::parse("D2").ok_or_else(|| EngineError::Other("D2".into()))?;
|
||||
cx.sheet_mut(0)?.set_value(at, gridcraft_core::Value::Text("y".into()));
|
||||
cx.touch(0, at);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({}))
|
||||
});
|
||||
// A group with a change, then one that is rolled back: the step is still the first group's.
|
||||
register("test.groupThenRolledBack", |s, _| {
|
||||
s.grouped("Named Step", |s| put(s, "D1", "x"))?;
|
||||
let failed: crate::Result<()> = s.grouped("Doomed", |s| {
|
||||
put(s, "D3", "gone")?;
|
||||
Err(EngineError::Other("no".into()))
|
||||
});
|
||||
assert!(failed.is_err());
|
||||
Ok(json!({}))
|
||||
});
|
||||
// An edit, then the rollback of the innermost group that was open before the command.
|
||||
register("test.rollbackOpenGroup", |s, _| {
|
||||
put(s, "E1", "gone")?;
|
||||
@@ -240,6 +282,33 @@ fn register_group_commands() {
|
||||
s.group_end(token.ok_or_else(|| EngineError::Other("no group".into()))?)?;
|
||||
Ok(json!({}))
|
||||
});
|
||||
// The end of the innermost group that was open before the command, then an edit.
|
||||
register("test.endOpenGroupThenEdit", |s, _| {
|
||||
let token = s.groups.last().map(|g| crate::GroupToken { id: g.id, doc: g.doc });
|
||||
s.group_end(token.ok_or_else(|| EngineError::Other("no group".into()))?)?;
|
||||
put(s, "E1", "after")?;
|
||||
Ok(json!({}))
|
||||
});
|
||||
// Many nested edits: the steps they make are bounded while the command runs. (Not undoable
|
||||
// itself: like a script, its nested steps are the undo steps.)
|
||||
crate::cmd::register_test_command("test.manyNestedEdits", |s, _| {
|
||||
let start = s.doc()?.undo.len();
|
||||
for i in 0..crate::UNDO_LIMIT + 50 {
|
||||
put(s, "A1", &i.to_string())?;
|
||||
}
|
||||
let len = s.doc()?.undo.len();
|
||||
if len > start + crate::UNDO_LIMIT {
|
||||
return Err(EngineError::Other(format!("{} steps kept inside the command", len - start)));
|
||||
}
|
||||
Ok(json!({}))
|
||||
});
|
||||
// An edit, then a group the command leaves open (a debugger's start).
|
||||
register("test.editThenOpenGroup", |s, _| {
|
||||
put(s, "A1", "first")?;
|
||||
let _open = s.group_begin("Debug")?;
|
||||
put(s, "A2", "second")?;
|
||||
Ok(json!({}))
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -266,9 +335,9 @@ fn a_command_that_runs_its_own_group_makes_one_step() {
|
||||
|
||||
#[test]
|
||||
fn a_stale_group_step_does_not_name_a_later_command() {
|
||||
// The record of the last group step (document, length, epoch) survives the Undo of that step.
|
||||
// A later command that ends a group of its own at the same history length, after rolling
|
||||
// another back, must not take the name of the nested edit it made.
|
||||
// The record of the last group step survives the Undo of that step. A later command that
|
||||
// ends a group of its own at the same history length, after rolling another back, must not
|
||||
// take the name of the nested edit it made.
|
||||
register_group_commands();
|
||||
let mut s = s();
|
||||
s.execute("test.onlyGroup", json!({})).unwrap();
|
||||
@@ -285,6 +354,95 @@ fn a_stale_group_step_does_not_name_a_later_command() {
|
||||
assert!(v(&s, "B2").is_empty() && v(&s, "D1").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_command_keeps_its_groups_name_only_for_exactly_that_step() {
|
||||
// The name comes from the group only when the group's step is the command's whole step: an
|
||||
// edit after the group (no nested command, so the history length matches) makes it the
|
||||
// command's own, while a later group that rolled back changes nothing.
|
||||
register_group_commands();
|
||||
let mut s = s();
|
||||
s.execute("test.groupThenEdit", json!({})).unwrap();
|
||||
assert_eq!(undo_labels(&s), ["test.groupThenEdit"]);
|
||||
assert_eq!((v(&s, "D1").display(), v(&s, "D2").display()), ("x".into(), "y".into()));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(v(&s, "D1").is_empty() && v(&s, "D2").is_empty());
|
||||
assert_eq!(undo_labels(&s), Vec::<String>::new());
|
||||
s.execute("test.groupThenRolledBack", json!({})).unwrap();
|
||||
assert_eq!(undo_labels(&s), ["Named Step"]);
|
||||
assert_eq!(v(&s, "D1").display(), "x");
|
||||
assert!(v(&s, "D3").is_empty());
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(v(&s, "D1").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_a_command_changes_after_ending_a_group_is_its_own_step() {
|
||||
// Ending a group that was open before the command leaves the group its step (if it made one)
|
||||
// and makes a step of the command's own for what it changed after the end.
|
||||
register_group_commands();
|
||||
let mut s = s();
|
||||
// The group made no step: the command's edit must still be undoable, under its own name.
|
||||
let _unchanged = s.group_begin("Debug").unwrap();
|
||||
s.execute("test.endOpenGroupThenEdit", json!({})).unwrap();
|
||||
assert_eq!(undo_labels(&s), ["test.endOpenGroupThenEdit"]);
|
||||
assert_eq!(v(&s, "E1").display(), "after");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(v(&s, "E1").is_empty());
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(v(&s, "E1").display(), "after");
|
||||
// The group made a step: two steps, the group's and the command's.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
let _changed = s.group_begin("Debug").unwrap();
|
||||
set(&mut s, "B1", "inside");
|
||||
s.execute("test.endOpenGroupThenEdit", json!({})).unwrap();
|
||||
assert_eq!(undo_labels(&s), ["Debug", "test.endOpenGroupThenEdit"]);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(v(&s, "E1").is_empty());
|
||||
assert_eq!(v(&s, "B1").display(), "inside");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(v(&s, "B1").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_group_a_command_leaves_open_starts_where_the_command_did() {
|
||||
// A command that edits and then opens a group it leaves open has no undo step of its own:
|
||||
// the group's step, made when it ends, covers the command's edit too, so no state from
|
||||
// inside the command can come back.
|
||||
register_group_commands();
|
||||
let mut s = s();
|
||||
set(&mut s, "C1", "base");
|
||||
s.execute("test.editThenOpenGroup", json!({})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
assert!(s.in_group(d.uid));
|
||||
let token = s.groups.last().map(|g| crate::GroupToken { id: g.id, doc: g.doc }).unwrap();
|
||||
assert!(s.group_end(token).unwrap());
|
||||
assert_eq!(undo_labels(&s), ["Enter Cell", "Debug"]);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(v(&s, "A1").is_empty() && v(&s, "A2").is_empty());
|
||||
assert_eq!(v(&s, "C1").display(), "base");
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!((v(&s, "A1").display(), v(&s, "A2").display()), ("first".into(), "second".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steps_made_inside_a_command_are_bounded_while_it_runs() {
|
||||
// The history limit applies when the outermost command finishes, so a command's bookkeeping
|
||||
// holds; but a command that runs thousands of nested commands (a script, a subtotal) must not
|
||||
// keep thousands of steps alive until then. Each nested step bounds the steps made since the
|
||||
// command began, and leaves the history from before it alone.
|
||||
register_group_commands();
|
||||
let mut s = s();
|
||||
let before = fill_history(&mut s);
|
||||
s.execute("test.manyNestedEdits", json!({})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
assert_eq!(d.undo.len(), crate::UNDO_LIMIT);
|
||||
assert!(d.undo.iter().all(|e| e.label == "Enter Cell"));
|
||||
assert!(!d.undo.iter().any(|e| before.iter().any(|w| std::sync::Arc::ptr_eq(w, &e.wb))), "the older history gave way to the newest steps");
|
||||
assert_eq!(v(&s, "A1").display(), "149");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(v(&s, "A1").display(), "148");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_group_rolled_back_inside_a_command_keeps_the_redo_history() {
|
||||
// The debugger's Stop with Undo Changes rolls its group back inside a command. The rollback
|
||||
@@ -385,12 +543,125 @@ fn a_group_ended_at_the_history_limit_replaces_only_the_oldest_step() {
|
||||
assert_undo_redo(&mut s, &[("F1", "one"), ("F2", "two")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_command_that_panics_leaves_the_history_as_it_found_it() {
|
||||
// A command that edits through a nested command and then panics: the nested step is gone
|
||||
// with the workbook it changed, and the redo history the nested step cleared comes back.
|
||||
crate::cmd::register_undoable_test_command("test.editThenPanic", |s, _| {
|
||||
put(s, "A1", "half done")?;
|
||||
panic!("after an edit");
|
||||
});
|
||||
let mut s = s();
|
||||
set(&mut s, "A1", "1");
|
||||
set(&mut s, "A1", "2");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!((undo_labels(&s).len(), s.doc().unwrap().redo.len()), (1, 1));
|
||||
let err = s.execute("test.editThenPanic", json!({})).unwrap_err().to_string();
|
||||
assert!(err.contains("after an edit"), "{err}");
|
||||
assert_eq!(v(&s, "A1").display(), "1", "the workbook is restored");
|
||||
assert_eq!(undo_labels(&s), ["Enter Cell"], "no step for the nested edit");
|
||||
assert_eq!(s.doc().unwrap().redo.len(), 1, "the redo history is back");
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(v(&s, "A1").display(), "2");
|
||||
|
||||
// A command that undoes through a nested command and then panics: the entry Undo removed
|
||||
// comes back, and the redo entry it made goes.
|
||||
crate::cmd::register_undoable_test_command("test.undoThenPanic", |s, _| {
|
||||
s.execute("edit.undo", json!({}))?;
|
||||
panic!("after an undo");
|
||||
});
|
||||
assert_eq!((undo_labels(&s).len(), s.doc().unwrap().redo.len()), (2, 0));
|
||||
assert!(s.execute("test.undoThenPanic", json!({})).is_err());
|
||||
assert_eq!(v(&s, "A1").display(), "2", "the workbook is restored");
|
||||
assert_eq!(undo_labels(&s), ["Enter Cell", "Enter Cell"], "the undone step is back");
|
||||
assert_eq!(s.doc().unwrap().redo.len(), 0);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(v(&s, "A1").display(), "1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_grouped_action_that_panics_is_rolled_back_before_the_panic_goes_on() {
|
||||
// Outside a command, a caller that catches the panic finds the group rolled back and no
|
||||
// group left open, so Undo is enabled and the next grouped action does not nest.
|
||||
let mut s = s();
|
||||
set(&mut s, "A1", "before");
|
||||
let caught = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
s.grouped::<()>("Doomed", |s| {
|
||||
set(s, "A1", "half done");
|
||||
panic!("inside the group");
|
||||
})
|
||||
}));
|
||||
assert!(caught.is_err(), "the panic goes on to the caller");
|
||||
let uid = s.doc().unwrap().uid;
|
||||
assert!(!s.in_group(uid));
|
||||
assert_eq!(v(&s, "A1").display(), "before");
|
||||
assert_eq!(undo_labels(&s), ["Enter Cell"]);
|
||||
s.grouped("Next", |s| {
|
||||
set(s, "A1", "after");
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(undo_labels(&s), ["Enter Cell", "Next"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_group_ended_by_a_command_that_then_panics_keeps_its_step() {
|
||||
// The command takes a token the test left for it, ends or rolls back that group, and panics.
|
||||
// The group's token went with the command, so the group stays ended; its step runs from the
|
||||
// group's start to the restored workbook, so Undo still returns to the state before the group.
|
||||
static TOKEN: std::sync::Mutex<Option<crate::GroupToken>> = std::sync::Mutex::new(None);
|
||||
crate::cmd::register_undoable_test_command("test.endGroupThenPanic", |s, _| {
|
||||
let token = TOKEN.lock().map_err(|_| EngineError::Other("lock".into()))?.take();
|
||||
if let Some(token) = token {
|
||||
s.group_end(token)?;
|
||||
}
|
||||
panic!("after ending a group");
|
||||
});
|
||||
crate::cmd::register_undoable_test_command("test.rollBackGroupThenPanic", |s, _| {
|
||||
let token = TOKEN.lock().map_err(|_| EngineError::Other("lock".into()))?.take();
|
||||
if let Some(token) = token {
|
||||
s.group_rollback(token)?;
|
||||
}
|
||||
panic!("after rolling back a group");
|
||||
});
|
||||
for cmd in ["test.endGroupThenPanic", "test.rollBackGroupThenPanic"] {
|
||||
let mut s = s();
|
||||
set(&mut s, "A1", "before");
|
||||
let token = s.group_begin("Outer").unwrap();
|
||||
set(&mut s, "A1", "inside");
|
||||
set(&mut s, "A2", "inside too");
|
||||
*TOKEN.lock().unwrap() = Some(token);
|
||||
assert!(s.execute(cmd, json!({})).is_err());
|
||||
let uid = s.doc().unwrap().uid;
|
||||
assert!(!s.in_group(uid), "{cmd}: the group stays ended");
|
||||
assert_eq!(undo_labels(&s), ["Enter Cell", "Outer"], "{cmd}");
|
||||
assert_eq!(v(&s, "A1").display(), "inside", "{cmd}: the workbook is as the command found it");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!((v(&s, "A1").display(), v(&s, "A2").display()), ("before".into(), String::new()), "{cmd}");
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(v(&s, "A2").display(), "inside too", "{cmd}");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- recording buffer
|
||||
|
||||
fn recorded_ids(s: &Session) -> Vec<String> {
|
||||
s.recorder.as_ref().unwrap().steps.iter().map(|st| st.id.clone()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_headless_call_that_fails_for_want_of_a_file_is_not_recorded() {
|
||||
// `file.open` with no file queues a dialog and succeeds; a headless caller gets an error for
|
||||
// it instead, so the recording keeps no step for it either.
|
||||
let mut s = s();
|
||||
s.start_recording();
|
||||
set(&mut s, "A1", "1");
|
||||
assert!(s.execute_headless("file.open", json!({})).is_err());
|
||||
assert_eq!(recorded_ids(&s), ["cell.set"]);
|
||||
let rec = s.stop_recording().unwrap();
|
||||
assert_eq!(rec.dropped, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_recorder_keeps_top_level_calls_with_where_they_ran() {
|
||||
let mut s = s();
|
||||
@@ -452,6 +723,31 @@ fn the_recorder_has_a_cap() {
|
||||
assert_eq!(rec.dropped, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_recorder_has_a_byte_cap_and_keeps_a_prefix() {
|
||||
// A `file.open` step holds the whole file as base64: the buffer is bounded in bytes too, and
|
||||
// once a step is dropped every later one is, so what is kept is always how the recording began.
|
||||
let mut rec = crate::record::Recorder::default();
|
||||
let step = |n: usize| crate::record::RecordedStep {
|
||||
doc: 1,
|
||||
sheet: 0,
|
||||
sheet_name: "Sheet1".into(),
|
||||
selection: crate::Selection::default(),
|
||||
id: "file.open".into(),
|
||||
params: json!({"base64": "A".repeat(n)}),
|
||||
};
|
||||
let big = crate::record::RECORD_MAX_BYTES / 2;
|
||||
rec.push(step(big));
|
||||
assert_eq!((rec.steps.len(), rec.dropped), (1, 0));
|
||||
rec.push(step(big));
|
||||
assert_eq!((rec.steps.len(), rec.dropped), (1, 1), "the second does not fit beside the first");
|
||||
rec.push(step(1));
|
||||
assert_eq!((rec.steps.len(), rec.dropped), (1, 2), "a small step after a dropped one is dropped too");
|
||||
let mut rec = crate::record::Recorder::default();
|
||||
rec.push(step(crate::record::RECORD_MAX_BYTES + 1));
|
||||
assert_eq!((rec.steps.len(), rec.dropped), (0, 1), "a step larger than the whole budget is not kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passwords_stay_out_of_the_recording_buffer() {
|
||||
const PW: &str = "synthetic-pw-5521";
|
||||
@@ -548,3 +844,35 @@ fn pasting_from_a_file_carries_its_trust() {
|
||||
assert_eq!(Trust::FromFile.receive(Trust::Local), Trust::FromFile);
|
||||
assert_eq!(Trust::Local.receive(Trust::Local), Trust::Local);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_linked_paste_carries_the_files_trust_whatever_it_pastes() {
|
||||
// Paste Link writes a formula into every cell whatever `what` says, so a linked paste of
|
||||
// formats from a file carries the file's trust like one of formulas. Column widths and notes
|
||||
// write no cells, linked or not.
|
||||
let mut s = s();
|
||||
open_json(&mut s, &gridcraft_model::Workbook::new());
|
||||
set(&mut s, "A1", "=1+1");
|
||||
s.execute("review.newComment", json!({"cell": "A1", "text": "theirs"})).unwrap();
|
||||
let wide = gridcraft_model::LineInfo { size: Some(123.0), ..Default::default() };
|
||||
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap().cols.insert(0, wide);
|
||||
s.execute("edit.copy", json!({"range": "A1"})).unwrap();
|
||||
let b1 = gridcraft_core::CellRef::parse("B1").unwrap();
|
||||
let sheet = |s: &Session| s.doc().unwrap().wb.sheet(0).unwrap().clone();
|
||||
for what in ["columnWidths", "comments"] {
|
||||
s.new_workbook();
|
||||
s.execute("edit.pasteSpecial", json!({"what": what, "link": true, "at": "B1"})).unwrap();
|
||||
assert_eq!(trust(&s), Trust::Local, "{what}");
|
||||
let sh = sheet(&s);
|
||||
assert!(sh.cell(b1).is_none(), "a linked paste of {what} writes no cell");
|
||||
if what == "comments" {
|
||||
assert_eq!(sh.comments.get(&b1).map(|c| c.text.as_str()), Some("theirs"));
|
||||
} else {
|
||||
assert_eq!(sh.cols.get(&1).and_then(|c| c.size), Some(123.0));
|
||||
}
|
||||
}
|
||||
s.new_workbook();
|
||||
s.execute("edit.pasteSpecial", json!({"what": "formats", "link": true, "at": "B1"})).unwrap();
|
||||
assert!(sheet(&s).cell(b1).is_some_and(|c| c.formula.is_some()), "a linked paste of formats writes a formula");
|
||||
assert_eq!(trust(&s), Trust::FromFile);
|
||||
}
|
||||
|
||||
+2
-2
@@ -23,7 +23,7 @@
|
||||
//!
|
||||
//! Knobs (environment variables):
|
||||
//! - `GRIDCRAFT_FUZZ_SEEDS` (default 6) and `GRIDCRAFT_FUZZ_STEPS` (default 40): the budget. Soak with
|
||||
//! `GRIDCRAFT_FUZZ_SEEDS=200 GRIDCRAFT_FUZZ_STEPS=200 cargo test --release -p gridcraft-engine --test command_sequence_fuzz`.
|
||||
//! `GRIDCRAFT_FUZZ_SEEDS=200 GRIDCRAFT_FUZZ_STEPS=200 cargo test --release -p gridcraft-engine --test it command_sequence_fuzz::`.
|
||||
//! - `GRIDCRAFT_FUZZ_SEED=17`: replay that one seed (the failure message prints the command line).
|
||||
//! - `GRIDCRAFT_FUZZ_SHRINK=1`: on failure, shrink the sequence to a minimal list of commands that
|
||||
//! still fails the same way, printed as `execute` calls ready for a regression test.
|
||||
@@ -138,7 +138,7 @@ fn report(seed: u64, steps: usize, sample: &str, sequence: &[Step], f: &Failure)
|
||||
None => format!("end-of-run checks after {} steps", sequence.len()),
|
||||
};
|
||||
let mut out = format!(
|
||||
"command-sequence fuzz failure: seed {seed} (sample `{sample}`), {at}\n invariant [{}]: {}\n replay: GRIDCRAFT_FUZZ_SEED={seed} GRIDCRAFT_FUZZ_STEPS={steps} cargo test -p gridcraft-engine --test command_sequence_fuzz\n",
|
||||
"command-sequence fuzz failure: seed {seed} (sample `{sample}`), {at}\n invariant [{}]: {}\n replay: GRIDCRAFT_FUZZ_SEED={seed} GRIDCRAFT_FUZZ_STEPS={steps} cargo test -p gridcraft-engine --test it command_sequence_fuzz::\n",
|
||||
f.kind, f.what
|
||||
);
|
||||
let end = (f.step + 1).min(sequence.len());
|
||||
@@ -0,0 +1,61 @@
|
||||
use gridcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
fn table() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range":"A1", "values":[["Item", "Qty"], ["x", "7"], ["y", "8"]]})).unwrap();
|
||||
s.execute("insert.table", json!({"range":"A1:B3", "header":true, "name":"T"})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn styles(s: &Session) -> Vec<String> {
|
||||
s.doc().unwrap().wb.active().unwrap().tables.iter().map(|t| t.style.clone()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_as_table_inside_a_table_restyles_it() {
|
||||
let mut s = table();
|
||||
s.execute("selection.set", json!({"range":"B2"})).unwrap();
|
||||
s.execute("home.formatAsTable", json!({"style":"TableStyleLight9"})).unwrap();
|
||||
assert_eq!(styles(&s), ["TableStyleLight9"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_as_table_on_a_range_inside_a_table_restyles_it() {
|
||||
let mut s = table();
|
||||
s.execute("home.formatAsTable", json!({"range":"A2:B3", "style":"TableStyleDark3"})).unwrap();
|
||||
assert_eq!(styles(&s), ["TableStyleDark3"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_as_table_across_a_table_edge_is_rejected() {
|
||||
let mut s = table();
|
||||
let e = s.execute("home.formatAsTable", json!({"range":"A1:C3", "style":"TableStyleLight9"})).unwrap_err();
|
||||
assert!(e.to_string().contains("overlap"), "{e}");
|
||||
assert_eq!(styles(&s), ["TableStyleMedium2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_as_table_inside_a_table_returns_that_table() {
|
||||
let mut s = table();
|
||||
let r = s.execute("home.formatAsTable", json!({"range":"B2", "style":"TableStyleLight9"})).unwrap();
|
||||
assert_eq!(r, json!({"table":"T", "range":"A1:B3"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_as_table_uses_the_active_area_of_a_multi_area_selection() {
|
||||
let mut s = table();
|
||||
s.execute("selection.set", json!({"range":"D10:E11,B2"})).unwrap();
|
||||
s.execute("home.formatAsTable", json!({"style":"TableStyleLight9"})).unwrap();
|
||||
assert_eq!(styles(&s), ["TableStyleLight9"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_as_table_inside_a_table_with_data_beside_it_restyles_it() {
|
||||
let mut s = table();
|
||||
s.execute("range.setValues", json!({"range":"C1", "values":[["Note"], ["a"], ["b"]]})).unwrap();
|
||||
s.execute("selection.set", json!({"range":"B2"})).unwrap();
|
||||
s.execute("home.formatAsTable", json!({"style":"TableStyleLight9"})).unwrap();
|
||||
assert_eq!(styles(&s), ["TableStyleLight9"]);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
//! The crate's integration tests, one module per file, built as one test binary.
|
||||
|
||||
mod autofit_filter_buttons;
|
||||
mod cf_formula_validate;
|
||||
mod chart_workbook_names;
|
||||
mod command_sequence_fuzz;
|
||||
mod cross_sheet_cut;
|
||||
mod csv_export_budget;
|
||||
mod custom_filter_blanks;
|
||||
mod custom_view_freeze;
|
||||
mod dynamicmetadata;
|
||||
mod extreme_selection_moves;
|
||||
mod format_as_table_restyle;
|
||||
mod hidden_sheet_navigation;
|
||||
mod ink_roundtrip;
|
||||
mod line_count_overflow;
|
||||
mod long_operator_chains;
|
||||
mod offset_overflow;
|
||||
mod percent_entry;
|
||||
mod pivot_unknown_func;
|
||||
mod pivotchart_link;
|
||||
mod range_fill_formats;
|
||||
mod row_autofit;
|
||||
mod sheet_name_quoting;
|
||||
mod sparse_clear_invalidation;
|
||||
mod stored_list_shape;
|
||||
mod table_resize_overlap;
|
||||
mod table_total_row_last_row;
|
||||
mod wrap_row_height;
|
||||
@@ -85,6 +85,28 @@ pub fn struct_ref_file(s: &StructRef) -> String {
|
||||
write_struct_ref(s, false)
|
||||
}
|
||||
|
||||
/// Splits a sheet-qualified name at its last `!` outside brackets. A `!` inside brackets
|
||||
/// belongs to a structured reference's column (`Table1[Done!]`, `Table1[#REF!]`).
|
||||
fn sheet_split(n: &str) -> Option<(&str, &str)> {
|
||||
let mut depth = 0usize;
|
||||
let mut escaped = false;
|
||||
let mut at = None;
|
||||
for (i, c) in n.char_indices() {
|
||||
if escaped {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
match c {
|
||||
'\'' if depth > 0 => escaped = true,
|
||||
'[' => depth = depth.saturating_add(1),
|
||||
']' => depth = depth.saturating_sub(1),
|
||||
'!' if depth == 0 => at = Some(i),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
at.and_then(|i| Some((n.get(..i)?, n.get(i + 1..)?)))
|
||||
}
|
||||
|
||||
fn write_struct_ref(s: &StructRef, at_shorthand: bool) -> String {
|
||||
let esc = |c: &str| c.replace('\'', "''").replace('[', "'[").replace(']', "']").replace('#', "'#");
|
||||
let item = |i: &StructItem| match i {
|
||||
@@ -132,7 +154,16 @@ fn write_struct_ref(s: &StructRef, at_shorthand: bool) -> String {
|
||||
}
|
||||
|
||||
fn number(n: f64) -> String {
|
||||
number_to_text(n)
|
||||
let text = number_to_text(n);
|
||||
// 15 significant digits can round past f64::MAX, which no longer parses.
|
||||
if text.parse::<f64>().is_ok_and(f64::is_finite) {
|
||||
return text;
|
||||
}
|
||||
let exact = format!("{:E}", n);
|
||||
match exact.split_once('E') {
|
||||
Some((m, e)) if !e.starts_with('-') => format!("{m}E+{e}"),
|
||||
_ => exact,
|
||||
}
|
||||
}
|
||||
|
||||
/// Prints an expression in A1 notation.
|
||||
@@ -178,7 +209,7 @@ fn write_expr(s: &mut String, e: &Expr, r1c1: Option<CellRef>) {
|
||||
Some(at) => s.push_str(&reference_r1c1(r, at)),
|
||||
None => s.push_str(&reference_a1(r)),
|
||||
},
|
||||
Expr::Name(n) => match n.rsplit_once('!') {
|
||||
Expr::Name(n) => match sheet_split(n) {
|
||||
// A sheet-qualified name is stored with the bare sheet name (`My Sheet!Total`): quote it so it parses back.
|
||||
Some((sheet, name)) => {
|
||||
s.push_str("e_sheet(sheet));
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
//! The crate's integration tests, one module per file, built as one test binary.
|
||||
|
||||
mod printer_roundtrip;
|
||||
mod spanish_input;
|
||||
@@ -0,0 +1,15 @@
|
||||
use gridcraft_formula::{Expr, parse, print};
|
||||
|
||||
#[test]
|
||||
fn number_near_f64_max_round_trips() {
|
||||
for src in ["=1.7976931348623157E308", "=-1.7976931348623157E308"] {
|
||||
let e = parse(src).unwrap();
|
||||
let printed = print(&e);
|
||||
let back = parse(&printed).unwrap_or_else(|err| panic!("{printed}: {err:?}"));
|
||||
assert_eq!(print(&back), printed);
|
||||
match (&e, &back) {
|
||||
(Expr::Number(a), Expr::Number(b)) => assert_eq!(a, b),
|
||||
_ => assert_eq!(e, back),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,10 +35,11 @@ fn field_index(db: &Array, a: &[Arg]) -> R<Option<usize>> {
|
||||
match v {
|
||||
Value::Number(n) => {
|
||||
let i = n.trunc();
|
||||
if i < 1.0 || i > db.cols as f64 {
|
||||
// NaN too: it would pass both comparisons and become index 0 (#2163).
|
||||
if i.is_nan() || i < 1.0 || i > db.cols as f64 {
|
||||
return Err(CellError::Value);
|
||||
}
|
||||
Ok(Some(i as usize - 1))
|
||||
Ok(Some((i as usize).saturating_sub(1)))
|
||||
}
|
||||
Value::Text(t) => find_column(db, &t).map(Some).ok_or(CellError::Value),
|
||||
Value::Bool(_) => Err(CellError::Value),
|
||||
|
||||
@@ -264,6 +264,37 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// #2163: a NaN column index is refused (it used to become 0 and underflow to `usize::MAX`).
|
||||
#[test]
|
||||
fn nan_index_is_a_value_error() {
|
||||
use crate::util::testutil::*;
|
||||
use gridcraft_core::CellError;
|
||||
let table = rf(arr(vec![vec![tv("Field"), tv("Other")], vec![nv(1.0), nv(2.0)]]));
|
||||
let nan = || av(Value::Number(f64::NAN));
|
||||
assert_eq!(ev("VLOOKUP", vec![n(1.0), table.clone(), nan()]), Value::Error(CellError::Value));
|
||||
assert_eq!(ev("HLOOKUP", vec![t("Field"), table.clone(), nan()]), Value::Error(CellError::Value));
|
||||
assert_eq!(ev("DSUM", vec![table.clone(), nan(), table.clone()]), Value::Error(CellError::Value));
|
||||
assert_eq!(ev("VLOOKUP", vec![n(1.0), table, n(2.0)]), Value::Number(2.0));
|
||||
}
|
||||
|
||||
/// #2162: REGEXREPLACE gives up on a too-long result while building it, and otherwise replaces
|
||||
/// as before (every match, `$n` groups).
|
||||
#[test]
|
||||
fn regexreplace_bounds_its_result() {
|
||||
use crate::util::testutil::*;
|
||||
use gridcraft_core::CellError;
|
||||
let many = "a".repeat(32_767);
|
||||
assert_eq!(ev("REGEXREPLACE", vec![t(&many), t("."), t(&"b".repeat(32_767))]), Value::Error(CellError::Value));
|
||||
assert_eq!(ev("REGEXREPLACE", vec![t(&many), t("a"), t("b")]), Value::text("b".repeat(32_767)));
|
||||
assert_eq!(ev("REGEXREPLACE", vec![t("a1b22c"), t("[0-9]+"), t("#")]), Value::text("a#b#c"));
|
||||
assert_eq!(ev("REGEXREPLACE", vec![t("2026-10-22"), t("(\\d+)-(\\d+)-(\\d+)"), t("$3/$2/$1")]), Value::text("22/10/2026"));
|
||||
assert_eq!(ev("REGEXREPLACE", vec![t("abc"), t("x*"), t("-")]), Value::text("-a-b-c-"));
|
||||
assert_eq!(ev("REGEXREPLACE", vec![t("a1b2"), t("[0-9]"), t("#"), n(2.0)]), Value::text("a1b#"));
|
||||
// One match whose replacement repeats it 10,000 times: refused before it is built.
|
||||
assert_eq!(ev("REGEXREPLACE", vec![t(&many), t("^.*$"), t(&"$0".repeat(10_000))]), Value::Error(CellError::Value));
|
||||
assert_eq!(ev("REGEXREPLACE", vec![t(&many), t("^.*$"), t(&"$0".repeat(10_000)), n(1.0)]), Value::Error(CellError::Value));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_inputs_never_panic() {
|
||||
use crate::util::testutil::*;
|
||||
@@ -279,6 +310,9 @@ mod tests {
|
||||
n(-1e308),
|
||||
n(1e10),
|
||||
n(2.5),
|
||||
// Numbers no formula should make, but code can (#2163).
|
||||
av(Value::Number(f64::NAN)),
|
||||
av(Value::Number(f64::INFINITY)),
|
||||
t(""),
|
||||
t("abc"),
|
||||
t("*?~"),
|
||||
|
||||
@@ -134,10 +134,11 @@ fn vh_lookup(args: &[Arg], vertical: bool) -> R<Value> {
|
||||
return Err(*e);
|
||||
}
|
||||
let idx = num(args, 2)?;
|
||||
if idx < 1.0 {
|
||||
// NaN too: it would pass `idx < 1.0` and become index 0 (#2163).
|
||||
if idx.is_nan() || idx < 1.0 {
|
||||
return Err(CellError::Value);
|
||||
}
|
||||
let idx = idx.trunc() as usize - 1;
|
||||
let idx = (idx.trunc() as usize).saturating_sub(1);
|
||||
let approx = opt_bool(args, 3, true)?;
|
||||
let (keys, len) = if vertical { (column(&table, 0), table.cols) } else { (row(&table, 0), table.rows) };
|
||||
if idx >= len {
|
||||
|
||||
@@ -5,8 +5,8 @@ use gridcraft_core::{CellError, Value, number_to_text};
|
||||
|
||||
use crate::criteria::wildcard_find;
|
||||
use crate::util::{
|
||||
A, MAX_CELLS, MAX_TEXT, R, S, arg, array_val, as_array, boolean, has, num, num_val, opt_bool, opt_int, opt_num, round_half_away, scalar, text,
|
||||
text_val,
|
||||
A, MAX_CELLS, MAX_TEXT, R, S, arg, array_val, as_array, boolean, has, int, num, num_val, opt_bool, opt_int, opt_num, round_half_away, scalar,
|
||||
text, text_val,
|
||||
};
|
||||
use crate::{Arg, Ctx, FnSpec, VAR};
|
||||
|
||||
@@ -550,7 +550,7 @@ fn find_matches(text: &[char], delims: &[Vec<char>], insensitive: bool, from_end
|
||||
fn text_before_after(a: &[Arg], before: bool) -> R<Value> {
|
||||
let s = chars(&text(a, 0)?);
|
||||
let delims = delimiters(&arg(a, 1)?.value)?;
|
||||
let inst = opt_int(a, 2, 1)?;
|
||||
let inst = if has(a, 2) { int(a, 2)? } else { 1 };
|
||||
let mode = opt_int(a, 3, 0)?;
|
||||
let match_end = opt_bool(a, 4, false)?;
|
||||
if !(0..=1).contains(&mode) || inst == 0 || inst.unsigned_abs() as usize > s.len().max(1) {
|
||||
@@ -763,23 +763,99 @@ fn convert_replacement(r: &str) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// A piece of a replacement: literal text or a capture group (by number or name).
|
||||
enum Piece {
|
||||
Text(String),
|
||||
Index(usize),
|
||||
Name(String),
|
||||
}
|
||||
|
||||
/// A replacement split into pieces as `regex::Captures::expand` reads it: `$$` is `$`, `$name` takes
|
||||
/// the longest run of `[_0-9A-Za-z]`, `${name}` ends at `}` (`${}` is empty), a name of digits is a
|
||||
/// group number, and a `$` that starts no reference is itself.
|
||||
fn template(rep: &str) -> Vec<Piece> {
|
||||
let mut out = Vec::new();
|
||||
let mut lit = String::new();
|
||||
let mut rest = rep;
|
||||
while let Some(i) = rest.find('$') {
|
||||
lit.push_str(&rest[..i]);
|
||||
rest = &rest[i + 1..];
|
||||
if let Some(r) = rest.strip_prefix('$') {
|
||||
lit.push('$');
|
||||
rest = r;
|
||||
continue;
|
||||
}
|
||||
// A braced name is a reference even when empty (it expands to nothing).
|
||||
let braced = rest.strip_prefix('{').and_then(|b| b.find('}').map(|j| (&b[..j], &b[j + 1..])));
|
||||
let (name, after) = braced.unwrap_or_else(|| {
|
||||
let j = rest.find(|c: char| !(c == '_' || c.is_ascii_alphanumeric())).unwrap_or(rest.len());
|
||||
(&rest[..j], &rest[j..])
|
||||
});
|
||||
if braced.is_none() && name.is_empty() {
|
||||
lit.push('$');
|
||||
continue;
|
||||
}
|
||||
if !lit.is_empty() {
|
||||
out.push(Piece::Text(std::mem::take(&mut lit)));
|
||||
}
|
||||
out.push(match name.parse::<usize>() {
|
||||
Ok(n) => Piece::Index(n),
|
||||
Err(_) => Piece::Name(name.to_string()),
|
||||
});
|
||||
rest = after;
|
||||
}
|
||||
lit.push_str(rest);
|
||||
if !lit.is_empty() {
|
||||
out.push(Piece::Text(lit));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Append a match's replacement to `out`, piece by piece; `None` once `out` would pass `limit`
|
||||
/// bytes, before anything that long is built (#2162).
|
||||
fn expand_capped(cap: ®ex::Captures, pieces: &[Piece], out: &mut String, limit: usize) -> Option<()> {
|
||||
for p in pieces {
|
||||
let s = match p {
|
||||
Piece::Text(t) => t.as_str(),
|
||||
Piece::Index(i) => cap.get(*i).map_or("", |m| m.as_str()),
|
||||
Piece::Name(n) => cap.name(n).map_or("", |m| m.as_str()),
|
||||
};
|
||||
if out.len().saturating_add(s.len()) > limit {
|
||||
return None;
|
||||
}
|
||||
out.push_str(s);
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn regexreplace(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
let s = text(a, 0)?;
|
||||
let rep = convert_replacement(&text(a, 2)?);
|
||||
let rep = template(&convert_replacement(&text(a, 2)?));
|
||||
let occ = opt_int(a, 3, 0)?;
|
||||
let re = build_regex(&text(a, 1)?, case_arg(a, 4)?)?;
|
||||
// Built match by match within a bound past which the text can only be too long (a character
|
||||
// is at most four bytes), instead of building every replacement first (#2162).
|
||||
let limit = MAX_TEXT * 4;
|
||||
let mut out = String::new();
|
||||
if occ == 0 {
|
||||
return check_len(re.replace_all(&s, rep.as_str()).into_owned());
|
||||
let mut last = 0;
|
||||
for cap in re.captures_iter(&s) {
|
||||
let Some(m) = cap.get(0) else { continue };
|
||||
out.push_str(s.get(last..m.start()).unwrap_or_default());
|
||||
expand_capped(&cap, &rep, &mut out, limit).ok_or(CellError::Value)?;
|
||||
last = m.end();
|
||||
}
|
||||
out.push_str(s.get(last..).unwrap_or_default());
|
||||
return check_len(out);
|
||||
}
|
||||
let caps: Vec<regex::Captures> = re.captures_iter(&s).collect();
|
||||
let k = occ.unsigned_abs() as usize;
|
||||
let idx = if occ > 0 { k.checked_sub(1) } else { caps.len().checked_sub(k) };
|
||||
let Some(cap) = idx.and_then(|i| caps.get(i)) else { return text_val(s) };
|
||||
let Some(m) = cap.get(0) else { return text_val(s) };
|
||||
let mut out = String::new();
|
||||
out.push_str(&s[..m.start()]);
|
||||
cap.expand(&rep, &mut out);
|
||||
out.push_str(&s[m.end()..]);
|
||||
out.push_str(s.get(..m.start()).unwrap_or_default());
|
||||
expand_capped(cap, &rep, &mut out, limit).ok_or(CellError::Value)?;
|
||||
out.push_str(s.get(m.end()..).unwrap_or_default());
|
||||
check_len(out)
|
||||
}
|
||||
|
||||
@@ -957,6 +1033,20 @@ pub(crate) fn specs() -> Vec<FnSpec> {
|
||||
mod tests {
|
||||
use gridcraft_core::{CellError, Value};
|
||||
|
||||
/// The pieces expand exactly as `regex::Captures::expand` does.
|
||||
#[test]
|
||||
fn template_expands_like_regex() {
|
||||
let re = regex::Regex::new(r"(?<y>\d+)-(\d+)").unwrap();
|
||||
let cap = re.captures("x 2026-10 y").unwrap();
|
||||
for rep in ["$1", "${1}", "$1a", "${1}a", "$y", "${y}!", "$$", "$$1", "a$", "$}", "${", "${}", "${y", "$0|$2|$9", "$-1", "€$1€", ""] {
|
||||
let mut want = String::new();
|
||||
cap.expand(rep, &mut want);
|
||||
let mut got = String::new();
|
||||
assert!(super::expand_capped(&cap, &super::template(rep), &mut got, usize::MAX).is_some());
|
||||
assert_eq!(got, want, "{rep:?}");
|
||||
}
|
||||
}
|
||||
|
||||
use crate::util::testutil::*;
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -81,10 +81,8 @@ impl Macro {
|
||||
return Err(format!("A macro description can be at most {MAX_MACRO_TEXT} characters long."));
|
||||
}
|
||||
// Count without recursing past the depth limit (a hostile file can nest deeper).
|
||||
// A Repeat or If at the top is nested 1 deep; one inside it, 2 deep.
|
||||
fn walk(steps: &[Step], level: usize, count: &mut usize) -> Result<(), String> {
|
||||
if level > MAX_MACRO_DEPTH {
|
||||
return Err(format!("Repeat and If steps can nest at most {MAX_MACRO_DEPTH} deep."));
|
||||
}
|
||||
for s in steps {
|
||||
*count = count.saturating_add(1);
|
||||
if *count > MAX_MACRO_STEPS {
|
||||
@@ -93,6 +91,9 @@ impl Macro {
|
||||
if s.note.chars().count() > MAX_MACRO_TEXT {
|
||||
return Err(format!("A step note can be at most {MAX_MACRO_TEXT} characters long."));
|
||||
}
|
||||
if s.kind.children().next().is_some() && level > MAX_MACRO_DEPTH {
|
||||
return Err(format!("Repeat and If steps can nest at most {MAX_MACRO_DEPTH} deep."));
|
||||
}
|
||||
for body in s.kind.children() {
|
||||
walk(body, level + 1, count)?;
|
||||
}
|
||||
@@ -112,6 +113,10 @@ impl Macro {
|
||||
if ["Output", "Row", "Column", "Name"].iter().any(|r| r.eq_ignore_ascii_case(n)) {
|
||||
return Err(format!("'{n}' is a token of its own and can't name an input."));
|
||||
}
|
||||
// A step written with plain A1 text (a recorded one) must keep meaning the cell.
|
||||
if gridcraft_core::RangeRef::parse(n).is_some() {
|
||||
return Err(format!("'{n}' reads as a cell reference and can't name an input."));
|
||||
}
|
||||
if self.inputs.iter().take(k).any(|o| o.name.eq_ignore_ascii_case(n)) {
|
||||
return Err(format!("Two inputs are called '{n}'."));
|
||||
}
|
||||
@@ -120,6 +125,75 @@ impl Macro {
|
||||
}
|
||||
}
|
||||
|
||||
/// The macros of a workbook. Workbook snapshots share the list until one of them changes it, so
|
||||
/// an undo entry or a copy made for a cell edit holds the same list rather than a copy of every
|
||||
/// step. Reads go through `Deref` to the list; a write through `DerefMut` first copies the list
|
||||
/// when another snapshot shares it.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct Macros(std::sync::Arc<Vec<Macro>>);
|
||||
|
||||
impl Macros {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
|
||||
/// Whether both hold the one list, with no copy made between them.
|
||||
pub fn shares(&self, other: &Macros) -> bool {
|
||||
std::sync::Arc::ptr_eq(&self.0, &other.0)
|
||||
}
|
||||
|
||||
/// A rough size of the list in memory, for the undo budget.
|
||||
pub fn estimated_bytes(&self) -> usize {
|
||||
self.0.iter().fold(0usize, |bytes, m| {
|
||||
let text = m.name.len().saturating_add(m.description.len());
|
||||
let steps = m.step_count().saturating_mul(std::mem::size_of::<Step>().saturating_add(64));
|
||||
bytes.saturating_add(std::mem::size_of::<Macro>()).saturating_add(text).saturating_add(steps)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Vec<Macro>> for Macros {
|
||||
fn from(list: Vec<Macro>) -> Macros {
|
||||
Macros(std::sync::Arc::new(list))
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Deref for Macros {
|
||||
type Target = Vec<Macro>;
|
||||
fn deref(&self) -> &Vec<Macro> {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::DerefMut for Macros {
|
||||
fn deref_mut(&mut self) -> &mut Vec<Macro> {
|
||||
std::sync::Arc::make_mut(&mut self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoIterator for Macros {
|
||||
type Item = Macro;
|
||||
type IntoIter = std::vec::IntoIter<Macro>;
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
std::sync::Arc::unwrap_or_clone(self.0).into_iter()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> IntoIterator for &'a Macros {
|
||||
type Item = &'a Macro;
|
||||
type IntoIter = std::slice::Iter<'a, Macro>;
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.0.iter()
|
||||
}
|
||||
}
|
||||
|
||||
impl FromIterator<Macro> for Macros {
|
||||
fn from_iter<I: IntoIterator<Item = Macro>>(iter: I) -> Macros {
|
||||
Macros(std::sync::Arc::new(iter.into_iter().collect()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Refuses a format version newer than [`MACRO_VERSION`]: this build would drop the fields it
|
||||
/// doesn't know, and a save would then write the rest back as if it were the whole macro.
|
||||
pub fn check_version(v: u64) -> Result<(), String> {
|
||||
|
||||
@@ -76,6 +76,10 @@ pub struct Sheet {
|
||||
pub show_headings: bool,
|
||||
pub show_formulas: bool,
|
||||
pub show_zeros: bool,
|
||||
/// The outline's level buttons and brackets (Excel's Show Outline Symbols, Ctrl+8); the
|
||||
/// outline itself stays.
|
||||
#[serde(default = "yes")]
|
||||
pub show_outline_symbols: bool,
|
||||
pub right_to_left: bool,
|
||||
pub zoom: u16,
|
||||
pub cond_formats: Vec<CondFormat>,
|
||||
@@ -113,6 +117,10 @@ pub struct Sheet {
|
||||
pub preserved: Option<Arc<crate::preserved::Preserved>>,
|
||||
}
|
||||
|
||||
fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Default width of a column and height of a row (pixels at 96 dpi) for the default font.
|
||||
pub const DEFAULT_COL_WIDTH: f32 = 64.0;
|
||||
pub const DEFAULT_ROW_HEIGHT: f32 = 20.0;
|
||||
@@ -140,6 +148,7 @@ impl Sheet {
|
||||
tab_color: None,
|
||||
visibility: Visibility::Visible,
|
||||
show_gridlines: true,
|
||||
show_outline_symbols: true,
|
||||
show_headings: true,
|
||||
show_formulas: false,
|
||||
show_zeros: true,
|
||||
|
||||
@@ -181,8 +181,8 @@ pub struct Workbook {
|
||||
pub csv_delimiter: Option<u8>,
|
||||
/// GridCraft macros (typed command lists, not VBA), in the order the macro list shows them.
|
||||
/// The JSON format keeps them as this field; xlsx keeps them in hidden defined names.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty", skip_deserializing)]
|
||||
pub macros: Vec<crate::macros::Macro>,
|
||||
#[serde(default, skip_serializing_if = "crate::macros::Macros::is_empty", skip_deserializing)]
|
||||
pub macros: crate::macros::Macros,
|
||||
/// The `macros` entries of a JSON file as read, before the engine checks each one on its own
|
||||
/// and moves it into `macros`, so one damaged or newer macro doesn't stop the file opening.
|
||||
/// Never written.
|
||||
@@ -234,7 +234,7 @@ impl Workbook {
|
||||
phonetics: vec![],
|
||||
vba_project: None,
|
||||
csv_delimiter: None,
|
||||
macros: vec![],
|
||||
macros: crate::macros::Macros::default(),
|
||||
macros_read: vec![],
|
||||
preserved: None,
|
||||
source_dir: None,
|
||||
|
||||
@@ -79,6 +79,9 @@ fn main() {
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
// Catalog the installed fonts first (the app scans in the background from its first frame), so
|
||||
// the workbook's fonts are drawn in their own faces, not a substitute.
|
||||
let _ = gridcraft_ui_egui::fonts::catalog();
|
||||
let mut session = Session::new();
|
||||
if let Some(p) = &input {
|
||||
session.execute("file.open", json!({"path": p})).expect("open");
|
||||
@@ -127,7 +130,9 @@ fn main() {
|
||||
app
|
||||
},
|
||||
);
|
||||
harness.run_steps(8);
|
||||
// The workbook's installed fonts bind once the font scan is done; frames past the first few
|
||||
// apply them, so the snapshot shows them as the app does.
|
||||
harness.run_steps(14);
|
||||
let img = harness.render().expect("render");
|
||||
img.save(&out).expect("save png");
|
||||
eprintln!("wrote {out} ({}x{})", img.width(), img.height());
|
||||
|
||||
@@ -1207,7 +1207,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
|
||||
}
|
||||
});
|
||||
}
|
||||
"macros" => macros(ui, &mut d),
|
||||
"macros" => macros(app, ui, &mut d),
|
||||
"statistics" | "accessibility" | "errorChecking" | "evaluateFormula" | "comments" => {
|
||||
let r = if d.name == "comments" {
|
||||
app.session.active().and_then(|doc| doc.wb.active().map(|s| json!(s.comments.iter().map(|(c, m)| json!({"cell": c.a1(), "author": m.author, "text": m.text})).collect::<Vec<_>>())))
|
||||
@@ -1753,7 +1753,7 @@ fn about(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
/// The Macros dialog: lists GridCraft's own recorded scripts and (if the file carries one) the
|
||||
/// preserved VBA project. Mirrors Excel's Developer ▸ Macros list, but is honest that VBA is
|
||||
/// preserved and not executed.
|
||||
fn macros(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
fn macros(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
ui.label("Automation in this workbook. GridCraft runs its own scripts; a file's VBA project is preserved but not executed.");
|
||||
ui.add_space(6.0);
|
||||
let items: Vec<Json> = d.result.as_ref().and_then(Json::as_array).cloned().unwrap_or_default();
|
||||
@@ -1761,6 +1761,17 @@ fn macros(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
ui.weak("This workbook has no scripts or macros.");
|
||||
return;
|
||||
}
|
||||
// Scripts that came with a file stay disabled until the user trusts the workbook (#2178).
|
||||
if items.iter().any(|it| it.get("trusted").and_then(Json::as_bool) == Some(false)) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.weak("Scripts in this workbook came from a file and are disabled.");
|
||||
if ui.button("Trust Scripts").on_hover_text("Let this workbook's scripts run for the rest of this session").clicked() {
|
||||
let _ = app.run("automate.trustScripts", json!({"trusted": true}));
|
||||
d.result = app.session.run("view.macros", json!({})).ok();
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| {
|
||||
for it in &items {
|
||||
let name = it.get("name").and_then(Json::as_str).unwrap_or("(unnamed)");
|
||||
|
||||
+164
-18
@@ -102,6 +102,19 @@ pub enum Drag {
|
||||
},
|
||||
}
|
||||
|
||||
/// The fill a left-drag of the handle just made, which the Auto Fill Options button can redo
|
||||
/// another way. It only applies while nothing else changed the workbook (`revision`) and the
|
||||
/// filled cells are still selected.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct FillTag {
|
||||
pub src: RangeRef,
|
||||
pub target: RangeRef,
|
||||
pub uid: u64,
|
||||
pub sheet: usize,
|
||||
pub revision: u64,
|
||||
pub undo_len: usize,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct GridState {
|
||||
pub drag: Drag,
|
||||
@@ -109,6 +122,10 @@ pub struct GridState {
|
||||
/// With `ensure_visible`: scroll to the moving end of the selection instead of the active cell
|
||||
/// (Shift/Ctrl+Shift+arrows keep the active cell on the anchor while the far corner moves).
|
||||
pub reveal_moving_end: bool,
|
||||
/// With `ensure_visible`: the cell an arrow or paging key moved from (the moving end when
|
||||
/// extending), set only by those keys. A move from the scrolled rows or columns into the
|
||||
/// frozen ones scrolls them back to their top or left.
|
||||
pub reveal_from: Option<CellRef>,
|
||||
pub last_title: Option<String>,
|
||||
pub drag_select: bool,
|
||||
pub rect: Option<Rect>,
|
||||
@@ -120,6 +137,8 @@ pub struct GridState {
|
||||
pub context_menu_frame: u64,
|
||||
/// After a right-drag of the fill handle: the source, the dragged range and where to open the menu.
|
||||
pub fill_menu: Option<(RangeRef, RangeRef, Pos2)>,
|
||||
/// After a left-drag of the fill handle: the Auto Fill Options button beside the filled cells.
|
||||
pub fill_tag: Option<FillTag>,
|
||||
/// The right-clicked chart, picture, shape or slicer: where to open its menu, its kind and id.
|
||||
pub object_menu: Option<(Pos2, &'static str, u32)>,
|
||||
pub marching_phase: f32,
|
||||
@@ -235,13 +254,15 @@ impl Geo {
|
||||
let frozen_h = (ri.start(fr) as f32) * z;
|
||||
let row_outline = Arc::new(crate::outline::Axis::new(&sh.rows));
|
||||
let col_outline = Arc::new(crate::outline::Axis::new(&sh.cols));
|
||||
let mut band_w = crate::outline::band(row_outline.levels, zoom);
|
||||
let mut band_h = crate::outline::band(col_outline.levels, zoom);
|
||||
// Show Outline Symbols off: no bands (the outline's hidden lines stay hidden).
|
||||
let symbols = sh.show_outline_symbols;
|
||||
let mut band_w = if symbols { crate::outline::band(row_outline.levels, zoom) } else { 0.0 };
|
||||
let mut band_h = if symbols { crate::outline::band(col_outline.levels, zoom) } else { 0.0 };
|
||||
// Without headings the level buttons still need a corner to sit in.
|
||||
if row_outline.levels > 0 && header_h == 0.0 {
|
||||
if symbols && row_outline.levels > 0 && header_h == 0.0 {
|
||||
band_h = band_h.max(crate::outline::lane(zoom) + 3.0);
|
||||
}
|
||||
if col_outline.levels > 0 && header_w == 0.0 {
|
||||
if symbols && col_outline.levels > 0 && header_w == 0.0 {
|
||||
band_w = band_w.max(crate::outline::lane(zoom) + 3.0);
|
||||
}
|
||||
let cells = Rect::from_min_max(pos2(rect.left() + band_w + header_w, rect.top() + band_h + header_h), rect.max);
|
||||
@@ -681,6 +702,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
if app.grid.ensure_visible {
|
||||
app.grid.ensure_visible = false;
|
||||
let cell = if std::mem::take(&mut app.grid.reveal_moving_end) { sel.moving_end() } else { sel.active };
|
||||
let from = app.grid.reveal_from.take();
|
||||
let target = app.editor.as_ref().and_then(|e| e.point_cell).unwrap_or(cell);
|
||||
if paper {
|
||||
crate::paper_view::ensure_covers(app, &wb, si, target);
|
||||
@@ -694,6 +716,11 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
} else if cr.bottom() > area.bottom() - 2.0 {
|
||||
view.scroll.y += (cr.bottom() - area.bottom() + 2.0) / geo.z;
|
||||
}
|
||||
} else if from.is_some_and(|f| f.row >= geo.fr) {
|
||||
// A move from the scrolled rows into the frozen ones (Page Up or Ctrl+Up onto a frozen
|
||||
// row 1) brings the scrolled rows back to their top; the rows it passed over would
|
||||
// otherwise stay out of sight.
|
||||
view.scroll.y = 0.0;
|
||||
}
|
||||
if target.col >= geo.fc {
|
||||
if cr.left() < area.left() {
|
||||
@@ -701,6 +728,8 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
} else if cr.right() > area.right() - 2.0 {
|
||||
view.scroll.x += ((cr.right() - area.right() + 2.0) / geo.z).min(cr.left() - area.left());
|
||||
}
|
||||
} else if from.is_some_and(|f| f.col >= geo.fc) {
|
||||
view.scroll.x = 0.0;
|
||||
}
|
||||
view.scroll = view.scroll.max(egui::Vec2::ZERO);
|
||||
geo = make_geo(app, view.scroll);
|
||||
@@ -790,6 +819,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
header_menu(app, ui, previous_popups.2 == app.grid.header_menu);
|
||||
context_menu(app, ui);
|
||||
fill_menu(app, ui, previous_popups.3 == app.grid.fill_menu);
|
||||
fill_options_tag(app, ui, &geo, sh);
|
||||
object_menu(app, ui);
|
||||
error_check_tag(app, ui, &geo, sh);
|
||||
let track_x = if rtl { (available.left(), rect.left()) } else { (rect.right(), available.right()) };
|
||||
@@ -1268,13 +1298,18 @@ pub(crate) fn paint_quadrant(
|
||||
let mut galley = p.layout_no_wrap(first_line.clone(), font.clone(), color);
|
||||
let mut w = galley.size().x;
|
||||
// Numbers and dates never overflow: they show #### when too narrow.
|
||||
if !shrunk && (numeric || matches!(v, Value::Number(_))) && w > avail_w - 2.0 * pad && st.align.h != HAlign::Fill {
|
||||
let n = ((avail_w - 2.0 * pad) / char_w).floor().max(1.0) as usize;
|
||||
let hashed = !shrunk && (numeric || matches!(v, Value::Number(_))) && w > avail_w - 2.0 * pad && st.align.h != HAlign::Fill;
|
||||
if hashed {
|
||||
// Measured on `#` itself: it is wider than `0` in some fonts, and #### must fit its cell.
|
||||
let hash_w = p.layout_no_wrap("#".to_string(), font.clone(), color).size().x.max(1.0);
|
||||
let n = ((avail_w - 2.0 * pad) / hash_w).floor().max(1.0) as usize;
|
||||
galley = p.layout_no_wrap("#".repeat(n), font.clone(), color);
|
||||
w = galley.size().x;
|
||||
}
|
||||
// Repeat fill (`*` in number formats) / Fill alignment.
|
||||
// Repeat fill (`*` in number formats) / Fill alignment. A number already shown as #### keeps
|
||||
// it: rebuilding it from the text would overflow and clip its leading digits.
|
||||
if let Some((ch, pos)) = fill_char
|
||||
&& !hashed
|
||||
&& pos <= first_line.len()
|
||||
{
|
||||
let base_w = w;
|
||||
@@ -2364,7 +2399,15 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
|
||||
d.selection.anchor = sel.anchor;
|
||||
}
|
||||
} else if mods.command {
|
||||
select_ranges(app, RangeRef::cell(c), true, c);
|
||||
// Ctrl+click on a hyperlink's text follows it instead of adding the cell to the selection.
|
||||
if let Some(h) = sh.hyperlinks.get(&c).cloned()
|
||||
&& primary_clicked
|
||||
&& p.x < geo.cell_rect(sh, c).left() + geo.cell_rect(sh, c).width() * 0.8
|
||||
{
|
||||
follow_link(app, &h.target);
|
||||
} else {
|
||||
select_ranges(app, RangeRef::cell(c), true, c);
|
||||
}
|
||||
} else {
|
||||
let _ = app.session.run("selection.set", json!({"cell": c.a1()}));
|
||||
if primary_clicked && wb.styles.get(sh.style_id(c)).num_fmt.as_str() == "checkbox" {
|
||||
@@ -2533,8 +2576,9 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
|
||||
if t != src {
|
||||
// Ctrl (Cmd on macOS) swaps copying and extending, as in Excel.
|
||||
let mode = if mods.command { "alternate" } else { "series" };
|
||||
if let Err(e) = app.run("edit.autoFill", json!({"source": src.a1(), "target": t.a1(), "mode": mode})) {
|
||||
app.message = Some(("GridCraft".into(), crate::clean_error(&e)));
|
||||
match app.run("edit.autoFill", json!({"source": src.a1(), "target": t.a1(), "mode": mode})) {
|
||||
Ok(_) => set_fill_tag(app, src, t),
|
||||
Err(e) => app.message = Some(("GridCraft".into(), crate::clean_error(&e))),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2967,9 +3011,11 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo:
|
||||
let cmd = m.command;
|
||||
let shift = m.shift;
|
||||
let mv = |app: &mut SheetApp, dr: i64, dc: i64| {
|
||||
let from = app.session.active().map(|d| if shift { d.selection.moving_end() } else { d.selection.active });
|
||||
let _ = app.session.run("selection.move", json!({"dr": dr, "dc": dc, "extend": shift, "jump": cmd}));
|
||||
app.grid.ensure_visible = true;
|
||||
app.grid.reveal_moving_end = shift;
|
||||
app.grid.reveal_from = from;
|
||||
};
|
||||
match key {
|
||||
Key::ArrowUp => mv(app, -1, 0),
|
||||
@@ -3317,6 +3363,85 @@ fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The ways to fill a dragged range, as the fill menu and the Auto Fill Options button list them.
|
||||
const FILL_MODES: [(&str, &str); 4] =
|
||||
[("Copy Cells", "copy"), ("Fill Series", "extend"), ("Fill Formatting Only", "formats"), ("Fill Without Formatting", "values")];
|
||||
|
||||
/// Remembers the fill just made so the Auto Fill Options button can offer the other ways.
|
||||
fn set_fill_tag(app: &mut SheetApp, src: RangeRef, target: RangeRef) {
|
||||
app.grid.fill_tag =
|
||||
app.session.active().map(|d| FillTag { src, target, uid: d.uid, sheet: d.wb.active_sheet, revision: d.revision, undo_len: d.undo.len() });
|
||||
}
|
||||
|
||||
/// The Auto Fill Options button below the bottom-right corner of cells a left-drag of the fill
|
||||
/// handle just filled. Its menu redoes that fill as Copy Cells, Fill Series, Fill Formatting Only
|
||||
/// or Fill Without Formatting: it undoes the drag's fill, then runs `edit.autoFill` in that mode.
|
||||
/// The button goes away as soon as anything else changes the workbook or the selection moves.
|
||||
fn fill_options_tag(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet) {
|
||||
let Some(tag) = app.grid.fill_tag.clone() else { return };
|
||||
let current = app.session.active().is_some_and(|d| {
|
||||
d.uid == tag.uid
|
||||
&& d.wb.active_sheet == tag.sheet
|
||||
&& d.revision == tag.revision
|
||||
&& d.undo.len() == tag.undo_len
|
||||
&& d.selection.ranges.len() == 1
|
||||
&& d.selection.ranges.first().is_some_and(|r| *r == tag.target)
|
||||
});
|
||||
if !current {
|
||||
app.grid.fill_tag = None;
|
||||
return;
|
||||
}
|
||||
if app.editor.is_some() || app.dialog.is_some() || app.grid.drag != Drag::None {
|
||||
return;
|
||||
}
|
||||
let size = vec2(18.0, 18.0);
|
||||
let corner = geo.range_rect(sh, tag.target).right_bottom();
|
||||
let button = geo.mirror_rect(Rect::from_min_size(corner + vec2(2.0, 2.0), size));
|
||||
if !geo.cells.contains_rect(button) {
|
||||
return;
|
||||
}
|
||||
let lang = crate::i18n::current(ui.ctx());
|
||||
let resp = ui.interact(button, egui::Id::new("gridcraft.fill_options_tag"), Sense::click()).on_hover_text(lang.tr("Auto Fill Options"));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, lang.tr("Auto Fill Options")));
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let p = ui.painter();
|
||||
p.rect_filled(button, 3.0, if resp.hovered() { t.hover } else { t.window });
|
||||
p.rect_stroke(button, 3.0, Stroke::new(1.0, t.separator), egui::StrokeKind::Inside);
|
||||
// Two small cells and a drop-down arrow.
|
||||
let c = button.center();
|
||||
p.rect_stroke(Rect::from_min_size(pos2(c.x - 6.0, c.y - 6.0), vec2(6.0, 6.0)), 0.0, Stroke::new(1.0, t.text), egui::StrokeKind::Inside);
|
||||
p.rect_stroke(Rect::from_min_size(pos2(c.x - 3.0, c.y - 3.0), vec2(6.0, 6.0)), 0.0, Stroke::new(1.0, t.text), egui::StrokeKind::Inside);
|
||||
p.add(egui::Shape::convex_polygon(
|
||||
vec![pos2(c.x + 1.0, c.y + 5.0), pos2(c.x + 7.0, c.y + 5.0), pos2(c.x + 4.0, c.y + 8.0)],
|
||||
t.text,
|
||||
Stroke::NONE,
|
||||
));
|
||||
let mut chosen = None;
|
||||
egui::Popup::menu(&resp).show(|ui| {
|
||||
ui.set_min_width(190.0);
|
||||
for (label, mode) in FILL_MODES {
|
||||
if ui.add(egui::Button::new(lang.tr(label)).frame(false).min_size(vec2(180.0, 20.0))).clicked() {
|
||||
chosen = Some(mode);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(mode) = chosen {
|
||||
if let Err(e) = app.run("edit.undo", json!({})) {
|
||||
app.grid.fill_tag = None;
|
||||
app.message = Some(("GridCraft".into(), crate::clean_error(&e)));
|
||||
return;
|
||||
}
|
||||
match app.run("edit.autoFill", json!({"source": tag.src.a1(), "target": tag.target.a1(), "mode": mode})) {
|
||||
Ok(_) => set_fill_tag(app, tag.src, tag.target),
|
||||
Err(e) => {
|
||||
app.grid.fill_tag = None;
|
||||
app.message = Some(("GridCraft".into(), crate::clean_error(&e)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The menu a right-drag of the fill handle opens on release, as in Excel.
|
||||
fn fill_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) {
|
||||
let Some((src, target, at)) = app.grid.fill_menu else { return };
|
||||
@@ -3324,14 +3449,7 @@ fn fill_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) {
|
||||
let response = egui::Area::new(egui::Id::new("fill_menu")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||||
ui.set_min_width(190.0);
|
||||
let items = [
|
||||
("Copy Cells", "copy"),
|
||||
("Fill Series", "extend"),
|
||||
("Fill Formatting Only", "formats"),
|
||||
("Fill Without Formatting", "values"),
|
||||
("-", ""),
|
||||
("Series…", "ui:series"),
|
||||
];
|
||||
let items = [FILL_MODES[0], FILL_MODES[1], FILL_MODES[2], FILL_MODES[3], ("-", ""), ("Series…", "ui:series")];
|
||||
let lang = crate::i18n::current(ui.ctx());
|
||||
for (label, mode) in items {
|
||||
if label == "-" {
|
||||
@@ -4216,6 +4334,34 @@ mod pane_tests {
|
||||
assert!(range_in_pane(sel, (2, 20), (2, 10)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_outline_symbols_take_no_room() {
|
||||
let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0));
|
||||
let mut sh = Sheet::new("S");
|
||||
sh.rows.entry(3).or_default().outline = 1;
|
||||
sh.cols.entry(2).or_default().outline = 2;
|
||||
let shown = Geo::new(&sh, rect, vec2(0.0, 0.0));
|
||||
assert!(shown.band_w > 0.0 && shown.band_h > 0.0);
|
||||
sh.show_outline_symbols = false;
|
||||
let hidden = Geo::new(&sh, rect, vec2(0.0, 0.0));
|
||||
assert_eq!((hidden.band_w, hidden.band_h), (0.0, 0.0));
|
||||
assert!(hidden.cells.left() < shown.cells.left() && hidden.cells.top() < shown.cells.top());
|
||||
// Without headings too.
|
||||
sh.show_headings = false;
|
||||
let bare = Geo::new(&sh, rect, vec2(0.0, 0.0));
|
||||
assert_eq!((bare.band_w, bare.band_h), (0.0, 0.0));
|
||||
// No button answers a click on the empty bands' edges (the grid's left and top), where the
|
||||
// level buttons would sit with no headings to hold them.
|
||||
for geo in [&hidden, &bare] {
|
||||
for i in 0..400 {
|
||||
let t = i as f32 * 0.5;
|
||||
for p in [pos2(geo.rect.left(), geo.rect.top() + t), pos2(geo.rect.left() + t, geo.rect.top())] {
|
||||
assert_eq!(crate::outline::hit(geo, &sh, p), None, "{p:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Two frozen rows and columns, scrolled right so that columns C:E lie under the frozen columns.
|
||||
fn frozen_geo(sh: &mut Sheet) -> Geo {
|
||||
sh.freeze = Some((2, 2));
|
||||
|
||||
@@ -980,10 +980,163 @@ pub(super) const DIALOGS: &[(&str, &str)] = &[
|
||||
("Unprotect Sheet…", "シート保護の解除…"),
|
||||
];
|
||||
|
||||
/// Labels of the ribbon menus, pop-ups and palettes that the reference catalog does not cover (the
|
||||
/// Portuguese catalog has no entry for them either). Kept apart so the two catalogs above keep the
|
||||
/// same key set; it never overlaps them.
|
||||
pub(super) const EXTRA: &[(&str, &str)] = &[
|
||||
("Cut (⌘X)", "切り取り (⌘X)"),
|
||||
("Copy (⌘C)", "コピー (⌘C)"),
|
||||
("Bold (⌘B)", "太字 (⌘B)"),
|
||||
("Italic (⌘I)", "斜体 (⌘I)"),
|
||||
("Underline (⌘U)", "下線 (⌘U)"),
|
||||
("AutoSum (⌘⇧T)", "オート SUM (⌘⇧T)"),
|
||||
("Filter (⌘⇧F)", "フィルター (⌘⇧F)"),
|
||||
("Flash Fill (⌘E)", "フラッシュ フィル (⌘E)"),
|
||||
("Insert Link (⌘K)", "リンクの挿入 (⌘K)"),
|
||||
("Paste (⌘V)", "貼り付け (⌘V)"),
|
||||
("Redo (⌘Y)", "やり直し (⌘Y)"),
|
||||
("Save (⌘S)", "上書き保存 (⌘S)"),
|
||||
("Search commands (⌘⇧Space)", "コマンドの検索 (⌘⇧Space)"),
|
||||
("Table (⌘T)", "テーブル (⌘T)"),
|
||||
("Undo (⌘Z)", "元に戻す (⌘Z)"),
|
||||
("New Workbook", "新しいブック"),
|
||||
("Save As…", "名前を付けて保存…"),
|
||||
("Print…", "印刷…"),
|
||||
("Export as PDF…", "PDF としてエクスポート…"),
|
||||
("Save a Copy as Excel Workbook (.xlsx)…", "Excel ブック (.xlsx) としてコピーを保存…"),
|
||||
("Export as CSV…", "CSV としてエクスポート…"),
|
||||
("Export as Web Page (.html)…", "Web ページ (.html) としてエクスポート…"),
|
||||
("Bottom Border", "下罫線"),
|
||||
("Top Border", "上罫線"),
|
||||
("Left Border", "左罫線"),
|
||||
("Right Border", "右罫線"),
|
||||
("No Border", "枠なし"),
|
||||
("All Borders", "格子"),
|
||||
("Outside Borders", "外枠"),
|
||||
("Thick Outside Borders", "太い外枠"),
|
||||
("Bottom Double Border", "下二重罫線"),
|
||||
("Thick Bottom Border", "下太罫線"),
|
||||
("Top and Bottom Border", "上下罫線"),
|
||||
("Top and Thick Bottom Border", "上罫線 + 下太罫線"),
|
||||
("Top and Double Bottom Border", "上罫線 + 下二重罫線"),
|
||||
("Inside Borders", "内側罫線"),
|
||||
("More Borders…", "その他の罫線…"),
|
||||
("More Functions…", "その他の関数…"),
|
||||
("No Fill", "塗りつぶしなし"),
|
||||
("Theme Colors", "テーマの色"),
|
||||
("Standard Colors", "標準の色"),
|
||||
("Highlight Cells Rules", "セルの強調表示ルール"),
|
||||
("Greater Than…", "指定の値より大きい…"),
|
||||
("Less Than…", "指定の値より小さい…"),
|
||||
("Between…", "指定の範囲内…"),
|
||||
("Equal To…", "指定の値に等しい…"),
|
||||
("Text that Contains…", "文字列…"),
|
||||
("A Date Occurring…", "日付…"),
|
||||
("Duplicate Values…", "重複する値…"),
|
||||
("Top/Bottom Rules", "上位/下位ルール"),
|
||||
("Blue", "青"),
|
||||
("Green", "緑"),
|
||||
("Red", "赤"),
|
||||
("Orange", "オレンジ"),
|
||||
("Light Blue", "水色"),
|
||||
("Purple", "紫"),
|
||||
("Green - Yellow - Red", "緑 - 黄 - 赤"),
|
||||
("Red - Yellow - Green", "赤 - 黄 - 緑"),
|
||||
("Green - White - Red", "緑 - 白 - 赤"),
|
||||
("Blue - White - Red", "青 - 白 - 赤"),
|
||||
("White - Green", "白 - 緑"),
|
||||
("White - Red", "白 - 赤"),
|
||||
("3 Arrows", "3 つの矢印"),
|
||||
("3 Traffic Lights", "3 つの信号"),
|
||||
("3 Symbols", "3 つの記号"),
|
||||
("4 Ratings", "4 段階評価"),
|
||||
("5 Arrows", "5 つの矢印"),
|
||||
("5 Quarters", "5 つの四分円"),
|
||||
("New Rule…", "新しいルール…"),
|
||||
("Clear Rules", "ルールのクリア"),
|
||||
("Manage Rules…", "ルールの管理…"),
|
||||
("Good, Bad and Neutral", "良い、悪い、どちらでもない"),
|
||||
("Bad", "悪い"),
|
||||
("Good", "良い"),
|
||||
("Neutral", "どちらでもない"),
|
||||
("Data and Model", "データとモデル"),
|
||||
("Calculation", "計算"),
|
||||
("Check Cell", "チェック セル"),
|
||||
("Explanatory Text", "説明文"),
|
||||
("Input", "入力"),
|
||||
("Linked Cell", "リンク セル"),
|
||||
("Note", "メモ"),
|
||||
("Output", "出力"),
|
||||
("Warning Text", "警告文"),
|
||||
("Titles and Headings", "タイトルと見出し"),
|
||||
("Heading 1", "見出し 1"),
|
||||
("Heading 2", "見出し 2"),
|
||||
("Heading 3", "見出し 3"),
|
||||
("Heading 4", "見出し 4"),
|
||||
("Total", "集計"),
|
||||
("Themed Cell Styles", "テーマのセルのスタイル"),
|
||||
("Number Format", "表示形式"),
|
||||
("Comma [0]", "桁区切り [0]"),
|
||||
("Currency [0]", "通貨表示形式 [0]"),
|
||||
("Percent", "パーセント"),
|
||||
("Place in Cell", "セル内に配置"),
|
||||
("Place over Cells", "セルの上に配置"),
|
||||
("Rectangle", "四角形"),
|
||||
("Rounded Rectangle", "角丸四角形"),
|
||||
("Oval", "楕円"),
|
||||
("Triangle", "三角形"),
|
||||
("Arrow", "矢印"),
|
||||
("Column", "縦棒"),
|
||||
("Win/Loss", "勝敗"),
|
||||
("Type to filter…", "入力して絞り込み…"),
|
||||
("Sort Ascending", "昇順"),
|
||||
("Sort Descending", "降順"),
|
||||
("Search", "検索"),
|
||||
("Apply", "適用"),
|
||||
("20% - Accent1", "20% - アクセント 1"),
|
||||
("20% - Accent2", "20% - アクセント 2"),
|
||||
("20% - Accent3", "20% - アクセント 3"),
|
||||
("20% - Accent4", "20% - アクセント 4"),
|
||||
("20% - Accent5", "20% - アクセント 5"),
|
||||
("20% - Accent6", "20% - アクセント 6"),
|
||||
("40% - Accent1", "40% - アクセント 1"),
|
||||
("40% - Accent2", "40% - アクセント 2"),
|
||||
("40% - Accent3", "40% - アクセント 3"),
|
||||
("40% - Accent4", "40% - アクセント 4"),
|
||||
("40% - Accent5", "40% - アクセント 5"),
|
||||
("40% - Accent6", "40% - アクセント 6"),
|
||||
("60% - Accent1", "60% - アクセント 1"),
|
||||
("60% - Accent2", "60% - アクセント 2"),
|
||||
("60% - Accent3", "60% - アクセント 3"),
|
||||
("60% - Accent4", "60% - アクセント 4"),
|
||||
("60% - Accent5", "60% - アクセント 5"),
|
||||
("60% - Accent6", "60% - アクセント 6"),
|
||||
("Accent1", "アクセント 1"),
|
||||
("Accent2", "アクセント 2"),
|
||||
("Accent3", "アクセント 3"),
|
||||
("Accent4", "アクセント 4"),
|
||||
("Accent5", "アクセント 5"),
|
||||
("Accent6", "アクセント 6"),
|
||||
("Recent Colors", "最近使用した色"),
|
||||
("More Colors…", "その他の色…"),
|
||||
("Hex", "16進"),
|
||||
("Page Break Preview", "改ページ プレビュー"),
|
||||
("Bubble", "バブル"),
|
||||
("Box & Whisker", "箱ひげ図"),
|
||||
("Treemap", "ツリーマップ"),
|
||||
("Sunburst", "サンバースト"),
|
||||
("Stock", "株価"),
|
||||
("Bar", "横棒"),
|
||||
(
|
||||
"Drag to fill cells. Drag from inside a cell to select a range.",
|
||||
"ドラッグしてセルをオートフィルします。セルの内側からドラッグすると範囲を選択します。",
|
||||
),
|
||||
];
|
||||
|
||||
/// Translates an interface label; `None` for a label the catalog does not know, so `i18n.rs` can
|
||||
/// fall back to English. Dialog strings come first, the shared core second.
|
||||
pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
DIALOGS.iter().chain(CORE).find(|(english, _)| *english == text).map(|(_, translated)| *translated)
|
||||
DIALOGS.iter().chain(CORE).chain(EXTRA).find(|(english, _)| *english == text).map(|(_, translated)| *translated)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1088,6 +1241,18 @@ mod tests {
|
||||
assert_eq!(catalog_keys().count(), 954);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_labels_are_translated_and_do_not_overlap_the_catalog() {
|
||||
let catalog: HashSet<&str> = catalog_keys().collect();
|
||||
let mut seen = HashSet::new();
|
||||
for (english, japanese) in EXTRA {
|
||||
assert!(!japanese.trim().is_empty(), "empty translation for {english:?}");
|
||||
assert!(!catalog.contains(english), "{english:?} is already in CORE or DIALOGS");
|
||||
assert!(seen.insert(*english), "duplicate EXTRA key: {english:?}");
|
||||
assert_eq!(translate(english), Some(*japanese));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn covers_every_key_the_portuguese_catalog_translates() {
|
||||
let portuguese = portuguese_keys();
|
||||
|
||||
@@ -1005,7 +1005,31 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Print job submitted: {receipt}" => "Lucrarea de imprimare a fost trimisă: {receipt}",
|
||||
"Print failed: {error}" => "Imprimarea a eșuat: {error}",
|
||||
"Print failed: the printing worker stopped." => "Imprimarea a eșuat: procesul de imprimare s-a oprit.",
|
||||
"Options|Editing" => "Editing",
|
||||
"Options|Editing" => "Editare",
|
||||
"Duplicate" => "Duplicare",
|
||||
"Rename" => "Redenumire",
|
||||
"Product Information" => "Informații despre produs",
|
||||
"Tab Color" => "Culoare filă",
|
||||
"Select All Sheets" => "Selectare toate foile",
|
||||
"Fit selection" => "Încadrare selecție",
|
||||
"All sheets" => "Toate foile",
|
||||
"Version" => "Versiune",
|
||||
"Unhide…" => "Reafișare…",
|
||||
"Platform" => "Platformă",
|
||||
"Unprotect Sheet…" => "Deprotejare foaie…",
|
||||
"AutoSave: save after every change (workbooks saved to a file)" => {
|
||||
"Salvare automată: salvează după fiecare modificare (registrele salvate într-un fișier)"
|
||||
}
|
||||
"Licence" => "Licență",
|
||||
"Account" => "Cont",
|
||||
"No Color" => "Fără culoare",
|
||||
"Move or Copy…" => "Mutare sau copiere…",
|
||||
"Number Stored as Text" => "Număr stocat ca text",
|
||||
"Select Data…" => "Selectare date…",
|
||||
"Convert to Number" => "Conversie în număr",
|
||||
"Format Chart Area…" => "Formatare zonă diagramă…",
|
||||
"Error checking options" => "Opțiuni de verificare a erorilor",
|
||||
"Paste formulas that refer to the copied cells" => "Lipire formule care fac referire la celulele copiate",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -223,9 +223,11 @@ pub(crate) fn dispatch(app: &mut SheetApp, ctx: &egui::Context, key: Key, physic
|
||||
let width = ((geo.cells.width() - geo.frozen_w) / geo.z).max(1.0);
|
||||
let cols = (width / sh.default_col_width.max(1.0)).floor().max(1.0) as i64;
|
||||
let dc = if shortcut == Shortcut::ScreenRight { cols } else { -cols };
|
||||
let from = app.session.active().map(|d| if m.shift { d.selection.moving_end() } else { d.selection.active });
|
||||
let _ = app.session.run("selection.move", json!({"dr": 0, "dc": dc, "extend": m.shift}));
|
||||
app.grid.ensure_visible = true;
|
||||
app.grid.reveal_moving_end = m.shift;
|
||||
app.grid.reveal_from = from;
|
||||
}
|
||||
Shortcut::Edit => app.begin_edit(None, false),
|
||||
Shortcut::ContextMenu => app.grid.context_menu = Some(geo.cell_rect(sh, active).center()),
|
||||
|
||||
@@ -224,6 +224,10 @@ pub fn layout(geo: &Geo, sh: &Sheet) -> Layout {
|
||||
|
||||
/// The button under `p` (logical coordinates), if any.
|
||||
pub fn hit(geo: &Geo, sh: &Sheet, p: Pos2) -> Option<Action> {
|
||||
// Hidden symbols (or no outline) leave empty bands: a click on their edge is a cell click.
|
||||
if geo.band_w <= 0.0 && geo.band_h <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let (rows, cols) = bands(geo);
|
||||
if !rows.contains(p) && !cols.contains(p) {
|
||||
return None;
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
//! Repeat fill (`*` in a number format, as in the Accounting format): a number that fits is drawn
|
||||
//! across its cell, and one too wide for its column shows #### instead of losing its leading digits.
|
||||
|
||||
use egui::Shape;
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_engine::core::CellRef;
|
||||
use gridcraft_ui_egui::{SheetApp, grid::Geo};
|
||||
use serde_json::json;
|
||||
|
||||
type Harness = egui_kittest::Harness<'static, SheetApp>;
|
||||
|
||||
const ACCOUNTING: &str = r#"_-* #,##0.00\ "CHF"_-;\-* #,##0.00\ "CHF"_-;_-* "-"??\ "CHF"_-;_-@_-"#;
|
||||
|
||||
fn harness() -> Harness {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1:B2", "values": [[344.75, 344.75], [2509, 2509]]})).unwrap();
|
||||
s.execute("home.formatCells", json!({"range": "A1:B2", "style": {"num_fmt": ACCOUNTING}})).unwrap();
|
||||
// Column A is too narrow for 2,509.00 CHF; column B fits both.
|
||||
s.execute("home.columnWidth", json!({"cols": "A:A", "chars": 7})).unwrap();
|
||||
s.execute("home.columnWidth", json!({"cols": "B:B", "chars": 20})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "H20"})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(s, Default::default()),
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
/// Painted text shapes whose text satisfies `f`: (text, left x, width).
|
||||
fn texts(h: &Harness, f: impl Fn(&str) -> bool) -> Vec<(String, f32, f32)> {
|
||||
h.output()
|
||||
.shapes
|
||||
.iter()
|
||||
.filter_map(|s| match &s.shape {
|
||||
Shape::Text(t) if f(&t.galley.job.text) => Some((t.galley.job.text.clone(), t.pos.x, t.galley.size().x)),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn too_wide_fill_number_shows_hashes_not_a_clipped_value() {
|
||||
let h = harness();
|
||||
let app = h.state();
|
||||
let sheet = app.session.active().unwrap().wb.active().unwrap();
|
||||
let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll);
|
||||
let a2 = geo.cell_rect(sheet, CellRef::parse("A2").unwrap());
|
||||
let b2 = geo.cell_rect(sheet, CellRef::parse("B2").unwrap());
|
||||
// A2 is drawn as #### inside its cell.
|
||||
let hashes = texts(&h, |t| !t.is_empty() && t.chars().all(|c| c == '#'));
|
||||
assert!(hashes.iter().any(|(_, x, w)| *x >= a2.left() && x + w <= a2.right() + 0.5), "A2 shows ####: {hashes:?}");
|
||||
// The only 2,509.00 drawn is B2's, whole and inside column B.
|
||||
let values = texts(&h, |t| t.contains("509.00"));
|
||||
assert_eq!(values.len(), 1, "{values:?}");
|
||||
let (text, x, w) = &values[0];
|
||||
assert!(text.contains("2,509.00 CHF"), "{text:?}");
|
||||
assert!(*x >= b2.left() && x + w <= b2.right() + 0.5, "B2's value stays in its cell: {values:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_spreads_a_fitting_number_across_its_cell() {
|
||||
let h = harness();
|
||||
let app = h.state();
|
||||
let sheet = app.session.active().unwrap().wb.active().unwrap();
|
||||
let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll);
|
||||
let b1 = geo.cell_rect(sheet, CellRef::parse("B1").unwrap());
|
||||
let drawn = texts(&h, |t| t.contains("344.75 CHF"));
|
||||
let (_, x, w) = drawn.iter().find(|(_, x, _)| *x >= b1.left()).expect("B1 is drawn");
|
||||
// The fill pushes the value to the right edge (less the trailing `_-` space).
|
||||
assert!(b1.right() - (x + w) < 6.0, "B1 ends at its right edge: {drawn:?} in {b1:?}");
|
||||
assert!(*x - b1.left() < 6.0, "the fill starts at the left edge: {drawn:?} in {b1:?}");
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user