mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-24 10:46:50 -06:00
Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8771d51fb | ||
|
|
2329507a8e | ||
|
|
7cf44d0fbb | ||
|
|
81fd0f6f7d | ||
|
|
68da23e2eb | ||
|
|
fba4224ffc | ||
|
|
2a4cc2a5b6 | ||
|
|
ca0c3afbab | ||
|
|
87935eb245 | ||
|
|
c746eea876 | ||
|
|
15a705e234 | ||
|
|
43a5a3c435 | ||
|
|
530a8cc810 | ||
|
|
7111812b87 | ||
|
|
08820cb4b2 | ||
|
|
0c14f59555 | ||
|
|
aa8399be95 | ||
|
|
49f26bdd79 | ||
|
|
39450b80d1 | ||
|
|
33a45130a1 | ||
|
|
a7608ada6d | ||
|
|
45ec9dfbfe | ||
|
|
8f2a60d176 | ||
|
|
599762f534 | ||
|
|
ba98039988 | ||
|
|
a0ceea1468 | ||
|
|
9135c7f6f0 |
+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:
|
||||
|
||||
@@ -11,7 +11,8 @@ permissions:
|
||||
|
||||
concurrency:
|
||||
group: sonarcloud-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
# A push to main is the run that publishes coverage; superseded runs there would never finish.
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
sonarcloud:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -68,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
|
||||
|
||||
@@ -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};
|
||||
@@ -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;
|
||||
@@ -22,7 +22,7 @@ pub fn restore(app: &mut SheetApp, ui: Option<&str>, prefs: Option<&str>) {
|
||||
{
|
||||
// No usable saved language: keep the browser's, as the desktop keeps the system's.
|
||||
let language = gridcraft_ui_egui::i18n::saved_language(&json).unwrap_or(app.ui.language);
|
||||
app.ui = state;
|
||||
app.ui = state.at_startup();
|
||||
app.ui.language = language;
|
||||
}
|
||||
if let Some(s) = prefs.filter(readable)
|
||||
|
||||
@@ -111,7 +111,7 @@ fn load_prefs(app: &mut SheetApp) {
|
||||
{
|
||||
let saved = serde_json::from_slice(&b).ok().and_then(|v| gridcraft_ui_egui::i18n::saved_language(&v));
|
||||
let language = saved.unwrap_or(app.ui.language);
|
||||
app.ui = ui;
|
||||
app.ui = ui.at_startup();
|
||||
app.ui.language = language;
|
||||
}
|
||||
if let Ok(b) = std::fs::read(dir.join("prefs.json"))
|
||||
|
||||
@@ -652,6 +652,24 @@ fn filter_blank(v: &Value) -> bool {
|
||||
v.is_empty() || matches!(v, Value::Text(t) if t.is_empty())
|
||||
}
|
||||
|
||||
/// Excel grows an AutoFilter range when a value is entered in the row right below it, in one of its columns (#2342).
|
||||
/// A table's own filter is left to the table, and a row typed after a blank row does not count.
|
||||
/// A formula counts before it is calculated: this runs inside the edit, ahead of the recalc.
|
||||
pub(crate) fn grow_autofilter(sh: &mut Sheet, at: CellRef) {
|
||||
if !sh.cells.get(at).is_some_and(|c| !filter_blank(&c.value) || c.formula.is_some()) {
|
||||
return;
|
||||
}
|
||||
let Some(af) = sh.autofilter.as_ref() else { return };
|
||||
let r = af.range;
|
||||
if r.end.row.checked_add(1) != Some(at.row) || at.col < r.start.col || at.col > r.end.col || sh.tables.iter().any(|t| t.range == r) {
|
||||
return;
|
||||
}
|
||||
let grown = RangeRef::new(r.start, CellRef::new(at.row, r.end.col));
|
||||
if let Some(af) = sh.autofilter.as_mut() {
|
||||
af.range = grown;
|
||||
}
|
||||
}
|
||||
|
||||
/// Hides rows of the autofilter range that fail any criterion.
|
||||
pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) {
|
||||
let Some(sh) = wb.sheet(sheet) else { return };
|
||||
@@ -1554,6 +1572,33 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autofilter_range_grows_when_a_value_is_entered_in_the_row_below() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["Name", "Qty"], ["d", 4], ["b", 2]]})).unwrap();
|
||||
s.execute("data.filter", json!({"range": "A1:B3"})).unwrap();
|
||||
let range = |s: &Session| s.doc().unwrap().wb.active().unwrap().autofilter.as_ref().map(|a| a.range.a1());
|
||||
s.execute("cell.set", json!({"cell": "A4", "input": "e"})).unwrap();
|
||||
assert_eq!(range(&s), Some("A1:B4".to_string()));
|
||||
s.execute("range.setValues", json!({"range": "A5", "values": [["f", 6]]})).unwrap();
|
||||
assert_eq!(range(&s), Some("A1:B5".to_string()));
|
||||
// A row typed after a blank row does not grow it.
|
||||
s.execute("cell.set", json!({"cell": "A7", "input": "x"})).unwrap();
|
||||
assert_eq!(range(&s), Some("A1:B5".to_string()));
|
||||
// A cell outside the filter's columns does not either.
|
||||
s.execute("cell.set", json!({"cell": "C6", "input": "x"})).unwrap();
|
||||
assert_eq!(range(&s), Some("A1:B5".to_string()));
|
||||
// Empty text is a blank entry, not a value.
|
||||
s.execute("range.setValues", json!({"range": "A6", "parse": false, "values": [[""]]})).unwrap();
|
||||
assert_eq!(range(&s), Some("A1:B5".to_string()));
|
||||
// A formula and Ctrl+Enter (range.fill) are entries too.
|
||||
s.execute("cell.set", json!({"cell": "B6", "input": "=1+1"})).unwrap();
|
||||
assert_eq!(range(&s), Some("A1:B6".to_string()));
|
||||
s.execute("range.fill", json!({"range": "A7:B7", "input": "g"})).unwrap();
|
||||
assert_eq!(range(&s), Some("A1:B7".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_validation_without_formula1_is_an_error() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -272,6 +272,7 @@ fn cell_set(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
sh.remove_cell(at);
|
||||
}
|
||||
}
|
||||
super::data::grow_autofilter(sh, at);
|
||||
cx.touch(sheet, at);
|
||||
Ok(Json::Null)
|
||||
})?;
|
||||
@@ -350,6 +351,7 @@ fn range_set_values(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
sh.remove_cell(at);
|
||||
}
|
||||
}
|
||||
super::data::grow_autofilter(sh, at);
|
||||
cx.touch(sheet, at);
|
||||
count += 1;
|
||||
}
|
||||
@@ -390,7 +392,9 @@ fn range_fill(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let shifted = gridcraft_formula::adjust::shift_relative(e, at.row as i64 - origin.row as i64, at.col as i64 - origin.col as i64);
|
||||
cell.formula = Some(std::sync::Arc::new(Formula::from_expr(shifted)));
|
||||
}
|
||||
cx.sheet_mut(sheet)?.set_cell(at, cell);
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
sh.set_cell(at, cell);
|
||||
super::data::grow_autofilter(sh, at);
|
||||
cx.touch(sheet, at);
|
||||
}
|
||||
}
|
||||
|
||||
+227
-10
@@ -179,6 +179,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
change_source
|
||||
),
|
||||
cmd!(
|
||||
"pivot.showDetails",
|
||||
"Show Details",
|
||||
[],
|
||||
None,
|
||||
"{cell?: \"B5\" (a value, subtotal or grand total cell of a PivotTable on the active sheet; default: the active cell)} puts the source records behind that value on a new sheet before the current one → {sheet, records}",
|
||||
has_doc,
|
||||
show_details
|
||||
),
|
||||
cmd!(
|
||||
"pivot.delete",
|
||||
"Delete PivotTable",
|
||||
@@ -654,16 +663,7 @@ fn insert_pivot(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (si, anchor) = match target {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
let at = cx.wb.active_sheet.min(cx.wb.sheets.len());
|
||||
let nm = cx.wb.next_sheet_name();
|
||||
cx.wb.sheets.insert(at, Arc::new(Sheet::new(nm)));
|
||||
for n in cx.wb.names.iter_mut() {
|
||||
if let Some(sc) = n.scope.as_mut()
|
||||
&& *sc >= at
|
||||
{
|
||||
*sc += 1;
|
||||
}
|
||||
}
|
||||
let at = insert_sheet_before_active(cx);
|
||||
new_sheet_at = Some(at);
|
||||
(at, CellRef::new(2, 0))
|
||||
}
|
||||
@@ -726,6 +726,104 @@ fn insert_pivot(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
/// Inserts a new, empty sheet before the active one (Excel's place for a new PivotTable or a
|
||||
/// Show Details sheet); returns its index.
|
||||
fn insert_sheet_before_active(cx: &mut Ctx) -> usize {
|
||||
let at = cx.wb.active_sheet.min(cx.wb.sheets.len());
|
||||
let nm = cx.wb.next_sheet_name();
|
||||
cx.wb.sheets.insert(at, Arc::new(Sheet::new(nm)));
|
||||
for n in cx.wb.names.iter_mut() {
|
||||
if let Some(sc) = n.scope.as_mut()
|
||||
&& *sc >= at
|
||||
{
|
||||
*sc += 1;
|
||||
}
|
||||
}
|
||||
at
|
||||
}
|
||||
|
||||
/// Show Details (double-click a value cell): the source records behind it, as values with their
|
||||
/// column's number format, under the source headers on a new sheet.
|
||||
fn show_details(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let d = s.doc()?;
|
||||
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
|
||||
let active = d.wb.active_sheet;
|
||||
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
|
||||
let pt = sh
|
||||
.pivots
|
||||
.iter()
|
||||
.find(|pt| pt.last_range.is_some_and(|r| r.contains(at)))
|
||||
.ok_or_else(|| EngineError::Disabled("pivot.showDetails".into(), pv::NOT_A_VALUE_CELL.into()))?;
|
||||
let origin = pt.last_range.map(|r| r.start).unwrap_or_default();
|
||||
let found = pv::detail_records(&d.wb, pt, active, at.row.saturating_sub(origin.row), at.col.saturating_sub(origin.col)).map_err(other)?;
|
||||
let (src, records) = found.ok_or_else(|| EngineError::Disabled("pivot.showDetails".into(), pv::NOT_A_VALUE_CELL.into()))?;
|
||||
if d.wb.protected_structure {
|
||||
return Err(other("The workbook is protected and cannot be changed."));
|
||||
}
|
||||
let count = records.len();
|
||||
let mut new_sheet_at: Option<usize> = None;
|
||||
let res = edit(s, |cx| {
|
||||
let si = insert_sheet_before_active(cx);
|
||||
new_sheet_at = Some(si);
|
||||
let base = cx.wb.styles.get(StyleId::default()).clone();
|
||||
let fmts: Vec<StyleId> = src
|
||||
.formats
|
||||
.iter()
|
||||
.map(|f| {
|
||||
let mut st = base.clone();
|
||||
st.num_fmt = NumFmt::new(f);
|
||||
cx.wb.styles.intern(st)
|
||||
})
|
||||
.collect();
|
||||
// Columns wide enough for their text, as the PivotTable report's are (dates would show
|
||||
// as ####); the first rows are enough to judge a column.
|
||||
let font = base.font.clone();
|
||||
let widths: Vec<f32> = src
|
||||
.headers
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(c, h)| {
|
||||
let fmt = src.formats.get(c).map(String::as_str).unwrap_or("General");
|
||||
let texts = records.iter().take(1_000).filter_map(|i| src.rows.get(*i).and_then(|r| r.get(c)));
|
||||
let widest = texts
|
||||
.map(|v| crate::cmd::format::approx_text_width(&crate::display::format(v, fmt, &cx.wb).text, font.size, false))
|
||||
.fold(crate::cmd::format::approx_text_width(h, font.size, false), f32::max);
|
||||
(widest + 14.0).min(320.0)
|
||||
})
|
||||
.collect();
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
for (c, w) in widths.into_iter().enumerate() {
|
||||
if w > sh.col_width(c as u32) {
|
||||
sh.cols.entry(c as u32).or_default().size = Some(w);
|
||||
}
|
||||
}
|
||||
for (c, h) in src.headers.iter().enumerate() {
|
||||
sh.set_cell(CellRef::new(0, c as u32), Cell { value: gridcraft_core::Value::text(h.as_str()), formula: None, style: StyleId::default() });
|
||||
}
|
||||
for (r, i) in records.iter().enumerate() {
|
||||
let Some(rec) = src.rows.get(*i) else { continue };
|
||||
for (c, v) in rec.iter().enumerate() {
|
||||
let style = fmts.get(c).copied().unwrap_or_default();
|
||||
if v.is_empty() && style == StyleId::default() {
|
||||
continue;
|
||||
}
|
||||
sh.set_cell(CellRef::new(r as u32 + 1, c as u32), Cell { value: v.clone(), formula: None, style });
|
||||
}
|
||||
}
|
||||
let name = sh.name.clone();
|
||||
cx.wb.active_sheet = si;
|
||||
*cx.sel = crate::Selection::at(CellRef::new(0, 0));
|
||||
cx.structural = true;
|
||||
Ok(json!({"sheet": name, "records": count}))
|
||||
})?;
|
||||
if let Some(at) = new_sheet_at
|
||||
&& let Ok(d) = s.doc_mut()
|
||||
{
|
||||
d.sheet_selections.insert(at.min(d.sheet_selections.len()), crate::Selection::default());
|
||||
}
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
fn summarize_table(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let d = s.doc()?;
|
||||
let name = match str_param(p, "table") {
|
||||
@@ -2236,6 +2334,7 @@ mod tests {
|
||||
"data.refreshAll",
|
||||
"pivot.changeSource",
|
||||
"pivot.delete",
|
||||
"pivot.showDetails",
|
||||
];
|
||||
let hostile = [
|
||||
json!({}),
|
||||
@@ -2363,4 +2462,122 @@ mod tests {
|
||||
assert_eq!(l[0]["anchor"], "A3");
|
||||
s.execute("pivot.refresh", json!({"pivot": "PivotTable1"})).unwrap();
|
||||
}
|
||||
|
||||
/// The active sheet's first five columns as shown, down to the first blank row.
|
||||
fn sheet_rows(s: &mut Session) -> Vec<Vec<String>> {
|
||||
let mut out = Vec::new();
|
||||
for r in 0..30u32 {
|
||||
let row: Vec<String> = (0..5)
|
||||
.map(|c| s.execute("cell.get", json!({"cell": CellRef::new(r, c).a1()})).unwrap()["text"].as_str().unwrap_or("").to_string())
|
||||
.collect();
|
||||
if row.iter().all(String::is_empty) {
|
||||
break;
|
||||
}
|
||||
out.push(row);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_details_lists_a_value_cells_records_on_a_new_sheet() {
|
||||
let mut s = sales();
|
||||
s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap();
|
||||
// B5 is East × Apples (100 + 60); the active cell is the default.
|
||||
s.execute("selection.set", json!({"range": "B5"})).unwrap();
|
||||
let r = s.execute("pivot.showDetails", json!({})).unwrap();
|
||||
assert_eq!(r["records"], 2);
|
||||
let d = s.doc().unwrap();
|
||||
assert_eq!(d.wb.sheets.len(), 3);
|
||||
assert_eq!(d.wb.active_sheet, 0, "the new sheet goes before the PivotTable's");
|
||||
assert_eq!(d.wb.active().unwrap().name, "Sheet3");
|
||||
assert_eq!(
|
||||
sheet_rows(&mut s),
|
||||
vec![
|
||||
vec!["Region", "Product", "Date", "Units", "Sales"],
|
||||
vec!["East", "Apples", "1/15/2026", "10", "100"],
|
||||
vec!["East", "Apples", "9/9/2026", "6", "60"],
|
||||
]
|
||||
);
|
||||
// The dates fit their column instead of showing ####.
|
||||
let sh = s.doc().unwrap().wb.active().unwrap().clone();
|
||||
assert!(sh.col_width(2) > sh.col_width(5), "{} vs {}", sh.col_width(2), sh.col_width(5));
|
||||
// One undo step takes the sheet away.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().wb.sheets.len(), 2);
|
||||
assert_eq!(val(&s, "B5"), Value::Number(160.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_details_on_a_total_takes_every_record_it_adds_up() {
|
||||
let mut s = sales();
|
||||
s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap();
|
||||
let records = |s: &mut Session, cell: &str| {
|
||||
let n = s.execute("pivot.showDetails", json!({"cell": cell})).unwrap()["records"].as_u64().unwrap();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
n
|
||||
};
|
||||
assert_eq!(records(&mut s, "E5"), 5, "East's row total");
|
||||
assert_eq!(records(&mut s, "C8"), 4, "Pears' column total");
|
||||
assert_eq!(records(&mut s, "E8"), 12, "the grand total");
|
||||
// A subtotal at the top of its group, in a two-level row axis.
|
||||
s.execute("pivot.moveField", json!({"field": "Product", "from": "columns", "to": "rows"})).unwrap();
|
||||
assert_eq!(text(&s, "A4"), "East");
|
||||
assert_eq!(records(&mut s, "B4"), 5);
|
||||
assert_eq!(text(&s, "A6"), "Pears");
|
||||
assert_eq!(records(&mut s, "B6"), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_details_leaves_out_the_records_the_report_filters_out() {
|
||||
let mut s = sales();
|
||||
s.execute("insert.pivotTable", json!({"rows": ["Product"], "values": ["Sales"], "filters": ["Region"]})).unwrap();
|
||||
s.execute("pivot.filter", json!({"field": "Region", "selected": ["East"]})).unwrap();
|
||||
s.execute("pivot.filter", json!({"field": "Product", "selected": ["Apples"]})).unwrap();
|
||||
let gt = find_row(&s, "Grand Total").unwrap();
|
||||
let r = s.execute("pivot.showDetails", json!({"cell": CellRef::new(gt, 1).a1()})).unwrap();
|
||||
assert_eq!(r["records"], 2);
|
||||
let rows = sheet_rows(&mut s);
|
||||
assert_eq!(rows.len(), 3);
|
||||
assert!(rows.iter().skip(1).all(|row| row[0] == "East" && row[1] == "Apples"), "{rows:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_details_refuses_a_report_its_source_has_moved_on_from() {
|
||||
let mut s = sales();
|
||||
s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap();
|
||||
// B5 still shows East × Apples (160), but the first East record is now Central's.
|
||||
s.execute("range.setValues", json!({"range": "Sheet1!A2", "values": [["Central"]]})).unwrap();
|
||||
assert_eq!(val(&s, "B5"), Value::Number(160.0));
|
||||
let e = s.execute("pivot.showDetails", json!({"cell": "B5"})).unwrap_err().to_string();
|
||||
assert!(e.contains("out of date"), "{e}");
|
||||
assert_eq!(s.doc().unwrap().wb.sheets.len(), 2);
|
||||
// After a refresh East × Apples (now B6) has the one record left.
|
||||
s.execute("pivot.refresh", json!({})).unwrap();
|
||||
assert_eq!(text(&s, "A6"), "East");
|
||||
assert_eq!(s.execute("pivot.showDetails", json!({"cell": "B6"})).unwrap()["records"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_details_refuses_a_report_that_would_now_be_shorter() {
|
||||
let mut s = sales();
|
||||
s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap();
|
||||
// North goes away: the source now gives one row fewer, so row 8 is no longer the Grand Total.
|
||||
s.execute("range.setValues", json!({"range": "Sheet1!A5", "values": [["East"]]})).unwrap();
|
||||
s.execute("range.setValues", json!({"range": "Sheet1!A8", "values": [["East"]]})).unwrap();
|
||||
s.execute("range.setValues", json!({"range": "Sheet1!A11", "values": [["East"]]})).unwrap();
|
||||
assert_eq!(text(&s, "A8"), "Grand Total");
|
||||
let e = s.execute("pivot.showDetails", json!({"cell": "E8"})).unwrap_err().to_string();
|
||||
assert!(e.contains("out of date"), "{e}");
|
||||
assert_eq!(s.doc().unwrap().wb.sheets.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_details_needs_a_value_cell() {
|
||||
let mut s = sales();
|
||||
s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap();
|
||||
for cell in ["A3", "B3", "A4", "B4", "A5", "A8", "H20"] {
|
||||
assert!(s.execute("pivot.showDetails", json!({"cell": cell})).is_err(), "{cell}");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().wb.sheets.len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -803,6 +803,51 @@ fn data_cols_exceed_sheet(col_groups: usize, grand_total: bool, values: usize) -
|
||||
/// Computes a PivotTable report from the workbook.
|
||||
pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Output, String> {
|
||||
let src = read_source(wb, &pt.source, pivot_sheet)?;
|
||||
compute_from(wb, pt, &src, None)
|
||||
}
|
||||
|
||||
/// Why Show Details turns a cell down when it isn't a value cell of a PivotTable (the front end
|
||||
/// edits that cell instead).
|
||||
pub const NOT_A_VALUE_CELL: &str = "select a value cell in a PivotTable";
|
||||
|
||||
/// Show Details: the source list and the indices of its records behind the value cell at
|
||||
/// (`row`, `col`) of the report grid (relative to its top-left), in source order. `None` when
|
||||
/// that cell holds no value (a label, a header, a blank). An error, for any cell, when the report
|
||||
/// on the sheet is no longer what the source computes: the cell's records would be another cell's.
|
||||
pub fn detail_records(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize, row: u32, col: u32) -> Result<Option<(Source, Vec<usize>)>, String> {
|
||||
let src = read_source(wb, &pt.source, pivot_sheet)?;
|
||||
let mut probe = Probe { at: (row as usize, col as usize), hit: None, paths: Vec::new() };
|
||||
let out = compute_from(wb, pt, &src, Some(&mut probe))?;
|
||||
// Excel reads its PivotTable cache; without one, the report written by the last refresh
|
||||
// must still be what the source gives. Checked first: in a stale report even a cell that
|
||||
// computes as a label may show a value.
|
||||
let sh = wb.sheet(pivot_sheet).ok_or("There's no such sheet.")?;
|
||||
let current = pt.last_range.is_some_and(|r| {
|
||||
r.height() == out.height().max(1)
|
||||
&& r.width() == out.width.max(1)
|
||||
&& out.grid.iter().enumerate().all(|(ri, line)| {
|
||||
line.iter().enumerate().all(|(ci, oc)| {
|
||||
sh.value_ref(CellRef::new(r.start.row + ri as u32, r.start.col + ci as u32)).unwrap_or(&Value::Empty) == &oc.value
|
||||
})
|
||||
})
|
||||
});
|
||||
if !current {
|
||||
return Err("The PivotTable report is out of date. Refresh it first.".into());
|
||||
}
|
||||
let Some((rp, cp)) = probe.hit else { return Ok(None) };
|
||||
let records = probe.paths.into_iter().filter(|(_, r, c)| r.starts_with(&rp) && c.starts_with(&cp)).map(|(i, _, _)| i).collect();
|
||||
Ok(Some((src, records)))
|
||||
}
|
||||
|
||||
/// What [`detail_records`] asks of a report computation: the row and column item paths of the
|
||||
/// value cell at `at`, and the paths of every record the filters let through.
|
||||
struct Probe {
|
||||
at: (usize, usize),
|
||||
hit: Option<(Vec<u32>, Vec<u32>)>,
|
||||
paths: Vec<(usize, Vec<u32>, Vec<u32>)>,
|
||||
}
|
||||
|
||||
fn compute_from(wb: &Workbook, pt: &PivotTable, src: &Source, mut probe: Option<&mut Probe>) -> Result<Output, String> {
|
||||
let fmt_of = |c: usize| src.formats.get(c).cloned().unwrap_or_else(|| "General".into());
|
||||
let rows: Vec<(usize, &PivotField)> = pt.rows.iter().filter_map(|f| src.col(&f.source_col).map(|c| (c, f))).collect();
|
||||
let cols: Vec<(usize, &PivotField)> = pt.columns.iter().filter_map(|f| src.col(&f.source_col).map(|c| (c, f))).collect();
|
||||
@@ -914,6 +959,9 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
|
||||
if !col_leaves.contains(&cp[..shown]) {
|
||||
col_leaves.insert(cp[..shown].to_vec());
|
||||
}
|
||||
if let Some(pr) = probe.as_deref_mut() {
|
||||
pr.paths.push((ri, rp.clone(), cp.clone()));
|
||||
}
|
||||
row_set.insert(rp);
|
||||
col_set.insert(cp);
|
||||
// Refuse a report that cannot fit before the accumulators for more column groups are allocated.
|
||||
@@ -1278,6 +1326,11 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
|
||||
_ => raw,
|
||||
},
|
||||
};
|
||||
if let Some(pr) = probe.as_deref_mut()
|
||||
&& pr.at == (r, l + j)
|
||||
{
|
||||
pr.hit = Some((rp.to_vec(), cp.to_vec()));
|
||||
}
|
||||
g.set(r, l + j, shown, ck.1, 0, data_fmt.get(*vi).cloned().flatten());
|
||||
}
|
||||
}
|
||||
|
||||
+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,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;
|
||||
@@ -0,0 +1,4 @@
|
||||
//! The crate's integration tests, one module per file, built as one test binary.
|
||||
|
||||
mod printer_roundtrip;
|
||||
mod spanish_input;
|
||||
@@ -306,12 +306,10 @@ fn modulo(x: f64, d: f64) -> R<f64> {
|
||||
if d == 0.0 {
|
||||
return Err(CellError::Div0);
|
||||
}
|
||||
let q = (x / d).floor();
|
||||
let r = x - d * q;
|
||||
// `%` is an exact remainder (truncated), so |r| < |d| even for tiny divisors.
|
||||
let r = x % d;
|
||||
// Keep the sign of the divisor and avoid returning |d| from rounding.
|
||||
if r != 0.0 && (r < 0.0) != (d < 0.0) {
|
||||
return Ok(r + d);
|
||||
}
|
||||
let r = if r != 0.0 && (r < 0.0) != (d < 0.0) { r + d } else { r };
|
||||
if r.abs() >= d.abs() {
|
||||
return Ok(0.0);
|
||||
}
|
||||
@@ -1442,6 +1440,19 @@ mod tests {
|
||||
close(ev1("ODD", 0.0), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mod_tiny_divisor_stays_in_range() {
|
||||
for (x, d) in [(3.0, 1e-300), (-1.0, 1e-300), (3.0, -1e-300), (-1.0, -1e-300)] {
|
||||
match ev2("MOD", x, d) {
|
||||
Value::Number(r) => {
|
||||
assert!(r == 0.0 || (r < 0.0) == (d < 0.0), "MOD({x}, {d}) = {r}");
|
||||
assert!(r.abs() < d.abs(), "MOD({x}, {d}) = {r}");
|
||||
}
|
||||
other => panic!("MOD({x}, {d}) = {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arithmetic() {
|
||||
close(ev2("MOD", 3.0, 2.0), 1.0);
|
||||
|
||||
@@ -1084,11 +1084,8 @@ mod tests {
|
||||
}
|
||||
assert!(age(Language::En, f64::MAX).ends_with(" years ago"));
|
||||
// Other languages put the count where their own words want it.
|
||||
assert_eq!(age(Language::Ja, 3.0 * DAY), "3 日前");
|
||||
assert_eq!(age(Language::De, 3.0 * DAY), "vor 3 Tagen");
|
||||
assert_eq!(age(Language::Fr, 90.0), "il y a 1 min");
|
||||
assert_eq!(age(Language::It, 3.0 * DAY), "3 giorni fa");
|
||||
for lang in Language::ALL {
|
||||
assert!(age(lang, 3.0 * DAY).contains('3'), "{lang:?}: {}", age(lang, 3.0 * DAY));
|
||||
for seconds in [10.0, 600.0, 7200.0, 1.5 * DAY, 5.0 * DAY, 30.0 * DAY, 200.0 * DAY, 2000.0 * DAY] {
|
||||
let words = age(lang, seconds);
|
||||
assert!(!words.is_empty() && !words.contains('{'), "{lang:?} {seconds}: {words}");
|
||||
|
||||
@@ -105,13 +105,6 @@ fn split_signature(signature: &str) -> Option<(&str, Vec<&str>)> {
|
||||
Some((head, if inner.is_empty() { Vec::new() } else { inner.split(", ").collect() }))
|
||||
}
|
||||
|
||||
/// The catalog names of a signature's arguments (`number` for `[number2]`), without `...`; the
|
||||
/// function help tables are checked against them.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn argument_names(signature: &str) -> Vec<&str> {
|
||||
split_signature(signature).map(|(_, args)| args.into_iter().map(argument_name).filter(|name| *name != "...").collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// `[number2]` -> `number`. A bracket can open or close on its own (SWITCH's
|
||||
/// `[default_or_value2, result2]`, LAMBDA's `[parameter1, ...]`).
|
||||
fn argument_name(arg: &str) -> &str {
|
||||
|
||||
@@ -1300,7 +1300,9 @@ pub(crate) fn paint_quadrant(
|
||||
// Numbers and dates never overflow: they show #### when too narrow.
|
||||
let hashed = !shrunk && (numeric || matches!(v, Value::Number(_))) && w > avail_w - 2.0 * pad && st.align.h != HAlign::Fill;
|
||||
if hashed {
|
||||
let n = ((avail_w - 2.0 * pad) / char_w).floor().max(1.0) as usize;
|
||||
// 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;
|
||||
}
|
||||
@@ -2386,6 +2388,20 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
|
||||
&& !mods.shift
|
||||
{
|
||||
app.grid.last_click = None;
|
||||
// A PivotTable's value cell shows its source records on a new sheet (Excel's Show
|
||||
// Details); its labels and headers still edit.
|
||||
if sh.pivots.iter().any(|pt| pt.last_range.is_some_and(|r| r.contains(c))) {
|
||||
let not_a_value_cell =
|
||||
gridcraft_engine::EngineError::Disabled("pivot.showDetails".into(), gridcraft_engine::pivot::NOT_A_VALUE_CELL.into()).to_string();
|
||||
match app.run("pivot.showDetails", json!({"cell": c.a1()})) {
|
||||
Ok(_) => return,
|
||||
Err(e) if e == not_a_value_cell => {}
|
||||
Err(e) => {
|
||||
app.message = Some(("GridCraft".into(), crate::clean_error(&e)));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Following a hyperlink requires a single click in Excel; double-click edits.
|
||||
app.begin_edit(None, false);
|
||||
return;
|
||||
@@ -2397,9 +2413,10 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
|
||||
d.selection.anchor = sel.anchor;
|
||||
}
|
||||
} else if mods.command {
|
||||
// Ctrl+click on a hyperlink's text follows it instead of adding the cell to the selection.
|
||||
// Ctrl+click (without Alt) 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
|
||||
&& !mods.alt
|
||||
&& p.x < geo.cell_rect(sh, c).left() + geo.cell_rect(sh, c).width() * 0.8
|
||||
{
|
||||
follow_link(app, &h.target);
|
||||
@@ -3087,6 +3104,19 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo:
|
||||
}
|
||||
}
|
||||
|
||||
/// Room the in-cell editor keeps right of its text. It is sized from the text of the last frame, so
|
||||
/// the next typed character must still fit: a fixed 14 px was less than one glyph at high zoom,
|
||||
/// and the field scrolled and snapped back on every key (#2336). `size` is the font size in px.
|
||||
fn editor_slack(size: f32) -> f32 {
|
||||
14.0 + size
|
||||
}
|
||||
|
||||
/// Width of the one-line in-cell editor. While the text fits the cell (with the usual 14 px margin) the
|
||||
/// editor is exactly the cell wide, as in Excel; once it overflows, the extra `editor_slack` applies.
|
||||
fn editor_width(text_w: f32, cell_w: f32, size: f32) -> f32 {
|
||||
if text_w + 14.0 <= cell_w { cell_w } else { text_w + editor_slack(size) }
|
||||
}
|
||||
|
||||
fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet, wb: &Workbook) {
|
||||
let Some(ed) = app.editor.as_ref() else { return };
|
||||
if ed.sheet != wb.active_sheet {
|
||||
@@ -3113,7 +3143,7 @@ fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet,
|
||||
let text_w = ui.painter().layout_no_wrap(ed.text.clone(), font.clone(), Color32::BLACK).size().x;
|
||||
// Excel keeps the cell's height for one line and only grows for extra lines.
|
||||
let h = if lines > 1.0 { (size * 1.3 * lines + 4.0).max(rect.height()) } else { rect.height() };
|
||||
((text_w + 14.0).max(rect.width()).min(geo.cells.right() - rect.left()), h)
|
||||
(editor_width(text_w, rect.width(), size).min(geo.cells.right() - rect.left()), h)
|
||||
};
|
||||
// Grows to the left on a right-to-left sheet.
|
||||
let erect = geo.mirror_rect(Rect::from_min_size(rect.min, vec2(w, h)));
|
||||
@@ -3395,7 +3425,8 @@ fn fill_options_tag(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet
|
||||
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) {
|
||||
// `button` is in screen space, so compare it with the mirrored cells area.
|
||||
if !geo.mirror_rect(geo.cells).contains_rect(button) {
|
||||
return;
|
||||
}
|
||||
let lang = crate::i18n::current(ui.ctx());
|
||||
@@ -4002,6 +4033,23 @@ pub(crate) fn heading_font_size(zoom: f32) -> f32 {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
#[test]
|
||||
fn editor_matches_the_cell_while_the_text_fits() {
|
||||
use super::editor_width;
|
||||
assert_eq!(editor_width(10.0, 80.0, 7.0), 80.0);
|
||||
assert_eq!(editor_width(66.0, 80.0, 7.0), 80.0);
|
||||
assert_eq!(editor_width(67.0, 80.0, 7.0), 88.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_slack_fits_one_more_glyph_at_high_zoom() {
|
||||
use super::editor_slack;
|
||||
// A glyph is at most about one em wide; at 400% an em is ~60 px, far over the old fixed 14 px.
|
||||
for size in [14.7_f32, 25.0, 60.0] {
|
||||
assert!(editor_slack(size) - 4.0 >= size, "slack {} too small for a {size} px glyph", editor_slack(size));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_left_and_right_keep_their_side_on_a_right_to_left_sheet() {
|
||||
use super::{HAlign, painted_halign};
|
||||
|
||||
+19
-191
@@ -321,21 +321,14 @@ fn name_sort_key(name: &str) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Help tables are sorted, unique, and keyed by text the function catalog still has.
|
||||
/// Help tables are sorted and unique: lookups binary-search them.
|
||||
#[test]
|
||||
fn function_help_is_sorted_and_keyed_by_current_catalog_text() {
|
||||
// Lookups binary-search the tables. A key that no longer matches the catalog (an English
|
||||
// description reworded, an argument renamed) would quietly show English again.
|
||||
let catalog = gridcraft_engine::cmd::formulas::function_list();
|
||||
let descriptions: std::collections::HashSet<&str> = catalog.iter().filter_map(|f| f["description"].as_str()).collect();
|
||||
let arguments: std::collections::HashSet<&str> =
|
||||
catalog.iter().filter_map(|f| f["signature"].as_str()).flat_map(crate::formula_locale::argument_names).collect();
|
||||
fn function_help_tables_are_sorted_and_unique() {
|
||||
for language in Language::ALL {
|
||||
let Some((description_table, argument_table)) = language.function_help() else { continue };
|
||||
for (table, catalog_text) in [(description_table, &descriptions), (argument_table, &arguments)] {
|
||||
for table in [description_table, argument_table] {
|
||||
assert!(table.windows(2).all(|pair| pair[0].0 < pair[1].0), "{language:?} function help is not sorted and unique");
|
||||
for (english, translated) in table {
|
||||
assert!(catalog_text.contains(english), "{language:?} translates {english:?}, which the function catalog doesn't use");
|
||||
assert!(!translated.is_empty(), "{language:?}: empty translation of {english:?}");
|
||||
}
|
||||
}
|
||||
@@ -343,105 +336,27 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translations_are_unique_and_preserve_unknown_text() {
|
||||
for (i, (en, ja)) in ja::CORE.iter().enumerate() {
|
||||
assert!(!ja.is_empty());
|
||||
assert!(ja::CORE.iter().take(i).all(|(other, _)| en != other));
|
||||
assert_eq!(Language::En.tr(en), en.rsplit_once('|').map_or(*en, |(_, label)| label));
|
||||
}
|
||||
fn untranslated_text_falls_back_to_english() {
|
||||
let label = |key: &'static str| key.rsplit_once('|').map_or(key, |(_, label)| label);
|
||||
for (english, _) in ja::CORE {
|
||||
assert!(zh::translate(english).is_some_and(|text| !text.is_empty()), "missing Simplified Chinese: {english}");
|
||||
assert!(ko::translate(english).is_some_and(|text| !text.is_empty()), "missing Korean: {english}");
|
||||
assert!(ru::translate(english).is_some_and(|text| !text.is_empty()), "missing Russian: {english}");
|
||||
assert!(pt::translate(english).is_some_and(|text| !text.is_empty()), "missing Portuguese: {english}");
|
||||
assert!(es::translate(english).is_some_and(|text| !text.is_empty()), "missing Spanish: {english}");
|
||||
assert!(el::translate(english).is_some_and(|text| !text.is_empty()), "missing Greek: {english}");
|
||||
assert!(de::translate(english).is_some_and(|text| !text.is_empty()), "missing German: {english}");
|
||||
assert!(cs::translate(english).is_some_and(|text| !text.is_empty()), "missing Czech: {english}");
|
||||
assert!(fr::translate(english).is_some_and(|text| !text.is_empty()), "missing French: {english}");
|
||||
assert!(sr::translate(english).is_some_and(|text| !text.is_empty()), "missing Serbian: {english}");
|
||||
assert!(tr::translate(english).is_some_and(|text| !text.is_empty()), "missing Turkish: {english}");
|
||||
assert!(pl::translate(english).is_some_and(|text| !text.is_empty()), "missing Polish: {english}");
|
||||
assert!(it::translate(english).is_some_and(|text| !text.is_empty()), "missing Italian: {english}");
|
||||
assert!(uk::translate(english).is_some_and(|text| !text.is_empty()), "missing Ukrainian: {english}");
|
||||
assert!(vi::translate(english).is_some_and(|text| !text.is_empty()), "missing Vietnamese: {english}");
|
||||
assert!(nl::translate(english).is_some_and(|text| !text.is_empty()), "missing Dutch: {english}");
|
||||
assert!(ro::translate(english).is_some_and(|text| !text.is_empty()), "missing Romanian: {english}");
|
||||
assert_eq!(Language::En.tr(english), label(english));
|
||||
}
|
||||
assert_eq!(Language::Ja.tr("Data"), "データ");
|
||||
assert_eq!(Language::Zh.tr("Data"), "数据");
|
||||
assert_eq!(Language::Ko.tr("Data"), "데이터");
|
||||
assert_eq!(Language::Ru.tr("Data"), "Данные");
|
||||
assert_eq!(Language::PtBr.tr("Data"), "Dados");
|
||||
assert_eq!(Language::PtBr.tr_error("Select a chart first."), "Selecione um gráfico primeiro.");
|
||||
assert_eq!(Language::PtBr.tr_error("Sheet|Unknown failure"), "Sheet|Unknown failure");
|
||||
assert_eq!(Language::PtBr.tr_error("command `chart`: Select a chart first."), "Selecione um gráfico primeiro.");
|
||||
assert_eq!(Language::PtBr.tr_error("Printer Office: permission denied"), "Printer Office: permission denied");
|
||||
assert_eq!(Language::Es.tr("Data"), "Datos");
|
||||
assert_eq!(Language::El.tr("Data"), "Δεδομένα");
|
||||
assert_eq!(Language::El.tr("Page Layout|Orientation"), "Προσανατολισμός σελίδας");
|
||||
assert_eq!(Language::El.tr("Sheet1!A1"), "Sheet1!A1");
|
||||
assert_eq!(Language::El.tr("Page {page} of {pages}"), "Σελίδα {page} από {pages}");
|
||||
assert_eq!(Language::Es.tr("Freeze Panes"), "Inmovilizar paneles");
|
||||
assert_eq!(Language::Es.tr("Sheet1!A1"), "Sheet1!A1");
|
||||
assert_eq!(Language::De.tr("Data"), "Daten");
|
||||
assert_eq!(Language::De.tr("Sheet1!A1"), "Sheet1!A1");
|
||||
assert_eq!(Language::Fr.tr("Data"), "Données");
|
||||
assert_eq!(Language::Fr.tr("Sheet1!A1"), "Sheet1!A1");
|
||||
assert_eq!(Language::Tr.tr("Data"), "Veri");
|
||||
assert_eq!(Language::Tr.tr("Sheet1!A1"), "Sheet1!A1");
|
||||
assert_eq!(Language::PtBr.tr("Date"), "Data");
|
||||
assert_eq!(Language::PtBr.tr("Sheet1!A1"), "Sheet1!A1");
|
||||
assert_eq!(Language::Ja.tr("Sheet1!A1"), "Sheet1!A1");
|
||||
assert_eq!(Language::Zh.tr("Sheet1!A1"), "Sheet1!A1");
|
||||
assert_eq!(Language::Ko.tr("Sheet1!A1"), "Sheet1!A1");
|
||||
assert_eq!(Language::Ru.tr("Sheet1!A1"), "Sheet1!A1");
|
||||
// Czech "Data" is spelled as in English, so probe it with a label that differs.
|
||||
assert_eq!(Language::Cs.tr("Data"), "Data");
|
||||
assert_eq!(Language::Cs.tr("Formulas"), "Vzorce");
|
||||
assert_eq!(Language::Cs.tr("Sheet1!A1"), "Sheet1!A1");
|
||||
assert_eq!(Language::Tr.tr("Formulas"), "Formüller");
|
||||
// A `context|label` key shows only its label in English and splits the translation.
|
||||
for language in Language::ALL {
|
||||
assert_eq!(language.tr("Sheet1!A1"), "Sheet1!A1");
|
||||
assert_eq!(language.tr("Not in any catalog|Untranslated"), "Untranslated");
|
||||
}
|
||||
// A `context|label` key shows only its label in English.
|
||||
assert_eq!(Language::En.tr("Page Layout|Orientation"), "Orientation");
|
||||
assert_eq!(Language::Ja.tr("Page Layout|Orientation"), "印刷の向き");
|
||||
assert_eq!(Language::Ja.tr("Orientation"), "方向");
|
||||
assert_eq!(Language::Cs.tr("Page Layout|Orientation"), "Orientace");
|
||||
assert_eq!(Language::Cs.tr("Orientation"), "Orientace textu");
|
||||
assert_eq!(Language::Sr.tr("Data"), "Podaci");
|
||||
assert_eq!(Language::Sr.tr("Sheet1!A1"), "Sheet1!A1");
|
||||
assert_eq!(Language::Sr.tr("Page Layout|Orientation"), "Orijentacija");
|
||||
assert_eq!(Language::Sr.tr("Orientation"), "Orijentacija");
|
||||
assert_eq!(Language::Tr.tr("Page Layout|Orientation"), "Sayfa Yönü");
|
||||
assert_eq!(Language::Tr.tr("Orientation"), "Yönlendirme");
|
||||
assert_eq!(Language::Pl.tr("Data"), "Dane");
|
||||
assert_eq!(Language::Pl.tr("Sheet1!A1"), "Sheet1!A1");
|
||||
assert_eq!(Language::Pl.tr("Page Layout|Orientation"), "Orientacja");
|
||||
assert_eq!(Language::It.tr("Data"), "Dati");
|
||||
assert_eq!(Language::It.tr("Sheet1!A1"), "Sheet1!A1");
|
||||
assert_eq!(Language::It.tr("Freeze Panes"), "Blocca riquadri");
|
||||
assert_eq!(Language::It.tr("Page Layout|Orientation"), "Orientamento");
|
||||
assert_eq!(Language::It.tr("Orientation"), "Orientamento");
|
||||
assert_eq!(Language::Uk.tr("Data"), "Дані");
|
||||
assert_eq!(Language::Uk.tr("Sheet1!A1"), "Sheet1!A1");
|
||||
assert_eq!(Language::Vi.tr("Data"), "Dữ liệu");
|
||||
assert_eq!(Language::Vi.tr("Sheet1!A1"), "Sheet1!A1");
|
||||
assert_eq!(Language::Vi.tr("Page Layout|Orientation"), "Hướng giấy");
|
||||
assert_eq!(Language::Vi.tr("Orientation"), "Hướng chữ");
|
||||
assert_eq!(Language::Nl.tr("Data"), "Gegevens");
|
||||
assert_eq!(Language::Nl.tr("Sheet1!A1"), "Sheet1!A1");
|
||||
assert_eq!(Language::Nl.tr("Page Layout|Orientation"), "Paginastand");
|
||||
assert_eq!(Language::Nl.tr("Orientation"), "Richting");
|
||||
assert_eq!(Language::Ro.tr("Data"), "Date");
|
||||
assert_eq!(Language::Ro.tr("Sheet1!A1"), "Sheet1!A1");
|
||||
assert_eq!(Language::Ro.tr("Page Layout|Orientation"), "Orientare");
|
||||
assert_eq!(Language::Ro.tr("Orientation"), "Orientare text");
|
||||
// Errors drop the `command `id`:` prefix before translating; an untranslated one stays whole.
|
||||
assert_eq!(Language::PtBr.tr_error("command `chart`: Select a chart first."), Language::PtBr.tr("Select a chart first."));
|
||||
assert_eq!(Language::PtBr.tr_error("Sheet|Unknown failure"), "Sheet|Unknown failure");
|
||||
assert_eq!(Language::PtBr.tr_error("Printer Office: permission denied"), "Printer Office: permission denied");
|
||||
assert_eq!(Language::parse("xx"), None);
|
||||
}
|
||||
|
||||
/// A catalog whose translations slip onto their neighbours' keys still covers every key, so
|
||||
/// check the shape of each translation against its English: placeholders survive, and a
|
||||
/// one-line label (a menu item, a tooltip) doesn't turn into a two-line ribbon label. French
|
||||
/// once shipped with over a hundred translations shifted this way ("Close" read "Société").
|
||||
/// one-line label (a menu item, a tooltip) doesn't turn into a two-line ribbon label.
|
||||
#[test]
|
||||
fn translations_keep_the_shape_of_their_english() {
|
||||
let placeholders =
|
||||
@@ -457,106 +372,19 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
for (english, french) in [
|
||||
("Close", "Fermer"),
|
||||
("File", "Fichier"),
|
||||
("Save", "Enregistrer"),
|
||||
("Animations", "Animations"),
|
||||
("Company", "Société"),
|
||||
("Show Detail", "Afficher le détail"),
|
||||
("Solver", "Solveur"),
|
||||
] {
|
||||
assert_eq!(Language::Fr.tr(english), french);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn german_cell_and_header_context_menus_are_fully_translated() {
|
||||
for label in [
|
||||
"Cut",
|
||||
"Copy",
|
||||
"Paste",
|
||||
"Paste Special…",
|
||||
"Insert…",
|
||||
"Delete…",
|
||||
"Clear Contents",
|
||||
"Filter by Selected Cell's Value",
|
||||
"Sort A to Z",
|
||||
"Sort Z to A",
|
||||
"New Comment",
|
||||
"New Note",
|
||||
"Format Cells…",
|
||||
"Pick From Drop-down List…",
|
||||
"Define Name…",
|
||||
"Insert Copied Cells",
|
||||
"Insert Copied Cells…",
|
||||
"Insert Cut Cells",
|
||||
"Insert",
|
||||
"Delete",
|
||||
"Row Height…",
|
||||
"AutoFit Row Height",
|
||||
"Column Width…",
|
||||
"AutoFit Column Width",
|
||||
"Hide",
|
||||
"Unhide",
|
||||
"Group",
|
||||
] {
|
||||
assert_ne!(Language::De.tr(label), label, "German context menu label missing: {label}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sheet_tab_menu_and_autosave_labels_are_translated() {
|
||||
let labels = [
|
||||
"Insert Sheet",
|
||||
"Delete",
|
||||
"Rename",
|
||||
"Move or Copy…",
|
||||
"Duplicate",
|
||||
"Tab Color",
|
||||
"No Color",
|
||||
"Hide",
|
||||
"Unhide…",
|
||||
"Protect Sheet…",
|
||||
"Unprotect Sheet…",
|
||||
"Select All Sheets",
|
||||
"AutoSave",
|
||||
"AutoSave: save after every change (workbooks saved to a file)",
|
||||
"Previous sheet",
|
||||
"Next sheet",
|
||||
"All sheets",
|
||||
"New sheet (⇧F11)",
|
||||
];
|
||||
for language in [Language::Es, Language::De, Language::Fr, Language::Ko, Language::Ru, Language::Zh, Language::Sr] {
|
||||
for label in labels {
|
||||
assert_ne!(language.tr(label), label, "{} lacks a translation of {label:?}", language.code());
|
||||
}
|
||||
}
|
||||
assert_eq!(Language::Es.tr("AutoSave"), "Autoguardado");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translations_never_show_the_context_prefix() {
|
||||
// `tr` returns a found translation as it is, so a catalog that keeps `context|` in its
|
||||
// value shows it on screen (File › Options read "Options|Général" in French).
|
||||
for language in Language::ALL {
|
||||
for (english, _) in ja::CORE {
|
||||
let keys = ja::CORE.iter().map(|(english, _)| *english).chain(["Options|General", "Options|Save", "Sheet Options|View"]);
|
||||
for english in keys {
|
||||
for language in Language::ALL {
|
||||
let shown = language.tr(english);
|
||||
assert!(!shown.contains('|'), "{} shows a context prefix for {english}: {shown}", language.code());
|
||||
}
|
||||
}
|
||||
assert_eq!(Language::Fr.tr("Options|General"), "Général");
|
||||
assert_eq!(Language::Fr.tr("Options|Save"), "Enregistrer");
|
||||
assert_eq!(Language::Fr.tr("Sheet Options|View"), "Affichage");
|
||||
assert_eq!(Language::Sr.tr("Options|General"), "Opšte");
|
||||
assert_eq!(Language::Sr.tr("Options|Save"), "Sačuvaj");
|
||||
assert_eq!(Language::Sr.tr("Sheet Options|View"), "Prikaz");
|
||||
assert_eq!(Language::Nl.tr("Options|General"), "Algemeen");
|
||||
assert_eq!(Language::Nl.tr("Options|Save"), "Opslaan");
|
||||
assert_eq!(Language::Nl.tr("Sheet Options|View"), "Weergeven");
|
||||
assert_eq!(Language::Ro.tr("Options|General"), "General");
|
||||
assert_eq!(Language::Ro.tr("Options|Save"), "Salvare");
|
||||
assert_eq!(Language::Ro.tr("Sheet Options|View"), "Vizualizare");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
//! Japanese translations for the interface strings in `i18n.rs`; labels follow Excel for Mac's
|
||||
//! Japanese wording, and anything absent falls back to English.
|
||||
//!
|
||||
//! [`CORE`] is the shared reference catalog every language translates: the ribbon, sheet tabs,
|
||||
//! widgets and the most common dialog labels. [`DIALOGS`] adds the dialog titles and labels,
|
||||
//! message boxes, Backstage/File-view items, template names and native-menu strings the Brazilian
|
||||
//! Portuguese catalog (`pt.rs`) covers on top of it. The two lists never overlap.
|
||||
//! [`CORE`] is the shared reference set: the ribbon, sheet tabs, widgets and the most common dialog
|
||||
//! labels. [`DIALOGS`] adds the dialog titles and labels, message boxes, Backstage/File-view items,
|
||||
//! template names and native-menu strings. The two lists never overlap. A string a language doesn't
|
||||
//! translate shows in English; `cargo xtask i18n-report` lists them per language.
|
||||
|
||||
/// English label → Japanese, the shared reference set every catalog covers.
|
||||
/// English label → Japanese, the shared reference set.
|
||||
pub(super) const CORE: &[(&str, &str)] = &[
|
||||
("Paste", "貼り付け"),
|
||||
("Paste Values", "値の貼り付け"),
|
||||
@@ -1150,99 +1150,17 @@ mod tests {
|
||||
DIALOGS.iter().chain(CORE).map(|(english, _)| *english)
|
||||
}
|
||||
|
||||
/// The decoded value of the Rust string literal starting at byte `start` (its opening `"`), and
|
||||
/// the byte index just past its closing quote. Escapes (`\n`, `\"`, `\\`, `\t`, `\u{…}`) are
|
||||
/// decoded, so the keys compare equal to the ones in the tables above.
|
||||
fn string_literal(source: &str, start: usize) -> Option<(String, usize)> {
|
||||
if !source[start..].starts_with('"') {
|
||||
return None;
|
||||
}
|
||||
let mut value = String::new();
|
||||
let mut chars = source[start + 1..].char_indices();
|
||||
while let Some((offset, c)) = chars.next() {
|
||||
match c {
|
||||
'"' => return Some((value, start + 1 + offset + 1)),
|
||||
'\\' => match chars.next().map(|(_, c)| c) {
|
||||
Some('n') => value.push('\n'),
|
||||
Some('t') => value.push('\t'),
|
||||
Some('r') => value.push('\r'),
|
||||
Some('0') => value.push('\0'),
|
||||
Some('\\') => value.push('\\'),
|
||||
Some('"') => value.push('"'),
|
||||
Some('\'') => value.push('\''),
|
||||
Some('u') => {
|
||||
let mut hex = String::new();
|
||||
for (_, c) in chars.by_ref() {
|
||||
if c == '}' {
|
||||
break;
|
||||
}
|
||||
if c != '{' {
|
||||
hex.push(c);
|
||||
}
|
||||
}
|
||||
if let Some(c) = u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) {
|
||||
value.push(c);
|
||||
}
|
||||
}
|
||||
// A line continuation (backslash, newline, indentation) adds nothing.
|
||||
_ => {}
|
||||
},
|
||||
_ => value.push(c),
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Every key `pt.rs` translates, read out of its source with a small scanner: a match arm is a
|
||||
/// string literal whose `=>` is followed by another string literal, which covers the block form
|
||||
/// (`"key" => { "value" }`) as well. Parsing the real source keeps the two catalogs from
|
||||
/// drifting apart again instead of checking a remembered count.
|
||||
fn portuguese_keys() -> Vec<String> {
|
||||
let source = include_str!("pt.rs");
|
||||
let mut literals: Vec<(String, usize, usize)> = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < source.len() {
|
||||
let rest = &source[i..];
|
||||
if rest.starts_with("//") {
|
||||
i += rest.find('\n').unwrap_or(rest.len());
|
||||
continue;
|
||||
}
|
||||
if rest.starts_with('"') {
|
||||
let (value, end) = string_literal(source, i).unwrap();
|
||||
literals.push((value, i, end));
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
i += rest.chars().next().unwrap().len_utf8();
|
||||
}
|
||||
let mut keys = Vec::new();
|
||||
for pair in literals.windows(2) {
|
||||
let (key, _, end) = &pair[0];
|
||||
let start = pair[1].1;
|
||||
let gap = source[*end..start].trim();
|
||||
if gap == "=>" || gap == "=> {" {
|
||||
keys.push(key.clone());
|
||||
}
|
||||
}
|
||||
keys
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_are_unique_and_every_value_is_translated() {
|
||||
fn keys_are_unique_and_no_value_is_blank() {
|
||||
let mut seen = HashSet::new();
|
||||
for (english, japanese) in DIALOGS.iter().chain(CORE) {
|
||||
assert!(!japanese.is_empty(), "empty translation for {english:?}");
|
||||
assert!(japanese.chars().any(|c| !c.is_whitespace()), "blank translation for {english:?}");
|
||||
assert!(seen.insert(*english), "duplicate key across CORE and DIALOGS: {english:?}");
|
||||
}
|
||||
assert_eq!(seen.len(), catalog_keys().count());
|
||||
assert_eq!(CORE.len(), 392, "the shared reference set every catalog covers");
|
||||
assert_eq!(DIALOGS.len(), 562, "the dialog, message and menu strings beyond it");
|
||||
assert_eq!(catalog_keys().count(), 954);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_labels_are_translated_and_do_not_overlap_the_catalog() {
|
||||
fn extra_labels_do_not_overlap_the_catalog() {
|
||||
let catalog: HashSet<&str> = catalog_keys().collect();
|
||||
let mut seen = HashSet::new();
|
||||
for (english, japanese) in EXTRA {
|
||||
@@ -1252,40 +1170,4 @@ mod tests {
|
||||
assert_eq!(translate(english), Some(*japanese));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn covers_every_key_the_portuguese_catalog_translates() {
|
||||
let portuguese = portuguese_keys();
|
||||
assert_eq!(portuguese.len(), 954, "pt.rs's arms changed: add the new keys here and update the count");
|
||||
let japanese: HashSet<&str> = catalog_keys().collect();
|
||||
for key in &portuguese {
|
||||
assert!(japanese.contains(key.as_str()), "the Portuguese catalog translates {key:?}, the Japanese one does not");
|
||||
}
|
||||
assert_eq!(japanese.len(), portuguese.len(), "the two catalogs hold the same key set");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn high_value_strings_use_excel_japanese_wording() {
|
||||
for (english, japanese) in [
|
||||
("OK", "OK"),
|
||||
("Cancel", "キャンセル"),
|
||||
("Don't Save", "保存しない"),
|
||||
("Text Box", "テキスト ボックス"),
|
||||
("Row Height", "行の高さ"),
|
||||
("Column Width", "列の幅"),
|
||||
("Sort", "並べ替え"),
|
||||
("Filter", "フィルター"),
|
||||
("Conditional Formatting", "条件付き書式"),
|
||||
("Do you want to save the changes you made to {title}?", "{title} への変更を保存しますか?"),
|
||||
("Save a copy as .{format}", ".{format} としてコピーを保存"),
|
||||
("Format Cells|Fill", "塗りつぶし"),
|
||||
("Insert\nSlicer", "スライサーの\n挿入"),
|
||||
] {
|
||||
assert_eq!(translate(english), Some(japanese), "{english:?}");
|
||||
assert_eq!(crate::i18n::Language::Ja.tr(english), japanese, "{english:?}");
|
||||
}
|
||||
// A `context|label` key shows only its label in English; an unknown label keeps the text.
|
||||
assert_eq!(crate::i18n::Language::En.tr("Format Cells|Fill"), "Fill");
|
||||
assert_eq!(crate::i18n::Language::Ja.tr("Not in any catalog"), "Not in any catalog");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,6 +117,14 @@ impl Default for UiState {
|
||||
}
|
||||
|
||||
impl UiState {
|
||||
/// The state saved by an earlier run, as the app starts with it. The ribbon tab is written to
|
||||
/// the preferences but not restored: Excel opens on Home every launch, so a contextual tab
|
||||
/// (Table Design) cannot come back with nothing selected to justify it.
|
||||
pub fn at_startup(mut self) -> Self {
|
||||
self.ribbon_tab = "Home".into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn theme_preference(&self) -> egui::ThemePreference {
|
||||
if self.system_theme {
|
||||
egui::ThemePreference::System
|
||||
|
||||
@@ -204,6 +204,11 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let collapsed = app.ui.ribbon_collapsed;
|
||||
let h = if collapsed { 34.0 } else { 34.0 + 92.0 };
|
||||
// A contextual tab exists only while its object is selected (Excel hides Table Design once the
|
||||
// cell leaves the table); a remembered one that is gone would draw an empty ribbon.
|
||||
if CONTEXTUAL_TABS.contains(&app.ui.ribbon_tab.as_str()) && !contextual_tabs(app).contains(&app.ui.ribbon_tab.as_str()) {
|
||||
app.ui.ribbon_tab = "Home".to_string();
|
||||
}
|
||||
egui::Panel::top("ribbon")
|
||||
.exact_size(h)
|
||||
.frame(egui::Frame::NONE.fill(t.window).inner_margin(egui::Margin { left: 10, right: 10, top: 0, bottom: 6 }))
|
||||
@@ -322,6 +327,8 @@ fn theme_choices(app: &mut SheetApp, ui: &mut Ui) {
|
||||
}
|
||||
}
|
||||
|
||||
const CONTEXTUAL_TABS: [&str; 3] = ["Table Design", "Chart Design", crate::paper_view::TAB];
|
||||
|
||||
fn contextual_tabs(app: &SheetApp) -> Vec<&'static str> {
|
||||
let mut v = Vec::new();
|
||||
if let Some(d) = app.session.active()
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
//! A contextual ribbon tab (Table Design, Chart Design) only exists while its object is selected.
|
||||
//! The tab remembered in the saved UI state, or left behind when the selection moves away, must
|
||||
//! not leave the ribbon blank: it falls back to Home.
|
||||
|
||||
use egui::vec2;
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_ui_egui::SheetApp;
|
||||
use serde_json::json;
|
||||
|
||||
type Harness = egui_kittest::Harness<'static, SheetApp>;
|
||||
|
||||
fn harness(session: Session) -> Harness {
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
fn session_with_table() -> Session {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
session.execute("range.setValues", json!({"range": "A1", "values": [["Name", "Qty"], ["a", 1], ["b", 2]]})).unwrap();
|
||||
session.execute("insert.table", json!({"range": "A1:B3", "header": true})).unwrap();
|
||||
session.execute("selection.set", json!({"range": "A2"})).unwrap();
|
||||
session
|
||||
}
|
||||
|
||||
fn app_restored_on(tab: &str, session: Session) -> Harness {
|
||||
let mut app = SheetApp::new(session, Default::default());
|
||||
app.ui.ribbon_tab = tab.into();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_table_design_tab_without_a_table_opens_on_home() {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
let h = app_restored_on("Table Design", session);
|
||||
assert_eq!(h.state().ui.ribbon_tab, "Home");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_chart_design_tab_without_a_chart_opens_on_home() {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
let h = app_restored_on("Chart Design", session);
|
||||
assert_eq!(h.state().ui.ribbon_tab, "Home");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaving_the_table_while_on_table_design_returns_to_home() {
|
||||
let mut h = harness(session_with_table());
|
||||
h.state_mut().ui.ribbon_tab = "Table Design".into();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().ui.ribbon_tab, "Table Design", "the tab stays while the cell is in the table");
|
||||
h.state_mut().session.execute("selection.set", json!({"range": "D10"})).unwrap();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().ui.ribbon_tab, "Home");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_opens_on_home_not_the_saved_tab() {
|
||||
let saved = gridcraft_ui_egui::UiState { ribbon_tab: "Table Design".into(), dark: true, ..Default::default() };
|
||||
let json = serde_json::to_string(&saved).unwrap();
|
||||
assert!(json.contains(r#""ribbonTab":"Table Design""#), "{json}");
|
||||
let restored = serde_json::from_str::<gridcraft_ui_egui::UiState>(&json).unwrap().at_startup();
|
||||
assert_eq!(restored.ribbon_tab, "Home", "the saved tab is not restored");
|
||||
assert!(restored.dark, "the other preferences are");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ordinary_tab_stays_put_in_a_session() {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
let mut h = harness(session);
|
||||
h.state_mut().ui.ribbon_tab = "Data".into();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().ui.ribbon_tab, "Data");
|
||||
}
|
||||
@@ -4,6 +4,7 @@ use std::sync::{Arc, Mutex};
|
||||
|
||||
use egui_kittest::kittest::Queryable as _;
|
||||
use gridcraft_engine::{Session, core::CellRef, core::Value};
|
||||
use gridcraft_ui_egui::i18n::Language;
|
||||
use gridcraft_ui_egui::{Services, SheetApp};
|
||||
|
||||
fn harness(services: Services) -> egui_kittest::Harness<'static, SheetApp> {
|
||||
@@ -134,30 +135,25 @@ fn file_options_opens_the_options_page_and_pages_are_reachable_by_command() {
|
||||
|
||||
#[test]
|
||||
fn translated_options_headings_show_no_context_prefix() {
|
||||
for (code, headings, prefixed, (view, view_prefixed)) in [
|
||||
("fr", ["Général", "Enregistrer"], ["Options|Général", "Options|Enregistrer"], ("Affichage", "Options de la feuille|Affichage")),
|
||||
("sr", ["Opšte", "Sačuvaj"], ["Opcije|Opšte", "Opcije|Sačuvaj"], ("Prikaz", "Opcije lista|Prikaz")),
|
||||
("nl", ["Algemeen", "Opslaan"], ["Opties|Algemeen", "Opties|Opslaan"], ("Weergeven", "Bladopties|Weergeven")),
|
||||
("ro", ["General", "Salvare"], ["Opțiuni|General", "Opțiuni|Salvare"], ("Vizualizare", "Opțiuni foaie|Vizualizare")),
|
||||
] {
|
||||
for language in [Language::Fr, Language::Sr, Language::Nl, Language::Ro] {
|
||||
let mut h = harness(Services::default());
|
||||
h.state_mut().run("app.language.set", serde_json::json!({"language": code})).unwrap();
|
||||
h.state_mut().run("app.language.set", serde_json::json!({"language": language.code()})).unwrap();
|
||||
h.state_mut().run("file.options", serde_json::json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert_eq!(page(&h), Some("options"));
|
||||
for heading in headings {
|
||||
assert!(h.query_all_by_label(heading).next().is_some(), "{code}: no {heading} heading");
|
||||
}
|
||||
for label in prefixed {
|
||||
assert!(h.query_by_label(label).is_none(), "{code}: {label} shows its context");
|
||||
for key in ["Options|General", "Options|Save"] {
|
||||
let heading = language.tr(key);
|
||||
assert!(!heading.contains('|'), "{language:?}: {key} shows its context: {heading}");
|
||||
assert!(h.query_all_by_label(heading).next().is_some(), "{language:?}: no {heading} heading");
|
||||
}
|
||||
// Page Layout › Sheet Options: the Gridlines and Headings "View" checkboxes had the same
|
||||
// prefix. The View tab may share the word, so count at least the two checkboxes.
|
||||
h.state_mut().run("ui.backstage", serde_json::json!({"page": "close"})).unwrap();
|
||||
h.state_mut().ui.ribbon_tab = "Page Layout".into();
|
||||
h.run_steps(3);
|
||||
assert!(h.query_by_label(view_prefixed).is_none(), "{code}: {view_prefixed} shows its context");
|
||||
assert!(h.query_all_by_label(view).count() >= 2, "{code}: the Sheet Options checkboxes don't read {view}");
|
||||
let view = language.tr("Sheet Options|View");
|
||||
assert!(!view.contains('|'), "{language:?}: Sheet Options|View shows its context: {view}");
|
||||
assert!(h.query_all_by_label(view).count() >= 2, "{language:?}: the Sheet Options checkboxes don't read {view}");
|
||||
}
|
||||
}
|
||||
|
||||
+62
@@ -209,3 +209,65 @@ fn the_button_stays_inside_the_grid_on_a_right_to_left_sheet() {
|
||||
let filled = geo.screen_cell_rect(sheet, CellRef::parse("B6").unwrap());
|
||||
assert!(button.center().x < filled.left() && button.top() >= filled.bottom() - 1.0, "below-left of the mirrored range's corner");
|
||||
}
|
||||
|
||||
/// Right-to-left sheet with column A made `chars` wide (so column B's right edge, where the fill
|
||||
/// range B1:B6 ends, sits far enough right to be scrolled to either edge of the cells area).
|
||||
fn rtl_wide_a(chars: u32) -> Harness {
|
||||
harness(|s| {
|
||||
one_two(s);
|
||||
s.execute("home.columnWidth", json!({"cols": "A:A", "chars": chars})).unwrap();
|
||||
s.execute("view.rightToLeft", json!({"on": true})).unwrap();
|
||||
})
|
||||
}
|
||||
|
||||
/// Logical right edge of the filled column B (scroll-dependent) and the current geometry.
|
||||
fn b_right(h: &Harness) -> (f32, Geo) {
|
||||
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);
|
||||
(geo.cell_rect(sheet, CellRef::parse("B6").unwrap()).right(), geo)
|
||||
}
|
||||
|
||||
fn scroll_b_right_to(h: &mut Harness, logical_x: f32) {
|
||||
let (now, geo) = b_right(h);
|
||||
h.state_mut().view_mut().unwrap().scroll.x += (now - logical_x) / geo.z;
|
||||
h.run_steps(3);
|
||||
let got = b_right(h).0;
|
||||
assert!((got - logical_x).abs() < 1.0, "setup: column B ends at logical x {got}, wanted {logical_x}");
|
||||
}
|
||||
|
||||
/// Logical x of the range's corner is `cells.right() - 25`: the 18 px button (at corner + 2 ..
|
||||
/// corner + 20) fits in the cells area, so on screen it sits at the left edge of the mirrored cells
|
||||
/// area, left of the filled range. `fill_options_tag` tested the mirrored button against the
|
||||
/// unmirrored cells, i.e. against the row-heading strip it does not own, and hid it.
|
||||
#[test]
|
||||
fn the_button_shows_near_the_left_screen_edge_on_a_right_to_left_sheet() {
|
||||
let mut h = rtl_wide_a(220);
|
||||
let (_, geo) = b_right(&h);
|
||||
scroll_b_right_to(&mut h, geo.cells.right() - 25.0);
|
||||
drag_handle(&mut h, "B2", "B6", PointerButton::Primary);
|
||||
|
||||
let (_, geo) = b_right(&h);
|
||||
let cells = h.state().grid.cells_rect.unwrap();
|
||||
assert!(geo.header_w > 20.0 && geo.cells.right() - 25.0 + 20.0 > geo.cells.right() - geo.header_w, "setup: in the zone the old check hides");
|
||||
let button = h.query_by_label("Auto Fill Options").expect("the button fits inside the cells area, so it shows").rect();
|
||||
assert!(cells.contains_rect(button), "{button:?} outside {cells:?}");
|
||||
}
|
||||
|
||||
/// The corner is 10 px left of `cells.left()` (scrolled partly behind the row headings, which are
|
||||
/// on the right on screen). The button would start 8 px behind the headings, so it must not be
|
||||
/// drawn there. The old check let it through because the mirrored button fits the unmirrored cells.
|
||||
#[test]
|
||||
fn the_button_stays_off_the_row_headings_on_a_right_to_left_sheet() {
|
||||
let mut h = rtl_wide_a(20);
|
||||
drag_handle(&mut h, "B2", "B6", PointerButton::Primary);
|
||||
assert!(h.query_by_label("Auto Fill Options").is_some(), "setup: the button shows before scrolling");
|
||||
let (_, geo) = b_right(&h);
|
||||
scroll_b_right_to(&mut h, geo.cells.left() - 10.0);
|
||||
|
||||
let cells = h.state().grid.cells_rect.unwrap();
|
||||
if let Some(b) = h.query_by_label("Auto Fill Options") {
|
||||
let button = b.rect();
|
||||
assert!(cells.contains_rect(button), "{button:?} overlaps the row headings, cells area is {cells:?}");
|
||||
}
|
||||
}
|
||||
@@ -176,14 +176,12 @@ fn portuguese_interface_retains_its_canonical_formula_fallback() {
|
||||
let mut app = app();
|
||||
app.run("app.language.set", json!({"code":"pt-BR"})).unwrap();
|
||||
assert_eq!(app.ui.language, Language::PtBr);
|
||||
assert_eq!(app.ui.language.tr("Data"), "Dados");
|
||||
assert!(enter(&mut app, "=SUM(1.5,2.25)"));
|
||||
app.begin_edit(None, true);
|
||||
assert_eq!(app.editor.as_ref().unwrap().text, "=SUM(1.5,2.25)");
|
||||
assert!(!enter(&mut app, "=SUMA(1;2)"));
|
||||
let restored: UiState = serde_json::from_str(&serde_json::to_string(&app.ui).unwrap()).unwrap();
|
||||
assert_eq!(restored.language, Language::PtBr);
|
||||
assert_eq!(Language::Es.tr("Page Layout|Orientation"), "Orientación");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -193,7 +191,6 @@ fn czech_interface_retains_its_canonical_formula_fallback() {
|
||||
let mut app = app();
|
||||
app.run("app.language.set", json!({"code":"cs-CZ"})).unwrap();
|
||||
assert_eq!(app.ui.language, Language::Cs);
|
||||
assert_eq!(app.ui.language.tr("Formulas"), "Vzorce");
|
||||
assert!(enter(&mut app, "=SUM(1.5,2.25)"));
|
||||
app.begin_edit(None, true);
|
||||
assert_eq!(app.editor.as_ref().unwrap().text, "=SUM(1.5,2.25)");
|
||||
@@ -225,8 +222,6 @@ fn turkish_interface_retains_its_canonical_formula_fallback() {
|
||||
let mut app = app();
|
||||
app.run("app.language.set", json!({"code":"tr-TR"})).unwrap();
|
||||
assert_eq!(app.ui.language, Language::Tr);
|
||||
assert_eq!(app.ui.language.tr("Data"), "Veri");
|
||||
assert_eq!(app.ui.language.tr("Formulas"), "Formüller");
|
||||
assert!(enter(&mut app, "=SUM(1.5,2.25)"));
|
||||
app.begin_edit(None, true);
|
||||
assert_eq!(app.editor.as_ref().unwrap().text, "=SUM(1.5,2.25)");
|
||||
+12
@@ -83,3 +83,15 @@ fn ctrl_click_beside_the_link_text_still_extends_the_selection() {
|
||||
assert_eq!(ranges, ["B2", "B4"], "the hyperlink cell joins the selection");
|
||||
assert!(h.state().dialog.is_none(), "no link was followed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_alt_click_on_the_link_text_still_extends_the_selection() {
|
||||
let mut h = harness();
|
||||
click_at(&mut h, "B2", 0.9, Modifiers::NONE);
|
||||
click_at(&mut h, "B4", 0.3, Modifiers { alt: true, ..Modifiers::COMMAND });
|
||||
let d = h.state().session.active().unwrap();
|
||||
let ranges: Vec<String> = d.selection.ranges.iter().map(|r| r.a1()).collect();
|
||||
assert_eq!(ranges, ["B2", "B4"], "Ctrl+Alt+click adds the hyperlink cell to the selection");
|
||||
assert_eq!(d.selection.active.a1(), "B4", "the link was not followed");
|
||||
assert!(h.state().dialog.is_none(), "no link dialog");
|
||||
}
|
||||
@@ -4,6 +4,7 @@ use egui::{Event, Key, Modifiers, PointerButton};
|
||||
use egui_kittest::kittest::Queryable as _;
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_engine::core::{CellRef, Value};
|
||||
use gridcraft_ui_egui::i18n::Language;
|
||||
use gridcraft_ui_egui::{SheetApp, grid::Geo};
|
||||
use serde_json::json;
|
||||
|
||||
@@ -400,8 +401,7 @@ fn renders_every_ribbon_tab_without_panicking() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn language_switch_translates_the_ribbon_and_persists() {
|
||||
use gridcraft_ui_egui::i18n::Language;
|
||||
fn language_switch_renders_the_ribbon_and_persists() {
|
||||
let mut h = harness(blank());
|
||||
// English by default whatever the host's locale: only the desktop app reads the system language.
|
||||
assert_eq!(h.state().ui.language, Language::En);
|
||||
@@ -415,31 +415,13 @@ fn language_switch_translates_the_ribbon_and_persists() {
|
||||
for id in ["app.language.set", "app.language.english", "app.language.japanese"] {
|
||||
assert!(ids.contains(&id), "engine.commands lists {id}");
|
||||
}
|
||||
for (language, expected) in [
|
||||
(Language::Zh, "数据"),
|
||||
(Language::Ja, "データ"),
|
||||
(Language::Ko, "데이터"),
|
||||
(Language::Ru, "Данные"),
|
||||
(Language::PtBr, "Dados"),
|
||||
(Language::De, "Daten"),
|
||||
(Language::Fr, "Données"),
|
||||
(Language::Tr, "Veri"),
|
||||
(Language::It, "Dati"),
|
||||
] {
|
||||
for language in Language::ALL {
|
||||
h.state_mut().run("app.language.set", json!({"language": language.code()})).unwrap();
|
||||
h.run_steps(2); // every locale renders the ribbon without panicking
|
||||
assert_eq!(h.state().ui.language, language);
|
||||
assert_eq!(language.tr("Data"), expected);
|
||||
let restored: gridcraft_ui_egui::UiState = serde_json::from_str(&serde_json::to_string(&h.state().ui).unwrap()).unwrap();
|
||||
assert_eq!(restored.language, language);
|
||||
}
|
||||
// Czech spells "Data" as English does, so the Czech table is checked on a label that differs.
|
||||
h.state_mut().run("app.language.set", json!({"language": "cs"})).unwrap();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().ui.language, Language::Cs);
|
||||
assert_eq!(Language::Cs.tr("Formulas"), "Vzorce");
|
||||
let restored: gridcraft_ui_egui::UiState = serde_json::from_str(&serde_json::to_string(&h.state().ui).unwrap()).unwrap();
|
||||
assert_eq!(restored.language, Language::Cs);
|
||||
h.state_mut().run("app.language.set", json!({"language": "en"})).unwrap();
|
||||
assert_eq!(h.state().ui.language, Language::En);
|
||||
}
|
||||
@@ -452,16 +434,21 @@ fn pivot_fields_pane_follows_the_interface_language() {
|
||||
h.state_mut().run("insert.pivotTable", json!({"source": "Sheet1!$A$1:$B$3", "rows": ["Region"]})).unwrap();
|
||||
h.state_mut().run("app.language.set", json!({"language": "es"})).unwrap();
|
||||
h.run_steps(4);
|
||||
for label in ["Campos de tabla dinámica", "Filas", "Valores", "Actualizar", "Campo calculado…"] {
|
||||
assert!(h.query_by_label(label).is_some(), "the pane shows `{label}`");
|
||||
let es = |english| Language::Es.tr(english);
|
||||
for english in ["PivotTable Fields", "Rows", "Values", "Refresh", "Calculated Field…"] {
|
||||
assert!(h.query_by_label(es(english)).is_some(), "the pane shows `{}`", es(english));
|
||||
}
|
||||
if es("PivotTable Fields") != "PivotTable Fields" {
|
||||
assert!(h.query_by_label("PivotTable Fields").is_none());
|
||||
}
|
||||
assert!(h.query_by_label("PivotTable Fields").is_none());
|
||||
h.get_by_label("Region ▾").click();
|
||||
h.run_steps(2);
|
||||
for label in ["Mover a rótulos de columna", "Agrupar por años", "Quitar campo"] {
|
||||
assert!(h.query_by_label(label).is_some(), "the field menu shows `{label}`");
|
||||
for english in ["Move to Column Labels", "Group by Years", "Remove Field"] {
|
||||
assert!(h.query_by_label(es(english)).is_some(), "the field menu shows `{}`", es(english));
|
||||
}
|
||||
if es("Remove Field") != "Remove Field" {
|
||||
assert!(h.query_by_label("Remove Field").is_none());
|
||||
}
|
||||
assert!(h.query_by_label("Remove Field").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -492,9 +479,12 @@ fn format_cells_draws_its_labels_in_the_interface_language() {
|
||||
h.state_mut().run("app.language.set", json!({"language": "de"})).unwrap();
|
||||
h.state_mut().open_dialog("formatCells", json!({"tab": "Number"}));
|
||||
h.run_steps(2);
|
||||
assert!(h.query_by_label("Wissenschaftlich").is_some(), "the Number tab's categories are translated");
|
||||
assert!(h.query_by_label("Scientific").is_none(), "no English category is left");
|
||||
h.get_by_label("Wissenschaftlich").click();
|
||||
let scientific = Language::De.tr("Scientific");
|
||||
assert!(h.query_by_label(scientific).is_some(), "the Number tab's categories are translated");
|
||||
if scientific != "Scientific" {
|
||||
assert!(h.query_by_label("Scientific").is_none(), "no English category is left");
|
||||
}
|
||||
h.get_by_label(scientific).click();
|
||||
h.run_steps(2);
|
||||
let style = &h.state().dialog.as_ref().unwrap().values["style"];
|
||||
assert_eq!(style["num_fmt"], json!("0.00E+00"), "a translated category still applies its code");
|
||||
@@ -0,0 +1,93 @@
|
||||
//! The crate's integration tests, one module per file, built as one test binary.
|
||||
//!
|
||||
//! `header_autofit_fonts.rs` and `keytips.rs` stay separate binaries: they set process-wide state
|
||||
//! (the installed-font catalog, a registered test command) that would leak into these tests.
|
||||
|
||||
mod accounting_fill;
|
||||
mod advanced_filter;
|
||||
mod argument_names;
|
||||
mod autosave_persist;
|
||||
mod borders;
|
||||
mod cell_editor_caret;
|
||||
mod cell_pictures;
|
||||
mod chart_resize;
|
||||
mod clipboard;
|
||||
mod comments_pane;
|
||||
mod commit_after_pointing;
|
||||
mod committed_text_fields;
|
||||
mod context_menu_stays_open;
|
||||
mod contextual_ribbon_tab;
|
||||
mod control_timeout;
|
||||
mod dialog_button_row;
|
||||
mod dialog_confirm;
|
||||
mod dialog_focus;
|
||||
mod dialog_move;
|
||||
mod drag_cursor;
|
||||
mod file_menu;
|
||||
mod fill_handle_double_click;
|
||||
mod fill_handle_right_drag;
|
||||
mod fill_options_button;
|
||||
mod filter_list_scroll;
|
||||
mod filter_menu_enter;
|
||||
mod find_dialog_focus;
|
||||
mod floating_pictures;
|
||||
mod font_size_box;
|
||||
mod format_painter;
|
||||
mod formula_locale;
|
||||
mod formula_point_headers;
|
||||
mod formula_popup;
|
||||
mod freeze_handles;
|
||||
mod header_autofit;
|
||||
mod header_column_drag;
|
||||
mod host_chrome;
|
||||
mod hyperlink_ctrl_click;
|
||||
mod in_cell_editor_size;
|
||||
mod in_cell_editor_wrap;
|
||||
mod insert_copied_cells;
|
||||
mod interaction;
|
||||
mod interface_scale;
|
||||
mod keyboard_shortcuts;
|
||||
mod keytips_generated;
|
||||
mod menu_hover;
|
||||
mod merged_cells;
|
||||
mod more_colors;
|
||||
mod native_print;
|
||||
mod nowrap_line_breaks;
|
||||
mod number_stored_as_text;
|
||||
mod open_encrypted;
|
||||
mod outline;
|
||||
mod page_layout_view;
|
||||
mod page_setup_margins;
|
||||
mod paste_special_dialog;
|
||||
mod pivot_pane;
|
||||
mod pivot_show_details;
|
||||
mod popup_dismissal;
|
||||
mod protect_password;
|
||||
mod right_to_left;
|
||||
mod scroll_position;
|
||||
mod scrollbar_column_fill;
|
||||
mod selection;
|
||||
mod selection_fill;
|
||||
mod selection_glide;
|
||||
mod selection_scroll;
|
||||
mod sheet_navigator;
|
||||
mod sheet_tab_rename;
|
||||
mod sheet_tabs;
|
||||
mod show_objects;
|
||||
mod shrink_to_fit;
|
||||
mod sort_dialog_columns;
|
||||
mod symbol_glyphs;
|
||||
mod system_theme;
|
||||
mod table_headings;
|
||||
mod text_box_editing;
|
||||
mod text_overflow_scroll;
|
||||
mod touch_pan;
|
||||
mod trace_navigation;
|
||||
mod translated_form_fields;
|
||||
mod ui_clock;
|
||||
mod validation_alert;
|
||||
mod validation_input_message;
|
||||
mod wheel_zoom;
|
||||
mod whole_line_shortcuts;
|
||||
mod widgets;
|
||||
mod zoom_dialog;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user