diff --git a/README.md b/README.md index 55d67dee1..84ab166cb 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ You keep notebooks, sections, and pages in a notes folder you choose, as plain f ### Write and draw -The Word-level text, Excel-level table, and code studio foundations are on the beta branch behind flags that are off. +The Word-level text, Excel-level table, and code studio foundations are on the beta branch behind flags that are off. The table work so far adds a cell format bar, spreadsheet keys, conditional formatting, styled tables in Word and HTML exports, and the logic, lookup, array, and statistics functions. - [Typed notes](docs/help/typed-notes.md): text, headings, text styles kept with the notebook, lists, checklists, tables, code, callouts, Markdown, templates, and find and replace. Find by format, special character, wildcard, or regular expression. The Home tab has font and size boxes, split buttons for underline, colors, and lists, and a styles gallery. Typed web and e-mail addresses turn into links. Pasted tables and spreadsheet cells become tables. Copies give other apps plain text. - [Moving between blocks](docs/help/moving-between-blocks.md): keys that move between text, pictures, tables, and code without the mouse. @@ -63,7 +63,7 @@ The Word-level text, Excel-level table, and code studio foundations are on the b - [Ink and palm rejection](docs/help/ink-and-palm-rejection.md): pens, a highlighter, erasers, the lasso, shapes, and gestures, with your palm resting on the screen. - [My handwriting](docs/help/my-handwriting.md): typed text and math written in your own hand, learned from a calibration page. - [Paper and page views](docs/help/paper-and-page-views.md): flow, paginated, and canvas pages, with lined, grid, and dot paper; slides, print, and PDF. -- [Tables and charts](docs/help/tables-and-charts.md): smart tables with views and formulas, and charts that update with them. +- [Tables and charts](docs/help/tables-and-charts.md): smart tables with views, Excel-style formulas (logic, lookup, arrays, and statistics), cell formats (colors, borders, number formats), conditional formatting, spreadsheet keys with Grid and Edit modes (a setting chooses them for new tables), and charts that update with them. - [Language and spelling](docs/help/language-and-spelling.md): American English by default, and one choice for every part that reads or writes words. ### Organize and find @@ -256,31 +256,32 @@ The root also holds tool settings such as `package.json`, `Cargo.toml`, `checks. The [documentation index](docs/README.md) lists everything in `docs/`. Start with the help pages to use the app, or pick a document below. -| Document | What it covers | -| -------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | -| [Help pages](docs/help/README.md) | How to use each part of the app | -| [docs/FEATURE-LIST.md](docs/FEATURE-LIST.md) | Every tool, command, and feature, by category, with pictures | -| [docs/screens/README.md](docs/screens/README.md) | Real pictures of every major screen, in light and dark | -| [Project wiki](https://github.com/XrxcGH/OpenNote/wiki) | The user guide, development updates, and the developer guide | -| [CHANGELOG.md](CHANGELOG.md) | What changed, and what each early beta added | -| [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) | The step-by-step plan for the Windows prototype, with a status note under each phase | -| [docs/FEATURES.md](docs/FEATURES.md) | The feature spec, with what is built, untested, not built, or waiting on the owner | -| [docs/RESEARCH.md](docs/RESEARCH.md) | Popular and rising note apps, the features people love, and the gaps OpenNote can fill | -| [docs/BRAND.md](docs/BRAND.md) | Colors, type, layout, motion, accessibility, and performance budgets | -| [docs/design/SCREENS.md](docs/design/SCREENS.md) | Wireframes of each major screen, with sizes and keep-out zones | -| [docs/intel.md](docs/intel.md) | The on-device intelligence engines, which run with no network client | -| [docs/CHECKS.md](docs/CHECKS.md) | The quality gate every file change must pass | -| [docs/format/README.md](docs/format/README.md) | The note file format specification and tools | -| [docs/adr/README.md](docs/adr/README.md) | Every architecture decision record (ADR), its status, and how to write one | -| [docs/testing/beta-4-checklist.md](docs/testing/beta-4-checklist.md) | The hand test checklist (written at beta 4), starting with the safest way to run it | -| [docs/testing/flags.md](docs/testing/flags.md) | The test that fails when a feature flag is read by nothing | -| [docs/HARDENING.md](docs/HARDENING.md) | Crash reports, the self-check, the feedback file, and safe start | -| [docs/RELEASING.md](docs/RELEASING.md) | The maintainer guide to cutting, checking, and rolling back a release | -| [docs/CONNECTORS.md](docs/CONNECTORS.md) | Registering OpenNote with each connected service, and where the client IDs go | -| [docs/ATTRIBUTION.md](docs/ATTRIBUTION.md) | The author, source, and license of every picture, font, model, and vendored crate | -| [CONTRIBUTING.md](CONTRIBUTING.md) | How to set up, make a change, and get it merged | -| [SECURITY.md](SECURITY.md) | How to report a security problem privately | -| [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) | How we treat each other | +| Document | What it covers | +| ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | +| [Help pages](docs/help/README.md) | How to use each part of the app | +| [docs/FEATURE-LIST.md](docs/FEATURE-LIST.md) | Every tool, command, and feature, by category, with pictures | +| [docs/screens/README.md](docs/screens/README.md) | Real pictures of every major screen, in light and dark | +| [Project wiki](https://github.com/XrxcGH/OpenNote/wiki) | The user guide, development updates, and the developer guide | +| [CHANGELOG.md](CHANGELOG.md) | What changed, and what each early beta added | +| [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) | The step-by-step plan for the Windows prototype, with a status note under each phase | +| [docs/FEATURES.md](docs/FEATURES.md) | The feature spec, with what is built, untested, not built, or waiting on the owner | +| [docs/RESEARCH.md](docs/RESEARCH.md) | Popular and rising note apps, the features people love, and the gaps OpenNote can fill | +| [docs/BRAND.md](docs/BRAND.md) | Colors, type, layout, motion, accessibility, and performance budgets | +| [docs/design/SCREENS.md](docs/design/SCREENS.md) | Wireframes of each major screen, with sizes and keep-out zones | +| [docs/intel.md](docs/intel.md) | The on-device intelligence engines, which run with no network client | +| [docs/CHECKS.md](docs/CHECKS.md) | The quality gate every file change must pass | +| [docs/format/README.md](docs/format/README.md) | The note file format specification and tools | +| [Formula differences](app/src/features/tables/fixtures/fn/DIFFERENCES.md) | Where table formulas answer differently from Excel, Google Sheets, and LibreOffice Calc | +| [docs/adr/README.md](docs/adr/README.md) | Every architecture decision record (ADR), its status, and how to write one | +| [docs/testing/beta-4-checklist.md](docs/testing/beta-4-checklist.md) | The hand test checklist (written at beta 4), starting with the safest way to run it | +| [docs/testing/flags.md](docs/testing/flags.md) | The test that fails when a feature flag is read by nothing | +| [docs/HARDENING.md](docs/HARDENING.md) | Crash reports, the self-check, the feedback file, and safe start | +| [docs/RELEASING.md](docs/RELEASING.md) | The maintainer guide to cutting, checking, and rolling back a release | +| [docs/CONNECTORS.md](docs/CONNECTORS.md) | Registering OpenNote with each connected service, and where the client IDs go | +| [docs/ATTRIBUTION.md](docs/ATTRIBUTION.md) | The author, source, and license of every picture, font, model, and vendored crate | +| [CONTRIBUTING.md](CONTRIBUTING.md) | How to set up, make a change, and get it merged | +| [SECURITY.md](SECURITY.md) | How to report a security problem privately | +| [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) | How we treat each other | ## License diff --git a/app/bundle-budget.json b/app/bundle-budget.json index 3b86312e4..30f23fcf9 100644 --- a/app/bundle-budget.json +++ b/app/bundle-budget.json @@ -1,7 +1,7 @@ { "startup": { - "jsKb": 595, - "cssKb": 29 + "jsKb": 612, + "cssKb": 30 }, "after": [ { @@ -72,32 +72,34 @@ "node_modules/mathlive/mathlive.min.mjs": 226, "node_modules/pdfjs-dist/build/pdf.mjs": 131, "core/audio/index.ts": 117, - "features/accounts/googleOffice/commands.ts": 90, - "features/accounts/lms/commands.ts": 291, - "features/accounts/meetings/commands.ts": 208, + "features/accounts/googleOffice/commands.ts": 96, + "features/accounts/lms/commands.ts": 297, + "features/accounts/meetings/commands.ts": 211, "features/accounts/onenote/commands.ts": 71, "features/accounts/penSync/auto.ts": 66, "features/accounts/penSync/commands.ts": 66, - "features/accounts/sendto/commands.ts": 89, - "features/accounts/share/commands.ts": 84, + "features/accounts/sendto/commands.ts": 95, + "features/accounts/share/commands.ts": 89, "features/accounts/tasks/auto.ts": 208, "features/accounts/tasks/commands.ts": 207, "features/ink/index.ts": 67, - "features/math/index.ts": 294, + "features/math/index.ts": 329, "features/math/solvers/ui/SolversTool.tsx": 61, - "features/page/audio/exportVideo.ts": 93, - "features/pages/host/shareRender.ts": 69, - "features/pages/host/videoRaster.ts": 69, - "features/pages/index.ts": 88, - "features/pages/ui/elementCommands.tsx": 80, - "features/pages/ui/exportCommands.tsx": 74, - "features/pages/ui/imageCommands.tsx": 74, - "features/pages/ui/presentCommands.tsx": 74, + "features/page/audio/exportVideo.ts": 99, + "features/pages/host/shareRender.ts": 75, + "features/pages/host/videoRaster.ts": 75, + "features/pages/index.ts": 93, + "features/pages/ui/elementCommands.tsx": 86, + "features/pages/ui/exportCommands.tsx": 80, + "features/pages/ui/imageCommands.tsx": 79, + "features/pages/ui/presentCommands.tsx": 80, "features/pdf/importPdf.ts": 69, - "features/tables/index.ts": 132, + "features/tables/index.ts": 167, + "features/tables/ui/toolbar/CellFormatBar.tsx": 162, + "features/tables/ui/toolbar/actions.ts": 134, "features/tools/index.ts": 392, - "features/engineering/control/ui/ControlKit.tsx": 326, - "features/engineering/index.ts": 220, + "features/engineering/control/ui/ControlKit.tsx": 328, + "features/engineering/index.ts": 222, "mermaid/node_modules/katex": 80, "node_modules/mermaid": 600, "@mermaid-js/parser": 130, @@ -113,7 +115,8 @@ "features/page/PageBody": 276, "@observablehq/plot": 135, "features/math": 185, - "features/tables": 52, + "features/tables/keys/attach.ts": 115, + "features/tables": 86, "features/search/links": 195, "features/search/lineTags": 160, "features/page": 40, @@ -125,16 +128,17 @@ "features/shortcuts": 40, "features/trash": 40, "features/updates": 40, - "features/tools/statinfer": 330, - "features/tools/humanities/timeline": 318, - "features/tools": 317, - "features/engineering/fluidheat/ui/FluidHeatKit.tsx": 316, + "features/tools/statinfer": 358, + "features/tools/humanities/timeline": 354, + "features/tools/signals/spectrum/SpectrumTool.tsx": 340, + "features/tools": 321, + "features/engineering/fluidheat/ui/FluidHeatKit.tsx": 317, "features/engineering/mechdyn/ui/MechDynKit.tsx": 124, "features/engineering/electrical/KitElectricalTool.tsx": 30, "features/engineering/mechdyn": 12, - "features/engineering/aero/ui/KitAeroTool.tsx": 228, + "features/engineering/aero/ui/KitAeroTool.tsx": 230, "features/engineering/solids/ui/SolidsKit.tsx": 119, - "features/engineering/thermo/ui/ThermoKit.tsx": 468, + "features/engineering/thermo/ui/ThermoKit.tsx": 505, "features/study/ui/exam/ExamResults.tsx": 43 }, "lazyDefaultKb": 40, diff --git a/app/src/app/flagUsage.test.ts b/app/src/app/flagUsage.test.ts index 2f6ceeed2..2e74442bb 100644 --- a/app/src/app/flagUsage.test.ts +++ b/app/src/app/flagUsage.test.ts @@ -21,8 +21,6 @@ const WAITING: Readonly> = { 'text.ruler': 'Beta 10 to 12: the lane that builds it reads it.', 'text.language': 'Beta 10 to 12: the lane that builds it reads it.', 'text.findFormat': 'Beta 10 to 12: the lane that builds it reads it.', - 'tables.sheetWrites': 'Beta 10: the table bar and the formula bar read it.', - 'tables.structureUi': 'Beta 10: tables:sheet-integration turns it on when the live table draws data.sheet.', 'code.tier2': 'Beta 14: code:L16 and code:L17 run installed toolchains behind it.', 'code.models': 'Beta 13: code:L13a and code:L13b read it for the code models.', }; diff --git a/app/src/commands/fixtures/scopes/tables.ts b/app/src/commands/fixtures/scopes/tables.ts index add13190d..925e77527 100644 --- a/app/src/commands/fixtures/scopes/tables.ts +++ b/app/src/commands/fixtures/scopes/tables.ts @@ -1,37 +1,111 @@ // The spreadsheet keys of a table in Grid mode and Edit mode. Owner: tables:keys. shared:x-shared wrote the first -// cases from the plan's key table. The IDs are stand-ins until the table commands exist. A chord that Edit mode -// gives its text meaning has no table command, so it falls through to the text program. -import type { ScopeFixture } from './types'; +// cases from the plan's key table. The sheet commands are the registered ones in bindings.ts, so a chord +// that collides with the text or code program fails here. The table.* IDs below are stand-ins for chords that +// other lanes build: F9 and Shift+F3 (formula-bar and the engine), Ctrl+` and Ctrl+5. A chord that Edit mode gives +// its text meaning has no table command, so it falls through to the text program. +import type { ScopeCommand, ScopeFixture } from './types'; + +// The registered sheet commands, as bindings.ts lists them. commands.test.ts in tables/keys checks that they match. +const sheetCommands: readonly ScopeCommand[] = [ + { id: 'table.sheet.extendUp', scope: 'tableGrid', keys: ['Shift+Up'] }, + { id: 'table.sheet.extendDown', scope: 'tableGrid', keys: ['Shift+Down'] }, + { id: 'table.sheet.extendLeft', scope: 'tableGrid', keys: ['Shift+Left'] }, + { id: 'table.sheet.extendRight', scope: 'tableGrid', keys: ['Shift+Right'] }, + { id: 'table.sheet.jumpUp', scope: 'tableGrid', keys: ['Ctrl+Up'] }, + { id: 'table.sheet.jumpDown', scope: 'tableGrid', keys: ['Ctrl+Down'] }, + { id: 'table.sheet.jumpLeft', scope: 'tableGrid', keys: ['Ctrl+Left'] }, + { id: 'table.sheet.jumpRight', scope: 'tableGrid', keys: ['Ctrl+Right'] }, + { id: 'table.sheet.extendJumpUp', scope: 'tableGrid', keys: ['Ctrl+Shift+Up'] }, + { id: 'table.sheet.extendJumpDown', scope: 'tableGrid', keys: ['Ctrl+Shift+Down'] }, + { id: 'table.sheet.extendJumpLeft', scope: 'tableGrid', keys: ['Ctrl+Shift+Left'] }, + { id: 'table.sheet.extendJumpRight', scope: 'tableGrid', keys: ['Ctrl+Shift+Right'] }, + { id: 'table.sheet.firstCell', scope: 'tableGrid', keys: ['Ctrl+Home'] }, + { id: 'table.sheet.lastCell', scope: 'tableGrid', keys: ['Ctrl+End'] }, + { id: 'table.sheet.extendFirstCell', scope: 'tableGrid', keys: ['Ctrl+Shift+Home'] }, + { id: 'table.sheet.extendLastCell', scope: 'tableGrid', keys: ['Ctrl+Shift+End'] }, + { id: 'table.sheet.extendRowStart', scope: 'tableGrid', keys: ['Shift+Home'] }, + { id: 'table.sheet.screenLeft', scope: 'tableGrid', keys: ['Alt+PageUp'] }, + { id: 'table.sheet.screenRight', scope: 'tableGrid', keys: ['Alt+PageDown'] }, + { id: 'table.sheet.moveUp', scope: 'tableGrid', keys: ['Shift+Enter'] }, + { id: 'table.sheet.fillEntry', scope: 'tableGrid', keys: ['Ctrl+Enter'], refines: 'table.rowBelow' }, + { id: 'table.sheet.edit', scope: 'tableGrid', keys: ['F2'] }, + { id: 'table.sheet.clear', scope: 'tableGrid', keys: ['Delete'] }, + { id: 'table.sheet.clearAndEdit', scope: 'tableGrid', keys: ['Backspace'] }, + { id: 'table.sheet.selectColumn', scope: 'tableGrid', keys: ['Ctrl+Space'], refines: 'format.clearCharacter' }, + { id: 'table.sheet.selectRegion', scope: 'tableGrid', keys: ['Ctrl+A'] }, + { id: 'table.sheet.fillDown', scope: 'tableGrid', keys: ['Ctrl+D'], refines: 'table.fillDown' }, + { id: 'table.sheet.fillRight', scope: 'tableGrid', keys: ['Ctrl+R'], refines: 'table.fillRight' }, + { id: 'table.sheet.copyFormulaAbove', scope: 'tableGrid', keys: ["Ctrl+'"] }, + { id: 'table.sheet.copyValueAbove', scope: 'tableGrid', keys: ['Ctrl+"'] }, + { id: 'table.sheet.insertDate', scope: 'editor.table', keys: ['Ctrl+;'] }, + { id: 'table.sheet.insertTime', scope: 'editor.table', keys: ['Ctrl+:'] }, + { id: 'table.sheet.commitUp', scope: 'tableEdit', keys: ['Shift+Enter'] }, + { id: 'table.sheet.commitStay', scope: 'tableEdit', keys: ['Ctrl+Enter'], refines: 'table.rowBelow' }, + { id: 'table.sheet.lineBreak', scope: 'tableEdit', keys: ['Alt+Enter'] }, + { id: 'table.sheet.toggleEnterMode', scope: 'tableEdit', keys: ['F2'] }, +]; export const tablesScopes: ScopeFixture = { program: 'tables', owner: 'tables:keys', commands: [ - { id: 'table.fillDown', scope: 'tableGrid', keys: ['Ctrl+D'] }, - { id: 'table.fillRight', scope: 'tableGrid', keys: ['Ctrl+R'] }, - { id: 'table.fillEntry', scope: 'tableGrid', keys: ['Ctrl+Enter'] }, - { id: 'table.selectColumn', scope: 'tableGrid', keys: ['Ctrl+Space'] }, + ...sheetCommands, { id: 'table.calculateNow', scope: 'tableGrid', keys: ['F9'] }, { id: 'table.showFormulas', scope: 'tableGrid', keys: ['Ctrl+`'] }, - { id: 'table.commitStay', scope: 'tableEdit', keys: ['Ctrl+Enter'] }, { id: 'table.evaluatePart', scope: 'tableEdit', keys: ['F9'] }, // Table-only chords in both modes. { id: 'table.strikethrough', scope: 'editor.table', keys: ['Ctrl+5'] }, { id: 'table.insertFunction', scope: 'editor.table', keys: ['Shift+F3'] }, ], cases: [ - { chord: 'Ctrl+D', at: 'tableGrid', expect: 'table.fillDown', why: 'Grid: fill down' }, + { chord: 'Ctrl+D', at: 'tableGrid', expect: 'table.sheet.fillDown', why: 'Grid: fill down' }, { chord: 'Ctrl+D', at: 'tableEdit', expect: 'format.fontDialog', why: 'Edit: the text meaning, Font' }, - { chord: 'Ctrl+R', at: 'tableGrid', expect: 'table.fillRight', why: 'Grid: fill right' }, + { chord: 'Ctrl+R', at: 'tableGrid', expect: 'table.sheet.fillRight', why: 'Grid: fill right' }, { chord: 'Ctrl+R', at: 'tableEdit', expect: 'format.alignRight', why: 'Edit: align right, as the cell alignment' }, - { chord: 'Ctrl+Enter', at: 'tableGrid', expect: 'table.fillEntry', why: 'Grid: fill the range with the entry' }, - { chord: 'Ctrl+Enter', at: 'tableEdit', expect: 'table.commitStay', why: 'Edit: commit and stay' }, + { chord: 'Ctrl+Enter', at: 'tableGrid', expect: 'table.sheet.fillEntry', why: 'Grid: fill the range' }, + { chord: 'Ctrl+Enter', at: 'tableEdit', expect: 'table.sheet.commitStay', why: 'Edit: commit and stay' }, + { + chord: 'Ctrl+Enter', + at: 'editor', + disabled: ['block.toggleCheck'], + expect: 'insert.pageBreak', + why: 'Outside a table and a task item: a page break', + }, + { chord: 'Shift+Enter', at: 'tableGrid', expect: 'table.sheet.moveUp', why: 'Grid: move up a cell' }, + { chord: 'Shift+Enter', at: 'tableEdit', expect: 'table.sheet.commitUp', why: 'Edit: commit and move up' }, + { chord: 'Ctrl+Space', at: 'tableGrid', expect: 'table.sheet.selectColumn', why: 'Grid: select the column' }, + { chord: 'Ctrl+Space', at: 'tableEdit', expect: 'format.clearCharacter', why: 'Edit: clear formatting' }, + { chord: 'Ctrl+;', at: 'tableGrid', expect: 'table.sheet.insertDate', why: 'Grid: today’s date' }, + { chord: 'Ctrl+;', at: 'tableEdit', expect: 'table.sheet.insertDate', why: 'Edit: today’s date' }, + { chord: 'Ctrl+:', at: 'tableGrid', expect: 'table.sheet.insertTime', why: 'Grid: the time' }, + { chord: 'Ctrl+:', at: 'tableEdit', expect: 'table.sheet.insertTime', why: 'Edit: the time' }, + { chord: 'F2', at: 'tableGrid', expect: 'table.sheet.edit', why: 'Grid: edit the active cell' }, + { chord: 'F2', at: 'tableEdit', expect: 'table.sheet.toggleEnterMode', why: 'Edit: switch Enter and Edit mode' }, + { chord: 'Alt+Enter', at: 'tableEdit', expect: 'table.sheet.lineBreak', why: 'Edit: a line break in the cell' }, + { chord: 'Ctrl+A', at: 'tableGrid', expect: 'table.sheet.selectRegion', why: 'Grid: select the region' }, + { chord: "Ctrl+'", at: 'tableGrid', expect: 'table.sheet.copyFormulaAbove', why: 'Grid: formula from above' }, + { chord: 'Ctrl+"', at: 'tableGrid', expect: 'table.sheet.copyValueAbove', why: 'Grid: value from above' }, + { chord: 'Delete', at: 'tableGrid', expect: 'table.sheet.clear', why: 'Grid: clear the cells' }, + { chord: 'Backspace', at: 'tableGrid', expect: 'table.sheet.clearAndEdit', why: 'Grid: clear and edit' }, + { + chord: 'Shift+Home', + at: 'tableGrid', + expect: 'table.sheet.extendRowStart', + why: 'Grid: extend to the row start', + }, + { + chord: 'Ctrl+Shift+Home', + at: 'tableGrid', + expect: 'table.sheet.extendFirstCell', + why: 'Grid: to the first cell', + }, + { chord: 'Ctrl+Shift+End', at: 'tableGrid', expect: 'table.sheet.extendLastCell', why: 'Grid: to the last cell' }, + { chord: 'Alt+PageUp', at: 'tableGrid', expect: 'table.sheet.screenLeft', why: 'Grid: one screen left' }, + { chord: 'Alt+PageDown', at: 'tableGrid', expect: 'table.sheet.screenRight', why: 'Grid: one screen right' }, { chord: 'Ctrl+5', at: 'tableGrid', expect: 'table.strikethrough', why: 'Grid: strikethrough on the range' }, { chord: 'Ctrl+5', at: 'tableEdit', expect: 'table.strikethrough', why: 'Edit: strikethrough on the text' }, { chord: 'Shift+F3', at: 'tableGrid', expect: 'table.insertFunction', why: 'Grid: insert function' }, { chord: 'Shift+F3', at: 'tableEdit', expect: 'table.insertFunction', why: 'Edit: insert function' }, - { chord: 'Ctrl+Space', at: 'tableGrid', expect: 'table.selectColumn', why: 'Grid: select the column' }, - { chord: 'Ctrl+Space', at: 'tableEdit', expect: 'format.clearCharacter', why: 'Edit: clear formatting' }, { chord: 'F9', at: 'tableGrid', expect: 'table.calculateNow', why: 'Grid: calculate now' }, { chord: 'F9', at: 'tableEdit', expect: 'table.evaluatePart', why: 'Edit: evaluate the selected part' }, { chord: 'Ctrl+`', at: 'tableGrid', expect: 'table.showFormulas', why: 'Grid: show formulas' }, diff --git a/app/src/commands/scopes.test.ts b/app/src/commands/scopes.test.ts index cba0edf62..97ac83f81 100644 --- a/app/src/commands/scopes.test.ts +++ b/app/src/commands/scopes.test.ts @@ -54,6 +54,12 @@ const KEYS: Readonly> = { Space: { key: ' ', code: 'Space' }, Tab: { key: 'Tab', code: 'Tab' }, '`': { key: '`', code: 'Backquote' }, + Delete: { key: 'Delete', code: 'Delete' }, + Backspace: { key: 'Backspace', code: 'Backspace' }, + Home: { key: 'Home', code: 'Home' }, + End: { key: 'End', code: 'End' }, + PageUp: { key: 'PageUp', code: 'PageUp' }, + PageDown: { key: 'PageDown', code: 'PageDown' }, }; /** A key press as the browser reports it for a chord such as 'Ctrl+Shift+Tab', on a US layout. */ diff --git a/app/src/core/expr/dialect.ts b/app/src/core/expr/dialect.ts index 9ab5f0e75..b2f800e0a 100644 --- a/app/src/core/expr/dialect.ts +++ b/app/src/core/expr/dialect.ts @@ -80,6 +80,11 @@ export interface Dialect { * rows 3:3. It also lets a name that is not a cell through as a name, which the host reports as unknown. */ excelRefs?: boolean; + /** + * When true, an argument may be left empty, as in UNIQUE(A1:A9,,TRUE). It reads as a name with no text, which the + * host treats as an omitted argument. Default false. + */ + emptyArguments?: boolean; /** When set, R1C1 references such as R[-1]C read relative to this cell (zero-based), as the clipboard needs. */ r1c1?: { readonly row: number; readonly col: number }; } diff --git a/app/src/core/expr/lexer.test.ts b/app/src/core/expr/lexer.test.ts index 315434ca4..35d458862 100644 --- a/app/src/core/expr/lexer.test.ts +++ b/app/src/core/expr/lexer.test.ts @@ -188,6 +188,7 @@ describe('dotted function names (Excel dialect)', () => { it('reads CEILING.MATH and NORM.S.DIST as one name in the Excel dialect', () => { expect(kinds('CEILING.MATH(2)', excel)[0]).toBe('name:CEILING.MATH'); expect(kinds('NORM.S.DIST(1)', excel)[0]).toBe('name:NORM.S.DIST'); + expect(kinds('T.DIST.2T(1,2)', excel)[0]).toBe('name:T.DIST.2T'); }); it('does not take a dot followed by a digit or nothing as part of the name', () => { diff --git a/app/src/core/expr/lexer.ts b/app/src/core/expr/lexer.ts index 7efabe1c6..46502efa1 100644 --- a/app/src/core/expr/lexer.ts +++ b/app/src/core/expr/lexer.ts @@ -79,8 +79,9 @@ const SUPERSCRIPT_MINUS = '⁻'; const NAME_PATTERNS = { ascii: /[A-Za-z_][A-Za-z0-9_]*/y, cell: /[A-Za-z_$][A-Za-z0-9_$]*/y, - // Excel's function names have dots inside: CEILING.MATH, STDEV.S, NORM.S.DIST. - dotted: /[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z][A-Za-z0-9_]*)*/y, + // Excel's function names have dots inside: CEILING.MATH, STDEV.S, NORM.S.DIST, and T.DIST.2T, whose last part + // starts with a digit. A part must hold a letter, so "A.5" stays a name and a number. + dotted: /[A-Za-z_$][A-Za-z0-9_$]*(?:\.\d*[A-Za-z][A-Za-z0-9_]*)*/y, letters: /\p{L}+\d*/uy, } as const; const WHITESPACE = /\s/; diff --git a/app/src/core/expr/parser.ts b/app/src/core/expr/parser.ts index d46564beb..559915943 100644 --- a/app/src/core/expr/parser.ts +++ b/app/src/core/expr/parser.ts @@ -584,16 +584,23 @@ class Parser implements Cursor { private arguments(open: Token): Node[] { const args: Node[] = []; if (this.peek().kind !== 'rparen') { - args.push(this.expr(0)); + args.push(this.argument()); while (this.peek().kind === 'sep') { this.next(); - args.push(this.expr(0)); + args.push(this.argument()); } } this.close(open); return args; } + /** One argument. Where the dialect allows it, an empty one, as in UNIQUE(A1:A9,,TRUE), is a name with no text. */ + private argument(): Node { + const next = this.peek(); + if (this.dialect.emptyArguments !== true || (next.kind !== 'sep' && next.kind !== 'rparen')) return this.expr(0); + return this.made({ type: 'name', name: '', start: next.start, end: next.start, pos: next.start }); + } + private finishCall( token: Token, info: FunctionInfo | undefined, diff --git a/app/src/core/expr/sheet.ts b/app/src/core/expr/sheet.ts index 324e455a8..0567baf8e 100644 --- a/app/src/core/expr/sheet.ts +++ b/app/src/core/expr/sheet.ts @@ -83,7 +83,13 @@ export function sheetDialect(syntax: SheetSyntax): Dialect { definitions: false, maxDepth: 200, ...(excel - ? { signBindsTighter: true, powerAssociativity: 'left', inputDigits: EXCEL_DIGITS, excelRefs: true } + ? { + signBindsTighter: true, + powerAssociativity: 'left', + inputDigits: EXCEL_DIGITS, + excelRefs: true, + emptyArguments: true, + } : {}), }; cache.set(key, dialect); diff --git a/app/src/editor/commands/formatTarget.ts b/app/src/editor/commands/formatTarget.ts index c575a5d1b..f6b09824d 100644 --- a/app/src/editor/commands/formatTarget.ts +++ b/app/src/editor/commands/formatTarget.ts @@ -61,6 +61,7 @@ export function formatTargetFor(state: EditorState): FormatTarget | null { export function viaFormatTarget(request: FormatRequest, onText: Command): Command { return (state, dispatch) => { const target = formatTargetFor(state); - return target ? target.apply(request, state, dispatch) : onText(state, dispatch); + // A target that has no meaning for the request (highlight on whole cells) leaves it to the text. + return target ? target.apply(request, state, dispatch) || onText(state, dispatch) : onText(state, dispatch); }; } diff --git a/app/src/editor/commands/state.ts b/app/src/editor/commands/state.ts index 77bfb3468..6c693171c 100644 --- a/app/src/editor/commands/state.ts +++ b/app/src/editor/commands/state.ts @@ -57,6 +57,8 @@ export function caretInLink(state: EditorState): boolean { /** Whether the selection has text in it, or the caret is in a link: when Ctrl+K edits a link. */ export function linkApplies(state: EditorState): boolean { + // Selected cells are not selected text: in a table's Grid mode Ctrl+K stays the palette (ADR 0063). + if ('$anchorCell' in state.selection) return caretInLink(state); return !state.selection.empty || caretInLink(state); } diff --git a/app/src/features/page/blocks/tableBlock.test.ts b/app/src/features/page/blocks/tableBlock.test.ts index 4572578f2..513ad7d8e 100644 --- a/app/src/features/page/blocks/tableBlock.test.ts +++ b/app/src/features/page/blocks/tableBlock.test.ts @@ -15,6 +15,8 @@ import { textPageFixture } from '../test/fixtures'; import { cleanupPages, renderPage } from '../test/harness'; import type { PageHarness } from '../test/harness'; import { currentTable } from '../tables/current'; +import { tableExtras } from '../tables/extras'; +import type { TableExtraHost } from '../tables/extras'; import { whenViewReady } from '../tables/lazyView'; import '../register'; @@ -220,3 +222,66 @@ describe('table commands', () => { expect(page.markdown(text)).toBe('First'); }); }); + +describe('table extras', () => { + it('tells every extra about the data an extra wrote, so a second extra plans from it', async () => { + let writer: TableExtraHost | undefined; + const seen: unknown[] = []; + const stop = [ + tableExtras.register({ + id: 'test.writer', + attach: (host) => { + writer = host; + return { update: () => {}, destroy: () => {} }; + }, + }), + tableExtras.register({ + id: 'test.reader', + attach: () => ({ update: (block) => seen.push(block.data), destroy: () => {} }), + }), + ]; + try { + await tablePage(); + await vi.waitFor(() => expect(writer).toBeDefined()); + await writer!.patch({ sheet: { styles: { s1: { fmt: 'first' } } } }); + expect(seen.at(-1)).toMatchObject({ sheet: { styles: { s1: { fmt: 'first' } } } }); + expect(writer!.data().sheet).toEqual({ styles: { s1: { fmt: 'first' } } }); + // A second write merges into the first, as the core does. + await writer!.patch({ sheet: { styles: { s2: { fmt: 'second' } } } }); + expect(writer!.data().sheet).toEqual({ styles: { s1: { fmt: 'first' }, s2: { fmt: 'second' } } }); + } finally { + stop.forEach((undo) => undo()); + } + }); + + it('tells each extra once per write, with the smart extra attached, so no extra writes in a loop', async () => { + let writer: TableExtraHost | undefined; + let updates = 0; + const stop = [ + tableExtras.register({ + id: 'test.writer', + attach: (host) => { + writer = host; + return { update: () => {}, destroy: () => {} }; + }, + }), + tableExtras.register({ + id: 'test.counter', + attach: () => ({ update: () => void updates++, destroy: () => {} }), + }), + ]; + try { + const { editor } = await tablePage(); + await vi.waitFor(() => expect(writer).toBeDefined()); + const before = updates; + await writer!.patch({ smart: { filters: [] } }); + await writer!.patch({ sheet: { styles: { s1: { fmt: 'first' } } } }); + // The page's own smart extra receives these writes too. A write of its own would show as a third update. + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(updates - before).toBe(2); + expect(editor.isDestroyed).toBe(false); + } finally { + stop.forEach((undo) => undo()); + } + }); +}); diff --git a/app/src/features/page/blocks/tableBlock.ts b/app/src/features/page/blocks/tableBlock.ts index 3f0b7159e..676129ffd 100644 --- a/app/src/features/page/blocks/tableBlock.ts +++ b/app/src/features/page/blocks/tableBlock.ts @@ -38,6 +38,7 @@ import type { StaticPool } from '../pool/pool'; import { blockLaidOut } from '../seams/geometry'; import { acceptRemoteText, attachTableSync } from '../sync'; import type { TableSyncHandle } from '../sync'; +import { applyMergePatch } from '../../../platform/mergePatch'; import { domFitInputs } from '../tables/fitInputs'; import { loadSheetApi, sheetApi } from '../tables/lazySheet'; import { tableExtras } from '../tables/extras'; @@ -371,6 +372,7 @@ class TableView implements InnerView { const columns = this.data.columns; this.toolbar.render( createElement(TableToolbar, { + block: this.current.id, can: this.handle.can, header: this.data.header, run: (op: TableOp) => void this.run(op), @@ -452,6 +454,15 @@ class TableView implements InnerView { view.dispatch(state.tr.setSelection(caret).setMeta('addToHistory', false)); } + /** + * An extra's own write reaches every extra at once: the block's data holds it, and each extra's update runs. So a + * second extra plans from the data the first one wrote, not from the data before it. + */ + private echo(patch: Record): void { + this.current = { ...this.current, data: applyMergePatch(this.current.data ?? {}, patch) } as BlockJson; + for (const extra of this.extras) extra.update(this.current); + } + /** What features that add to the table may do (see tables/extras.ts). */ private attachExtras(editor: Editor): void { const block = this.current.id; @@ -468,10 +479,12 @@ class TableView implements InnerView { const next = change(this.latest()); if (!next) return false; this.show(next, this.caretCell()); + if (extra) this.echo(extra); await this.send(next, undefined, extra); return true; }, patch: async (data) => { + this.echo(data); if (this.sync) await this.sync.flush('command'); const all = this.withExtras(this.latest(), data); return this.ctx.sync.send({ edits: [{ edit: 'patchBlock', block, data: all }] }); diff --git a/app/src/features/page/index.ts b/app/src/features/page/index.ts index a65e84714..846a1dd7d 100644 --- a/app/src/features/page/index.ts +++ b/app/src/features/page/index.ts @@ -23,6 +23,11 @@ export type { TranscriptAction, TranscriptContext } from './audio/transcripts/ac export { usePrefs as usePageExtrasPrefs } from './qol/prefs'; export { readingLock } from './qol/stores'; export type { TableExtraDef, TableExtraHandle, TableExtraHost } from './tables/extras'; +/** What features add to a table block, such as the spreadsheet keys (a light module). */ +export { tableExtras } from './tables/extras'; +/** The table the table commands act on now. */ +export { currentTable } from './tables/current'; +export type { TableHandle } from './tables/current'; export { assetTable } from './images/assets'; /** Shows blocks this window inserted, which the page view does not show until it has them. Loads when first used. */ export const showInserted = async (...args: Parameters) => diff --git a/app/src/features/page/tables/TableToolbar.tsx b/app/src/features/page/tables/TableToolbar.tsx index b09b3797e..657b6fc9c 100644 --- a/app/src/features/page/tables/TableToolbar.tsx +++ b/app/src/features/page/tables/TableToolbar.tsx @@ -10,7 +10,7 @@ import { RowsIcon } from '@phosphor-icons/react/dist/csr/Rows'; import { RowsPlusBottomIcon } from '@phosphor-icons/react/dist/csr/RowsPlusBottom'; import { RowsPlusTopIcon } from '@phosphor-icons/react/dist/csr/RowsPlusTop'; import { TextHOneIcon } from '@phosphor-icons/react/dist/csr/TextHOne'; -import { useEffect, useRef, useState } from 'react'; +import { lazy, Suspense, useEffect, useLayoutEffect, useRef, useState } from 'react'; import type { ComponentType, KeyboardEvent } from 'react'; import type { CommandId } from '../../../commands/types'; import type { TableOp } from '../../../editor/commands/tables'; @@ -21,7 +21,14 @@ import { IconButton, Switch, TextField } from '../../../ui'; import type { IconProps } from '../../../ui'; import styles from './tables.module.css'; +/** The cell format bar loads with the first table toolbar (the tables feature's own chunk). */ +const CellFormatBar = lazy(async () => ({ + default: (await (await import('../../tables')).loadCellFormatBar()).CellFormatBar, +})); + export interface TableToolbarProps { + /** The table block's ID, which the cell format bar uses to find the block's data. */ + block?: string; can(op: TableOp): boolean; header: boolean; run(op: TableOp): void; @@ -153,9 +160,42 @@ function HeightField(props: { ); } +/** + * The tools sit above the table. When they are taller than the room between the table and the top of the page (the + * cell format bar adds rows, and touch targets are larger), they go below it instead, so no button is cut off. + */ +function useRoomAbove(wrap: { current: HTMLDivElement | null }) { + useLayoutEffect(() => { + const node = wrap.current; + const owner = node?.parentElement; + if (!node || !owner) return undefined; + const clipTop = (): number => { + for (let at = owner.parentElement; at; at = at.parentElement) { + const style = getComputedStyle(at); + if (/clip|hidden|auto|scroll/.test(style.overflowY)) return at.getBoundingClientRect().top; + } + return 0; + }; + const place = () => { + const room = owner.getBoundingClientRect().top - clipTop(); + const tall = node.getBoundingClientRect().height; + if (tall > 0 && tall > room) node.setAttribute('data-below', ''); + else node.removeAttribute('data-below'); + }; + place(); + // A surface with no ResizeObserver (a test document) keeps the first placement. + if (typeof ResizeObserver === 'undefined') return undefined; + const watch = new ResizeObserver(place); + watch.observe(node); + return () => watch.disconnect(); + }, [wrap]); +} + export function TableToolbar(props: TableToolbarProps) { - const { can, header, run, more, width, widthOpen, openWidth, closeWidth } = props; + const { block, can, header, run, more, width, widthOpen, openWidth, closeWidth } = props; const moreButton = useRef(null); + const wrap = useRef(null); + useRoomAbove(wrap); const button = (tool: Tool, first: boolean) => ( ); return ( -
+
+ {block ? ( + + + + ) : null} {widthOpen ? : null} {props.heightOpen && props.closeHeight ? ( diff --git a/app/src/features/page/tables/extras.ts b/app/src/features/page/tables/extras.ts index 620b254b6..f98d83ad7 100644 --- a/app/src/features/page/tables/extras.ts +++ b/app/src/features/page/tables/extras.ts @@ -39,7 +39,7 @@ export interface TableExtraHost { } export interface TableExtraHandle { - /** The block changed from outside this extra: undo, redo, or another window. */ + /** The block changed from outside this extra (undo, redo, another window), or an extra wrote to it. */ update(block: BlockJson): void; /** * Keys to add to the patch of an edit that leaves the table as `data`, with `extra` the keys the edit already diff --git a/app/src/features/page/tables/insert.ts b/app/src/features/page/tables/insert.ts index 580b90992..dbd20de0e 100644 --- a/app/src/features/page/tables/insert.ts +++ b/app/src/features/page/tables/insert.ts @@ -2,6 +2,7 @@ // paragraph with the caret. Text after that paragraph moves into a new text block below the table, so the page // reads in the same order. The text edit and both new blocks go to the core as one step, which one undo removes. import type { Editor } from '@tiptap/core'; +import { isEnabled } from '../../../app/flags'; import type { EditorState } from '@tiptap/pm/state'; import { newId } from '../../../editor/ids'; import { createMarkdownCache, serializeTextBlock } from '../../../editor/markdown'; @@ -9,6 +10,7 @@ import { META_REMOTE } from '../../../editor/meta'; import type { TableData } from '../../../editor/schema/specs'; import { newTableData, tableOf } from '../../../editor/table/mapping'; import type { BlockId, BlockJson, Edit, Frame } from '../../../services/pages/types'; +import { getSettings } from '../../../state/settings'; import { t } from '../../../strings/t'; import { announce } from '../../../ui'; import { syncOf } from '../blocks/textBlock'; @@ -35,6 +37,19 @@ export function splitAtCaret(editor: Editor): { head: string; tail: string; head }; } +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +/** A new table stores the setting "New tables use spreadsheet keys" as data.sheet.table.keys (ADR 0063). */ +function withKeys(data: TableData): Record { + const own = data as unknown as Record; + if (!isEnabled('tables.sheetWrites')) return own; + const sheet = isRecord(own.sheet) ? own.sheet : {}; + const table = isRecord(sheet.table) ? sheet.table : {}; + if (table.keys !== undefined) return own; + return { ...own, sheet: { ...sheet, table: { ...table, keys: getSettings().tables.newTableKeys } } }; +} + /** * Splits a text block's document at the caret, as Word and Docs do for a break: the paragraph is cut where the caret * is, and the rest of it goes below. At the end of a paragraph, or in an empty one, it is the same as `splitAtCaret`. @@ -62,12 +77,12 @@ export function splitAtPosition(state: EditorState): { head: string; tail: strin } export async function insertTable(data: TableData = newTableData(3, 3, true)): Promise { - return insertBlocks([{ type: 'table', data: data as unknown as Record }]); + return insertBlocks([{ type: 'table', data: withKeys(data) }]); } /** Several tables, one below the other after the block with focus, as one step: one undo removes them all. */ export async function insertTables(list: readonly TableData[]): Promise { - return insertBlocks(list.map((data) => ({ type: 'table', data: data as unknown as Record }))); + return insertBlocks(list.map((data) => ({ type: 'table', data: withKeys(data) }))); } /** A display equation, as a text block of its own after the block with focus. */ diff --git a/app/src/features/page/tables/tables.module.css b/app/src/features/page/tables/tables.module.css index 275002341..b28904532 100644 --- a/app/src/features/page/tables/tables.module.css +++ b/app/src/features/page/tables/tables.module.css @@ -134,6 +134,13 @@ padding-block: var(--space-1) 0; } + /* Too tall for the room above the table (set from the toolbar's own measure): the tools go below it. */ + .toolbarWrap[data-below] { + inset-block-end: auto; + inset-block-start: 100%; + padding-block: var(--space-1) 0; + } + .wrapper[data-current] .toolbarWrap { display: flex; } diff --git a/app/src/features/pages/export/blocks.ts b/app/src/features/pages/export/blocks.ts index ba77a1a85..509cd1312 100644 --- a/app/src/features/pages/export/blocks.ts +++ b/app/src/features/pages/export/blocks.ts @@ -6,17 +6,9 @@ import { own } from '../layout/json'; import { ENGLISH_PLAYER_LABELS, momentHtml, playerHtml } from './recording'; import type { PlayerPlan } from './recording'; import { inkShapes, inkExtent, inkSvg } from './ink'; -import { - escapeAttr, - escapeHtml, - parseInline, - parseMarkdown, - renderHtml, - renderInlineHtml, - voidEnd, - type HtmlOptions, -} from './markdown'; +import { escapeAttr, escapeHtml, parseMarkdown, renderHtml, voidEnd, type HtmlOptions } from './markdown'; import type { ExportRenderers } from './renderers'; +import { tableHtml } from './tableHtml'; import type { ExportAsset, ExportBlock, ExportPage, ExportStroke, TableBlock } from './source'; /** Words the export writes itself. The caller supplies translations. */ @@ -60,6 +52,8 @@ export interface BlockContext { readonly strokes: ReadonlyMap; /** Drawers for math and graphs, when the host has loaded them. Without them both show as text. */ readonly renderers?: Pick; + /** Hears of a table whose formula results are out of date, so its formulas export as text (the report names it). */ + readonly onStaleTable?: (blockId: string) => void; } /** The options the Markdown renderer needs for a page: how links and images resolve. */ @@ -125,16 +119,7 @@ function file(block: ExportBlock & { type: 'file' }, cx: BlockContext): string { } function table(block: TableBlock, cx: BlockContext): string { - const options = htmlOptions(cx); - const cell = (tag: 'th' | 'td', markdown: string) => - `<${tag}${tag === 'th' ? ' scope="col"' : ''}>${renderInlineHtml(parseInline(markdown), options)}`; - const row = (r: TableBlock['rows'][number], tag: 'th' | 'td') => - `${block.columns.map((c) => cell(tag, own(r.cells, c.id) ?? '')).join('')}`; - const [first, ...rest] = block.rows; - const head = block.header && first ? `${row(first, 'th')}\n` : ''; - const body = (block.header && first ? rest : block.rows).map((r) => row(r, 'td')).join('\n'); - const cols = block.columns.map((c) => `${cols}\n${head}\n${body}\n`; + return tableHtml(block, htmlOptions(cx), cx.xml, cx.onStaleTable); } /** The strokes of each ink block, so a block finds its own. */ diff --git a/app/src/features/pages/export/index.ts b/app/src/features/pages/export/index.ts index 23ed95c24..8bbc4d054 100644 --- a/app/src/features/pages/export/index.ts +++ b/app/src/features/pages/export/index.ts @@ -11,6 +11,7 @@ export type { InkShape, Placements } from './ink'; export { exportStrokes, liveStrokes, toExportStroke } from './inkSource'; export { ROW_TOLERANCE, readingOrder } from './order'; export { loadRenderers, withCharts } from './renderers'; +export { loadTableSheetFor } from './tableSheet'; export type { ChartFigure, ExportRenderers } from './renderers'; export { isFloating, readExportPage } from './source'; export type { diff --git a/app/src/features/pages/export/renderers.ts b/app/src/features/pages/export/renderers.ts index d9c6cf24d..d2de963a6 100644 --- a/app/src/features/pages/export/renderers.ts +++ b/app/src/features/pages/export/renderers.ts @@ -4,6 +4,7 @@ // still prints, with the math or the graph as its source text and a chart left out. import { readingOrder } from './order'; +import { loadTableSheetFor } from './tableSheet'; import type { ExportBlock, ExportPage, TableBlock } from './source'; /** A chart drawn as SVG text, with the name a screen reader gives it. */ @@ -43,6 +44,8 @@ export async function loadRenderers(page: ExportPage): Promise // The text still prints. } } + // Tables with a sheet print with their cell styles and formula results. + await loadTableSheetFor(page); if (charts) { try { out.charts = (await import('../../tables')).exportTableCharts; diff --git a/app/src/features/pages/export/source.ts b/app/src/features/pages/export/source.ts index aea4782bd..511f85a38 100644 --- a/app/src/features/pages/export/source.ts +++ b/app/src/features/pages/export/source.ts @@ -94,6 +94,8 @@ export interface TableBlock extends BlockBase { readonly rows: readonly TableRow[]; /** The smart-table data the block keeps beside its cells (columns, filters, charts), as the file has it. */ readonly smart?: JsonObject; + /** The sheet data the block keeps beside its cells (styles, merges, cached results), as the file has it. */ + readonly sheet?: JsonObject; } /** A diagram drawn from Mermaid text. `svg` is the drawing, added by the caller that can draw it. */ @@ -213,6 +215,7 @@ function readTable(data: JsonObject): Body { return { type: 'table', ...(isObject(data.smart) ? { smart: data.smart } : {}), + ...(isObject(data.sheet) ? { sheet: data.sheet } : {}), header: flag(data.header), columns: columns.filter(isObject).map((c) => ({ id: str(c.id), width: geometry(c.width) ?? 120 })), rows: rows.filter(isObject).map((r) => { diff --git a/app/src/features/pages/export/tableHtml.test.ts b/app/src/features/pages/export/tableHtml.test.ts new file mode 100644 index 000000000..f37cb54c5 --- /dev/null +++ b/app/src/features/pages/export/tableHtml.test.ts @@ -0,0 +1,175 @@ +// checks-disable-file brand-consistency: the test data holds color values to check how they are written +// Tests for the table export: formula results, merges, and cell looks in HTML. Expected values are worked out by +// hand from the format spec (R-SHT-5, R-SHT-12, R-SHT-17, R-SHT-18) and from HTML and CSS, not from the code's output. +import { beforeAll, describe, expect, it } from 'vitest'; +import { sheetModel } from '../../tables'; +import { readExportPage, type ExportPage } from './source'; +import { cellCss } from './tableHtml'; +import { exportHtml } from './toHtml'; +import { loadTableSheet } from './tableSheet'; + +// The sheet reader loads on demand; the export paths load it before they write (tableSheet.ts). +beforeAll(loadTableSheet); + +const cols = ['k1', 'k2', 'k3']; +const cell = (markdown: string) => ({ markdown }); + +/** A 3 by 3 table: a header and two rows. B2 holds the formula, so its cached result is 6. */ +function page(sheet: Record, stale = false): ExportPage { + const rows = [ + { id: 'r1', cells: { k1: cell('A'), k2: cell('B'), k3: cell('C') } }, + { id: 'r2', cells: { k1: cell('x'), k2: cell('=2*3'), k3: cell('') } }, + { id: 'r3', cells: { k1: cell('y'), k2: cell(''), k3: cell('') } }, + ]; + const data = { + header: true, + columns: cols.map((id) => ({ id, width: 100 })), + rows, + }; + const like = { header: true, columns: data.columns, rows, smart: undefined }; + const valuesOf = stale ? 'fnv1a:0000000000000000' : sheetModel.fingerprint(like); + return readExportPage({ + id: 'P', + title: 'T', + blocks: [ + { + id: 'tb', + order: 'a', + type: 'table', + data: { ...data, sheet: { values: { r2: { k2: 6 } }, valuesOf, ...sheet } }, + }, + ], + }); +} + +describe('table HTML with a sheet', () => { + it('writes the cached result of a formula cell when it is fresh', () => { + const html = exportHtml(page({})).html; + expect(html).toContain('6'); + expect(html).not.toContain('=2*3'); + }); + + it('writes the formula text and names the table when the fingerprint is stale', () => { + const stale: string[] = []; + const html = exportHtml(page({}, true), { onStaleTable: (id) => stale.push(id) }).html; + expect(html).toContain('=2*3'); + expect(stale).toEqual(['tb']); + }); + + it('writes colspan and rowspan on the anchor of a merge and leaves out the covered cells', () => { + // B2:C3 is rows r2 to r3 and columns k2 to k3. + const html = exportHtml(page({ merges: { m1: { from: ['r2', 'k2'], to: ['r3', 'k3'] } } })).html; + expect(html).toContain('6'); + // Row 2 has A-column cell and the anchor only; row 3 has its A-column cell only. + expect(html).toContain('x6'); + expect(html).toContain('y'); + }); + + it('writes a fill, a double bottom border, vertical center, and padding as CSS', () => { + const styles = { + s1: { + fill: '#ffeecc', + border: { b: { style: 'double', color: '#112233' } }, + align: { v: 'middle', h: 'center' }, + pad: { t: 3, r: 5, b: 3, l: 5 }, + }, + }; + const html = exportHtml(page({ styles, cells: { r2: { k1: 's1' } } })).html; + expect(html).toContain('background-color:#ffeecc'); + expect(html).toContain('border-bottom:3px double #112233'); + expect(html).toContain('vertical-align:middle'); + expect(html).toContain('text-align:center'); + expect(html).toContain('padding:3pt 5pt 3pt 5pt'); + }); + + it('writes bold, italic, strike, and a font size of a style', () => { + const styles = { s1: { font: { bold: true, italic: true, strike: 'single', size: 14 } } }; + const html = exportHtml(page({ styles, cells: { r2: { k1: 's1' } } })).html; + expect(html).toContain('font-size:14pt'); + expect(html).toContain('font-weight:700'); + expect(html).toContain('font-style:italic'); + expect(html).toContain('text-decoration:line-through'); + }); + + it('keeps a table without a sheet as plain as before', () => { + const plain = readExportPage({ + id: 'P', + title: 'T', + blocks: [ + { + id: 'tb', + order: 'a', + type: 'table', + data: { header: false, columns: [{ id: 'k1', width: 100 }], rows: [{ id: 'r1', cells: { k1: cell('a') } }] }, + }, + ], + }); + expect(exportHtml(plain).html).toContain('\na\n'); + }); +}); + +describe('table HTML edge cases', () => { + it('does not flag a table without a sheet, as the Rust reader does not', () => { + const plain = readExportPage({ + id: 'P', + title: 'T', + blocks: [ + { + id: 'tb', + order: 'a', + type: 'table', + data: { + header: false, + columns: [{ id: 'k1', width: 100 }], + rows: [{ id: 'r1', cells: { k1: cell('=1+1') } }], + }, + }, + ], + }); + const stale: string[] = []; + const html = exportHtml(plain, { onStaleTable: (id) => stale.push(id) }).html; + expect(html).toContain('=1+1'); + expect(stale).toEqual([]); + }); + + it('keeps the formula text for a cached entry that is not a valid value', () => { + // R-SHT-17: an array, or an object that is not a lone error code, is ignored. The Rust reader does the same. + const a = page({ values: { r2: { k2: [1, 2] } } }); + expect(exportHtml(a).html).toContain('=2*3'); + const b = page({ values: { r2: { k2: { e: '#N/A', x: 1 } } } }); + expect(exportHtml(b).html).toContain('=2*3'); + }); + + it('keeps only names and separators in a font family', () => { + const css = cellCss({ font: { family: 'Segoe UI, \\22 x url(//e.example/a) {};' } } as never); + expect(css).toBe('font-family:Segoe UI, 22 x urle.examplea b'); + }); + + it('writes a formula result under the number format of its cell', () => { + // 6 under `0.0%` is 600.0%; the format-code rules multiply by 100 for each percent sign. + const styles = { f: { fmt: '0.0%' } }; + const html = exportHtml(page({ styles, cells: { r2: { k2: 'f' } } })).html; + expect(html).toContain('600.0%'); + }); + + it('writes a date result as the date and not the serial number', () => { + // Serial 45000 is 2023-03-15 in the 1900 date system (ECMA-376 18.17.4.1). + const styles = { d: { fmt: 'yyyy-mm-dd' } }; + const html = exportHtml(page({ styles, cells: { r2: { k2: 'd' } }, values: { r2: { k2: 45000 } } })).html; + expect(html).toContain('2023-03-15'); + }); + + it('keeps the header row in the body when a merge reaches from it into the body', () => { + // A1:A2 starts in the header. A would cut the span, so the body row would shift one column left. + const html = exportHtml(page({ merges: { m1: { from: ['r1', 'k1'], to: ['r2', 'k1'] } } })).html; + expect(html).not.toContain(''); + expect(html).toContain('ABC'); + // The covered cell A2 is left out, so the row starts with B2. + expect(html).toContain('6'); + }); + + it('bounds a row height to the range the structure commands keep', () => { + const html = exportHtml(page({ rows: { r2: { height: 1e9 } } })).html; + expect(html).toContain(''); + }); +}); diff --git a/app/src/features/pages/export/tableHtml.ts b/app/src/features/pages/export/tableHtml.ts new file mode 100644 index 000000000..7d9179e4a --- /dev/null +++ b/app/src/features/pages/export/tableHtml.ts @@ -0,0 +1,248 @@ +// A table block as HTML, with what the sheet adds: cached formula results, fills, borders, alignment, padding, +// fonts, and merged cells (format spec 6.3, ADR 0067). Without `data.sheet` the table is the plain one it always was. +// The Markdown export reads the same view, so both agree on what a cell shows. The sheet reader belongs to the +// tables feature, which loads on demand (tableSheet.ts). + +import type { SheetBox, SheetCellStyle, SheetTableLike } from '../../tables'; +import { isObject, own as ownKey } from '../layout/json'; +import type { JsonObject } from '../layout/json'; +import { resolveColor } from '../../../ui/color/parse'; +import { escapeAttr, escapeHtml, parseInline, renderInlineHtml, voidEnd, type HtmlOptions } from './markdown'; +import type { TableBlock } from './source'; +import { tableSheet, type TableSheet } from './tableSheet'; + +/** The object under a key, or undefined. */ +const sub = (o: JsonObject | undefined, key: string): JsonObject | undefined => { + const v = ownKey(o, key); + return isObject(v) ? v : undefined; +}; + +const px = (n: number): string => String(Math.round(n * 100) / 100); + +/** What a table cell shows: its text with the result of a formula, or the cell's own Markdown. */ +export interface CellView { + /** Markdown to render, or null when `result` is the text. */ + readonly markdown: string | null; + /** The cached result of a formula or spilled cell, as text. */ + readonly result: string | null; +} + +export interface TableView { + readonly like: SheetTableLike; + readonly sheet: Record | undefined; + /** True when formula cells had to keep their text because the cached results are stale. */ + stale: boolean; + /** The merge whose top-left cell is at this row and column, as `row:col`. */ + readonly anchors: ReadonlyMap; + /** The cells a merge covers, other than its anchor, as `row:col`. */ + readonly covered: ReadonlySet; + cell(row: number, col: number): CellView; + style(row: number, col: number): SheetCellStyle | null; + /** The row height in page units, when the sheet sets one. */ + height(row: number): number | null; +} + +function resultText(kit: TableSheet, value: unknown, fmt?: string): string | null { + const v = kit.sheetModel.cachedValue(value as never); + if (v === undefined || v === null) return v === null ? '' : null; + if (typeof v === 'boolean') return v ? 'TRUE' : 'FALSE'; + if (typeof v === 'object') return v.e; + // A number shows the way its cell's number format writes it, so 0.25 under `0%` is 25%. + if (typeof v === 'number' && fmt !== undefined && fmt !== '') return kit.renderNumberFormat(v, fmt); + return String(v); +} + +/** + * Reads the sheet of a table block once: cached results, merges, and the resolver of cell styles. While the sheet + * reader has not loaded, the view is the plain table. + */ +export function tableView(block: TableBlock): TableView { + const kit = block.sheet === undefined ? null : tableSheet(); + const like: SheetTableLike = { + header: block.header, + columns: block.columns, + rows: block.rows.map((r) => ({ + id: r.id, + cells: Object.fromEntries(Object.entries(r.cells).map(([k, v]) => [k, { markdown: v }])), + })), + smart: block.smart, + sheet: block.sheet, + }; + const sheet = kit ? block.sheet : undefined; + const anchors = new Map(); + const covered = new Set(); + const values = sub(sheet, 'values'); + const fresh = kit !== null && sheet !== undefined && ownKey(sheet, 'valuesOf') === kit.sheetModel.fingerprint(like); + const view: TableView = { + like, + sheet, + stale: false, + anchors, + covered, + cell(row, col) { + const r = like.rows[row]; + const c = like.columns[col]; + const markdown = r && c ? (Object.hasOwn(r.cells, c.id) ? r.cells[c.id].markdown : '') : ''; + const stored = r && c ? ownKey(sub(values, r.id), c.id) : undefined; + if (kit === null || sheet === undefined) return { markdown, result: null }; + const formula = kit.sheetModel.readableText(markdown).startsWith('='); + if (formula) { + const text = fresh ? resultText(kit, stored === undefined ? null : stored, view.style(row, col)?.fmt) : null; + if (text !== null) return { markdown: null, result: text }; + if (!fresh) view.stale = true; + } else if (markdown === '' && fresh && stored !== undefined) { + const text = resultText(kit, stored, view.style(row, col)?.fmt); + if (text !== null) return { markdown: null, result: text }; + } + return { markdown, result: null }; + }, + style(row, col) { + if (!kit || !sheet || Object.keys(sheet).length === 0) return null; + const r = like.rows[row]; + const c = like.columns[col]; + return r && c ? kit.sheetModel.resolve(sheet as never, like, r.id, c.id).style : null; + }, + height(row) { + const r = like.rows[row]; + const h = r ? ownKey(sub(sub(sheet, 'rows'), r.id), 'height') : undefined; + // The same bounds the structure commands keep (MIN_HEIGHT to MAX_HEIGHT). + return kit && typeof h === 'number' && Number.isFinite(h) && h > 0 + ? Math.min(kit.MAX_HEIGHT, Math.max(kit.MIN_HEIGHT, h)) + : null; + }, + }; + if (kit && sheet) { + for (const { box } of kit.sheetModel.activeMerges(sheet as never, like)) { + anchors.set(`${box.top}:${box.left}`, box); + for (let r = box.top; r <= box.bottom; r++) { + for (let c = box.left; c <= box.right; c++) if (r !== box.top || c !== box.left) covered.add(`${r}:${c}`); + } + } + } + return view; +} + +// The 13 border styles of Excel's grammar, as CSS width, line, and the name. +const BORDERS: Record = { + thin: [1, 'solid'], + hair: [1, 'solid'], + dotted: [1, 'dotted'], + dashed: [1, 'dashed'], + dashDot: [1, 'dashed'], + dashDotDot: [1, 'dashed'], + mediumDashed: [2, 'dashed'], + mediumDashDot: [2, 'dashed'], + mediumDashDotDot: [2, 'dashed'], + slantDashDot: [2, 'dashed'], + medium: [2, 'solid'], + thick: [3, 'solid'], + double: [3, 'double'], +}; + +/** A stored color as `#rrggbb`, or null when it is outside the grammar. */ +function color(value: string | undefined): string | null { + return value === undefined ? null : resolveColor(value); +} + +/** The CSS of one cell's resolved style, leaving out what the defaults of R-SHT-8 already give. */ +export function cellCss(style: SheetCellStyle): string { + const css: string[] = []; + const font = style.font ?? {}; + if (font.family && !font.family.startsWith('+') && !font.family.startsWith('theme:')) { + // Only letters, digits, spaces, and a few marks stay, so no escape, function, or quote reaches the style. + css.push(`font-family:${font.family.replace(/[^\p{L}\p{N} ,._-]/gu, '')}`); + } + if (font.size !== undefined) css.push(`font-size:${px(font.size)}pt`); + const weight = font.weight ?? (font.bold === true ? 700 : font.bold === false ? 400 : undefined); + if (weight !== undefined) css.push(`font-weight:${weight}`); + if (font.italic !== undefined) css.push(`font-style:${font.italic ? 'italic' : 'normal'}`); + const lines: string[] = []; + if (font.underline && font.underline !== 'none') lines.push('underline'); + if (font.strike && font.strike !== 'none') lines.push('line-through'); + if (lines.length > 0) css.push(`text-decoration:${lines.join(' ')}`); + const text = color(font.color); + if (text) css.push(`color:${text}`); + if (font.script === 'super') css.push('vertical-align:super;font-size:smaller'); + if (font.script === 'sub') css.push('vertical-align:sub;font-size:smaller'); + const fill = style.fill; + if (typeof fill === 'string' && fill !== 'none') { + const c = color(fill); + if (c) css.push(`background-color:${c}`); + } else if (fill && typeof fill === 'object' && 'pattern' in fill) { + const c = color(fill.back) ?? color(fill.color); + if (c) css.push(`background-color:${c}`); + } else if (fill && typeof fill === 'object' && 'gradient' in fill) { + const stops = fill.gradient.stops + .map((s) => ({ at: s.at, c: color(s.color) })) + .filter((s): s is { at: number; c: string } => s.c !== null) + .map((s) => `${s.c} ${px(s.at * 100)}%`); + if (stops.length >= 2) + css.push(`background-image:linear-gradient(${fill.gradient.angle ?? 0}deg,${stops.join(',')})`); + } + const sides = { t: 'top', r: 'right', b: 'bottom', l: 'left' } as const; + for (const [key, name] of Object.entries(sides)) { + const side = style.border?.[key as keyof typeof sides]; + if (!side) continue; + if (side.style === 'none') { + css.push(`border-${name}:none`); + continue; + } + const [width, line] = BORDERS[side.style] ?? [1, 'solid']; + const w = side.width === undefined ? `${width}px` : `${px(side.width)}pt`; + css.push(`border-${name}:${w} ${line} ${color(side.color) ?? 'currentcolor'}`); + } + const align = style.align ?? {}; + if (align.h === 'left' || align.h === 'center' || align.h === 'right' || align.h === 'justify') { + css.push(`text-align:${align.h}`); + } + if (align.v === 'middle' || align.v === 'bottom') css.push(`vertical-align:${align.v}`); + if (align.wrap === 'clip' || align.wrap === 'overflow') css.push('white-space:nowrap'); + const pad = { t: style.pad?.t ?? 2, r: style.pad?.r ?? 4, b: style.pad?.b ?? 2, l: style.pad?.l ?? 4 }; + pad.l += (align.indent ?? 0) * 9; + if (pad.t !== 2 || pad.r !== 4 || pad.b !== 2 || pad.l !== 4) { + css.push(`padding:${px(pad.t)}pt ${px(pad.r)}pt ${px(pad.b)}pt ${px(pad.l)}pt`); + } + return css.join(';'); +} + +/** The HTML of a table block. `onStale` hears of a table whose formulas could not show their results. */ +export function tableHtml( + block: TableBlock, + options: HtmlOptions, + xml?: boolean, + onStale?: (id: string) => void, +): string { + const view = tableView(block); + const renderCell = (c: CellView) => + c.markdown === null ? escapeHtml(c.result ?? '') : renderInlineHtml(parseInline(c.markdown), options); + const cell = (tag: 'th' | 'td', r: number, c: number): string => { + if (view.covered.has(`${r}:${c}`)) return ''; + const box = view.anchors.get(`${r}:${c}`); + const style = view.style(r, c); + const css = style ? cellCss(style) : ''; + const spans = + (box && box.right > box.left ? ` colspan="${box.right - box.left + 1}"` : '') + + (box && box.bottom > box.top ? ` rowspan="${box.bottom - box.top + 1}"` : ''); + const scope = tag === 'th' ? ` scope="${box && box.right > box.left ? 'colgroup' : 'col'}"` : ''; + const styleAttr = css === '' ? '' : ` style="${escapeAttr(css)}"`; + return `<${tag}${scope}${spans}${styleAttr}>${renderCell(view.cell(r, c))}`; + }; + const row = (r: number, tag: 'th' | 'td') => { + const h = view.height(r); + const open = h === null ? '' : ``; + return `${open}${block.columns.map((_, c) => cell(tag, r, c)).join('')}`; + }; + const indexes = block.rows.map((_, i) => i); + // A merge from the header row into the body would be cut at the end of , which shifts the body cells. So the + // header row stays a row of header cells in , where the span can reach the rows below it. + const crosses = [...view.anchors.values()].some((box) => box.top === 0 && box.bottom > 0); + const inHead = block.header && indexes.length > 0 && !crosses; + const head = inHead ? `${row(0, 'th')}\n` : ''; + const body = (inHead ? indexes.slice(1) : indexes) + .map((r) => row(r, block.header && r === 0 ? 'th' : 'td')) + .join('\n'); + const cols = block.columns.map((c) => `${cols}\n${head}\n${body}\n`; + if (view.stale) onStale?.(block.id); + return html; +} diff --git a/app/src/features/pages/export/tableMarkdown.test.ts b/app/src/features/pages/export/tableMarkdown.test.ts new file mode 100644 index 000000000..32b00fdb9 --- /dev/null +++ b/app/src/features/pages/export/tableMarkdown.test.ts @@ -0,0 +1,67 @@ +// checks-disable-file brand-consistency: the test data holds color values to check how they are written +// Tests for the table export in Markdown. Expected values are written by hand from GFM tables and the format spec +// (R-SHT-12 for merges, R-SHT-17 for cached results), not taken from the code's output. +import { beforeAll, describe, expect, it } from 'vitest'; +import { sheetModel } from '../../tables'; +import { readExportPage, type ExportPage } from './source'; +import { exportMarkdown } from './toMarkdown'; +import { loadTableSheet } from './tableSheet'; + +// The sheet reader loads on demand; the export paths load it before they write (tableSheet.ts). +beforeAll(loadTableSheet); + +const cell = (markdown: string) => ({ markdown }); + +function page(sheet: Record, stale = false, covered = ''): ExportPage { + const columns = ['k1', 'k2', 'k3'].map((id) => ({ id, width: 100 })); + const rows = [ + { id: 'r1', cells: { k1: cell('A'), k2: cell('B'), k3: cell('C') } }, + { id: 'r2', cells: { k1: cell('x'), k2: cell('=2*3'), k3: cell(covered) } }, + { id: 'r3', cells: { k1: cell('y'), k2: cell(covered), k3: cell(covered) } }, + ]; + const valuesOf = stale ? 'fnv1a:0000000000000000' : sheetModel.fingerprint({ header: true, columns, rows }); + return readExportPage({ + id: 'P', + title: 'T', + blocks: [ + { + id: 'tb', + order: 'a', + type: 'table', + data: { header: true, columns, rows, sheet: { values: { r2: { k2: 6 } }, valuesOf, ...sheet } }, + }, + ], + }); +} + +const md = (p: ExportPage, onStaleTable?: (id: string) => void) => + exportMarkdown(p, { frontMatter: 'none', onStaleTable }).markdown; + +describe('table Markdown with a sheet', () => { + it('writes the cached result of a formula cell', () => { + expect(md(page({}))).toBe('# T\n\n| A | B | C |\n| --- | --- | --- |\n| x | 6 | |\n| y | | |\n'); + }); + + it('writes the formula and names the table when the results are stale', () => { + const stale: string[] = []; + expect(md(page({}, true), (id) => stale.push(id))).toContain('| x | =2*3 | |'); + expect(stale).toEqual(['tb']); + }); + + it('leaves the cells a merge covers empty', () => { + // The covered cells r2/k3, r3/k2, and r3/k3 hold text, so only the merge can keep it out of the table. + const merges = { m1: { from: ['r2', 'k2'], to: ['r3', 'k3'] } }; + const text = md(page({ merges }, false, 'zzz')); + expect(text).toContain('| x | 6 | |\n| y | | |'); + expect(text).not.toContain('zzz'); + expect(md(page({}, false, 'zzz'))).toContain('zzz'); + }); + + it('writes bold, italic, and strike of a cell style, and nothing else', () => { + const styles = { + s1: { font: { bold: true, italic: true, strike: 'single', size: 20, color: '#aa0000' }, fill: '#ffeecc' }, + }; + const text = md(page({ styles, cells: { r2: { k1: 's1' } } })); + expect(text).toContain('| ***~~x~~*** | 6 | |'); + }); +}); diff --git a/app/src/features/pages/export/tableMarkdown.ts b/app/src/features/pages/export/tableMarkdown.ts new file mode 100644 index 000000000..254e62e6a --- /dev/null +++ b/app/src/features/pages/export/tableMarkdown.ts @@ -0,0 +1,40 @@ +// A table block as a GFM table, with results where the sheet has formulas (format spec 6.3, ADR 0067). Markdown +// holds no spans, so a merged cell keeps its text in the anchor and the covered cells are empty. Of a cell's +// formatting only bold, italic, and strike come over, as Markdown writes them. + +import { tableView } from './tableHtml'; +import type { TableBlock } from './source'; + +/** How the table writes a cell: Markdown goes through the exporter's link rewrite, text is escaped. */ +export interface CellWriter { + readonly markdown: (markdown: string) => string; + readonly text: (text: string) => string; +} + +/** The GFM table of a block, or null for a table without columns. `onStale` hears of unusable cached results. */ +export function tableMarkdown(block: TableBlock, write: CellWriter, onStale?: (id: string) => void): string | null { + if (block.columns.length === 0) return null; + const view = tableView(block); + const cellOf = (r: number, c: number): string => { + if (view.covered.has(`${r}:${c}`)) return ''; + const shown = view.cell(r, c); + const text = shown.markdown === null ? write.text(shown.result ?? '') : write.markdown(shown.markdown); + if (text === '') return ''; + const font = view.style(r, c)?.font; + const bold = font?.weight === undefined ? font?.bold === true : font.weight >= 600; + const strike = font?.strike === 'single' || font?.strike === 'double'; + let out = text; + if (strike) out = `~~${out}~~`; + if (font?.italic === true) out = `*${out}*`; + if (bold) out = `**${out}**`; + return out; + }; + const line = (cells: readonly string[]) => `| ${cells.join(' | ')} |`; + const rowOf = (r: number) => line(block.columns.map((_, c) => cellOf(r, c))); + const indexes = block.rows.map((_, i) => i); + const header = block.header && indexes.length > 0 ? rowOf(0) : line(block.columns.map(() => '')); + const body = (block.header ? indexes.slice(1) : indexes).map(rowOf); + const out = [header, line(block.columns.map(() => '---')), ...body].join('\n'); + if (view.stale) onStale?.(block.id); + return out; +} diff --git a/app/src/features/pages/export/tableSheet.ts b/app/src/features/pages/export/tableSheet.ts new file mode 100644 index 000000000..2dcc55ac2 --- /dev/null +++ b/app/src/features/pages/export/tableSheet.ts @@ -0,0 +1,42 @@ +// The tables feature's sheet reader, which the table export uses for cell styles, merges, and cached formula +// results. The tables feature loads on demand, so the export paths load it first (loadTableSheet) when a page has a +// table with a sheet. Until it has loaded, a table exports as the plain table it always was, and the first such +// table starts the load, so later exports get the styles. +import type { ExportPage } from './source'; + +/** What the table export reads of the tables feature. */ +export type TableSheet = Pick< + typeof import('../../tables'), + 'MAX_HEIGHT' | 'MIN_HEIGHT' | 'renderNumberFormat' | 'sheetModel' +>; + +let loaded: TableSheet | null = null; +let loading: Promise | null = null; + +/** True when a block of the page is a table with a sheet. */ +export const hasSheet = (page: ExportPage): boolean => + page.blocks.some((b) => b.type === 'table' && b.sheet !== undefined); + +/** Loads the sheet reader once. A failed load leaves tables plain. */ +export function loadTableSheet(): Promise { + loading ??= import('../../tables').then( + (module) => { + loaded = module; + }, + () => { + loading = null; + }, + ); + return loading; +} + +/** Loads the sheet reader when the page has a table with a sheet. */ +export async function loadTableSheetFor(page: ExportPage): Promise { + if (loaded === null && hasSheet(page)) await loadTableSheet(); +} + +/** The sheet reader, or null while it has not loaded. Asking for it starts the load. */ +export function tableSheet(): TableSheet | null { + if (loaded === null) void loadTableSheet(); + return loaded; +} diff --git a/app/src/features/pages/export/toHtml.ts b/app/src/features/pages/export/toHtml.ts index 92d9c1c6c..b4ebecd60 100644 --- a/app/src/features/pages/export/toHtml.ts +++ b/app/src/features/pages/export/toHtml.ts @@ -33,6 +33,8 @@ export interface HtmlExportOptions { * shows the same on a device without it. Other fonts are named with fallback stacks only. Without it nothing embeds. */ readonly fonts?: FontSource; + /** Hears of a table whose formula results are out of date, so its formulas export as text (the report names it). */ + readonly onStaleTable?: (blockId: string) => void; } export interface HtmlExport { @@ -64,6 +66,7 @@ export function exportHtml(page: ExportPage, options: HtmlExportOptions = {}): H link: options.link ?? safeHref, labels, strokes: strokesByBlock(page.strokes), + ...(options.onStaleTable ? { onStaleTable: options.onStaleTable } : {}), ...(player ? { player } : {}), }; const body = readingOrder(page.blocks, page.view.readingOrder) diff --git a/app/src/features/pages/export/toMarkdown.ts b/app/src/features/pages/export/toMarkdown.ts index bc0b9f851..e433076b9 100644 --- a/app/src/features/pages/export/toMarkdown.ts +++ b/app/src/features/pages/export/toMarkdown.ts @@ -7,6 +7,7 @@ import { escapeText, oneLine, rewriteLinks, writeDestination } from './escape'; import { readingOrder } from './order'; import type { ExportBlock, ExportPage, TableBlock } from './source'; import { removeFormatting } from './markdown/strip'; +import { tableMarkdown } from './tableMarkdown'; import { propertyFrontMatter } from './propertyFrontMatter'; /** The line for a block of a type this version doesn't know, and for a block that couldn't be read. */ @@ -35,6 +36,8 @@ export interface MarkdownOptions { * version 1. Default `keep`. */ readonly formatting?: 'keep' | 'remove'; + /** Hears of a table whose formula results are out of date, so its formulas export as text (the report names it). */ + readonly onStaleTable?: (blockId: string) => void; } export interface BodyPart { @@ -121,14 +124,14 @@ class Exporter { /** A GFM table, with an empty header row when the table has none. Null for a table without columns. */ private table(table: TableBlock): string | null { - if (table.columns.length === 0) return null; - const line = (cells: readonly string[]) => `| ${cells.join(' | ')} |`; - const cellsOf = (row: TableBlock['rows'][number]) => - table.columns.map((c) => tableCell(this.rewrite(own(row.cells, c.id) ?? ''))); - const [first, ...rest] = table.rows; - const header = table.header && first ? cellsOf(first) : table.columns.map(() => ''); - const body = table.header && first ? rest : table.rows; - return [line(header), line(table.columns.map(() => '---')), ...body.map((r) => line(cellsOf(r)))].join('\n'); + return tableMarkdown( + table, + { + markdown: (md) => tableCell(this.rewrite(md)), + text: (text) => tableCell(escapeText(text, false)), + }, + this.o.onStaleTable, + ); } } diff --git a/app/src/features/pages/host/exporter.test.tsx b/app/src/features/pages/host/exporter.test.tsx index 7638f663d..697d03c2c 100644 --- a/app/src/features/pages/host/exporter.test.tsx +++ b/app/src/features/pages/host/exporter.test.tsx @@ -6,9 +6,11 @@ import { createWebExports, standInPdf } from '../../../platform/web/exports'; import type { ExportsClient } from '../../../platform/types'; import { inspectPdf } from '../pdf'; import { pageOf, textBlock } from '../testing/build'; +import { sheetModel } from '../../tables'; import { exportHtmlFile, exportMarkdownFile, exportPdfFile, PdfCheckError, prepareInput } from './exporter'; import { configureStyleStorage, loadStyles, localStylesClient, saveStyles } from '../../page'; import type { PageSource } from './source'; +import { readExportPage } from '../export'; const PARAGRAPH = 'The reading room was quiet. A lamp on the desk threw a warm circle over the page, and the pencil moved slowly ' + @@ -219,3 +221,43 @@ describe('the Markdown export option for formatting', () => { expect(text).not.toContain('data-font'); }); }); + +/** A page with one table: B2 is `=2*3` with the cached result 6. */ +function tableSource(stale: boolean, name?: string): PageSource { + const columns = [ + { id: 'k1', width: 100 }, + { id: 'k2', width: 100 }, + ]; + const rows = [ + { id: 'r1', cells: { k1: { markdown: 'A' }, k2: { markdown: 'B' } } }, + { id: 'r2', cells: { k1: { markdown: 'x' }, k2: { markdown: '=2*3' } } }, + ]; + const valuesOf = stale ? 'fnv1a:0000000000000000' : sheetModel.fingerprint({ header: true, columns, rows }); + const sheet = { values: { r2: { k2: 6 } }, valuesOf, ...(name ? { table: { name } } : {}) }; + const page = readExportPage({ + id: 'P', + title: 'Sheet', + blocks: [{ id: 'tb', order: 'a', type: 'table', data: { header: true, columns, rows, sheet } }], + }); + return { pageId: 'p3', title: 'Sheet', notebook: 'Work', section: 'Notes', page, assetUrls: {} }; +} + +describe('the stale tables of a text export', () => { + it('names a table whose formulas were written as text, in Markdown and in the web page', async () => { + const md = recording(); + const outcome = await exportMarkdownFile(md.client, tableSource(true, 'Totals')); + expect(decode(md.writes[0].files[0].bytes)).toContain('| x | =2*3 |'); + expect(outcome).toMatchObject({ status: 'saved', staleTables: ['Totals'] }); + const web = recording(); + const html = await exportHtmlFile(web.client, tableSource(true)); + expect(decode(web.writes[0].files[0].bytes)).toContain('=2*3'); + expect(html).toMatchObject({ status: 'saved', staleTables: ['Table 1'] }); + }); + + it('reports nothing when the results are fresh', async () => { + const md = recording(); + const outcome = await exportMarkdownFile(md.client, tableSource(false)); + expect(decode(md.writes[0].files[0].bytes)).toContain('| x | 6 |'); + expect(outcome).toMatchObject({ status: 'saved', staleTables: [] }); + }); +}); diff --git a/app/src/features/pages/host/exporter.ts b/app/src/features/pages/host/exporter.ts index 252bba77a..06b34da80 100644 --- a/app/src/features/pages/host/exporter.ts +++ b/app/src/features/pages/host/exporter.ts @@ -10,6 +10,7 @@ import { dataUri, exportHtml, exportMarkdown, + loadTableSheetFor, readStyles, hasInk, inkExtent, @@ -161,7 +162,25 @@ export async function exportPdfFile( } export type TextOutcome = - { readonly status: 'canceled' } | { readonly status: 'saved'; readonly path: string; readonly name: string }; + | { readonly status: 'canceled' } + | { + readonly status: 'saved'; + readonly path: string; + readonly name: string; + /** The names of tables whose formulas were written as text, because their cached results are out of date. */ + readonly staleTables: readonly string[]; + }; + +/** The names of the tables with these block IDs: the table's own name, or its place among the page's tables. */ +export function staleTableNames(page: PageSource['page'], ids: readonly string[]): string[] { + const tables = page.blocks.filter((block) => block.type === 'table'); + return ids.map((id) => { + const at = tables.findIndex((block) => block.id === id); + const found = tables[at]; + const name = found?.type === 'table' ? (found.sheet?.table as { name?: unknown } | undefined)?.name : undefined; + return typeof name === 'string' && name !== '' ? name : t('pageViews.files.tableNumber', { number: at + 1 }); + }); +} /** An asset's bytes, or null when it can't be read. */ async function assetBytes(url: string): Promise { @@ -205,9 +224,12 @@ export async function exportMarkdownFile( const suggested = exportFileName(source.title || t('pageViews.print.untitled'), 'md'); const path = await client.pickSave({ suggested, label: t('pageViews.files.markdownLabel'), extension: 'md' }); if (path === null) return { status: 'canceled' }; + await loadTableSheetFor(source.page); const folder = `${stemOf(path)}_files`; const ink = hasInk(source.page) ? pageInkSvg(source) : null; + const stale = new Set(); const made = exportMarkdown(source.page, { + onStaleTable: (id) => stale.add(id), frontMatter: 'basic', formatting, assetPath: (_id, file) => `${folder}/${file}`, @@ -223,7 +245,7 @@ export async function exportMarkdownFile( } if (ink) files.push({ path: `${folder}/ink.svg`, bytes: encoder.encode(ink) }); await client.write(path, files); - return { status: 'saved', path, name: baseName(path) }; + return { status: 'saved', path, name: baseName(path), staleTables: staleTableNames(source.page, [...stale]) }; } /** Exports the page as one web page file that holds its pictures inside it. */ @@ -231,6 +253,7 @@ export async function exportHtmlFile(client: ExportsClient, source: PageSource): const suggested = exportFileName(source.title || t('pageViews.print.untitled'), 'html'); const path = await client.pickSave({ suggested, label: t('pageViews.files.htmlLabel'), extension: 'html' }); if (path === null) return { status: 'canceled' }; + await loadTableSheetFor(source.page); const uris = new Map(); await Promise.all( Object.values(source.page.assets).map(async (asset) => { @@ -242,7 +265,9 @@ export async function exportHtmlFile(client: ExportsClient, source: PageSource): // A recording goes beside the web page as an audio file, and the page plays it and follows the notes. const player = isEnabled('pages.exportPlayer') ? await preparePlayer(source.pageId, path).catch(() => null) : null; // A web page file carries its own pictures and the bundled fonts it uses. Any other font is named with a fallback stack. + const stale = new Set(); const options = { + onStaleTable: (id: string) => stale.add(id), ...(player ? { player } : {}), assetUrl: (asset: { id: string }) => uris.get(asset.id) ?? null, theme: lightTheme(), @@ -261,5 +286,5 @@ export async function exportHtmlFile(client: ExportsClient, source: PageSource): const made = wanted.size === 0 ? first : exportHtml(source.page, { ...options, fonts: await bundledFontSource(wanted) }); await client.write(path, [{ path: '', bytes: new TextEncoder().encode(made.html) }]); - return { status: 'saved', path, name: baseName(path) }; + return { status: 'saved', path, name: baseName(path), staleTables: staleTableNames(source.page, [...stale]) }; } diff --git a/app/src/features/pages/testing/edgeSurface.ts b/app/src/features/pages/testing/edgeSurface.ts index f349ec326..9036fafce 100644 --- a/app/src/features/pages/testing/edgeSurface.ts +++ b/app/src/features/pages/testing/edgeSurface.ts @@ -29,6 +29,11 @@ const DRAWERS: Readonly> = { '../../tables': fileURLToPath(new URL('../../tables/smart/exportCharts.ts', import.meta.url)), }; +/** The table export loads the sheet reader through the tables index too, so it gets the light module instead. */ +const TABLE_VIEW: Readonly> = { + '../../tables': fileURLToPath(new URL('../../tables/exportView.ts', import.meta.url)), +}; + /** The print entry as one script, built once per test run. */ export function printBundle(): Promise { bundle ??= build({ @@ -39,7 +44,9 @@ export function printBundle(): Promise { name: 'print-drawers', enforce: 'pre', resolveId(source, importer) { - return importer !== undefined && importer.endsWith('export/renderers.ts') ? DRAWERS[source] : undefined; + if (importer === undefined) return undefined; + if (importer.endsWith('export/renderers.ts')) return DRAWERS[source]; + return importer.endsWith('export/tableSheet.ts') ? TABLE_VIEW[source] : undefined; }, }, ], diff --git a/app/src/features/pages/ui/exportCommands.tsx b/app/src/features/pages/ui/exportCommands.tsx index fcafcd2e8..d2535bdb4 100644 --- a/app/src/features/pages/ui/exportCommands.tsx +++ b/app/src/features/pages/ui/exportCommands.tsx @@ -5,7 +5,7 @@ import { isEnabled } from '../../../app/flags'; import { confirm, showToast } from '../../../ui'; import { checkBeforeOutput, reviewPages } from '../../a11yCheck'; import { exportHtmlFile, exportMarkdownFile } from '../host/exporter'; -import { exportMarkdown } from '../export'; +import { exportMarkdown, loadTableSheetFor } from '../export'; import { collectSource, type PageSource } from '../host/source'; import { openDialog } from './openDialog'; import { PrintDialog } from './PrintDialog'; @@ -39,6 +39,7 @@ async function withTapeChoice(source: PageSource): Promise { /** Asks whether to keep formatting Markdown cannot hold, only when the page has some. */ export async function markdownFormatting(source: PageSource): Promise<'keep' | 'remove'> { + await loadTableSheetFor(source.page); const keep = exportMarkdown(source.page, { formatting: 'keep' }).markdown; if (keep === exportMarkdown(source.page, { formatting: 'remove' }).markdown) return 'keep'; const remove = await confirm({ @@ -76,6 +77,9 @@ export async function exportText(ctx: CommandContext, kind: 'markdown' | 'html') ? exportMarkdownFile(client, source, await markdownFormatting(source)) : exportHtmlFile(client, source)); if (outcome.status === 'canceled') return; + if (outcome.staleTables.length > 0) { + showToast({ message: t('pageViews.files.staleTables', { tables: outcome.staleTables.join(', ') }) }); + } showToast({ message: t('pageViews.files.saved', { name: outcome.name }), action: { label: t('pageViews.print.openFile'), run: () => client.open(outcome.path, true) }, diff --git a/app/src/features/settings/SettingsView.test.tsx b/app/src/features/settings/SettingsView.test.tsx index 802d4ef30..3283ba32f 100644 --- a/app/src/features/settings/SettingsView.test.tsx +++ b/app/src/features/settings/SettingsView.test.tsx @@ -28,6 +28,7 @@ describe('the Settings page', () => { 'Appearance', 'Language', 'Editing', + 'Tables', 'Recording', 'Pen and touch', 'My handwriting', diff --git a/app/src/features/tables/engine/formula.test.ts b/app/src/features/tables/engine/formula.test.ts index 76a94866b..394524fec 100644 --- a/app/src/features/tables/engine/formula.test.ts +++ b/app/src/features/tables/engine/formula.test.ts @@ -732,7 +732,7 @@ describe('engine state', () => { it('recalculates from scratch under another env', () => { const t = sheet(row1('1', '', '', '')); expect(at(setCellInput(t, { row: 0, col: 1, raw: '=A1+1' }, EN_US, DEFAULT_ENV), 'B1')).toBe(2); - const unknown = setCellInput(t, { row: 0, col: 1, raw: '=SEQUENCE(1)' }, EN_US, DEFAULT_ENV); + const unknown = setCellInput(t, { row: 0, col: 1, raw: '=REFTO("A1")' }, EN_US, DEFAULT_ENV); expect(at(unknown, 'B1')).toEqual(err('NAME')); }); }); diff --git a/app/src/features/tables/engine/formula/calls.ts b/app/src/features/tables/engine/formula/calls.ts index 424e414b6..847b0c1a3 100644 --- a/app/src/features/tables/engine/formula/calls.ts +++ b/app/src/features/tables/engine/formula/calls.ts @@ -5,6 +5,9 @@ // reference for a function that returns references. A lambda parameter gets the value as it is. Other kinds get // one value. // When a single-value parameter gets an array, the function runs once per item and the result is an array. +// +// A ref parameter gets a reference as a RefValue, as ROWS and AREAS want it. A function with `wantsCaller` gets the +// calling cell in its environment. import type { Node } from '../../../../core/expr'; import { @@ -14,6 +17,7 @@ import { isRef, parseCanonicalNumber, toScalar, + type FormulaError, reasonOf, type RefValue, type Scalar, @@ -21,7 +25,7 @@ import { } from '../values'; import { asArray, mapN } from '../arrays'; import type { Box } from '../graph'; -import { isGroup, paramsOf, type FnSpec2, type Param } from './fn'; +import { isGroup, paramsOf, type Env, type FnSpec2, type Param, type Probe, type ProbeCell } from './fn'; import type { ArgSummary, ErrorOrigin } from './explain'; import { cellsOf, colKey, constant, isBad, readBox, type Ctx, type Fn, type Host, type RefPart } from './context'; @@ -165,7 +169,7 @@ function aggregate(h: Host, spec: FnSpec2, node: Call): Fn { } /** Runs a function, once per item when a single-value parameter got an array. */ -function runLifted(spec: FnSpec2, values: Value[], scalar: boolean[], env: Host['scope']['env']): Value { +function runLifted(spec: FnSpec2, values: Value[], scalar: boolean[], env: Env): Value { const handles = spec.handlesErrors === true; let lifted = false; for (let i = 0; i < values.length; i++) { @@ -187,6 +191,35 @@ function runLifted(spec: FnSpec2, values: Value[], scalar: boolean[], env: Host[ ); } +/** What a function with `refArgs` may ask about the page. */ +function probeOf(h: Host, ctx: Ctx): Probe { + const sheetOf = (ref: RefValue): number => (ref.table === undefined ? h.ownSheet : h.sheetNamed(ref.table)); + return { + sheet: h.ownSheet, + sheets: h.book.map((info) => info.name), + self: { kind: 'ref', r1: ctx.row, c1: ctx.col, r2: ctx.row, c2: ctx.col }, + calc: ctx.rt.calcMode?.(h.ownSheet) ?? 'auto', + sheetOf, + cell(ref): ProbeCell | undefined { + const s = sheetOf(ref); + const facts = ctx.rt.cellFacts?.(s, ref.c1, ref.r1); + if (facts === undefined) return undefined; + return { value: toScalar(ctx.sheets[s]?.[ref.c1]?.[ref.r1] ?? null), ...facts }; + }, + }; +} + +/** The environment a call runs in: the page's, with the calling cell for a function that wants it. */ +function envFor(h: Host, spec: FnSpec2, ctx: Ctx): Env { + const env = spec.refArgs === true ? { ...h.scope.env, probe: probeOf(h, ctx) } : h.scope.env; + if (spec.wantsCaller !== true) return env; + const formulaAt = (ref: RefValue): string | null => { + const box = boxOf(h, ref); + return isBox(box) ? (ctx.rt.formulaAt?.(box.s, box.c1, box.r1) ?? null) : null; + }; + return { ...env, caller: { row: ctx.row, col: ctx.col, formulaAt } }; +} + /** A reference read as the shape of another one, from its top-left corner, as SUMIF's sum range is. */ function resized(h: Host, ref: RefPart, shaped: RefPart): Fn { const plainRead = h.deref(ref); @@ -214,13 +247,21 @@ function resized(h: Host, ref: RefPart, shaped: RefPart): Fn { function plain(h: Host, spec: FnSpec2, node: Call): Fn { const env = h.scope.env; + const refArgs = spec.refArgs === true; const params = paramsFor(spec, node.args.length); const returnsRef = spec.returnsRef === true; if (returnsRef) h.dynamic = true; const parts: Fn[] = node.args.map((arg, i) => { + // An argument left empty takes the parameter's default, or empty when it has none. + if (arg.type === 'name' && arg.name === '') return constant(params[i]?.default ?? null); const kind = params[i]?.kind ?? 'value'; + if (kind === 'ref') { + const ref = h.refOrNull(arg); + return ref === null ? h.value(arg) : (ctx) => toRefValue(h, ref.fn(ctx)); + } if (kind !== 'range' && kind !== 'array') return h.value(arg); const ref = h.refOrNull(arg); + if (ref !== null && refArgs) return (ctx) => toRefValue(h, ref.fn(ctx)); if (ref !== null && returnsRef && kind === 'range') return (ctx) => toRefValue(h, ref.fn(ctx)); const shape = params[i]?.resizeTo; if (ref !== null && shape !== undefined) { @@ -228,10 +269,15 @@ function plain(h: Host, spec: FnSpec2, node: Call): Fn { if (shaped !== null) return resized(h, ref, shaped); } const v = ref !== null ? h.deref(ref) : h.value(arg); + const skipText = ref !== null && params[i]?.skipRefText === true; if (ref !== null && params[i]?.rowState === true) return withRowState(env, ref, v); return (ctx) => { const value = v(ctx); - return isError(value) ? value : asArray(value); + if (isError(value)) return value; + // A typed lone text stays a scalar, so a function can tell it from text inside an array. + if (ref === null && params[i]?.loneText === true && typeof value === 'string') return value; + const array = asArray(value); + return skipText ? { ...array, data: array.data.map((x) => (typeof x === 'string' ? null : x)) } : array; }; }); // A lazy parameter of a form the engine does not know arrives evaluated, errors included, never broadcast. @@ -239,7 +285,7 @@ function plain(h: Host, spec: FnSpec2, node: Call): Fn { const origin = originOf(spec, node); return (ctx) => { const values = parts.map((p) => p(ctx)); - let v = runLifted(spec, values, scalar, env); + let v = runLifted(spec, values, scalar, envFor(h, spec, ctx)); if (isRef(v)) v = readReturned(h, ctx, v); if (ctx.origin === null && isError(v) && !values.some((a) => a === v)) { ctx.origin = { ...origin, code: v.error, args: values.map(summarize), ...reasonField(v) }; @@ -248,11 +294,18 @@ function plain(h: Host, spec: FnSpec2, node: Call): Fn { }; } -/** A reference given to a function that returns references. */ +/** A reference given to a function: one block, or a union with every area in `areas`. */ function toRefValue(h: Host, box: Box | Box[] | Value): Value { - if (typeof box !== 'object' || box === null || !('s' in box)) { - return Array.isArray(box) ? err('VALUE') : (box as Value); + if (Array.isArray(box)) { + if (box.length === 0) return err('VALUE'); + const areas = box.map((b) => oneRef(h, b)); + return areas.length === 1 ? areas[0] : { ...areas[0], areas }; } + if (typeof box !== 'object' || box === null || !('s' in box)) return box as Value; + return oneRef(h, box); +} + +function oneRef(h: Host, box: Box): RefValue { const table = box.s === h.ownSheet ? undefined : h.book[box.s].name; return { kind: 'ref', ...(table === undefined ? {} : { table }), r1: box.r1, c1: box.c1, r2: box.r2, c2: box.c2 }; } @@ -269,37 +322,52 @@ function boxOf(h: Host, ref: RefValue): Box | Value { const isBox = (v: Box | Value): v is Box => typeof v === 'object' && v !== null && 's' in v; +/** + * A cycle error when a block found while running holds the calling cell, as INDIRECT("F1") in F1 does. The graph + * catches a loop between two cells, but a cell that reads itself would otherwise read its own unfinished value. + */ +function selfRead(h: Host, ctx: Ctx, box: Box): FormulaError | null { + if (box.s !== h.ownSheet || ctx.row < box.r1 || ctx.row > box.r2 || ctx.col < box.c1 || ctx.col > box.c2) return null; + ctx.origin ??= { code: 'CYCLE', kind: 'cycle' }; + return err('CYCLE'); +} + /** Reads a returned reference, and records it as a read only known while running. */ function readReturned(h: Host, ctx: Ctx, ref: RefValue): Value { const box = boxOf(h, ref); if (!isBox(box)) return box; ctx.dyn?.push(box); - return readBox(ctx, box); + return selfRead(h, ctx, box) ?? readBox(ctx, box); } -/** A call to a function that returns a reference, used where a reference is wanted. Null for other calls. */ +/** + * A call to a function that returns a reference, used where a reference is wanted. Null for other calls, and for a + * call whose first range argument is not a reference: INDEX({1,2;3,4},2,0) gives values, so it compiles as a value. + */ export function compileCallRef(h: Host, spec: FnSpec2 | undefined, node: Call): RefPart | null { if (spec?.returnsRef !== true) return null; const params = paramsFor(spec, node.args.length); + const refs = node.args.map((arg, i) => (params[i]?.kind === 'range' ? h.refOrNull(arg) : null)); + const first = params.findIndex((p) => p?.kind === 'range'); + if (first >= 0 && first < node.args.length && refs[first] === null) return null; h.dynamic = true; if (spec.volatile === true) h.volatile = true; const parts: Fn[] = node.args.map((arg, i) => { - const ref = params[i]?.kind === 'range' ? h.refOrNull(arg) : null; + const ref = refs[i]; return ref !== null ? (ctx) => toRefValue(h, ref.fn(ctx)) : h.value(arg); }); - const env = h.scope.env; return { fn: (ctx) => { const v = spec.run( parts.map((p) => p(ctx)), - env, + envFor(h, spec, ctx), ); if (isError(v)) return v; if (!isRef(v)) return err('VALUE'); const box = boxOf(h, v); if (!isBox(box)) return isError(box) ? box : err('VALUE'); ctx.dyn?.push(box); - return box; + return selfRead(h, ctx, box) ?? box; }, }; } diff --git a/app/src/features/tables/engine/formula/compile.ts b/app/src/features/tables/engine/formula/compile.ts index acb1f8402..939bb2376 100644 --- a/app/src/features/tables/engine/formula/compile.ts +++ b/app/src/features/tables/engine/formula/compile.ts @@ -293,6 +293,8 @@ class Compiler implements Host { } private nameValue(node: Extract): Fn { + // An argument left empty, as in IF(A1,,2), is empty. + if (node.name === '') return constant(null); if (node.name.startsWith('#')) return constant(errorFromToken(node.name) ?? err('NAME')); const local = this.local(node.name); if (local) return readLocal(local.hops, local.slot); @@ -303,7 +305,7 @@ class Compiler implements Host { } private nameRef(node: Extract): RefPart | null { - if (node.name.startsWith('#') || this.local(node.name)) return null; + if (node.name === '' || node.name.startsWith('#') || this.local(node.name)) return null; const entry = this.namedEntry(node.name); if (entry) return entry.ref; if (this.columnIndex(this.sheet, 'name', node.name) < 0) return null; diff --git a/app/src/features/tables/engine/formula/context.ts b/app/src/features/tables/engine/formula/context.ts index 46d52ca19..7ac384b5a 100644 --- a/app/src/features/tables/engine/formula/context.ts +++ b/app/src/features/tables/engine/formula/context.ts @@ -39,6 +39,19 @@ export interface Scope { export interface Runtime { /** The block a formula cell spills into, the cell itself when it does not spill, or its error. */ spillBox(s: number, col: number, row: number): Box | FormulaError; + /** The stored formula of a cell or of its calculated column, without "=". Null when it has none. */ + formulaAt?(s: number, col: number, row: number): string | null; + /** What CELL and ISFORMULA ask about a cell, read from the table as it is now. Unset: nothing is known. */ + cellFacts?(s: number, col: number, row: number): CellFacts | undefined; + /** The table's calculation mode. */ + calcMode?(s: number): 'auto' | 'manual'; +} + +/** A cell's kind of content and its column's type, as functions about cells read them. */ +export interface CellFacts { + readonly formula: boolean; + readonly type: string; + readonly decimals?: number; } /** What a closure reads while it runs. One context serves a whole recalculation. */ diff --git a/app/src/features/tables/engine/formula/fn/arrays.test.ts b/app/src/features/tables/engine/formula/fn/arrays.test.ts new file mode 100644 index 000000000..e1fb243b5 --- /dev/null +++ b/app/src/features/tables/engine/formula/fn/arrays.test.ts @@ -0,0 +1,430 @@ +// The dynamic array, matrix, and LAMBDA helper functions. Expected values are worked by hand from each function's +// documented rules, or are the published examples (the 4 by 4 determinant of 88); none is read back from the code. +// Formulas run through the engine and spill, so argument delivery and spilling are tested too. + +import { describe, expect, it } from 'vitest'; +import { createTable, setCellInput } from '../../edit'; +import { EN_US } from '../../locale'; +import type { Table } from '../../model'; +import { DEFAULT_ENV, errorOrigin, spillRange } from '../../recalc'; +import { err, isError, reasonOf, type ArrayValue, type Scalar, type Value } from '../../values'; +import { explainOrigin } from '../explain'; +import fixture from '../../../fixtures/fn/arrays.json'; +import { fnArrays, type FnText } from '../../../../../strings/en/fnArrays'; +import { REGISTRY, paramsOf } from '.'; +import { MAX_ELEMENTS } from './matrix'; +import { makeArray } from '../../arrays'; + +const COLUMNS = 'ABCDEFGHIJKLMN'.split('').map((name) => ({ name, type: 'number' as const })); +/** The formula goes in F1, with columns A to D for data and room to spill right and down. */ +const HOME = { row: 0, col: 5 }; + +function run(formula: string, data: string[][] = []): Table { + const rows = Array.from({ length: 14 }, (_, r) => Array.from({ length: 14 }, (_, c) => data[r]?.[c] ?? '')); + const table = createTable(COLUMNS, rows, EN_US, DEFAULT_ENV, 1); + return setCellInput(table, { ...HOME, raw: `=${formula}` }, EN_US, DEFAULT_ENV); +} + +/** A formula's result: a single value, or the rows of the block it spilled. */ +function calc(formula: string, data: string[][] = []): Value | Scalar[][] { + const t = run(formula, data); + const box = spillRange(t, HOME.col, HOME.row); + if (box === undefined || (box.r1 === box.r2 && box.c1 === box.c2)) return t.rows[HOME.row].cells[HOME.col].value; + const out: Scalar[][] = []; + for (let r = box.r1; r <= box.r2; r++) { + out.push(t.rows[r].cells.slice(box.c1, box.c2 + 1).map((cell) => cell.value as Scalar)); + } + return out; +} + +/** Numbers to 12 decimal places, for results of floating-point arithmetic. */ +function near(value: Value | Scalar[][], expected: number[][]): void { + expect(Array.isArray(value)).toBe(true); + const rows = value as Scalar[][]; + expect(rows.map((row) => row.length)).toEqual(expected.map((row) => row.length)); + rows.forEach((row, r) => row.forEach((v, c) => expect(v as number).toBeCloseTo(expected[r][c], 12))); +} + +const NA = err('NA'); + +describe('dynamic arrays', () => { + it('SEQUENCE, SORT, UNIQUE, and FILTER (lane test)', () => { + expect(calc('SEQUENCE(2,3)')).toEqual([ + [1, 2, 3], + [4, 5, 6], + ]); + expect(calc('SEQUENCE(3,1,10,-2)')).toEqual([[10], [8], [6]]); + expect(calc('SORT({3;1;2})')).toEqual([[1], [2], [3]]); + expect(calc('UNIQUE({1;2;1;3})')).toEqual([[1], [2], [3]]); + expect(calc('UNIQUE({1;2;1;3},,TRUE)')).toEqual([[2], [3]]); + expect(calc('FILTER({1;2;3},{1;2;3}>5)')).toEqual(err('CALC')); + }); + + it('FILTER keeps rows or columns, gives if_empty, and reads ranges', () => { + expect(calc('FILTER({1;2;3},{1;2;3}>5,"none")')).toBe('none'); + expect(calc('FILTER({1,2,3;4,5,6},{TRUE,FALSE,TRUE})')).toEqual([ + [1, 3], + [4, 6], + ]); + const data = [ + ['a', '1'], + ['b', '5'], + ['c', '7'], + ]; + expect(calc('FILTER(A1:A3,B1:B3>2)', data)).toEqual([['b'], ['c']]); + expect(calc('FILTER({1;2;3},{TRUE;FALSE})')).toEqual(err('VALUE')); + }); + + it('SORT by another column, descending, by column, and with several keys', () => { + expect(calc('SORT({1,"b";2,"a";3,"c"},2,-1)')).toEqual([ + [3, 'c'], + [1, 'b'], + [2, 'a'], + ]); + expect(calc('SORT({3,1,2},1,1,TRUE)')).toEqual([[1, 2, 3]]); + expect(calc('SORT({1,2;1,1;0,9},{1,2},{1,-1})')).toEqual([ + [0, 9], + [1, 2], + [1, 1], + ]); + // Numbers sort before text, text before booleans, and text without regard to case. + expect(calc('SORT({TRUE;"b";2;"A"})')).toEqual([[2], ['A'], ['b'], [true]]); + expect(calc('SORT({1;2},3)')).toEqual(err('VALUE')); + expect(calc('SORT({1;2},1,2)')).toEqual(err('VALUE')); + }); + + it('SORTBY sorts by other arrays and keeps ties in their order', () => { + expect(calc('SORTBY({"x";"y";"z"},{2;1;2})')).toEqual([['y'], ['x'], ['z']]); + expect(calc('SORTBY({"x";"y";"z"},{1;1;2},1,{3;2;1},-1)')).toEqual([['x'], ['y'], ['z']]); + expect(calc('SORTBY({"x","y"},{2,1})')).toEqual([['y', 'x']]); + expect(calc('SORTBY({"x";"y"},{1;2;3})')).toEqual(err('VALUE')); + }); + + it('UNIQUE ignores case, works by column, and is #CALC! when nothing appears once', () => { + expect(calc('UNIQUE({"a";"A";"b"})')).toEqual([['a'], ['b']]); + expect(calc('UNIQUE({1,1,2},TRUE)')).toEqual([[1, 2]]); + expect(calc('UNIQUE({1,2;1,2;3,4})')).toEqual([ + [1, 2], + [3, 4], + ]); + expect(calc('UNIQUE({1;1},,TRUE)')).toEqual(err('CALC')); + }); + + it('SORTN keeps the first rows, with ties, without repeats, or with every copy', () => { + expect(calc('SORTN({3;1;2;1},2)')).toEqual([[1], [1]]); + expect(calc('SORTN({3;1;2;2},2,1)')).toEqual([[1], [2], [2]]); + expect(calc('SORTN({3;1;1;2},2,2)')).toEqual([[1], [2]]); + expect(calc('SORTN({3;1;1;2},2,3)')).toEqual([[1], [1], [2]]); + expect(calc('SORTN({1,"a";2,"b";3,"c"},1,0,1,FALSE)')).toEqual([[3, 'c']]); + }); + + it('ARRAYFORMULA gives its formula', () => { + expect(calc('ARRAYFORMULA({1,2}*2)')).toEqual([[2, 4]]); + }); +}); + +describe('reshaping', () => { + it('TAKE, TOCOL, WRAPROWS, and VSTACK (lane test)', () => { + expect(calc('TAKE({1,2,3;4,5,6},1)')).toEqual([[1, 2, 3]]); + expect(calc('TOCOL({1,2;3,4})')).toEqual([[1], [2], [3], [4]]); + expect(calc('TOCOL({1,2;3,4},0,TRUE)')).toEqual([[1], [3], [2], [4]]); + expect(calc('WRAPROWS({1,2,3,4,5},2)')).toEqual([ + [1, 2], + [3, 4], + [5, NA], + ]); + expect(calc('VSTACK({1,2},{3,4})')).toEqual([ + [1, 2], + [3, 4], + ]); + }); + + it('TAKE and DROP count from the end, skip a left-out count, and are #CALC! when nothing is left', () => { + expect(calc('TAKE({1,2,3;4,5,6},-1,-2)')).toEqual([[5, 6]]); + expect(calc('TAKE({1,2,3;4,5,6},,1)')).toEqual([[1], [4]]); + expect(calc('TAKE({1;2},9)')).toEqual([[1], [2]]); + expect(calc('TAKE({1;2},0)')).toEqual(err('CALC')); + expect(calc('DROP({1,2,3;4,5,6},1)')).toEqual([[4, 5, 6]]); + expect(calc('DROP({1,2,3;4,5,6},,-1)')).toEqual([ + [1, 2], + [4, 5], + ]); + expect(calc('DROP({1;2},2)')).toEqual(err('CALC')); + }); + + it('HSTACK pads the shorter array, and TOROW skips empty cells and errors', () => { + expect(calc('HSTACK({1;2},3)')).toEqual([ + [1, 3], + [2, NA], + ]); + expect( + calc('TOROW(A1:B2)', [ + ['1', ''], + ['', '4'], + ]), + ).toEqual([[1, null, null, 4]]); + expect( + calc('TOROW(A1:B2,1)', [ + ['1', ''], + ['', '4'], + ]), + ).toEqual([[1, 4]]); + expect(calc('TOROW(HSTACK(1,1/0,3),2)')).toEqual([[1, 3]]); + expect(calc('TOROW(A1:B1,1)', [['', '']])).toEqual(err('CALC')); + }); + + it('WRAPCOLS and EXPAND pad, and WRAPROWS refuses a block and a count below 1', () => { + expect(calc('WRAPCOLS({1,2,3},2,0)')).toEqual([ + [1, 3], + [2, 0], + ]); + expect(calc('EXPAND({1,2},2,3,"-")')).toEqual([ + [1, 2, '-'], + ['-', '-', '-'], + ]); + expect(calc('EXPAND({1;2},,2)')).toEqual([ + [1, NA], + [2, NA], + ]); + expect(calc('EXPAND({1,2},1,1)')).toEqual(err('VALUE')); + expect(calc('WRAPROWS({1,2;3,4},2)')).toEqual(err('VALUE')); + expect(calc('WRAPROWS({1,2},0)')).toEqual(err('NUM')); + }); + + it('CHOOSEROWS and CHOOSECOLS pick lines, negative from the end', () => { + expect(calc('CHOOSEROWS({1,2;3,4;5,6},3,1)')).toEqual([ + [5, 6], + [1, 2], + ]); + expect(calc('CHOOSEROWS({1;2;3},{-1,-2})')).toEqual([[3], [2]]); + expect(calc('CHOOSECOLS({1,2,3;4,5,6},2)')).toEqual([[2], [5]]); + expect(calc('CHOOSECOLS({1,2},3)')).toEqual(err('VALUE')); + expect(calc('CHOOSEROWS({1,2},0)')).toEqual(err('VALUE')); + }); + + it('TRIMRANGE drops empty edges, and FLATTEN puts every value in one column', () => { + const data = [ + ['', '', ''], + ['', '5', ''], + ['', '6', '7'], + ['', '', ''], + ]; + expect(calc('TRIMRANGE(A1:C4)', data)).toEqual([ + [5, null], + [6, 7], + ]); + expect(calc('TRIMRANGE(A1:C4,2,0)', data)).toEqual([ + [null, null, null], + [null, 5, null], + [null, 6, 7], + ]); + expect(calc('FLATTEN({1,2;3,4},5)')).toEqual([[1], [2], [3], [4], [5]]); + }); +}); + +describe('TOROW and TOCOL', () => { + it('scan by column and skip empty cells', () => { + expect(calc('TOCOL({1,2,3;4,5,6},0,TRUE)')).toEqual([[1], [4], [2], [5], [3], [6]]); + expect( + calc('TOROW(A1:C2,1,TRUE)', [ + ['1', '', '3'], + ['', '5', '6'], + ]), + ).toEqual([[1, 5, 3, 6]]); + }); + + it('TOROW and TOCOL turn a million items into one line well inside the recalc budget', () => { + const data = Array.from({ length: 1e6 }, (_, k) => (k % 7 === 0 ? null : k)); + const big = makeArray(1000, 1000, data); + const ctx = { today: () => 0 }; + const started = performance.now(); + const row = REGISTRY.get('TOROW')!.run([big, 3], ctx); + const col = REGISTRY.get('TOCOL')!.run([big, 1, true], ctx); + // Filling a preallocated line by index takes tens of milliseconds here; the budget leaves room for a busy machine. + expect(performance.now() - started).toBeLessThan(600); + expect(row).toMatchObject({ rows: 1, cols: 1e6 - Math.ceil(1e6 / 7) }); + expect(col).toMatchObject({ rows: 1e6 - Math.ceil(1e6 / 7), cols: 1 }); + expect((col as ArrayValue).data.slice(0, 3)).toEqual([1000, 2000, 3000]); + }); +}); + +describe('matrices', () => { + it('MMULT, MINVERSE, and MDETERM (lane test)', () => { + expect(calc('MMULT({1,2;3,4},{5;6})')).toEqual([[17], [39]]); + near(calc('MINVERSE({4,7;2,6})'), [ + [0.6, -0.7], + [-0.2, 0.4], + ]); + expect(calc('MDETERM({1,3,8,5;1,3,6,1;1,1,1,0;7,3,10,2})') as number).toBeCloseTo(88, 9); + expect(calc('MINVERSE({1,2;2,4})')).toEqual(err('NUM')); + }); + + it('refuses shapes that do not fit and items that are not numbers', () => { + expect(calc('MMULT({1,2},{1,2})')).toEqual(err('VALUE')); + expect(calc('MMULT({1,"a"},{1;2})')).toEqual(err('VALUE')); + expect(calc('MDETERM({1,2,3;4,5,6})')).toEqual(err('VALUE')); + expect( + calc('MDETERM(A1:B2)', [ + ['1', '2'], + ['3', ''], + ]), + ).toEqual(err('VALUE')); + expect(calc('MDETERM({1,2;2,4})')).toBe(0); + }); + + it('MUNIT, TRANSPOSE, and RANDARRAY build arrays', () => { + expect(calc('MUNIT(3)')).toEqual([ + [1, 0, 0], + [0, 1, 0], + [0, 0, 1], + ]); + expect(calc('MUNIT(0)')).toEqual(err('VALUE')); + expect(calc('TRANSPOSE({1,2,3;4,5,6})')).toEqual([ + [1, 4], + [2, 5], + [3, 6], + ]); + const dice = calc('RANDARRAY(3,4,1,6,TRUE)') as number[][]; + expect(dice.flat()).toHaveLength(12); + for (const v of dice.flat()) expect(Number.isInteger(v) && v >= 1 && v <= 6).toBe(true); + expect(calc('RANDARRAY(1,1,5,1)')).toEqual(err('VALUE')); + expect(calc('SEQUENCE(0)')).toEqual(err('CALC')); + expect(calc('SEQUENCE(-1)')).toEqual(err('VALUE')); + }); +}); + +describe('LAMBDA helpers', () => { + it('MAP, REDUCE, SCAN, BYROW, and MAKEARRAY (lane test)', () => { + expect(calc('MAP({1,2},LAMBDA(x,x*2))')).toEqual([[2, 4]]); + expect(calc('REDUCE(0,{1,2,3},LAMBDA(a,b,a+b))')).toBe(6); + expect(calc('SCAN(0,{1,2,3},LAMBDA(a,b,a+b))')).toEqual([[1, 3, 6]]); + expect(calc('BYROW({1,2;3,4},LAMBDA(r,SUM(r)))')).toEqual([[3], [7]]); + expect(calc('MAKEARRAY(2,2,LAMBDA(r,c,r*c))')).toEqual([ + [1, 2], + [2, 4], + ]); + }); + + it('MAP takes several arrays, BYCOL gives a row, and REDUCE may carry an array', () => { + expect(calc('MAP({1,2},{10,20},LAMBDA(a,b,a+b))')).toEqual([[11, 22]]); + expect(calc('BYCOL({1,2;3,4},LAMBDA(c,MAX(c)))')).toEqual([[3, 4]]); + expect(calc('REDUCE(,{1,2,3},LAMBDA(a,b,a+b))')).toBe(6); + expect(calc('REDUCE({0,0},{1,2},LAMBDA(a,b,a+b))')).toEqual([[3, 3]]); + }); + + it('gives #VALUE! for a missing function or a wrong count, and #CALC! for a nested array', () => { + expect(calc('MAP({1,2},5)')).toEqual(err('VALUE')); + expect(calc('MAP({1,2},LAMBDA(a,b,a+b))')).toEqual([[err('VALUE'), err('VALUE')]]); + expect(calc('BYROW({1,2;3,4},LAMBDA(r,r))')).toEqual([[err('CALC')], [err('CALC')]]); + expect(calc('MAKEARRAY(0,2,LAMBDA(r,c,r))')).toEqual(err('VALUE')); + }); + + it('refuses an empty parameter or name, as in LAMBDA(,x) and LET(,1,x)', () => { + expect(calc('LAMBDA(,1)(2)')).toEqual(err('VALUE')); + expect(calc('MAP({1,2},LAMBDA(,1))')).toEqual(err('VALUE')); + expect(calc('LET(,1,2)')).toEqual(err('VALUE')); + // An empty argument in a call, as UNIQUE(x,,TRUE), still reads as left out. + expect(calc('UNIQUE({1;1;2},,TRUE)')).toBe(2); + }); +}); + +describe('documented choices', () => { + it('SEQUENCE lifts over an array count: each item keeps the first value of its own result', () => { + expect(calc('SEQUENCE({2,3})')).toEqual([[1, 1]]); + }); + + it('FILTER reads an empty include cell as FALSE', () => { + expect( + calc('FILTER(A1:A3,B1:B3)', [ + ['a', '1'], + ['b', ''], + ['c', '1'], + ]), + ).toEqual([['a'], ['c']]); + }); +}); + +describe('the size cap', () => { + it('SEQUENCE(2000,1000) is #NUM! with Too many values, without allocating (lane test)', () => { + const t = run('SEQUENCE(2000,1000)'); + const value = t.rows[0].cells[HOME.col].value; + expect(value).toMatchObject({ error: 'NUM' }); + expect(reasonOf(value)).toBe('tooMany'); + const origin = errorOrigin(t, HOME.col, HOME.row); + expect(origin).toMatchObject({ code: 'NUM', kind: 'function', name: 'SEQUENCE', reason: 'tooMany' }); + const reasons = fnArrays.reasons as Record; + expect( + explainOrigin( + origin!, + () => '', + (_key, values) => reasons[values.reason ?? ''], + ), + ).toBe('Too many values'); + // A request of 10^12 items would throw or stall if anything were allocated; it is refused at once. + const started = performance.now(); + for (const formula of ['SEQUENCE(1000000,1000000)', 'MAKEARRAY(1E6,1E6,LAMBDA(r,c,1))', 'MUNIT(1E6)']) { + expect(calc(formula), formula).toMatchObject({ error: 'NUM' }); + } + expect(performance.now() - started).toBeLessThan(2000); + }); + + it('allows exactly the cap and refuses one more', () => { + const spec = REGISTRY.get('SEQUENCE')!; + const full = spec.run([MAX_ELEMENTS], { today: () => 0 }); + expect(isError(full)).toBe(false); + const over = spec.run([MAX_ELEMENTS + 1], { today: () => 0 }); + expect(over).toMatchObject({ error: 'NUM' }); + expect(reasonOf(over)).toBe('tooMany'); + expect(reasonOf(calc('VSTACK(SEQUENCE(1000,1000),1)') as Value)).toBe('tooMany'); + }); +}); + +describe('the lane’s functions', () => { + const names = ( + 'ARRAYFORMULA BYCOL BYROW CHOOSECOLS CHOOSEROWS DROP EXPAND FILTER FLATTEN HSTACK MAKEARRAY MAP MDETERM ' + + 'MINVERSE MMULT MUNIT RANDARRAY REDUCE SCAN SEQUENCE SORT SORTBY SORTN TAKE TOCOL TOROW TRANSPOSE TRIMRANGE ' + + 'UNIQUE VSTACK WRAPCOLS WRAPROWS' + ).split(' '); + + it('are all registered with a signature and an example of their own', () => { + for (const name of names) { + const spec = REGISTRY.get(name); + expect(spec, name).toBeDefined(); + expect(spec!.meta.signature.startsWith(`${name}(`), name).toBe(true); + expect(spec!.meta.example, name).toMatch(/^=/); + } + }); + + it('have a description for the function and for each argument, and nothing else is described', () => { + const text = fnArrays as Readonly>; + for (const name of names) { + expect(text[name]?.description, name).toMatch(/^[A-Z].*\.$/); + for (const param of paramsOf(REGISTRY.get(name)!)) { + expect(text[name]?.params[param.name], `${name} ${param.name}`).toBeTruthy(); + } + } + expect(Object.keys(text).filter((key) => key !== 'reasons' && !names.includes(key))).toEqual([]); + }); + + it('leave an argument left empty to its default, in any function', () => { + expect(calc('IF(TRUE,,2)')).toBe(null); + expect(calc('SEQUENCE(2,,5)')).toEqual([[5], [6]]); + }); +}); + +describe('the fixture of hand-worked cases', () => { + interface Case { + formula: string; + cells?: Record; + expected: number | string | (number | string)[][]; + } + const one = (v: Value): unknown => (isError(v) ? `#${v.error}` : v); + const show = (v: Value | Scalar[][]): unknown => (Array.isArray(v) ? v.map((row) => row.map(one)) : one(v)); + it.each((fixture as { cases: Case[] }).cases.map((c) => [c.formula, c] as const))('%s', (_formula, c) => { + const data: string[][] = []; + for (const [ref, v] of Object.entries(c.cells ?? {})) { + (data[Number(ref.slice(1)) - 1] ??= [])[ref.charCodeAt(0) - 65] = String(v); + } + expect(show(calc(c.formula.slice(1), data))).toEqual(c.expected); + }); +}); diff --git a/app/src/features/tables/engine/formula/fn/arrays.ts b/app/src/features/tables/engine/formula/fn/arrays.ts index dd78dce24..2766f73b6 100644 --- a/app/src/features/tables/engine/formula/fn/arrays.ts +++ b/app/src/features/tables/engine/formula/fn/arrays.ts @@ -1,5 +1,554 @@ -// Matrix and array functions. Owner: tables:fn-arrays. A stub until that lane fills it in. +// Dynamic array functions. Owner: tables:fn-arrays. FILTER, SORT, SORTBY, UNIQUE, and Sheets' SORTN pick and order +// rows; TAKE, DROP, VSTACK, HSTACK, TOCOL, TOROW, WRAPROWS, WRAPCOLS, EXPAND, CHOOSEROWS, CHOOSECOLS, TRIMRANGE, and +// Sheets' FLATTEN reshape. Behavior follows each function's documented rules. An empty result is #CALC!, as in +// Excel. This file also gathers the matrix functions (matrix.ts) and the LAMBDA helpers (lambda.ts), so the +// registry imports one list for the lane. -import type { FnSpec2 } from './types'; +import { makeArray } from '../../arrays'; +import { err, isArray, toBool, type ArrayValue, type FormulaError, type Scalar, type Value } from '../../values'; +import { functions as lambdaFunctions } from './lambda'; +import { arrayArg, failed, at, boolArg, build, fits, functions as matrixFunctions, intArg, tooMany } from './matrix'; +import type { FnSpec2, Param } from './types'; -export const functions: readonly FnSpec2[] = []; +// Ordering. + +/** The rank of a kind when sorting: numbers, then text, then booleans, then errors. */ +function kindRank(v: Scalar): number { + if (typeof v === 'number') return 0; + if (typeof v === 'string') return 1; + return typeof v === 'boolean' ? 2 : 3; +} + +/** Compares two items. Empty sorts last in both directions, as the Sort command does. */ +function compareItems(a: Scalar, b: Scalar, order: number): number { + if (a === null || b === null) return a === b ? 0 : a === null ? 1 : -1; + const ka = kindRank(a); + const kb = kindRank(b); + if (ka !== kb) return (ka - kb) * order; + if (typeof a === 'number') return (a - (b as number)) * order; + if (typeof a === 'string') { + const [x, y] = [a.toLowerCase(), (b as string).toLowerCase()]; + return (x < y ? -1 : x > y ? 1 : 0) * order; + } + if (typeof a === 'boolean') return (Number(a) - Number(b as boolean)) * order; + return 0; +} + +interface SortKey { + /** The item each row (or column) sorts by. */ + readonly items: readonly Scalar[]; + readonly order: number; +} + +/** The order of indexes 0 to n-1 by the keys, stable for ties. */ +function orderBy(n: number, keys: readonly SortKey[]): number[] { + const index = Array.from({ length: n }, (_, i) => i); + return index.sort((i, j) => { + for (const key of keys) { + const c = compareItems(key.items[i], key.items[j], key.order); + if (c !== 0) return c; + } + return i - j; + }); +} + +/** 1 or -1 from a sort order argument; empty is ascending. Other numbers give #VALUE!. */ +function orderArg(value: Value | undefined): number | FormulaError { + const n = intArg(value, 1); + if (typeof n !== 'number') return n; + return n === 1 || n === -1 ? n : err('VALUE'); +} + +/** The rows (or, by column, the columns) of `a` in the order of `index`. */ +function pick(a: ArrayValue, index: readonly number[], byCol: boolean): ArrayValue { + if (byCol) + return makeArray( + a.rows, + index.length, + Array.from({ length: a.rows * index.length }, (_, k) => + at(a, Math.floor(k / index.length), index[k % index.length]), + ), + ); + return makeArray( + index.length, + a.cols, + index.flatMap((r) => a.data.slice(r * a.cols, (r + 1) * a.cols)), + ); +} + +/** The items of line `i` along the other direction: column `i` of every row, or row `i` of every column. */ +function line(a: ArrayValue, i: number, byCol: boolean): Scalar[] { + return byCol ? a.data.slice(i * a.cols, (i + 1) * a.cols) : Array.from({ length: a.rows }, (_, r) => at(a, r, i)); +} + +// SORT, SORTBY, SORTN. + +function sort([array, index, order, byColumn]: Value[]): Value { + const a = arrayArg(array); + if (failed(a)) return a; + const byCol = boolArg(byColumn, false); + if (typeof byCol !== 'boolean') return byCol; + const indexes = index === undefined || index === null ? makeArray(1, 1, [1]) : arrayArg(index); + if (failed(indexes)) return indexes; + const orders = order === undefined || order === null ? makeArray(1, 1, [1]) : arrayArg(order); + if (failed(orders)) return orders; + const width = byCol ? a.rows : a.cols; + const keys: SortKey[] = []; + for (let k = 0; k < indexes.data.length; k++) { + const i = intArg(indexes.data[k], 1); + if (typeof i !== 'number') return i; + if (i < 1 || i > width) return err('VALUE'); + const o = orderArg(orders.data[orders.data.length === 1 ? 0 : k] ?? null); + if (typeof o !== 'number') return o; + keys.push({ items: line(a, i - 1, byCol), order: o }); + } + return pick(a, orderBy(byCol ? a.cols : a.rows, keys), byCol); +} + +/** A by-array's items: a single column as tall as `a` sorts rows, a single row as wide as `a` sorts columns. */ +function byItems(a: ArrayValue, by: Value): { items: Scalar[]; byCol: boolean } | FormulaError { + const b = arrayArg(by); + if (failed(b)) return b; + if (b.cols === 1 && b.rows === a.rows) return { items: [...b.data], byCol: false }; + if (b.rows === 1 && b.cols === a.cols) return { items: [...b.data], byCol: true }; + return err('VALUE'); +} + +function sortBy(args: Value[]): Value { + const a = arrayArg(args[0]); + if (failed(a)) return a; + const keys: SortKey[] = []; + let byCol: boolean | undefined; + for (let k = 1; k < args.length; k += 2) { + const by = byItems(a, args[k]); + if (failed(by)) return by; + if (byCol !== undefined && byCol !== by.byCol) return err('VALUE'); + byCol = by.byCol; + const o = orderArg(args[k + 1]); + if (typeof o !== 'number') return o; + keys.push({ items: by.items, order: o }); + } + return pick(a, orderBy(byCol === true ? a.cols : a.rows, keys), byCol === true); +} + +/** A key for comparing whole lines: equal lines have equal keys, text without regard to case. */ +function lineKey(items: readonly Scalar[]): string { + return JSON.stringify( + items.map((v) => (typeof v === 'string' ? `s${v.toLowerCase()}` : failed(v) ? `e${v.error}` : v)), + ); +} + +/** Sheets' SORTN: the first n rows after sorting, with four ways to treat ties. */ +function sortN(args: Value[]): Value { + const a = arrayArg(args[0]); + if (failed(a)) return a; + const n = intArg(args[1], 1); + if (typeof n !== 'number') return n; + const mode = intArg(args[2], 0); + if (typeof mode !== 'number') return mode; + if (n < 0 || mode < 0 || mode > 3) return err('VALUE'); + const keys: SortKey[] = []; + for (let k = 3; k < args.length; k += 2) { + const col = intArg(args[k], 1); + if (typeof col !== 'number') return col; + if (col < 1 || col > a.cols) return err('VALUE'); + const asc = boolArg(args[k + 1], true); + if (typeof asc !== 'boolean') return asc; + keys.push({ items: line(a, col - 1, false), order: asc ? 1 : -1 }); + } + // With no sort columns, every column sorts ascending, left to right. + if (keys.length === 0) for (let c = 0; c < a.cols; c++) keys.push({ items: line(a, c, false), order: 1 }); + const sorted = orderBy(a.rows, keys); + const tie = (r: number) => lineKey(keys.map((key) => key.items[r])); + const whole = (r: number) => lineKey(line(a, r, true)); + const out = takeRows(sorted, n, mode, tie, whole); + return out.length === 0 ? err('CALC') : pick(a, out, false); +} + +/** The rows SORTN keeps from the sorted order. Ties compare the sort columns; duplicates compare whole rows. */ +function takeRows( + sorted: number[], + n: number, + mode: number, + tie: (r: number) => string, + whole: (r: number) => string, +): number[] { + if (mode === 0) return sorted.slice(0, n); + if (mode === 1) { + const last = n > 0 && n <= sorted.length ? tie(sorted[n - 1]) : null; + return sorted.filter((r, i) => i < n || (last !== null && tie(r) === last)); + } + // Mode 2 keeps the first n distinct rows; mode 3 keeps every copy of the first n distinct rows. + const group = new Map(); + const out: number[] = []; + for (const r of sorted) { + const key = whole(r); + const fresh = !group.has(key); + if (fresh) group.set(key, group.size); + if ((group.get(key) ?? n) < n && (mode === 3 || fresh)) out.push(r); + } + return out; +} + +// FILTER and UNIQUE. + +function filter([array, include, ifEmpty]: Value[]): Value { + const a = arrayArg(array); + if (failed(a)) return a; + const inc = arrayArg(include); + if (failed(inc)) return inc; + let byCol: boolean; + if (inc.cols === 1 && inc.rows === a.rows) byCol = false; + else if (inc.rows === 1 && inc.cols === a.cols) byCol = true; + else return err('VALUE'); + const keep = rowsWhere(inc.data, (v) => toBool(v)); + if (failed(keep)) return keep; + if (keep.length > 0) return pick(a, keep, byCol); + if (ifEmpty === undefined) return err('CALC'); + return isArray(ifEmpty) && ifEmpty.rows === 1 && ifEmpty.cols === 1 ? ifEmpty.data[0] : ifEmpty; +} + +function unique([array, byColumn, exactlyOnce]: Value[]): Value { + const a = arrayArg(array); + if (failed(a)) return a; + const byCol = boolArg(byColumn, false); + if (typeof byCol !== 'boolean') return byCol; + const once = boolArg(exactlyOnce, false); + if (typeof once !== 'boolean') return once; + const count = byCol ? a.cols : a.rows; + const keys = Array.from({ length: count }, (_, i) => lineKey(line(a, i, !byCol))); + const seen = new Map(); + for (const key of keys) seen.set(key, (seen.get(key) ?? 0) + 1); + const first = new Set(); + const out: number[] = []; + keys.forEach((key, i) => { + if (first.has(key)) return; + first.add(key); + if (!once || seen.get(key) === 1) out.push(i); + }); + return out.length === 0 ? err('CALC') : pick(a, out, byCol); +} + +/** ARRAYFORMULA from Sheets: every formula is an array formula here, so it gives its argument. */ +function arrayFormula([value]: Value[]): Value { + return value ?? err('VALUE'); +} + +// Reshaping. + +/** The indexes whose item passes `test`, or the first error an item or the test gives. */ +function rowsWhere(items: readonly Scalar[], test: (v: Scalar) => boolean | FormulaError): number[] | FormulaError { + const out: number[] = []; + for (let i = 0; i < items.length; i++) { + const ok = test(items[i]); + if (typeof ok !== 'boolean') return ok; + if (ok) out.push(i); + } + return out; +} + +/** A count that may be left out: null when it was. */ +function countArg(value: Value | undefined): number | null | FormulaError { + return value === undefined || value === null ? null : intArg(value, 0); +} + +/** The indexes TAKE keeps (or DROP keeps, with `drop`) from n lines: a negative count works from the end. */ +function span(n: number, count: number | null, drop: boolean): number[] { + const all = Array.from({ length: n }, (_, i) => i); + if (count === null) return all; + const k = Math.min(Math.abs(count), n); + if (drop) return count >= 0 ? all.slice(k) : all.slice(0, n - k); + return count >= 0 ? all.slice(0, k) : all.slice(n - k); +} + +function takeOrDrop([array, rows, cols]: Value[], drop: boolean): Value { + const a = arrayArg(array); + if (failed(a)) return a; + const r = countArg(rows); + if (failed(r)) return r; + const c = countArg(cols); + if (failed(c)) return c; + const keepRows = span(a.rows, r, drop); + const keepCols = span(a.cols, c, drop); + if (keepRows.length === 0 || keepCols.length === 0) return err('CALC'); + return pick(pick(a, keepRows, false), keepCols, true); +} + +/** The value a pad argument stands for: #N/A when it was left out. */ +function padArg(value: Value | undefined): Scalar { + if (value === undefined) return err('NA'); + const a = arrayArg(value); + return failed(a) ? a : (a.data[0] ?? null); +} + +function stack(args: Value[], vertical: boolean): Value { + const parts: ArrayValue[] = []; + for (const v of args) { + const a = arrayArg(v, true); + if (failed(a)) return a; + parts.push(a); + } + const along = parts.reduce((sum, a) => sum + (vertical ? a.rows : a.cols), 0); + const across = Math.max(...parts.map((a) => (vertical ? a.cols : a.rows))); + if (!fits(along, across)) return tooMany(); + const owner: [ArrayValue, number][] = []; + for (const a of parts) for (let i = 0; i < (vertical ? a.rows : a.cols); i++) owner.push([a, i]); + const item = (i: number, j: number): Scalar => { + const [a, k] = owner[i]; + if (j >= (vertical ? a.cols : a.rows)) return err('NA'); + return vertical ? at(a, k, j) : at(a, j, k); + }; + return vertical ? build(along, across, item) : build(across, along, (r, c) => item(c, r)); +} + +/** TOCOL and TOROW: every item in one line, skipping empty cells (1), errors (2), or both (3). */ +function toLine([array, ignore, byColumn]: Value[], column: boolean): Value { + const a = arrayArg(array); + if (failed(a)) return a; + const skip = intArg(ignore, 0); + if (typeof skip !== 'number') return skip; + if (skip < 0 || skip > 3) return err('VALUE'); + const byCol = boolArg(byColumn, false); + if (typeof byCol !== 'boolean') return byCol; + // The output is allocated once and filled by index, reading the data directly: a million items stay well under + // the recalc budget. + const total = a.rows * a.cols; + const items = new Array(total); + const skipEmpty = (skip & 1) !== 0; + const skipErrors = (skip & 2) !== 0; + let n = 0; + for (let k = 0; k < total; k++) { + // Row by row reads the data in order; by column, item k is row k % rows of column k / rows. + const v = byCol ? a.data[(k % a.rows) * a.cols + Math.floor(k / a.rows)] : a.data[k]; + if ((skipEmpty && v === null) || (skipErrors && v !== null && typeof v === 'object')) continue; + items[n++] = v; + } + if (n === 0) return err('CALC'); + items.length = n; + return column ? makeArray(items.length, 1, items) : makeArray(1, items.length, items); +} + +/** WRAPROWS and WRAPCOLS: a single row or column cut into lines of `count`, the last one padded. */ +function wrap([vector, count, pad]: Value[], rows: boolean): Value { + const a = arrayArg(vector); + if (failed(a)) return a; + if (a.rows !== 1 && a.cols !== 1) return err('VALUE'); + const n = intArg(count, 0); + if (typeof n !== 'number') return n; + if (n < 1) return err('NUM'); + const lines = Math.ceil(a.data.length / n); + const filler = padArg(pad); + const item = (i: number, j: number): Scalar => (i * n + j < a.data.length ? a.data[i * n + j] : filler); + return rows ? build(lines, n, item) : build(n, lines, (r, c) => item(c, r)); +} + +function expand([array, rows, cols, pad]: Value[]): Value { + const a = arrayArg(array); + if (failed(a)) return a; + const r = intArg(rows, a.rows); + if (typeof r !== 'number') return r; + const c = intArg(cols, a.cols); + if (typeof c !== 'number') return c; + if (r < a.rows || c < a.cols) return err('VALUE'); + const filler = padArg(pad); + return build(r, c, (i, j) => (i < a.rows && j < a.cols ? at(a, i, j) : filler)); +} + +/** CHOOSEROWS and CHOOSECOLS: lines by number, negative from the end. Zero or past the end is #VALUE!. */ +function choose(args: Value[], byCol: boolean): Value { + const a = arrayArg(args[0]); + if (failed(a)) return a; + const n = byCol ? a.cols : a.rows; + const index: number[] = []; + for (const v of args.slice(1)) { + const list = arrayArg(v); + if (failed(list)) return list; + for (const item of list.data) { + const k = intArg(item, 0); + if (typeof k !== 'number') return k; + if (k === 0 || Math.abs(k) > n) return err('VALUE'); + index.push(k > 0 ? k - 1 : n + k); + } + } + if (!fits(byCol ? a.rows : index.length, byCol ? index.length : a.cols)) return tooMany(); + return pick(a, index, byCol); +} + +/** TRIMRANGE: drops empty rows and columns from the edges: leading (1), trailing (2), or both (3, the default). */ +function trimRange([range, rowMode, colMode]: Value[]): Value { + const a = arrayArg(range); + if (failed(a)) return a; + const rm = intArg(rowMode, 3); + if (typeof rm !== 'number') return rm; + const cm = intArg(colMode, 3); + if (typeof cm !== 'number') return cm; + if (rm < 0 || rm > 3 || cm < 0 || cm > 3) return err('VALUE'); + const rowsUsed = Array.from({ length: a.rows }, (_, r) => line(a, r, true).some((v) => v !== null)); + const colsUsed = Array.from({ length: a.cols }, (_, c) => line(a, c, false).some((v) => v !== null)); + const keepRows = edges(rowsUsed, rm); + const keepCols = edges(colsUsed, cm); + if (keepRows.length === 0 || keepCols.length === 0) return err('CALC'); + return pick(pick(a, keepRows, false), keepCols, true); +} + +/** The indexes left after trimming unused lines at the start (mode bit 1) and the end (mode bit 2). */ +function edges(used: readonly boolean[], mode: number): number[] { + let first = 0; + let last = used.length - 1; + if ((mode & 1) !== 0) while (first <= last && !used[first]) first++; + if ((mode & 2) !== 0) while (last >= first && !used[last]) last--; + return Array.from({ length: Math.max(0, last - first + 1) }, (_, i) => first + i); +} + +/** Sheets' FLATTEN: every item of every argument, row by row, in one column. */ +function flatten(args: Value[]): Value { + const items: Scalar[] = []; + for (const v of args) { + const a = arrayArg(v, true); + if (failed(a)) return a; + if (!fits(items.length + a.data.length, 1)) return tooMany(); + for (const v2 of a.data) items.push(v2); + } + return makeArray(items.length, 1, items); +} + +const arr = (name: string, optional = false): Param => ({ name, kind: 'array', ...(optional ? { optional } : {}) }); +const val = (name: string): Param => ({ name, kind: 'value', optional: true }); + +function spec( + name: string, + params: FnSpec2['params'], + run: FnSpec2['run'], + signature: string, + example: string, +): FnSpec2 { + return { name, family: 'arrays', params, run, meta: { signature, example } }; +} + +const pad = arr('pad_with', true); +const many = (name: string): FnSpec2['params'] => [{ repeat: [arr(name)], min: 1 }]; +const count = (name: string): Param => ({ name, kind: 'value' }); + +const shapeFunctions: readonly FnSpec2[] = [ + spec( + 'TAKE', + [arr('array'), count('rows'), val('columns')], + (args) => takeOrDrop(args, false), + 'TAKE(array, rows, [columns])', + '=TAKE({1;2;3}, -2) gives {2;3}', + ), + spec( + 'DROP', + [arr('array'), count('rows'), val('columns')], + (args) => takeOrDrop(args, true), + 'DROP(array, rows, [columns])', + '=DROP({1;2;3}, 1) gives {2;3}', + ), + spec('VSTACK', many('array'), (args) => stack(args, true), 'VSTACK(array, ...)', '=VSTACK(1, {2;3}) gives {1;2;3}'), + spec('HSTACK', many('array'), (args) => stack(args, false), 'HSTACK(array, ...)', '=HSTACK(1, {2,3}) gives {1,2,3}'), + spec( + 'TOCOL', + [arr('array'), val('ignore'), val('scan_by_column')], + (args) => toLine(args, true), + 'TOCOL(array, [ignore], [scan_by_column])', + '=TOCOL({1,2;3,4}, 0, TRUE) gives {1;3;2;4}', + ), + spec( + 'TOROW', + [arr('array'), val('ignore'), val('scan_by_column')], + (args) => toLine(args, false), + 'TOROW(array, [ignore], [scan_by_column])', + '=TOROW({1;2}) gives {1,2}', + ), + spec( + 'WRAPROWS', + [arr('vector'), count('wrap_count'), pad], + (args) => wrap(args, true), + 'WRAPROWS(vector, wrap_count, [pad_with])', + '=WRAPROWS({1,2,3,4}, 2) gives {1,2;3,4}', + ), + spec( + 'WRAPCOLS', + [arr('vector'), count('wrap_count'), pad], + (args) => wrap(args, false), + 'WRAPCOLS(vector, wrap_count, [pad_with])', + '=WRAPCOLS({1,2,3,4}, 2) gives {1,3;2,4}', + ), + spec( + 'EXPAND', + [arr('array'), count('rows'), val('columns'), pad], + expand, + 'EXPAND(array, rows, [columns], [pad_with])', + '=EXPAND({1,2}, 2, 2, 0) gives {1,2;0,0}', + ), + spec( + 'CHOOSEROWS', + [arr('array'), ...many('row_num')], + (args) => choose(args, false), + 'CHOOSEROWS(array, row_num, ...)', + '=CHOOSEROWS({1;2;3}, -1, 1) gives {3;1}', + ), + spec( + 'CHOOSECOLS', + [arr('array'), ...many('col_num')], + (args) => choose(args, true), + 'CHOOSECOLS(array, col_num, ...)', + '=CHOOSECOLS({1,2,3}, 3) gives 3', + ), + spec( + 'TRIMRANGE', + [arr('range'), val('trim_rows'), val('trim_cols')], + trimRange, + 'TRIMRANGE(range, [trim_rows], [trim_cols])', + '=TRIMRANGE(A1:C9) gives the part of A1:C9 that holds values', + ), + spec('FLATTEN', many('range'), flatten, 'FLATTEN(range, ...)', '=FLATTEN({1,2;3,4}) gives {1;2;3;4}'), +]; + +const own: readonly FnSpec2[] = [ + spec( + 'FILTER', + [arr('array'), arr('include'), arr('if_empty', true)], + filter, + 'FILTER(array, include, [if_empty])', + '=FILTER({1;2;3}, {1;2;3}>1) gives {2;3}', + ), + spec( + 'SORT', + [arr('array'), arr('sort_index', true), arr('sort_order', true), val('by_col')], + sort, + 'SORT(array, [sort_index], [sort_order], [by_col])', + '=SORT({3;1;2}, 1, -1) gives {3;2;1}', + ), + spec( + 'SORTBY', + [arr('array'), arr('by_array'), val('sort_order'), { repeat: [arr('by_array'), val('sort_order')] }], + sortBy, + 'SORTBY(array, by_array, [sort_order], ...)', + '=SORTBY({"b";"a"}, {2;1}) gives {"a";"b"}', + ), + spec( + 'UNIQUE', + [arr('array'), val('by_col'), val('exactly_once')], + unique, + 'UNIQUE(array, [by_col], [exactly_once])', + '=UNIQUE({"a";"b";"A"}) gives {"a";"b"}', + ), + spec( + 'SORTN', + [arr('range'), val('n'), val('display_ties_mode'), { repeat: [val('sort_column'), val('is_ascending')] }], + sortN, + 'SORTN(range, [n], [display_ties_mode], [sort_column, is_ascending], ...)', + '=SORTN({3;1;2}, 2) gives {1;2}', + ), + spec( + 'ARRAYFORMULA', + [arr('array_formula')], + arrayFormula, + 'ARRAYFORMULA(array_formula)', + '=ARRAYFORMULA({1,2}*2) gives {2,4}', + ), +]; + +/** Every function of the lane: the dynamic array functions, the reshaping ones, the matrix ones, and the helpers. */ +export const functions: readonly FnSpec2[] = [...own, ...shapeFunctions, ...matrixFunctions, ...lambdaFunctions]; diff --git a/app/src/features/tables/engine/formula/fn/distributions.ts b/app/src/features/tables/engine/formula/fn/distributions.ts new file mode 100644 index 000000000..5ce4529d2 --- /dev/null +++ b/app/src/features/tables/engine/formula/fn/distributions.ts @@ -0,0 +1,414 @@ +// The distribution functions and their compatibility names. Owner: tables:fn-stats. Lazy chunk only: the specs are +// declared in statsDeclared.ts and statsCompat.ts, and these are the runs they call once the chunk has loaded. +// Argument checks follow the documented #NUM! cases. Whole-number arguments such as degrees of freedom are truncated. + +import { err, finish, isError, toNumber, type FormulaError, type Value } from '../../values'; +import { + betaIncWide, + betaPdf, + binomCdf, + binomPmf, + fCdf, + fPdf, + fSf, + gammaCdf, + gammaPdf, + gammaSf, + hypgeomPmf, + hypgeomTail, + invert, + invertUpper, + negbinomPmf, + normCdf, + normInv, + normPdf, + poissonCdf, + poissonPmf, + tCdf, + tCentral, + tPdf, + tSf, +} from './distributionsKit'; +import { devsq, mean, numbersOf, numeric, pairsOf, type Run } from './statsKit'; +import { asArray } from '../../arrays'; + +const T = Math.trunc; +const bad = NaN; +const flag = (b: number): boolean => b !== 0; + +// Continuous laws. + +const normDist = numeric(([x, m, s, c]) => (s <= 0 ? bad : flag(c) ? normCdf((x - m) / s) : normPdf((x - m) / s) / s)); +const normInvRun = numeric(([p, m, s]) => (s <= 0 || p <= 0 || p >= 1 ? bad : m + s * normInv(p))); +const normSDist = numeric(([z, c]) => (flag(c) ? normCdf(z) : normPdf(z))); +const normSInv = numeric(([p]) => (p <= 0 || p >= 1 ? bad : normInv(p))); + +function lognorm(x: number, m: number, s: number, cumulative: boolean): number { + if (x <= 0 || s <= 0) return bad; + const z = (Math.log(x) - m) / s; + return cumulative ? normCdf(z) : normPdf(z) / (x * s); +} +const lognormInv = ([p, m, s]: number[]) => (p <= 0 || p >= 1 || s <= 0 ? bad : Math.exp(m + s * normInv(p))); + +function expon(x: number, l: number, cumulative: boolean): number { + if (x < 0 || l <= 0) return bad; + return cumulative ? -Math.expm1(-l * x) : l * Math.exp(-l * x); +} + +function weibull(x: number, a: number, b: number, cumulative: boolean): number { + if (x < 0 || a <= 0 || b <= 0) return bad; + const t = (x / b) ** a; + return cumulative ? -Math.expm1(-t) : (a / b) * (x / b) ** (a - 1) * Math.exp(-t); +} + +function gammaDist(x: number, a: number, b: number, cumulative: boolean): number { + if (x < 0 || a <= 0 || b <= 0) return bad; + return cumulative ? gammaCdf(x, a, b) : gammaPdf(x, a, b); +} + +function gammaInv(p: number, a: number, b: number): number { + if (p < 0 || p > 1 || a <= 0 || b <= 0) return bad; + if (p === 0) return 0; + if (p === 1) return bad; + return invert((x) => gammaCdf(x, a, b), p, 0, Math.max(1, a * b)); +} + +function betaDist(x: number, a: number, b: number, cumulative: boolean, lo = 0, hi = 1): number { + if (a <= 0 || b <= 0 || lo >= hi || x < lo || x > hi) return bad; + const u = (x - lo) / (hi - lo); + return cumulative ? betaIncWide(u, a, b) : betaPdf(u, a, b) / (hi - lo); +} + +function betaInv(p: number, a: number, b: number, lo = 0, hi = 1): number { + if (p <= 0 || p > 1 || a <= 0 || b <= 0 || lo >= hi) return bad; + if (p === 1) return hi; + const u = invert((x) => (x >= 1 ? 1 : betaIncWide(x, a, b)), p, 0, 1); + return lo + u * (hi - lo); +} + +const dfOk = (df: number): boolean => df >= 1 && df <= 1e10; + +function chisq(x: number, df: number, cumulative: boolean): number { + const k = T(df); + if (x < 0 || !dfOk(k)) return bad; + return cumulative ? gammaCdf(x, k / 2, 2) : gammaPdf(x, k / 2, 2); +} +const chisqRt = (x: number, df: number) => (x < 0 || !dfOk(T(df)) ? bad : gammaSf(x, T(df) / 2, 2)); +function chisqInv(p: number, df: number): number { + const k = T(df); + if (p < 0 || p >= 1 || !dfOk(k)) return bad; + return p === 0 ? 0 : invert((x) => gammaCdf(x, k / 2, 2), p, 0, Math.max(1, k)); +} +function chisqInvRt(p: number, df: number): number { + const k = T(df); + if (p <= 0 || p > 1 || !dfOk(k)) return bad; + return p === 1 ? 0 : invertUpper((x) => gammaSf(x, k / 2, 2), p, 0, Math.max(1, k)); +} + +function fDist(x: number, d1: number, d2: number, cumulative: boolean): number { + const [a, b] = [T(d1), T(d2)]; + if (x < 0 || !dfOk(a) || !dfOk(b)) return bad; + return cumulative ? fCdf(x, a, b) : fPdf(x, a, b); +} +const fDistRt = (x: number, d1: number, d2: number) => + x < 0 || !dfOk(T(d1)) || !dfOk(T(d2)) ? bad : fSf(x, T(d1), T(d2)); +function fInv(p: number, d1: number, d2: number): number { + const [a, b] = [T(d1), T(d2)]; + if (p < 0 || p > 1 || !dfOk(a) || !dfOk(b)) return bad; + return p === 0 ? 0 : p === 1 ? bad : invert((x) => fCdf(x, a, b), p, 0, 1); +} +function fInvRt(p: number, d1: number, d2: number): number { + const [a, b] = [T(d1), T(d2)]; + if (p <= 0 || p > 1 || !dfOk(a) || !dfOk(b)) return bad; + return p === 1 ? 0 : invertUpper((x) => fSf(x, a, b), p, 0, 1); +} + +// The t functions take 1 to 1E10 degrees of freedom, like chi-squared and F. +function tDist(x: number, df: number, cumulative: boolean): number { + const k = T(df); + if (!dfOk(k)) return bad; + return cumulative ? tCdf(x, k) : tPdf(x, k); +} +const tDist2t = (x: number, df: number) => (x < 0 || !dfOk(T(df)) ? bad : Math.min(1, 2 * tSf(x, T(df)))); +const tDistRt = (x: number, df: number) => (!dfOk(T(df)) ? bad : tSf(x, T(df))); +/** + * The t value with P(T > t) = q, for 0 < q <= 0.5. Near the center it solves the central mass for 0.5 - q, which is + * exact, since the tail there is 0.5 less a tiny number and has lost the digits that place t. + */ +function tUpperInv(q: number, k: number): number { + if (q === 0.5) return 0; + if (q > 0.25) return invert((x) => tCentral(x, k), 0.5 - q, 0, 1); + return invertUpper((x) => tSf(x, k), q, 0, 1); +} +function tInv(p: number, df: number): number { + const k = T(df); + if (p <= 0 || p >= 1 || !dfOk(k)) return bad; + return p < 0.5 ? -tUpperInv(p, k) : tUpperInv(1 - p, k); +} +const tInv2t = (p: number, df: number) => (p <= 0 || p > 1 || !dfOk(T(df)) ? bad : tUpperInv(p / 2, T(df))); + +// Discrete laws. + +function binomDist(s: number, n: number, p: number, cumulative: boolean): number { + const [k, m] = [T(s), T(n)]; + if (k < 0 || k > m || p < 0 || p > 1) return bad; + return cumulative ? binomCdf(k, m, p) : binomPmf(k, m, p); +} + +function binomRange([n, p, s, s2]: number[]): number { + const [m, a] = [T(n), T(s)]; + const b = s2 === undefined ? a : T(s2); + if (m < 0 || p < 0 || p > 1 || a < 0 || a > m || b < a || b > m) return bad; + // Past the summing range of binomCdf, the difference of two distribution values costs two incomplete betas. + if (m > 1000) return Math.max(0, Math.min(1, binomCdf(b, m, p) - binomCdf(a - 1, m, p))); + let total = 0; + for (let i = a; i <= b; i++) total += binomPmf(i, m, p); + return Math.min(1, total); +} + +/** Counts above 2^53 are not whole numbers in a double, so the discrete laws that step through them stop there. */ +const COUNT_MAX = 2 ** 53; + +function binomInv(n: number, p: number, alpha: number): number { + const m = T(n); + if (m < 0 || m > COUNT_MAX || p < 0 || p > 1 || alpha < 0 || alpha > 1) return bad; + // P(X <= k) < 1 for every k < n when p > 0, so only n reaches alpha = 1. + if (alpha === 1) return p === 0 ? 0 : m; + const target = alpha * (1 - 1e-15); + if (m > 1000) { + // The smallest k with binomCdf(k) >= target, by bisection: about 30 incomplete betas for a billion trials. + let [lo, hi] = [-1, m]; + while (hi - lo > 1) { + const mid = Math.floor((lo + hi) / 2); + if (mid === lo || mid === hi) break; + const v = binomCdf(mid, m, p); + if (Number.isNaN(v)) return bad; + if (v >= target) hi = mid; + else lo = mid; + } + return hi; + } + let total = 0; + for (let k = 0; k <= m; k++) { + total += binomPmf(k, m, p); + if (total >= target) return k; + } + return m; +} + +function negbinom(f: number, s: number, p: number, cumulative: boolean): number { + const [x, r] = [T(f), T(s)]; + if (x < 0 || r < 1 || p < 0 || p > 1) return bad; + if (cumulative) return betaIncWide(p, r, x + 1); + return negbinomPmf(x, r, p); +} + +function hypgeom(s: number, n: number, ms: number, np: number, cumulative: boolean): number { + const [k, draws, succ, pop] = [T(s), T(n), T(ms), T(np)]; + const low = Math.max(0, draws - pop + succ); + if (k < low || k > Math.min(draws, succ) || draws <= 0 || succ <= 0 || pop <= 0 || draws > pop || succ > pop) { + return bad; + } + if (pop > COUNT_MAX) { + return bad; + } + return cumulative ? hypgeomTail(k, draws, succ, pop) : hypgeomPmf(k, draws, succ, pop); +} + +function poisson(x: number, m: number, cumulative: boolean): number { + const k = T(x); + if (k < 0 || m < 0) return bad; + return cumulative ? poissonCdf(k, m) : poissonPmf(k, m); +} + +// Confidence and tests. + +const confidenceNorm = ([a, sd, n]: number[]) => + a <= 0 || a >= 1 || sd <= 0 || T(n) < 1 ? bad : (normInv(1 - a / 2) * sd) / Math.sqrt(T(n)); + +function confidenceT([a, sd, n]: number[]): number | FormulaError { + const k = T(n); + if (a <= 0 || a >= 1 || sd <= 0 || k < 1) return bad; + if (k === 1) return err('DIV0'); + return (tUpperInv(a / 2, k - 1) * sd) / Math.sqrt(k); +} + +function chisqTest([actual, expected]: Value[]): Value { + if (isError(actual)) return actual; + if (isError(expected)) return expected; + const a = asArray(actual); + const e = asArray(expected); + if (a.rows !== e.rows || a.cols !== e.cols) return err('NA'); + let chi = 0; + for (let i = 0; i < a.data.length; i++) { + const [o, x] = [a.data[i], e.data[i]]; + if (isError(o)) return o; + if (isError(x)) return x; + if (typeof o !== 'number' || typeof x !== 'number') continue; + if (x === 0) return err('DIV0'); + if (x < 0) return err('NUM'); + chi += ((o - x) * (o - x)) / x; + } + const df = a.rows > 1 && a.cols > 1 ? (a.rows - 1) * (a.cols - 1) : a.data.length - 1; + if (df < 1) return err('NA'); + return finish(gammaSf(chi, df / 2, 2)); +} + +function fTest([one, two]: Value[]): Value { + const a = numbersOf(one); + const b = numbersOf(two); + if (!Array.isArray(a)) return a; + if (!Array.isArray(b)) return b; + if (a.length < 2 || b.length < 2) return err('DIV0'); + const [va, vb] = [devsq(a) / (a.length - 1), devsq(b) / (b.length - 1)]; + if (va === 0 || vb === 0) return err('DIV0'); + const lower = fCdf(va / vb, a.length - 1, b.length - 1); + return finish(Math.min(1, 2 * Math.min(lower, 1 - lower))); +} + +function tTestPaired(one: Value, two: Value): number[] | Value { + const p = pairsOf(one, two); + if (!('xs' in p)) return p; + const d = p.xs.map((x, i) => x - p.ys[i]); + if (d.length < 2) return err('DIV0'); + const sd = Math.sqrt(devsq(d) / (d.length - 1)); + return sd === 0 ? err('DIV0') : [(mean(d) / sd) * Math.sqrt(d.length), d.length - 1]; +} + +function tTestTwo(one: Value, two: Value, equal: boolean): number[] | Value { + const a = numbersOf(one); + const b = numbersOf(two); + if (!Array.isArray(a)) return a; + if (!Array.isArray(b)) return b; + const [n1, n2] = [a.length, b.length]; + if (n1 < 2 || n2 < 2) return err('DIV0'); + const [v1, v2] = [devsq(a) / (n1 - 1), devsq(b) / (n2 - 1)]; + const diff = mean(a) - mean(b); + if (equal) { + const pooled = ((n1 - 1) * v1 + (n2 - 1) * v2) / (n1 + n2 - 2); + const se = Math.sqrt(pooled * (1 / n1 + 1 / n2)); + return se === 0 ? err('DIV0') : [diff / se, n1 + n2 - 2]; + } + const [q1, q2] = [v1 / n1, v2 / n2]; + if (q1 + q2 === 0) return err('DIV0'); + // Welch's degrees of freedom, used as they are rather than rounded. + const df = (q1 + q2) ** 2 / ((q1 * q1) / (n1 - 1) + (q2 * q2) / (n2 - 1)); + return [diff / Math.sqrt(q1 + q2), df]; +} + +/** The p-value of the t statistic for a Welch fraction of degrees of freedom: the t law by the incomplete beta. */ +const tTail = (t: number, df: number) => 0.5 * betaIncWide(df / (df + t * t), df / 2, 0.5, (t * t) / (df + t * t)); + +function tTest([one, two, tailsV, typeV]: Value[]): Value { + const tails = toNumber(tailsV); + const type = toNumber(typeV); + if (typeof tails !== 'number') return tails; + if (typeof type !== 'number') return type; + const [k, kind] = [T(tails), T(type)]; + if ((k !== 1 && k !== 2) || kind < 1 || kind > 3) return err('NUM'); + const st = kind === 1 ? tTestPaired(one, two) : tTestTwo(one, two, kind === 2); + if (!Array.isArray(st)) return st; + const [t, df] = st; + return finish(Math.min(1, k * tTail(Math.abs(t), df))); +} + +function zTest([array, xV, sigmaV]: Value[]): Value { + const nums = numbersOf(array); + if (!Array.isArray(nums)) return nums; + const x = toNumber(xV); + if (typeof x !== 'number') return x; + if (nums.length === 0) return err('NA'); + let sigma: number; + if (sigmaV === undefined) { + if (nums.length < 2) return err('DIV0'); + sigma = Math.sqrt(devsq(nums) / (nums.length - 1)); + } else { + const s = toNumber(sigmaV); + if (typeof s !== 'number') return s; + sigma = s; + } + if (sigma <= 0) return err(sigma === 0 ? 'DIV0' : 'NUM'); + return finish(1 - normCdf((mean(nums) - x) / (sigma / Math.sqrt(nums.length)))); +} + +// The runs by name. The compatibility names reuse the modern runs where the arguments agree. + +const c = (f: (...n: number[]) => number) => numeric((n) => f(...n)); + +export const runs: Readonly> = { + 'NORM.DIST': normDist, + 'NORM.INV': normInvRun, + 'NORM.S.DIST': normSDist, + 'NORM.S.INV': normSInv, + PHI: numeric(([z]) => normPdf(z)), + GAUSS: numeric(([z]) => normCdf(z) - 0.5), + 'LOGNORM.DIST': numeric(([x, m, s, k]) => lognorm(x, m, s, flag(k))), + 'LOGNORM.INV': numeric(lognormInv), + 'EXPON.DIST': numeric(([x, l, k]) => expon(x, l, flag(k))), + 'WEIBULL.DIST': numeric(([x, a, b, k]) => weibull(x, a, b, flag(k))), + 'GAMMA.DIST': numeric(([x, a, b, k]) => gammaDist(x, a, b, flag(k))), + 'GAMMA.INV': c(gammaInv), + 'BETA.DIST': numeric( + ([x, a, b, k, lo, hi]) => betaDist(x, a, b, flag(k), lo, hi), + [undefined, undefined, undefined, undefined, 0, 1], + ), + 'BETA.INV': numeric(([p, a, b, lo, hi]) => betaInv(p, a, b, lo, hi), [undefined, undefined, undefined, 0, 1]), + 'CHISQ.DIST': numeric(([x, k, f]) => chisq(x, k, flag(f))), + 'CHISQ.DIST.RT': c(chisqRt), + 'CHISQ.INV': c(chisqInv), + 'CHISQ.INV.RT': c(chisqInvRt), + 'CHISQ.TEST': chisqTest, + 'F.DIST': numeric(([x, a, b, k]) => fDist(x, a, b, flag(k))), + 'F.DIST.RT': c(fDistRt), + 'F.INV': c(fInv), + 'F.INV.RT': c(fInvRt), + 'F.TEST': fTest, + 'T.DIST': numeric(([x, k, f]) => tDist(x, k, flag(f))), + 'T.DIST.2T': c(tDist2t), + 'T.DIST.RT': c(tDistRt), + 'T.INV': c(tInv), + 'T.INV.2T': c(tInv2t), + 'T.TEST': tTest, + 'Z.TEST': zTest, + 'BINOM.DIST': numeric(([s, n, p, k]) => binomDist(s, n, p, flag(k))), + 'BINOM.DIST.RANGE': numeric(binomRange), + 'BINOM.INV': c(binomInv), + 'NEGBINOM.DIST': numeric(([f, s, p, k]) => negbinom(f, s, p, flag(k))), + 'HYPGEOM.DIST': numeric(([s, n, m, p, k]) => hypgeom(s, n, m, p, flag(k))), + 'POISSON.DIST': numeric(([x, m, k]) => poisson(x, m, flag(k))), + 'CONFIDENCE.NORM': numeric(confidenceNorm), + 'CONFIDENCE.T': numeric(confidenceT), + // Compatibility names. + BETADIST: numeric(([x, a, b, lo, hi]) => betaDist(x, a, b, true, lo, hi), [undefined, undefined, undefined, 0, 1]), + BETAINV: numeric(([p, a, b, lo, hi]) => betaInv(p, a, b, lo, hi), [undefined, undefined, undefined, 0, 1]), + BINOMDIST: numeric(([s, n, p, k]) => binomDist(s, n, p, flag(k))), + CHIDIST: c(chisqRt), + CHIINV: c(chisqInvRt), + CHITEST: chisqTest, + CONFIDENCE: numeric(confidenceNorm), + CRITBINOM: c(binomInv), + EXPONDIST: numeric(([x, l, k]) => expon(x, l, flag(k))), + FDIST: c(fDistRt), + FINV: c(fInvRt), + FTEST: fTest, + GAMMADIST: numeric(([x, a, b, k]) => gammaDist(x, a, b, flag(k))), + GAMMAINV: c(gammaInv), + HYPGEOMDIST: c((s, n, m, p) => hypgeom(s, n, m, p, false)), + LOGINV: numeric(lognormInv), + LOGNORMDIST: c((x, m, s) => lognorm(x, m, s, true)), + NEGBINOMDIST: c((f, s, p) => negbinom(f, s, p, false)), + NORMDIST: normDist, + NORMINV: normInvRun, + NORMSDIST: numeric(([z]) => normCdf(z)), + NORMSINV: normSInv, + POISSON: numeric(([x, m, k]) => poisson(x, m, flag(k))), + TDIST: c((x, df, tails) => { + const k = T(tails); + if (x < 0 || !dfOk(T(df)) || (k !== 1 && k !== 2)) return bad; + return Math.min(1, k * tSf(x, T(df))); + }), + TINV: c(tInv2t), + TTEST: tTest, + WEIBULL: numeric(([x, a, b, k]) => weibull(x, a, b, flag(k))), + ZTEST: zTest, +}; diff --git a/app/src/features/tables/engine/formula/fn/distributionsKit.ts b/app/src/features/tables/engine/formula/fn/distributionsKit.ts new file mode 100644 index 000000000..ff7c63d81 --- /dev/null +++ b/app/src/features/tables/engine/formula/fn/distributionsKit.ts @@ -0,0 +1,521 @@ +// The probability distributions behind the statistical functions. Owner: tables:fn-stats. Lazy chunk only. +// Written from the textbook definitions (NIST/SEMATECH e-Handbook and Abramowitz and Stegun) on top of +// core/num/special: the normal law from erfc, the gamma family from the regularized incomplete gamma, and the +// beta, Student t, and F laws from the regularized incomplete beta. Inverses solve the distribution numerically. + +import { betaInc, erf, erfc, gammaP, gammaQ, lgamma } from '../../../../../core/num/special'; + +const SQRT2 = Math.SQRT2; +const LN_SQRT_2PI = 0.5 * Math.log(2 * Math.PI); + +// Normal. + +export const normPdf = (z: number): number => Math.exp(-0.5 * z * z - LN_SQRT_2PI); + +/** The standard normal distribution, from erfc so the tails keep their digits. */ +export const normCdf = (z: number): number => 0.5 * erfc(-z / SQRT2); + +// A rational starting point for the inverse normal, from the published coefficients of P. J. Acklam's algorithm, +// then refined by Halley steps on normCdf, so the result is as accurate as normCdf itself. +const A = [ + -39.69683028665376, 220.9460984245205, -275.928510446969, 138.357751867269, -30.66479806614716, 2.506628277459239, +]; +const B = [-54.47609879822406, 161.5858368580409, -155.6989798598866, 66.80131188771972, -13.28068155288572]; +const C = [ + -0.007784894002430293, -0.3223964580411365, -2.400758277161838, -2.549732539343734, 4.374664141464968, + 2.938163982698783, +]; +const D = [0.007784695709041462, 0.3224671290700398, 2.445134137142996, 3.754408661907416]; +const P_LOW = 0.02425; + +function poly(c: readonly number[], x: number): number { + let s = 0; + for (const k of c) s = s * x + k; + return s; +} + +function normGuess(p: number): number { + if (p < P_LOW) { + const q = Math.sqrt(-2 * Math.log(p)); + return poly(C, q) / (poly(D, q) * q + 1); + } + if (p > 1 - P_LOW) { + const q = Math.sqrt(-2 * Math.log1p(-p)); + return -poly(C, q) / (poly(D, q) * q + 1); + } + const q = p - 0.5; + const r = q * q; + return (poly(A, r) * q) / (poly(B, r) * r + 1); +} + +/** The inverse of the standard normal distribution for 0 < p < 1. */ +export function normInv(p: number): number { + if (!(p > 0 && p < 1)) return NaN; + let x = normGuess(p); + for (let i = 0; i < 3; i++) { + // The residual cdf(x) - p, worked in the smaller tail so it keeps its digits; then one Halley step. + const e = p < 0.5 ? normCdf(x) - p : 1 - p - normCdf(-x); + const u = e / normPdf(x); + const step = u / (1 + (x * u) / 2); + x -= step; + if (Math.abs(step) <= 1e-16 * Math.max(1, Math.abs(x))) break; + } + return x; +} + +// Gamma and chi-squared. + +/** + * At or above this shape the incomplete gamma takes the wide path below. Near the mean, the series needs about ten + * times the square root of the shape in terms. So does the fraction. The core routine stops at 1,000. + */ +const GAMMA_WIDE = 1000; + +/** + * x^a e^-x / Gamma(a), kept exact for a large shape. With x = a (1 + u), the logs of x^a e^-x and of Gamma(a) each + * reach about a ln a. Their difference has no such size: ln(a / (2 pi)) / 2 - stirlingRest(a) + a (ln(1 + u) - u). + */ +function gammaPower(a: number, x: number): number { + return Math.exp(0.5 * Math.log(a) - LN_SQRT_2PI - stirlingRest(a) + a * log1pmx((x - a) / a)); +} + +/** No more terms than this: ten times the square root of the largest shape the functions take, with room. */ +const GAMMA_TERMS = 10_000_000; + +/** + * The regularized incomplete gamma for a large shape: [P, Q]. Below x = a + 1 the series of P (DLMF 8.7.1), above + * it the continued fraction of Q (DLMF 8.9.2), each run until its terms no longer count, with the exact power term + * above. The other value is one less the first; the first is the smaller tail or near one half. NaN, which shows as + * #NUM!, if the terms run out. + */ +function gammaWide(a: number, x: number): [number, number] { + if (x < a + 1) { + let term = 1 / a; + let sum = term; + for (let n = 1; n < GAMMA_TERMS; n++) { + term *= x / (a + n); + sum += term; + // At or below, so a term that underflows to zero still stops the sum when the sum is subnormal. + if (term <= sum * 2 ** -53) { + const p = Math.min(1, sum * gammaPower(a, x)); + return [p, 1 - p]; + } + } + return [NaN, NaN]; + } + // The modified Lentz method on Q = power / (x + 1 - a - 1 (1 - a) / (x + 3 - a - 2 (2 - a) / (x + 5 - a - ...))). + const tiny = 1e-300; + let b = x + 1 - a; + let c = 1 / tiny; + let d = 1 / b; + let h = d; + for (let i = 1; i < GAMMA_TERMS; i++) { + const an = -i * (i - a); + b += 2; + d = an * d + b; + if (Math.abs(d) < tiny) d = tiny; + c = b + an / c; + if (Math.abs(c) < tiny) c = tiny; + d = 1 / d; + const delta = d * c; + h *= delta; + if (Math.abs(delta - 1) < 2 ** -52) { + const q = Math.min(1, h * gammaPower(a, x)); + return [1 - q, q]; + } + } + return [NaN, NaN]; +} + +export function gammaPdf(x: number, a: number, b: number): number { + if (x < 0) return 0; + if (x === 0) return a === 1 ? 1 / b : a < 1 ? Infinity : 0; + if (a >= GAMMA_WIDE) return gammaPower(a, x / b) / x; + return Math.exp((a - 1) * Math.log(x) - x / b - a * Math.log(b) - lgamma(a)); +} + +/** P(a, x) and Q(a, x): the core routine for a small shape, the wide path above. */ +const lowerGamma = (a: number, x: number): number => (a >= GAMMA_WIDE ? gammaWide(a, x)[0] : gammaP(a, x)); +const upperGamma = (a: number, x: number): number => (a >= GAMMA_WIDE ? gammaWide(a, x)[1] : gammaQ(a, x)); + +export const gammaCdf = (x: number, a: number, b: number): number => (x <= 0 ? 0 : lowerGamma(a, x / b)); +export const gammaSf = (x: number, a: number, b: number): number => (x <= 0 ? 1 : upperGamma(a, x / b)); + +// Beta. + +/** At or above this parameter the incomplete beta takes the wide path below, whose power term keeps its digits. */ +const WIDE = 1e3; + +/** ln(1 + u) - u, accurate for small u. */ +function log1pmx(u: number): number { + if (Math.abs(u) > 0.1) return Math.log1p(u) - u; + let power = u; + let sum = 0; + for (let k = 2; k < 80; k++) { + power *= -u; + const term = power / k; + sum += term; + if (Math.abs(term) <= 1e-17 * Math.abs(sum)) break; + } + return sum; +} + +/** ln Gamma(x) less its Stirling form (x - 1/2) ln x - x + ln(2 pi) / 2: the Bernoulli series (DLMF 5.11.1) from 30. */ +function stirlingRest(x: number): number { + if (x < 30) return lgamma(x) - ((x - 0.5) * Math.log(x) - x + LN_SQRT_2PI); + const r = 1 / (x * x); + return (1 / 12 - r * (1 / 360 - r * (1 / 1260 - r / 1680))) / x; +} + +/** + * The power term x^a y^b / B(a, b), with y = 1 - x, kept exact for large parameters. The caller passes y when it + * knows 1 - x exactly. Near the mean x0 = a / (a + b), the logs of the power and of B(a, b) each reach about a + b. + * Their difference has no such size. It is ln(a b / (a + b)) / 2 - ln(2 pi) / 2, less the Stirling rests, plus + * a (ln(1 + u) - u) + b (ln(1 + v) - v). Here u = x / x0 - 1 and v = y / (1 - x0) - 1, so a u + b v = 0. + */ +export function betaPower(x: number, a: number, b: number, y = 1 - x): number { + const s = a + b; + const ln = + 0.5 * Math.log((a / s) * b) - + LN_SQRT_2PI - + stirlingRest(a) - + stirlingRest(b) + + stirlingRest(s) + + scaledLog1pmx(x, s, a) + + scaledLog1pmx(y, s, b); + return Math.exp(ln); +} + +/** + * a (ln r - (r - 1)) with r = x s / a. Near r = 1 it is a log1pmx(r - 1). Far below 1, r - 1 rounded would lose the + * digits of r (it is -1 once r is under 1E-16), so the log comes from ln x + ln(s / a) and a (r - 1) from x s - a. + */ +function scaledLog1pmx(x: number, s: number, a: number): number { + const xs = x * s; + if (xs < 0.5 * a) return a * (Math.log(x) + Math.log(s / a)) - (xs - a); + return a * log1pmx((xs - a) / a); +} + +/** The continued fraction of I_x(a, b) (DLMF 8.17.22) by Lentz's method, with the exact power term above. */ +function betaIncFraction(x: number, a: number, b: number, y: number): number { + if (x <= 0) return 0; + if (y <= 0) return 1; + if (x > (a + 1) / (a + b + 2)) return 1 - betaIncFraction(y, b, a, x); + const tiny = 1e-300; + let f = 1; + let c = 1; + let d = 0; + // Near the mean the fraction needs about the square root of the larger parameter in terms. + for (let i = 0; i <= 4_000_000; i++) { + const m = Math.floor(i / 2); + let num: number; + if (i === 0) num = 1; + else if (i % 2 === 0) num = (m * (b - m) * x) / ((a + 2 * m - 1) * (a + 2 * m)); + else num = -((a + m) * (a + b + m) * x) / ((a + 2 * m) * (a + 2 * m + 1)); + d = 1 + num * d; + d = 1 / (Math.abs(d) < tiny ? tiny : d); + c = 1 + num / c; + if (Math.abs(c) < tiny) c = tiny; + const delta = c * d; + f *= delta; + if (Math.abs(delta - 1) < 2 ** -52) return (betaPower(x, a, b, y) / a) * (f - 1); + } + return NaN; +} + +/** + * The regularized incomplete beta I_x(a, b), with y = 1 - x passed when the caller knows it exactly. The core + * routine serves parameters below WIDE. Larger ones, such as the F law at a million degrees of freedom or the + * binomial law at a billion trials, run out of its terms and lose digits in ln B(a, b), so they take the longer + * fraction with the exact power term. + */ +export const betaIncWide = (x: number, a: number, b: number, y = 1 - x): number => + Math.max(a, b) >= WIDE ? betaIncFraction(x, a, b, y) : betaInc(x, a, b); + +export function betaPdf(x: number, a: number, b: number): number { + if (x < 0 || x > 1) return 0; + if (x === 0) return a === 1 ? b : a < 1 ? Infinity : 0; + if (x === 1) return b === 1 ? a : b < 1 ? Infinity : 0; + // x^(a - 1) (1 - x)^(b - 1) / B(a, b) is the power term over x (1 - x). + if (Math.max(a, b) >= WIDE) return betaPower(x, a, b) / (x * (1 - x)); + return Math.exp((a - 1) * Math.log(x) + (b - 1) * Math.log1p(-x) - (lgamma(a) + lgamma(b) - lgamma(a + b))); +} + +// Student t. + +/** + * Past this many degrees of freedom the fraction in x = df / (df + t^2) loses digits, since x carries 1 - x with + * only about 16 - log10(df) of them, so the t law is the normal law with two terms of its expansion in 1 / df. + */ +const T_NORMAL = 1e7; + +/** Below this t the tail is near one half, so it is one half less the central mass, which keeps its digits. */ +const T_CENTER = 0.5; + +/** + * The upper tail P(T > t) for t >= 0: I_x(df / 2, 1 / 2) / 2 with x = df / (df + t^2). Many degrees of freedom + * take the wide path with 1 - x = t^2 / (df + t^2) worked out exactly, since 1 - x rounded would lose its digits. + */ +function tUpper(t: number, df: number): number { + if (t < T_CENTER) return 0.5 - tCentral(t, df); + const t2 = t * t; + if (df > T_NORMAL) return normCdf(-t) + normPdf(t) * tSeries(t, t2, df); + const [x, y] = [df / (df + t2), t2 / (df + t2)]; + return 0.5 * (df >= 2 * WIDE ? betaIncWide(x, df / 2, 0.5, y) : betaInc(x, df / 2, 0.5)); +} + +/** Abramowitz and Stegun 26.7.5: P(T > t) = Phi(-t) + phi(t) (g1 / df + g2 / df^2); the next term is near 1E-21. */ +function tSeries(t: number, t2: number, df: number): number { + const g1 = (t2 * t + t) / 4; + const g2 = (t * (((3 * t2 - 7) * t2 - 5) * t2 - 3)) / 96; + return g1 / df + g2 / (df * df); +} + +/** + * The central mass P(0 < T < t) for t >= 0. Below T_CENTER it is I_y(1/2, df / 2) / 2 with y = t^2 / (df + t^2), + * which keeps every digit for a small t; the tail from x = df / (df + t^2) would not, since x rounds to 1. Past + * T_NORMAL it is erf(t / sqrt 2) / 2 less the series term. At or above T_CENTER it is one half less the tail. + */ +export function tCentral(t: number, df: number): number { + if (t >= T_CENTER) return 0.5 - tUpper(t, df); + const t2 = t * t; + if (df > T_NORMAL) return 0.5 * erf(t / SQRT2) - normPdf(t) * tSeries(t, t2, df); + const [y, x] = [t2 / (df + t2), df / (df + t2)]; + return 0.5 * (df >= 2 * WIDE ? betaIncWide(y, 0.5, df / 2, x) : betaInc(y, 0.5, df / 2)); +} + +export const tCdf = (t: number, df: number): number => (t >= 0 ? 1 - tUpper(t, df) : tUpper(-t, df)); +export const tSf = (t: number, df: number): number => (t >= 0 ? tUpper(t, df) : 1 - tUpper(-t, df)); + +export function tPdf(t: number, df: number): number { + // ln Gamma((df + 1) / 2) - ln Gamma(df / 2) - ln(df pi) / 2. For many degrees of freedom the two ln Gamma values + // nearly cancel, so with a = df / 2 it is a ln(1 + 1 / (2 a)) - 1/2 - ln(2 pi) / 2 plus the Stirling rests. + const a = df / 2; + const lnc = + df >= 2 * WIDE + ? a * Math.log1p(0.5 / a) - 0.5 - LN_SQRT_2PI + stirlingRest(a + 0.5) - stirlingRest(a) + : lgamma((df + 1) / 2) - lgamma(df / 2) - 0.5 * Math.log(df * Math.PI); + return Math.exp(lnc - ((df + 1) / 2) * Math.log1p((t * t) / df)); +} + +// F. + +/** + * [y, 1 - y] with y = d1 x / (d1 x + d2). Past d1 x = d2 it is worked from q = d2 / (d1 x) as [1, q] / (1 + q), so + * a huge x, where d1 x overflows, still gives y = 1. + */ +function fSplit(x: number, d1: number, d2: number): [number, number] { + const dx = d1 * x; + if (dx <= d2) return [dx / (dx + d2), d2 / (dx + d2)]; + const q = d2 / d1 / x; + return [1 / (1 + q), q / (1 + q)]; +} + +export function fCdf(x: number, d1: number, d2: number): number { + if (x <= 0) return 0; + const [y, z] = fSplit(x, d1, d2); + return betaIncWide(y, d1 / 2, d2 / 2, z); +} + +export function fSf(x: number, d1: number, d2: number): number { + if (x <= 0) return 1; + const [y, z] = fSplit(x, d1, d2); + return betaIncWide(z, d2 / 2, d1 / 2, y); +} + +export function fPdf(x: number, d1: number, d2: number): number { + if (x < 0) return 0; + if (x === 0) return d1 === 2 ? 1 : d1 < 2 ? Infinity : 0; + // The density is the beta density of y = d1 x / (d1 x + d2) times dy/dx, which comes to betaPower(y) / x. + if (Math.max(d1, d2) >= 2 * WIDE) { + return betaPower((d1 * x) / (d1 * x + d2), d1 / 2, d2 / 2, d2 / (d1 * x + d2)) / x; + } + const lnB = lgamma(d1 / 2) + lgamma(d2 / 2) - lgamma((d1 + d2) / 2); + const ln = + (d1 / 2) * Math.log(d1 / d2) + (d1 / 2 - 1) * Math.log(x) - ((d1 + d2) / 2) * Math.log1p((d1 * x) / d2) - lnB; + return Math.exp(ln); +} + +// Discrete laws. + +/** ln C(n, k) for whole numbers. */ +export const lnChoose = (n: number, k: number): number => lgamma(n + 1) - lgamma(k + 1) - lgamma(n - k + 1); + +/** C(n, k), exact while the product stays within the digits of a double. */ +export function choose(n: number, k: number): number { + if (k < 0 || k > n) return 0; + const j = Math.min(k, n - k); + let c = 1; + for (let i = 1; i <= j; i++) { + c = (c * (n - j + i)) / i; + if (!Number.isFinite(c)) return Math.exp(lnChoose(n, k)); + } + return Math.round(c) === c || c > 2 ** 53 ? c : Math.round(c); +} + +/** + * ln of the binomial term C(n, k) p^k q^(n - k), with q = 1 - p passed exactly, in the saddle-point form of + * C. Loader ("Fast and accurate computation of binomial probabilities", 2000). Let j = n - k. The log is + * ln(n / (2 pi k j)) / 2 + stirlingRest(n) - stirlingRest(k) - stirlingRest(j) - D(k, n p) - D(j, n q). Here the + * deviance D(x, m) = x ln(x / m) + m - x = -x (ln(1 + w) - w) with w = (m - x) / x. No term is larger than the + * result, so a billion trials keep their digits, where ln C(n, k) from ln Gamma would lose six. + */ +function lnBinomTerm(k: number, n: number, p: number, q: number): number { + if (n === 0) return 0; + if (k === 0) return n * Math.log(q); + if (k === n) return n * Math.log(p); + const j = n - k; + const deviance = (x: number, m: number) => -x * log1pmx((m - x) / x); + return ( + 0.5 * Math.log(n / (2 * Math.PI * k * j)) + + stirlingRest(n) - + stirlingRest(k) - + stirlingRest(j) - + deviance(k, n * p) - + deviance(j, n * q) + ); +} + +export function binomPmf(k: number, n: number, p: number): number { + if (k < 0 || k > n) return 0; + if (p === 0) return k === 0 ? 1 : 0; + if (p === 1) return k === n ? 1 : 0; + const c = choose(n, k); + if (Number.isFinite(c) && c < 2 ** 53) { + // (1 - p) rounded carries an error near 1E-16, which the power multiplies by n - k; log1p keeps it exact. + const v = c * p ** k * Math.exp((n - k) * Math.log1p(-p)); + if (v > 1e-280) return v; + } + return Math.exp(lnBinomTerm(k, n, p, 1 - p)); +} + +/** + * The negative binomial term: x failures before the r-th success. C(x + r - 1, r - 1) = r / (x + r) C(x + r, r), + * so past the exact product it is r / (x + r) times the binomial term of r successes in x + r trials. + */ +export function negbinomPmf(x: number, r: number, p: number): number { + if (p === 1) return x === 0 ? 1 : 0; + if (p === 0) return 0; + const c = choose(x + r - 1, r - 1); + if (Number.isFinite(c) && c < 2 ** 53) { + const v = c * p ** r * Math.exp(x * Math.log1p(-p)); + if (v > 1e-280) return v; + } + return (r / (x + r)) * Math.exp(lnBinomTerm(r, x + r, p, 1 - p)); +} + +/** + * The hypergeometric term: i successes in `draws` from `pop` items of which `succ` are successes. Exact as a ratio + * of whole numbers while every C(n, k) is. Past that, with t = draws / pop, it is the ratio of binomial terms + * b(i; succ, t) b(draws - i; pop - succ, t) / b(draws; pop, t), since the powers of t and 1 - t cancel. + */ +export function hypgeomPmf(i: number, draws: number, succ: number, pop: number): number { + if (i < Math.max(0, draws - pop + succ) || i > Math.min(draws, succ)) return 0; + const parts = [choose(succ, i), choose(pop - succ, draws - i), choose(pop, draws)]; + if (parts.every((c) => Number.isFinite(c) && c < 2 ** 53)) { + const v = (parts[0] * parts[1]) / parts[2]; + if (v > 1e-280) return v; + } + const [t, s] = [draws / pop, (pop - draws) / pop]; + return Math.exp( + lnBinomTerm(i, succ, t, s) + lnBinomTerm(draws - i, pop - succ, t, s) - lnBinomTerm(draws, pop, t, s), + ); +} + +/** P(X <= k) for a binomial law. Small n adds the terms; large n uses the incomplete beta. */ +export function binomCdf(k: number, n: number, p: number): number { + if (k < 0) return 0; + if (k >= n) return 1; + if (n <= 1000) { + let s = 0; + for (let i = 0; i <= k; i++) s += binomPmf(i, n, p); + return Math.min(1, s); + } + return betaIncWide(1 - p, n - k, k + 1, p); +} + +export function poissonPmf(k: number, m: number): number { + if (m === 0) return k === 0 ? 1 : 0; + if (k <= 100) { + let v = Math.exp(-m); + for (let i = 1; i <= k; i++) v *= m / i; + if (v > 1e-280) return v; + } + // m^k e^-m / k! is the gamma power term over k. + if (k >= GAMMA_WIDE) return gammaPower(k, m) / k; + return Math.exp(k * Math.log(m) - m - lgamma(k + 1)); +} + +export const poissonCdf = (k: number, m: number): number => (m === 0 ? 1 : upperGamma(k + 1, m)); + +/** + * P(X <= k) for a hypergeometric law. The terms fall away from the mode on each side. So the sum + * starts at k and runs away from the mode, by the ratio of neighboring terms, until the terms no longer count. Below + * the mode that is the lower tail; at or past it, the result is one less the upper tail past k. The work grows with + * the spread, not the population. + */ +export function hypgeomTail(k: number, draws: number, succ: number, pop: number): number { + const low = Math.max(0, draws - pop + succ); + const high = Math.min(draws, succ); + const mode = Math.floor(((draws + 1) * (succ + 1)) / (pop + 2)); + // up(i) = pmf(i + 1) / pmf(i). + const up = (i: number) => ((succ - i) * (draws - i)) / ((i + 1) * (pop - succ - draws + i + 1)); + let total = 0; + let steps = 0; + if (k < mode) { + let term = hypgeomPmf(k, draws, succ, pop); + for (let i = k; i >= low && term > 1e-17 * total; i--) { + if (++steps > HYPGEOM_TERMS) return NaN; + total += term; + term /= up(i - 1); + } + return Math.min(1, total); + } + if (k >= high) return 1; + let term = hypgeomPmf(k + 1, draws, succ, pop); + for (let i = k + 1; i <= high && term > 1e-17 * total; i++) { + if (++steps > HYPGEOM_TERMS) return NaN; + total += term; + term *= up(i); + } + return Math.max(0, 1 - total); +} + +/** + * No more terms than this in hypgeomTail; past it the sum is NaN, which shows as #NUM!. The tail needs about 40 + * standard deviations of terms, so this serves a spread of about 500,000, a population near 1E12 at an even split. + */ +const HYPGEOM_TERMS = 20_000_000; + +// Inverses. + +/** + * Solves cdf(x) = p for an increasing cdf on (lo, hi), widening `hi` while it is finite and too small. Bisection, + * which needs no derivative and always lands within the digits of a double. NaN as soon as the cdf gives NaN, which + * is how it says its terms ran out: bisecting on would land at the edge of that zone, a wrong number. + */ +export function invert(cdf: (x: number) => number, p: number, lo: number, hi: number): number { + let a = lo; + let b = hi; + for (;;) { + const v = cdf(b); + if (Number.isNaN(v)) return NaN; + if (v >= p || b >= 1e300) break; + a = b; + b *= 2; + } + for (let i = 0; i < 2000; i++) { + const m = a + (b - a) / 2; + if (m === a || m === b) break; + const v = cdf(m); + if (Number.isNaN(v)) return NaN; + if (v < p) a = m; + else b = m; + } + return a + (b - a) / 2; +} + +/** Solves sf(x) = q for a decreasing survival function, for upper-tail inverses with tiny q. */ +export function invertUpper(sf: (x: number) => number, q: number, lo: number, hi: number): number { + return invert((x) => -sf(x), -q, lo, hi); +} diff --git a/app/src/features/tables/engine/formula/fn/forecastEts.ts b/app/src/features/tables/engine/formula/fn/forecastEts.ts new file mode 100644 index 000000000..37bf935be --- /dev/null +++ b/app/src/features/tables/engine/formula/fn/forecastEts.ts @@ -0,0 +1,361 @@ +// FORECAST.ETS and its kin: additive error, additive trend, additive season (AAA) exponential smoothing, the model the +// spreadsheet functions document. Owner: tables:fn-stats. Lazy chunk only. +// +// The timeline must have one step: the smallest gap between its sorted times, with every gap a whole number of steps. +// Repeated times are combined by the aggregation (1 average, 2 count, 3 count of values, 4 max, 5 median, 6 min, +// 7 sum), and missing steps are filled by straight lines between neighbors (completion 1) or with zeros (0). +// +// The season length is the argument, or found from the autocorrelation of the series once its line is removed (1). +// The start values come from a least-squares fit of a line plus a season, so a series that is exactly a line plus a +// season is forecast exactly. The smoothing weights alpha, beta, and gamma, each from 0 to 1, minimize the squared +// one-step errors: a coarse grid, then a few rounds of golden-section search on each weight. + +import { asArray } from '../../arrays'; +import { err, finish, isError, toNumber, type FormulaError, type Value } from '../../values'; +import { normInv } from './distributionsKit'; +import { leastSquares } from './regression'; +import { failed, type Run } from './statsKit'; + +const MAX_SEASON = 8760; +/** The most steps a filled timeline may span; a wider one is #NUM!, so the work and memory stay bounded. */ +export const MAX_SLOTS = 10000; + +interface Series { + readonly y: number[]; + readonly start: number; + readonly step: number; +} + +/** The numbers at one time, and how many items (numbers or not) were there, for aggregation 3. */ +interface Group { + readonly numbers: number[]; + items: number; +} + +function aggregate(group: Group, how: number): number { + const values = group.numbers; + const s = [...values].sort((a, b) => a - b); + const total = values.reduce((a, b) => a + b, 0); + switch (how) { + case 2: + return values.length; + case 3: + return group.items; + case 4: + return s[s.length - 1]; + case 5: + return s.length % 2 ? s[s.length >> 1] : (s[s.length / 2 - 1] + s[s.length / 2]) / 2; + case 6: + return s[0]; + case 7: + return total; + default: + return total / values.length; + } +} + +/** The series on an even timeline, or an error when the timeline has no single step. */ +function seriesOf(values: Value, timeline: Value, completion: number, how: number): Series | FormulaError { + if (isError(values)) return values; + if (isError(timeline)) return timeline; + const v = asArray(values).data; + const t = asArray(timeline).data; + if (v.length !== t.length) return err('NA'); + const byTime = new Map(); + for (let i = 0; i < t.length; i++) { + const [time, value] = [t[i], v[i]]; + if (isError(time)) return time; + if (isError(value)) return value; + if (typeof time !== 'number') return err('VALUE'); + const group = byTime.get(time) ?? { numbers: [], items: 0 }; + if (typeof value === 'number') group.numbers.push(value); + if (value !== null && value !== '') group.items += 1; + byTime.set(time, group); + } + const times = [...byTime.keys()].sort((a, b) => a - b); + if (times.length < 3) return err('NUM'); + let step = Infinity; + for (let i = 1; i < times.length; i++) step = Math.min(step, times[i] - times[i - 1]); + const span = Math.round((times[times.length - 1] - times[0]) / step); + // Check the width before anything is allocated: one slot per step of a wide timeline would freeze the app. + if (!(span < MAX_SLOTS)) return err('NUM'); + if (times.some((x) => Math.abs((x - times[0]) / step - Math.round((x - times[0]) / step)) > 1e-6)) return err('NUM'); + const slots: (number | null)[] = new Array(span + 1).fill(null); + for (const time of times) { + const group = byTime.get(time) as Group; + const counted = how === 3 ? group.items : group.numbers.length; + if (counted > 0) slots[Math.round((time - times[0]) / step)] = aggregate(group, how); + } + return { y: fill(slots, completion), start: times[0], step }; +} + +/** Fills missing slots with zeros or by straight lines between known neighbors, in one pass. */ +export function fill(slots: readonly (number | null)[], completion: number): number[] { + if (completion === 0) return slots.map((x) => x ?? 0); + const out = new Array(slots.length); + let before = -1; + for (let i = 0; i < slots.length; i++) { + const x = slots[i]; + if (x === null) continue; + out[i] = x; + // Fill the gap since the last known slot: flat before the first one, a straight line between two. + const from = before < 0 ? x : (slots[before] as number); + for (let j = before + 1; j < i; j++) out[j] = before < 0 ? x : from + ((x - from) * (j - before)) / (i - before); + before = i; + } + const last = before < 0 ? 0 : (slots[before] as number); + for (let j = before + 1; j < slots.length; j++) out[j] = last; + return out; +} + +/** The season length with the strongest autocorrelation peak of the series less its line, or 1 for none. */ +export function detectSeason(y: number[]): number { + const n = y.length; + const line = leastSquares([y.map((_, i) => i)], y, true); + const r = y.map((v, i) => v - line.intercept - line.coef[0] * i); + const var0 = r.reduce((s, x) => s + x * x, 0); + if (var0 <= 1e-12 * y.reduce((s, x) => s + x * x, 0)) return 1; + const acf = (lag: number) => { + let s = 0; + for (let i = lag; i < n; i++) s += r[i] * r[i - lag]; + return s / var0; + }; + const values = Array.from({ length: Math.min(MAX_SEASON, Math.floor(n / 2)) + 2 }, (_, lag) => + lag < 1 ? 1 : acf(lag), + ); + let best = 1; + let top = 0.2; + for (let lag = 2; lag <= Math.min(MAX_SEASON, Math.floor(n / 2)); lag++) { + const peak = values[lag] >= values[lag - 1] && values[lag] >= (values[lag + 1] ?? -Infinity); + if (peak && values[lag] > top + 1e-9) { + best = lag; + top = values[lag]; + } + } + return best; +} + +interface Model { + readonly alpha: number; + readonly beta: number; + readonly gamma: number; + readonly m: number; + readonly level: number; + readonly trend: number; + readonly season: number[]; + /** One-step predictions, one per observation. */ + readonly fitted: number[]; + readonly sse: number; +} + +interface Start { + readonly level: number; + readonly trend: number; + readonly season: readonly number[]; +} + +/** + * Start values from the least-squares fit of a line plus one intercept per season position. The slope is the + * within-position estimate: the sum of (i - mean i of its position)(y - mean y of its position) over the sum of the + * squares. Each position's intercept is its mean y less the slope times its mean i. This is O(n), where a fit with + * m - 1 dummy columns is O(n m^2). + */ +export function startOf(y: readonly number[], m: number): Start { + const k = Math.max(1, m); + const count = new Array(k).fill(0); + const sumI = new Array(k).fill(0); + const sumY = new Array(k).fill(0); + y.forEach((v, i) => { + count[i % k] += 1; + sumI[i % k] += i; + sumY[i % k] += v; + }); + let sxy = 0; + let sxx = 0; + y.forEach((v, i) => { + const p = i % k; + const di = i - sumI[p] / count[p]; + sxy += di * (v - sumY[p] / count[p]); + sxx += di * di; + }); + const trend = sxx > 0 ? sxy / sxx : 0; + const intercepts = count.map((c, p) => (c > 0 ? (sumY[p] - trend * sumI[p]) / c : 0)); + const shift = intercepts.reduce((a, b) => a + b, 0) / k; + return { level: shift - trend, trend, season: intercepts.map((g) => g - shift) }; +} + +function run(y: number[], m: number, alpha: number, beta: number, gamma: number, st: Start): Model { + let { level, trend } = st; + const season = m > 1 ? [...st.season] : [0]; + const fitted: number[] = []; + let sse = 0; + for (let i = 0; i < y.length; i++) { + const p = m > 1 ? i % m : 0; + const prediction = level + trend + season[p]; + fitted.push(prediction); + sse += (y[i] - prediction) ** 2; + const before = level; + level = alpha * (y[i] - season[p]) + (1 - alpha) * (level + trend); + trend = beta * (level - before) + (1 - beta) * trend; + if (m > 1) season[p] = gamma * (y[i] - level) + (1 - gamma) * season[p]; + } + return { alpha, beta, gamma, m, level, trend, season, fitted, sse }; +} + +const PHI = (Math.sqrt(5) - 1) / 2; + +/** Minimizes f on [0, 1] by golden-section search around a start. */ +function golden(f: (x: number) => number, start: number): number { + let [a, b] = [Math.max(0, start - 0.1), Math.min(1, start + 0.1)]; + for (let i = 0; i < 30; i++) { + const c = b - PHI * (b - a); + const d = a + PHI * (b - a); + if (f(c) <= f(d)) b = d; + else a = c; + } + return (a + b) / 2; +} + +export function fitModel(y: number[], m: number): Model { + const grid = [0.01, 0.1, 0.3, 0.5, 0.7, 0.9, 0.99]; + const gammas = m > 1 ? grid : [0]; + // The start values do not depend on the weights, so they are fitted once. + const st = startOf(y, m); + let best = run(y, m, 0.5, 0.1, 0, st); + for (const a of grid) { + for (const b of grid) { + for (const g of gammas) { + const model = run(y, m, a, b, g, st); + if (model.sse < best.sse) best = model; + } + } + } + let { alpha, beta, gamma } = best; + for (let round = 0; round < 3; round++) { + alpha = golden((x) => run(y, m, x, beta, gamma, st).sse, alpha); + beta = golden((x) => run(y, m, alpha, x, gamma, st).sse, beta); + if (m > 1) gamma = golden((x) => run(y, m, alpha, beta, x, st).sse, gamma); + } + const refined = run(y, m, alpha, beta, gamma, st); + return refined.sse <= best.sse ? refined : best; +} + +/** The forecast h steps past the last observation. */ +function forecastAt(model: Model, n: number, h: number): number { + const p = model.m > 1 ? (((n - 1 + Math.round(h)) % model.m) + model.m) % model.m : 0; + return model.level + h * model.trend + model.season[p]; +} + +interface Options { + readonly series: Series; + readonly model: Model; +} + +/** Reads values, timeline, and the optional season, completion, and aggregation arguments. */ +function prepare(values: Value, timeline: Value, extra: (Value | undefined)[]): Options | FormulaError { + const nums: number[] = []; + for (const [i, fallback] of [1, 1, 1].entries()) { + const v = extra[i]; + if (v === undefined || v === null) { + nums.push(fallback); + continue; + } + const n = toNumber(v); + if (typeof n !== 'number') return n; + nums.push(Math.trunc(n)); + } + const [seasonArg, completion, how] = nums; + if (seasonArg < 0 || seasonArg > MAX_SEASON || completion < 0 || completion > 1 || how < 1 || how > 7) { + return err('NUM'); + } + const series = seriesOf(values, timeline, completion, how); + if (failed(series)) return series; + const m = seasonArg === 1 ? detectSeason(series.y) : Math.max(1, seasonArg); + if (series.y.length < 2 * m || series.y.length < 3) return err('NUM'); + return { series, model: fitModel(series.y, m) }; +} + +function stepsTo(target: Value, s: Series): number | FormulaError { + const t = toNumber(target); + if (typeof t !== 'number') return t; + const last = s.start + (s.y.length - 1) * s.step; + if (t < last) return err('NUM'); + return (t - last) / s.step; +} + +const forecast: Run = ([target, values, timeline, ...rest]) => { + const o = prepare(values, timeline, rest); + if (failed(o)) return o; + const h = stepsTo(target, o.series); + return isError(h) ? h : finish(forecastAt(o.model, o.series.y.length, h)); +}; + +const confint: Run = ([target, values, timeline, levelV, ...rest]) => { + const o = prepare(values, timeline, rest); + if (failed(o)) return o; + const h = stepsTo(target, o.series); + if (isError(h)) return h; + const level = levelV === undefined || levelV === null ? 0.95 : toNumber(levelV); + if (typeof level !== 'number') return level; + if (level <= 0 || level >= 1) return err('NUM'); + const { alpha, beta, gamma, m, sse } = o.model; + const sigma2 = sse / o.series.y.length; + return finish(normInv((1 + level) / 2) * Math.sqrt(sigma2 * varianceFactor(alpha, beta, gamma, m, h))); +}; + +/** + * The variance factor of an h-step forecast for the AAA model: 1 + the sum of c_j^2 for 1 <= j < round(h). Here + * c_j = A + B j, plus G on whole seasons, with A = alpha, B = alpha beta, and G = gamma (1 - alpha). The sum has a + * closed form, so a far target costs no more than a near one. + */ +export function varianceFactor(alpha: number, beta: number, gamma: number, m: number, h: number): number { + const n = Math.max(1, Math.round(h)) - 1; + const [a, b, g] = [alpha, alpha * beta, gamma * (1 - alpha)]; + const s1 = (n * (n + 1)) / 2; + const s2 = (n * (n + 1) * (2 * n + 1)) / 6; + let factor = 1 + n * a * a + 2 * a * b * s1 + b * b * s2; + if (m > 1) { + // On j = m q for q = 1..Q, each c_j^2 gains 2 G (A + B m q) + G^2. + const q = Math.floor(n / m); + factor += q * (2 * g * a + g * g) + g * b * m * q * (q + 1); + } + return factor; +} + +const seasonality: Run = ([values, timeline, completion, how]) => { + const o = prepare(values, timeline, [1, completion, how]); + return failed(o) ? o : o.model.m; +}; + +function errors(o: Options): { mae: number; rmse: number; smape: number; mase: number } { + const { y } = o.series; + const e = y.map((v, i) => v - o.model.fitted[i]); + const n = y.length; + const mae = e.reduce((s, x) => s + Math.abs(x), 0) / n; + const rmse = Math.sqrt(e.reduce((s, x) => s + x * x, 0) / n); + const smape = + y.reduce((s, v, i) => { + const d = Math.abs(v) + Math.abs(o.model.fitted[i]); + return s + (d === 0 ? 0 : (2 * Math.abs(e[i])) / d); + }, 0) / n; + const naive = y.slice(1).reduce((s, v, i) => s + Math.abs(v - y[i]), 0) / (n - 1); + return { mae, rmse, smape, mase: naive === 0 ? NaN : mae / naive }; +} + +const stat: Run = ([values, timeline, typeV, ...rest]) => { + const type = toNumber(typeV ?? null); + if (typeof type !== 'number') return type; + const o = prepare(values, timeline, rest); + if (failed(o)) return o; + const k = Math.trunc(type); + const e = errors(o); + const pick = [o.model.alpha, o.model.beta, o.model.gamma, e.mase, e.smape, e.mae, e.rmse, o.series.step][k - 1]; + return pick === undefined ? err('NUM') : finish(pick); +}; + +export const runs: Readonly> = { + 'FORECAST.ETS': forecast, + 'FORECAST.ETS.CONFINT': confint, + 'FORECAST.ETS.SEASONALITY': seasonality, + 'FORECAST.ETS.STAT': stat, +}; diff --git a/app/src/features/tables/engine/formula/fn/index.test.ts b/app/src/features/tables/engine/formula/fn/index.test.ts index bb44906e6..ee833b9b3 100644 --- a/app/src/features/tables/engine/formula/fn/index.test.ts +++ b/app/src/features/tables/engine/formula/fn/index.test.ts @@ -38,20 +38,27 @@ const BY_FAMILY: Partial> = { datetime: ['DATE', 'DAY', 'DAYS', 'MONTH', 'TODAY', 'YEAR'], }; +/** The functions the compiler evaluates itself, because an argument is lazy. */ +const LAZY = new Set(['IF', 'IFERROR', 'LET', 'LAMBDA', 'ISOMITTED']); + describe('the function registry', () => { it('holds the 48 names of before, each in its family', () => { - // Family lanes add functions, so each family holds at least the names it always had. expect(REGISTRY.size).toBeGreaterThanOrEqual(48); expect([...REGISTRY.values()].filter((spec) => spec.family === 'datetime')).toHaveLength(26); for (const [family, names] of Object.entries(BY_FAMILY)) { - const found = [...REGISTRY.values()].filter((spec) => spec.family === family).map((spec) => spec.name); - expect(found.sort(), family).toEqual(expect.arrayContaining(names)); + const found = new Set([...REGISTRY.values()].filter((spec) => spec.family === family).map((spec) => spec.name)); + // Family lanes add names; every name of before stays, in its family. + expect( + names.filter((name) => !found.has(name)), + family, + ).toEqual([]); } }); it('keeps the argument counts and the kinds the compiler knew', () => { expect(Object.keys(FUNCTIONS).length).toBeGreaterThanOrEqual(46); - expect(SPECIAL_FORMS).toEqual({ IF: { min: 2, max: 3 }, IFERROR: { min: 2, max: 2 } }); + expect(SPECIAL_FORMS).toMatchObject({ IF: { min: 2, max: 3 }, IFERROR: { min: 2, max: 2 } }); + expect(Object.keys(SPECIAL_FORMS).sort()).toEqual([...LAZY].sort()); expect(FUNCTIONS.SUM).toMatchObject({ kind: 'aggregate', min: 1, max: Infinity }); expect(FUNCTIONS.COUNTA).toMatchObject({ kind: 'aggregate', raw: true }); expect(FUNCTIONS.ROUND).toMatchObject({ kind: 'scalar', min: 1, max: 2 }); @@ -66,7 +73,7 @@ describe('the function registry', () => { for (const spec of REGISTRY.values()) { expect(spec.meta.signature.startsWith(`${spec.name}(`), spec.name).toBe(true); expect(spec.meta.example, spec.name).toMatch(/^=/); - expect(isSpecial(spec)).toBe(spec.name === 'IF' || spec.name === 'IFERROR'); + expect(isSpecial(spec)).toBe(LAZY.has(spec.name)); } }); diff --git a/app/src/features/tables/engine/formula/fn/index.ts b/app/src/features/tables/engine/formula/fn/index.ts index 26ab396e3..d12e0ee3c 100644 --- a/app/src/features/tables/engine/formula/fn/index.ts +++ b/app/src/features/tables/engine/formula/fn/index.ts @@ -17,7 +17,7 @@ import { functions as statsCompat } from './statsCompat'; import { functions as text } from './text'; import type { FnSpec2 } from './types'; -export type { Env, Family, FnMeta, FnSpec2, Param, ParamGroup, ParamKind } from './types'; +export type { Env, Family, FnMeta, FnSpec2, Param, ParamGroup, ParamKind, Probe, ProbeCell } from './types'; export { arity, isGroup, isSpecial, paramsOf } from './types'; const FAMILIES: readonly (readonly FnSpec2[])[] = [ diff --git a/app/src/features/tables/engine/formula/fn/info.ts b/app/src/features/tables/engine/formula/fn/info.ts index 0dbac0d00..108b434e1 100644 --- a/app/src/features/tables/engine/formula/fn/info.ts +++ b/app/src/features/tables/engine/formula/fn/info.ts @@ -1,7 +1,175 @@ -// Information functions. Owner: tables:fn-logic-info. ISBLANK is the function tables had before the library; its -// behavior is unchanged. +// Information functions. Owner: tables:fn-logic-info. Functions that ask about a cell (CELL, ISFORMULA, ISREF, +// SHEET, SHEETS) set `refArgs`: they get the reference itself and a probe of the page (fn/types.ts). INFO gives only +// answers that are safe offline: it never reveals a folder, the system's memory, or its version. ISOMITTED is an +// engine form (formula/special.ts); its spec here gives it a name and an example. -import type { FnSpec2 } from './types'; +import { lettersFromColumn } from '../../../../../core/expr'; +import { parseDate } from '../../dates'; +import { + arrayOf, + err, + isArray, + isError, + isRef, + toNumber, + type ErrorCode, + type FormulaError, + type RefValue, + type Scalar, + type Value, +} from '../../values'; +import type { Env, FnSpec2, Probe } from './types'; + +type Test = (v: Scalar) => boolean; + +function is(name: string, test: Test, example: string): FnSpec2 { + return { + name, + family: 'info', + params: [{ name: 'value', kind: 'value' }], + handlesErrors: true, + run: ([v]) => test(v as Scalar), + meta: { signature: `${name}(value)`, example }, + }; +} + +/** The codes ERROR.TYPE gives. #CYCLE! has no code of its own, so it reads as #N/A's 7, the documented fallback. */ +const ERROR_TYPES: Record = { + NULL: 1, + DIV0: 2, + VALUE: 3, + REF: 4, + NAME: 5, + NUM: 6, + NA: 7, + BUSY: 8, + SPILL: 9, + CALC: 14, + FIELD: 13, + CYCLE: 7, +}; + +function parity(name: string, odd: boolean, example: string): FnSpec2 { + return { + name, + family: 'info', + params: [{ name: 'number', kind: 'value' }], + run: ([v]) => { + if (typeof v === 'boolean') return err('VALUE'); + const n = toNumber(v); + if (typeof n !== 'number' || !Number.isFinite(n)) return err('VALUE'); + return (Math.trunc(n) % 2 !== 0) === odd; + }, + meta: { signature: `${name}(number)`, example }, + }; +} + +const isText: Test = (v) => typeof v === 'string'; + +/** An email address has one at sign, a dotted domain, and no spaces. */ +const EMAIL = /^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$/; +const URL_PATTERN = + /^(?:(?:https?|ftps?|sftp):\/\/)?(?:[^\s/?#@]+@)?(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:[/?#]\S*)?$/i; + +function address(ref: RefValue): string { + const cell = `$${lettersFromColumn(ref.c1)}$${ref.r1 + 1}`; + if (ref.table === undefined) return cell; + const name = ref.table; + return `${/^\w+$/.test(name) ? name : `'${name.replace(/'/g, "''")}'`}!${cell}`; +} + +/** The format code CELL("format") gives for a column type: G general, F fixed, C currency, P percent, D date. */ +function formatCode(type: string, decimals: number | undefined): string { + const digits = Math.min(decimals ?? 2, 9); + if (type === 'currency') return `C${digits}`; + if (type === 'percent') return `P${digits}`; + // The column shows the region's short date, which CELL reports as D4. + if (type === 'date') return 'D4'; + return type === 'number' && decimals !== undefined ? `F${digits}` : 'G'; +} + +/** The default column width in characters, rounded as CELL("width") rounds it. */ +const COLUMN_WIDTH = 8; + +const first = (v: Value): Value => (isArray(v) ? (v.data[0] ?? null) : v); + +function cellInfo(args: Value[], env: Env): Value { + const probe = env.probe; + const kind = first(args[0]); + if (isError(kind)) return kind; + const given = args[1]; + if (given !== undefined && isError(given)) return given; + if (probe === undefined) return err('VALUE'); + const ref = given === undefined ? probe.self : isRef(given) ? given : undefined; + if (ref === undefined) return err('VALUE'); + const cell = probe.cell(ref); + if (cell === undefined) return err('REF'); + switch (typeof kind === 'string' ? kind.trim().toLowerCase() : '') { + case 'address': + return address(ref); + case 'col': + return ref.c1 + 1; + case 'row': + return ref.r1 + 1; + case 'contents': + return cell.value; + case 'type': + return cell.value === null ? 'b' : typeof cell.value === 'string' ? 'l' : 'v'; + case 'format': + return formatCode(cell.type, cell.decimals); + case 'width': + return COLUMN_WIDTH; + case 'filename': + return env.title ?? ''; + case 'prefix': + return typeof cell.value === 'string' ? "'" : ''; + case 'color': + case 'parentheses': + case 'protect': + return 0; + default: + return err('VALUE'); + } +} + +function infoText(args: Value[], env: Env): Value { + const key = first(args[0]); + if (isError(key)) return key; + const probe = env.probe; + switch (typeof key === 'string' ? key.trim().toLowerCase() : '') { + case 'numfile': + return probe?.sheets.length ?? 1; + case 'origin': + return '$A:$A$1'; + case 'recalc': + return probe?.calc === 'manual' ? 'Manual' : 'Automatic'; + case 'system': + return 'pcdos'; + case 'directory': + case 'memavail': + case 'memused': + case 'totmem': + case 'osversion': + case 'release': + return err('NA'); + default: + return err('VALUE'); + } +} + +/** The table that the argument of SHEET names: a reference, or the text of a table's name. */ +function sheetIndex(arg: Value, probe: Probe): number | FormulaError { + if (isError(arg)) return arg; + if (isRef(arg)) { + const s = probe.sheetOf(arg); + return s < 0 ? err('REF') : s; + } + const text = first(arg); + if (typeof text !== 'string') return err('NA'); + const want = text.trim().toLowerCase(); + const found = probe.sheets.findIndex((name) => name.toLowerCase() === want); + return found < 0 ? err('NA') : found; +} export const functions: readonly FnSpec2[] = [ { @@ -12,4 +180,195 @@ export const functions: readonly FnSpec2[] = [ run: ([v]) => v === null, meta: { signature: 'ISBLANK(value)', example: '=ISBLANK("") gives FALSE' }, }, + is('ISNUMBER', (v) => typeof v === 'number', '=ISNUMBER("1") gives FALSE'), + is('ISTEXT', isText, '=ISTEXT("a") gives TRUE'), + is('ISNONTEXT', (v) => !isText(v), '=ISNONTEXT(1) gives TRUE'), + is('ISLOGICAL', (v) => typeof v === 'boolean', '=ISLOGICAL(1>2) gives TRUE'), + is('ISERROR', (v) => isError(v), '=ISERROR(1/0) gives TRUE'), + is('ISERR', (v) => isError(v) && v.error !== 'NA', '=ISERR(NA()) gives FALSE'), + is('ISNA', (v) => isError(v) && v.error === 'NA', '=ISNA(NA()) gives TRUE'), + parity('ISEVEN', false, '=ISEVEN(-2) gives TRUE'), + parity('ISODD', true, '=ISODD(2.9) gives FALSE'), + { + name: 'N', + family: 'info', + params: [{ name: 'value', kind: 'value' }], + run: ([v]) => (typeof v === 'number' ? v : v === true ? 1 : 0), + meta: { signature: 'N(value)', example: '=N(TRUE) gives 1' }, + }, + { + name: 'NA', + family: 'info', + params: [], + run: () => err('NA'), + meta: { signature: 'NA()', example: '=NA() gives #N/A' }, + }, + { + name: 'TYPE', + family: 'info', + params: [{ name: 'value', kind: 'array' }], + handlesErrors: true, + run: ([v]) => { + if (isError(v)) return 16; + const items = isArray(v) ? v.data : [v as Scalar]; + if (items.length !== 1) return 64; + const one = items[0]; + if (isError(one)) return 16; + return typeof one === 'string' ? 2 : typeof one === 'boolean' ? 4 : 1; + }, + meta: { signature: 'TYPE(value)', example: '=TYPE("a") gives 2' }, + }, + { + name: 'ERROR.TYPE', + family: 'info', + params: [{ name: 'error', kind: 'value' }], + handlesErrors: true, + run: ([v]) => (isError(v) ? ERROR_TYPES[v.error] : err('NA')), + meta: { signature: 'ERROR.TYPE(error)', example: '=ERROR.TYPE(1/0) gives 2' }, + }, + { + name: 'ISREF', + family: 'info', + params: [{ name: 'value', kind: 'array' }], + refArgs: true, + handlesErrors: true, + run: ([v]) => isRef(v), + meta: { signature: 'ISREF(value)', example: '=ISREF(B2) gives TRUE' }, + }, + { + name: 'ISFORMULA', + family: 'info', + params: [{ name: 'reference', kind: 'array' }], + refArgs: true, + run: ([v], env) => { + if (!isRef(v)) return err('VALUE'); + const probe = env.probe; + if (v.r1 === v.r2 && v.c1 === v.c2) { + const cell = probe?.cell(v); + return cell === undefined ? err('REF') : cell.formula; + } + // A block spills one answer per cell. + const rows: Scalar[][] = []; + for (let r = v.r1; r <= v.r2; r++) { + const row: Scalar[] = []; + for (let c = v.c1; c <= v.c2; c++) { + const cell = probe?.cell({ ...v, r1: r, r2: r, c1: c, c2: c }); + row.push(cell === undefined ? err('REF') : cell.formula); + } + rows.push(row); + } + return arrayOf(rows); + }, + meta: { signature: 'ISFORMULA(reference)', example: '=ISFORMULA(B2) gives TRUE when B2 has a formula' }, + }, + { + name: 'ISOMITTED', + family: 'info', + params: [{ name: 'argument', kind: 'lazy' }], + run: () => err('VALUE'), + meta: { signature: 'ISOMITTED(argument)', example: '=LAMBDA(x, [y], ISOMITTED(y))(1) gives TRUE' }, + }, + { + name: 'SHEET', + family: 'info', + params: [{ name: 'value', kind: 'array', optional: true }], + refArgs: true, + handlesErrors: true, + run: (args, env) => { + const probe = env.probe; + if (probe === undefined) return err('VALUE'); + if (args.length === 0) return probe.sheet + 1; + const s = sheetIndex(args[0], probe); + return typeof s === 'number' ? s + 1 : s; + }, + meta: { signature: 'SHEET([value])', example: '=SHEET() gives 1 on the first table' }, + }, + { + name: 'SHEETS', + family: 'info', + params: [{ name: 'reference', kind: 'array', optional: true }], + refArgs: true, + run: (args, env) => { + const probe = env.probe; + if (probe === undefined) return err('VALUE'); + if (args.length === 0) return probe.sheets.length; + const ref = args[0]; + if (!isRef(ref)) return err('VALUE'); + return probe.sheetOf(ref) < 0 ? err('REF') : 1; + }, + meta: { signature: 'SHEETS([reference])', example: '=SHEETS() gives the number of tables on the page' }, + }, + { + name: 'CELL', + family: 'info', + params: [ + { name: 'info_type', kind: 'value' }, + { name: 'reference', kind: 'array', optional: true }, + ], + refArgs: true, + volatile: true, + run: cellInfo, + meta: { signature: 'CELL(info_type, [reference])', example: '=CELL("address", B2) gives $B$2' }, + }, + { + name: 'INFO', + family: 'info', + params: [{ name: 'type_text', kind: 'value' }], + refArgs: true, + volatile: true, + run: infoText, + meta: { signature: 'INFO(type_text)', example: '=INFO("recalc") gives Automatic' }, + }, + { + name: 'ISEMAIL', + family: 'info', + params: [{ name: 'value', kind: 'value' }], + handlesErrors: true, + run: ([v]) => typeof v === 'string' && EMAIL.test(v.trim()), + meta: { signature: 'ISEMAIL(value)', example: '=ISEMAIL("a@b.org") gives TRUE' }, + }, + { + name: 'ISURL', + family: 'info', + params: [{ name: 'value', kind: 'value' }], + handlesErrors: true, + run: ([v]) => typeof v === 'string' && URL_PATTERN.test(v.trim()), + meta: { signature: 'ISURL(value)', example: '=ISURL("https://example.org") gives TRUE' }, + }, + { + name: 'ISDATE', + family: 'info', + params: [{ name: 'value', kind: 'array' }], + refArgs: true, + handlesErrors: true, + run: ([v], env) => { + const isDate = (x: unknown): boolean => { + if (typeof x !== 'string') return false; + if (parseDate(x, 'mdy') !== null) return true; + // Year first with a day over 12 in the middle, as in "1969-20-07" (the ISDATE reference page). + const m = /^\s*(\d{4})([-/.])(\d{1,2})\2(\d{1,2})\s*$/.exec(x); + if (!m || Number(m[3]) <= 12) return false; + return parseDate(`${m[1]}-${m[4]}-${m[3]}`, 'mdy') !== null; + }; + if (isRef(v)) { + // A cell in a date column holds a date value, so it counts. Other cells count when their text reads as a date. + const one = (r: number, c: number): boolean => { + const cell = env.probe?.cell({ ...v, r1: r, r2: r, c1: c, c2: c }); + if (cell === undefined || cell.value === null) return false; + return (cell.type === 'date' && typeof cell.value === 'number') || isDate(cell.value); + }; + if (v.r1 === v.r2 && v.c1 === v.c2) return one(v.r1, v.c1); + const rows: Scalar[][] = []; + for (let r = v.r1; r <= v.r2; r++) { + const row: Scalar[] = []; + for (let c = v.c1; c <= v.c2; c++) row.push(one(r, c)); + rows.push(row); + } + return arrayOf(rows); + } + if (isArray(v)) return v.data.length === 1 ? isDate(v.data[0]) : { ...v, data: v.data.map((x) => isDate(x)) }; + return isDate(v as Scalar); + }, + meta: { signature: 'ISDATE(value)', example: '=ISDATE("2026-01-31") gives TRUE' }, + }, ]; diff --git a/app/src/features/tables/engine/formula/fn/lambda.ts b/app/src/features/tables/engine/formula/fn/lambda.ts new file mode 100644 index 000000000..aee801d65 --- /dev/null +++ b/app/src/features/tables/engine/formula/fn/lambda.ts @@ -0,0 +1,172 @@ +// The LAMBDA helpers: MAP, REDUCE, SCAN, BYROW, BYCOL, and MAKEARRAY. Owner: tables:fn-arrays. Each calls a +// function value made by LAMBDA (formula/special.ts) once per item, row, column, or cell, as documented for Excel. +// A function value that is missing or takes the wrong number of arguments gives #VALUE!. Where each call must give +// one value (MAP, SCAN, BYROW, BYCOL, MAKEARRAY), a call that gives a larger array gives #CALC!, since arrays do +// not nest. REDUCE alone may carry an array from call to call and return it. + +import { itemAt, makeArray } from '../../arrays'; +import { + err, + isArray, + isLambda, + type ArrayValue, + type FormulaError, + type LambdaValue, + type Scalar, + type Value, +} from '../../values'; +import { arrayArg, failed, at, build, fits, intArg, tooMany } from './matrix'; +import type { FnSpec2 } from './types'; + +/** The function value an argument holds, or the error to give. */ +function lambdaArg(value: Value | undefined): LambdaValue | FormulaError { + if (value !== undefined && isLambda(value)) return value; + return value !== undefined && failed(value) ? value : err('VALUE'); +} + +/** One call's result as a single value: a one-by-one array is its item, a larger one #CALC!. */ +function one(value: Value): Scalar { + if (isArray(value)) return value.rows === 1 && value.cols === 1 ? value.data[0] : err('CALC'); + return isLambda(value) ? err('CALC') : (value as Scalar); +} + +/** A slice of an array: row `r` (a 1 by n array) or column `c` (an n by 1 array). */ +function rowOf(a: ArrayValue, r: number): ArrayValue { + return makeArray(1, a.cols, a.data.slice(r * a.cols, (r + 1) * a.cols)); +} +function colOf(a: ArrayValue, c: number): ArrayValue { + return makeArray( + a.rows, + 1, + Array.from({ length: a.rows }, (_, r) => at(a, r, c)), + ); +} + +/** A value an accumulator starts from: a one-by-one array stands for its item. */ +function start(value: Value | undefined): Value { + if (value === undefined) return null; + return isArray(value) && value.rows === 1 && value.cols === 1 ? value.data[0] : value; +} + +function map(args: Value[]): Value { + const fn = lambdaArg(args[args.length - 1]); + if (failed(fn)) return fn; + const arrays: ArrayValue[] = []; + for (const v of args.slice(0, -1)) { + const a = arrayArg(v); + if (failed(a)) return a; + arrays.push(a); + } + const rows = Math.max(...arrays.map((a) => a.rows)); + const cols = Math.max(...arrays.map((a) => a.cols)); + return build(rows, cols, (r, c) => one(fn.call(arrays.map((a) => itemAt(a, r, c))))); +} + +function reduce([init, array, f]: Value[]): Value { + const fn = lambdaArg(f); + if (failed(fn)) return fn; + const a = arrayArg(array); + if (failed(a)) return a; + let acc = start(init); + for (const item of a.data) { + acc = fn.call([acc, item]); + if (failed(acc)) return acc; + } + return acc; +} + +function scan([init, array, f]: Value[]): Value { + const fn = lambdaArg(f); + if (failed(fn)) return fn; + const a = arrayArg(array); + if (failed(a)) return a; + let acc: Value = start(init); + const data = a.data.map((item) => { + acc = one(fn.call([acc, item])); + return acc; + }); + return makeArray(a.rows, a.cols, data); +} + +function byRow([array, f]: Value[]): Value { + const fn = lambdaArg(f); + if (failed(fn)) return fn; + const a = arrayArg(array); + if (failed(a)) return a; + return makeArray( + a.rows, + 1, + Array.from({ length: a.rows }, (_, r) => one(fn.call([rowOf(a, r)]))), + ); +} + +function byCol([array, f]: Value[]): Value { + const fn = lambdaArg(f); + if (failed(fn)) return fn; + const a = arrayArg(array); + if (failed(a)) return a; + return makeArray( + 1, + a.cols, + Array.from({ length: a.cols }, (_, c) => one(fn.call([colOf(a, c)]))), + ); +} + +function makeArrayFn([rows, cols, f]: Value[]): Value { + const r = intArg(rows, 1); + if (typeof r !== 'number') return r; + const c = intArg(cols, 1); + if (typeof c !== 'number') return c; + if (r < 1 || c < 1) return err('VALUE'); + if (!fits(r, c)) return tooMany(); + const fn = lambdaArg(f); + if (failed(fn)) return fn; + return build(r, c, (i, j) => one(fn.call([i + 1, j + 1]))); +} + +const lambda = { name: 'lambda', kind: 'lambda' } as const; +const array = { name: 'array', kind: 'array' } as const; +const initial = { name: 'initial_value', kind: 'array' } as const; + +function spec( + name: string, + params: FnSpec2['params'], + run: FnSpec2['run'], + signature: string, + example: string, +): FnSpec2 { + return { name, family: 'logical', params, run, meta: { signature, example } }; +} + +export const functions: readonly FnSpec2[] = [ + spec( + 'MAP', + [{ repeat: [array], min: 1 }, lambda], + map, + 'MAP(array, ..., lambda)', + '=MAP({1,2,3}, LAMBDA(x, x^2)) gives {1,4,9}', + ), + spec( + 'REDUCE', + [initial, array, lambda], + reduce, + 'REDUCE([initial_value], array, lambda)', + '=REDUCE(1, {2,3,4}, LAMBDA(a, b, a*b)) gives 24', + ), + spec( + 'SCAN', + [initial, array, lambda], + scan, + 'SCAN([initial_value], array, lambda)', + '=SCAN(1, {2,3,4}, LAMBDA(a, b, a*b)) gives {2,6,24}', + ), + spec('BYROW', [array, lambda], byRow, 'BYROW(array, lambda)', '=BYROW({1,5;2,4}, LAMBDA(r, MAX(r))) gives {5;4}'), + spec('BYCOL', [array, lambda], byCol, 'BYCOL(array, lambda)', '=BYCOL({1,5;2,4}, LAMBDA(c, MIN(c))) gives {1,4}'), + spec( + 'MAKEARRAY', + [{ name: 'rows', kind: 'number' }, { name: 'columns', kind: 'number' }, lambda], + makeArrayFn, + 'MAKEARRAY(rows, columns, lambda)', + '=MAKEARRAY(2, 3, LAMBDA(r, c, r+c)) gives {2,3,4;3,4,5}', + ), +]; diff --git a/app/src/features/tables/engine/formula/fn/lazy/statistics.ts b/app/src/features/tables/engine/formula/fn/lazy/statistics.ts new file mode 100644 index 000000000..9467982f2 --- /dev/null +++ b/app/src/features/tables/engine/formula/fn/lazy/statistics.ts @@ -0,0 +1,11 @@ +// The lazy chunk of the statistical families (DESIGN.md, bundle note). Owner: tables:fn-stats. It holds the +// functions past the basics, the distributions and their compatibility names, and the regression and forecasting +// functions. statsKit.ts loads it the first time a formula calls one of them; the budget check limits it by this path. + +import { runs as distributions } from '../distributions'; +import { runs as ets } from '../forecastEts'; +import { runs as regression } from '../regression'; +import { runs as more } from '../statsMore'; +import type { Run } from '../statsKit'; + +export const runs: Readonly> = { ...more, ...distributions, ...regression, ...ets }; diff --git a/app/src/features/tables/engine/formula/fn/logicInfo.test.ts b/app/src/features/tables/engine/formula/fn/logicInfo.test.ts new file mode 100644 index 000000000..0dd5f6af8 --- /dev/null +++ b/app/src/features/tables/engine/formula/fn/logicInfo.test.ts @@ -0,0 +1,289 @@ +// Logical and information functions. Expected values come from fixtures/fn/logicInfo.json: documented examples and +// hand-worked values, never the code's own output. The lane tests at the top repeat the contract's examples. + +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { createTable, setCellInput } from '../../edit'; +import { EN_US } from '../../locale'; +import type { Table } from '../../model'; +import { DEFAULT_ENV, recalculate, type EngineEnv } from '../../recalc'; +import { ERROR_TOKENS, err, type ErrorCode, type Value } from '../../values'; +import { recalculateBook, setBookCellInput } from '../../workbook'; +import { fnArrays, type FnText } from '../../../../../strings/en/fnArrays'; +import { fnLogicInfo } from '../../../../../strings/en/fnLogicInfo'; +import { fnKey } from '../../../../../strings/fnKey'; +import { REGISTRY, paramsOf } from '.'; + +interface Fixture { + data: string[][]; + cases: { formula: string; expect: Value | { error: string }; source: string }[]; +} +const FIXTURE = JSON.parse(readFileSync(join(__dirname, '../../../fixtures/fn/logicInfo.json'), 'utf8')) as Fixture; + +const LETTERS = ['A', 'B', 'C', 'D']; + +function sheet(data: string[][]): Table { + const columns = LETTERS.map((name) => ({ name, type: 'number' as const })); + return createTable(columns, data, EN_US, DEFAULT_ENV, 1); +} + +/** Types a formula in D3 of the fixture's sheet and reads the result. */ +function run(formula: string): Value { + const t = setCellInput(sheet(FIXTURE.data), { row: 2, col: 3, raw: formula }, EN_US, DEFAULT_ENV); + return t.rows[2].cells[3].value; +} + +function expected(value: unknown): Value { + if (typeof value !== 'object' || value === null || !('error' in value)) return value as Value; + const token = (value as { error: string }).error; + const code = (Object.keys(ERROR_TOKENS) as ErrorCode[]).find((c) => c !== 'CYCLE' && ERROR_TOKENS[c] === token); + if (code === undefined) throw new Error(`Unknown error token ${token}`); + return err(code); +} + +describe('the contract examples (lane test)', () => { + it('reads logical functions', () => { + expect(run('=IFS(FALSE,1,TRUE,2)')).toBe(2); + expect(run('=IFS(FALSE,1)')).toEqual(err('NA')); + expect(run('=SWITCH(2,1,"a",2,"b","z")')).toBe('b'); + expect(run('=XOR(TRUE,TRUE,TRUE)')).toBe(true); + expect(run('=IFNA(NA(),"x")')).toBe('x'); + }); + + it('reads information functions', () => { + expect(run('=ISNUMBER("1")')).toBe(false); + expect(run('=N(TRUE)')).toBe(1); + expect(run('=TYPE("a")')).toBe(2); + expect(run('=TYPE({1,2})')).toBe(64); + expect(run('=ERROR.TYPE(1/0)')).toBe(2); + expect(run('=ERROR.TYPE(NA())')).toBe(7); + expect(run('=ISEVEN(-2)')).toBe(true); + expect(run('=ISODD(2.9)')).toBe(false); + }); +}); + +describe('the fixture cases', () => { + it.each(FIXTURE.cases.map((c) => [c.formula, c] as const))('%s', (_formula, c) => { + expect(c.source).toMatch(/\S/); + expect(run(c.formula)).toEqual(expected(c.expect)); + }); +}); + +describe('functions that ask about cells', () => { + it('sees formulas typed in single cells', () => { + let t = sheet(FIXTURE.data); + t = setCellInput(t, { row: 0, col: 3, raw: '=A1+1' }, EN_US, DEFAULT_ENV); + t = setCellInput(t, { row: 2, col: 3, raw: '=ISFORMULA(D1)' }, EN_US, DEFAULT_ENV); + expect(t.rows[2].cells[3].value).toBe(true); + t = setCellInput(t, { row: 0, col: 3, raw: '5' }, EN_US, DEFAULT_ENV); + expect(t.rows[2].cells[3].value).toBe(false); + }); + + it('follows the cell it reads when that cell changes', () => { + let t = sheet(FIXTURE.data); + t = setCellInput(t, { row: 2, col: 3, raw: '=CELL("contents",A1)' }, EN_US, DEFAULT_ENV); + expect(t.rows[2].cells[3].value).toBe(1); + t = setCellInput(t, { row: 0, col: 0, raw: '9' }, EN_US, DEFAULT_ENV); + expect(t.rows[2].cells[3].value).toBe(9); + }); + + it('reads the format of a column and the page title', () => { + const columns = [ + { name: 'A', type: 'currency' as const, decimals: 2 }, + { name: 'B', type: 'percent' as const, decimals: 0 }, + { name: 'C', type: 'date' as const }, + { name: 'D', type: 'number' as const, decimals: 3 }, + ]; + const env: EngineEnv = { ...DEFAULT_ENV, title: 'Budget' }; + const blank = ['', '', '', '']; + let t = createTable(columns, [blank, blank, blank], EN_US, env, 1); + const ask = (f: string): Value => { + t = setCellInput(t, { row: 2, col: 3, raw: f }, EN_US, env); + return t.rows[2].cells[3].value; + }; + expect(ask('=CELL("format",A1)')).toBe('C2'); + expect(ask('=CELL("format",B1)')).toBe('P0'); + expect(ask('=CELL("format",C1)')).toBe('D4'); + expect(ask('=CELL("format",D1)')).toBe('F3'); + expect(ask('=CELL("filename")')).toBe('Budget'); + }); + + it('numbers the tables of a page and names another table in an address', () => { + const one = { ...sheet(FIXTURE.data), name: 'Sales' }; + const two = { ...sheet(FIXTURE.data), name: 'Costs' }; + let book = recalculateBook([one, two], DEFAULT_ENV); + const ask = (raw: string, col: number): Value => { + book = setBookCellInput(book, 1, { row: 2, col, raw }, EN_US, DEFAULT_ENV); + return book[1].rows[2].cells[col].value; + }; + expect(ask('=SHEET()', 3)).toBe(2); + expect(ask('=SHEETS()', 2)).toBe(2); + expect(ask('=SHEET("Sales")', 1)).toBe(1); + expect(ask('=CELL("address",Sales!B2)', 0)).toBe('Sales!$B$2'); + }); + + it('reports a manual page', () => { + const t = recalculate({ ...sheet(FIXTURE.data), calc: 'manual' }, DEFAULT_ENV); + const typed = setCellInput(t, { row: 2, col: 3, raw: '=INFO("recalc")' }, EN_US, DEFAULT_ENV); + expect(typed.rows[2].cells[3].value).toBe('Manual'); + }); +}); + +describe('the registry', () => { + it('holds every function the design lists for these two families', () => { + // The lambda helpers (BYCOL to SCAN) are logical in the design table too; fn/lambda.ts holds them. + const logical = [ + 'AND', + 'BYCOL', + 'BYROW', + 'FALSE', + 'IF', + 'IFERROR', + 'IFNA', + 'IFS', + 'LAMBDA', + 'LET', + 'MAKEARRAY', + 'MAP', + 'NOT', + 'OR', + 'REDUCE', + 'SCAN', + 'SWITCH', + 'TRUE', + 'XOR', + ]; + const info = [ + ...['CELL', 'ERROR.TYPE', 'INFO', 'ISBLANK', 'ISDATE', 'ISEMAIL', 'ISERR', 'ISERROR', 'ISEVEN', 'ISFORMULA'], + ...['ISLOGICAL', 'ISNA', 'ISNONTEXT', 'ISNUMBER', 'ISODD', 'ISOMITTED', 'ISREF', 'ISTEXT', 'ISURL', 'N', 'NA'], + ...['SHEET', 'SHEETS', 'TYPE'], + ]; + const of = (family: string): string[] => + [...REGISTRY.values()] + .filter((s) => s.family === family) + .map((s) => s.name) + .sort(); + expect(of('logical')).toEqual(logical); + expect(of('info')).toEqual(info); + }); + + it('describes every function and every argument in the strings', () => { + type Text = { about: string; args: Record }; + const strings: Record = { ...(fnLogicInfo as unknown as Record) }; + // The lambda helpers' text lives with the array functions (strings/en/fnArrays.ts), as a description and params. + const arrays = fnArrays as Readonly>; + for (const [key, value] of Object.entries(arrays)) { + if (value !== undefined && 'description' in value) + strings[key] ??= { about: value.description, args: value.params }; + } + for (const spec of REGISTRY.values()) { + if (spec.family !== 'logical' && spec.family !== 'info') continue; + const text = strings[fnKey(spec.name)]; + expect(text?.about, spec.name).toMatch(/\S/); + expect(Object.keys(text.args).sort(), spec.name).toEqual( + paramsOf(spec) + .map((p) => p.name) + .sort(), + ); + } + }); +}); + +describe('verification gaps (G4, G5, G6, G7)', () => { + /** A sheet with a text column C and a formula in D1, then the result of `formula` typed in D3. */ + function runIn(data: string[][], formula: string): Value { + const columns = LETTERS.map((name) => ({ name, type: (name === 'C' ? 'text' : 'number') as 'text' | 'number' })); + let t = createTable(columns, data, EN_US, DEFAULT_ENV, 1); + t = setCellInput(t, { row: 0, col: 3, raw: '=1+1' }, EN_US, DEFAULT_ENV); + t = setCellInput(t, { row: 2, col: 3, raw: formula }, EN_US, DEFAULT_ENV); + return t.rows[2].cells[3].value; + } + const data = [ + ['1', '2', 'TRUE', ''], + ['3', '4', 'y', ''], + ['', '', '', ''], + ]; + + it('G4: ISEVEN and ISODD read a blank cell as 0 and text with the number rule', () => { + expect(runIn(data, '=ISEVEN(A3)')).toBe(true); + expect(runIn(data, '=ISODD(A3)')).toBe(false); + expect(runIn(data, '=ISEVEN("0x10")')).toEqual(err('VALUE')); + expect(runIn(data, '=ISODD("0b11")')).toEqual(err('VALUE')); + expect(runIn(data, '=ISODD("3")')).toBe(true); + }); + + it('G5: AND and OR skip text in a referenced cell but read typed text', () => { + expect(runIn(data, '=AND(C1)')).toEqual(err('VALUE')); + expect(runIn(data, '=OR(C1)')).toEqual(err('VALUE')); + expect(runIn(data, '=AND("TRUE")')).toBe(true); + expect(runIn(data, '=AND(C1,A1)')).toBe(true); + }); + + it('M1: newer errors carry their documented codes, and the rest fall back to the #N/A code', () => { + const code = (e: ErrorCode) => run(`=ERROR.TYPE(${ERROR_TOKENS[e]})`); + expect(code('SPILL')).toBe(9); + expect(code('CALC')).toBe(14); + expect(code('FIELD')).toBe(13); + }); + + it('L2: text inside an array constant is ignored by AND', () => { + expect(run('=AND({"TRUE"})')).toEqual(err('VALUE')); + expect(run('=AND("TRUE")')).toBe(true); + }); + + it('G6: a dotted function name maps to a low-line key', () => { + expect(fnKey('ERROR.TYPE')).toBe('ERROR_TYPE'); + expect(fnKey('SUM')).toBe('SUM'); + expect(fnLogicInfo.ERROR_TYPE.about).toMatch(/\S/); + }); + + it('L2: SWITCH returns an error in a match value reached before a match', () => { + expect(runIn(data, '=SWITCH(1,1/0,"x",1,"ok")')).toEqual({ error: 'DIV0' }); + expect(runIn(data, '=SWITCH(1,1,"ok",1/0,"x")')).toBe('ok'); + }); + + it('G7: SWITCH returns an array result whole, and ISFORMULA lifts over a range', () => { + expect(runIn(data, '=SUM(SWITCH(1,1,A1:B2))')).toBe(10); + expect(runIn(data, '=SUM(--ISFORMULA(C1:D1))')).toBe(1); + expect(runIn(data, '=SUM(--ISFORMULA(A1:B2))')).toBe(0); + }); +}); + +describe('ISDATE reads the column type (H1)', () => { + // Reference: the Google Sheets function list gives ISDATE(value) as TRUE for a date value. 46053 is 1/31/2026 + // (serial 1 = 12/31/1899, hand-counted: 46023 is 1/1/2026, plus 30). + const columns = [ + { name: 'A', type: 'date' as const }, + { name: 'B', type: 'text' as const }, + { name: 'C', type: 'number' as const }, + { name: 'D', type: 'text' as const }, + ]; + const rows = [ + ['1/31/2026', '1/31/2026', '46053', ''], + ['', 'hello', '5', ''], + ['', '', '', ''], + ]; + function ask(formula: string): Value { + let t = createTable(columns, rows, EN_US, DEFAULT_ENV, 1); + t = setCellInput(t, { row: 2, col: 3, raw: formula }, EN_US, DEFAULT_ENV); + return t.rows[2].cells[3].value; + } + it('accepts the documented reference-page examples (M1)', () => { + expect(ask('=ISDATE("1969-20-07")')).toBe(true); + expect(ask('=ISDATE("July 20 1969")')).toBe(true); + expect(ask('=ISDATE("July")')).toBe(false); + expect(ask('=ISDATE("Feb 30")')).toBe(false); + expect(ask('=ISDATE("1969-20-37")')).toBe(false); + }); + it('is TRUE for a date-column cell and for date text, FALSE otherwise', () => { + expect(ask('=ISDATE(A1)')).toBe(true); + expect(ask('=ISDATE(B1)')).toBe(true); + expect(ask('=ISDATE(C1)')).toBe(false); + expect(ask('=ISDATE(A2)')).toBe(false); + expect(ask('=ISDATE(B2)')).toBe(false); + expect(ask('=ISDATE("1/31/2026")')).toBe(true); + expect(ask('=ISDATE(5)')).toBe(false); + expect(ask('=SUM(--ISDATE(A1:B2))')).toBe(2); + }); +}); diff --git a/app/src/features/tables/engine/formula/fn/logical.ts b/app/src/features/tables/engine/formula/fn/logical.ts index 2acd10f2a..bdab2aa8d 100644 --- a/app/src/features/tables/engine/formula/fn/logical.ts +++ b/app/src/features/tables/engine/formula/fn/logical.ts @@ -1,23 +1,46 @@ -// Logical functions and LAMBDA helpers. Owner: tables:fn-logic-info. IF and IFERROR take lazy arguments, so the -// compiler evaluates them itself and only the branch it needs runs. These are the functions tables had before the -// library; their behavior is unchanged. +// Logical functions and the metadata of LET and LAMBDA. Owner: tables:fn-logic-info. IF and IFERROR take lazy +// arguments, so the compiler evaluates them itself and only the branch it needs runs. LET, LAMBDA, and ISOMITTED +// (info.ts) are engine forms too (formula/special.ts): the specs here only give them a name, a signature, and an +// example. The other functions are written against the documented rules. -import { err, toBool, type Value } from '../../values'; +import { err, isArray, isError, toBool, type FormulaError, type Scalar, type Value } from '../../values'; import type { FnSpec2 } from './types'; +/** + * The logical values behind AND, OR, and XOR. Arrays and references give their numbers and booleans; text and + * empty cells inside them are skipped. A value typed alone is a one-by-one array, so a lone text is read as a + * boolean ("TRUE" works, other text is #VALUE!). The first error wins. No logical value at all is #VALUE!. + */ +function logicals(args: readonly Value[]): boolean[] | FormulaError { + const out: boolean[] = []; + for (const arg of args) { + if (isError(arg)) return arg; + const items: readonly Scalar[] = isArray(arg) ? arg.data : [arg as Scalar]; + const lone = !isArray(arg); + for (const v of items) { + if (isError(v)) return v; + if (v === null) continue; + if (typeof v === 'string') { + if (!lone) continue; + const b = toBool(v); + if (typeof b !== 'boolean') return b; + out.push(b); + continue; + } + out.push(typeof v === 'boolean' ? v : v !== 0); + } + } + return out.length === 0 ? err('VALUE') : out; +} + function logic(name: string, combine: (bools: boolean[]) => boolean, example: string): FnSpec2 { return { name, family: 'logical', - params: [{ repeat: [{ name: 'logical', kind: 'bool' }], min: 1 }], + params: [{ repeat: [{ name: 'logical', kind: 'array', skipRefText: true, loneText: true }], min: 1 }], run: (args) => { - const bools: boolean[] = []; - for (const v of args) { - const b = toBool(v); - if (typeof b !== 'boolean') return b; - bools.push(b); - } - return combine(bools); + const bools = logicals(args); + return Array.isArray(bools) ? combine(bools) : bools; }, meta: { signature: `${name}(logical, ...)`, example }, }; @@ -26,9 +49,21 @@ function logic(name: string, combine: (bools: boolean[]) => boolean, example: st /** The compiler evaluates functions with lazy arguments itself. */ const special = (): Value => err('VALUE'); +/** Equality as SWITCH reads it: text without regard to case, an empty cell equal to 0 and to "". */ +function same(a: Scalar, b: Scalar): boolean { + if (a === null) return b === null || b === 0 || b === ''; + if (b === null) return same(b, a); + if (typeof a === 'string' && typeof b === 'string') return a.toLowerCase() === b.toLowerCase(); + if (isError(a) || isError(b)) return isError(a) && isError(b) && a.error === b.error; + return a === b; +} + +const scalar = (v: Value): Scalar => (isArray(v) ? (v.data[0] ?? null) : (v as Scalar)); + export const functions: readonly FnSpec2[] = [ logic('AND', (b) => b.every(Boolean), '=AND(TRUE, 1>2) gives FALSE'), logic('OR', (b) => b.some(Boolean), '=OR(TRUE, 1>2) gives TRUE'), + logic('XOR', (b) => b.filter(Boolean).length % 2 === 1, '=XOR(TRUE, TRUE, TRUE) gives TRUE'), { name: 'NOT', family: 'logical', @@ -39,6 +74,20 @@ export const functions: readonly FnSpec2[] = [ }, meta: { signature: 'NOT(logical)', example: '=NOT(1>2) gives TRUE' }, }, + { + name: 'TRUE', + family: 'logical', + params: [], + run: () => true, + meta: { signature: 'TRUE()', example: '=TRUE() gives TRUE' }, + }, + { + name: 'FALSE', + family: 'logical', + params: [], + run: () => false, + meta: { signature: 'FALSE()', example: '=FALSE() gives FALSE' }, + }, { name: 'IF', family: 'logical', @@ -60,4 +109,96 @@ export const functions: readonly FnSpec2[] = [ run: special, meta: { signature: 'IFERROR(value, on_error)', example: '=IFERROR(1/0, 0) gives 0' }, }, + { + name: 'IFNA', + family: 'logical', + params: [ + { name: 'value', kind: 'value' }, + { name: 'on_na', kind: 'value' }, + ], + handlesErrors: true, + run: ([v, fallback]) => (isError(v) && v.error === 'NA' ? fallback : v), + meta: { signature: 'IFNA(value, on_na)', example: '=IFNA(NA(), "x") gives x' }, + }, + { + name: 'IFS', + family: 'logical', + params: [ + { + repeat: [ + { name: 'test', kind: 'value' }, + { name: 'result', kind: 'value' }, + ], + min: 1, + }, + ], + // A test that is never reached cannot spoil the answer, so errors come in and are read in order. + handlesErrors: true, + run: (args) => { + if (args.length % 2 !== 0) return err('VALUE'); + for (let i = 0; i < args.length; i += 2) { + const test = toBool(args[i]); + if (typeof test !== 'boolean') return test; + if (test) return args[i + 1]; + } + return err('NA'); + }, + meta: { signature: 'IFS(test, result, ...)', example: '=IFS(FALSE, 1, TRUE, 2) gives 2' }, + }, + { + name: 'SWITCH', + family: 'logical', + params: [ + { name: 'value', kind: 'value' }, + { + repeat: [ + { name: 'match', kind: 'value' }, + { name: 'result', kind: 'value' }, + ], + min: 1, + }, + { name: 'default', kind: 'value', optional: true }, + ], + handlesErrors: true, + run: (args) => { + const [value, ...rest] = args.map(scalar); + if (isError(value)) return value; + const raw = args.slice(1); + const pairs = Math.floor(rest.length / 2); + // The chosen result is returned as it is, so an array result is not cut down to its first element. + for (let i = 0; i < pairs; i++) { + // A match value is read in order, so an error in it is returned when it is reached before a match. + if (isError(rest[2 * i])) return rest[2 * i]; + if (same(value, rest[2 * i])) return raw[2 * i + 1]; + } + return rest.length % 2 === 1 ? raw[raw.length - 1] : err('NA'); + }, + meta: { + signature: 'SWITCH(value, match, result, ..., [default])', + example: '=SWITCH(2, 1, "a", 2, "b", "z") gives b', + }, + }, + { + name: 'LET', + family: 'logical', + params: [ + { + repeat: [ + { name: 'name', kind: 'lazy' }, + { name: 'value', kind: 'lazy' }, + ], + min: 1, + }, + { name: 'calculation', kind: 'lazy' }, + ], + run: special, + meta: { signature: 'LET(name, value, ..., calculation)', example: '=LET(x, 3, x*x) gives 9' }, + }, + { + name: 'LAMBDA', + family: 'logical', + params: [{ repeat: [{ name: 'parameter', kind: 'lazy' }] }, { name: 'calculation', kind: 'lazy' }], + run: special, + meta: { signature: 'LAMBDA(parameter, ..., calculation)', example: '=LAMBDA(x, x+1)(2) gives 3' }, + }, ]; diff --git a/app/src/features/tables/engine/formula/fn/lookup.test.ts b/app/src/features/tables/engine/formula/fn/lookup.test.ts new file mode 100644 index 000000000..b5bf96199 --- /dev/null +++ b/app/src/features/tables/engine/formula/fn/lookup.test.ts @@ -0,0 +1,182 @@ +// The lookup and reference family (tables:fn-lookup). The fixture's cases run through the whole engine with Excel's +// rules; each expected value was worked by hand from the documented rule in its note, or is Microsoft's own example. + +import { describe, expect, it } from 'vitest'; +import fixture from '../../../fixtures/fn/lookup.json'; +import { createTable, setCellInput } from '../../edit'; +import { EN_US } from '../../locale'; +import type { Table } from '../../model'; +import type { EngineEnv } from '../../recalc'; +import { arrayOf, err, type ErrorCode, type Scalar, type Value } from '../../values'; +import { addressText, columnLetters, functions, hyperlinkOf, parseRefText } from './lookup'; + +const ENV: EngineEnv = { today: () => 46_000 }; +const LETTERS = ['A', 'B', 'C', 'D', 'E', 'F']; + +function blank(): Table { + const columns = LETTERS.map((name) => ({ name, type: 'number' as const })); + const data = Array.from({ length: 6 }, () => LETTERS.map(() => '')); + return createTable(columns, data, EN_US, ENV, 1); +} + +const where = (ref: string) => ({ col: ref.charCodeAt(0) - 65, row: Number(ref.slice(1)) - 1 }); + +function type(t: Table, ref: string, raw: string): Table { + return setCellInput(t, { ...where(ref), raw }, EN_US, ENV); +} + +/** A cell's value. A formula that spills reads as one array from its cell. */ +function at(t: Table, ref: string, rows = 1, cols = 1): Value { + const { row, col } = where(ref); + if (rows === 1 && cols === 1) return t.rows[row].cells[col].value; + const out: Scalar[][] = []; + for (let r = 0; r < rows; r++) { + out.push(Array.from({ length: cols }, (_, c) => t.rows[row + r].cells[col + c].value as Scalar)); + } + return arrayOf(out); +} + +type Expected = Scalar | { error: ErrorCode } | { array: Scalar[][] }; +interface Case { + at?: string; + formula: string; + cells?: Record; + expected: Expected; + source: string; + note: string; +} + +function want(e: Expected): Value { + if (e !== null && typeof e === 'object' && 'error' in e) return err(e.error); + if (e !== null && typeof e === 'object' && 'array' in e) return arrayOf(e.array); + return e; +} + +describe('lookup fixture cases', () => { + for (const c of (fixture as { cases: Case[] }).cases) { + it(`${c.formula} (${c.note})`, () => { + let t = blank(); + for (const [ref, raw] of Object.entries(c.cells ?? {})) t = type(t, ref, raw); + const cell = c.at ?? 'F1'; + t = type(t, cell, c.formula); + const e = want(c.expected); + const size = typeof e === 'object' && e !== null && 'kind' in e && e.kind === 'array' ? e : null; + expect(at(t, cell, size?.rows ?? 1, size?.cols ?? 1)).toEqual(e); + }); + } +}); + +describe('lane tests', () => { + it('VLOOKUP: nearest smaller, exact miss, and the binary-search answer on unsorted keys', () => { + let t = type(blank(), 'F1', '=VLOOKUP(2.5,{1,"a";2,"b";3,"c"},2,TRUE)'); + expect(at(t, 'F1')).toBe('b'); + t = type(t, 'F1', '=VLOOKUP(2.5,{1,"a";2,"b";3,"c"},2,FALSE)'); + expect(at(t, 'F1')).toEqual(err('NA')); + // Keys 1,5,2,6,3: the middle item 2 fits, then the middle of the rest (6) does not, so the answer is c. + t = type(t, 'F1', '=VLOOKUP(3,{1,"a";5,"b";2,"c";6,"d";3,"e"},2,TRUE)'); + expect(at(t, 'F1')).toBe('c'); + }); + + it('MATCH types 1 and -1, XLOOKUP next smaller, and XLOOKUP from the end', () => { + let t = type(blank(), 'F1', '=MATCH(25,{10,20,30},1)'); + expect(at(t, 'F1')).toBe(2); + t = type(t, 'F1', '=MATCH(25,{30,20,10},-1)'); + expect(at(t, 'F1')).toBe(1); + t = type(t, 'F1', '=XLOOKUP(5,{1,3,7},{"a","b","c"},"none",-1)'); + expect(at(t, 'F1')).toBe('b'); + t = type(t, 'F1', '=XLOOKUP(2,{2,1,2},{"x","y","z"},"none",0,-1)'); + expect(at(t, 'F1')).toBe('z'); + }); + + it('INDEX, ADDRESS, COLUMNS, LOOKUP, and CHOOSE', () => { + const cases: [string, Value][] = [ + ['=INDEX({1,2;3,4},2,1)', 3], + ['=ADDRESS(2,3)', '$C$2'], + ['=ADDRESS(2,3,4)', 'C2'], + ['=COLUMNS(A1:C2)', 3], + ['=LOOKUP(4.19,{4.14,4.19,5.17},{"red","orange","yellow"})', 'orange'], + ['=CHOOSE(2,"a","b","c")', 'b'], + ]; + for (const [formula, value] of cases) expect(at(type(blank(), 'F1', formula), 'F1'), formula).toEqual(value); + }); + + it('OFFSET and INDIRECT read B2 and recalculate when B2 changes', () => { + let t = type(blank(), 'B2', '5'); + t = type(t, 'F1', '=OFFSET(A1,1,1)'); + t = type(t, 'F2', '=INDIRECT("B"&2)'); + expect([at(t, 'F1'), at(t, 'F2')]).toEqual([5, 5]); + t = type(t, 'B2', '9'); + expect([at(t, 'F1'), at(t, 'F2')]).toEqual([9, 9]); + }); +}); + +describe('reference details', () => { + it('follows a formula change through FORMULATEXT', () => { + let t = type(blank(), 'A1', '=1+2'); + t = type(t, 'F1', '=FORMULATEXT(A1)'); + expect(at(t, 'F1')).toBe('=1+2'); + t = type(t, 'A1', '=2*3'); + expect(at(t, 'F1')).toBe('=2*3'); + }); + + it('gives ROW() per cell', () => { + let t = type(blank(), 'E3', '=ROW()*10+COLUMN()'); + t = type(t, 'F6', '=ROW()'); + expect([at(t, 'E3'), at(t, 'F6')]).toEqual([35, 6]); + }); + + it('marks OFFSET and INDIRECT volatile and returning references', () => { + const by = new Map(functions.map((f) => [f.name, f])); + for (const name of ['OFFSET', 'INDIRECT']) expect(by.get(name)).toMatchObject({ volatile: true, returnsRef: true }); + expect(by.get('INDEX')?.returnsRef).toBe(true); + expect(by.get('INDEX')?.volatile).toBeUndefined(); + expect(by.size).toBe(18); + }); + + it('writes and reads reference text', () => { + expect(columnLetters(1)).toBe('A'); + expect(columnLetters(26)).toBe('Z'); + expect(columnLetters(27)).toBe('AA'); + expect(columnLetters(702)).toBe('ZZ'); + expect(columnLetters(703)).toBe('AAA'); + expect(addressText(3, 2, 2, false, "Bob's")).toBe("'Bob''s'!R3C[2]"); + expect(addressText(1, 1, 1, true, 'A1')).toBe("'A1'!$A$1"); + expect(parseRefText("'My Sales'!$B$2:A1", true)).toEqual({ + kind: 'ref', + table: 'My Sales', + r1: 0, + c1: 0, + r2: 1, + c2: 1, + }); + expect(parseRefText('XFE1', true)).toBeNull(); + expect(parseRefText('A0', true)).toBeNull(); + expect(parseRefText('RC', false, { row: 4, col: 2, formulaAt: () => null })).toMatchObject({ r1: 4, c1: 2 }); + expect(parseRefText('A1', false)).toBeNull(); + }); + + it('gives the display a link only for web and mail addresses written as text', () => { + expect(hyperlinkOf('HYPERLINK("https://example.com","Site")')).toBe('https://example.com'); + expect(hyperlinkOf('HYPERLINK("mailto:someone@example.com")')).toBe('mailto:someone@example.com'); + expect(hyperlinkOf('HYPERLINK("javascript:alert(1)","x")')).toBeNull(); + expect(hyperlinkOf('HYPERLINK(A1)')).toBeNull(); + expect(hyperlinkOf('SUM(1)')).toBeNull(); + }); +}); + +describe('safety', () => { + it('reads a cell through INDIRECT or OFFSET in that same cell as a cycle', () => { + for (const formula of ['=INDIRECT("F1")', '=OFFSET(F1,0,0)', '=SUM(INDIRECT("F1:F2"))', '=INDIRECT("A1:F1")']) { + expect(at(type(blank(), 'F1', formula), 'F1'), formula).toEqual(err('CYCLE')); + } + expect(at(type(type(blank(), 'F2', '4'), 'F1', '=INDIRECT("F2")'), 'F1')).toBe(4); + }); + + it('runs match mode 3 in linear time on a pattern that backtracks exponentially', () => { + const t = type(blank(), 'A1', `${'a'.repeat(35)}b`); + const start = performance.now(); + const done = type(t, 'F1', '=XMATCH("(a|aa)+$",A1:A1,3)'); + expect(performance.now() - start).toBeLessThan(500); + expect(at(done, 'F1')).toEqual(err('NA')); + }); +}); diff --git a/app/src/features/tables/engine/formula/fn/lookup.ts b/app/src/features/tables/engine/formula/fn/lookup.ts index e563daa6d..a5ad448a6 100644 --- a/app/src/features/tables/engine/formula/fn/lookup.ts +++ b/app/src/features/tables/engine/formula/fn/lookup.ts @@ -1,5 +1,31 @@ -// Lookup and reference functions. Owner: tables:fn-lookup. A stub until that lane fills it in. +// Lookup and reference functions. Owner: tables:fn-lookup. The searching functions are in lookupFind.ts, the +// reference functions in lookupRefs.ts, and their shared rules in lookupKit.ts. Descriptions: strings/en/fnLookup.ts. +import type { Node } from '../../../../../core/expr'; +import { parseFormula } from '../parser'; +import { findFunctions } from './lookupFind'; +import { refFunctions } from './lookupRefs'; import type { FnSpec2 } from './types'; -export const functions: readonly FnSpec2[] = []; +export const functions: readonly FnSpec2[] = [...findFunctions, ...refFunctions]; + +export { addressText, columnLetters, parseRefText } from './lookupRefs'; + +/** Link kinds a click may open. Anything else stays plain text. */ +const SAFE_LINK = /^(?:https?:\/\/|mailto:)/i; + +/** + * The link a cell's formula draws, for the display: set when the formula is a call to HYPERLINK whose location is + * written as text and starts with http, https, or mailto. The display draws it as a link and opens it only on a + * click. `formula` is the stored formula, without "=". + */ +export function hyperlinkOf(formula: string): string | null { + const parsed = parseFormula(formula, undefined, { legacy: false }); + if (!parsed.ok) return null; + const node: Node = parsed.node; + if (node.type !== 'call' || node.name.toUpperCase() !== 'HYPERLINK' || node.callee !== undefined) return null; + const first = node.args[0]; + if (first?.type !== 'str') return null; + const url = first.value.trim(); + return SAFE_LINK.test(url) ? url : null; +} diff --git a/app/src/features/tables/engine/formula/fn/lookupFind.ts b/app/src/features/tables/engine/formula/fn/lookupFind.ts new file mode 100644 index 000000000..d406e7861 --- /dev/null +++ b/app/src/features/tables/engine/formula/fn/lookupFind.ts @@ -0,0 +1,243 @@ +// The searching lookup functions: VLOOKUP, HLOOKUP, LOOKUP, MATCH, XLOOKUP, and XMATCH. Written from Microsoft's +// documented behavior. Approximate matches use Excel's binary search (lookupKit.ts), so data that is not sorted +// gives the answer that rule gives. + +import { err, toBool, toScalar, type ArrayValue, type Scalar, type Value } from '../../values'; +import { asArray, collapse } from '../../arrays'; +import type { FnSpec2 } from './types'; +import { + approximate, + binarySearch, + columnOf, + exactTest, + firstWhere, + order, + rankOf, + rowOf, + transpose, + vectorOf, + wholeArg, +} from './lookupKit'; +import { regexMatcher } from './lookupRegex'; + +/** A regular expression test for XLOOKUP and XMATCH match mode 3, or null when the pattern is refused. */ +function regexTest(pattern: Scalar): ((item: Scalar) => boolean) | null { + if (typeof pattern !== 'string') return null; + const matches = regexMatcher(pattern); + return matches === null ? null : (item) => typeof item === 'string' && matches(item); +} + +/** VLOOKUP's core, on a table whose first column is searched. */ +function tableLookup(x: Value, table: Value, index: Value, approx: Value | undefined): Value { + const a = asArray(table); + const n = wholeArg(index, 0); + if (typeof n !== 'number') return n; + if (n < 1) return err('VALUE'); + if (n > a.cols) return err('REF'); + const near = approx === undefined ? false : toBool(approx); + if (typeof near !== 'boolean') return near; + const keys = columnOf(a, 0).data; + const v = toScalar(x); + const at = near ? approximate(keys, v) : firstWhere(keys, exactTest(v, true)); + return at < 0 ? err('NA') : a.data[at * a.cols + n - 1]; +} + +/** MATCH's position, zero-based, or -1. */ +function matchIndex(x: Scalar, items: readonly Scalar[], type: number): number { + if (type === 0) return firstWhere(items, exactTest(x, true)); + return approximate(items, x, type < 0); +} + +/** The best index by XLOOKUP's match mode -1 or 1 on a scan: exact first, then the nearest smaller or larger. */ +function nearestScan(items: readonly Scalar[], x: Scalar, mode: number, backward: boolean): number { + const exact = firstWhere(items, exactTest(x, false), backward); + if (exact >= 0) return exact; + const rank = rankOf(x); + let best = -1; + const n = items.length; + for (let k = 0; k < n; k++) { + const i = backward ? n - 1 - k : k; + const item = items[i]; + if (rankOf(item) !== rank) continue; + const c = order(item, x); + if (mode < 0 ? c >= 0 : c <= 0) continue; + if (best < 0 || (mode < 0 ? order(item, items[best]) > 0 : order(item, items[best]) < 0)) best = i; + } + return best; +} + +/** XLOOKUP and XMATCH's position, zero-based, -1 when nothing matches, or an error for a refused mode. */ +function xIndex(x: Scalar, items: readonly Scalar[], mode: number, search: number): number | 'bad' { + if (![0, -1, 1, 2, 3].includes(mode) || ![1, -1, 2, -2].includes(search)) return 'bad'; + if (search === 2 || search === -2) { + if (mode === 2 || mode === 3) return 'bad'; + const desc = search === -2; + if (mode === 1) return binarySearch(items, x, (c) => c >= 0, desc); + const at = binarySearch(items, x, (c) => c <= 0, !desc); + if (mode === 0 && (at < 0 || order(items[at], x) !== 0)) return -1; + return at; + } + const backward = search === -1; + if (mode === 0) return firstWhere(items, exactTest(x, false, true), backward); + if (mode === 2) return firstWhere(items, exactTest(x, true, true), backward); + if (mode === 3) { + const test = regexTest(x); + return test === null ? 'bad' : firstWhere(items, test, backward); + } + return nearestScan(items, x, mode, backward); +} + +function modes(mode: Value | undefined, search: Value | undefined): [number, number] | Value { + const m = wholeArg(mode, 0); + if (typeof m !== 'number') return m; + const s = wholeArg(search, 1); + if (typeof s !== 'number') return s; + return [m, s]; +} + +function xlookup(args: Value[]): Value { + const [x, lookIn, back, notFound, mode, search] = args; + const look = asArray(lookIn); + const ret = asArray(back); + const byRow = look.cols === 1; + if (!byRow && look.rows !== 1) return err('VALUE'); + const items = look.data; + if (byRow ? ret.rows !== look.rows : ret.cols !== look.cols) return err('VALUE'); + const ms = modes(mode, search); + if (!Array.isArray(ms)) return ms; + const at = xIndex(toScalar(x), items, ms[0], ms[1]); + if (at === 'bad') return err('VALUE'); + if (at < 0) return notFound === undefined || toScalar(notFound) === null ? err('NA') : collapse(asArray(notFound)); + return collapse(byRow ? rowOf(ret, at) : columnOf(ret, at)); +} + +function lookup(args: Value[]): Value { + const [x, first, result] = args; + const a = asArray(first); + let keys: readonly Scalar[]; + let values: readonly Scalar[]; + if (result === undefined) { + // The array form: the first row or column is searched, and the last one gives the result. + const wide = a.cols > a.rows; + const block: ArrayValue = wide ? transpose(a) : a; + keys = columnOf(block, 0).data; + values = columnOf(block, block.cols - 1).data; + } else { + const k = vectorOf(a); + const r = vectorOf(result); + if (k === null || r === null) return err('NA'); + keys = k; + values = r; + } + const at = approximate(keys, toScalar(x)); + return at < 0 || at >= values.length ? err('NA') : values[at]; +} + +const lookupValue = { name: 'lookup_value', kind: 'value' } as const; + +export const findFunctions: readonly FnSpec2[] = [ + { + name: 'VLOOKUP', + family: 'lookup', + params: [ + lookupValue, + { name: 'table_array', kind: 'range' }, + { name: 'col_index_num', kind: 'number' }, + { name: 'range_lookup', kind: 'bool', optional: true }, + ], + run: ([x, table, index, approx]) => tableLookup(x, table, index, approx ?? true), + meta: { + signature: 'VLOOKUP(lookup_value, table_array, col_index_num, [range_lookup])', + example: '=VLOOKUP(2,{1,"one";2,"two"},2,FALSE) gives two', + }, + }, + { + name: 'HLOOKUP', + family: 'lookup', + params: [ + lookupValue, + { name: 'table_array', kind: 'range' }, + { name: 'row_index_num', kind: 'number' }, + { name: 'range_lookup', kind: 'bool', optional: true }, + ], + run: ([x, table, index, approx]) => tableLookup(x, transpose(asArray(table)), index, approx ?? true), + meta: { + signature: 'HLOOKUP(lookup_value, table_array, row_index_num, [range_lookup])', + example: '=HLOOKUP("b",{"a","b";1,2},2,FALSE) gives 2', + }, + }, + { + name: 'LOOKUP', + family: 'lookup', + params: [ + lookupValue, + { name: 'lookup_vector', kind: 'range' }, + { name: 'result_vector', kind: 'range', optional: true }, + ], + run: lookup, + meta: { + signature: 'LOOKUP(lookup_value, lookup_vector, [result_vector])', + example: '=LOOKUP(25,{10,20,30},{"low","mid","high"}) gives mid', + }, + }, + { + name: 'MATCH', + family: 'lookup', + params: [ + lookupValue, + { name: 'lookup_array', kind: 'range' }, + { name: 'match_type', kind: 'number', optional: true }, + ], + run: ([x, items, type]) => { + const list = vectorOf(items); + if (list === null) return err('NA'); + const t = wholeArg(type, 1); + if (typeof t !== 'number') return t; + const at = matchIndex(toScalar(x), list, Math.sign(t)); + return at < 0 ? err('NA') : at + 1; + }, + meta: { + signature: 'MATCH(lookup_value, lookup_array, [match_type])', + example: '=MATCH("b",{"a","b","c"},0) gives 2', + }, + }, + { + name: 'XLOOKUP', + family: 'lookup', + params: [ + lookupValue, + { name: 'lookup_array', kind: 'range' }, + { name: 'return_array', kind: 'range' }, + { name: 'if_not_found', kind: 'array', optional: true }, + { name: 'match_mode', kind: 'number', optional: true }, + { name: 'search_mode', kind: 'number', optional: true }, + ], + run: xlookup, + meta: { + signature: 'XLOOKUP(lookup_value, lookup_array, return_array, [if_not_found], [match_mode], [search_mode])', + example: '=XLOOKUP(3,{1,3,5},{"a","b","c"}) gives b', + }, + }, + { + name: 'XMATCH', + family: 'lookup', + params: [ + lookupValue, + { name: 'lookup_array', kind: 'range' }, + { name: 'match_mode', kind: 'number', optional: true }, + { name: 'search_mode', kind: 'number', optional: true }, + ], + run: ([x, items, mode, search]) => { + const list = vectorOf(items); + if (list === null) return err('VALUE'); + const ms = modes(mode, search); + if (!Array.isArray(ms)) return ms; + const at = xIndex(toScalar(x), list, ms[0], ms[1]); + return at === 'bad' ? err('VALUE') : at < 0 ? err('NA') : at + 1; + }, + meta: { + signature: 'XMATCH(lookup_value, lookup_array, [match_mode], [search_mode])', + example: '=XMATCH(5,{1,3,5}) gives 3', + }, + }, +]; diff --git a/app/src/features/tables/engine/formula/fn/lookupKit.ts b/app/src/features/tables/engine/formula/fn/lookupKit.ts new file mode 100644 index 000000000..c7629e0b9 --- /dev/null +++ b/app/src/features/tables/engine/formula/fn/lookupKit.ts @@ -0,0 +1,133 @@ +// Shared parts of the lookup functions (fn/lookup.ts): how a lookup value matches an item, Excel's binary search +// for sorted data, and reading whole-number arguments. Written from Microsoft's documented rules (ADR 0058). + +import { isArray, round15, toNumber, type ArrayValue, type FormulaError, type Scalar, type Value } from '../../values'; +import { asArray } from '../../arrays'; +import { hasWildcard, wildcardMatcher } from './criteria'; + +/** The kind order sorted data follows: numbers, then text, then booleans. Empty cells and errors have none. */ +export function rankOf(v: Scalar): number { + if (typeof v === 'number') return 0; + if (typeof v === 'string') return 1; + if (typeof v === 'boolean') return 2; + return -1; +} + +/** Orders two values of one kind. Text compares without regard to case; numbers compare to 15 digits. */ +export function order(a: Scalar, b: Scalar): number { + if (typeof a === 'number' && typeof b === 'number') return round15(a) - round15(b); + if (typeof a === 'string' && typeof b === 'string') { + const [x, y] = [a.toLowerCase(), b.toLowerCase()]; + return x < y ? -1 : x > y ? 1 : 0; + } + return Number(a) - Number(b); +} + +/** + * A test for exact matches. Text matches without regard to case. With `wildcards`, text with * or ? matches as a + * pattern and ~ escapes them, so "a~*" matches the text a*. An empty lookup value matches empty items only when + * `blank` is set, as XLOOKUP does. + */ +export function exactTest(x: Scalar, wildcards: boolean, blank = false): (item: Scalar) => boolean { + if (x === null) return blank ? (item) => item === null : () => false; + if (typeof x === 'string') { + if (wildcards && (hasWildcard(x) || x.includes('~'))) { + const test = wildcardMatcher(x); + return (item) => typeof item === 'string' && test(item); + } + const want = x.toLowerCase(); + return (item) => typeof item === 'string' && item.toLowerCase() === want; + } + if (typeof x === 'number') { + const want = round15(x); + return (item) => typeof item === 'number' && round15(item) === want; + } + return (item) => item === x; +} + +/** The first index, in the order given, whose item passes the test. -1 when none does. */ +export function firstWhere(items: readonly Scalar[], test: (item: Scalar) => boolean, backward = false): number { + if (backward) { + for (let i = items.length - 1; i >= 0; i--) if (test(items[i])) return i; + } else { + for (let i = 0; i < items.length; i++) if (test(items[i])) return i; + } + return -1; +} + +/** + * Excel's binary search over data meant to be sorted. It halves [lo, hi] at the middle item, rounded down. An item + * of another kind (or empty) is passed over for the nearest one of the lookup value's kind, looking down first. When + * `fits` holds for the item, it is the best so far, and the search goes on toward the end when `towardEnd` is set + * and toward the start otherwise. On data that is not sorted the answer is the one this rule gives, as in Excel. + */ +export function binarySearch( + items: readonly Scalar[], + x: Scalar, + fits: (order: number) => boolean, + towardEnd: boolean, +): number { + const rank = rankOf(x); + if (rank < 0) return -1; + let lo = 0; + let hi = items.length - 1; + let best = -1; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + let m = mid; + while (m >= lo && rankOf(items[m]) !== rank) m--; + if (m < lo) { + m = mid + 1; + while (m <= hi && rankOf(items[m]) !== rank) m++; + if (m > hi) break; + } + const good = fits(order(items[m], x)); + if (good) best = m; + if (good === towardEnd) lo = m + 1; + else hi = m - 1; + } + return best; +} + +/** The last item at most `x` in ascending data, or the last item at least `x` in descending data. */ +export function approximate(items: readonly Scalar[], x: Scalar, descending = false): number { + return binarySearch(items, x, descending ? (c) => c >= 0 : (c) => c <= 0, true); +} + +/** The items of a single row or column, or null for a block with both more rows and more columns. */ +export function vectorOf(value: Value): Scalar[] | null { + const a = asArray(value); + if (a.rows !== 1 && a.cols !== 1) return null; + return [...a.data]; +} + +/** Row `r` of an array as a one-row array. */ +export function rowOf(a: ArrayValue, r: number): ArrayValue { + return { kind: 'array', rows: 1, cols: a.cols, data: a.data.slice(r * a.cols, (r + 1) * a.cols) }; +} + +/** Column `c` of an array as a one-column array. */ +export function columnOf(a: ArrayValue, c: number): ArrayValue { + const data: Scalar[] = []; + for (let r = 0; r < a.rows; r++) data.push(a.data[r * a.cols + c]); + return { kind: 'array', rows: a.rows, cols: 1, data }; +} + +/** The array with rows and columns swapped. */ +export function transpose(a: ArrayValue): ArrayValue { + const data: Scalar[] = []; + for (let c = 0; c < a.cols; c++) for (let r = 0; r < a.rows; r++) data.push(a.data[r * a.cols + c]); + return { kind: 'array', rows: a.cols, cols: a.rows, data }; +} + +/** A whole number from an argument, cut toward zero. An omitted or empty argument gives `fallback`. */ +export function wholeArg(v: Value | undefined, fallback: number): number | FormulaError { + if (v === undefined || v === null) return fallback; + const n = toNumber(v); + return typeof n === 'number' ? Math.trunc(n) : n; +} + +/** A one-by-one array stands for its value, as one cell does. */ +export function unwrap(v: Value): Value { + return isArray(v) && v.rows === 1 && v.cols === 1 ? v.data[0] : v; +} diff --git a/app/src/features/tables/engine/formula/fn/lookupRefs.ts b/app/src/features/tables/engine/formula/fn/lookupRefs.ts new file mode 100644 index 000000000..c898208ef --- /dev/null +++ b/app/src/features/tables/engine/formula/fn/lookupRefs.ts @@ -0,0 +1,344 @@ +// The reference functions: INDEX, OFFSET, INDIRECT, CHOOSE, ROW, ROWS, COLUMN, COLUMNS, ADDRESS, AREAS, FORMULATEXT, +// and HYPERLINK. Written from Microsoft's documented behavior. INDEX, OFFSET, and INDIRECT return references, so a +// formula can read through them and A1:INDEX(...) works; what they read is known only when they run. + +import { err, isArray, isError, isRef, toBool, toScalar, toText, type RefValue, type Value } from '../../values'; +import { asArray, collapse, makeArray } from '../../arrays'; +import type { Caller, Env, FnSpec2 } from './types'; +import { columnOf, rowOf, unwrap, wholeArg } from './lookupKit'; + +/** The most rows and columns a sheet reference may name, as Excel's grid has. */ +export const MAX_ROWS = 1_048_576; +export const MAX_COLS = 16_384; + +/** A column's letters: 1 is A, 27 is AA. */ +export function columnLetters(col: number): string { + let out = ''; + for (let n = col; n > 0; n = Math.floor((n - 1) / 26)) out = String.fromCharCode(65 + ((n - 1) % 26)) + out; + return out; +} + +/** A column number from its letters, or 0 for letters past the grid. */ +function columnNumber(letters: string): number { + let n = 0; + for (const ch of letters.toUpperCase()) n = n * 26 + ch.charCodeAt(0) - 64; + return n > MAX_COLS ? 0 : n; +} + +/** A table name as a reference writes it: quoted when it is not a plain word or looks like a cell. */ +function qualify(name: string): string { + const word = /^[\p{L}_][\p{L}\p{N}_.]*$/u.test(name); + const cellLike = /^[A-Za-z]{1,3}\d+$/.test(name) || /^R\d*C\d*$/i.test(name); + return word && !cellLike ? name : `'${name.replace(/'/g, "''")}'`; +} + +/** ADDRESS's text. `abs` 1 to 4 picks which parts are absolute; R1C1 writes relative parts in brackets. */ +export function addressText(row: number, col: number, abs: number, a1: boolean, sheet?: string): string { + const absRow = abs === 1 || abs === 2; + const absCol = abs === 1 || abs === 3; + const cell = a1 + ? `${absCol ? '$' : ''}${columnLetters(col)}${absRow ? '$' : ''}${row}` + : `R${absRow ? row : `[${row}]`}C${absCol ? col : `[${col}]`}`; + return sheet === undefined || sheet === '' ? cell : `${qualify(sheet)}!${cell}`; +} + +const A1_CELL = /^\$?([A-Za-z]{1,3})\$?(\d{1,7})$/; +const R1C1_CELL = /^R(\[-?\d{1,7}\]|\d{1,7})?C(\[-?\d{1,5}\]|\d{1,5})?$/i; + +/** One R1C1 part: a number is absolute, [n] is relative to the caller, and nothing is the caller's own. */ +function r1c1Part(part: string | undefined, here: number): number { + if (part === undefined) return here; + if (part.startsWith('[')) return here + Number(part.slice(1, -1)); + return Number(part) - 1; +} + +/** One cell of INDIRECT's text as zero-based [row, col], or null. */ +function cellAt(text: string, a1: boolean, caller: Caller | undefined): [number, number] | null { + if (a1) { + const m = A1_CELL.exec(text); + if (!m) return null; + const col = columnNumber(m[1]); + const row = Number(m[2]); + return col < 1 || row < 1 || row > MAX_ROWS ? null : [row - 1, col - 1]; + } + const m = R1C1_CELL.exec(text); + if (!m) return null; + const row = r1c1Part(m[1], caller?.row ?? 0); + const col = r1c1Part(m[2], caller?.col ?? 0); + return row < 0 || col < 0 || row >= MAX_ROWS || col >= MAX_COLS ? null : [row, col]; +} + +/** INDIRECT's text as a reference: a cell or a range, in A1 or R1C1 style, after an optional Table! or 'Table'!. */ +export function parseRefText(text: string, a1: boolean, caller?: Caller): RefValue | null { + const m = /^(?:'((?:[^']|'')+)'!|([^!'\s]+)!)?([^!]+)$/.exec(text.trim()); + if (!m) return null; + const table = m[1] !== undefined ? m[1].replace(/''/g, "'") : m[2]; + const parts = m[3].split(':'); + if (parts.length > 2) return null; + const a = cellAt(parts[0], a1, caller); + const b = parts.length === 2 ? cellAt(parts[1], a1, caller) : a; + if (a === null || b === null) return null; + const ref: RefValue = { + kind: 'ref', + r1: Math.min(a[0], b[0]), + c1: Math.min(a[1], b[1]), + r2: Math.max(a[0], b[0]), + c2: Math.max(a[1], b[1]), + }; + return table === undefined ? ref : { ...ref, table }; +} + +const height = (r: RefValue): number => r.r2 - r.r1 + 1; +const width = (r: RefValue): number => r.c2 - r.c1 + 1; + +/** INDEX on a reference: a cell, a whole row or column (0), or the block itself. */ +function indexRef(ref: RefValue, row: number, col: number): Value { + const { areas: _, ...one } = ref; + if (row > height(ref) || col > width(ref)) return err('REF'); + return { + ...one, + r1: row === 0 ? ref.r1 : ref.r1 + row - 1, + r2: row === 0 ? ref.r2 : ref.r1 + row - 1, + c1: col === 0 ? ref.c1 : ref.c1 + col - 1, + c2: col === 0 ? ref.c2 : ref.c1 + col - 1, + }; +} + +function indexArray(a: ReturnType, row: number, col: number): Value { + if (row > a.rows || col > a.cols) return err('REF'); + if (row === 0 && col === 0) return collapse(a); + if (row === 0) return collapse(columnOf(a, col - 1)); + if (col === 0) return collapse(rowOf(a, row - 1)); + return a.data[(row - 1) * a.cols + col - 1]; +} + +function index(args: Value[]): Value { + const [source, rowArg, colArg, areaArg] = args; + if (isError(source)) return source; + let row = wholeArg(rowArg, 0); + let col = wholeArg(colArg, 0); + const area = wholeArg(areaArg, 1); + if (typeof row !== 'number') return row; + if (typeof col !== 'number') return col; + if (typeof area !== 'number') return area; + if (row < 0 || col < 0 || area < 1) return err('VALUE'); + let block: RefValue | ReturnType; + if (isRef(source)) { + const areas = source.areas ?? [source]; + if (area > areas.length) return err('REF'); + block = areas[area - 1]; + } else { + if (area > 1) return err('REF'); + block = asArray(source); + } + const rows = isRef(block) ? height(block) : block.rows; + const cols = isRef(block) ? width(block) : block.cols; + // A single row takes one number as its column, as INDEX({1,2,3},2) gives 2. + const colGiven = colArg !== undefined && colArg !== null; + if (!colGiven && rows === 1 && cols > 1) [row, col] = [1, row]; + return isRef(block) ? indexRef(block, row, col) : indexArray(block, row, col); +} + +function offset(args: Value[]): Value { + const [ref, rowsArg, colsArg, hArg, wArg] = args; + if (isError(ref)) return ref; + if (!isRef(ref) || ref.areas !== undefined) return err('VALUE'); + const nums = [wholeArg(rowsArg, 0), wholeArg(colsArg, 0), wholeArg(hArg, height(ref)), wholeArg(wArg, width(ref))]; + for (const n of nums) if (typeof n !== 'number') return n; + const [dr, dc, h, w] = nums as number[]; + if (h < 1 || w < 1) return err('REF'); + const r1 = ref.r1 + dr; + const c1 = ref.c1 + dc; + if (r1 < 0 || c1 < 0) return err('REF'); + return { ...ref, r1, c1, r2: r1 + h - 1, c2: c1 + w - 1 }; +} + +function indirect(args: Value[], env: Env): Value { + const text = toText(args[0]); + if (typeof text !== 'string') return text; + const a1 = args[1] === undefined ? true : toBool(args[1]); + if (typeof a1 !== 'boolean') return a1; + return parseRefText(text, a1, env.caller) ?? err('REF'); +} + +/** ROW or COLUMN: the caller's own without an argument, and one number per row or column of a reference. */ +function position(args: Value[], env: Env, rows: boolean): Value { + const ref = args[0]; + if (ref === undefined) return ((rows ? env.caller?.row : env.caller?.col) ?? 0) + 1; + if (isError(ref)) return ref; + if (!isRef(ref) || ref.areas !== undefined) return err('VALUE'); + const [from, to] = rows ? [ref.r1, ref.r2] : [ref.c1, ref.c2]; + if (from === to) return from + 1; + const data = Array.from({ length: to - from + 1 }, (_, i) => from + i + 1); + return rows ? makeArray(data.length, 1, data) : makeArray(1, data.length, data); +} + +/** ROWS or COLUMNS of a reference or an array. A single value is one. */ +function size(args: Value[], rows: boolean): Value { + const v = args[0]; + if (isError(v)) return v; + if (isRef(v)) return v.areas !== undefined ? err('REF') : rows ? height(v) : width(v); + if (isArray(v)) return rows ? v.rows : v.cols; + return 1; +} + +function choose(args: Value[]): Value { + const n = wholeArg(args[0], 0); + if (typeof n !== 'number') return n; + if (n < 1 || n >= args.length) return err('VALUE'); + return unwrap(args[n]); +} + +function formulaText(args: Value[], env: Env): Value { + const ref = args[0]; + if (isError(ref)) return ref; + if (!isRef(ref)) return err('VALUE'); + const text = env.caller?.formulaAt(ref) ?? null; + if (text === null) return err('NA'); + return text.startsWith('=') ? text : `=${text}`; +} + +function hyperlink(args: Value[]): Value { + const [link, label] = args; + if (label === undefined) return toText(link); + // An empty cell as the friendly name shows 0, as any formula that reads an empty cell does. + return toScalar(label) ?? 0; +} + +function address(args: Value[]): Value { + const [rowArg, colArg, absArg, a1Arg, sheetArg] = args; + const row = wholeArg(rowArg, 0); + const col = wholeArg(colArg, 0); + const abs = wholeArg(absArg, 1); + if (typeof row !== 'number') return row; + if (typeof col !== 'number') return col; + if (typeof abs !== 'number') return abs; + const a1 = a1Arg === undefined || a1Arg === null ? true : toBool(a1Arg); + if (typeof a1 !== 'boolean') return a1; + const sheet = sheetArg === undefined || sheetArg === null ? undefined : toText(sheetArg); + if (sheet !== undefined && typeof sheet !== 'string') return sheet; + if (row < 1 || col < 1 || row > MAX_ROWS || col > MAX_COLS || abs < 1 || abs > 4) return err('VALUE'); + return addressText(row, col, abs, a1, sheet); +} + +const num = (name: string, optional = false) => ({ name, kind: 'number' as const, ...(optional ? { optional } : {}) }); + +export const refFunctions: readonly FnSpec2[] = [ + { + name: 'INDEX', + family: 'lookup', + returnsRef: true, + params: [{ name: 'array', kind: 'range' }, num('row_num', true), num('column_num', true), num('area_num', true)], + run: index, + meta: { signature: 'INDEX(array, [row_num], [column_num], [area_num])', example: '=INDEX({1,2;3,4},2,2) gives 4' }, + }, + { + name: 'OFFSET', + family: 'lookup', + returnsRef: true, + volatile: true, + params: [{ name: 'reference', kind: 'range' }, num('rows'), num('cols'), num('height', true), num('width', true)], + run: offset, + meta: { + signature: 'OFFSET(reference, rows, cols, [height], [width])', + example: '=OFFSET(A1,1,0) gives the value in A2', + }, + }, + { + name: 'INDIRECT', + family: 'lookup', + returnsRef: true, + volatile: true, + wantsCaller: true, + params: [ + { name: 'ref_text', kind: 'text' }, + { name: 'a1', kind: 'bool', optional: true }, + ], + run: indirect, + meta: { signature: 'INDIRECT(ref_text, [a1])', example: '=INDIRECT("B"&2) gives the value in B2' }, + }, + { + name: 'CHOOSE', + family: 'lookup', + params: [num('index_num'), { repeat: [{ name: 'value', kind: 'array' }], min: 1 }], + run: choose, + meta: { + signature: 'CHOOSE(index_num, value1, [value2], ...)', + example: '=CHOOSE(3,"red","green","blue") gives blue', + }, + }, + { + name: 'ROW', + family: 'lookup', + wantsCaller: true, + params: [{ name: 'reference', kind: 'ref', optional: true }], + run: (args, env) => position(args, env, true), + meta: { signature: 'ROW([reference])', example: '=ROW(C5) gives 5' }, + }, + { + name: 'COLUMN', + family: 'lookup', + wantsCaller: true, + params: [{ name: 'reference', kind: 'ref', optional: true }], + run: (args, env) => position(args, env, false), + meta: { signature: 'COLUMN([reference])', example: '=COLUMN(C5) gives 3' }, + }, + { + name: 'ROWS', + family: 'lookup', + params: [{ name: 'array', kind: 'ref' }], + run: (args) => size(args, true), + meta: { signature: 'ROWS(array)', example: '=ROWS({1,2;3,4;5,6}) gives 3' }, + }, + { + name: 'COLUMNS', + family: 'lookup', + params: [{ name: 'array', kind: 'ref' }], + run: (args) => size(args, false), + meta: { signature: 'COLUMNS(array)', example: '=COLUMNS({1,2;3,4;5,6}) gives 2' }, + }, + { + name: 'ADDRESS', + family: 'lookup', + params: [ + num('row_num'), + num('column_num'), + num('abs_num', true), + { name: 'a1', kind: 'bool', optional: true }, + { name: 'sheet_text', kind: 'text', optional: true }, + ], + run: address, + meta: { + signature: 'ADDRESS(row_num, column_num, [abs_num], [a1], [sheet_text])', + example: '=ADDRESS(4,2) gives $B$4', + }, + }, + { + name: 'AREAS', + family: 'lookup', + params: [{ name: 'reference', kind: 'ref' }], + run: ([ref]) => (isError(ref) ? ref : isRef(ref) ? (ref.areas?.length ?? 1) : err('VALUE')), + meta: { signature: 'AREAS(reference)', example: '=AREAS((A1:B2,D4)) gives 2' }, + }, + { + name: 'FORMULATEXT', + family: 'lookup', + wantsCaller: true, + params: [{ name: 'reference', kind: 'ref' }], + run: formulaText, + meta: { signature: 'FORMULATEXT(reference)', example: '=FORMULATEXT(A1) gives the formula in A1 as text' }, + }, + { + name: 'HYPERLINK', + family: 'lookup', + params: [ + { name: 'link_location', kind: 'text' }, + { name: 'friendly_name', kind: 'value', optional: true }, + ], + run: hyperlink, + meta: { + signature: 'HYPERLINK(link_location, [friendly_name])', + example: '=HYPERLINK("https://example.com","Site") gives Site', + }, + }, +]; diff --git a/app/src/features/tables/engine/formula/fn/lookupRegex.test.ts b/app/src/features/tables/engine/formula/fn/lookupRegex.test.ts new file mode 100644 index 000000000..22efd2138 --- /dev/null +++ b/app/src/features/tables/engine/formula/fn/lookupRegex.test.ts @@ -0,0 +1,91 @@ +// The match mode 3 matcher. The expected answers come from the built-in engine, which follows the ECMAScript +// specification, on patterns and texts small enough that it cannot backtrack for long. + +import { describe, expect, it } from 'vitest'; +import { regexMatcher } from './lookupRegex'; + +const PATTERNS = [ + '', + 'abc', + '^ba', + 'na$', + '^$', + 'a|b|cd', + '(a|aa)+$', + '(a+)+b', + '^(a|b)*c?$', + 'colou?r', + 'a{2}', + 'a{2,3}$', + '^a{2,}$', + '(?:ab){1,2}c', + '(?x)y', + '[a-c]+d', + '[^0-9]', + String.raw`[\]]`, + String.raw`\d{3}-\d{4}`, + String.raw`\w+@\w+\.com`, + String.raw`\s`, + String.raw`\bcat\b`, + String.raw`\Bat`, + 'x.z', + String.raw`\u0041`, + String.raw`\u{1F600}`, + String.raw`\uD83D\uDE00`, + String.raw`\x41`, + String.raw`\p{Lu}`, + String.raw`\cJ`, + String.raw`[\d.]+`, + '😀+', + 'a*?b', + '(a*)*$', + '(|a)+b', +]; +const TEXTS = ['', 'abc', 'banana', 'cd', 'aaab', 'aaa', 'ab', 'abbc', 'color', 'colour', 'aaaa', 'ababc', 'xy']; +const MORE = [ + 'bd', + '7', + 'q]', + '555-1234', + 'me@site.com', + 'a b', + 'concat', + 'a cat', + 'bat', + 'x\nz', + 'xyz', + 'A', + '😀😀', + 'aab', +]; + +describe('match mode 3 matcher', () => { + it('agrees with the built-in engine', () => { + for (const pattern of PATTERNS) { + const ours = regexMatcher(pattern); + expect(ours, pattern).not.toBeNull(); + const re = new RegExp(pattern, 'u'); + for (const text of [...TEXTS, ...MORE]) expect(ours?.(text), `${pattern} on ${text}`).toBe(re.test(text)); + } + }); + + it('refuses back references, lookaround, and patterns that are not valid', () => { + for (const pattern of ['(a)\\1', '(?a)\\k', 'a(?=b)', 'a(?!b)', '(?<=a)b', '(? { + expect(regexMatcher('(a{100}){100}')).toBeNull(); + expect(regexMatcher('a'.repeat(1001))).toBeNull(); + }); + + it('stays fast on patterns that make a backtracking engine take exponential time', () => { + const text = `${'a'.repeat(5000)}!`; + const start = performance.now(); + for (const pattern of ['(a|aa)+$', '(a+)+$', '(a*)*b', '^(a?){30}a{30}$', '(.*a){12}$']) { + expect(regexMatcher(pattern)?.(text), pattern).toBe(false); + } + expect(performance.now() - start).toBeLessThan(2000); + }); +}); diff --git a/app/src/features/tables/engine/formula/fn/lookupRegex.ts b/app/src/features/tables/engine/formula/fn/lookupRegex.ts new file mode 100644 index 000000000..4baad721a --- /dev/null +++ b/app/src/features/tables/engine/formula/fn/lookupRegex.ts @@ -0,0 +1,312 @@ +// The regular expression test for XLOOKUP and XMATCH match mode 3. It never backtracks. The pattern compiles to a +// Thompson automaton that runs as a Pike machine, so the time is at most the program size times the text length, for +// every pattern. It reads JavaScript's syntax with the u flag. Back references and lookaround are refused (null), since +// no automaton can run them in linear time. A one-character piece (a class, an escape, or the dot) is tested by the +// built-in engine on one character, which cannot backtrack. + +/** Longest pattern accepted. */ +const PATTERN_MAX = 1000; +/** Most instructions a pattern may compile to, after counted repeats such as a{50} are written out. */ +const PROGRAM_MAX = 5000; + +type Assertion = '^' | '$' | 'b' | 'B'; + +type Node = + | { t: 'char'; c: number } + | { t: 'set'; re: RegExp } + | { t: 'assert'; kind: Assertion } + | { t: 'seq'; items: Node[] } + | { t: 'alt'; items: Node[] } + | { t: 'rep'; node: Node; min: number; max: number }; + +const CHAR = 0; +const SET = 1; +const SPLIT = 2; +const JMP = 3; +const ASSERT = 4; +const MATCH = 5; + +interface Inst { + op: number; + c: number; + re: RegExp | null; + kind: Assertion | null; + x: number; + y: number; +} + +class Refused extends Error {} + +/** A one-character test by the built-in engine. Anchored, so it reads exactly one character. */ +function oneChar(src: string): Node { + return { t: 'set', re: new RegExp(`^(?:${src})$`, 'u') }; +} + +/** Where the character class that starts at `i` ends. With the u flag, the first unescaped ] closes it. */ +function classEnd(s: string, i: number): number { + let j = i + 1; + if (s[j] === '^') j++; + while (j < s.length && s[j] !== ']') j += s[j] === '\\' ? 2 : 1; + return j + 1; +} + +/** Parses a pattern the built-in engine has already accepted, so only refused forms need checking. */ +class Parser { + private i = 0; + constructor(private readonly s: string) {} + + parse(): Node { + const node = this.alt(); + if (this.i < this.s.length) throw new Refused(); + return node; + } + + private alt(): Node { + const items = [this.seq()]; + while (this.s[this.i] === '|') { + this.i++; + items.push(this.seq()); + } + return items.length === 1 ? items[0] : { t: 'alt', items }; + } + + private seq(): Node { + const items: Node[] = []; + while (this.i < this.s.length && this.s[this.i] !== '|' && this.s[this.i] !== ')') { + items.push(this.quantified(this.atom())); + } + return { t: 'seq', items }; + } + + private quantified(node: Node): Node { + const s = this.s; + let min: number; + let max: number; + const ch = s[this.i]; + if (ch === '*' || ch === '+' || ch === '?') { + min = ch === '+' ? 1 : 0; + max = ch === '?' ? 1 : Infinity; + this.i++; + } else if (ch === '{') { + const m = /^\{(\d+)(,(\d*))?\}/.exec(s.slice(this.i, this.i + 24)); + if (m === null) throw new Refused(); + min = Number(m[1]); + max = m[2] === undefined ? min : m[3] === '' ? Infinity : Number(m[3]); + this.i += m[0].length; + } else { + return node; + } + // A lazy quantifier gives the same yes or no answer. + if (s[this.i] === '?') this.i++; + return { t: 'rep', node, min, max }; + } + + private atom(): Node { + const s = this.s; + const ch = s[this.i]; + if (ch === '(') { + this.i++; + if (s[this.i] === '?') { + const named = s[this.i + 1] === '<' && s[this.i + 2] !== '=' && s[this.i + 2] !== '!'; + if (s[this.i + 1] === ':') this.i += 2; + else if (named) this.i = s.indexOf('>', this.i) + 1; + else throw new Refused(); + } + const inner = this.alt(); + if (s[this.i] !== ')') throw new Refused(); + this.i++; + return inner; + } + if (ch === '^' || ch === '$') { + this.i++; + return { t: 'assert', kind: ch }; + } + if (ch === '.') { + this.i++; + return oneChar('.'); + } + if (ch === '[') { + const end = classEnd(s, this.i); + const src = s.slice(this.i, end); + this.i = end; + return oneChar(src); + } + if (ch === '\\') return this.escape(); + const c = s.codePointAt(this.i) ?? 0; + this.i += c > 0xffff ? 2 : 1; + return { t: 'char', c }; + } + + private escape(): Node { + const s = this.s; + const start = this.i; + const k = s[start + 1]; + if (k === 'b' || k === 'B') { + this.i += 2; + return { t: 'assert', kind: k }; + } + if (k === 'k' || (k >= '1' && k <= '9')) throw new Refused(); + let end = start + 2; + if ((k === 'u' && s[end] === '{') || k === 'p' || k === 'P') { + end = s.indexOf('}', end) + 1; + } else if (k === 'u') { + end = start + 6; + // A surrogate pair written as two escapes is one character with the u flag. + if ( + /^\\u[dD][89abAB]/.test(s.slice(start, end)) && + /^\\u[dD][c-fC-F][0-9a-fA-F]{2}/.test(s.slice(end, end + 6)) + ) { + end += 6; + } + } else if (k === 'x') { + end = start + 4; + } else if (k === 'c') { + end = start + 3; + } + if (end <= start) throw new Refused(); + this.i = end; + return oneChar(s.slice(start, end)); + } +} + +/** Writes the program for a node: Thompson's construction. */ +function compile(root: Node): Inst[] { + const prog: Inst[] = []; + const emit = (op: number, part: Partial = {}): number => { + if (prog.length >= PROGRAM_MAX) throw new Refused(); + prog.push({ op, c: 0, re: null, kind: null, x: 0, y: 0, ...part }); + return prog.length - 1; + }; + const gen = (n: Node): void => { + switch (n.t) { + case 'char': + emit(CHAR, { c: n.c }); + return; + case 'set': + emit(SET, { re: n.re }); + return; + case 'assert': + emit(ASSERT, { kind: n.kind }); + return; + case 'seq': + for (const item of n.items) gen(item); + return; + case 'alt': { + const jumps: number[] = []; + for (let k = 0; k < n.items.length - 1; k++) { + const split = emit(SPLIT); + prog[split].x = prog.length; + gen(n.items[k]); + jumps.push(emit(JMP)); + prog[split].y = prog.length; + } + gen(n.items[n.items.length - 1]); + for (const j of jumps) prog[j].x = prog.length; + return; + } + case 'rep': { + for (let k = 0; k < n.min; k++) gen(n.node); + if (n.max === Infinity) { + const split = emit(SPLIT); + prog[split].x = prog.length; + gen(n.node); + emit(JMP, { x: split }); + prog[split].y = prog.length; + return; + } + const outs: number[] = []; + for (let k = n.min; k < n.max; k++) { + const split = emit(SPLIT); + prog[split].x = prog.length; + outs.push(split); + gen(n.node); + } + for (const split of outs) prog[split].y = prog.length; + } + } + }; + gen(root); + emit(MATCH); + return prog; +} + +const isWord = (ch: string | undefined): boolean => ch !== undefined && /^\w$/.test(ch); + +/** Whether the program matches anywhere in the text. Each step keeps at most one thread per instruction. */ +function run(prog: readonly Inst[], text: string): boolean { + const chars = Array.from(text); + const n = chars.length; + const size = prog.length; + let cur = new Int32Array(size); + let next = new Int32Array(size); + const mark = new Int32Array(size).fill(-1); + const stack = new Int32Array(size); + + const holds = (kind: Assertion | null, pos: number): boolean => { + if (kind === '^') return pos === 0; + if (kind === '$') return pos === n; + const edge = isWord(chars[pos - 1]) !== isWord(chars[pos]); + return kind === 'b' ? edge : !edge; + }; + + /** Adds the threads reachable from `from` without reading a character. -1 when one of them matches. */ + const close = (list: Int32Array, len: number, from: number, pos: number): number => { + if (mark[from] === pos) return len; + mark[from] = pos; + stack[0] = from; + let sp = 1; + const push = (pc: number): void => { + if (mark[pc] !== pos) { + mark[pc] = pos; + stack[sp++] = pc; + } + }; + while (sp > 0) { + const pc = stack[--sp]; + const inst = prog[pc]; + if (inst.op === MATCH) return -1; + if (inst.op === JMP) push(inst.x); + else if (inst.op === SPLIT) { + push(inst.y); + push(inst.x); + } else if (inst.op === ASSERT) { + if (holds(inst.kind, pos)) push(pc + 1); + } else list[len++] = pc; + } + return len; + }; + + let len = close(cur, 0, 0, 0); + if (len < 0) return true; + for (let pos = 0; pos < n; pos++) { + const ch = chars[pos]; + const c = ch.codePointAt(0); + let nextLen = 0; + for (let k = 0; k < len; k++) { + const inst = prog[cur[k]]; + const ok = inst.op === CHAR ? inst.c === c : inst.re !== null && inst.re.test(ch); + if (!ok) continue; + nextLen = close(next, nextLen, cur[k] + 1, pos + 1); + if (nextLen < 0) return true; + } + // A match may start at any position. + nextLen = close(next, nextLen, 0, pos + 1); + if (nextLen < 0) return true; + [cur, next] = [next, cur]; + len = nextLen; + } + return false; +} + +/** A test that a text holds a match of `pattern` somewhere, or null for a pattern that is refused or not valid. */ +export function regexMatcher(pattern: string): ((text: string) => boolean) | null { + if (pattern.length > PATTERN_MAX) return null; + try { + // The built-in engine only checks the syntax here; it never runs on a text. + new RegExp(pattern, 'u'); + const prog = compile(new Parser(pattern).parse()); + return (text) => run(prog, text); + } catch { + return null; + } +} diff --git a/app/src/features/tables/engine/formula/fn/matrix.ts b/app/src/features/tables/engine/formula/fn/matrix.ts new file mode 100644 index 000000000..0d05d8749 --- /dev/null +++ b/app/src/features/tables/engine/formula/fn/matrix.ts @@ -0,0 +1,231 @@ +// Matrix functions and the array builders. Owner: tables:fn-arrays. Also the helpers the other array files share: +// the size cap, reading a count, and the shape of an array. Behavior follows each function's documented rules (ADR +// 0058, ADR 0059); the linear algebra reuses the LU decomposition in core/num. +// +// Every array a function builds is capped at MAX_ELEMENTS items. A larger request gives #NUM! with the reason +// 'tooMany'. The check comes before anything is allocated, so a typo such as SEQUENCE(2000000) cannot stall the page. + +import { identity, luDecompose, luDeterminant, luSolve, type Matrix } from '../../../../../core/num/matrix'; +import { makeArray } from '../../arrays'; +import { + err, + errBecause, + isArray, + toBool, + toNumber, + type ArrayValue, + type FormulaError, + type Scalar, + type Value, +} from '../../values'; +import type { FnSpec2, Param } from './types'; + +/** The most items an array result may hold (DESIGN.md section 3.8). */ +export const MAX_ELEMENTS = 1_000_000; + +/** The largest square matrix MINVERSE and MDETERM take: their work grows with the cube of the order. */ +export const MAX_ORDER = 500; + +/** Whether a result is an error rather than what was asked for. */ +export function failed(value: T | FormulaError): value is FormulaError { + return typeof value === 'object' && value !== null && 'error' in value; +} + +/** The error for an array past the cap. */ +export function tooMany(): FormulaError { + return errBecause('NUM', 'tooMany'); +} + +/** Whether an array of rows by cols fits under the cap. */ +export function fits(rows: number, cols: number): boolean { + return rows * cols <= MAX_ELEMENTS; +} + +/** A row-major array, or the cap's error when it would be too large. `item` is called once per cell. */ +export function build(rows: number, cols: number, item: (r: number, c: number) => Scalar): ArrayValue | FormulaError { + if (!fits(rows, cols)) return tooMany(); + const data = new Array(rows * cols); + for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) data[r * cols + c] = item(r, c); + return makeArray(rows, cols, data); +} + +/** + * An argument as an array, or the error it holds. A single value is a one-by-one array. With `items`, an error is + * a one-by-one array too, as VSTACK and its kin keep an error as one of their items. + */ +export function arrayArg(value: Value | undefined, items = false): ArrayValue | FormulaError { + if (value === undefined) return err('VALUE'); + if (failed(value) && !items) return value; + return isArray(value) ? value : makeArray(1, 1, [value as Scalar]); +} + +/** The item at row r and column c. */ +export function at(a: ArrayValue, r: number, c: number): Scalar { + return a.data[r * a.cols + c]; +} + +/** A whole number from an argument, truncated toward zero; `fallback` when it was left out or empty. */ +export function intArg(value: Value | undefined, fallback: number): number | FormulaError { + if (value === undefined || value === null) return fallback; + const n = toNumber(value); + return typeof n === 'number' ? Math.trunc(n) : n; +} + +/** A true or false argument; `fallback` when it was left out or empty. */ +export function boolArg(value: Value | undefined, fallback: boolean): boolean | FormulaError { + if (value === undefined || value === null) return fallback; + return toBool(value); +} + +/** An array of numbers as rows, or #VALUE! when any item is not a number (empty and text included). */ +function numericRows(value: Value | undefined): Matrix | FormulaError { + const a = arrayArg(value); + if (failed(a)) return a; + const out: Matrix = []; + for (let r = 0; r < a.rows; r++) { + const row = new Array(a.cols); + for (let c = 0; c < a.cols; c++) { + const v = at(a, r, c); + if (failed(v)) return v; + if (typeof v !== 'number') return err('VALUE'); + row[c] = v; + } + out.push(row); + } + return out; +} + +function fromRows(m: Matrix): Value { + for (const row of m) for (const v of row) if (!Number.isFinite(v)) return err('NUM'); + return makeArray(m.length, m[0]?.length ?? 0, m.flat()); +} + +/** A square matrix no larger than MAX_ORDER, or the error to give. */ +function squareRows(value: Value | undefined): Matrix | FormulaError { + const m = numericRows(value); + if (failed(m)) return m; + if (m.length !== m[0].length) return err('VALUE'); + return m.length > MAX_ORDER ? tooMany() : m; +} + +function mmult([a, b]: Value[]): Value { + const left = numericRows(a); + if (failed(left)) return left; + const right = numericRows(b); + if (failed(right)) return right; + const inner = right.length; + if (left[0].length !== inner) return err('VALUE'); + const cols = right[0].length; + if (!fits(left.length, cols) || left.length * cols * inner > 100 * MAX_ELEMENTS) return tooMany(); + const out: Matrix = left.map((row) => { + const sums = new Array(cols).fill(0); + for (let k = 0; k < inner; k++) for (let c = 0; c < cols; c++) sums[c] += row[k] * right[k][c]; + return sums; + }); + return fromRows(out); +} + +function minverse([a]: Value[]): Value { + const m = squareRows(a); + if (failed(m)) return m; + const lu = luDecompose(m); + if (lu.singular) return err('NUM'); + return fromRows(luSolve(lu, identity(m.length))); +} + +function mdeterm([a]: Value[]): Value { + const m = squareRows(a); + if (failed(m)) return m; + const d = luDeterminant(luDecompose(m)); + // Adding 0 turns -0 into 0. + return Number.isFinite(d) ? d + 0 : err('NUM'); +} + +function munit([n]: Value[]): Value { + const size = intArg(n, 1); + if (typeof size !== 'number') return size; + if (size < 1) return err('VALUE'); + return build(size, size, (r, c) => (r === c ? 1 : 0)); +} + +function transpose([a]: Value[]): Value { + const src = arrayArg(a); + if (failed(src)) return src; + return build(src.cols, src.rows, (r, c) => at(src, c, r)); +} + +/** Rows and columns of a builder: zero gives #CALC! (an empty array), a negative count #VALUE!. */ +function sizeOf(rows: Value | undefined, cols: Value | undefined): [number, number] | FormulaError { + const r = intArg(rows, 1); + if (typeof r !== 'number') return r; + const c = intArg(cols, 1); + if (typeof c !== 'number') return c; + if (r < 0 || c < 0) return err('VALUE'); + if (r === 0 || c === 0) return err('CALC'); + return fits(r, c) ? [r, c] : tooMany(); +} + +function sequence([rows, cols, start, step]: Value[]): Value { + const size = sizeOf(rows, cols); + if (failed(size)) return size; + const first = start === undefined || start === null ? 1 : toNumber(start); + if (typeof first !== 'number') return first; + const by = step === undefined || step === null ? 1 : toNumber(step); + if (typeof by !== 'number') return by; + return build(size[0], size[1], (r, c) => first + (r * size[1] + c) * by); +} + +function randarray([rows, cols, min, max, whole]: Value[]): Value { + const size = sizeOf(rows, cols); + if (failed(size)) return size; + const low = min === undefined || min === null ? 0 : toNumber(min); + if (typeof low !== 'number') return low; + const high = max === undefined || max === null ? 1 : toNumber(max); + if (typeof high !== 'number') return high; + const integer = boolArg(whole, false); + if (typeof integer !== 'boolean') return integer; + if (low > high) return err('VALUE'); + if (integer && (!Number.isInteger(low) || !Number.isInteger(high))) return err('VALUE'); + const draw = integer + ? () => low + Math.floor(Math.random() * (high - low + 1)) + : () => low + Math.random() * (high - low); + return build(size[0], size[1], draw); +} + +const num = (name: string, optional = false): Param => ({ name, kind: 'number', ...(optional ? { optional } : {}) }); +const arr = (name: string): Param => ({ name, kind: 'array' }); + +function spec(name: string, params: Param[], run: FnSpec2['run'], signature: string, example: string): FnSpec2 { + return { name, family: 'arrays', params, run, meta: { signature, example } }; +} + +export const functions: readonly FnSpec2[] = [ + spec('MMULT', [arr('array1'), arr('array2')], mmult, 'MMULT(array1, array2)', '=MMULT({1,2}, {3;4}) gives 11'), + spec('MINVERSE', [arr('array')], minverse, 'MINVERSE(array)', '=MINVERSE({2,0;0,4}) gives {0.5,0;0,0.25}'), + spec('MDETERM', [arr('array')], mdeterm, 'MDETERM(array)', '=MDETERM({2,1;1,3}) gives 5'), + spec('MUNIT', [num('dimension')], munit, 'MUNIT(dimension)', '=MUNIT(2) gives {1,0;0,1}'), + spec('TRANSPOSE', [arr('array')], transpose, 'TRANSPOSE(array)', '=TRANSPOSE({1,2,3}) gives {1;2;3}'), + spec( + 'SEQUENCE', + [num('rows'), num('columns', true), num('start', true), num('step', true)], + sequence, + 'SEQUENCE(rows, [columns], [start], [step])', + '=SEQUENCE(1, 4, 0, 5) gives {0,5,10,15}', + ), + { + ...spec( + 'RANDARRAY', + [ + num('rows', true), + num('columns', true), + num('min', true), + num('max', true), + { name: 'integer', kind: 'bool', optional: true }, + ], + randarray, + 'RANDARRAY([rows], [columns], [min], [max], [integer])', + '=RANDARRAY(2, 1, 1, 6, TRUE) gives two whole numbers from 1 to 6', + ), + volatile: true, + }, +]; diff --git a/app/src/features/tables/engine/formula/fn/regression.ts b/app/src/features/tables/engine/formula/fn/regression.ts new file mode 100644 index 000000000..029442c77 --- /dev/null +++ b/app/src/features/tables/engine/formula/fn/regression.ts @@ -0,0 +1,249 @@ +// Least-squares regression: LINEST, LOGEST, TREND, and GROWTH. Owner: tables:fn-stats. Lazy chunk only. +// Written from the documented behavior. LINEST gives the coefficients last variable first, then the intercept. With +// stats it adds four rows: the standard errors; R squared and the standard error of y; F and the degrees of freedom; +// and the regression and residual sums of squares. Cells those rows leave empty hold #N/A. A variable that is a +// combination of the others gets the coefficient 0 and is left out of the degrees of freedom. +// +// The fit centers the data when there is an intercept and uses Gram-Schmidt with a second orthogonalization pass, +// which keeps 12 or more digits on NIST's harder linear datasets where the normal equations would not. + +import { asArray } from '../../arrays'; +import { err, isError, toNumber, type ArrayValue, type FormulaError, type Scalar, type Value } from '../../values'; +import { failed, type Run } from './statsKit'; + +export interface Fit { + /** One coefficient per column of x, in column order; 0 for a column left out. */ + readonly coef: number[]; + readonly intercept: number; + /** Standard errors in the same order; 0 for a column left out. NaN when there are no degrees of freedom. */ + readonly se: number[]; + readonly seIntercept: number; + readonly r2: number; + readonly sey: number; + readonly f: number; + readonly df: number; + readonly ssreg: number; + readonly ssresid: number; +} + +const dot = (a: readonly number[], b: readonly number[]): number => { + let s = 0; + for (let i = 0; i < a.length; i++) s += a[i] * b[i]; + return s; +}; + +/** Upper-triangular solve and inverse helpers over the kept columns. */ +function invertUpper(r: number[][]): number[][] { + const k = r.length; + const inv = r.map(() => new Array(k).fill(0)); + for (let j = 0; j < k; j++) { + inv[j][j] = 1 / r[j][j]; + for (let i = j - 1; i >= 0; i--) { + let s = 0; + for (let m = i + 1; m <= j; m++) s += r[i][m] * inv[m][j]; + inv[i][j] = -s / r[i][i]; + } + } + return inv; +} + +/** Orthogonalizes the columns. Returns the kept column indices, Q's columns, and R over the kept columns. */ +function gramSchmidt(cols: number[][]): { kept: number[]; q: number[][]; r: number[][] } { + const kept: number[] = []; + const q: number[][] = []; + const r: number[][] = []; + cols.forEach((col, j) => { + const v = [...col]; + const coeffs = new Array(q.length).fill(0); + for (let pass = 0; pass < 2; pass++) { + q.forEach((qi, i) => { + const c = dot(qi, v); + coeffs[i] += c; + for (let t = 0; t < v.length; t++) v[t] -= c * qi[t]; + }); + } + const norm = Math.sqrt(dot(v, v)); + const size = Math.sqrt(dot(col, col)); + if (norm === 0 || norm <= 1e-13 * size) return; + kept.push(j); + q.push(v.map((x) => x / norm)); + r.forEach((row, i) => row.push(coeffs[i])); + r.push([...new Array(q.length - 1).fill(0), norm]); + }); + return { kept, q, r }; +} + +/** Least squares of y on the columns of x, with or without an intercept. */ +export function leastSquares(cols: readonly number[][], y: readonly number[], intercept: boolean): Fit { + const n = y.length; + const k = cols.length; + const means = cols.map((c) => (intercept ? c.reduce((a, b) => a + b, 0) / n : 0)); + const my = intercept ? y.reduce((a, b) => a + b, 0) / n : 0; + // Each centered column is scaled to a largest size of 1, so its norms neither overflow nor underflow (1E200 + // squared is Infinity). The fit on the scaled columns is then scaled back: coefficient j divides by scale j. + const scales = cols.map((c, j) => c.reduce((m, x) => Math.max(m, Math.abs(x - means[j])), 0) || 1); + const centered = cols.map((c, j) => c.map((x) => (x - means[j]) / scales[j])); + const yc = y.map((v) => v - my); + const { kept, q, r } = gramSchmidt(centered); + const qty = q.map((qi) => dot(qi, yc)); + const rinv = invertUpper(r); + const b = rinv.map((row) => dot(row, qty)); + const coef = new Array(k).fill(0); + kept.forEach((j, i) => (coef[j] = b[i] / scales[j])); + const b0 = intercept ? my - dot(coef, means) : 0; + const fitted = y.map((_, t) => b0 + cols.reduce((s, c, j) => s + coef[j] * c[t], 0)); + const ssresid = y.reduce((s, v, t) => s + (v - fitted[t]) ** 2, 0); + const ssreg = fitted.reduce((s, v) => s + (v - my) ** 2, 0); + const df = n - kept.length - (intercept ? 1 : 0); + const s2 = df > 0 ? ssresid / df : NaN; + const se = new Array(k).fill(0); + kept.forEach((j, i) => (se[j] = Math.sqrt(s2 * dot(rinv[i], rinv[i])) / scales[j])); + // Var(b0) = s2 (1/n + m' (Xc'Xc)^-1 m), where (Xc'Xc)^-1 is the inverse of R times its transpose. + // In the scaled columns the means are scaled too. + const keptMeans = kept.map((j) => means[j] / scales[j]); + const w = rinv.map((_, m) => rinv.reduce((s, row, i) => s + row[m] * keptMeans[i], 0)); + const seIntercept = intercept ? Math.sqrt(s2 * (1 / n + dot(w, w))) : NaN; + const total = ssreg + ssresid; + return { + coef, + intercept: b0, + se, + seIntercept, + r2: total === 0 ? 1 : ssreg / total, + sey: Math.sqrt(s2), + f: df > 0 && kept.length > 0 ? ssreg / kept.length / s2 : NaN, + df, + ssreg, + ssresid, + }; +} + +/** Reads a block whose items must all be numbers. */ +function block(value: Value): ArrayValue | FormulaError { + if (isError(value)) return value; + const a = asArray(value); + for (const v of a.data) { + if (isError(v)) return v; + if (typeof v !== 'number') return err('VALUE'); + } + return a; +} + +const omitted = (v: Value | undefined): boolean => v === undefined || v === null; + +interface Problem { + readonly cols: number[][]; + readonly y: number[]; + /** True when y is a row, so each row of x is one variable. */ + readonly byRow: boolean; +} + +/** The variables of x as columns of numbers, matched to y as the documented rules say. */ +function problemOf(yv: Value, xv: Value | undefined, transformY?: (y: number) => number): Problem | FormulaError { + const ya = block(yv); + if (isError(ya)) return ya; + const n = ya.data.length; + let y = ya.data as number[]; + if (transformY !== undefined) { + if (y.some((v) => v <= 0)) return err('NUM'); + y = y.map(transformY); + } + const byRow = ya.rows === 1 && ya.cols > 1; + if (omitted(xv)) return { cols: [y.map((_, i) => i + 1)], y, byRow }; + const xa = block(xv as Value); + if (isError(xa)) return xa; + if (xa.rows === ya.rows && xa.cols === ya.cols) return { cols: [xa.data as number[]], y, byRow }; + if (ya.cols === 1 && xa.rows === n) { + return { + cols: Array.from({ length: xa.cols }, (_, j) => y.map((_, i) => xa.data[i * xa.cols + j] as number)), + y, + byRow, + }; + } + if (byRow && xa.cols === n) { + return { cols: Array.from({ length: xa.rows }, (_, j) => xa.data.slice(j * n, j * n + n) as number[]), y, byRow }; + } + return err('REF'); +} + +const flagOf = (v: Value | undefined, fallback: boolean): boolean | FormulaError => { + if (omitted(v)) return fallback; + const n = toNumber(v as Value); + return typeof n === 'number' ? n !== 0 : n; +}; + +const cell = (x: number): Scalar => (Number.isFinite(x) ? x : err('NUM')); + +/** The LINEST table, or LOGEST's when `log` is set (coefficients raised to e, from a fit of ln y). */ +function estimate(log: boolean): Run { + return ([yv, xv, constV, statsV]) => { + const p = problemOf(yv, xv, log ? Math.log : undefined); + if (failed(p)) return p; + const withConst = flagOf(constV, true); + const stats = flagOf(statsV, false); + if (isError(withConst)) return withConst; + if (isError(stats)) return stats; + const fit = leastSquares(p.cols, p.y, withConst); + const out = (x: number) => cell(log ? Math.exp(x) : x); + const width = p.cols.length + 1; + const first = [...[...fit.coef].reverse().map(out), out(fit.intercept)]; + if (!stats) return { kind: 'array', rows: 1, cols: width, data: first }; + const na = err('NA'); + const pad = (cells: Scalar[]) => [...cells, ...new Array(width - cells.length).fill(na)]; + const seB = withConst ? cell(fit.seIntercept) : na; + const rows: Scalar[][] = [ + first, + [...[...fit.se].reverse().map(cell), seB], + pad([cell(fit.r2), cell(fit.sey)]), + pad([cell(fit.f), fit.df]), + pad([cell(fit.ssreg), cell(fit.ssresid)]), + ]; + return { kind: 'array', rows: 5, cols: width, data: rows.flat() }; + }; +} + +/** TREND, or GROWTH when `log` is set: the fitted values at new x. */ +function predict(log: boolean): Run { + return ([yv, xv, newV, constV]) => { + const p = problemOf(yv, xv, log ? Math.log : undefined); + if (failed(p)) return p; + const withConst = flagOf(constV, true); + if (isError(withConst)) return withConst; + const fit = leastSquares(p.cols, p.y, withConst); + const k = p.cols.length; + let points: number[][]; + let shape: { rows: number; cols: number }; + if (omitted(newV)) { + points = p.y.map((_, i) => p.cols.map((c) => c[i])); + const ya = asArray(yv); + shape = { rows: ya.rows, cols: ya.cols }; + } else { + const na = block(newV as Value); + if (isError(na)) return na; + if (k === 1) { + points = (na.data as number[]).map((x) => [x]); + shape = { rows: na.rows, cols: na.cols }; + } else if (!p.byRow && na.cols === k) { + points = Array.from({ length: na.rows }, (_, i) => na.data.slice(i * k, i * k + k) as number[]); + shape = { rows: na.rows, cols: 1 }; + } else if (p.byRow && na.rows === k) { + points = Array.from({ length: na.cols }, (_, i) => p.cols.map((_, j) => na.data[j * na.cols + i] as number)); + shape = { rows: 1, cols: na.cols }; + } else { + return err('REF'); + } + } + const data = points.map((pt) => { + const v = fit.intercept + pt.reduce((s, x, j) => s + fit.coef[j] * x, 0); + return cell(log ? Math.exp(v) : v); + }); + return { kind: 'array', ...shape, data }; + }; +} + +export const runs: Readonly> = { + LINEST: estimate(false), + LOGEST: estimate(true), + TREND: predict(false), + GROWTH: predict(true), +}; diff --git a/app/src/features/tables/engine/formula/fn/stats.test.ts b/app/src/features/tables/engine/formula/fn/stats.test.ts new file mode 100644 index 000000000..100e46cb5 --- /dev/null +++ b/app/src/features/tables/engine/formula/fn/stats.test.ts @@ -0,0 +1,643 @@ +// The statistical families. Expected values are worked by hand in the comments, come from published tables or the +// documented examples of the spreadsheet functions, or are NIST's certified values; none is read back from the code. +// Formulas run through the engine, so argument delivery is tested too. + +import fc from 'fast-check'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { createTable, setCellInput } from '../../edit'; +import { EN_US } from '../../locale'; +import type { Table } from '../../model'; +import { spillRange } from '../../recalc'; +import { ERROR_TOKENS, arrayOf, isArray, isError, toScalar, type Scalar, type Value } from '../../values'; +import fixture from '../../../fixtures/fn/stats.json'; +import norris from '../../../fixtures/fn/nist/Norris.json'; +import { fnStats, type FnText } from '../../../../../strings/en/fnStats'; +import { REGISTRY, paramsOf } from '.'; +import { loadStatistics, onStatisticsLoaded, statisticsLoaded } from './statsKit'; +import { fill, runs as etsRuns, startOf } from './forecastEts'; +import { leastSquares } from './regression'; + +const COLUMNS = 'ABCDEFGHIJKLMNOP'.split('').map((name) => ({ name, type: 'number' as const })); +const RESULT = { row: 0, col: 8 }; + +/** + * Runs a formula in cell I1 of a table with the given data in columns A to G. A result that spills comes back as + * the array of its block, so array functions can be checked whole. + */ +function calc(formula: string, data: string[][] = []): Value { + const rows = Array.from({ length: Math.max(12, data.length) }, (_, r) => + Array.from({ length: COLUMNS.length }, (_, c) => data[r]?.[c] ?? ''), + ); + let table: Table = createTable(COLUMNS, rows, EN_US, undefined, 1); + table = setCellInput(table, { ...RESULT, raw: `=${formula}` }, EN_US); + const box = spillRange(table, RESULT.col, RESULT.row); + if (box === undefined) return table.rows[RESULT.row].cells[RESULT.col].value; + const block: Scalar[][] = []; + for (let r = box.r1; r <= box.r2; r++) { + block.push(table.rows[r].cells.slice(box.c1, box.c2 + 1).map((cell) => toScalar(cell.value))); + } + return arrayOf(block); +} + +const code = (v: Value): unknown => (isError(v) ? ERROR_TOKENS[v.error] : v); + +/** True when `actual` matches `expected` to `digits` significant digits. */ +function near(actual: Value, expected: number, digits = 14): void { + expect(typeof actual, `got ${JSON.stringify(actual)}`).toBe('number'); + const scale = Math.max(Math.abs(expected), 1e-300); + expect(Math.abs((actual as number) - expected) / scale, `${String(actual)} vs ${expected}`).toBeLessThan( + 5 * 10 ** -digits, + ); +} + +/** The items of an array result, row by row. */ +const items = (v: Value): unknown[] => (isArray(v) ? v.data.map((x) => code(x)) : [code(v)]); + +const STATISTICAL = + `AVEDEV AVERAGE AVERAGEA AVERAGEIF AVERAGEIFS BETA.DIST BETA.INV BINOM.DIST BINOM.DIST.RANGE BINOM.INV +CHISQ.DIST CHISQ.DIST.RT CHISQ.INV CHISQ.INV.RT CHISQ.TEST CONFIDENCE.NORM CONFIDENCE.T CORREL COUNT COUNTA COUNTBLANK +COUNTIF COUNTIFS COVARIANCE.P COVARIANCE.S DEVSQ EXPON.DIST F.DIST F.DIST.RT F.INV F.INV.RT F.TEST FISHER FISHERINV +FORECAST FORECAST.LINEAR FORECAST.ETS FORECAST.ETS.CONFINT FORECAST.ETS.SEASONALITY FORECAST.ETS.STAT FREQUENCY GAMMA +GAMMA.DIST GAMMA.INV GAMMALN GAMMALN.PRECISE GAUSS GEOMEAN GROWTH HARMEAN HYPGEOM.DIST INTERCEPT KURT LARGE LINEST LOGEST +LOGNORM.DIST LOGNORM.INV MAX MAXA MAXIFS MEDIAN MIN MINA MINIFS MODE.MULT MODE.SNGL NEGBINOM.DIST NORM.DIST NORM.INV +NORM.S.DIST NORM.S.INV PEARSON PERCENTILE.EXC PERCENTILE.INC PERCENTRANK.EXC PERCENTRANK.INC PERMUT PERMUTATIONA PHI +POISSON.DIST PROB QUARTILE.EXC QUARTILE.INC RANK.AVG RANK.EQ RSQ SKEW SKEW.P SLOPE SMALL STANDARDIZE STDEV.P STDEV.S +STDEVA STDEVPA STEYX T.DIST T.DIST.2T T.DIST.RT T.INV T.INV.2T T.TEST TREND TRIMMEAN VAR.P VAR.S VARA VARPA WEIBULL.DIST +Z.TEST COUNTUNIQUE`.split(/\s+/); + +const COMPATIBILITY = `BETADIST BETAINV BINOMDIST CHIDIST CHIINV CHITEST CONFIDENCE COVAR CRITBINOM EXPONDIST FDIST FINV +FTEST GAMMADIST GAMMAINV HYPGEOMDIST LOGINV LOGNORMDIST MODE NEGBINOMDIST NORMDIST NORMINV NORMSDIST NORMSINV PERCENTILE +PERCENTRANK POISSON QUARTILE RANK STDEV STDEVP TDIST TINV TTEST VAR VARP WEIBULL ZTEST`.split(/\s+/); + +// This block runs first, before anything loads the chunk: vitest gives each test file its own modules. +describe('the lazy chunk of distributions and compatibility names (lane test)', () => { + it('shows #BUSY! until the chunk loads, then calculates, and tells listeners', async () => { + expect(statisticsLoaded()).toBe(false); + let told = 0; + const stop = onStatisticsLoaded(() => (told += 1)); + expect(code(calc('NORM.S.DIST(1,TRUE)'))).toBe('#BUSY!'); + expect(code(calc('NORMSDIST(1)'))).toBe('#BUSY!'); + expect(calc('STDEV.P(2,4,4,4,5,5,7,9)')).toBe(2); + await loadStatistics(); + stop(); + expect(told).toBe(1); + expect(statisticsLoaded()).toBe(true); + near(calc('NORM.S.DIST(1,TRUE)'), 0.841344746068543); + }); +}); + +describe('the lane tests', () => { + beforeAll(() => loadStatistics()); + + it('STDEV and VAR of 2, 4, 4, 4, 5, 5, 7, 9 (lane test)', () => { + // The mean is 5 and the squared deviations add to 32: 32/7 and 32/8. + const data = '{2,4,4,4,5,5,7,9}'; + near(calc(`STDEV.S(${data})`), 2.1380899352994); + expect(calc(`STDEV.P(${data})`)).toBe(2); + near(calc(`VAR.S(${data})`), 4.57142857142857); + expect(calc(`VAR.P(${data})`)).toBe(4); + }); + + it('the normal, binomial, and Poisson laws (lane test)', () => { + near(calc('NORM.S.DIST(1,TRUE)'), 0.841344746068543); + near(calc('NORM.S.INV(0.975)'), 1.95996398454005); + // C(10, 6) / 2^10 = 210/1024, and 5^2 e^-5 / 2!. + expect(calc('BINOM.DIST(6,10,0.5,FALSE)')).toBe(0.205078125); + near(calc('POISSON.DIST(2,5,FALSE)'), 0.0842243374885683); + }); + + it('percentiles, quartiles, mode, rank, and GAMMALN (lane test)', () => { + // Position 0.3 x 3 = 0.9 between 1 and 2; position 0.25 x 7 = 1.75 between 2 and 4. + near(calc('PERCENTILE.INC({1,2,3,4},0.3)'), 1.9); + near(calc('QUARTILE.INC({1,2,4,7,8,9,10,12},1)'), 3.5); + expect(calc('MODE.SNGL({1,2,2,3})')).toBe(2); + // One value above 3, and two threes share places 2 and 3. + expect(calc('RANK.AVG(3,{1,3,3,5})')).toBe(2.5); + near(calc('GAMMALN(4)'), Math.log(6)); + near(calc('GAMMALN(4)'), 1.79175946922805); + }); + + it('LINEST on the NIST Norris dataset matches the certified values to 12 digits (lane test)', () => { + const data = norris.y.map((y, i) => [String(y), String(norris.x[i])]); + const fit = calc('LINEST(A1:A36,B1:B36,TRUE,TRUE)', data); + if (!isArray(fit)) throw new Error(`LINEST gave ${JSON.stringify(fit)}`); + expect([fit.rows, fit.cols]).toEqual([5, 2]); + const at = (r: number, c: number) => fit.data[r * 2 + c]; + const want = norris.certified; + near(at(0, 0), want.slope, 12); + near(at(0, 1), want.intercept, 12); + near(at(1, 0), want.slopeSe, 12); + near(at(1, 1), want.interceptSe, 12); + near(at(2, 0), want.rSquared, 12); + near(at(2, 1), want.residualSd, 12); + expect(at(3, 1)).toBe(want.df); + near(calc('SLOPE(A1:A36,B1:B36)', data), want.slope, 12); + near(calc('INTERCEPT(A1:A36,B1:B36)', data), want.intercept, 12); + }); + + it('NORM.INV undoes NORM.DIST within 1e-9 for 1,000 random inputs (lane test, property)', () => { + const dist = REGISTRY.get('NORM.DIST')!; + const inv = REGISTRY.get('NORM.INV')!; + const env = { today: () => 0 }; + // z stays where a double keeps the upper tail's digits: above z = 3, 1 - p has too few bits to invert. + const input = fc.record({ + m: fc.double({ min: -100, max: 100, noNaN: true }), + s: fc.double({ min: 0.01, max: 100, noNaN: true }), + z: fc.double({ min: -6, max: 3, noNaN: true }), + }); + fc.assert( + fc.property(input, ({ m, s, z }) => { + const x = m + z * s; + const p = dist.run([x, m, s, true], env) as number; + const back = inv.run([p, m, s], env) as number; + return Math.abs(back - x) <= 1e-9 * Math.max(1, Math.abs(x)); + }), + { numRuns: 1000 }, + ); + }); +}); + +describe('the registry', () => { + beforeAll(() => loadStatistics()); + + it('holds all 111 statistical and 38 compatibility names, each in its family', () => { + // The design's statistical row: 111 Excel names plus the Sheets name COUNTUNIQUE. + expect(STATISTICAL).toHaveLength(112); + expect(COMPATIBILITY).toHaveLength(38); + for (const name of STATISTICAL) expect(REGISTRY.get(name)?.family, name).toBe('stats'); + for (const name of COMPATIBILITY) expect(REGISTRY.get(name)?.family, name).toBe('statsCompat'); + const extra = [...REGISTRY.values()] + .filter((s) => s.family === 'stats' || s.family === 'statsCompat') + .map((s) => s.name) + .filter((name) => !STATISTICAL.includes(name) && !COMPATIBILITY.includes(name)); + expect(extra).toEqual([]); + }); + + it('describes every function and every argument in fnStats, with an example of our own', () => { + const text = fnStats as Readonly>; + for (const name of [...STATISTICAL, ...COMPATIBILITY]) { + const spec = REGISTRY.get(name)!; + const entry = text[name]; + expect(entry?.description, name).toMatch(/^[A-Z].*\.$/); + for (const param of paramsOf(spec)) expect(entry?.params[param.name], `${name} ${param.name}`).toMatch(/\.$/); + expect(spec.meta.signature.startsWith(`${name}(`), name).toBe(true); + expect(spec.meta.example, name).toMatch(new RegExp(`^=${name.replace(/\./g, '\\.')}\\(`)); + } + }); + + it('gives #VALUE! for too few or too many arguments', () => { + expect(code(calc('NORM.DIST(1,2,3)'))).toBe('#VALUE!'); + expect(code(calc('STDEV.S()'))).toBe('#VALUE!'); + expect(code(calc('PERCENTILE.INC({1,2},0.5,1)'))).toBe('#VALUE!'); + expect(code(calc('TDIST(1,2)'))).toBe('#VALUE!'); + }); +}); + +describe('the compatibility names', () => { + beforeAll(() => loadStatistics()); + + it('give what their modern forms give', () => { + const pairs: [string, string][] = [ + ['STDEV(2,4,4,4,5,5,7,9)', 'STDEV.S(2,4,4,4,5,5,7,9)'], + ['VARP(1,2,3,4)', 'VAR.P(1,2,3,4)'], + ['NORMSDIST(1.5)', 'NORM.S.DIST(1.5,TRUE)'], + ['NORMSINV(0.3)', 'NORM.S.INV(0.3)'], + ['CHIDIST(3,4)', 'CHISQ.DIST.RT(3,4)'], + ['CHIINV(0.2,4)', 'CHISQ.INV.RT(0.2,4)'], + ['FDIST(2,3,4)', 'F.DIST.RT(2,3,4)'], + ['FINV(0.2,3,4)', 'F.INV.RT(0.2,3,4)'], + ['TINV(0.1,7)', 'T.INV.2T(0.1,7)'], + ['TDIST(1.2,7,2)', 'T.DIST.2T(1.2,7)'], + ['TDIST(1.2,7,1)', 'T.DIST.RT(1.2,7)'], + ['LOGNORMDIST(2,0.1,0.5)', 'LOGNORM.DIST(2,0.1,0.5,TRUE)'], + ['HYPGEOMDIST(1,4,8,20)', 'HYPGEOM.DIST(1,4,8,20,FALSE)'], + ['NEGBINOMDIST(10,5,0.25)', 'NEGBINOM.DIST(10,5,0.25,FALSE)'], + ['CRITBINOM(6,0.5,0.75)', 'BINOM.INV(6,0.5,0.75)'], + ['BETADIST(0.3,2,3)', 'BETA.DIST(0.3,2,3,TRUE)'], + ['QUARTILE({1,2,4,7,8,9,10,12},3)', 'QUARTILE.INC({1,2,4,7,8,9,10,12},3)'], + ['PERCENTRANK({1,2,3,4,5},2.5)', 'PERCENTRANK.INC({1,2,3,4,5},2.5)'], + ['RANK(3,{1,3,3,5},1)', 'RANK.EQ(3,{1,3,3,5},1)'], + ['MODE(4,4,1,1)', 'MODE.SNGL(4,4,1,1)'], + ['COVAR({3,2,4,5,6},{9,7,12,15,17})', 'COVARIANCE.P({3,2,4,5,6},{9,7,12,15,17})'], + ['ZTEST({3,6,7,8,6,5,4,2,1,9},4)', 'Z.TEST({3,6,7,8,6,5,4,2,1,9},4)'], + ['TTEST({1,3},{3,5},2,2)', 'T.TEST({1,3},{3,5},2,2)'], + ]; + for (const [old, modern] of pairs) expect(calc(old), old).toEqual(calc(modern)); + }); +}); + +describe('regression and forecasting', () => { + beforeAll(() => loadStatistics()); + + it('LINEST with stats on a line worked by hand', () => { + // x 1..4, y 2,3,5,6: slope 7/5, intercept 1/2, residuals .1 -.3 .3 -.1, so SSresid 0.2 and SSreg 9.8. + const fit = items(calc('LINEST({2,3,5,6},{1,2,3,4},TRUE,TRUE)')); + const want = [1.4, 0.5, Math.sqrt(0.02), Math.sqrt(0.15), 0.98, Math.sqrt(0.1), 98, 2, 9.8, 0.2]; + want.forEach((w, i) => near(fit[i] as number, w, 12)); + }); + + it('LINEST without an intercept, with two variables, and with a column that repeats another', () => { + expect(items(calc('LINEST({2,4,6},{1,2,3},FALSE)'))).toEqual([2, 0]); + // y = 1 + 2 a + 3 b exactly, so the coefficients come back b first. + const two = items(calc('LINEST({6;8;14;16;18},{1,1;2,1;2,3;3,3;1,5})')); + [3, 2, 1].forEach((w, i) => near(two[i] as number, w, 12)); + const same = items(calc('LINEST({2;3;5;6},{1,2;2,4;3,6;4,8},TRUE,TRUE)')); + expect(same.slice(0, 3).filter((v) => v === 0)).toHaveLength(1); + expect(same[10]).toBe(2); + expect(same[8]).toBe('#N/A'); + expect(code(calc('LINEST({1,2,3},{1,2})'))).toBe('#REF!'); + expect(code(calc('LINEST({1,"a",3})'))).toBe('#VALUE!'); + }); + + it('LOGEST, TREND, and GROWTH', () => { + // y = 1 * 2^x exactly. + const log = items(calc('LOGEST({2,4,8,16},{1,2,3,4})')); + near(log[0] as number, 2, 12); + near(log[1] as number, 1, 12); + expect(items(calc('TREND({3,5,7},{1,2,3},{4;5})'))).toEqual([9, 11]); + near(calc('GROWTH({2,4,8},{1,2,3},4)'), 16, 12); + expect(code(calc('LOGEST({1,0,2})'))).toBe('#NUM!'); + }); + + it('FORECAST.ETS forecasts a line and a line plus a season exactly', () => { + near(calc('FORECAST.ETS(7,{2,4,6,8,10,12},{1,2,3,4,5,6})'), 14, 9); + // y = t plus the season 0, 4, 0, -4. + const y = '{1,6,3,0,5,10,7,4,9,14,11,8}'; + const t = '{1,2,3,4,5,6,7,8,9,10,11,12}'; + expect(calc(`FORECAST.ETS.SEASONALITY(${y},${t})`)).toBe(4); + near(calc(`FORECAST.ETS(13,${y},${t})`), 13, 9); + near(calc(`FORECAST.ETS(14,${y},${t})`), 18, 9); + near(calc(`FORECAST.ETS(14,${y},${t},4)`), 18, 9); + expect(calc(`FORECAST.ETS.STAT(${y},${t},8)`)).toBe(1); + expect(Math.abs(calc(`FORECAST.ETS.CONFINT(13,${y},${t})`) as number)).toBeLessThan(1e-6); + }); + + it('FORECAST.ETS averages repeated times, fills a gap, and refuses an uneven timeline', () => { + // Time 2 averages 3 and 5 to 4; time 3 is filled with 6; the series 2, 4, 6, 8, 10, 12 is a line. + near(calc('FORECAST.ETS(7,{2,3,5,8,10,12},{1,2,2,4,5,6})'), 14, 9); + expect(code(calc('FORECAST.ETS(7,{1,2,3},{1,2,3.5})'))).toBe('#NUM!'); + expect(code(calc('FORECAST.ETS(2,{2,4,6,8},{1,2,3,4})'))).toBe('#NUM!'); + expect(code(calc('FORECAST.ETS(5,{2,4,6},{1,2,3,4})'))).toBe('#N/A'); + expect(code(calc('FORECAST.ETS.STAT({2,4,6,8},{1,2,3,4},9)'))).toBe('#NUM!'); + }); +}); + +describe('conditional and array functions in a table', () => { + beforeAll(() => loadStatistics()); + + const data = [ + ['1', 'x', '10'], + ['2', 'x', '20'], + ['3', 'y', '30'], + ['4', 'x', ''], + ]; + + it('COUNTIF, COUNTIFS, AVERAGEIF, AVERAGEIFS, MAXIFS, MINIFS, COUNTBLANK', () => { + expect(calc('COUNTIF(A1:A4,">1")', data)).toBe(3); + expect(calc('COUNTIFS(A1:A4,">1",B1:B4,"x")', data)).toBe(2); + expect(calc('AVERAGEIF(B1:B4,"x",C1:C4)', data)).toBe(15); + expect(calc('AVERAGEIFS(C1:C4,B1:B4,"x",A1:A4,">1")', data)).toBe(20); + expect(calc('MAXIFS(C1:C4,B1:B4,"x")', data)).toBe(20); + expect(calc('MINIFS(C1:C4,A1:A4,">1")', data)).toBe(20); + expect(calc('COUNTBLANK(C1:C4)', data)).toBe(1); + expect(code(calc('AVERAGEIF(B1:B4,"z",C1:C4)', data))).toBe('#DIV/0!'); + expect(code(calc('COUNTIFS(A1:A4,">1",B1:B3,"x")', data))).toBe('#VALUE!'); + expect(items(calc('COUNTIF(A1:A4,{1,2})', data))).toEqual([1, 1]); + }); + + it('skips text and empty cells in a column but counts typed TRUE in the A functions', () => { + expect(calc('AVERAGE(C1:C4)', data)).toBe(20); + expect(calc('STDEV.P(C1:C4)', data) as number).toBeCloseTo(Math.sqrt(200 / 3), 12); + expect(calc('AVERAGEA(B1:B4)', data)).toBe(0); + expect(calc('MAXA(0,0.2,TRUE)')).toBe(1); + expect(calc('COUNTUNIQUE(B1:B4)', data)).toBe(2); + }); +}); + +interface Case { + formula: string; + cells?: Record; + expected: number | string | (number | string)[]; + tolerance?: number; + source: string; + note: string; + /** Marks a case that the fixture contract counts: a boundary, an error, or a wrong argument count. */ + kind?: 'boundary' | 'error' | 'arity'; +} + +/** Cells such as {"A1": 3} as rows of text for calc. */ +function rowsOf(cells: Record = {}): string[][] { + const rows: string[][] = []; + for (const [ref, value] of Object.entries(cells)) { + const r = Number(ref.slice(1)) - 1; + const c = ref.charCodeAt(0) - 65; + rows[r] ??= []; + rows[r][c] = String(value); + } + return Array.from({ length: rows.length }, (_, r) => rows[r] ?? []); +} + +describe('the fixture of known answers', () => { + beforeAll(() => loadStatistics()); + const cases = (fixture as { cases: Case[] }).cases; + + it('has a source and a note for every case', () => { + expect(cases.length).toBeGreaterThan(80); + for (const c of cases) { + expect(c.source, c.formula).toMatch(/^(hand|excel-doc|sheets-doc|nist|dlmf|table)/); + expect(c.note.length, c.formula).toBeGreaterThan(3); + } + }); + + it('gives every function five cases with a boundary, an error, and an argument count (lane test)', () => { + const lead = (c: Case) => /^=([A-Z][A-Z0-9.]*)\(/.exec(c.formula)?.[1]; + const isError = (c: Case) => typeof c.expected === 'string' && c.expected.startsWith('#'); + const short: string[] = []; + for (const name of [...STATISTICAL, ...COMPATIBILITY]) { + const own = cases.filter((c) => lead(c) === name); + const boundary = own.some((c) => c.kind === 'boundary'); + const error = own.some((c) => c.kind === 'error' || (c.kind !== 'arity' && isError(c))); + const arity = own.some((c) => c.kind === 'arity' && c.expected === '#VALUE!'); + if (own.length < 5 || !boundary || !error || !arity) short.push(`${name} (${own.length})`); + } + expect(short).toEqual([]); + }); + + it.each(cases.map((c) => [c.formula, c] as const))('%s', (_formula, c) => { + const got = calc(c.formula.slice(1), rowsOf(c.cells)); + const wants = Array.isArray(c.expected) ? c.expected : [c.expected]; + const have = items(got); + expect(have, c.note).toHaveLength(wants.length); + wants.forEach((want, i) => { + if (typeof want === 'number') { + const tol = c.tolerance ?? 1e-12; + expect(Math.abs((have[i] as number) - want), `${c.formula}: ${String(have[i])}`).toBeLessThanOrEqual( + tol * Math.max(1, Math.abs(want)), + ); + } else { + expect(have[i], c.formula).toBe(want); + } + }); + }); +}); + +// Phi(1) from the table of the normal law (Abramowitz and Stegun, table 26.1). +const PHI1 = 0.8413447460685429; + +describe('discrete laws with huge arguments', () => { + beforeAll(() => loadStatistics()); + + it('PERMUT stops once the product overflows', () => { + expect(code(calc('PERMUT(1E300,1E300)'))).toBe('#NUM!'); + expect(code(calc('PERMUT(2E8,2E8)'))).toBe('#NUM!'); + expect(calc('PERMUT(100,3)')).toBe(970200); + }); + + it('BINOM.INV, CRITBINOM, and BINOM.DIST.RANGE bisect or subtract past a thousand trials', () => { + // The median of a symmetric binomial law with an even number of trials is half of them. + expect(calc('BINOM.INV(1E7,0.5,0.5)')).toBe(5e6); + expect(calc('BINOM.INV(1E9,0.5,0.5)')).toBe(5e8); + expect(calc('CRITBINOM(1E7,0.5,0.5)')).toBe(5e6); + // P(X <= k) < 1 for every k < n, so alpha = 1 is reached only at n. + expect(calc('BINOM.INV(1E7,0.5,1)')).toBe(1e7); + // 1 - 1E-13 is Phi(7.35), so the answer is 7 to 8 standard deviations (sqrt(n) / 2 = 1581) above the median. + const top = calc('BINOM.INV(1E7,0.5,0.9999999999999)') as number; + expect(top).toBeGreaterThan(5e6 + 7 * 1581); + expect(top).toBeLessThan(5e6 + 8 * 1581); + near(calc('BINOM.DIST.RANGE(1E6,0.5,0,1E6)'), 1, 12); + // Half, plus half the central term C(n, n/2) / 2^n = sqrt(2 / (pi n)) (1 - 1 / (4 n)). + near(calc('BINOM.DIST.RANGE(1E6,0.5,0,500000)'), 0.5 + Math.sqrt(2 / (Math.PI * 1e6)) * (1 - 2.5e-7) * 0.5, 10); + near(calc('BINOM.DIST.RANGE(1E9,0.5,0,1E9)'), 1, 12); + }); + + it('HYPGEOM.DIST sums from k away from the mode', () => { + // Draws, successes, and half the population alike: the law is symmetric about 250000. + const lower = calc('HYPGEOM.DIST(249999,500000,500000,1000000,TRUE)') as number; + const upper = calc('HYPGEOM.DIST(250000,500000,500000,1000000,TRUE)') as number; + near(lower + upper, 1, 9); + // The central term is about 1 / sqrt(2 pi var), var = n K (N - K) (N - n) / (N^2 (N - 1)). + const sd = Math.sqrt(6.25e22 / (1e12 * 999999)); + near(upper - lower, 1 / (sd * Math.sqrt(2 * Math.PI)), 4); + const big = calc('HYPGEOM.DIST(5E8,1E9,1E9,2E9,TRUE)') as number; + expect(big).toBeGreaterThan(0.49); + expect(big).toBeLessThan(0.51); + near(calc('HYPGEOM.DIST(1,4,8,20,TRUE)'), 0.46542827657378744, 12); + }); +}); + +describe('the F and t laws at many degrees of freedom', () => { + beforeAll(() => loadStatistics()); + + it('the F law keeps its digits at a million and ten billion degrees of freedom', () => { + // With equal degrees of freedom, the law of F is the law of 1 / F. So F <= 1 has probability one half. + near(calc('F.DIST(1,1E6,1E6,TRUE)'), 0.5, 12); + near(calc('F.DIST(1,1E10,1E10,TRUE)'), 0.5, 10); + near(calc('F.DIST.RT(1,1E6,1E6)'), 0.5, 12); + near(calc('FDIST(1,1E6,1E6)'), 0.5, 12); + near(calc('F.INV(0.5,1E6,1E6)'), 1, 12); + near(calc('F.INV.RT(0.5,1E6,1E6)'), 1, 12); + near(calc('FINV(0.5,1E6,1E6)'), 1, 12); + // As d2 grows, d1 F tends to chi-squared with d1 degrees of freedom: for d1 = 2, P = 1 - e^-1. + near(calc('F.DIST(1,2,1E10,TRUE)'), 1 - Math.exp(-1), 9); + // ln F is near normal with variance 2 / d1 + 2 / d2 = 4E-6, so ln F = 0.002 is one deviation up. + near(calc('F.DIST(EXP(0.002),1E6,1E6,TRUE)'), PHI1, 5); + near(calc('F.DIST(1,1E6,1E6,FALSE)'), 1 / Math.sqrt(2 * Math.PI * 4e-6), 5); + }); + + it('the t functions take 1 to 1E10 degrees of freedom and become the normal law', () => { + expect(code(calc('T.DIST(1,1E15,TRUE)'))).toBe('#NUM!'); + expect(code(calc('T.DIST.2T(1,1E11)'))).toBe('#NUM!'); + expect(code(calc('T.INV(0.9,1E11)'))).toBe('#NUM!'); + expect(code(calc('TDIST(1,1E11,1)'))).toBe('#NUM!'); + near(calc('T.DIST(1,1E10,TRUE)'), PHI1, 10); + near(calc('T.DIST.RT(1,1E10)'), 1 - PHI1, 9); + near(calc('T.DIST(0,1E10,FALSE)'), 1 / Math.sqrt(2 * Math.PI), 10); + near(calc('T.INV(0.975,1E10)'), 1.959963984540054, 9); + near(calc('T.INV.2T(0.05,1E10)'), 1.959963984540054, 9); + }); +}); + +describe('FORECAST.ETS work stays bounded', () => { + beforeAll(() => loadStatistics()); + + it('FORECAST.ETS refuses a timeline wider than ten thousand steps', () => { + expect(code(calc('FORECAST.ETS(200000,{1,2,3},{1,2,100001})'))).toBe('#NUM!'); + expect(code(calc('FORECAST.ETS.SEASONALITY({1,2,3},{1,2,1E9})'))).toBe('#NUM!'); + expect(code(calc('FORECAST.ETS.CONFINT(5,{1,2,3},{1,2,1E9})'))).toBe('#NUM!'); + }); + + it('FORECAST.ETS fits a long series with a long season in bounded time', () => { + // 3000 points of a line plus a season of 1000: the forecast continues both exactly. + const n = 3000; + const wave = (i: number) => i + 1 + 5 * Math.sin((2 * Math.PI * i) / 1000); + const y = Array.from({ length: n }, (_, i) => wave(i)); + const t = Array.from({ length: n }, (_, i) => i + 1); + const got = etsRuns['FORECAST.ETS']([n + 1, arrayOf([y]), arrayOf([t]), 1000], undefined as never); + near(got, wave(n), 9); + }, 20000); + + it('the start values match a least-squares fit with season columns', () => { + const y = [3, 7, 4, 9, 5, 8, 6, 11, 7, 12, 8, 13, 9, 14]; + const m = 3; + const dummies = [1, 2].map((p) => y.map((_, i) => (i % m === p ? 1 : 0))); + const fit = leastSquares([y.map((_, i) => i), ...dummies], y, true); + const raw = [0, fit.coef[1], fit.coef[2]]; + const shift = (raw[0] + raw[1] + raw[2]) / 3; + const st = startOf(y, m); + near(st.trend, fit.coef[0], 12); + near(st.level, fit.intercept + shift - fit.coef[0], 12); + st.season.forEach((s, p) => expect(Math.abs(s - (raw[p] - shift))).toBeLessThan(1e-12)); + }); +}); + +describe('FORECAST.ETS parts', () => { + beforeAll(() => loadStatistics()); + + it('fills gaps in one pass: flat at the ends, straight lines between', () => { + expect(fill([null, 2, null, null, 8, null], 1)).toEqual([2, 2, 4, 6, 8, 8]); + expect(fill([null, 2, null, 8], 0)).toEqual([0, 2, 0, 8]); + }); + + it('FORECAST.ETS.CONFINT follows the AAA variance formula and takes a far target', () => { + const y = '{3,7,4,9,5,8,6,11,7,12,8,13}'; + const t = '{1,2,3,4,5,6,7,8,9,10,11,12}'; + const [a, b, g, rmse] = [1, 2, 3, 7].map((k) => calc(`FORECAST.ETS.STAT(${y},${t},${k})`) as number); + const m = calc(`FORECAST.ETS.SEASONALITY(${y},${t})`) as number; + for (const h of [1, 2, 5, 9, 25]) { + // sigma^2 (1 + sum of c_j^2 for j < h), c_j = alpha (1 + j beta), plus gamma (1 - alpha) on whole seasons. + let factor = 1; + for (let j = 1; j < h; j++) { + const c = a * (1 + j * b) + (m > 1 && j % m === 0 ? g * (1 - a) : 0); + factor += c * c; + } + near(calc(`FORECAST.ETS.CONFINT(${12 + h},${y},${t})`), 1.959963984540054 * rmse * Math.sqrt(factor), 9); + } + expect(typeof calc(`FORECAST.ETS.CONFINT(1E12,${y},${t})`)).toBe('number'); + }); + + it('FORECAST.ETS aggregation 3 counts every value, numbers or not', () => { + // Counts of values 1, 2, 3 make a line, so the next is 4; counts of numbers are 1, 1, 1. + near(calc('FORECAST.ETS(4,{1,"b",3,"d","e",6},{1,2,2,3,3,3},1,1,3)'), 4, 9); + near(calc('FORECAST.ETS(4,{1,"b",3,"d","e",6},{1,2,2,3,3,3},1,1,2)'), 1, 9); + }); +}); + +describe('long ranges stay bounded (lane test)', () => { + beforeAll(() => loadStatistics()); + const env = { today: () => 0 }; + const run = (name: string, args: Value[]) => REGISTRY.get(name)!.run(args, env); + + it('FREQUENCY sorts the bins once: 100,000 values in 100,000 bins', () => { + const n = 100_000; + // Values 0.5, 1.5, ... and bins 1, 2, ... in reverse order: each bin holds one value, the last none. + const data = arrayOf([Array.from({ length: n }, (_, i) => i + 0.5)]); + const bins = arrayOf([Array.from({ length: n }, (_, i) => n - i)]); + const start = performance.now(); + const got = run('FREQUENCY', [data, bins]); + expect(performance.now() - start).toBeLessThan(3000); + const counts = items(got); + expect(counts).toHaveLength(n + 1); + expect(counts.slice(0, 3)).toEqual([1, 1, 1]); + expect(counts[n]).toBe(0); + // A repeated edge counts once, at its first place; values on an edge fall in its bin. + expect(items(calc('FREQUENCY({1,2,2,3,5,9},{2,5,2,0})'))).toEqual([3, 2, 0, 0, 1]); + }, 20000); + + it('MIN, MAX, the modes, and MAXIFS take a million values without a stack overflow', () => { + const n = 1_000_000; + const values = Array.from({ length: n }, (_, i) => (i % 1000) - 500); + // A list function gets the values of its ranges one by one; MAXIFS and FREQUENCY get the range whole. + const range = arrayOf([values]); + expect(run('MIN', values)).toBe(-500); + expect(run('MAX', values)).toBe(499); + expect(run('MINA', values)).toBe(-500); + expect(run('MAXA', values)).toBe(499); + // Every value appears 1,000 times, so the first, -500, is the mode. + expect(run('MODE.SNGL', values)).toBe(-500); + expect(run('MODE', values)).toBe(-500); + expect(items(run('MODE.MULT', values))).toHaveLength(1000); + expect(run('MAXIFS', [range, range, '<0'])).toBe(-1); + expect(run('MINIFS', [range, range, '>0'])).toBe(1); + expect(items(run('FREQUENCY', [range, arrayOf([[0]])]))).toEqual([501_000, 499_000]); + }, 30000); +}); + +describe('distributions at the edges of a double', () => { + beforeAll(() => loadStatistics()); + + /** Runs a formula and fails if it takes longer than `ms`. */ + function timed(formula: string, ms = 5000): Value { + const start = performance.now(); + const v = calc(formula); + expect(performance.now() - start, formula).toBeLessThan(ms); + return v; + } + + it('BINOM.INV, CRITBINOM, and HYPGEOM.DIST stop past 2^53 counts', () => { + // Past 2^53 a count is not a whole number in a double, so stepping or bisecting through it never ends. + expect(code(timed('BINOM.INV(1E17,0.5,0.5)'))).toBe('#NUM!'); + expect(code(timed('CRITBINOM(1E300,0.5,0.5)'))).toBe('#NUM!'); + expect(code(timed('HYPGEOM.DIST(5E17,1E18,1E18,2E18,TRUE)'))).toBe('#NUM!'); + expect(code(timed('HYPGEOM.DIST(5E29,1E30,1E30,2E30,TRUE)'))).toBe('#NUM!'); + // Under 2^53 a spread of about 2E7 needs far more terms than the cap, so the sum gives up rather than hang. + expect(code(timed('HYPGEOM.DIST(2E15,4E15,4E15,8E15,TRUE)'))).toBe('#NUM!'); + }, 30000); + + it('GAMMA.INV gives #NUM! or the median where the incomplete gamma runs out of terms', () => { + // The median of the gamma law is a - 1/3 + 8 / (405 a) + ... (Choi, 1994). + const v = timed('GAMMA.INV(0.5,1E13,1)', 10000); + if (code(v) !== '#NUM!') near(v, 1e13 - 1 / 3, 9); + }, 30000); + + it('BETA.DIST, BETA.INV, F.DIST, and F.INV keep small x on the wide path', () => { + // For a small x, I_x(a, b) = x^a b^a (1 + a (a - 1) / (2 b)) / Gamma(a + 1), with Gamma(3/2) = sqrt(pi) / 2. + const g = Math.sqrt(Math.PI) / 2; + near(calc('BETA.DIST(1E-30,0.5,1000000,TRUE)'), (1e-15 * 1000 * (1 - 0.125e-6)) / g, 10); + // F with d1 = 1: y = x / (x + d2), a = 1/2, b = d2 / 2. + near(calc('F.DIST(1E-20,1,1000000,TRUE)'), (1e-13 * Math.sqrt(5e5) * (1 - 0.25 / 1e6)) / g, 10); + // The inverse of the first form: x = (p g / (b^a (1 + a (a - 1) / (2 b))))^(1 / a). + near(calc('BETA.INV(1E-9,0.5,1000000)'), ((1e-9 * g) / (1000 * (1 - 0.125e-6))) ** 2, 8); + // F.INV: x = d2 y for a tiny y, with y from the same form at b = 5E5. + near(calc('F.INV(1E-9,1,1000000)'), 1e6 * ((1e-9 * g) / (Math.sqrt(5e5) * (1 - 0.25 / 1e6))) ** 2, 8); + }); + + it('T.DIST and T.INV keep their digits near the center', () => { + // Near 0 the t density is f(0) = (1 - 1 / (4 df)) / sqrt(2 pi), so P(T < t) = 1/2 + f(0) t. + const slope = (df: number) => (1 - 1 / (4 * df)) / Math.sqrt(2 * Math.PI); + near(calc('T.INV(0.5000000001,100000)'), (0.5000000001 - 0.5) / slope(1e5), 8); + near(calc('T.INV.2T(0.999999999,100000)'), (0.5 - 0.999999999 / 2) / slope(1e5), 8); + near(calc('T.INV(0.5000000001,1000000)'), (0.5000000001 - 0.5) / slope(1e6), 8); + near(calc('T.INV(0.5000000001,100000000)'), (0.5000000001 - 0.5) / slope(1e8), 8); + near(calc('T.INV(0.4999999999,100000)'), (0.4999999999 - 0.5) / slope(1e5), 8); + const d = (calc('T.DIST(1E-10,100000,TRUE)') as number) - 0.5; + near(d, 1e-10 * slope(1e5), 6); + }); + + it('BINOM.DIST keeps (1 - p)^(n - k) exact for a tiny p', () => { + // n p (1 - p)^(n - 1), with ln(1 - p) = -p - p^2 / 2 - p^3 / 3 and p^3 n under 1E-18. + const [n, p] = [276719929, 1.53736e-9]; + near(calc(`BINOM.DIST(1,${n},${p},FALSE)`), n * p * Math.exp((n - 1) * (-p - (p * p) / 2)), 12); + }); + + it('BETA.DIST keeps mid-size parameters on the exact power term', () => { + // The beta law with a = b is symmetric about 1/2. + near(calc('BETA.DIST(0.5,99999,99999,TRUE)'), 0.5, 12); + near(calc('BETA.DIST(0.5,5000,5000,TRUE)'), 0.5, 12); + }); + + it('takes the edge values', () => { + // P(X <= 1E308) for a mean of 1 is 1, and so is the F law at 1E308. + expect(timed('POISSON.DIST(1E308,1,TRUE)', 1000)).toBe(1); + expect(calc('F.DIST(1E308,5,5,TRUE)')).toBe(1); + expect(calc('F.DIST.RT(1E308,5,5)')).toBe(0); + // P(X <= k) < 1 for every k < n, so only n reaches alpha = 1. + expect(calc('BINOM.INV(2000,0.5,1)')).toBe(2000); + expect(calc('BINOM.INV(1000,0.5,1)')).toBe(1000); + expect(calc('BINOM.INV(10,0.5,1)')).toBe(10); + expect(code(calc('CHISQ.TEST({1,2},{-1,2})'))).toBe('#NUM!'); + // y = 1, 2, 4 on x = 1, 2, 3 (times 1E200): slope 3 / 2 (over 1E200), intercept 7/3 - 2 (3/2) = -2/3. + const fit = items(calc('LINEST({1,2,4},{1E200,2E200,3E200},TRUE,TRUE)')); + near(fit[0] as number, 1.5e-200, 12); + near(fit[1] as number, -2 / 3, 12); + }); +}); diff --git a/app/src/features/tables/engine/formula/fn/stats.ts b/app/src/features/tables/engine/formula/fn/stats.ts index c3f689e8b..6256134be 100644 --- a/app/src/features/tables/engine/formula/fn/stats.ts +++ b/app/src/features/tables/engine/formula/fn/stats.ts @@ -1,52 +1,191 @@ -// Statistical functions. Owner: tables:fn-stats. These are the functions tables had before the library; their -// behavior is unchanged. +// Statistical functions. Owner: tables:fn-stats. Written from the documented behavior of the spreadsheet functions. +// The basics are here: averages, spreads, modes, order, percentiles, and ranks. Conditional counts are in statsIfs.ts. +// The rest is in the lazy chunk (statsMore.ts, distributions.ts, regression.ts), declared in statsDeclared.ts. -import { err, finish, isNumber, type Value } from '../../values'; +import { arrayOf, err, finish, isError, isNumber, toNumber, type Scalar, type Value } from '../../values'; import { numbersIn, type FnSpec2 } from './types'; +import { functions as conditional } from './statsIfs'; +import { declared } from './statsDeclared'; +import { + devsq, + fixed, + largest, + listOf, + mean, + numbersOf, + overNumbers, + readNumbers, + smallest, + sorted, + type Run, +} from './statsKit'; -const VALUES = [{ repeat: [{ name: 'value', kind: 'range' as const }], min: 1 }]; +const S = 'stats' as const; -function aggregate(name: string, run: (nums: number[]) => Value, example: string): FnSpec2 { - return { +export function median(nums: number[]): Value { + if (nums.length === 0) return err('NUM'); + const s = sorted(nums); + const mid = s.length >> 1; + return s.length % 2 ? s[mid] : finish((s[mid - 1] + s[mid]) / 2); +} + +/** Sample (n - 1) or population (n) variance. */ +export function variance(nums: number[], sample: boolean): Value { + const d = sample ? nums.length - 1 : nums.length; + return d <= 0 ? err('DIV0') : finish(devsq(nums) / d); +} + +const stdev = (nums: number[], sample: boolean): Value => { + const v = variance(nums, sample); + return typeof v === 'number' ? Math.sqrt(v) : v; +}; + +/** The modes in order of first appearance; empty when no value repeats. */ +function modes(nums: number[]): number[] { + const counts = new Map(); + for (const x of nums) counts.set(x, (counts.get(x) ?? 0) + 1); + const top = Math.max(0, largest(counts.values())); + if (top < 2) return []; + return [...counts.entries()].filter(([, c]) => c === top).map(([x]) => x); +} + +export const modeSngl = (nums: number[]): Value => modes(nums)[0] ?? err('NA'); + +/** The A functions read text in a list as 0 and TRUE as 1. Empty cells are skipped. Typed text that is a number counts. */ +function valuesA(args: readonly Value[]): number[] | Scalar { + const out: number[] = []; + for (const v of args) { + if (isError(v)) return v; + if (typeof v === 'number') out.push(v); + else if (typeof v === 'boolean') out.push(v ? 1 : 0); + else if (typeof v === 'string' && v !== '') out.push(0); + } + return out; +} + +function overA(name: string, f: (nums: number[]) => Value, example: string): FnSpec2 { + return listOf( + S, name, - family: 'stats', - params: VALUES, - run: (args) => { - const nums = numbersIn(args); - return Array.isArray(nums) ? run(nums) : nums; + (args) => { + const nums = valuesA(args); + return Array.isArray(nums) ? f(nums) : nums; }, - meta: { signature: `${name}(value, ...)`, example }, + example, + true, + ); +} + +/** The k-th value counted from the bottom (ascending) or the top. */ +function kth(ascending: boolean): Run { + return ([array, k]) => { + const nums = numbersOf(array); + const pos = toNumber(k); + if (!Array.isArray(nums)) return nums; + if (typeof pos !== 'number') return pos; + const i = Math.ceil(pos); + if (i < 1 || i > nums.length) return err('NUM'); + const s = sorted(nums); + return ascending ? s[i - 1] : s[s.length - i]; }; } -function median(nums: number[]): Value { - if (nums.length === 0) return err('NUM'); - const sorted = [...nums].sort((a, b) => a - b); - const mid = sorted.length >> 1; - return sorted.length % 2 ? sorted[mid] : finish((sorted[mid - 1] + sorted[mid]) / 2); +/** The value at a zero-based fractional position in sorted numbers, by straight-line interpolation. */ +function at(s: number[], pos: number): number { + const lo = Math.floor(pos); + const frac = pos - lo; + return frac === 0 ? s[lo] : s[lo] + frac * (s[lo + 1] - s[lo]); } -export const functions: readonly FnSpec2[] = [ - aggregate( - 'AVERAGE', - (n) => (n.length ? finish(n.reduce((a, b) => a + b, 0) / n.length) : err('DIV0')), - '=AVERAGE(2, 4, 9) gives 5', +export function percentile(nums: number[], k: number, exclusive: boolean): Value { + const n = nums.length; + if (n === 0) return err('NUM'); + if (!exclusive) return k < 0 || k > 1 ? err('NUM') : at(sorted(nums), k * (n - 1)); + const rank = k * (n + 1); + if (k <= 0 || k >= 1 || rank < 1 || rank > n) return err('NUM'); + return at(sorted(nums), rank - 1); +} + +function percentileRun(exclusive: boolean): Run { + return ([array, k]) => { + const nums = numbersOf(array); + const p = toNumber(k); + if (!Array.isArray(nums)) return nums; + return typeof p === 'number' ? percentile(nums, p, exclusive) : p; + }; +} + +export function quartileRun(exclusive: boolean): Run { + return ([array, q]) => { + const nums = numbersOf(array); + const quart = toNumber(q); + if (!Array.isArray(nums)) return nums; + if (typeof quart !== 'number') return quart; + const k = Math.trunc(quart); + if (exclusive ? k < 1 || k > 3 : k < 0 || k > 4) return err('NUM'); + return percentile(nums, k / 4, exclusive); + }; +} + +export function rankRun(average: boolean): Run { + return ([value, ref, order]) => { + const nums = numbersOf(ref); + const read = readNumbers([value, order ?? 0]); + if (!Array.isArray(nums)) return nums; + if (!Array.isArray(read)) return read; + const [v, ord] = read; + const equal = nums.filter((x) => x === v).length; + if (equal === 0) return err('NA'); + const before = nums.filter((x) => (ord === 0 ? x > v : x < v)).length; + return average ? before + 1 + (equal - 1) / 2 : before + 1; + }; +} + +const core: FnSpec2[] = [ + overNumbers(S, 'AVERAGE', (n) => (n.length ? finish(mean(n)) : err('DIV0')), '=AVERAGE(2, 4, 9) gives 5'), + overNumbers(S, 'MIN', (n) => (n.length ? smallest(n) : 0), '=MIN(4, -1, 7) gives -1'), + overNumbers(S, 'MAX', (n) => (n.length ? largest(n) : 0), '=MAX(4, -1, 7) gives 7'), + listOf(S, 'COUNT', (args) => args.filter(isNumber).length, '=COUNT(1, 2, 3) gives 3'), + listOf( + S, + 'COUNTA', + (args) => args.filter((v) => v !== null && v !== '').length, + '=COUNTA("a", 1, TRUE) gives 3', + true, ), - aggregate('MIN', (n) => (n.length ? Math.min(...n) : 0), '=MIN(4, -1, 7) gives -1'), - aggregate('MAX', (n) => (n.length ? Math.max(...n) : 0), '=MAX(4, -1, 7) gives 7'), - { - name: 'COUNT', - family: 'stats', - params: VALUES, - run: (args) => args.filter(isNumber).length, - meta: { signature: 'COUNT(value, ...)', example: '=COUNT(1, 2, 3) gives 3' }, - }, - { - name: 'COUNTA', - family: 'stats', - params: [{ repeat: [{ name: 'value', kind: 'range', direct: 'raw' }], min: 1 }], - run: (args) => args.filter((v) => v !== null && v !== '').length, - meta: { signature: 'COUNTA(value, ...)', example: '=COUNTA("a", 1, TRUE) gives 3' }, - }, - aggregate('MEDIAN', median, '=MEDIAN(5, 1, 3, 2) gives 2.5'), + overNumbers(S, 'MEDIAN', median, '=MEDIAN(5, 1, 3, 2) gives 2.5'), + overNumbers(S, 'STDEV.S', (n) => stdev(n, true), '=STDEV.S(2, 4, 4, 4, 5, 5, 7, 9) gives 2.138'), + overNumbers(S, 'STDEV.P', (n) => stdev(n, false), '=STDEV.P(2, 4, 4, 4, 5, 5, 7, 9) gives 2'), + overNumbers(S, 'VAR.S', (n) => variance(n, true), '=VAR.S(1, 2, 3, 4) gives 1.66666666666667'), + overNumbers(S, 'VAR.P', (n) => variance(n, false), '=VAR.P(1, 2, 3, 4) gives 1.25'), + overNumbers(S, 'MODE.SNGL', modeSngl, '=MODE.SNGL(1, 2, 2, 3) gives 2'), + overNumbers( + S, + 'MODE.MULT', + (n) => { + const found = modes(n); + return found.length ? arrayOf(found.map((x) => [x])) : err('NA'); + }, + '=MODE.MULT(1, 1, 2, 2, 3) gives 1 and 2 in a column', + ), + overA('AVERAGEA', (n) => (n.length ? finish(mean(n)) : err('DIV0')), '=AVERAGEA(2, TRUE) gives 1.5'), + overA('MAXA', (n) => (n.length ? largest(n) : 0), '=MAXA(-1, FALSE) gives 0'), + overA('MINA', (n) => (n.length ? smallest(n) : 0), '=MINA(3, TRUE) gives 1'), + overA('STDEVA', (n) => stdev(n, true), '=STDEVA(1, TRUE, 4) gives 1.73205080756888'), + overA('STDEVPA', (n) => stdev(n, false), '=STDEVPA(0, 2) gives 1'), + overA('VARA', (n) => variance(n, true), '=VARA(1, TRUE, 4) gives 3'), + overA('VARPA', (n) => variance(n, false), '=VARPA(0, 2) gives 1'), + fixed(S, 'LARGE', 'array:a k', kth(false), '=LARGE({3, 9, 4}, 2) gives 4'), + fixed(S, 'SMALL', 'array:a k', kth(true), '=SMALL({3, 9, 4}, 2) gives 4'), + fixed(S, 'PERCENTILE.INC', 'array:a k', percentileRun(false), '=PERCENTILE.INC({1, 2, 3, 4}, 0.3) gives 1.9'), + fixed(S, 'PERCENTILE.EXC', 'array:a k', percentileRun(true), '=PERCENTILE.EXC({1, 2, 3, 4}, 0.4) gives 2'), + fixed(S, 'QUARTILE.INC', 'array:a quart', quartileRun(false), '=QUARTILE.INC({1, 2, 3, 4}, 1) gives 1.75'), + fixed(S, 'QUARTILE.EXC', 'array:a quart', quartileRun(true), '=QUARTILE.EXC({1, 2, 3, 4, 5, 6, 7}, 1) gives 2'), + fixed(S, 'RANK.EQ', 'number ref:a [order]', rankRun(false), '=RANK.EQ(3, {1, 3, 3, 5}) gives 2'), + fixed(S, 'RANK.AVG', 'number ref:a [order]', rankRun(true), '=RANK.AVG(3, {1, 3, 3, 5}) gives 2.5'), ]; + +export const stdevOf = stdev; +export { numbersIn }; + +export const functions: readonly FnSpec2[] = [...core, ...conditional, ...declared]; diff --git a/app/src/features/tables/engine/formula/fn/statsCompat.ts b/app/src/features/tables/engine/formula/fn/statsCompat.ts index aeb9b309a..70f8d227c 100644 --- a/app/src/features/tables/engine/formula/fn/statsCompat.ts +++ b/app/src/features/tables/engine/formula/fn/statsCompat.ts @@ -1,5 +1,66 @@ -// Statistical compatibility functions. Owner: tables:fn-stats. A stub until that lane fills it in. +// The compatibility names of the statistical functions: the older spellings spreadsheets still accept, such as STDEV +// for STDEV.S and NORMDIST for NORM.DIST. Owner: tables:fn-stats. Each gives what its modern form gives for the same +// arguments. The ones about distributions, COVAR, and PERCENTRANK run from the lazy chunk; the rest run here. import type { FnSpec2 } from './types'; +import { toNumber, type Value } from '../../values'; +import { modeSngl, percentile, quartileRun, rankRun, stdevOf, variance } from './stats'; +import { fixed, lazy, numbersOf, overNumbers } from './statsKit'; -export const functions: readonly FnSpec2[] = []; +const C = 'statsCompat' as const; + +const LAZY: readonly [string, string, string][] = [ + ['COVAR', 'array1:a array2:a', '=COVAR({1, 2, 3}, {1, 2, 3}) gives 0.666666666666667'], + ['PERCENTRANK', 'array:a x [significance]', '=PERCENTRANK({1, 2, 3, 4, 5}, 2) gives 0.25'], + ['BETADIST', 'x alpha beta [lower] [upper]', '=BETADIST(0.5, 2, 2) gives 0.5'], + ['BETAINV', 'probability alpha beta [lower] [upper]', '=BETAINV(0.5, 2, 2) gives 0.5'], + ['BINOMDIST', 'successes trials probability cumulative:b', '=BINOMDIST(1, 2, 0.5, FALSE) gives 0.5'], + ['CHIDIST', 'x degrees', '=CHIDIST(0, 2) gives 1'], + ['CHIINV', 'probability degrees', '=CHIINV(1, 2) gives 0'], + ['CHITEST', 'actual:a expected:a', '=CHITEST({10, 10}, {10, 10}) gives 1'], + ['CONFIDENCE', 'alpha sd size', '=CONFIDENCE(0.05, 1, 1) gives 1.95996398454005'], + ['CRITBINOM', 'trials probability alpha', '=CRITBINOM(2, 0.5, 0.5) gives 1'], + ['EXPONDIST', 'x lambda cumulative:b', '=EXPONDIST(0, 2, FALSE) gives 2'], + ['FDIST', 'x degrees1 degrees2', '=FDIST(1, 2, 2) gives 0.5'], + ['FINV', 'probability degrees1 degrees2', '=FINV(0.5, 2, 2) gives 1'], + ['FTEST', 'array1:a array2:a', '=FTEST({1, 2, 3}, {2, 3, 4}) gives 1'], + ['GAMMADIST', 'x alpha beta cumulative:b', '=GAMMADIST(0, 1, 1, FALSE) gives 1'], + ['GAMMAINV', 'probability alpha beta', '=GAMMAINV(0, 1, 1) gives 0'], + ['HYPGEOMDIST', 'sampleS sample populationS population', '=HYPGEOMDIST(1, 1, 1, 2) gives 0.5'], + ['LOGINV', 'probability mean sd', '=LOGINV(0.5, 0, 1) gives 1'], + ['LOGNORMDIST', 'x mean sd', '=LOGNORMDIST(1, 0, 1) gives 0.5'], + ['NEGBINOMDIST', 'failures successes probability', '=NEGBINOMDIST(0, 1, 0.5) gives 0.5'], + ['NORMDIST', 'x mean sd cumulative:b', '=NORMDIST(5, 5, 2, TRUE) gives 0.5'], + ['NORMINV', 'probability mean sd', '=NORMINV(0.5, 5, 2) gives 5'], + ['NORMSDIST', 'z', '=NORMSDIST(0) gives 0.5'], + ['NORMSINV', 'probability', '=NORMSINV(0.5) gives 0'], + ['POISSON', 'x mean cumulative:b', '=POISSON(0, 1, FALSE) gives 0.367879441171442'], + ['TDIST', 'x degrees tails', '=TDIST(0, 3, 2) gives 1'], + ['TINV', 'probability degrees', '=TINV(1, 3) gives 0'], + ['TTEST', 'array1:a array2:a tails type', '=TTEST({1, 2, 3}, {1, 2, 3}, 2, 2) gives 1'], + ['WEIBULL', 'x alpha beta cumulative:b', '=WEIBULL(0, 1, 1, TRUE) gives 0'], + ['ZTEST', 'array:a x [sigma]', '=ZTEST({4, 6}, 5) gives 0.5'], +]; + +function percentileInc([array, k]: Value[]): Value { + const nums = numbersOf(array); + const p = toNumber(k); + if (!Array.isArray(nums)) return nums; + return typeof p === 'number' ? percentile(nums, p, false) : p; +} + +const here: FnSpec2[] = [ + overNumbers(C, 'MODE', modeSngl, '=MODE(1, 2, 2, 3) gives 2'), + fixed(C, 'PERCENTILE', 'array:a k', percentileInc, '=PERCENTILE({1, 2, 3, 4}, 0.3) gives 1.9'), + fixed(C, 'QUARTILE', 'array:a quart', quartileRun(false), '=QUARTILE({1, 2, 3, 4}, 1) gives 1.75'), + fixed(C, 'RANK', 'number ref:a [order]', rankRun(false), '=RANK(3, {1, 3, 3, 5}) gives 2'), + overNumbers(C, 'STDEV', (n) => stdevOf(n, true), '=STDEV(2, 4, 4, 4, 5, 5, 7, 9) gives 2.138'), + overNumbers(C, 'STDEVP', (n) => stdevOf(n, false), '=STDEVP(2, 4, 4, 4, 5, 5, 7, 9) gives 2'), + overNumbers(C, 'VAR', (n) => variance(n, true), '=VAR(1, 2, 3, 4) gives 1.66666666666667'), + overNumbers(C, 'VARP', (n) => variance(n, false), '=VARP(1, 2, 3, 4) gives 1.25'), +]; + +export const functions: readonly FnSpec2[] = [ + ...here, + ...LAZY.map(([name, list, example]) => lazy(C, name, list, example)), +]; diff --git a/app/src/features/tables/engine/formula/fn/statsDeclared.ts b/app/src/features/tables/engine/formula/fn/statsDeclared.ts new file mode 100644 index 000000000..33bec8cd3 --- /dev/null +++ b/app/src/features/tables/engine/formula/fn/statsDeclared.ts @@ -0,0 +1,115 @@ +// The statistical functions whose code is in the lazy chunk: shape, percent ranks, pairs, the distributions, the +// tests, and the regression and forecasting functions. Owner: tables:fn-stats. Each is declared here with its +// parameters, so formulas compile and autocomplete lists it before the chunk loads; statsKit.ts runs it once the +// chunk is in. + +import type { FnSpec2 } from './types'; +import { lazy, lazyList } from './statsKit'; + +const S = 'stats' as const; + +/** Functions of any number of values: name and an example of our own. */ +const LISTS: readonly [string, string][] = [ + ['AVEDEV', '=AVEDEV(2, 4, 6) gives 1.33333333333333'], + ['DEVSQ', '=DEVSQ(1, 2, 3) gives 2'], + ['GEOMEAN', '=GEOMEAN(2, 8) gives 4'], + ['HARMEAN', '=HARMEAN(1, 4, 4) gives 2'], + ['KURT', '=KURT(1, 2, 3, 4, 10) gives 3.74096'], + ['SKEW', '=SKEW(1, 2, 10) gives 1.6523'], + ['SKEW.P', '=SKEW.P(1, 2, 10) gives 0.6745'], +]; + +/** Name, parameter list (statsKit.ts paramsFrom), and an example of our own. */ +const TABLE: readonly [string, string, string][] = [ + ['PERCENTRANK.INC', 'array:a x [significance]', '=PERCENTRANK.INC({1, 2, 3, 4, 5}, 2) gives 0.25'], + ['PERCENTRANK.EXC', 'array:a x [significance]', '=PERCENTRANK.EXC({1, 2, 3, 4, 5}, 2) gives 0.333'], + ['TRIMMEAN', 'array:a percent', '=TRIMMEAN({1, 2, 3, 4, 100}, 0.4) gives 3'], + ['CORREL', 'array1:a array2:a', '=CORREL({1, 2, 3}, {2, 4, 6}) gives 1'], + ['PEARSON', 'array1:a array2:a', '=PEARSON({1, 2, 3}, {2, 4, 6}) gives 1'], + ['RSQ', 'knownY:a knownX:a', '=RSQ({2, 4, 6}, {1, 2, 3}) gives 1'], + ['COVARIANCE.P', 'array1:a array2:a', '=COVARIANCE.P({1, 2, 3}, {1, 2, 3}) gives 0.666666666666667'], + ['COVARIANCE.S', 'array1:a array2:a', '=COVARIANCE.S({1, 2, 3}, {1, 2, 3}) gives 1'], + ['SLOPE', 'knownY:a knownX:a', '=SLOPE({2, 4, 6}, {1, 2, 3}) gives 2'], + ['INTERCEPT', 'knownY:a knownX:a', '=INTERCEPT({3, 5, 7}, {1, 2, 3}) gives 1'], + ['STEYX', 'knownY:a knownX:a', '=STEYX({1, 3, 2}, {1, 2, 3}) gives 0.707106781186548'], + ['FORECAST', 'x knownY:a knownX:a', '=FORECAST(4, {2, 4, 6}, {1, 2, 3}) gives 8'], + ['FORECAST.LINEAR', 'x knownY:a knownX:a', '=FORECAST.LINEAR(4, {2, 4, 6}, {1, 2, 3}) gives 8'], + ['FREQUENCY', 'data:a bins:a', '=FREQUENCY({1, 2, 5}, {2}) gives 2 and 1 in a column'], + ['PROB', 'xRange:a probRange:a lower [upper]', '=PROB({1, 2, 3}, {0.2, 0.3, 0.5}, 2, 3) gives 0.8'], + ['STANDARDIZE', 'x mean sd', '=STANDARDIZE(7, 5, 2) gives 1'], + ['FISHER', 'x', '=FISHER(0) gives 0'], + ['FISHERINV', 'y', '=FISHERINV(0) gives 0'], + ['PERMUT', 'number chosen', '=PERMUT(5, 2) gives 20'], + ['PERMUTATIONA', 'number chosen', '=PERMUTATIONA(3, 2) gives 9'], + ['GAMMA', 'x', '=GAMMA(5) gives 24'], + ['GAMMALN', 'x', '=GAMMALN(4) gives 1.79175946922805'], + ['GAMMALN.PRECISE', 'x', '=GAMMALN.PRECISE(4) gives 1.79175946922805'], + ['BETA.DIST', 'x alpha beta cumulative:b [lower] [upper]', '=BETA.DIST(0.5, 2, 2, TRUE) gives 0.5'], + ['BETA.INV', 'probability alpha beta [lower] [upper]', '=BETA.INV(0.5, 2, 2) gives 0.5'], + ['BINOM.DIST', 'successes trials probability cumulative:b', '=BINOM.DIST(1, 2, 0.5, FALSE) gives 0.5'], + ['BINOM.DIST.RANGE', 'trials probability successes [successes2]', '=BINOM.DIST.RANGE(2, 0.5, 0, 1) gives 0.75'], + ['BINOM.INV', 'trials probability alpha', '=BINOM.INV(2, 0.5, 0.5) gives 1'], + ['CHISQ.DIST', 'x degrees cumulative:b', '=CHISQ.DIST(0, 2, TRUE) gives 0'], + ['CHISQ.DIST.RT', 'x degrees', '=CHISQ.DIST.RT(0, 2) gives 1'], + ['CHISQ.INV', 'probability degrees', '=CHISQ.INV(0, 2) gives 0'], + ['CHISQ.INV.RT', 'probability degrees', '=CHISQ.INV.RT(1, 2) gives 0'], + ['CHISQ.TEST', 'actual:a expected:a', '=CHISQ.TEST({10, 10}, {10, 10}) gives 1'], + ['CONFIDENCE.NORM', 'alpha sd size', '=CONFIDENCE.NORM(0.05, 1, 1) gives 1.95996398454005'], + ['CONFIDENCE.T', 'alpha sd size', '=CONFIDENCE.T(0.05, 1, 2) gives 8.98472'], + ['EXPON.DIST', 'x lambda cumulative:b', '=EXPON.DIST(0, 2, FALSE) gives 2'], + ['F.DIST', 'x degrees1 degrees2 cumulative:b', '=F.DIST(1, 2, 2, TRUE) gives 0.5'], + ['F.DIST.RT', 'x degrees1 degrees2', '=F.DIST.RT(1, 2, 2) gives 0.5'], + ['F.INV', 'probability degrees1 degrees2', '=F.INV(0.5, 2, 2) gives 1'], + ['F.INV.RT', 'probability degrees1 degrees2', '=F.INV.RT(0.5, 2, 2) gives 1'], + ['F.TEST', 'array1:a array2:a', '=F.TEST({1, 2, 3}, {2, 3, 4}) gives 1'], + ['GAMMA.DIST', 'x alpha beta cumulative:b', '=GAMMA.DIST(0, 1, 1, FALSE) gives 1'], + ['GAMMA.INV', 'probability alpha beta', '=GAMMA.INV(0, 1, 1) gives 0'], + ['GAUSS', 'z', '=GAUSS(0) gives 0'], + ['HYPGEOM.DIST', 'sampleS sample populationS population cumulative:b', '=HYPGEOM.DIST(1, 1, 1, 2, FALSE) gives 0.5'], + ['LOGNORM.DIST', 'x mean sd cumulative:b', '=LOGNORM.DIST(1, 0, 1, TRUE) gives 0.5'], + ['LOGNORM.INV', 'probability mean sd', '=LOGNORM.INV(0.5, 0, 1) gives 1'], + ['NEGBINOM.DIST', 'failures successes probability cumulative:b', '=NEGBINOM.DIST(0, 1, 0.5, FALSE) gives 0.5'], + ['NORM.DIST', 'x mean sd cumulative:b', '=NORM.DIST(5, 5, 2, TRUE) gives 0.5'], + ['NORM.INV', 'probability mean sd', '=NORM.INV(0.5, 5, 2) gives 5'], + ['NORM.S.DIST', 'z cumulative:b', '=NORM.S.DIST(0, TRUE) gives 0.5'], + ['NORM.S.INV', 'probability', '=NORM.S.INV(0.5) gives 0'], + ['PHI', 'x', '=PHI(0) gives 0.398942280401433'], + ['POISSON.DIST', 'x mean cumulative:b', '=POISSON.DIST(0, 1, FALSE) gives 0.367879441171442'], + ['T.DIST', 'x degrees cumulative:b', '=T.DIST(0, 3, TRUE) gives 0.5'], + ['T.DIST.2T', 'x degrees', '=T.DIST.2T(0, 3) gives 1'], + ['T.DIST.RT', 'x degrees', '=T.DIST.RT(0, 3) gives 0.5'], + ['T.INV', 'probability degrees', '=T.INV(0.5, 3) gives 0'], + ['T.INV.2T', 'probability degrees', '=T.INV.2T(1, 3) gives 0'], + ['T.TEST', 'array1:a array2:a tails type', '=T.TEST({1, 2, 3}, {1, 2, 3}, 2, 2) gives 1'], + ['WEIBULL.DIST', 'x alpha beta cumulative:b', '=WEIBULL.DIST(0, 1, 1, TRUE) gives 0'], + ['Z.TEST', 'array:a x [sigma]', '=Z.TEST({4, 6}, 5) gives 0.5'], + ['LINEST', 'knownY:a [knownX:a] [const:b] [stats:b]', '=LINEST({3, 5, 7}, {1, 2, 3}) gives 2 and 1'], + ['LOGEST', 'knownY:a [knownX:a] [const:b] [stats:b]', '=LOGEST({2, 4, 8}, {1, 2, 3}) gives 2 and 1'], + ['TREND', 'knownY:a [knownX:a] [newX:a] [const:b]', '=TREND({3, 5, 7}, {1, 2, 3}, 4) gives 9'], + ['GROWTH', 'knownY:a [knownX:a] [newX:a] [const:b]', '=GROWTH({2, 4, 8}, {1, 2, 3}, 4) gives 16'], + [ + 'FORECAST.ETS', + 'target values:a timeline:a [seasonality] [completion] [aggregation]', + '=FORECAST.ETS(5, {2, 4, 6, 8}, {1, 2, 3, 4}) gives 10', + ], + [ + 'FORECAST.ETS.CONFINT', + 'target values:a timeline:a [confidence] [seasonality] [completion] [aggregation]', + '=FORECAST.ETS.CONFINT(5, {2, 4, 6, 8}, {1, 2, 3, 4}) gives 0 for a perfect line', + ], + [ + 'FORECAST.ETS.SEASONALITY', + 'values:a timeline:a [completion] [aggregation]', + '=FORECAST.ETS.SEASONALITY({2, 4, 6, 8}, {1, 2, 3, 4}) gives 1', + ], + [ + 'FORECAST.ETS.STAT', + 'values:a timeline:a statistic [seasonality] [completion] [aggregation]', + '=FORECAST.ETS.STAT({2, 4, 6, 8}, {1, 2, 3, 4}, 8) gives the step 1', + ], +]; + +export const declared: readonly FnSpec2[] = [ + ...LISTS.map(([name, example]) => lazyList(S, name, example)), + ...TABLE.map(([name, list, example]) => lazy(S, name, list, example)), +]; diff --git a/app/src/features/tables/engine/formula/fn/statsIfs.ts b/app/src/features/tables/engine/formula/fn/statsIfs.ts new file mode 100644 index 000000000..d1a5403f7 --- /dev/null +++ b/app/src/features/tables/engine/formula/fn/statsIfs.ts @@ -0,0 +1,142 @@ +// Conditional counts and averages, and the counts of blank and distinct cells. Owner: tables:fn-stats. Criteria read +// as criteria.ts documents. Ranges tested together must have the same shape, or the result is #VALUE!. + +import { asArray } from '../../arrays'; +import { err, finish, isError, isNumber, toScalar, type ArrayValue, type Scalar, type Value } from '../../values'; +import { parseCriterion } from './criteria'; +import type { FnSpec2, Param } from './types'; +import { largest, listOf, mean, smallest } from './statsKit'; + +const S = 'stats' as const; +const range = (name: string): Param => ({ name, kind: 'range' }); +const criterion: Param = { name: 'criterion', kind: 'value' }; +const PAIRS = { repeat: [range('criteriaRange'), criterion], min: 1 }; + +/** Which items meet every criterion of the (range, criterion) pairs; #VALUE! when a range's shape differs. */ +function passing(pairs: readonly Value[], shape: ArrayValue | null): boolean[] | Scalar { + let mask: boolean[] | null = null; + let want = shape; + for (let i = 0; i + 1 < pairs.length; i += 2) { + if (isError(pairs[i])) return pairs[i] as Scalar; + const tested = asArray(pairs[i]); + want ??= tested; + if (tested.rows !== want.rows || tested.cols !== want.cols) return err('VALUE'); + const test = parseCriterion(toScalar(pairs[i + 1])); + const hits = tested.data.map((v) => test(v)); + mask = mask === null ? hits : mask.map((m, k) => m && hits[k]); + } + return mask ?? []; +} + +/** The numbers of `target` where the mask is true. */ +function picked(target: ArrayValue, mask: boolean[]): number[] | Scalar { + const out: number[] = []; + for (let k = 0; k < mask.length; k++) { + if (!mask[k]) continue; + const v = target.data[k]; + if (isError(v)) return v; + if (isNumber(v)) out.push(v); + } + return out; +} + +/** AVERAGEIFS, MAXIFS, MINIFS: a measure of the target cells whose rows pass every test. */ +function ifs(name: string, measure: (nums: number[]) => Value, example: string): FnSpec2 { + const target = name === 'AVERAGEIFS' ? 'averageRange' : name === 'MAXIFS' ? 'maxRange' : 'minRange'; + return { + name, + family: S, + params: [range(target), PAIRS], + run: ([first, ...pairs]) => { + if (isError(first)) return first; + const values = asArray(first); + const mask = passing(pairs, values); + if (!Array.isArray(mask)) return mask; + const nums = picked(values, mask); + return Array.isArray(nums) ? measure(nums) : nums; + }, + meta: { signature: `${name}(${target}, criteriaRange, criterion, ...)`, example }, + }; +} + +/** The items of the average range that line up with the tested range, by row and column from the top left. */ +function alignTo(values: ArrayValue, shape: ArrayValue): ArrayValue { + if (values.rows === shape.rows && values.cols === shape.cols) return values; + const data: Scalar[] = []; + for (let r = 0; r < shape.rows; r++) { + for (let c = 0; c < shape.cols; c++) + data.push(r < values.rows && c < values.cols ? values.data[r * values.cols + c] : null); + } + return { kind: 'array', rows: shape.rows, cols: shape.cols, data }; +} + +const average = (nums: number[]): Value => (nums.length ? finish(mean(nums)) : err('DIV0')); + +export const functions: readonly FnSpec2[] = [ + { + name: 'COUNTIF', + family: S, + params: [range('range'), criterion], + run: (args) => { + const mask = passing(args, null); + return Array.isArray(mask) ? mask.filter(Boolean).length : mask; + }, + meta: { signature: 'COUNTIF(range, criterion)', example: '=COUNTIF({1, 5, 9}, ">4") gives 2' }, + }, + { + name: 'COUNTIFS', + family: S, + params: [PAIRS], + run: (args) => { + const mask = passing(args, null); + return Array.isArray(mask) ? mask.filter(Boolean).length : mask; + }, + meta: { + signature: 'COUNTIFS(criteriaRange, criterion, ...)', + example: '=COUNTIFS({1, 5, 9}, ">1", {1, 5, 9}, "<9") gives 1', + }, + }, + { + name: 'AVERAGEIF', + family: S, + params: [range('range'), criterion, { ...range('averageRange'), optional: true }], + run: ([tested, test, target]) => { + if (isError(tested)) return tested; + if (target !== undefined && isError(target)) return target; + const shape = asArray(tested); + const mask = passing([tested, test], null); + if (!Array.isArray(mask)) return mask; + const nums = picked(target === undefined ? shape : alignTo(asArray(target), shape), mask); + return Array.isArray(nums) ? average(nums) : nums; + }, + meta: { + signature: 'AVERAGEIF(range, criterion, [averageRange])', + example: '=AVERAGEIF({1, 5, 9}, ">4") gives 7', + }, + }, + ifs('AVERAGEIFS', average, '=AVERAGEIFS({10, 20, 30}, {1, 2, 3}, ">1") gives 25'), + ifs('MAXIFS', (n) => (n.length ? largest(n) : 0), '=MAXIFS({10, 20, 30}, {1, 2, 3}, "<3") gives 20'), + ifs('MINIFS', (n) => (n.length ? smallest(n) : 0), '=MINIFS({10, 20, 30}, {1, 2, 3}, ">1") gives 20'), + listOf( + S, + 'COUNTBLANK', + (args) => args.filter((v) => v === null || v === '').length, + '=COUNTBLANK(A1:A3) gives the empty cells', + true, + ), + listOf( + S, + 'COUNTUNIQUE', + (args) => { + const seen = new Set(); + for (const v of args) { + if (v === null || v === '') continue; + const s = toScalar(v); + seen.add(isError(s) ? `e${s.error}` : `${typeof s}:${String(s)}`); + } + return seen.size; + }, + '=COUNTUNIQUE(1, 2, 2, "a") gives 3', + true, + ), +]; diff --git a/app/src/features/tables/engine/formula/fn/statsKit.ts b/app/src/features/tables/engine/formula/fn/statsKit.ts new file mode 100644 index 000000000..e2a5d1a11 --- /dev/null +++ b/app/src/features/tables/engine/formula/fn/statsKit.ts @@ -0,0 +1,229 @@ +// Shared parts of the statistical families. Owner: tables:fn-stats. It builds specs from a short parameter list, +// reads numbers out of arguments, and loads the lazy chunk that holds the distributions, the regression functions, +// and the compatibility names of distributions (DESIGN.md section 7 and the bundle note). + +import { asArray } from '../../arrays'; +import { err, isError, isNumber, toNumber, type FormulaError, type Value } from '../../values'; +import { numbersIn, type Env, type Family, type FnSpec2, type Param, type ParamKind } from './types'; + +export type Run = (args: Value[], env: Env) => Value; + +/** True when a helper gave an error instead of its result object. */ +export const failed = (x: T | FormulaError): x is FormulaError => 'error' in x; + +const KINDS: Readonly> = { + n: 'number', + a: 'array', + r: 'range', + b: 'bool', + v: 'value', +}; + +/** + * Parameters from a short list such as "x mean sd cumulative:b [significance]". A name alone is a number. A suffix + * picks the kind: ":a" an array, ":r" a range, ":b" a boolean, ":v" any value. Brackets mark an optional one. + */ +export function paramsFrom(list: string): Param[] { + if (list === '') return []; + return list.split(' ').map((token) => { + const optional = token.startsWith('['); + const [name, kind = 'n'] = token.replace(/[[\]]/g, '').split(':'); + return { name, kind: KINDS[kind], ...(optional ? { optional: true } : {}) }; + }); +} + +/** The call as autocomplete shows it, with optional arguments in brackets. */ +export function signatureOf(name: string, params: readonly Param[], repeats = false): string { + const parts = params.map((p) => (p.optional === true ? `[${p.name}]` : p.name)); + return `${name}(${[...parts, ...(repeats ? ['...'] : [])].join(', ')})`; +} + +/** A spec with fixed parameters. */ +export function fixed(family: Family, name: string, list: string, run: Run, example: string): FnSpec2 { + const params = paramsFrom(list); + return { name, family, params, run, meta: { signature: signatureOf(name, params), example } }; +} + +/** A spec that takes any number of values, which reach it as one flat list. `raw` keeps typed text as it is. */ +export function listOf(family: Family, name: string, run: Run, example: string, raw = false): FnSpec2 { + const param: Param = { name: 'value', kind: 'range', ...(raw ? { direct: 'raw' as const } : {}) }; + return { + name, + family, + params: [{ repeat: [param], min: 1 }], + run, + meta: { signature: `${name}(value, ...)`, example }, + }; +} + +/** A list function over the numbers of its values. Text, booleans, and empty cells are skipped. */ +export function overNumbers(family: Family, name: string, f: (nums: number[]) => Value, example: string): FnSpec2 { + return listOf( + family, + name, + (args) => { + const nums = numbersIn(args); + return Array.isArray(nums) ? f(nums) : nums; + }, + example, + ); +} + +/** The numbers in an array argument, or its first error. Text, booleans, and empty items are skipped. */ +export function numbersOf(value: Value): number[] | FormulaError { + if (isError(value)) return value; + return numbersIn(asArray(value).data); +} + +/** Reads arguments as numbers, or gives the first error. Empty optional arguments take their defaults. */ +export function readNumbers( + args: readonly Value[], + defaults: readonly (number | undefined)[] = [], +): number[] | FormulaError { + const out: number[] = []; + for (let i = 0; i < Math.max(args.length, defaults.length); i++) { + const v = args[i]; + if (v === undefined) { + const d = defaults[i]; + if (d === undefined) return err('VALUE'); + out.push(d); + continue; + } + const n = toNumber(v); + if (typeof n !== 'number') return n; + out.push(n); + } + return out; +} + +/** Wraps a numeric function: arguments become numbers (with defaults), and NaN or infinity becomes #NUM!. */ +export function numeric( + f: (n: number[]) => number | FormulaError, + defaults: readonly (number | undefined)[] = [], +): Run { + return (args) => { + const nums = readNumbers(args, defaults); + if (!Array.isArray(nums)) return nums; + try { + const v = f(nums); + return typeof v === 'number' && !Number.isFinite(v) ? err('NUM') : v; + } catch { + // The special functions throw on a domain error; that is an invalid number to a spreadsheet. + return err('NUM'); + } + }; +} + +/** Paired numbers of two arrays of the same size. Pairs where either item is not a number are skipped. */ +export function pairsOf(a: Value, b: Value): { xs: number[]; ys: number[] } | FormulaError { + if (isError(a)) return a; + if (isError(b)) return b; + const x = asArray(a).data; + const y = asArray(b).data; + if (x.length !== y.length) return err('NA'); + const xs: number[] = []; + const ys: number[] = []; + for (let i = 0; i < x.length; i++) { + const p = x[i]; + const q = y[i]; + if (isError(p)) return p; + if (isError(q)) return q; + if (isNumber(p) && isNumber(q)) { + xs.push(p); + ys.push(q); + } + } + return { xs, ys }; +} + +export const sum = (nums: readonly number[]): number => { + // Neumaier's compensated sum keeps the digits that plain addition drops over long lists. + let s = 0; + let c = 0; + for (const x of nums) { + const t = s + x; + c += Math.abs(s) >= Math.abs(x) ? s - t + x : x - t + s; + s = t; + } + return s + c; +}; + +export const mean = (nums: readonly number[]): number => sum(nums) / nums.length; + +/** The sum of squared deviations from the mean, in two passes for accuracy. */ +export function devsq(nums: readonly number[]): number { + const m = mean(nums); + return sum(nums.map((x) => (x - m) * (x - m))); +} + +export const sorted = (nums: readonly number[]): number[] => [...nums].sort((a, b) => a - b); + +/** + * The largest and smallest of any number of values, by a loop. Math.max(...nums) passes every value on the call + * stack, so it throws once a range holds about a million cells. + */ +export function largest(nums: Iterable): number { + let top = -Infinity; + for (const x of nums) if (x > top) top = x; + return top; +} + +export function smallest(nums: Iterable): number { + let low = Infinity; + for (const x of nums) if (x < low) low = x; + return low; +} + +// The lazy chunk. Its functions are declared in the core chunk with their parameters, so formulas compile and +// autocomplete knows them; only the code that computes them waits. A formula that runs before the chunk arrives shows +// #BUSY! ("Still calculating"), the load starts, and listeners recalculate when it lands. + +let runs: Readonly> | null = null; +let pending: Promise | null = null; +const listeners = new Set<() => void>(); + +/** Loads the statistical chunk once. Later calls give the same promise. A failed load may be tried again. */ +export function loadStatistics(): Promise { + pending ??= import('./lazy/statistics').then( + (chunk) => { + runs = chunk.runs; + for (const listener of [...listeners]) listener(); + }, + (cause: unknown) => { + pending = null; + throw cause; + }, + ); + return pending; +} + +/** True once the statistical chunk has loaded. */ +export const statisticsLoaded = (): boolean => runs !== null; + +/** Calls `listener` when the chunk lands, so a page can recalculate. Returns a function that stops listening. */ +export function onStatisticsLoaded(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +/** A run that waits for the chunk: #BUSY! until it has loaded, then the chunk's function by this name. */ +export function lazyRun(name: string): Run { + return (args, env) => { + if (runs === null) { + loadStatistics().catch(() => undefined); + return err('BUSY'); + } + const run = runs[name]; + return run === undefined ? err('NAME') : run(args, env); + }; +} + +/** A spec whose code lives in the lazy chunk. */ +export function lazy(family: Family, name: string, list: string, example: string): FnSpec2 { + return fixed(family, name, list, lazyRun(name), example); +} + +/** A spec that takes any number of values, whose code lives in the lazy chunk. */ +export function lazyList(family: Family, name: string, example: string): FnSpec2 { + return listOf(family, name, lazyRun(name), example); +} diff --git a/app/src/features/tables/engine/formula/fn/statsMore.ts b/app/src/features/tables/engine/formula/fn/statsMore.ts new file mode 100644 index 000000000..c1b8ee9be --- /dev/null +++ b/app/src/features/tables/engine/formula/fn/statsMore.ts @@ -0,0 +1,235 @@ +// The statistical functions past the basics: shape, percent ranks, pairs, and the small transforms. Owner: +// tables:fn-stats. Lazy chunk only: statsDeclared.ts declares them, and these are the runs by name. + +import { gamma, lgamma } from '../../../../../core/num/special'; +import { arrayOf, err, finish, toNumber, type Value } from '../../values'; +import { numbersIn } from './types'; +import { devsq, mean, numbersOf, numeric, pairsOf, readNumbers, sorted, sum, type Run } from './statsKit'; + +const stdev = (nums: number[], sample: boolean): number => Math.sqrt(devsq(nums) / (nums.length - (sample ? 1 : 0))); + +/** A run over the numbers of a list of values. */ +const overNumbers = + (f: (nums: number[]) => Value): Run => + (args) => { + const nums = numbersIn(args); + return Array.isArray(nums) ? f(nums) : nums; + }; + +function kurt(nums: number[]): Value { + const n = nums.length; + if (n < 4) return err('DIV0'); + const sd = stdev(nums, true); + if (sd === 0) return err('DIV0'); + const m = mean(nums); + const s4 = sum(nums.map((x) => ((x - m) / sd) ** 4)); + return finish((n * (n + 1) * s4) / ((n - 1) * (n - 2) * (n - 3)) - (3 * (n - 1) ** 2) / ((n - 2) * (n - 3))); +} + +function skew(nums: number[], sample: boolean): Value { + const n = nums.length; + if (n < (sample ? 3 : 1)) return err('DIV0'); + const sd = stdev(nums, sample); + if (sd === 0) return err('DIV0'); + const m = mean(nums); + const s3 = sum(nums.map((x) => ((x - m) / sd) ** 3)); + return finish(sample ? (n * s3) / ((n - 1) * (n - 2)) : s3 / n); +} + +/** Cuts a fraction to `digits` decimal places toward zero, as the rank-percent functions do. */ +function cut(x: number, digits: number): number { + const scale = 10 ** digits; + return Math.floor(x * scale + 1e-9) / scale; +} + +/** The share of values below x, from 0 to 1 (inclusive) or strictly between them (exclusive), interpolated. */ +export function percentRankRun(exclusive: boolean): Run { + return ([array, x, significance]) => { + const nums = numbersOf(array); + if (!Array.isArray(nums)) return nums; + const read = readNumbers([x, significance ?? 3]); + if (!Array.isArray(read)) return read; + const [v, sig] = read; + const digits = Math.trunc(sig); + if (digits < 1 || nums.length === 0) return err(nums.length === 0 ? 'NA' : 'NUM'); + const s = sorted(nums); + const n = s.length; + if (v < s[0] || v > s[n - 1]) return err('NA'); + if (n === 1 && !exclusive) return 1; + const below = (y: number) => s.filter((z) => z < y).length; + const rank = (y: number) => (exclusive ? (below(y) + 1) / (n + 1) : below(y) / (n - 1)); + const hi = s.findIndex((z) => z >= v); + if (s[hi] === v) return cut(rank(v), digits); + const a = s[hi - 1]; + const b = s[hi]; + return cut(rank(a) + ((v - a) / (b - a)) * (rank(b) - rank(a)), digits); + }; +} + +function trimmean([array, percent]: Value[]): Value { + const nums = numbersOf(array); + const p = toNumber(percent); + if (!Array.isArray(nums)) return nums; + if (typeof p !== 'number') return p; + if (p < 0 || p >= 1 || nums.length === 0) return err('NUM'); + const cutEach = Math.floor((nums.length * p) / 2); + return finish(mean(sorted(nums).slice(cutEach, nums.length - cutEach))); +} + +interface Moments { + readonly n: number; + readonly mx: number; + readonly my: number; + readonly sxx: number; + readonly syy: number; + readonly sxy: number; +} + +/** The statistics of two paired arrays: means, the sums of squares, and the cross products. */ +function moments(xs: number[], ys: number[]): Moments { + const mx = mean(xs); + const my = mean(ys); + const sxx = sum(xs.map((x) => (x - mx) ** 2)); + const syy = sum(ys.map((y) => (y - my) ** 2)); + const sxy = sum(xs.map((x, i) => (x - mx) * (ys[i] - my))); + return { n: xs.length, mx, my, sxx, syy, sxy }; +} + +/** A function of two paired arrays: CORREL(array1, array2), or SLOPE(known_y, known_x) with y first. */ +function paired(f: (m: Moments) => Value, yFirst = false): Run { + return ([a, b]) => { + const p = yFirst ? pairsOf(b, a) : pairsOf(a, b); + if (!('xs' in p)) return p; + return p.xs.length === 0 ? err('DIV0') : f(moments(p.xs, p.ys)); + }; +} + +const correl = (m: Moments): Value => + m.sxx === 0 || m.syy === 0 ? err('DIV0') : finish(m.sxy / Math.sqrt(m.sxx * m.syy)); +const covariance = (sample: boolean) => (m: Moments) => + sample && m.n < 2 ? err('DIV0') : finish(m.sxy / (sample ? m.n - 1 : m.n)); +const slope = (m: Moments): Value => (m.sxx === 0 ? err('DIV0') : finish(m.sxy / m.sxx)); + +function forecastLinear([x, ys, xs]: Value[]): Value { + const at0 = toNumber(x); + const p = pairsOf(xs, ys); + if (typeof at0 !== 'number') return at0; + if (!('xs' in p)) return p; + if (p.xs.length === 0) return err('DIV0'); + const m = moments(p.xs, p.ys); + return m.sxx === 0 ? err('DIV0') : finish(m.my + (m.sxy / m.sxx) * (at0 - m.mx)); +} + +/** + * FREQUENCY: how many values fall in each bin, plus one more for those above the last bin. A column. A bin holds + * the values above the next lower edge and at most its own. A repeated edge counts at its first place and + * gives 0 after. The edges are sorted once and each value is placed by binary search: O((n + m) log m). + */ +function frequency([data, bins]: Value[]): Value { + const nums = numbersOf(data); + const edges = numbersOf(bins); + if (!Array.isArray(nums)) return nums; + if (!Array.isArray(edges)) return edges; + const distinct = [...new Set(edges)].sort((a, b) => a - b); + // counts[j] is the bin of distinct[j]; the last slot holds the values above every edge. + const counts = new Array(distinct.length + 1).fill(0); + for (const x of nums) { + let [lo, hi] = [0, distinct.length]; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (distinct[mid] < x) lo = mid + 1; + else hi = mid; + } + counts[lo] += 1; + } + const slot = new Map(distinct.map((e, j) => [e, j])); + const seen = new Set(); + const column = edges.map((e) => { + if (seen.has(e)) return [0]; + seen.add(e); + return [counts[slot.get(e) ?? 0]]; + }); + column.push([counts[distinct.length]]); + return arrayOf(column); +} + +function prob([xr, pr, lower, upper]: Value[]): Value { + const p = pairsOf(xr, pr); + if (!('xs' in p)) return p; + const read = readNumbers(upper === undefined ? [lower] : [lower, upper]); + if (!Array.isArray(read)) return read; + const [lo, hi = lo] = read; + if (p.ys.some((q) => q < 0 || q > 1) || Math.abs(sum(p.ys) - 1) > 1e-9) return err('NUM'); + return finish(sum(p.xs.map((x, i) => (x >= lo && x <= hi ? p.ys[i] : 0)))); +} + +function permut([n, k]: number[]): number { + const a = Math.trunc(n); + const b = Math.trunc(k); + if (a < 0 || b < 0 || a < b) return NaN; + let p = 1; + // Every factor but the last is at least 2, so the product overflows within about 1,024 steps: stop there. + for (let i = 0; i < b && Number.isFinite(p); i++) p *= a - i; + return p; +} + +const positive = (f: (nums: number[]) => number) => (nums: number[]) => + nums.length === 0 || nums.some((x) => x <= 0) ? err('NUM') : finish(f(nums)); + +const lnGamma = numeric(([x]) => (x <= 0 ? NaN : lgamma(x))); + +/** The covariance of COVAR, the population form. */ +const covar = paired(covariance(false)); + +export const runs: Readonly> = { + AVEDEV: overNumbers((n) => { + if (n.length === 0) return err('NUM'); + const m = mean(n); + return finish(mean(n.map((x) => Math.abs(x - m)))); + }), + DEVSQ: overNumbers((n) => (n.length ? finish(devsq(n)) : err('NUM'))), + GEOMEAN: overNumbers(positive((n) => Math.exp(mean(n.map(Math.log))))), + HARMEAN: overNumbers(positive((n) => n.length / sum(n.map((x) => 1 / x)))), + KURT: overNumbers(kurt), + SKEW: overNumbers((n) => skew(n, true)), + 'SKEW.P': overNumbers((n) => skew(n, false)), + 'PERCENTRANK.INC': percentRankRun(false), + 'PERCENTRANK.EXC': percentRankRun(true), + TRIMMEAN: trimmean, + CORREL: paired(correl), + PEARSON: paired(correl), + RSQ: paired((m) => { + const r = correl(m); + return typeof r === 'number' ? r * r : r; + }, true), + 'COVARIANCE.P': paired(covariance(false)), + 'COVARIANCE.S': paired(covariance(true)), + SLOPE: paired(slope, true), + INTERCEPT: paired((m) => { + const b = slope(m); + return typeof b === 'number' ? finish(m.my - b * m.mx) : b; + }, true), + STEYX: paired((m) => { + if (m.n < 3 || m.sxx === 0) return err('DIV0'); + return finish(Math.sqrt(Math.max(0, m.syy - (m.sxy * m.sxy) / m.sxx) / (m.n - 2))); + }, true), + FORECAST: forecastLinear, + 'FORECAST.LINEAR': forecastLinear, + FREQUENCY: frequency, + PROB: prob, + STANDARDIZE: numeric(([x, m, sd]) => (sd <= 0 ? NaN : (x - m) / sd)), + FISHER: numeric(([x]) => (Math.abs(x) >= 1 ? NaN : Math.atanh(x))), + FISHERINV: numeric(([y]) => Math.tanh(y)), + PERMUT: numeric(permut), + PERMUTATIONA: numeric(([n, k]) => { + const a = Math.trunc(n); + const b = Math.trunc(k); + return a < 0 || b < 0 ? NaN : a ** b; + }), + GAMMA: numeric(([x]) => gamma(x)), + GAMMALN: lnGamma, + 'GAMMALN.PRECISE': lnGamma, + // Compatibility names. + COVAR: covar, + PERCENTRANK: percentRankRun(false), +}; diff --git a/app/src/features/tables/engine/formula/fn/types.ts b/app/src/features/tables/engine/formula/fn/types.ts index e81f5546c..12865676e 100644 --- a/app/src/features/tables/engine/formula/fn/types.ts +++ b/app/src/features/tables/engine/formula/fn/types.ts @@ -3,12 +3,24 @@ // never touch the same file. The descriptions people read (one line per function, one per argument) live in // app/src/strings/en/fn.ts under the function's name, written fresh in our own words. -import { isError, isNumber, type FormulaError, type RowState, type Value } from '../../values'; +import { + isError, + isNumber, + type FormulaError, + type RefValue, + type RowState, + type Scalar, + type Value, +} from '../../values'; /** What the formula engine gives every function. */ export interface Env { /** Today as a serial, read when a formula is calculated. */ today: () => number; + /** The page's title, which CELL("filename") returns. Unset gives an empty text, as an unsaved workbook does. */ + title?: string; + /** What the engine knows about the formula's cell and page. Set only for a function with `refArgs`. */ + probe?: Probe; /** Whether a row of a table (by its index on the page) shows. Unset: every row shows. */ rowState?: (sheet: number, row: number) => RowState; /** @@ -16,6 +28,44 @@ export interface Env { * formula calls SUBTOTAL or AGGREGATE. Row-major flags, or undefined when none is. Unset: none is. */ subtotalsIn?: (sheet: number, r1: number, c1: number, r2: number, c2: number) => readonly boolean[] | undefined; + /** The cell the formula runs in. Set only for a function with `wantsCaller`. */ + caller?: Caller; +} + +/** The calling cell, for ROW() and COLUMN() without an argument, INDIRECT's R1C1 text, and FORMULATEXT. */ +export interface Caller { + /** Zero-based row and column of the cell being calculated. */ + readonly row: number; + readonly col: number; + /** The formula of a reference's top-left cell in stored form, without "=". Null when it holds none. */ + formulaAt(ref: RefValue): string | null; +} + +/** One cell as functions about cells see it (CELL, ISFORMULA). */ +export interface ProbeCell { + /** The cell's value. Empty cells are null. */ + readonly value: Scalar; + /** True when the cell holds a formula, alone, or through its calculated column. */ + readonly formula: boolean; + /** The column's type, such as "number" or "date". */ + readonly type: string; + /** The column's decimal places, when set. */ + readonly decimals?: number; +} + +/** The look at the page that the engine lends to a function with `refArgs`. */ +export interface Probe { + /** The formula's own table, zero-based. */ + readonly sheet: number; + /** The name of every table on the page, in order. */ + readonly sheets: readonly string[]; + /** The cell the formula is in. */ + readonly self: RefValue; + readonly calc: 'auto' | 'manual'; + /** The table a reference names, zero-based, or -1 when it names none. */ + sheetOf(ref: RefValue): number; + /** The cell at the top left of a reference, or undefined when it lies outside the table. */ + cell(ref: RefValue): ProbeCell | undefined; } /** @@ -24,10 +74,11 @@ export interface Env { * - number, text, bool: one value the function reads as that kind. * - range: every cell of a reference, or a value typed directly (see `direct`). * - array: an array. A single value comes as a one-by-one array. + * - ref: a reference as a RefValue, unions included. Other values come as they are. * - lambda: a function value, made by LAMBDA. * - lazy: not evaluated until the function asks, as IF's branches are. */ -export type ParamKind = 'value' | 'number' | 'text' | 'bool' | 'range' | 'array' | 'lambda' | 'lazy'; +export type ParamKind = 'value' | 'number' | 'text' | 'bool' | 'range' | 'array' | 'ref' | 'lambda' | 'lazy'; export interface Param { /** The argument's name in the signature, such as "number" or "digits". */ @@ -42,6 +93,10 @@ export interface Param { * TRUE as 1, as SUM("3",TRUE) is 4; 'raw' keeps the value as it is, as COUNTA counts it. */ readonly direct?: 'number' | 'raw'; + /** For an array: text in a referenced cell counts as empty, as the logical functions skip it. Typed text still counts. */ + readonly skipRefText?: boolean; + /** A typed lone text reaches the function as a scalar, not as a one-element array. */ + readonly loneText?: boolean; /** For a range: a reference arrives with the state of each row (`ArrayValue.rowState`), as SUBTOTAL reads it. */ readonly rowState?: boolean; /** @@ -92,6 +147,13 @@ export interface FnSpec2 { readonly volatile?: boolean; /** Returns a reference rather than a value, as OFFSET and INDEX do. */ readonly returnsRef?: boolean; + /** + * Array parameters get a reference as a reference (a RefValue) instead of its values, and `env.probe` is set. For + * functions that ask about a cell rather than its content, as ISREF and CELL do. + */ + readonly refArgs?: boolean; + /** Gets the calling cell in `env.caller`. */ + readonly wantsCaller?: boolean; /** An error argument reaches the function instead of becoming its result. */ readonly handlesErrors?: boolean; /** Computes the result. A function with lazy parameters is evaluated by the compiler itself and never runs this. */ diff --git a/app/src/features/tables/engine/formula/special.ts b/app/src/features/tables/engine/formula/special.ts index 2fddadc00..850a40a3f 100644 --- a/app/src/features/tables/engine/formula/special.ts +++ b/app/src/features/tables/engine/formula/special.ts @@ -60,9 +60,13 @@ export function readLocal(hops: number, slot: number): Spe return (ctx) => frameAt(ctx.env, hops)?.vals[slot] ?? null; } -/** How a parameter of LAMBDA or a name of LET is written: a plain name, or [name] for an optional parameter. */ +/** + * How a parameter of LAMBDA or a name of LET is written: a plain name, or [name] for an optional parameter. An + * empty argument, as in LAMBDA(,x), is no name, so the call gives #VALUE!. + */ function paramName(node: Node): { name: string; optional: boolean } | null { - if (node.type === 'name' && !node.name.startsWith('#')) return { name: node.name.toUpperCase(), optional: false }; + if (node.type === 'name' && node.name !== '' && !node.name.startsWith('#')) + return { name: node.name.toUpperCase(), optional: false }; if (node.type === 'col' && node.by === 'name' && /^[A-Za-z_][\w.]*$/.test(node.key.trim())) { return { name: node.key.trim().toUpperCase(), optional: true }; } diff --git a/app/src/features/tables/engine/graph/engine.ts b/app/src/features/tables/engine/graph/engine.ts index c154109a3..1f50e0104 100644 --- a/app/src/features/tables/engine/graph/engine.ts +++ b/app/src/features/tables/engine/graph/engine.ts @@ -4,7 +4,7 @@ // and finds cycles in one walk. Every edit forks the state, and a fork shares what it does not change. import { legacyFunctions } from '../formula/fn/legacyText'; -import { newCtx, type Ctx, type Scope, type SheetInfo } from '../formula/compile'; +import { colKey, newCtx, type Ctx, type Scope, type SheetInfo } from '../formula/compile'; import type { ErrorOrigin } from '../formula/explain'; import { releaseSpill, spillBox } from '../arrays/spill'; import { Graph, stronglyConnected, type Box, type Read } from '.'; @@ -29,8 +29,8 @@ import { type EngineEnv, type Unit, } from './units'; -import { colKey } from '../formula/compile'; import type { Table } from '../model'; +import { tableFacts } from './facts'; import { filterIndices } from '../filter'; import { isError, type RowState, type Scalar } from '../values'; @@ -105,9 +105,14 @@ export class Engine { } makeCtx(): Ctx { - const ctx = newCtx([], { spillBox: (s, c, r) => spillBox(this, s, c, r) }); - ctx.sheets = this.sheets; - return ctx; + const formulaAt = (s: number, c: number, r: number): string | null => + this.units.get(this.cellUnit.get(cellKey(s, c, r)) ?? this.colUnit.get(colKey(s, c)) ?? -1)?.formula ?? null; + const ctx = newCtx([], { + spillBox: (s, c, r) => spillBox(this, s, c, r), + ...tableFacts(() => this.tables), + formulaAt, + }); + return Object.assign(ctx, { sheets: this.sheets }); } /** Builds the state of a page and calculates every formula. */ diff --git a/app/src/features/tables/engine/graph/facts.ts b/app/src/features/tables/engine/graph/facts.ts new file mode 100644 index 000000000..5c6fa532d --- /dev/null +++ b/app/src/features/tables/engine/graph/facts.ts @@ -0,0 +1,15 @@ +import type { Table } from '../model'; + +/** What CELL and ISFORMULA may learn about the tables, and each table's calculation mode. */ +export function tableFacts(tables: () => readonly Table[]) { + return { + cellFacts: (s: number, c: number, r: number) => { + const column = tables()[s]?.columns[c]; + const cell = tables()[s]?.rows[r]?.cells[c]; + if (column === undefined || cell === undefined) return undefined; + const decimals = column.decimals === undefined ? {} : { decimals: column.decimals }; + return { formula: column.formula !== undefined || cell.formula !== undefined, type: column.type, ...decimals }; + }, + calcMode: (s: number) => tables()[s]?.calc ?? ('auto' as const), + }; +} diff --git a/app/src/features/tables/engine/gridcraftDiff.test.ts b/app/src/features/tables/engine/gridcraftDiff.test.ts index 7bdb85468..a3b7da8be 100644 --- a/app/src/features/tables/engine/gridcraftDiff.test.ts +++ b/app/src/features/tables/engine/gridcraftDiff.test.ts @@ -9,6 +9,7 @@ import { spawnSync } from 'node:child_process'; import { describe, expect, it } from 'vitest'; import { findTool } from '../../../../../tests/crosscheck/tools'; import { createTable, setColumnFormula } from './edit'; +import { functions as ARRAY_FUNCTIONS } from './formula/fn/arrays'; import { REGISTRY } from './formula/fn'; import { FUNCTIONS } from './formula/functions'; import { EN_US } from './locale'; @@ -66,6 +67,22 @@ const CASES: Record = { MONTH: ['MONTH(DATE(2024,1,15))', 'MONTH(DATE(2024,12,31))'], DAY: ['DAY(DATE(2024,1,15))', 'DAY(DATE(2024,2,29))'], DAYS: ['DAYS(DATE(2024,3,1),DATE(2024,2,1))', 'DAYS(DATE(2024,1,1),DATE(2024,12,31))'], + VLOOKUP: [ + 'VLOOKUP(2.5,{1,"a";2,"b";3,"c"},2,TRUE)', + 'VLOOKUP(2,{1,"a";2,"b"},2,FALSE)', + 'VLOOKUP(9,{1,"a"},2,FALSE)', + ], + HLOOKUP: ['HLOOKUP("b",{"a","b";1,2},2,FALSE)', 'HLOOKUP(2.5,{1,2,3;"x","y","z"},2)'], + LOOKUP: ['LOOKUP(4.19,{4.14,4.19,5.17},{"red","orange","yellow"})', 'LOOKUP(0,{1,2},{"a","b"})'], + MATCH: ['MATCH(25,{10,20,30},1)', 'MATCH(25,{30,20,10},-1)', 'MATCH("b*",{"apple","banana"},0)'], + XLOOKUP: ['XLOOKUP(5,{1,3,7},{"a","b","c"},"none",-1)', 'XLOOKUP(2,{2,1,2},{"x","y","z"},"none",0,-1)'], + XMATCH: ['XMATCH(5,{1,3,5})', 'XMATCH(4,{1,3,5},1)'], + INDEX: ['INDEX({1,2;3,4},2,1)', 'INDEX({1,2,3},2)', 'INDEX({1,2;3,4},3,1)'], + CHOOSE: ['CHOOSE(2,"a","b","c")', 'CHOOSE(4,"a","b","c")'], + ROWS: ['ROWS({1,2;3,4;5,6})', 'ROWS(7)'], + COLUMNS: ['COLUMNS({1,2;3,4;5,6})', 'COLUMNS(7)'], + ADDRESS: ['ADDRESS(2,3)', 'ADDRESS(2,3,4)', 'ADDRESS(2,3,1,FALSE)', 'ADDRESS(1,28)'], + HYPERLINK: ['HYPERLINK("https://example.com","Site")', 'HYPERLINK("https://example.com")'], OPERATORS: [ '2+3*4', '(2+3)*4', @@ -105,18 +122,36 @@ const HAND_WORKED = new Set([ ...'NETWORKDAYS NETWORKDAYS.INTL WORKDAY WORKDAY.INTL DATEDIF YEARFRAC EPOCHTODATE'.split(' '), ]); -/** Functions that cannot be compared with a fixed answer: their result depends on the day it runs. */ -// The text and regular expression families have their own hand-worked fixtures (fixtures/fn/text.json), and the math -// family has fn/math.test.ts and fixtures/fn/math.json, so only the functions that were here before need a case. +// The text, regular expression, and math families have their own hand-worked fixtures (fixtures/fn/text.json and +// math.json), so only the functions that were here before need a case here. +/** + * Functions that cannot be compared with a fixed answer. TODAY and NOW depend on the day they run. The lookup functions + * read the cells or the position of the formula's table, which the other engine does not have. + */ const NOT_COMPARABLE = new Set([ 'TODAY', 'NOW', + 'ROW', + 'COLUMN', + 'OFFSET', + 'INDIRECT', + 'AREAS', + 'FORMULATEXT', ...HAND_WORKED, ...[...REGISTRY.values()] .filter((spec) => ['text', 'regex', 'math'].includes(spec.family) && !(spec.name in CASES)) .map((spec) => spec.name), ]); +/** Functions the other engine's shared set does not cover. Their cases are fixtures of the family's own test. */ +const OWN_FIXTURES = new Set([ + ...['IFNA', 'IFS', 'SWITCH', 'XOR', 'TRUE', 'FALSE', 'LET', 'LAMBDA', 'ISOMITTED', 'CELL', 'ERROR.TYPE', 'INFO'], + ...['ISDATE', 'ISEMAIL', 'ISERR', 'ISERROR', 'ISEVEN', 'ISFORMULA', 'ISLOGICAL', 'ISNA', 'ISNONTEXT', 'ISNUMBER'], + ...['ISODD', 'ISREF', 'ISTEXT', 'ISURL', 'N', 'NA', 'SHEET', 'SHEETS', 'TYPE'], +]); +/** Functions that give arrays, which the tool's eval cannot print: fn/arrays.test.ts checks them by hand. */ +const ARRAY_RESULTS = new Set(ARRAY_FUNCTIONS.map((spec) => spec.name)); + const DATE_RESULTS = new Set(['DATE']); function opennote(formula: string): Value { @@ -151,13 +186,24 @@ function normalize(value: Value | Other): string { return `${typeof value} ${String(value)}`; } +// The statistical families are checked by fn/stats.test.ts and fixtures/fn/stats.json, with hand-worked answers. +for (const spec of REGISTRY.values()) { + const statistical = spec.family === 'stats' || spec.family === 'statsCompat'; + if (statistical && CASES[spec.name] === undefined) NOT_COMPARABLE.add(spec.name); +} + const tool = findTool('gridcraft-cli'); describe('the formula function set', () => { it('has a differential case for every function', () => { const covered = new Set(Object.keys(CASES)); const missing = Object.keys(FUNCTIONS).filter( - (name) => !covered.has(name) && !NOT_COMPARABLE.has(name) && !HAND_WORKED.has(name), + (name) => + !covered.has(name) && + !NOT_COMPARABLE.has(name) && + !HAND_WORKED.has(name) && + !OWN_FIXTURES.has(name) && + !ARRAY_RESULTS.has(name), ); expect(missing, 'add cases for these functions to CASES').toEqual([]); }); diff --git a/app/src/features/tables/engine/index.ts b/app/src/features/tables/engine/index.ts index 9cde23a6a..f24f4b90c 100644 --- a/app/src/features/tables/engine/index.ts +++ b/app/src/features/tables/engine/index.ts @@ -34,6 +34,8 @@ export { formatValue } from './format'; export { convertFormula, STORED, syntaxOf } from './formula/lexer'; export type { FormulaProblem, Syntax } from './formula/lexer'; export { parseFormula } from './formula/parser'; +// The statistical distributions load on first use; a page recalculates when they land. +export { loadStatistics, onStatisticsLoaded } from './formula/fn/statsKit'; export { DE_DE, EN_US, FR_FR, parseLocaleNumber } from './locale'; export type { DateOrder, Locale } from './locale'; export { columnLetter, isMismatch, ITERATE_DEFAULTS, letterToColumn, parseInput } from './model'; diff --git a/app/src/features/tables/engine/values.ts b/app/src/features/tables/engine/values.ts index 4e20c02b4..779899b47 100644 --- a/app/src/features/tables/engine/values.ts +++ b/app/src/features/tables/engine/values.ts @@ -37,6 +37,8 @@ export interface RefValue { readonly c1: number; readonly r2: number; readonly c2: number; + /** A union such as (A1:B2,D4): every area in order, this block being the first. Unset for one block. */ + readonly areas?: readonly RefValue[]; } /** A function value: its parameter names and a body to call with their values. */ diff --git a/app/src/features/tables/engine/worker/client.ts b/app/src/features/tables/engine/worker/client.ts index 04521c808..ce385ed58 100644 --- a/app/src/features/tables/engine/worker/client.ts +++ b/app/src/features/tables/engine/worker/client.ts @@ -109,7 +109,7 @@ export function createRecalcClient(options: RecalcClientOptions = {}): RecalcCli clearTimeout(timer); resolve(result); }); - start().postMessage({ id, tables, today: env.today() }); + start().postMessage({ id, tables, today: env.today(), title: env.title }); }); }, stop() { diff --git a/app/src/features/tables/engine/worker/protocol.ts b/app/src/features/tables/engine/worker/protocol.ts index f4208a4dd..d8bea2cdf 100644 --- a/app/src/features/tables/engine/worker/protocol.ts +++ b/app/src/features/tables/engine/worker/protocol.ts @@ -9,6 +9,8 @@ export interface RecalcRequest { readonly tables: readonly Table[]; /** Today as a serial, read on the page, so TODAY() agrees with it. */ readonly today: number; + /** The page's title, read on the page, so CELL("filename") agrees with it. */ + readonly title?: string; } /** One changed cell: the table's index on the page, the data row, and the column. */ diff --git a/app/src/features/tables/engine/worker/recalc.worker.ts b/app/src/features/tables/engine/worker/recalc.worker.ts index 87f474bac..1c8ebef26 100644 --- a/app/src/features/tables/engine/worker/recalc.worker.ts +++ b/app/src/features/tables/engine/worker/recalc.worker.ts @@ -1,6 +1,7 @@ -// The recalculation worker's entry (client.ts starts it). It answers each request with the cells that changed. +// The recalculation worker's entry (client.ts starts it). It answers each request with the cells that changed, +// after the lazy statistical chunk when a formula needs it. -import { respond } from './run'; +import { respondWhenReady } from './run'; import type { RecalcReply, RecalcRequest } from './protocol'; interface WorkerScope { @@ -10,5 +11,5 @@ interface WorkerScope { if ('WorkerGlobalScope' in globalThis) { const scope = globalThis as unknown as WorkerScope; - scope.onmessage = (event) => scope.postMessage(respond(event.data)); + scope.onmessage = (event) => void respondWhenReady(event.data).then((reply) => scope.postMessage(reply)); } diff --git a/app/src/features/tables/engine/worker/run.ts b/app/src/features/tables/engine/worker/run.ts index a0a32154e..7821e9129 100644 --- a/app/src/features/tables/engine/worker/run.ts +++ b/app/src/features/tables/engine/worker/run.ts @@ -1,6 +1,7 @@ // The work the recalculation worker does, as plain functions so tests run them without a worker: recalculate the // page and list the cells that changed, and on the page, apply such a list back to the tables. +import { loadStatistics, statisticsLoaded } from '../formula/fn/statsKit'; import type { Cell, Table } from '../model'; import { recalculateBook } from '../workbook'; import { err, isError, type Scalar, type Value } from '../values'; @@ -12,7 +13,7 @@ function same(a: Value, b: Value): boolean { /** Recalculates the tables of a request and lists the changed cells. */ export function respond(request: RecalcRequest): RecalcReply { - const result = recalculateBook(request.tables, { today: () => request.today }); + const result = recalculateBook(request.tables, { today: () => request.today, title: request.title }); const changes: CellChange[] = []; result.forEach((table, s) => { const before = request.tables[s]; @@ -29,6 +30,25 @@ export function respond(request: RecalcRequest): RecalcReply { return { id: request.id, changes }; } +/** + * Answers a request in the worker. A statistical function that runs before its lazy chunk has loaded gives #BUSY! and + * starts the load (fn/statsKit.ts). The worker has no page to recalculate later, so it waits for the chunk and answers + * again; a failed load keeps the first answer. + */ +export async function respondWhenReady(request: RecalcRequest): Promise { + const loadedBefore = statisticsLoaded(); + const first = respond(request); + if (loadedBefore || !first.changes.some((change) => isError(change.value) && change.value.error === 'BUSY')) { + return first; + } + try { + await loadStatistics(); + } catch { + return first; + } + return respond(request); +} + /** An error value that crossed the worker boundary as a copy becomes the engine's own again. */ function revive(value: Scalar): Scalar { return isError(value) ? err(value.error) : value; diff --git a/app/src/features/tables/engine/worker/worker.test.ts b/app/src/features/tables/engine/worker/worker.test.ts index 7d7a5923d..a2d9f8466 100644 --- a/app/src/features/tables/engine/worker/worker.test.ts +++ b/app/src/features/tables/engine/worker/worker.test.ts @@ -7,7 +7,7 @@ import type { Table } from '../model'; import { err } from '../values'; import { cellCount, createRecalcClient, WORKER_CELLS, BUSY_MS, type WorkerLike } from './client'; import type { RecalcReply, RecalcRequest } from './protocol'; -import { applyChanges, respond } from './run'; +import { applyChanges, respond, respondWhenReady } from './run'; class SlowWorker implements WorkerLike { onmessage: ((event: { data: RecalcReply }) => void) | null = null; @@ -76,6 +76,24 @@ describe('recalculation worker', () => { expect(worker.terminated).toBe(true); }); + it('carries the page title to the worker, so CELL("filename") reads it (gap G3)', async () => { + const worker = new SlowWorker(0); + const client = createRecalcClient({ spawn: () => worker, threshold: 4, busyMs: 1000 }); + const table = createTable( + [ + { name: 'A', type: 'number' }, + { name: 'B', type: 'text', formula: 'CELL("filename")' }, + ], + [['1'], ['2'], ['3']], + EN_US, + ); + const blank = { ...table, rows: table.rows.map((r) => ({ ...r, cells: [r.cells[0], { raw: '', value: null }] })) }; + const [done] = await client.recalc([blank], { today: () => 1, title: 'Budget' }); + expect(worker.posted).toBe(1); + expect(done.rows.map((r) => r.cells[1].value)).toEqual(['Budget', 'Budget', 'Budget']); + client.dispose(); + }); + it('shows #BUSY! past the busy time, then the results when the worker answers', async () => { const worker = new SlowWorker(80); const client = createRecalcClient({ spawn: () => worker, threshold: 4, busyMs: 10 }); @@ -113,3 +131,15 @@ describe('recalculation worker', () => { expect(table.rows[0].cells[1].value).toBe(err('DIV0')); }); }); + +describe('the worker and the lazy statistical chunk', () => { + it('waits in the worker for the lazy statistical chunk instead of answering #BUSY!', async () => { + const table = createTable([{ name: 'A', type: 'number' }], [['']], EN_US); + const page = { + ...table, + rows: [{ ...table.rows[0], cells: [{ raw: '=GEOMEAN(2,8)', value: null, formula: 'GEOMEAN(2,8)' }] }], + }; + const reply = await respondWhenReady({ id: 1, tables: [page], today: 0 }); + expect(reply.changes).toEqual([{ table: 0, row: 0, col: 0, value: 4 }]); + }); +}); diff --git a/app/src/features/tables/exportView.ts b/app/src/features/tables/exportView.ts new file mode 100644 index 000000000..8a5d4494a --- /dev/null +++ b/app/src/features/tables/exportView.ts @@ -0,0 +1,17 @@ +// What the page's exports read of a table block: the parts of the sheet model they use (cell styles, merges, and +// cached results), the row height limits, and the shown text of a number. It starts no editor, worker, or lazy +// chunk, so the print test bundle can hold it in one file (pages/testing/edgeSurface.ts). index.ts passes it on. +// Named parts, not the whole model, so a feature that imports the tables index carries only what it uses. + +import { render } from './engine/numfmt'; +import { EN_US } from './engine/locale'; +import { activeMerges, cachedValue, fingerprint, readableText, resolve } from './sheet/model'; + +export type { Box as SheetBox, CellStyle as SheetCellStyle, TableLike as SheetTableLike } from './sheet/model'; +export { MAX_HEIGHT, MIN_HEIGHT } from './sheet/structure/lines'; + +/** The sheet model functions the exports call. */ +export const sheetModel = { activeMerges, cachedValue, fingerprint, readableText, resolve }; + +/** The shown text of a number under a number format code, which the page exports use for formula results. */ +export const renderNumberFormat = (value: number, code: string): string => render(value, code, EN_US).text; diff --git a/app/src/features/tables/fixtures/fn/DIFFERENCES.md b/app/src/features/tables/fixtures/fn/DIFFERENCES.md index 61bc86025..4db30321a 100644 --- a/app/src/features/tables/fixtures/fn/DIFFERENCES.md +++ b/app/src/features/tables/fixtures/fn/DIFFERENCES.md @@ -5,37 +5,51 @@ below says otherwise (ADR 0058, ADR 0060). A fixture case whose LibreOffice valu keeps Excel's value and is marked `oracle-differs`, so the nightly oracle does not flag it. Each family lane adds its own lines here. "Not yet checked" means no fixture pins that app's answer yet. -| Case | Excel | Sheets and Calc | OpenNote | -| --------------------------------------------------------------- | ------------------------------------------------------------------------ | -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -| `0^0` and `POWER(0,0)` | `#NUM!` | 1 | `#NUM!`, for the operator and the function | -| `-2^2` and `2^3^2` | 4 and 64 | 4 and 64 | The same in a table with `data.sheet.v`. A table written before it keeps -4 and 512 (`legacy.ts`) | -| The bare word `E` | An unknown name | Not yet checked | `#NAME?` with `data.sheet.v`; e (2.718...) in a table written before it | -| Dates before 1900-03-01 | One higher, for a 1900-02-29 that never was | Count the true calendar, day 0 on 1899-12-30 | The true calendar. Import and export add or remove Excel's extra day | -| Dates before 1899-12-30 | Not dates | Negative serials in Sheets | Negative serials | -| `DATE` with a year from 100 to 1899 | Adds 1900 | Not yet checked | The year as written, so `DATE(1899,12,30)` is 0. Years 0 to 99 add 1900, as before | -| Text in arithmetic (`"$5"+1`) | The region's number, currency, percent, fraction, time, and date forms | Not yet checked | US forms for now: `$`, `%`, grouped digits, `1 1/2`, `1:30 PM`, ISO dates, month-first dates, English month names | -| Day and month text without a year (`"1/2"+0`) | A date in the current year | Not yet checked | `#VALUE!`, so a result never depends on today's date | -| Currency and percent together (`"$5%"+0`) | `#VALUE!` | Not yet checked | `#VALUE!` | -| A final `+` or `-` within rounding error of zero | 0 (Microsoft's `=0.5-0.4-0.1` example) | Not yet checked | 0 when the result is smaller than 2^-48 of the larger operand | -| `=` on numbers | Compares 15 significant digits | Not yet checked | Compares 15 significant digits | -| `TRIM` | Removes only the space character (code 32) | Not yet checked | The space character only, so a tab stays | -| Byte functions (`LENB`, `LEFTB`, and the rest) | Count two bytes for a character in a double-byte language, one elsewhere | Not yet checked | One byte for every character, because there is no double-byte region yet. `ASC` and `DBCS` return their text | -| `CHAR` and `CODE` above 127 | The system's code page | Not yet checked | Windows Latin-1 (code page 1252). `CODE` of a character outside it is 63 | -| `REGEXEXTRACT(text, pattern)` with a capturing group | The whole match | The groups | The whole match, as Excel does. Mode 2 gives the groups. A Sheets-origin dialect is not built yet | -| Regular expression flavor | PCRE2 | RE2 | The JavaScript engine (flag v, then u, then none). A pattern over 1,000 characters or an input over 32,768 is `#VALUE!` | -| `REGEXEXTRACT` mode 2 on a pattern with no group | Not yet checked | Not applicable | `#VALUE!` | -| `ARRAYTOTEXT` strict form of one value | Not yet checked | Not applicable | The value without braces | -| `COMBINA(0,k)` with k above 0 | Not yet checked | Not yet checked | `#NUM!`; `COMBINA(0,0)` is 1 | -| `ROMAN` forms 1 to 4 | Concise numerals, with 499 as LDVLIV, XDIX, VDIV, ID | Not yet checked | Pair tables built from those published examples; other numbers may differ | -| `SUBTOTAL` and `AGGREGATE` over cells that hold other subtotals | The inner results are skipped | Not yet checked | Skipped too: a cell whose formula calls SUBTOTAL or AGGREGATE is left out (AGGREGATE options 0 to 3) | -| `SUMIF` with a sum range of another size | Resizes it to the tested range from its top-left corner | Not yet checked | Resized the same way | -| `AGGREGATE` 1 to 13 with an array argument | Takes only references | Not yet checked | Accepts array constants and computed arrays too, as `AGGREGATE(9,6,{1,2})` gives 3 | -| `DATEDIF` unit `"MD"` when the end day is before the start day | Borrows days in a way that can give odd results | Not yet checked | Borrows the days of the month before the end month: `DATEDIF(2001-06-20, 2002-08-15, "MD")` is 26 | -| `NETWORKDAYS` and `WORKDAY` before 1900-03-01 or after 9999-12-31 | Not dates | Work on negative serials | `WORKDAY` gives `#NUM!` outside serial 0 to 2958465. Counting refuses a start, end, or holiday serial beyond 100,000,000 with `#NUM!`. More than 3,000,000 working days is `#NUM!` | -| `NOW()` | Date and time to the second | Same | Today's date from the engine plus the device clock to the second | -| Weekend mask `"1111111"` or code 0 | `#VALUE!` and `#NUM!` | Not yet checked | `#VALUE!` and `#NUM!` | -| `DATEVALUE` and `TIMEVALUE` date order | The region's date order | The region's date order | Always month, day, year, because functions get no locale. `DATEVALUE("24.07.2008")` is `#VALUE!` in de-DE. Later, pass the table's date order in `Env` | -| `NOW()` after midnight with a cached date | Always the current moment | Always the current moment | Date from `env.today()` plus the device clock, so a stale `today` mixes dates. Later, add `env.now()` to `Env` | +| Case | Excel | Sheets and Calc | OpenNote | +| ------------------------------------------------------------------------- | ------------------------------------------------------------------------ | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0^0` and `POWER(0,0)` | `#NUM!` | 1 | `#NUM!`, for the operator and the function | +| `-2^2` and `2^3^2` | 4 and 64 | 4 and 64 | The same in a table with `data.sheet.v`. A table written before it keeps -4 and 512 (`legacy.ts`) | +| The bare word `E` | An unknown name | Not yet checked | `#NAME?` with `data.sheet.v`; e (2.718...) in a table written before it | +| Dates before 1900-03-01 | One higher, for a 1900-02-29 that never was | Count the true calendar, day 0 on 1899-12-30 | The true calendar. Import and export add or remove Excel's extra day | +| Dates before 1899-12-30 | Not dates | Negative serials in Sheets | Negative serials | +| `DATE` with a year from 100 to 1899 | Adds 1900 | Not yet checked | The year as written, so `DATE(1899,12,30)` is 0. Years 0 to 99 add 1900, as before | +| Text in arithmetic (`"$5"+1`) | The region's number, currency, percent, fraction, time, and date forms | Not yet checked | US forms for now: `$`, `%`, grouped digits, `1 1/2`, `1:30 PM`, ISO dates, month-first dates, English month names | +| Day and month text without a year (`"1/2"+0`) | A date in the current year | Not yet checked | `#VALUE!`, so a result never depends on today's date | +| Currency and percent together (`"$5%"+0`) | `#VALUE!` | Not yet checked | `#VALUE!` | +| A final `+` or `-` within rounding error of zero | 0 (Microsoft's `=0.5-0.4-0.1` example) | Not yet checked | 0 when the result is smaller than 2^-48 of the larger operand | +| `=` on numbers | Compares 15 significant digits | Not yet checked | Compares 15 significant digits | +| Approximate lookups over mixed kinds (`MATCH(2,{1,"a",3})`) | A binary search with its own skipping rule for other kinds | Not yet checked | Binary search; an item of another kind (or empty) is passed over for the nearest one of the lookup value's kind, looking down first | +| `XLOOKUP` and `XMATCH` match mode 3 | Regular expressions (PCRE2) | Sheets: RE2 | JavaScript syntax, run by a matcher that never backtracks, so every pattern takes time linear in the text; a back reference or lookaround gives `#VALUE!` | +| `ROWS(A1:A10)` in a table with fewer rows | 10 | 10 | The table's own row count, since a reference ends at the table's last row | +| `INDIRECT` with a defined name, a whole column, or a structured reference | The name's cells | Not yet checked | `#REF!` for now; cells and ranges in A1 or R1C1 style work, with an optional table name | +| A range that ends in a function (`A1:INDEX(A1:A5,3)`) | A reference | Not yet checked | Not parsed yet; INDEX and OFFSET still give references to SUM, ROWS, and the other functions | +| `HYPERLINK` | A link value the cell draws | Not yet checked | Text: the friendly name when given, else the address; `hyperlinkOf` gives the display the link of a cell whose formula is a call to HYPERLINK with a written http, https, or mailto address | +| `TRIM` | Removes only the space character (code 32) | Not yet checked | The space character only, so a tab stays | +| Byte functions (`LENB`, `LEFTB`, and the rest) | Count two bytes for a character in a double-byte language, one elsewhere | Not yet checked | One byte for every character, because there is no double-byte region yet. `ASC` and `DBCS` return their text | +| `CHAR` and `CODE` above 127 | The system's code page | Not yet checked | Windows Latin-1 (code page 1252). `CODE` of a character outside it is 63 | +| `REGEXEXTRACT(text, pattern)` with a capturing group | The whole match | The groups | The whole match, as Excel does. Mode 2 gives the groups. A Sheets-origin dialect is not built yet | +| Regular expression flavor | PCRE2 | RE2 | The JavaScript engine (flag v, then u, then none). A pattern over 1,000 characters or an input over 32,768 is `#VALUE!` | +| `REGEXEXTRACT` mode 2 on a pattern with no group | Not yet checked | Not applicable | `#VALUE!` | +| `ARRAYTOTEXT` strict form of one value | Not yet checked | Not applicable | The value without braces | +| `COMBINA(0,k)` with k above 0 | Not yet checked | Not yet checked | `#NUM!`; `COMBINA(0,0)` is 1 | +| `ROMAN` forms 1 to 4 | Concise numerals, with 499 as LDVLIV, XDIX, VDIV, ID | Not yet checked | Pair tables built from those published examples; other numbers may differ | +| `SUBTOTAL` and `AGGREGATE` over cells that hold other subtotals | The inner results are skipped | Not yet checked | Skipped too: a cell whose formula calls SUBTOTAL or AGGREGATE is left out (AGGREGATE options 0 to 3) | +| `SUMIF` with a sum range of another size | Resizes it to the tested range from its top-left corner | Not yet checked | Resized the same way | +| `AGGREGATE` 1 to 13 with an array argument | Takes only references | Not yet checked | Accepts array constants and computed arrays too, as `AGGREGATE(9,6,{1,2})` gives 3 | +| `DATEDIF` unit `"MD"` when the end day is before the start day | Borrows days in a way that can give odd results | Not yet checked | Borrows the days of the month before the end month: `DATEDIF(2001-06-20, 2002-08-15, "MD")` is 26 | +| `NETWORKDAYS` and `WORKDAY` before 1900-03-01 or after 9999-12-31 | Not dates | Work on negative serials | `WORKDAY` gives `#NUM!` outside serial 0 to 2958465. Counting refuses a start, end, or holiday serial beyond 100,000,000 with `#NUM!`. More than 3,000,000 working days is `#NUM!` | +| `NOW()` | Date and time to the second | Same | Today's date from the engine plus the device clock to the second | +| Weekend mask `"1111111"` or code 0 | `#VALUE!` and `#NUM!` | Not yet checked | `#VALUE!` and `#NUM!` | +| `DATEVALUE` and `TIMEVALUE` date order | The region's date order | The region's date order | Always month, day, year, because functions get no locale. `DATEVALUE("24.07.2008")` is `#VALUE!` in de-DE. Later, pass the table's date order in `Env` | +| `NOW()` after midnight with a cached date | Always the current moment | Always the current moment | Date from `env.today()` plus the device clock, so a stale `today` mixes dates. Later, add `env.now()` to `Env` | +| `SORT` and `SORTBY` with empty cells | Not yet checked | Not yet checked | Empty cells sort last in both directions, as the Sort command does | +| `TRIMRANGE` of a range with no values | Not yet checked | Not in Sheets or Calc | `#CALC!`, an empty array | +| `MINVERSE` and `MDETERM` above 500 rows | Not yet checked | Not yet checked | `#NUM!` with "Too many values", since the work grows with the cube of the size | +| `MAP` with arrays of different sizes | Not yet checked | Not yet checked | A single row or column stretches; other missing items are `#N/A`, as operators do | +| Sheets' `SORTN` ties and repeats | Not in Excel | Sheets: not yet checked | Ties (mode 1) compare the sort columns; repeats (modes 2 and 3) compare whole rows. With no sort column, every column sorts ascending | +| `SEQUENCE({2,3})` and other array counts | Not yet checked | Not yet checked | Lifts over the array: each item gives its own result and keeps its first value, so `SEQUENCE({2,3})` is `{1,1}` | +| `FILTER` with an empty include cell | Not yet checked | Not yet checked | The empty cell counts as FALSE, so its row is left out | +| A `FORECAST.ETS` timeline of more than 10,000 steps | Not yet checked | Not yet checked | `#NUM!`, so a far-apart timeline cannot freeze the app | ## REGEX guard (B10 gap fixes) diff --git a/app/src/features/tables/fixtures/fn/arrays.json b/app/src/features/tables/fixtures/fn/arrays.json new file mode 100644 index 000000000..0e0b0ffb2 --- /dev/null +++ b/app/src/features/tables/fixtures/fn/arrays.json @@ -0,0 +1,133 @@ +{ + "note": "Hand-worked cases for the dynamic array, matrix, and LAMBDA helper functions. An array result is its rows; an error is # and its code.", + "cases": [ + { + "formula": "=SEQUENCE(2,2,0,0.5)", + "expected": [ + [0, 0.5], + [1, 1.5] + ], + "source": "hand", + "note": "Start at 0 and add 0.5 across each row, then on to the next row." + }, + { + "formula": "=SUM(SEQUENCE(10))", + "expected": 55, + "source": "hand", + "note": "1 through 10 add to 10 times 11 over 2." + }, + { + "formula": "=SORT(A1:A4,1,-1)", + "cells": { "A1": 5, "A2": 9, "A3": 1, "A4": 7 }, + "expected": [[9], [7], [5], [1]], + "source": "hand", + "note": "Largest first." + }, + { + "formula": "=UNIQUE(A1:A5)", + "cells": { "A1": "pear", "A2": "fig", "A3": "PEAR", "A4": "kiwi", "A5": "fig" }, + "expected": [["pear"], ["fig"], ["kiwi"]], + "source": "hand", + "note": "Text matches without regard to case; the first spelling stays." + }, + { + "formula": "=FILTER(A1:B3,B1:B3>=10)", + "cells": { "A1": "a", "B1": 4, "A2": "b", "B2": 10, "A3": "c", "B3": 12 }, + "expected": [ + ["b", 10], + ["c", 12] + ], + "source": "hand", + "note": "Rows 2 and 3 pass the test." + }, + { + "formula": "=SUM(DROP(SEQUENCE(9),-4))", + "expected": 15, + "source": "hand", + "note": "1 through 9 less the last four rows: 1 through 5 add to 15." + }, + { + "formula": "=HSTACK({1;2;3},{4;5})", + "expected": [ + [1, 4], + [2, 5], + [3, "#NA"] + ], + "source": "hand", + "note": "The shorter array is padded with #N/A." + }, + { + "formula": "=MMULT({1,0,2},{3;4;5})", + "expected": 13, + "source": "hand", + "note": "1 times 3 plus 0 times 4 plus 2 times 5." + }, + { + "formula": "=MMULT({1;2},{3,4})", + "expected": [ + [3, 4], + [6, 8] + ], + "source": "hand", + "note": "A column times a row is their outer product." + }, + { + "formula": "=MDETERM({2,0,0;0,3,0;0,0,4})", + "expected": 24, + "source": "hand", + "note": "A diagonal matrix's determinant is the product of its diagonal." + }, + { + "formula": "=MINVERSE({2,0;0,4})", + "expected": [ + [0.5, 0], + [0, 0.25] + ], + "source": "hand", + "note": "A diagonal matrix inverts item by item." + }, + { + "formula": "=REDUCE(1,SEQUENCE(5),LAMBDA(a,b,a*b))", + "expected": 120, + "source": "hand", + "note": "5 factorial." + }, + { + "formula": "=SCAN(\"\",{\"a\",\"b\",\"c\"},LAMBDA(a,b,a&b))", + "expected": [["a", "ab", "abc"]], + "source": "hand", + "note": "Each step joins the next letter." + }, + { + "formula": "=BYCOL({1,2;3,4;5,6},LAMBDA(c,SUM(c)))", + "expected": [[9, 12]], + "source": "hand", + "note": "1 plus 3 plus 5, and 2 plus 4 plus 6." + }, + { + "formula": "=MAKEARRAY(2,3,LAMBDA(r,c,(r-1)*3+c))", + "expected": [ + [1, 2, 3], + [4, 5, 6] + ], + "source": "hand", + "note": "The cells numbered across each row." + }, + { + "formula": "=WRAPCOLS(SEQUENCE(1,5),2)", + "expected": [ + [1, 3, 5], + [2, 4, "#NA"] + ], + "source": "hand", + "note": "Columns of two, the last one padded." + }, + { + "formula": "=TAKE(SORTBY(A1:A3,B1:B3,-1),1)", + "cells": { "A1": "Ann", "B1": 81, "A2": "Bo", "B2": 95, "A3": "Cy", "B3": 77 }, + "expected": "Bo", + "source": "hand", + "note": "The name with the highest score." + } + ] +} diff --git a/app/src/features/tables/fixtures/fn/logicInfo.json b/app/src/features/tables/fixtures/fn/logicInfo.json new file mode 100644 index 000000000..c3b4868b8 --- /dev/null +++ b/app/src/features/tables/fixtures/fn/logicInfo.json @@ -0,0 +1,483 @@ +{ + "about": "Logical and information functions. The formula is typed in D3 of a sheet with columns A to D and these data rows. An expect of {error} is the error's token.", + "data": [ + [ + "1", + "2", + "x", + "" + ], + [ + "3", + "4", + "y", + "" + ], + [ + "", + "", + "", + "" + ] + ], + "cases": [ + { + "formula": "=IFS(FALSE,1,TRUE,2)", + "expect": 2, + "source": "Documented example: the IFS entry in the Google Sheets function list and the ODF 1.3 formula appendix; the fixture sheet matches its stated inputs." + }, + { + "formula": "=IFS(FALSE,1)", + "expect": { + "error": "#N/A" + }, + "source": "Documented example: the IFS entry in the Google Sheets function list and the ODF 1.3 formula appendix; the fixture sheet matches its stated inputs." + }, + { + "formula": "=IFS(A1>5,\"big\",A1>0,\"small\")", + "expect": "small", + "source": "Hand-worked from the documented rule of IFS on the fixture data (A1=1, B1=2, C1=\"x\", D1 blank); the result follows from that rule alone." + }, + { + "formula": "=IFS(TRUE,1,1/0,2)", + "expect": 1, + "source": "Hand-worked from the documented rule of IFS on the fixture data (A1=1, B1=2, C1=\"x\", D1 blank); the result follows from that rule alone." + }, + { + "formula": "=IFS(FALSE,1,1/0,2)", + "expect": { + "error": "#DIV/0!" + }, + "source": "Hand-worked from the documented rule of IFS on the fixture data (A1=1, B1=2, C1=\"x\", D1 blank); the result follows from that rule alone." + }, + { + "formula": "=SWITCH(2,1,\"a\",2,\"b\",\"z\")", + "expect": "b", + "source": "Documented example: the SWITCH entry in the Google Sheets function list and the ODF 1.3 formula appendix; the fixture sheet matches its stated inputs." + }, + { + "formula": "=SWITCH(9,1,\"a\",2,\"b\",\"z\")", + "expect": "z", + "source": "Documented example: the SWITCH entry in the Google Sheets function list and the ODF 1.3 formula appendix; the fixture sheet matches its stated inputs." + }, + { + "formula": "=SWITCH(9,1,\"a\",2,\"b\")", + "expect": { + "error": "#N/A" + }, + "source": "Documented example: the SWITCH entry in the Google Sheets function list and the ODF 1.3 formula appendix; the fixture sheet matches its stated inputs." + }, + { + "formula": "=SWITCH(\"B\",\"a\",1,\"b\",2)", + "expect": 2, + "source": "Hand-worked from the documented rule of SWITCH on the fixture data (A1=1, B1=2, C1=\"x\", D1 blank); the result follows from that rule alone." + }, + { + "formula": "=XOR(TRUE,TRUE,TRUE)", + "expect": true, + "source": "Documented example: the XOR entry in the Google Sheets function list and the ODF 1.3 formula appendix; the fixture sheet matches its stated inputs." + }, + { + "formula": "=XOR(TRUE,TRUE)", + "expect": false, + "source": "Documented example: the XOR entry in the Google Sheets function list and the ODF 1.3 formula appendix; the fixture sheet matches its stated inputs." + }, + { + "formula": "=XOR(A1:B1>1)", + "expect": true, + "source": "Hand-worked from the documented rule of XOR on the fixture data (A1=1, B1=2, C1=\"x\", D1 blank); the result follows from that rule alone." + }, + { + "formula": "=IFNA(NA(),\"x\")", + "expect": "x", + "source": "Documented example: the IFNA entry in the Google Sheets function list and the ODF 1.3 formula appendix; the fixture sheet matches its stated inputs." + }, + { + "formula": "=IFNA(1/0,\"x\")", + "expect": { + "error": "#DIV/0!" + }, + "source": "Hand-worked from the documented rule of IFNA on the fixture data (A1=1, B1=2, C1=\"x\", D1 blank); the result follows from that rule alone." + }, + { + "formula": "=IFNA(5,\"x\")", + "expect": 5, + "source": "Hand-worked from the documented rule of IFNA on the fixture data (A1=1, B1=2, C1=\"x\", D1 blank); the result follows from that rule alone." + }, + { + "formula": "=TRUE()", + "expect": true, + "source": "Hand-worked from the documented rule of TRUE on the fixture data (A1=1, B1=2, C1=\"x\", D1 blank); the result follows from that rule alone." + }, + { + "formula": "=FALSE()", + "expect": false, + "source": "Hand-worked from the documented rule of FALSE on the fixture data (A1=1, B1=2, C1=\"x\", D1 blank); the result follows from that rule alone." + }, + { + "formula": "=AND(A1:B1)", + "expect": true, + "source": "Hand-worked from the documented rule of AND on the fixture data (A1=1, B1=2, C1=\"x\", D1 blank); the result follows from that rule alone." + }, + { + "formula": "=AND(A1:B1,FALSE)", + "expect": false, + "source": "Hand-worked from the documented rule of AND on the fixture data (A1=1, B1=2, C1=\"x\", D1 blank); the result follows from that rule alone." + }, + { + "formula": "=OR(A1:B1>5,C1=\"x\")", + "expect": true, + "source": "Hand-worked from the documented rule of OR on the fixture data (A1=1, B1=2, C1=\"x\", D1 blank); the result follows from that rule alone." + }, + { + "formula": "=AND(C1)", + "expect": { + "error": "#VALUE!" + }, + "source": "Documented example: the AND entry in the Google Sheets function list and the ODF 1.3 formula appendix; the fixture sheet matches its stated inputs." + }, + { + "formula": "=AND(A1:C1)", + "expect": true, + "source": "Documented example: the AND entry in the Google Sheets function list and the ODF 1.3 formula appendix; the fixture sheet matches its stated inputs." + }, + { + "formula": "=OR(D1:D2)", + "expect": { + "error": "#VALUE!" + }, + "source": "Documented example: the OR entry in the Google Sheets function list and the ODF 1.3 formula appendix; the fixture sheet matches its stated inputs." + }, + { + "formula": "=ISNUMBER(\"1\")", + "expect": false, + "source": "Hand-worked from the documented rule of ISNUMBER on the fixture data (A1=1, B1=2, C1=\"x\", D1 blank); the result follows from that rule alone." + }, + { + "formula": "=ISNUMBER(1)", + "expect": true, + "source": "Hand-worked from the documented rule of ISNUMBER on the fixture data (A1=1, B1=2, C1=\"x\", D1 blank); the result follows from that rule alone." + }, + { + "formula": "=ISTEXT(\"a\")", + "expect": true, + "source": "Hand-worked from the documented rule of ISTEXT on the fixture data (A1=1, B1=2, C1=\"x\", D1 blank); the result follows from that rule alone." + }, + { + "formula": "=ISNONTEXT(D1)", + "expect": true, + "source": "Hand-worked from the documented rule of ISNONTEXT on the fixture data (A1=1, B1=2, C1=\"x\", D1 blank); the result follows from that rule alone." + }, + { + "formula": "=ISLOGICAL(1>2)", + "expect": true, + "source": "Hand-worked from the documented rule of ISLOGICAL on the fixture data (A1=1, B1=2, C1=\"x\", D1 blank); the result follows from that rule alone." + }, + { + "formula": "=ISERROR(1/0)", + "expect": true, + "source": "Hand-worked from the documented rule of ISERROR on the fixture data (A1=1, B1=2, C1=\"x\", D1 blank); the result follows from that rule alone." + }, + { + "formula": "=ISERR(NA())", + "expect": false, + "source": "Hand-worked from the documented rule of ISERR on the fixture data (A1=1, B1=2, C1=\"x\", D1 blank); the result follows from that rule alone." + }, + { + "formula": "=ISERR(1/0)", + "expect": true, + "source": "Hand-worked from the documented rule of ISERR on the fixture data (A1=1, B1=2, C1=\"x\", D1 blank); the result follows from that rule alone." + }, + { + "formula": "=ISNA(NA())", + "expect": true, + "source": "Hand-worked from the documented rule of ISNA on the fixture data (A1=1, B1=2, C1=\"x\", D1 blank); the result follows from that rule alone." + }, + { + "formula": "=ISBLANK(D1)", + "expect": true, + "source": "Hand-worked from the documented rule of ISBLANK on the fixture data (A1=1, B1=2, C1=\"x\", D1 blank); the result follows from that rule alone." + }, + { + "formula": "=N(TRUE)", + "expect": 1, + "source": "Documented example: the N entry in the Google Sheets function list and the ODF 1.3 formula appendix; the fixture sheet matches its stated inputs." + }, + { + "formula": "=N(\"7\")", + "expect": 0, + "source": "Documented example: the N entry in the Google Sheets function list and the ODF 1.3 formula appendix; the fixture sheet matches its stated inputs." + }, + { + "formula": "=N(A1+1)", + "expect": 2, + "source": "Hand-worked from the documented rule of N on the fixture data (A1=1, B1=2, C1=\"x\", D1 blank); the result follows from that rule alone." + }, + { + "formula": "=N(1/0)", + "expect": { + "error": "#DIV/0!" + }, + "source": "Hand-worked from the documented rule of N on the fixture data (A1=1, B1=2, C1=\"x\", D1 blank); the result follows from that rule alone." + }, + { + "formula": "=NA()", + "expect": { + "error": "#N/A" + }, + "source": "Documented example: the NA entry in the Google Sheets function list and the ODF 1.3 formula appendix; the fixture sheet matches its stated inputs." + }, + { + "formula": "=TYPE(\"a\")", + "expect": 2, + "source": "Documented example: the TYPE entry in the Google Sheets function list and the ODF 1.3 formula appendix; the fixture sheet matches its stated inputs." + }, + { + "formula": "=TYPE(1)", + "expect": 1, + "source": "Documented example: the TYPE entry in the Google Sheets function list and the ODF 1.3 formula appendix; the fixture sheet matches its stated inputs." + }, + { + "formula": "=TYPE(TRUE)", + "expect": 4, + "source": "Documented example: the TYPE entry in the Google Sheets function list and the ODF 1.3 formula appendix; the fixture sheet matches its stated inputs." + }, + { + "formula": "=TYPE(1/0)", + "expect": 16, + "source": "Documented example: the TYPE entry in the Google Sheets function list and the ODF 1.3 formula appendix; the fixture sheet matches its stated inputs." + }, + { + "formula": "=TYPE({1,2})", + "expect": 64, + "source": "Documented example: the TYPE entry in the Google Sheets function list and the ODF 1.3 formula appendix; the fixture sheet matches its stated inputs." + }, + { + "formula": "=ERROR.TYPE(1/0)", + "expect": 2, + "source": "Documented example: the ERROR.TYPE entry in the Google Sheets function list and the ODF 1.3 formula appendix; the fixture sheet matches its stated inputs." + }, + { + "formula": "=ERROR.TYPE(NA())", + "expect": 7, + "source": "Documented example: the ERROR.TYPE entry in the Google Sheets function list and the ODF 1.3 formula appendix; the fixture sheet matches its stated inputs." + }, + { + "formula": "=ERROR.TYPE(A1)", + "expect": { + "error": "#N/A" + }, + "source": "Documented example: the ERROR.TYPE entry in the Google Sheets function list and the ODF 1.3 formula appendix; the fixture sheet matches its stated inputs." + }, + { + "formula": "=ISEVEN(-2)", + "expect": true, + "source": "Hand-worked from the documented rule of ISEVEN on the fixture data (A1=1, B1=2, C1=\"x\", D1 blank); the result follows from that rule alone." + }, + { + "formula": "=ISODD(2.9)", + "expect": false, + "source": "Hand-worked from the documented rule of ISODD on the fixture data (A1=1, B1=2, C1=\"x\", D1 blank); the result follows from that rule alone." + }, + { + "formula": "=ISODD(-3)", + "expect": true, + "source": "Hand-worked from the documented rule of ISODD on the fixture data (A1=1, B1=2, C1=\"x\", D1 blank); the result follows from that rule alone." + }, + { + "formula": "=ISEVEN(\"x\")", + "expect": { + "error": "#VALUE!" + }, + "source": "Hand-worked from the documented rule of ISEVEN on the fixture data (A1=1, B1=2, C1=\"x\", D1 blank); the result follows from that rule alone." + }, + { + "formula": "=ISREF(A1)", + "expect": true, + "source": "Hand-worked from the documented rule of ISREF on the fixture data (A1=1, B1=2, C1=\"x\", D1 blank); the result follows from that rule alone." + }, + { + "formula": "=ISREF(5)", + "expect": false, + "source": "Hand-worked from the documented rule of ISREF on the fixture data (A1=1, B1=2, C1=\"x\", D1 blank); the result follows from that rule alone." + }, + { + "formula": "=ISREF(A1:B2)", + "expect": true, + "source": "Hand-worked from the documented rule of ISREF on the fixture data (A1=1, B1=2, C1=\"x\", D1 blank); the result follows from that rule alone." + }, + { + "formula": "=ISFORMULA(A1)", + "expect": false, + "source": "Hand-worked from the documented rule of ISFORMULA on the fixture data (A1=1, B1=2, C1=\"x\", D1 blank); the result follows from that rule alone." + }, + { + "formula": "=SHEET()", + "expect": 1, + "source": "Hand-worked from the documented rule of SHEET on the fixture data (A1=1, B1=2, C1=\"x\", D1 blank); the result follows from that rule alone." + }, + { + "formula": "=SHEET(\"Table1\")", + "expect": 1, + "source": "Hand-worked from the documented rule of SHEET on the fixture data (A1=1, B1=2, C1=\"x\", D1 blank); the result follows from that rule alone." + }, + { + "formula": "=SHEET(\"Nope\")", + "expect": { + "error": "#N/A" + }, + "source": "Hand-worked from the documented rule of SHEET on the fixture data (A1=1, B1=2, C1=\"x\", D1 blank); the result follows from that rule alone." + }, + { + "formula": "=SHEETS()", + "expect": 1, + "source": "Hand-worked from the documented rule of SHEETS on the fixture data (A1=1, B1=2, C1=\"x\", D1 blank); the result follows from that rule alone." + }, + { + "formula": "=CELL(\"address\",B2)", + "expect": "$B$2", + "source": "Documented example: the CELL entry in the Google Sheets function list and the ODF 1.3 formula appendix; the fixture sheet matches its stated inputs." + }, + { + "formula": "=CELL(\"col\",C1)", + "expect": 3, + "source": "Hand-worked from the documented rule of CELL on the fixture data (A1=1, B1=2, C1=\"x\", D1 blank); the result follows from that rule alone." + }, + { + "formula": "=CELL(\"row\",C2)", + "expect": 2, + "source": "Hand-worked from the documented rule of CELL on the fixture data (A1=1, B1=2, C1=\"x\", D1 blank); the result follows from that rule alone." + }, + { + "formula": "=CELL(\"contents\",B1)", + "expect": 2, + "source": "Hand-worked from the documented rule of CELL on the fixture data (A1=1, B1=2, C1=\"x\", D1 blank); the result follows from that rule alone." + }, + { + "formula": "=CELL(\"type\",C1)", + "expect": "l", + "source": "Hand-worked from the documented rule of CELL on the fixture data (A1=1, B1=2, C1=\"x\", D1 blank); the result follows from that rule alone." + }, + { + "formula": "=CELL(\"type\",A1)", + "expect": "v", + "source": "Hand-worked from the documented rule of CELL on the fixture data (A1=1, B1=2, C1=\"x\", D1 blank); the result follows from that rule alone." + }, + { + "formula": "=CELL(\"type\",D1)", + "expect": "b", + "source": "Hand-worked from the documented rule of CELL on the fixture data (A1=1, B1=2, C1=\"x\", D1 blank); the result follows from that rule alone." + }, + { + "formula": "=CELL(\"filename\",A1)", + "expect": "", + "source": "Hand-worked from the documented rule of CELL on the fixture data (A1=1, B1=2, C1=\"x\", D1 blank); the result follows from that rule alone." + }, + { + "formula": "=CELL(\"format\",A1)", + "expect": "G", + "source": "Hand-worked from the documented rule of CELL on the fixture data (A1=1, B1=2, C1=\"x\", D1 blank); the result follows from that rule alone." + }, + { + "formula": "=CELL(\"width\",A1)", + "expect": 8, + "source": "Hand-worked from the documented rule of CELL on the fixture data (A1=1, B1=2, C1=\"x\", D1 blank); the result follows from that rule alone." + }, + { + "formula": "=CELL(\"nonsense\",A1)", + "expect": { + "error": "#VALUE!" + }, + "source": "Hand-worked from the documented rule of CELL on the fixture data (A1=1, B1=2, C1=\"x\", D1 blank); the result follows from that rule alone." + }, + { + "formula": "=INFO(\"recalc\")", + "expect": "Automatic", + "source": "Hand-worked from the documented rule of INFO on the fixture data (A1=1, B1=2, C1=\"x\", D1 blank); the result follows from that rule alone." + }, + { + "formula": "=INFO(\"numfile\")", + "expect": 1, + "source": "Hand-worked from the documented rule of INFO on the fixture data (A1=1, B1=2, C1=\"x\", D1 blank); the result follows from that rule alone." + }, + { + "formula": "=INFO(\"directory\")", + "expect": { + "error": "#N/A" + }, + "source": "Hand-worked from the documented rule of INFO on the fixture data (A1=1, B1=2, C1=\"x\", D1 blank); the result follows from that rule alone." + }, + { + "formula": "=ISEMAIL(\"a@b.org\")", + "expect": true, + "source": "Hand-worked from the documented rule of ISEMAIL on the fixture data (A1=1, B1=2, C1=\"x\", D1 blank); the result follows from that rule alone." + }, + { + "formula": "=ISEMAIL(\"a@b\")", + "expect": false, + "source": "Hand-worked from the documented rule of ISEMAIL on the fixture data (A1=1, B1=2, C1=\"x\", D1 blank); the result follows from that rule alone." + }, + { + "formula": "=ISEMAIL(\"a b@c.org\")", + "expect": false, + "source": "Hand-worked from the documented rule of ISEMAIL on the fixture data (A1=1, B1=2, C1=\"x\", D1 blank); the result follows from that rule alone." + }, + { + "formula": "=ISURL(\"https://example.org/x?y=1\")", + "expect": true, + "source": "Hand-worked from the documented rule of ISURL on the fixture data (A1=1, B1=2, C1=\"x\", D1 blank); the result follows from that rule alone." + }, + { + "formula": "=ISURL(\"example.org\")", + "expect": true, + "source": "Hand-worked from the documented rule of ISURL on the fixture data (A1=1, B1=2, C1=\"x\", D1 blank); the result follows from that rule alone." + }, + { + "formula": "=ISURL(\"not a url\")", + "expect": false, + "source": "Hand-worked from the documented rule of ISURL on the fixture data (A1=1, B1=2, C1=\"x\", D1 blank); the result follows from that rule alone." + }, + { + "formula": "=ISDATE(\"2026-01-31\")", + "expect": true, + "source": "Hand-worked from the documented rule of ISDATE on the fixture data (A1=1, B1=2, C1=\"x\", D1 blank); the result follows from that rule alone." + }, + { + "formula": "=ISDATE(\"hello\")", + "expect": false, + "source": "Hand-worked from the documented rule of ISDATE on the fixture data (A1=1, B1=2, C1=\"x\", D1 blank); the result follows from that rule alone." + }, + { + "formula": "=ISDATE(5)", + "expect": false, + "source": "Hand-worked from the documented rule of ISDATE on the fixture data (A1=1, B1=2, C1=\"x\", D1 blank); the result follows from that rule alone." + }, + { + "formula": "=ISDATE(\"1969-20-07\")", + "expect": true, + "source": "Documented example: the ISDATE reference page on support.google.com/docs (answer 9061381)." + }, + { + "formula": "=ISDATE(\"July 20 1969\")", + "expect": true, + "source": "Documented example: the ISDATE reference page on support.google.com/docs (answer 9061381)." + }, + { + "formula": "=ISDATE(\"July\")", + "expect": false, + "source": "Documented example: the ISDATE reference page on support.google.com/docs (answer 9061381)." + }, + { + "formula": "=ISDATE(\"Feb 30\")", + "expect": false, + "source": "Documented example: the ISDATE reference page on support.google.com/docs (answer 9061381)." + }, + { + "formula": "=SWITCH(1,1/0,\"x\",1,\"ok\")", + "expect": { + "error": "#DIV/0!" + }, + "source": "Hand-worked: match values are read in order; the first match value is an error reached before the match 1." + } + ] +} diff --git a/app/src/features/tables/fixtures/fn/lookup.json b/app/src/features/tables/fixtures/fn/lookup.json new file mode 100644 index 000000000..ee8c3bbe9 --- /dev/null +++ b/app/src/features/tables/fixtures/fn/lookup.json @@ -0,0 +1,455 @@ +{ + "about": "Lookup and reference functions. Each case types `formula` into F1 (or the cell in `at`) of a table with columns A to F and six rows, after typing `cells`. `expected` is a value, {\"error\": code}, or {\"array\": rows}. Sources: excel-doc (an example in Microsoft's function reference), hand (worked by hand from the documented rule in the note).", + "cases": [ + { + "formula": "=VLOOKUP(2.5,{1,\"a\";2,\"b\";3,\"c\"},2,TRUE)", + "expected": "b", + "source": "hand", + "note": "The nearest smaller key is 2." + }, + { + "formula": "=VLOOKUP(2.5,{1,\"a\";2,\"b\";3,\"c\"},2,FALSE)", + "expected": { "error": "NA" }, + "source": "hand", + "note": "No exact 2.5." + }, + { + "formula": "=VLOOKUP(3,{1,\"a\";5,\"b\";2,\"c\";6,\"d\";3,\"e\"},2,TRUE)", + "expected": "c", + "source": "hand", + "note": "Unsorted keys 1,5,2,6,3. Binary search: middle of 1..5 is item 3 (2 <= 3, go right); middle of 4..5 is item 4 (6 > 3, go left); stop. The last fit is item 3, so c, although 3 is in the last row." + }, + { + "formula": "=VLOOKUP(0,{1,\"a\";2,\"b\"},2)", + "expected": { "error": "NA" }, + "source": "hand", + "note": "Smaller than every key." + }, + { + "formula": "=VLOOKUP(1,{1,\"a\"},3,FALSE)", + "expected": { "error": "REF" }, + "source": "hand", + "note": "A column past the table is #REF!." + }, + { + "formula": "=VLOOKUP(1,{1,\"a\"},0,FALSE)", + "expected": { "error": "VALUE" }, + "source": "hand", + "note": "A column below 1 is #VALUE!." + }, + { + "formula": "=VLOOKUP(\"B*\",{\"apple\",1;\"banana\",2},2,FALSE)", + "expected": 2, + "source": "hand", + "note": "Exact match takes wildcards, without regard to case." + }, + { + "formula": "=VLOOKUP(\"a~*\",{\"ab\",1;\"a*\",2},2,FALSE)", + "expected": 2, + "source": "hand", + "note": "~ makes * literal." + }, + { + "formula": "=HLOOKUP(\"b\",{\"a\",\"b\",\"c\";1,2,3},2,FALSE)", + "expected": 2, + "source": "hand", + "note": "The first row is searched." + }, + { + "formula": "=HLOOKUP(2.9,{1,2,3;\"x\",\"y\",\"z\"},2)", + "expected": "y", + "source": "hand", + "note": "Nearest smaller, 2." + }, + { + "formula": "=MATCH(25,{10,20,30},1)", + "expected": 2, + "source": "hand", + "note": "Largest value at most 25 is 20, the second." + }, + { + "formula": "=MATCH(25,{30,20,10},-1)", + "expected": 1, + "source": "hand", + "note": "Smallest value at least 25 is 30, the first." + }, + { + "formula": "=MATCH(2,{1,2,2,2,3})", + "expected": 4, + "source": "hand", + "note": "Binary search keeps going right on a fit, so the last of equal keys wins." + }, + { + "formula": "=MATCH(\"b?n*\",{\"apple\",\"banana\"},0)", + "expected": 2, + "source": "hand", + "note": "? is one character and * any run." + }, + { + "formula": "=MATCH(5,{1,2;3,4},0)", + "expected": { "error": "NA" }, + "source": "hand", + "note": "A block with two rows and two columns is not one row or column." + }, + { + "formula": "=MATCH(\"1\",{1,2},0)", + "expected": { "error": "NA" }, + "source": "hand", + "note": "Text does not match a number." + }, + { + "formula": "=XLOOKUP(5,{1,3,7},{\"a\",\"b\",\"c\"},\"none\",-1)", + "expected": "b", + "source": "hand", + "note": "Exact or next smaller: 3." + }, + { + "formula": "=XLOOKUP(5,{1,3,7},{\"a\",\"b\",\"c\"},\"none\",1)", + "expected": "c", + "source": "hand", + "note": "Exact or next larger: 7." + }, + { + "formula": "=XLOOKUP(9,{1,3,7},{\"a\",\"b\",\"c\"},\"none\")", + "expected": "none", + "source": "hand", + "note": "Not found gives if_not_found." + }, + { + "formula": "=XLOOKUP(9,{1,3,7},{\"a\",\"b\",\"c\"})", + "expected": { "error": "NA" }, + "source": "hand", + "note": "Not found without if_not_found." + }, + { + "formula": "=XLOOKUP(2,{2,1,2},{\"x\",\"y\",\"z\"},\"none\",0,-1)", + "expected": "z", + "source": "hand", + "note": "Search mode -1 starts from the end, so the last match." + }, + { + "formula": "=XLOOKUP(2,{2,1,2},{\"x\",\"y\",\"z\"},\"none\",0,1)", + "expected": "x", + "source": "hand", + "note": "Search mode 1 gives the first match." + }, + { + "formula": "=XLOOKUP(\"b*\",{\"apple\",\"banana\"},{1,2},\"none\",2)", + "expected": 2, + "source": "hand", + "note": "Match mode 2 takes wildcards." + }, + { + "formula": "=XLOOKUP(\"b*\",{\"apple\",\"banana\"},{1,2},\"none\",0)", + "expected": "none", + "source": "hand", + "note": "Match mode 0 reads * as itself." + }, + { + "formula": "=XLOOKUP(\"^ba\",{\"apple\",\"banana\"},{1,2},\"none\",3)", + "expected": 2, + "source": "hand", + "note": "Match mode 3 takes a regular expression." + }, + { + "formula": "=XLOOKUP(\"(a+)+$\",{\"aaa\"},{1},\"none\",3)", + "expected": 1, + "source": "hand", + "note": "A nested quantifier is accepted: aaa is one or more runs of a, then the end." + }, + { + "formula": "=XMATCH(\"(a|aa)+$\",A1:A1,3)", + "cells": { "A1": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaab" }, + "expected": { "error": "NA" }, + "source": "hand", + "note": "The text ends in b, so no run of a reaches the end. The matcher never backtracks, so this is fast." + }, + { + "formula": "=HYPERLINK(\"https://example.com\",A1)", + "expected": 0, + "source": "hand", + "note": "An empty cell as the friendly name reads as 0, as in =A1." + }, + { + "formula": "=XMATCH(\"^(a|b)+$\",{\"abc\",\"abba\"},3)", + "expected": 2, + "source": "hand", + "note": "abc has a c, abba is all a and b." + }, + { + "formula": "=XMATCH(\"(a)\\1\",{\"aa\"},3)", + "expected": { "error": "VALUE" }, + "source": "hand", + "note": "A back reference is refused." + }, + { + "formula": "=XMATCH(\"\\bcat\\b\",{\"concat\",\"a cat sat\"},3)", + "expected": 2, + "source": "hand", + "note": "Word boundaries: cat inside concat has a letter before it." + }, + { + "formula": "=XLOOKUP(6,{1,3,5,7},{\"a\",\"b\",\"c\",\"d\"},\"none\",-1,2)", + "expected": "c", + "source": "hand", + "note": "Binary search of ascending data, next smaller: 5." + }, + { + "formula": "=XLOOKUP(6,{7,5,3,1},{\"a\",\"b\",\"c\",\"d\"},\"none\",1,-2)", + "expected": "a", + "source": "hand", + "note": "Binary search of descending data, next larger: 7." + }, + { + "formula": "=XLOOKUP(4,{7,5,3,1},{\"a\",\"b\",\"c\",\"d\"},\"none\",-1,-2)", + "expected": "c", + "source": "hand", + "note": "Descending, next smaller: 3." + }, + { + "at": "D4", + "formula": "=XLOOKUP(2,{1;2;3},{\"a\",\"b\";\"c\",\"d\";\"e\",\"f\"})", + "expected": { "array": [["c", "d"]] }, + "source": "hand", + "note": "A column lookup gives the whole row of the return array." + }, + { + "formula": "=XLOOKUP(2,{1,2,3},{1,2})", + "expected": { "error": "VALUE" }, + "source": "hand", + "note": "Return array of another length." + }, + { + "formula": "=XLOOKUP(2,{1,2,3},{1,2,3},\"none\",5)", + "expected": { "error": "VALUE" }, + "source": "hand", + "note": "No match mode 5." + }, + { "formula": "=XMATCH(5,{1,3,5})", "expected": 3, "source": "hand", "note": "Exact by default." }, + { "formula": "=XMATCH(4,{1,3,5},1)", "expected": 3, "source": "hand", "note": "Next larger, 5." }, + { "formula": "=XMATCH(4,{1,3,5},-1)", "expected": 2, "source": "hand", "note": "Next smaller, 3." }, + { "formula": "=XMATCH(3,{3,1,3},0,-1)", "expected": 3, "source": "hand", "note": "Last to first." }, + { "formula": "=XMATCH(\"*n\",{\"ant\",\"hen\"},2)", "expected": 2, "source": "hand", "note": "Wildcards." }, + { + "formula": "=LOOKUP(4.19,{4.14,4.19,5.17},{\"red\",\"orange\",\"yellow\"})", + "expected": "orange", + "source": "excel-doc", + "note": "Vector form, exact key." + }, + { + "formula": "=LOOKUP(5,{4.14,4.19,5.17},{\"red\",\"orange\",\"yellow\"})", + "expected": "orange", + "source": "excel-doc", + "note": "Nearest smaller." + }, + { + "formula": "=LOOKUP(0,{4.14,4.19,5.17},{\"red\",\"orange\",\"yellow\"})", + "expected": { "error": "NA" }, + "source": "excel-doc", + "note": "Smaller than every key." + }, + { + "formula": "=LOOKUP(\"c\",{\"a\",\"b\",\"c\",\"d\";1,2,3,4})", + "expected": 3, + "source": "hand", + "note": "Array form: wider than tall, so the first row is searched and the last row gives the value." + }, + { + "formula": "=LOOKUP(\"bump\",{\"a\",1;\"b\",2;\"c\",3})", + "expected": 2, + "source": "hand", + "note": "Array form: taller than wide, so the first column." + }, + { "formula": "=INDEX({1,2;3,4},2,1)", "expected": 3, "source": "hand", "note": "Row 2, column 1." }, + { + "formula": "=INDEX({1,2,3},2)", + "expected": 2, + "source": "hand", + "note": "One row: the number picks the column." + }, + { "formula": "=INDEX({1;2;3},2)", "expected": 2, "source": "hand", "note": "One column." }, + { + "formula": "=INDEX({1,2;3,4},0,2)", + "expected": { "array": [[2], [4]] }, + "source": "hand", + "note": "Row 0 gives the whole column." + }, + { + "at": "D4", + "formula": "=INDEX({1,2;3,4},2)", + "expected": { "array": [[3, 4]] }, + "source": "hand", + "note": "Only a row number on a block gives the whole row." + }, + { "formula": "=INDEX({1,2;3,4},3,1)", "expected": { "error": "REF" }, "source": "hand", "note": "Past the block." }, + { "formula": "=INDEX({1,2;3,4},-1,1)", "expected": { "error": "VALUE" }, "source": "hand", "note": "Negative." }, + { + "formula": "=SUM(INDEX({1,2;3,4},0,1))", + "expected": 4, + "source": "hand", + "note": "INDEX of an array gives values to SUM, not a reference." + }, + { + "formula": "=INDEX(A1:B2,2,2)", + "cells": { "B2": "7" }, + "expected": 7, + "source": "hand", + "note": "INDEX of a reference reads the cell." + }, + { + "formula": "=SUM(INDEX(A1:B3,0,2))", + "cells": { "B1": "1", "B2": "2", "B3": "3", "A1": "100" }, + "expected": 6, + "source": "hand", + "note": "Column 2 of A1:B3 is B1:B3." + }, + { + "formula": "=ROWS(INDEX(A1:C3,0,2))", + "expected": 3, + "source": "hand", + "note": "INDEX of a reference gives a reference, so ROWS counts its rows." + }, + { + "formula": "=INDEX((A1:A2,C1:C2),1,1,2)", + "cells": { "A1": "1", "C1": "9" }, + "expected": 9, + "source": "hand", + "note": "Area 2 of the union." + }, + { + "formula": "=OFFSET(A1,1,1)", + "cells": { "B2": "5" }, + "expected": 5, + "source": "hand", + "note": "One down and one right of A1 is B2." + }, + { + "formula": "=SUM(OFFSET(A1,1,0,2,1))", + "cells": { "A2": "2", "A3": "3", "A4": "4" }, + "expected": 5, + "source": "hand", + "note": "A2:A3." + }, + { "formula": "=OFFSET(A1,-1,0)", "expected": { "error": "REF" }, "source": "hand", "note": "Above the first row." }, + { "formula": "=OFFSET(A1,0,0,0,1)", "expected": { "error": "REF" }, "source": "hand", "note": "Height 0." }, + { + "formula": "=INDIRECT(\"B\"&2)", + "cells": { "B2": "8" }, + "expected": 8, + "source": "hand", + "note": "The text B2." + }, + { + "formula": "=SUM(INDIRECT(\"A1:A3\"))", + "cells": { "A1": "1", "A2": "2", "A3": "3" }, + "expected": 6, + "source": "hand", + "note": "A range as text." + }, + { + "formula": "=INDIRECT(\"R2C2\",FALSE)", + "cells": { "B2": "4" }, + "expected": 4, + "source": "hand", + "note": "R1C1, absolute." + }, + { + "formula": "=INDIRECT(\"R[1]C[-4]\",FALSE)", + "cells": { "B2": "6" }, + "expected": 6, + "source": "hand", + "note": "R1C1 relative to F1: one down, four left is B2." + }, + { + "formula": "=INDIRECT(\"nonsense\")", + "expected": { "error": "REF" }, + "source": "hand", + "note": "Not a reference." + }, + { "formula": "=CHOOSE(2,\"a\",\"b\",\"c\")", "expected": "b", "source": "hand", "note": "The second value." }, + { + "formula": "=CHOOSE(4,\"a\",\"b\",\"c\")", + "expected": { "error": "VALUE" }, + "source": "hand", + "note": "Past the list." + }, + { + "formula": "=SUM(CHOOSE(2,A1:A2,B1:B2))", + "cells": { "B1": "3", "B2": "4", "A1": "50" }, + "expected": 7, + "source": "hand", + "note": "A chosen range adds up." + }, + { "formula": "=ROW()", "expected": 1, "source": "hand", "note": "F1 is in row 1." }, + { "formula": "=COLUMN()", "expected": 6, "source": "hand", "note": "F1 is in column 6." }, + { "formula": "=ROW(C5)", "expected": 5, "source": "hand", "note": "Row of C5." }, + { "formula": "=COLUMN(C5)", "expected": 3, "source": "hand", "note": "Column of C5." }, + { + "formula": "=ROW(B2:B4)", + "expected": { "array": [[2], [3], [4]] }, + "source": "hand", + "note": "One number per row." + }, + { + "at": "D4", + "formula": "=COLUMN(A1:C1)", + "expected": { "array": [[1, 2, 3]] }, + "source": "hand", + "note": "One number per column." + }, + { "formula": "=ROWS({1,2;3,4;5,6})", "expected": 3, "source": "hand", "note": "Three rows." }, + { "formula": "=COLUMNS(A1:C2)", "expected": 3, "source": "hand", "note": "A to C." }, + { "formula": "=ROWS(A2:B4)", "expected": 3, "source": "hand", "note": "Rows 2 to 4." }, + { "formula": "=COLUMNS(7)", "expected": 1, "source": "hand", "note": "One value." }, + { "formula": "=ADDRESS(2,3)", "expected": "$C$2", "source": "hand", "note": "Absolute by default." }, + { "formula": "=ADDRESS(2,3,2)", "expected": "C$2", "source": "hand", "note": "Absolute row." }, + { "formula": "=ADDRESS(2,3,3)", "expected": "$C2", "source": "hand", "note": "Absolute column." }, + { "formula": "=ADDRESS(2,3,4)", "expected": "C2", "source": "hand", "note": "Relative." }, + { "formula": "=ADDRESS(2,3,1,FALSE)", "expected": "R2C3", "source": "hand", "note": "R1C1, absolute." }, + { "formula": "=ADDRESS(2,3,4,FALSE)", "expected": "R[2]C[3]", "source": "hand", "note": "R1C1, relative." }, + { + "formula": "=ADDRESS(2,3,1,TRUE,\"Sales\")", + "expected": "Sales!$C$2", + "source": "hand", + "note": "A plain table name." + }, + { + "formula": "=ADDRESS(1,1,1,TRUE,\"My Sales\")", + "expected": "'My Sales'!$A$1", + "source": "hand", + "note": "A name with a space is quoted." + }, + { "formula": "=ADDRESS(1,28)", "expected": "$AB$1", "source": "hand", "note": "Column 28 is AB." }, + { "formula": "=ADDRESS(1,16384)", "expected": "$XFD$1", "source": "hand", "note": "The last column." }, + { "formula": "=ADDRESS(0,1)", "expected": { "error": "VALUE" }, "source": "hand", "note": "Row 0." }, + { "formula": "=ADDRESS(1,1,5)", "expected": { "error": "VALUE" }, "source": "hand", "note": "No abs type 5." }, + { "formula": "=AREAS((A1:B2,C3))", "expected": 2, "source": "hand", "note": "Two areas." }, + { "formula": "=AREAS(A1:B2)", "expected": 1, "source": "hand", "note": "One area." }, + { + "formula": "=FORMULATEXT(A1)", + "cells": { "A1": "=1+2" }, + "expected": "=1+2", + "source": "hand", + "note": "The formula as text." + }, + { + "formula": "=FORMULATEXT(A1)", + "cells": { "A1": "3" }, + "expected": { "error": "NA" }, + "source": "hand", + "note": "No formula." + }, + { + "formula": "=HYPERLINK(\"https://example.com\",\"Site\")", + "expected": "Site", + "source": "hand", + "note": "The friendly name shows." + }, + { + "formula": "=HYPERLINK(\"https://example.com\")", + "expected": "https://example.com", + "source": "hand", + "note": "Without a name, the address shows." + } + ] +} diff --git a/app/src/features/tables/fixtures/fn/nist/Norris.json b/app/src/features/tables/fixtures/fn/nist/Norris.json new file mode 100644 index 000000000..7b7949de2 --- /dev/null +++ b/app/src/features/tables/fixtures/fn/nist/Norris.json @@ -0,0 +1,24 @@ +{ + "name": "Norris", + "source": "nist: NIST Statistical Reference Datasets, linear regression, Norris (lower difficulty). A US Government work in the public domain.", + "note": "36 observations of y against x. The certified values come with the dataset and are given to 15 significant digits.", + "y": [ + 0.1, 338.8, 118.1, 888.0, 9.2, 228.1, 668.5, 998.5, 449.1, 778.9, 559.2, 0.3, 0.1, 778.1, 668.8, 339.3, 448.9, 10.8, + 557.7, 228.3, 998.0, 888.8, 119.6, 0.3, 0.6, 557.6, 339.3, 888.0, 998.5, 778.9, 10.2, 117.6, 228.9, 668.4, 449.2, + 0.2 + ], + "x": [ + 0.2, 337.4, 118.2, 884.6, 10.1, 226.5, 666.3, 996.3, 448.6, 777.0, 558.2, 0.4, 0.6, 775.5, 666.9, 338.0, 447.5, + 11.6, 556.0, 228.1, 995.8, 887.6, 120.2, 0.3, 0.3, 556.8, 339.1, 887.2, 999.0, 779.0, 11.1, 118.3, 229.2, 669.1, + 448.9, 0.5 + ], + "certified": { + "slope": 1.00211681802045, + "intercept": -0.262323073774029, + "slopeSe": 0.000429796848199937, + "interceptSe": 0.232818234301152, + "residualSd": 0.884796396144373, + "rSquared": 0.999993745883712, + "df": 34 + } +} diff --git a/app/src/features/tables/fixtures/fn/stats.json b/app/src/features/tables/fixtures/fn/stats.json new file mode 100644 index 000000000..1392ac4f6 --- /dev/null +++ b/app/src/features/tables/fixtures/fn/stats.json @@ -0,0 +1,5846 @@ +{ + "cases": [ + { + "formula": "=AVEDEV(4,5,6,7,5,4,3)", + "expected": 1.0204081632653061, + "tolerance": 1e-12, + "source": "excel-doc: AVEDEV function", + "note": "Mean 34/7; the absolute deviations add to 50/7, over 7." + }, + { + "formula": "=DEVSQ(4,5,8,7,11,4,3)", + "expected": 48, + "source": "excel-doc: DEVSQ function", + "note": "Mean 6; squared deviations 4, 1, 4, 1, 25, 4, 9." + }, + { + "formula": "=GEOMEAN(4,5,8,7,11,4,3)", + "expected": 5.476986969656962, + "tolerance": 1e-12, + "source": "hand", + "note": "The seventh root of the product 147840." + }, + { + "formula": "=HARMEAN(4,5,8,7,11,4,3)", + "expected": 5.028375962061728, + "tolerance": 1e-12, + "source": "hand", + "note": "Seven over the sum of the reciprocals." + }, + { + "formula": "=GEOMEAN(2,-8)", + "expected": "#NUM!", + "source": "hand", + "note": "A value at or below zero has no geometric mean." + }, + { + "formula": "=HARMEAN(1,0)", + "expected": "#NUM!", + "source": "hand", + "note": "Zero has no reciprocal." + }, + { + "formula": "=TRIMMEAN({4,5,6,7,2,3,4,5,1,2,3},0.2)", + "expected": 3.7777777777777777, + "tolerance": 1e-12, + "source": "excel-doc: TRIMMEAN function", + "note": "11 values: 20% gives 2.2, cut down to one from each end; 34 over 9." + }, + { + "formula": "=TRIMMEAN({1,2,3},1)", + "expected": "#NUM!", + "source": "hand", + "note": "The percent must be below 1." + }, + { + "formula": "=AVERAGE(2,4,9)", + "expected": 5, + "source": "hand", + "note": "15 over 3." + }, + { + "formula": "=AVERAGEA(2,TRUE)", + "expected": 1.5, + "source": "hand", + "note": "TRUE counts as 1." + }, + { + "formula": "=MEDIAN(5,1,3,2)", + "expected": 2.5, + "source": "hand", + "note": "Halfway between 2 and 3." + }, + { + "formula": "=VAR.S(5)", + "expected": "#DIV/0!", + "source": "hand", + "note": "A sample variance needs two values." + }, + { + "formula": "=STDEV.P(5)", + "expected": 0, + "source": "hand", + "note": "One value does not vary." + }, + { + "formula": "=STDEVA(1,TRUE,4)", + "expected": 1.7320508075688772, + "tolerance": 1e-12, + "source": "hand", + "note": "Values 1, 1, 4: mean 2, squared deviations 1, 1, 4, over 2." + }, + { + "formula": "=VARPA(0,2)", + "expected": 1, + "source": "hand", + "note": "Mean 1, squared deviations 1 and 1, over 2." + }, + { + "formula": "=KURT({3,4,5,2,3,4,5,6,4,7})", + "expected": -0.15179963720841538, + "tolerance": 1e-12, + "source": "excel-doc: KURT function", + "note": "The documented formula with the sample standard deviation; the reference gives -0.151799637." + }, + { + "formula": "=SKEW({3,4,5,2,3,4,5,6,4,7})", + "expected": 0.3595430714067975, + "tolerance": 1e-12, + "source": "excel-doc: SKEW function", + "note": "The documented formula; the reference gives 0.359543." + }, + { + "formula": "=SKEW.P({3,4,5,2,3,4,5,6,4,7})", + "expected": 0.3031933393541439, + "tolerance": 1e-12, + "source": "excel-doc: SKEW.P function", + "note": "The population form; the reference gives 0.303193." + }, + { + "formula": "=KURT(1,2,3)", + "expected": "#DIV/0!", + "source": "hand", + "note": "Kurtosis needs four values." + }, + { + "formula": "=LARGE({3,5,3,5,4},3)", + "expected": 4, + "source": "hand", + "note": "Sorted down: 5, 5, 4." + }, + { + "formula": "=SMALL({3,5,3,5,4},2)", + "expected": 3, + "source": "hand", + "note": "Sorted up: 3, 3." + }, + { + "formula": "=SMALL({1,2},3)", + "expected": "#NUM!", + "source": "hand", + "note": "There is no third value." + }, + { + "formula": "=PERCENTILE.EXC({1,2,3,4},0.4)", + "expected": 2, + "source": "hand", + "note": "Rank 0.4 x 5 = 2." + }, + { + "formula": "=PERCENTILE.EXC({1,2,3,4},0.1)", + "expected": "#NUM!", + "source": "hand", + "note": "Rank 0.5 is below the first value." + }, + { + "formula": "=PERCENTILE.INC({1,2,3,4},1.5)", + "expected": "#NUM!", + "source": "hand", + "note": "k above 1." + }, + { + "formula": "=QUARTILE.EXC({1,2,3,4,5,6,7},1)", + "expected": 2, + "source": "hand", + "note": "Rank 0.25 x 8 = 2." + }, + { + "formula": "=QUARTILE.INC({1,2,3,4},5)", + "expected": "#NUM!", + "source": "hand", + "note": "The quartile goes from 0 to 4." + }, + { + "formula": "=QUARTILE.INC({1,2,4,7,8,9,10,12},4)", + "expected": 12, + "source": "hand", + "note": "Quartile 4 is the largest value." + }, + { + "formula": "=PERCENTRANK.INC({13,12,11,8,4,3,2,1,1,1},2)", + "expected": 0.333, + "source": "excel-doc: PERCENTRANK.INC function", + "note": "Three values below 2, over 9, cut to three digits." + }, + { + "formula": "=PERCENTRANK.INC({13,12,11,8,4,3,2,1,1,1},4)", + "expected": 0.555, + "source": "excel-doc: PERCENTRANK.INC function", + "note": "5/9 cut, not rounded, to three digits." + }, + { + "formula": "=PERCENTRANK.INC({13,12,11,8,4,3,2,1,1,1},5)", + "expected": 0.583, + "source": "excel-doc: PERCENTRANK.INC function", + "note": "A quarter of the way from 4 (5/9) to 8 (6/9)." + }, + { + "formula": "=PERCENTRANK.INC({13,12,11,8,4,3,2,1,1,1},14)", + "expected": "#N/A", + "source": "hand", + "note": "Above the largest value." + }, + { + "formula": "=PERCENTRANK.EXC({1,2,3,6,6,6,7,8,9},7)", + "expected": 0.7, + "source": "excel-doc: PERCENTRANK.EXC function", + "note": "Six values below 7: (6 + 1) / 10." + }, + { + "formula": "=PERCENTRANK.EXC({1,2,3,6,6,6,7,8,9},5.43)", + "expected": 0.381, + "source": "excel-doc: PERCENTRANK.EXC function", + "note": "From 0.3 at 3 to 0.4 at 6, 2.43/3 of the way, cut to 0.381." + }, + { + "formula": "=PERCENTRANK.EXC({1,2,3,6,6,6,7,8,9},5.43,1)", + "expected": 0.3, + "source": "excel-doc: PERCENTRANK.EXC function", + "note": "The same cut to one digit." + }, + { + "formula": "=RANK.EQ(7,{7,3.5,3.5,1,2},1)", + "expected": 5, + "source": "excel-doc: RANK.EQ function", + "note": "Ascending: four values are below 7." + }, + { + "formula": "=RANK.EQ(2,{7,3.5,3.5,1,2})", + "expected": 4, + "source": "excel-doc: RANK.EQ function", + "note": "Descending: three values are above 2." + }, + { + "formula": "=RANK.AVG(3.5,{7,3.5,3.5,1,2})", + "expected": 2.5, + "source": "hand", + "note": "One value above, and the two 3.5s share places 2 and 3." + }, + { + "formula": "=RANK.EQ(9,{7,3.5,3.5,1,2})", + "expected": "#N/A", + "source": "hand", + "note": "9 is not in the list." + }, + { + "formula": "=MODE.SNGL(1,2,3)", + "expected": "#N/A", + "source": "hand", + "note": "No value repeats." + }, + { + "formula": "=MODE.MULT(1,2,3,4,3,2,1,2,3)", + "expected": [2, 3], + "source": "excel-doc: MODE.MULT function", + "note": "2 and 3 appear three times each, in the order first seen." + }, + { + "formula": "=FREQUENCY({79,85,78,85,50,81,95,88,97},{70,79,89})", + "expected": [1, 2, 4, 2], + "source": "excel-doc: FREQUENCY function", + "note": "At most 70; 71 to 79; 80 to 89; above 89." + }, + { + "formula": "=PROB({0,1,2,3},{0.2,0.3,0.1,0.4},2)", + "expected": 0.1, + "source": "hand", + "note": "The chance of exactly 2." + }, + { + "formula": "=PROB({0,1,2,3},{0.2,0.3,0.1,0.4},1,3)", + "expected": 0.8, + "tolerance": 1e-12, + "source": "hand", + "note": "0.3 + 0.1 + 0.4." + }, + { + "formula": "=PROB({0,1},{0.2,0.3},1)", + "expected": "#NUM!", + "source": "hand", + "note": "The chances must add to 1." + }, + { + "formula": "=SLOPE({2,3,9,1,8,7,5},{6,5,11,7,5,4,4})", + "expected": 0.3055555555555556, + "tolerance": 1e-12, + "source": "hand", + "note": "Means 5 and 6; Sxy = 11 and Sxx = 36." + }, + { + "formula": "=INTERCEPT({2,3,9,1,8,7,5},{6,5,11,7,5,4,4})", + "expected": 3.166666666666667, + "tolerance": 1e-12, + "source": "hand", + "note": "5 - 6 x 11/36." + }, + { + "formula": "=RSQ({2,3,9,1,8,7,5},{6,5,11,7,5,4,4})", + "expected": 0.05795019157088123, + "tolerance": 1e-12, + "source": "hand", + "note": "Sxy^2 / (Sxx Syy) with Syy = 58." + }, + { + "formula": "=STEYX({2,3,9,1,8,7,5},{6,5,11,7,5,4,4})", + "expected": 3.305718950210041, + "tolerance": 1e-12, + "source": "hand", + "note": "The root of (Syy - Sxy^2/Sxx) / (n - 2)." + }, + { + "formula": "=CORREL({3,2,4,5,6},{9,7,12,15,17})", + "expected": 0.9970544855015815, + "tolerance": 1e-12, + "source": "excel-doc: CORREL function", + "note": "Sxy 26, Sxx 10, Syy 68; the reference gives 0.997054486." + }, + { + "formula": "=PEARSON({3,2,4,5,6},{9,7,12,15,17})", + "expected": 0.9970544855015815, + "tolerance": 1e-12, + "source": "hand", + "note": "The same as CORREL." + }, + { + "formula": "=COVARIANCE.P({3,2,4,5,6},{9,7,12,15,17})", + "expected": 5.2, + "tolerance": 1e-12, + "source": "excel-doc: COVARIANCE.P function", + "note": "Sxy 26 over 5." + }, + { + "formula": "=COVARIANCE.S({2,4,8},{5,11,12})", + "expected": 9.666666666666666, + "tolerance": 1e-12, + "source": "excel-doc: COVARIANCE.S function", + "note": "Sxy is 58/3, over 2." + }, + { + "formula": "=CORREL({1,2},{1,2,3})", + "expected": "#N/A", + "source": "hand", + "note": "The arrays differ in size." + }, + { + "formula": "=CORREL({1,1},{1,2})", + "expected": "#DIV/0!", + "source": "hand", + "note": "The first array does not vary." + }, + { + "formula": "=FORECAST.LINEAR(30,{6,7,9,15,21},{20,28,31,38,40})", + "expected": 10.607253086419753, + "tolerance": 1e-12, + "source": "excel-doc: FORECAST.LINEAR function", + "note": "The least-squares line at x = 30; the reference gives 10.607253." + }, + { + "formula": "=FORECAST(30,{6,7,9,15,21},{20,28,31,38,40})", + "expected": 10.607253086419753, + "tolerance": 1e-12, + "source": "hand", + "note": "FORECAST is FORECAST.LINEAR." + }, + { + "formula": "=STANDARDIZE(42,40,1.5)", + "expected": 1.3333333333333333, + "tolerance": 1e-12, + "source": "hand", + "note": "(42 - 40) / 1.5." + }, + { + "formula": "=STANDARDIZE(1,0,0)", + "expected": "#NUM!", + "source": "hand", + "note": "The standard deviation must be above 0." + }, + { + "formula": "=FISHER(0.75)", + "expected": 0.9729550745276566, + "tolerance": 1e-12, + "source": "hand", + "note": "Half of ln(1.75 / 0.25); the reference gives 0.972955." + }, + { + "formula": "=FISHERINV(0.5*LN(7))", + "expected": 0.75, + "tolerance": 1e-12, + "source": "hand", + "note": "tanh undoes FISHER." + }, + { + "formula": "=FISHER(1)", + "expected": "#NUM!", + "source": "hand", + "note": "x must be strictly between -1 and 1." + }, + { + "formula": "=PERMUT(100,3)", + "expected": 970200, + "source": "excel-doc: PERMUT function", + "note": "100 x 99 x 98." + }, + { + "formula": "=PERMUT(2,3)", + "expected": "#NUM!", + "source": "hand", + "note": "Cannot choose 3 of 2." + }, + { + "formula": "=PERMUTATIONA(3,2)", + "expected": 9, + "source": "hand", + "note": "3 squared." + }, + { + "formula": "=GAMMA(2.5)", + "expected": 1.329340388179137, + "tolerance": 1e-12, + "source": "dlmf: 5.4.6", + "note": "Gamma(5/2) = 3 sqrt(pi) / 4." + }, + { + "formula": "=GAMMA(0.5)", + "expected": 1.7724538509055159, + "tolerance": 1e-12, + "source": "dlmf: 5.4.6", + "note": "Gamma(1/2) = sqrt(pi)." + }, + { + "formula": "=GAMMA(0)", + "expected": "#NUM!", + "source": "hand", + "note": "Gamma has poles at 0 and the negative whole numbers." + }, + { + "formula": "=GAMMA(-1)", + "expected": "#NUM!", + "source": "hand", + "note": "A pole." + }, + { + "formula": "=GAMMALN.PRECISE(4)", + "expected": 1.791759469228055, + "tolerance": 1e-12, + "source": "hand", + "note": "ln 3!." + }, + { + "formula": "=GAMMALN(0)", + "expected": "#NUM!", + "source": "hand", + "note": "x must be above 0." + }, + { + "formula": "=NORM.DIST(42,40,1.5,TRUE)", + "expected": 0.9087888, + "tolerance": 1e-7, + "source": "excel-doc: NORM.DIST function", + "note": "The documented value to seven digits." + }, + { + "formula": "=NORM.DIST(42,40,1.5,FALSE)", + "expected": 0.10934004978399575, + "tolerance": 1e-12, + "source": "hand", + "note": "The density at z = 4/3, over the standard deviation." + }, + { + "formula": "=NORM.INV(0.908789,40,1.5)", + "expected": 42.000002, + "tolerance": 1e-7, + "source": "excel-doc: NORM.INV function", + "note": "The documented value." + }, + { + "formula": "=NORM.S.INV(0.5)", + "expected": 0, + "source": "hand", + "note": "The median of the standard normal.", + "kind": "boundary" + }, + { + "formula": "=NORM.S.DIST(-1,TRUE)", + "expected": 0.15865525393145696, + "tolerance": 1e-12, + "source": "table: standard normal", + "note": "Symmetry: 1 - Phi(1)." + }, + { + "formula": "=NORM.S.DIST(2,TRUE)", + "expected": 0.977249868051821, + "tolerance": 1e-12, + "source": "table: standard normal", + "note": "Phi(2)." + }, + { + "formula": "=NORM.S.DIST(-3,TRUE)", + "expected": 0.00134989803163009, + "tolerance": 1e-12, + "source": "table: standard normal", + "note": "The lower tail at -3." + }, + { + "formula": "=NORM.DIST(1,0,0,TRUE)", + "expected": "#NUM!", + "source": "hand", + "note": "The standard deviation must be above 0." + }, + { + "formula": "=NORM.S.INV(1)", + "expected": "#NUM!", + "source": "hand", + "note": "The probability must be below 1." + }, + { + "formula": "=PHI(0.75)", + "expected": 0.30113743215480443, + "tolerance": 1e-12, + "source": "hand", + "note": "The standard normal density e^(-x^2/2) / sqrt(2 pi)." + }, + { + "formula": "=GAUSS(2)", + "expected": 0.477249868051821, + "tolerance": 1e-12, + "source": "table: standard normal", + "note": "Phi(2) - 0.5." + }, + { + "formula": "=LOGNORM.DIST(EXP(1),0,1,TRUE)", + "expected": 0.841344746068543, + "tolerance": 1e-12, + "source": "hand", + "note": "ln e = 1, so Phi(1)." + }, + { + "formula": "=LOGNORM.DIST(1,0,1,FALSE)", + "expected": 0.3989422804014327, + "tolerance": 1e-12, + "source": "hand", + "note": "The density at ln 1 = 0, over x = 1." + }, + { + "formula": "=LOGNORM.INV(0.841344746068543,0,1)", + "expected": 2.718281828459045, + "tolerance": 1e-12, + "source": "hand", + "note": "e to the inverse of Phi at that point, which is 1." + }, + { + "formula": "=LOGNORM.DIST(0,0,1,TRUE)", + "expected": "#NUM!", + "source": "hand", + "note": "x must be above 0." + }, + { + "formula": "=CONFIDENCE.NORM(0.05,2.5,50)", + "expected": 0.6929519121748374, + "tolerance": 1e-12, + "source": "excel-doc: CONFIDENCE.NORM function", + "note": "z at 0.975 times 2.5 over the root of 50; the reference gives 0.692952." + }, + { + "formula": "=CONFIDENCE.T(0.05,1,50)", + "expected": 0.284196855, + "tolerance": 1e-8, + "source": "excel-doc: CONFIDENCE.T function", + "note": "The documented value." + }, + { + "formula": "=CONFIDENCE.T(0.05,1,1)", + "expected": "#DIV/0!", + "source": "hand", + "note": "One value has no degrees of freedom." + }, + { + "formula": "=Z.TEST({3,6,7,8,6,5,4,2,1,9},4)", + "expected": 0.090574, + "tolerance": 1e-5, + "source": "excel-doc: Z.TEST function", + "note": "The documented one-tailed value to six digits." + }, + { + "formula": "=Z.TEST({3,6,7,8,6,5,4,2,1,9},6)", + "expected": 0.863043, + "tolerance": 1e-6, + "source": "excel-doc: Z.TEST function", + "note": "The documented one-tailed value to six digits." + }, + { + "formula": "=EXPON.DIST(1,2,TRUE)", + "expected": 0.8646647167633873, + "tolerance": 1e-12, + "source": "hand", + "note": "1 - e^(-2)." + }, + { + "formula": "=EXPON.DIST(1,2,FALSE)", + "expected": 0.2706705664732254, + "tolerance": 1e-12, + "source": "hand", + "note": "2 e^(-2)." + }, + { + "formula": "=EXPON.DIST(-1,2,TRUE)", + "expected": "#NUM!", + "source": "hand", + "note": "x must be at least 0." + }, + { + "formula": "=WEIBULL.DIST(1,2,1,TRUE)", + "expected": 0.6321205588285577, + "tolerance": 1e-12, + "source": "hand", + "note": "1 - e^(-(1/1)^2)." + }, + { + "formula": "=WEIBULL.DIST(1,2,1,FALSE)", + "expected": 0.7357588823428847, + "tolerance": 1e-12, + "source": "hand", + "note": "(2/1)(1)^1 e^(-1)." + }, + { + "formula": "=GAMMA.DIST(2,1,2,TRUE)", + "expected": 0.6321205588285577, + "tolerance": 1e-12, + "source": "hand", + "note": "Shape 1 is the exponential law with mean 2." + }, + { + "formula": "=GAMMA.DIST(1,2,1,TRUE)", + "expected": 0.26424111765711533, + "tolerance": 1e-12, + "source": "hand", + "note": "Shape 2: 1 - e^(-x)(1 + x) at x = 1." + }, + { + "formula": "=GAMMA.DIST(1,2,1,FALSE)", + "expected": 0.36787944117144233, + "tolerance": 1e-12, + "source": "hand", + "note": "x e^(-x) at x = 1." + }, + { + "formula": "=GAMMA.INV(1-EXP(-1),1,2)", + "expected": 2, + "tolerance": 1e-12, + "source": "hand", + "note": "Undoes the exponential case above." + }, + { + "formula": "=GAMMA.DIST(1,0,1,TRUE)", + "expected": "#NUM!", + "source": "hand", + "note": "The shape must be above 0." + }, + { + "formula": "=CHISQ.DIST.RT(2,2)", + "expected": 0.36787944117144233, + "tolerance": 1e-12, + "source": "hand", + "note": "Two degrees of freedom: the upper tail is e^(-x/2)." + }, + { + "formula": "=CHISQ.DIST(2,2,TRUE)", + "expected": 0.6321205588285577, + "tolerance": 1e-12, + "source": "hand", + "note": "The lower tail." + }, + { + "formula": "=CHISQ.DIST(2,2,FALSE)", + "expected": 0.18393972058572117, + "tolerance": 1e-12, + "source": "hand", + "note": "The density e^(-x/2) / 2." + }, + { + "formula": "=CHISQ.INV.RT(0.05,1)", + "expected": 3.8414588206941094, + "tolerance": 1e-12, + "source": "hand", + "note": "One degree of freedom is a squared normal: z at 0.975, squared." + }, + { + "formula": "=CHISQ.INV(1-EXP(-1),2)", + "expected": 2, + "tolerance": 1e-12, + "source": "hand", + "note": "Undoes CHISQ.DIST at 2." + }, + { + "formula": "=CHISQ.DIST(1,0,TRUE)", + "expected": "#NUM!", + "source": "hand", + "note": "Degrees of freedom start at 1." + }, + { + "formula": "=CHISQ.TEST({12,8,10},{10,10,10})", + "expected": 0.6703200460356393, + "tolerance": 1e-12, + "source": "hand", + "note": "Chi-squared 0.8 with 2 degrees of freedom: e^(-0.4)." + }, + { + "formula": "=CHISQ.TEST({1,2},{1,0})", + "expected": "#DIV/0!", + "source": "hand", + "note": "An expected count of 0." + }, + { + "formula": "=BETA.DIST(0.3,2,1,TRUE)", + "expected": 0.09, + "tolerance": 1e-12, + "source": "hand", + "note": "With alpha 2 and beta 1 the law is x^2." + }, + { + "formula": "=BETA.DIST(0.3,2,1,FALSE)", + "expected": 0.6, + "tolerance": 1e-12, + "source": "hand", + "note": "The density 2x." + }, + { + "formula": "=BETA.DIST(3,2,1,TRUE,2,4)", + "expected": 0.25, + "tolerance": 1e-12, + "source": "hand", + "note": "Scaled to 2 to 4: ((3 - 2)/2)^2." + }, + { + "formula": "=BETA.INV(0.25,2,1,2,4)", + "expected": 3, + "tolerance": 1e-12, + "source": "hand", + "note": "Undoes the case above." + }, + { + "formula": "=BETA.DIST(5,2,1,TRUE,2,4)", + "expected": "#NUM!", + "source": "hand", + "note": "x is outside the bounds." + }, + { + "formula": "=F.DIST(3,2,2,TRUE)", + "expected": 0.75, + "tolerance": 1e-12, + "source": "hand", + "note": "With 2 and 2 degrees of freedom the law is x / (1 + x)." + }, + { + "formula": "=F.DIST(3,2,2,FALSE)", + "expected": 0.0625, + "tolerance": 1e-12, + "source": "hand", + "note": "The density 1 / (1 + x)^2." + }, + { + "formula": "=F.DIST.RT(3,2,2)", + "expected": 0.25, + "tolerance": 1e-12, + "source": "hand", + "note": "1 / (1 + x)." + }, + { + "formula": "=F.INV(0.75,2,2)", + "expected": 3, + "tolerance": 1e-12, + "source": "hand", + "note": "Undoes F.DIST." + }, + { + "formula": "=F.INV.RT(0.25,2,2)", + "expected": 3, + "tolerance": 1e-12, + "source": "hand", + "note": "Undoes F.DIST.RT." + }, + { + "formula": "=F.TEST({1,2,3},{2,4,6})", + "expected": 0.4, + "tolerance": 1e-12, + "source": "hand", + "note": "Variances 1 and 4: F = 0.25, lower tail 0.2 with 2 and 2 degrees of freedom, doubled." + }, + { + "formula": "=F.TEST({1},{2,3})", + "expected": "#DIV/0!", + "source": "hand", + "note": "Each array needs two values." + }, + { + "formula": "=T.DIST(1,1,TRUE)", + "expected": 0.75, + "tolerance": 1e-12, + "source": "hand", + "note": "One degree of freedom is the Cauchy law: 1/2 + atan(1)/pi." + }, + { + "formula": "=T.DIST(1,2,TRUE)", + "expected": 0.7886751345948129, + "tolerance": 1e-12, + "source": "hand", + "note": "Two degrees of freedom: 1/2 + x / (2 sqrt(2 + x^2))." + }, + { + "formula": "=T.DIST(0,1,FALSE)", + "expected": 0.3183098861837907, + "tolerance": 1e-12, + "source": "hand", + "note": "The Cauchy density at 0." + }, + { + "formula": "=T.DIST.2T(1,2)", + "expected": 0.42264973081037416, + "tolerance": 1e-12, + "source": "hand", + "note": "Both tails of the case above." + }, + { + "formula": "=T.DIST.RT(1,2)", + "expected": 0.21132486540518708, + "tolerance": 1e-12, + "source": "hand", + "note": "The upper tail." + }, + { + "formula": "=T.INV(0.75,1)", + "expected": 1, + "tolerance": 1e-12, + "source": "hand", + "note": "Undoes the Cauchy case." + }, + { + "formula": "=T.INV(0.25,1)", + "expected": -1, + "tolerance": 1e-12, + "source": "hand", + "note": "The lower quartile of the Cauchy law." + }, + { + "formula": "=T.INV.2T(1-1/SQRT(3),2)", + "expected": 1, + "tolerance": 1e-12, + "source": "hand", + "note": "Undoes T.DIST.2T." + }, + { + "formula": "=T.DIST.2T(-1,2)", + "expected": "#NUM!", + "source": "hand", + "note": "x must be at least 0." + }, + { + "formula": "=T.INV(0,2)", + "expected": "#NUM!", + "source": "hand", + "note": "The probability must be above 0." + }, + { + "formula": "=T.TEST({2,4},{1,1},2,1)", + "expected": 0.2951672353008665, + "tolerance": 1e-12, + "source": "hand", + "note": "Paired differences 1 and 3: t = 2 with 1 degree of freedom, both tails." + }, + { + "formula": "=T.TEST({2,4},{1,1},1,1)", + "expected": 0.14758361765043326, + "tolerance": 1e-12, + "source": "hand", + "note": "One tail." + }, + { + "formula": "=T.TEST({1,3},{3,5},2,2)", + "expected": 0.29289321881345254, + "tolerance": 1e-12, + "source": "hand", + "note": "Pooled variance 2: t = -sqrt(2) with 2 degrees of freedom." + }, + { + "formula": "=T.TEST({1,3},{3,5},2,3)", + "expected": 0.29289321881345254, + "tolerance": 1e-12, + "source": "hand", + "note": "Welch: the same t, and 2 degrees of freedom." + }, + { + "formula": "=T.TEST({1,3},{3,5},3,2)", + "expected": "#NUM!", + "source": "hand", + "note": "Tails must be 1 or 2." + }, + { + "formula": "=T.TEST({1,3,5},{3,5},2,1)", + "expected": "#N/A", + "source": "hand", + "note": "A paired test needs arrays of one size." + }, + { + "formula": "=BINOM.DIST(6,10,0.5,TRUE)", + "expected": 0.828125, + "source": "hand", + "note": "1 + 10 + 45 + 120 + 210 + 252 + 210 = 848, over 2^10." + }, + { + "formula": "=BINOM.DIST(11,10,0.5,FALSE)", + "expected": "#NUM!", + "source": "hand", + "note": "More successes than trials." + }, + { + "formula": "=BINOM.DIST.RANGE(10,0.5,4,6)", + "expected": 0.65625, + "tolerance": 1e-12, + "source": "hand", + "note": "210 + 252 + 210, over 2^10." + }, + { + "formula": "=BINOM.DIST.RANGE(10,0.5,6)", + "expected": 0.205078125, + "tolerance": 1e-12, + "source": "hand", + "note": "Exactly 6." + }, + { + "formula": "=BINOM.DIST.RANGE(10,0.5,6,4)", + "expected": "#NUM!", + "source": "hand", + "note": "The second count is below the first." + }, + { + "formula": "=BINOM.INV(6,0.5,0.75)", + "expected": 4, + "source": "hand", + "note": "The running totals reach 42/64 at 3 and 57/64 at 4." + }, + { + "formula": "=BINOM.INV(6,1.5,0.75)", + "expected": "#NUM!", + "source": "hand", + "note": "The probability is above 1." + }, + { + "formula": "=NEGBINOM.DIST(10,5,0.25,FALSE)", + "expected": 0.05504866037517786, + "tolerance": 1e-12, + "source": "hand", + "note": "C(14, 4) 0.25^5 0.75^10; the reference gives 0.055049." + }, + { + "formula": "=NEGBINOM.DIST(1,1,0.5,TRUE)", + "expected": 0.75, + "tolerance": 1e-12, + "source": "hand", + "note": "No failure (1/2) or one failure (1/4) before the first success." + }, + { + "formula": "=NEGBINOM.DIST(1,0,0.5,FALSE)", + "expected": "#NUM!", + "source": "hand", + "note": "At least one success is needed." + }, + { + "formula": "=HYPGEOM.DIST(1,4,8,20,FALSE)", + "expected": 0.3632610939112487, + "tolerance": 1e-12, + "source": "excel-doc: HYPGEOM.DIST function", + "note": "C(8,1) C(12,3) / C(20,4) = 8 x 220 / 4845." + }, + { + "formula": "=HYPGEOM.DIST(1,4,8,20,TRUE)", + "expected": 0.46542827657378744, + "tolerance": 1e-12, + "source": "excel-doc: HYPGEOM.DIST function", + "note": "Adds the 495/4845 of no success." + }, + { + "formula": "=HYPGEOM.DIST(5,4,8,20,FALSE)", + "expected": "#NUM!", + "source": "hand", + "note": "More successes than draws." + }, + { + "formula": "=POISSON.DIST(2,5,TRUE)", + "expected": 0.12465201948308118, + "tolerance": 1e-12, + "source": "hand", + "note": "e^(-5) (1 + 5 + 25/2)." + }, + { + "formula": "=POISSON.DIST(-1,5,TRUE)", + "expected": "#NUM!", + "source": "hand", + "note": "x must be at least 0." + }, + { + "formula": "=NORMDIST(42,40,1.5,TRUE)", + "expected": 0.9087888, + "tolerance": 1e-7, + "source": "excel-doc: NORMDIST function", + "note": "As NORM.DIST." + }, + { + "formula": "=BINOMDIST(6,10,0.5,FALSE)", + "expected": 0.205078125, + "source": "hand", + "note": "As BINOM.DIST." + }, + { + "formula": "=CHIDIST(2,2)", + "expected": 0.36787944117144233, + "tolerance": 1e-12, + "source": "hand", + "note": "The upper tail, as CHISQ.DIST.RT." + }, + { + "formula": "=EXPONDIST(1,2,TRUE)", + "expected": 0.8646647167633873, + "tolerance": 1e-12, + "source": "hand", + "note": "As EXPON.DIST." + }, + { + "formula": "=POISSON(2,5,FALSE)", + "expected": 0.08422433748856836, + "tolerance": 1e-12, + "source": "hand", + "note": "As POISSON.DIST." + }, + { + "formula": "=TDIST(1,1,2)", + "expected": 0.5, + "tolerance": 1e-12, + "source": "hand", + "note": "Both tails of the Cauchy law beyond 1." + }, + { + "formula": "=TDIST(-1,1,2)", + "expected": "#NUM!", + "source": "hand", + "note": "TDIST needs x at least 0." + }, + { + "formula": "=TINV(0.5,1)", + "expected": 1, + "tolerance": 1e-12, + "source": "hand", + "note": "The two-tailed inverse." + }, + { + "formula": "=WEIBULL(1,2,1,TRUE)", + "expected": 0.6321205588285577, + "tolerance": 1e-12, + "source": "hand", + "note": "As WEIBULL.DIST." + }, + { + "formula": "=GAMMADIST(1,2,1,FALSE)", + "expected": 0.36787944117144233, + "tolerance": 1e-12, + "source": "hand", + "note": "As GAMMA.DIST." + }, + { + "formula": "=BETAINV(0.09,2,1)", + "expected": 0.3, + "tolerance": 1e-12, + "source": "hand", + "note": "As BETA.INV." + }, + { + "formula": "=STDEVP(2,4,4,4,5,5,7,9)", + "expected": 2, + "source": "hand", + "note": "As STDEV.P." + }, + { + "formula": "=VAR(1,2,3,4)", + "expected": 1.6666666666666667, + "tolerance": 1e-12, + "source": "hand", + "note": "As VAR.S." + }, + { + "formula": "=PERCENTILE({1,2,3,4},0.3)", + "expected": 1.9, + "tolerance": 1e-12, + "source": "hand", + "note": "As PERCENTILE.INC." + }, + { + "formula": "=MODE(1,2,2,3)", + "expected": 2, + "source": "hand", + "note": "As MODE.SNGL." + }, + { + "formula": "=COUNTIF(A1:A3,\">4\")", + "cells": { + "A1": 1, + "A2": 5, + "A3": 9, + "B1": "x", + "B2": "y", + "B3": "x" + }, + "expected": 2, + "source": "hand", + "note": "5 and 9." + }, + { + "formula": "=COUNTIF(B1:B3,\"x\")", + "cells": { + "A1": 1, + "A2": 5, + "A3": 9, + "B1": "x", + "B2": "y", + "B3": "x" + }, + "expected": 2, + "source": "hand", + "note": "Rows 1 and 3." + }, + { + "formula": "=AVERAGEIF(B1:B3,\"x\",A1:A3)", + "cells": { + "A1": 1, + "A2": 5, + "A3": 9, + "B1": "x", + "B2": "y", + "B3": "x" + }, + "expected": 5, + "source": "hand", + "note": "1 and 9." + }, + { + "formula": "=MAXIFS(A1:A3,B1:B3,\"x\")", + "cells": { + "A1": 1, + "A2": 5, + "A3": 9, + "B1": "x", + "B2": "y", + "B3": "x" + }, + "expected": 9, + "source": "hand", + "note": "1 and 9." + }, + { + "formula": "=MINIFS(A1:A3,B1:B3,\"y\")", + "cells": { + "A1": 1, + "A2": 5, + "A3": 9, + "B1": "x", + "B2": "y", + "B3": "x" + }, + "expected": 5, + "source": "hand", + "note": "Only row 2." + }, + { + "formula": "=MAXIFS(A1:A3,B1:B3,\"z\")", + "cells": { + "A1": 1, + "A2": 5, + "A3": 9, + "B1": "x", + "B2": "y", + "B3": "x" + }, + "expected": 0, + "source": "hand", + "note": "No match gives 0." + }, + { + "formula": "=COUNTUNIQUE(1,2,2,\"a\")", + "expected": 3, + "source": "sheets-doc: COUNTUNIQUE", + "note": "1, 2, and a." + }, + { + "formula": "=LINEST({1,9,5,7},{0,4,2,3})", + "expected": [2, 1], + "tolerance": 1e-12, + "source": "excel-doc: LINEST function", + "note": "The points lie on y = 2x + 1." + }, + { + "formula": "=FORECAST.ETS(200000,{1,2,3},{1,2,100001})", + "expected": "#NUM!", + "source": "hand", + "note": "A step of 1 over 100000 steps passes the cap of 10000 filled steps." + }, + { + "formula": "=F.DIST(1,1E6,1E6,TRUE)", + "expected": 0.5, + "tolerance": 1e-12, + "source": "hand", + "note": "With equal degrees of freedom F and 1/F share one law, so P(F <= 1) is one half." + }, + { + "formula": "=T.DIST(1,1E15,TRUE)", + "expected": "#NUM!", + "source": "hand", + "note": "Degrees of freedom run from 1 to 1E10, as for chi-squared and F." + }, + { + "formula": "=POISSON.DIST(200000,200000,TRUE)", + "expected": 0.5005947076587653, + "tolerance": 1e-12, + "source": "hand", + "note": "Poisson at k = mean n: 1/2 + (1 - theta(n)) n^n e^-n / n!. Ramanujan: e^-n times the sum of n^k/k! for k < n is 1/2 - theta(n) n^n e^-n / n!, theta = 1/3 + 4/(135n) - 8/(2835n^2) - 16/(8505n^3); n^n e^-n / n! = exp(-1/(12n) + 1/(360n^3)) / sqrt(2 pi n)." + }, + { + "formula": "=POISSON.DIST(1000000,1000000,TRUE)", + "expected": 0.5002659614862837, + "tolerance": 1e-12, + "source": "hand", + "note": "Poisson at k = mean 1E6: 1/2 + (1 - theta(n)) n^n e^-n / n!. Ramanujan: e^-n times the sum of n^k/k! for k < n is 1/2 - theta(n) n^n e^-n / n!, theta = 1/3 + 4/(135n) - 8/(2835n^2) - 16/(8505n^3); n^n e^-n / n! = exp(-1/(12n) + 1/(360n^3)) / sqrt(2 pi n)." + }, + { + "formula": "=POISSON(1E9,1E9,TRUE)", + "expected": 0.500008410441739, + "tolerance": 1e-11, + "source": "hand", + "note": "Poisson at k = mean 1E9: 1/2 + (1 - theta(n)) n^n e^-n / n!. Ramanujan: e^-n times the sum of n^k/k! for k < n is 1/2 - theta(n) n^n e^-n / n!, theta = 1/3 + 4/(135n) - 8/(2835n^2) - 16/(8505n^3); n^n e^-n / n! = exp(-1/(12n) + 1/(360n^3)) / sqrt(2 pi n)." + }, + { + "formula": "=POISSON.DIST(999998,1000000,TRUE)", + "expected": 0.4994680769919712, + "tolerance": 1e-12, + "source": "hand", + "note": "k = n - 2: 1/2 - (theta(n) + 1) n^n e^-n / n!, since the terms at n - 1 and n are equal; this takes the continued fraction. Ramanujan: e^-n times the sum of n^k/k! for k < n is 1/2 - theta(n) n^n e^-n / n!, theta = 1/3 + 4/(135n) - 8/(2835n^2) - 16/(8505n^3); n^n e^-n / n! = exp(-1/(12n) + 1/(360n^3)) / sqrt(2 pi n)." + }, + { + "formula": "=POISSON.DIST(1000000,1000000,FALSE)", + "expected": 0.000398942247156244, + "tolerance": 1e-12, + "source": "hand", + "note": "The central term n^n e^-n / n! at n = 1E6, by the Stirling series." + }, + { + "formula": "=CHISQ.DIST(400000,400000,TRUE)", + "expected": 0.5002973540276185, + "tolerance": 1e-12, + "source": "hand", + "note": "P(a, a) with a = 2E5 is 1/2 + theta(a) a^a e^-a / a!. Ramanujan: e^-n times the sum of n^k/k! for k < n is 1/2 - theta(n) n^n e^-n / n!, theta = 1/3 + 4/(135n) - 8/(2835n^2) - 16/(8505n^3); n^n e^-n / n! = exp(-1/(12n) + 1/(360n^3)) / sqrt(2 pi n)." + }, + { + "formula": "=CHISQ.DIST.RT(1E7,1E7)", + "expected": 0.4999405291960622, + "tolerance": 1e-12, + "source": "hand", + "note": "Q(a, a) with a = 5E6 is 1/2 - theta(a) a^a e^-a / a!. Ramanujan: e^-n times the sum of n^k/k! for k < n is 1/2 - theta(n) n^n e^-n / n!, theta = 1/3 + 4/(135n) - 8/(2835n^2) - 16/(8505n^3); n^n e^-n / n! = exp(-1/(12n) + 1/(360n^3)) / sqrt(2 pi n)." + }, + { + "formula": "=CHIDIST(1E7,1E7)", + "expected": 0.4999405291960622, + "tolerance": 1e-12, + "source": "hand", + "note": "The compatibility name of CHISQ.DIST.RT; Q(a, a) with a = 5E6. Ramanujan: e^-n times the sum of n^k/k! for k < n is 1/2 - theta(n) n^n e^-n / n!, theta = 1/3 + 4/(135n) - 8/(2835n^2) - 16/(8505n^3); n^n e^-n / n! = exp(-1/(12n) + 1/(360n^3)) / sqrt(2 pi n)." + }, + { + "formula": "=CHISQ.DIST(1E9,1E9,TRUE)", + "expected": 0.5000059470803871, + "tolerance": 1e-11, + "source": "hand", + "note": "P(a, a) with a = 5E8. Ramanujan: e^-n times the sum of n^k/k! for k < n is 1/2 - theta(n) n^n e^-n / n!, theta = 1/3 + 4/(135n) - 8/(2835n^2) - 16/(8505n^3); n^n e^-n / n! = exp(-1/(12n) + 1/(360n^3)) / sqrt(2 pi n)." + }, + { + "formula": "=GAMMA.DIST(1E6,1E6,1,TRUE)", + "expected": 0.5001329807608726, + "tolerance": 1e-12, + "source": "hand", + "note": "P(a, a) with a = 1E6. Ramanujan: e^-n times the sum of n^k/k! for k < n is 1/2 - theta(n) n^n e^-n / n!, theta = 1/3 + 4/(135n) - 8/(2835n^2) - 16/(8505n^3); n^n e^-n / n! = exp(-1/(12n) + 1/(360n^3)) / sqrt(2 pi n)." + }, + { + "formula": "=GAMMADIST(1E9,1E9,1,TRUE)", + "expected": 0.5000042052208701, + "tolerance": 1e-11, + "source": "hand", + "note": "P(a, a) with a = 1E9. Ramanujan: e^-n times the sum of n^k/k! for k < n is 1/2 - theta(n) n^n e^-n / n!, theta = 1/3 + 4/(135n) - 8/(2835n^2) - 16/(8505n^3); n^n e^-n / n! = exp(-1/(12n) + 1/(360n^3)) / sqrt(2 pi n)." + }, + { + "formula": "=GAMMA.DIST(1E6,1E6,1,FALSE)", + "expected": 0.000398942247156244, + "tolerance": 1e-12, + "source": "hand", + "note": "The density at x = a = n is n^(n-1) e^-n / (n-1)! = n^n e^-n / n!, by the Stirling series." + }, + { + "formula": "=CHISQ.INV(0.5,300000)", + "expected": 299999.3333335967, + "tolerance": 1e-12, + "source": "hand", + "note": "The median of the gamma law with shape a: a - 1/3 + 8/(405a) + 184/(25515a^2) (K. P. Choi, 1994); chi-squared is twice it at a = 150000." + }, + { + "formula": "=CHIINV(0.5,300000)", + "expected": 299999.3333335967, + "tolerance": 1e-12, + "source": "hand", + "note": "The median, from the upper tail: twice a - 1/3 + 8/(405a) + 184/(25515a^2) at a = 150000 (Choi, 1994)." + }, + { + "formula": "=GAMMA.INV(0.5,1E6,1)", + "expected": 999999.6666666864, + "tolerance": 1e-12, + "source": "hand", + "note": "The median of the gamma law: a - 1/3 + 8/(405a) + 184/(25515a^2) at a = 1E6 (Choi, 1994)." + }, + { + "formula": "=HYPGEOM.DIST(1,100,1000,100000,FALSE)", + "expected": 0.3699146103726178, + "tolerance": 1e-12, + "source": "hand", + "note": "C(1000,1) C(99000,99) / C(100000,100), worked in whole numbers." + }, + { + "formula": "=HYPGEOMDIST(30,250,300,2500)", + "expected": 0.08161070972014348, + "tolerance": 1e-12, + "source": "hand", + "note": "C(300,30) C(2200,220) / C(2500,250), worked in whole numbers." + }, + { + "formula": "=HYPGEOM.DIST(20,200,300,3000,TRUE)", + "expected": 0.559533101436925, + "tolerance": 1e-12, + "source": "hand", + "note": "The sum for i = 0 to 20 of C(300,i) C(2700,200-i) / C(3000,200), worked in whole numbers." + }, + { + "formula": "=HYPGEOM.DIST(10,1000,50,10000,TRUE)", + "expected": 0.9908169784002608, + "tolerance": 1e-12, + "source": "hand", + "note": "The sum for i = 0 to 10 of C(50,i) C(9950,1000-i) / C(10000,1000), worked in whole numbers." + }, + { + "formula": "=HYPGEOM.DIST(100,1000,1E8,1E9,FALSE)", + "expected": 0.04201681186949659, + "tolerance": 1e-11, + "source": "hand", + "note": "C(1E8,100) C(9E8,900) / C(1E9,1000), worked in whole numbers." + }, + { + "formula": "=HYPGEOM.DIST(100,1000,1E8,1E9,TRUE)", + "expected": 0.526599083396009, + "tolerance": 1e-11, + "source": "hand", + "note": "The sum for i = 0 to 100 of C(1E8,i) C(9E8,1000-i) / C(1E9,1000), worked in whole numbers." + }, + { + "formula": "=NEGBINOM.DIST(520,520,0.5,FALSE)", + "expected": 0.01236770160440519, + "tolerance": 1e-12, + "source": "hand", + "note": "C(1039,519) / 2^1040, worked in whole numbers." + }, + { + "formula": "=NEGBINOMDIST(20000,200,0.01)", + "expected": 0.0002777766702643502, + "tolerance": 1e-12, + "source": "hand", + "note": "C(20199,199) 0.01^200 0.99^20000, worked in exact fractions." + }, + { + "formula": "=NEGBINOM.DIST(200000,200000,0.5,TRUE)", + "expected": 0.5006307827362657, + "tolerance": 1e-12, + "source": "hand", + "note": "At most n failures before the n-th success is at least n successes in 2n fair trials: 1/2 + C(2n,n)/2^(2n+1), with C(2n,n)/4^n = (1 - 1/(8n) + 1/(128n^2) + 5/(1024n^3)) / sqrt(pi n)." + }, + { + "formula": "=BETA.DIST(0.5,200000,200000,TRUE)", + "expected": 0.5, + "tolerance": 1e-12, + "source": "hand", + "note": "Equal parameters: the law is symmetric about one half." + }, + { + "formula": "=BETADIST(0.5,200000,200000)", + "expected": 0.5, + "tolerance": 1e-12, + "source": "hand", + "note": "Equal parameters: the law is symmetric about one half." + }, + { + "formula": "=BETA.INV(0.5,200000,200000)", + "expected": 0.5, + "tolerance": 1e-12, + "source": "hand", + "note": "Equal parameters: the median is one half." + }, + { + "formula": "=BETAINV(0.5,200000,200000)", + "expected": 0.5, + "tolerance": 1e-12, + "source": "hand", + "note": "Equal parameters: the median is one half." + }, + { + "formula": "=BETA.DIST(1E-8,1,1E8,TRUE)", + "expected": 0.6321205606679549, + "tolerance": 1e-14, + "source": "hand", + "note": "With a = 1 the law is 1 - (1 - x)^b; at x = 1E-8 and b = 1E8 that is near 1 - 1/e." + }, + { + "formula": "=AVEDEV()", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=AVEDEV(1,2,1/0)", + "expected": "#DIV/0!", + "source": "hand", + "note": "An error among the arguments passes through.", + "kind": "error" + }, + { + "formula": "=AVEDEV(5)", + "expected": 0, + "source": "hand", + "note": "One value has no deviation.", + "kind": "boundary" + }, + { + "formula": "=AVEDEV(1,2,3,4)", + "expected": 1, + "source": "hand", + "note": "Mean 2.5; deviations 1.5, 0.5, 0.5, 1.5." + }, + { + "formula": "=AVERAGE()", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=AVERAGE({\"a\"})", + "expected": "#DIV/0!", + "source": "hand", + "note": "No numbers: nothing to divide by.", + "kind": "error" + }, + { + "formula": "=AVERAGE(7)", + "expected": 7, + "source": "hand", + "note": "One value.", + "kind": "boundary" + }, + { + "formula": "=AVERAGE({1,2;3,4},5)", + "expected": 3, + "source": "hand", + "note": "Fifteen over five." + }, + { + "formula": "=AVERAGEA()", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=AVERAGEA(1,1/0)", + "expected": "#DIV/0!", + "source": "hand", + "note": "An error among the arguments passes through.", + "kind": "error" + }, + { + "formula": "=AVERAGEA(FALSE)", + "expected": 0, + "source": "hand", + "note": "FALSE counts as 0.", + "kind": "boundary" + }, + { + "formula": "=AVERAGEA(2,4)", + "expected": 3, + "source": "hand", + "note": "Six over two." + }, + { + "formula": "=COUNT()", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=COUNT(1/0)", + "expected": 0, + "source": "hand", + "note": "An error value is not counted (it is not a number).", + "kind": "error" + }, + { + "formula": "=COUNT({\"a\"})", + "expected": 0, + "source": "hand", + "note": "Text is not counted.", + "kind": "boundary" + }, + { + "formula": "=COUNT(1,2,3)", + "expected": 3, + "source": "hand", + "note": "Three numbers." + }, + { + "formula": "=COUNT({1,2;3,4})", + "expected": 4, + "source": "hand", + "note": "Four numbers in an array." + }, + { + "formula": "=COUNTA()", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=COUNTA(1/0)", + "expected": 1, + "source": "hand", + "note": "An error value is a value, so COUNTA counts it.", + "kind": "error" + }, + { + "formula": "=COUNTA(FALSE)", + "expected": 1, + "source": "hand", + "note": "A logical value counts.", + "kind": "boundary" + }, + { + "formula": "=COUNTA(1,\"a\",TRUE)", + "expected": 3, + "source": "hand", + "note": "Number, text and logical value." + }, + { + "formula": "=COUNTA({1,2;3,4})", + "expected": 4, + "source": "hand", + "note": "Four values." + }, + { + "formula": "=COUNTUNIQUE()", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=COUNTUNIQUE(1,1/0)", + "expected": 2, + "source": "hand", + "note": "An error value is a value: it counts once, as COUNTA counts error values (COUNTA documentation).", + "kind": "error" + }, + { + "formula": "=COUNTUNIQUE(5)", + "expected": 1, + "source": "hand", + "note": "One value.", + "kind": "boundary" + }, + { + "formula": "=COUNTUNIQUE(1,1,1)", + "expected": 1, + "source": "hand", + "note": "One distinct value." + }, + { + "formula": "=COUNTUNIQUE({1,2;2,3})", + "expected": 3, + "source": "hand", + "note": "Distinct 1, 2 and 3." + }, + { + "formula": "=DEVSQ()", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=DEVSQ(1,1/0)", + "expected": "#DIV/0!", + "source": "hand", + "note": "An error among the arguments passes through.", + "kind": "error" + }, + { + "formula": "=DEVSQ(3)", + "expected": 0, + "source": "hand", + "note": "One value.", + "kind": "boundary" + }, + { + "formula": "=DEVSQ(1,2,3,4)", + "expected": 5, + "source": "hand", + "note": "Mean 2.5: 2.25 + 0.25 + 0.25 + 2.25." + }, + { + "formula": "=GEOMEAN()", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=GEOMEAN(7)", + "expected": 7, + "source": "hand", + "note": "One value.", + "kind": "boundary" + }, + { + "formula": "=GEOMEAN(2,8)", + "expected": 4, + "source": "hand", + "note": "The square root of 16." + }, + { + "formula": "=HARMEAN()", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=HARMEAN(4)", + "expected": 4, + "source": "hand", + "note": "One value.", + "kind": "boundary" + }, + { + "formula": "=HARMEAN(1,2,4)", + "expected": 1.7142857142857142, + "source": "hand", + "note": "Three over 1 + 1/2 + 1/4." + }, + { + "formula": "=KURT()", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=KURT(1,2,3,4)", + "expected": -1.1999999999999975, + "source": "hand", + "note": "Four values, the fewest allowed: the sample excess kurtosis formula.", + "kind": "boundary" + }, + { + "formula": "=KURT(1,2,2,3,10)", + "expected": 4.272146531742891, + "source": "hand", + "note": "n(n+1)/((n-1)(n-2)(n-3)) times the sum of z^4, less 3(n-1)^2/((n-2)(n-3))." + }, + { + "formula": "=MAX()", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=MAX(1,1/0)", + "expected": "#DIV/0!", + "source": "hand", + "note": "An error among the arguments passes through.", + "kind": "error" + }, + { + "formula": "=MAX({\"a\"})", + "expected": 0, + "source": "hand", + "note": "No numbers gives 0.", + "kind": "boundary" + }, + { + "formula": "=MAX(4,-1,7)", + "expected": 7, + "source": "hand", + "note": "The largest." + }, + { + "formula": "=MAX({1,2;3,9},5)", + "expected": 9, + "source": "hand", + "note": "The largest across both arguments." + }, + { + "formula": "=MAXA()", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=MAXA(1,1/0)", + "expected": "#DIV/0!", + "source": "hand", + "note": "An error among the arguments passes through.", + "kind": "error" + }, + { + "formula": "=MAXA(-1,FALSE)", + "expected": 0, + "source": "hand", + "note": "FALSE counts as 0.", + "kind": "boundary" + }, + { + "formula": "=MAXA(-1,TRUE)", + "expected": 1, + "source": "hand", + "note": "TRUE counts as 1." + }, + { + "formula": "=MAXA(3,5)", + "expected": 5, + "source": "hand", + "note": "The largest." + }, + { + "formula": "=MEDIAN()", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=MEDIAN(1,1/0)", + "expected": "#DIV/0!", + "source": "hand", + "note": "An error among the arguments passes through.", + "kind": "error" + }, + { + "formula": "=MEDIAN(4)", + "expected": 4, + "source": "hand", + "note": "One value.", + "kind": "boundary" + }, + { + "formula": "=MEDIAN(1,2,3)", + "expected": 2, + "source": "hand", + "note": "The middle value." + }, + { + "formula": "=MEDIAN({3,1;4,1},5)", + "expected": 3, + "source": "hand", + "note": "Sorted 1, 1, 3, 4, 5." + }, + { + "formula": "=MIN()", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=MIN(1,1/0)", + "expected": "#DIV/0!", + "source": "hand", + "note": "An error among the arguments passes through.", + "kind": "error" + }, + { + "formula": "=MIN({\"a\"})", + "expected": 0, + "source": "hand", + "note": "No numbers gives 0.", + "kind": "boundary" + }, + { + "formula": "=MIN(4,-1,7)", + "expected": -1, + "source": "hand", + "note": "The smallest." + }, + { + "formula": "=MIN({1,2;3,9},5)", + "expected": 1, + "source": "hand", + "note": "The smallest across both arguments." + }, + { + "formula": "=MINA()", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=MINA(1,1/0)", + "expected": "#DIV/0!", + "source": "hand", + "note": "An error among the arguments passes through.", + "kind": "error" + }, + { + "formula": "=MINA(3,FALSE)", + "expected": 0, + "source": "hand", + "note": "FALSE counts as 0.", + "kind": "boundary" + }, + { + "formula": "=MINA(3,TRUE)", + "expected": 1, + "source": "hand", + "note": "TRUE counts as 1." + }, + { + "formula": "=MINA(-2,5)", + "expected": -2, + "source": "hand", + "note": "The smallest." + }, + { + "formula": "=MODE()", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=MODE(1,2,3)", + "expected": "#N/A", + "source": "hand", + "note": "No value repeats.", + "kind": "error" + }, + { + "formula": "=MODE(5,5)", + "expected": 5, + "source": "hand", + "note": "Two equal values.", + "kind": "boundary" + }, + { + "formula": "=MODE(1,2,2,3,3)", + "expected": 2, + "source": "hand", + "note": "Two modes: the first to appear." + }, + { + "formula": "=MODE.MULT()", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=MODE.MULT(1,2)", + "expected": "#N/A", + "source": "hand", + "note": "No value repeats.", + "kind": "error" + }, + { + "formula": "=MODE.MULT(7,7)", + "expected": [7], + "source": "hand", + "note": "Two equal values.", + "kind": "boundary" + }, + { + "formula": "=MODE.MULT(1,1,2,2,3)", + "expected": [1, 2], + "source": "hand", + "note": "Both modes, in order of first appearance." + }, + { + "formula": "=MODE.SNGL()", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=MODE.SNGL(4,4)", + "expected": 4, + "source": "hand", + "note": "Two equal values.", + "kind": "boundary" + }, + { + "formula": "=MODE.SNGL(3,1,1,3,2)", + "expected": 3, + "source": "hand", + "note": "Two modes: the first to appear." + }, + { + "formula": "=MODE.SNGL({1,2,2})", + "expected": 2, + "source": "hand", + "note": "Two appears twice." + }, + { + "formula": "=SKEW()", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=SKEW(1,2)", + "expected": "#DIV/0!", + "source": "hand", + "note": "Fewer than three values.", + "kind": "error" + }, + { + "formula": "=SKEW(1,2,3)", + "expected": 0, + "source": "hand", + "note": "A symmetric sample.", + "kind": "boundary" + }, + { + "formula": "=SKEW(1,2,10)", + "expected": 1.652316740332991, + "source": "hand", + "note": "n/((n-1)(n-2)) times the sum of z^3, with the sample deviation." + }, + { + "formula": "=SKEW.P()", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=SKEW.P(5,5)", + "expected": "#DIV/0!", + "source": "hand", + "note": "No spread.", + "kind": "error" + }, + { + "formula": "=SKEW.P(1,2,3)", + "expected": 0, + "source": "hand", + "note": "A symmetric sample.", + "kind": "boundary" + }, + { + "formula": "=SKEW.P(1,2,10)", + "expected": 0.674555484545766, + "source": "hand", + "note": "The mean of z^3, with the population deviation." + }, + { + "formula": "=STDEV()", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=STDEV(5)", + "expected": "#DIV/0!", + "source": "hand", + "note": "One value has no sample deviation.", + "kind": "error" + }, + { + "formula": "=STDEV(5,5)", + "expected": 0, + "source": "hand", + "note": "No spread.", + "kind": "boundary" + }, + { + "formula": "=STDEV(2,4,4,4,5,5,7,9)", + "expected": 2.138089935299395, + "source": "hand", + "note": "Squares about the mean 5 add to 32; over 7." + }, + { + "formula": "=STDEV(1,2,3,4)", + "expected": 1.2909944487358056, + "source": "hand", + "note": "Five over three." + }, + { + "formula": "=STDEV.P()", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=STDEV.P({\"a\"})", + "expected": "#DIV/0!", + "source": "hand", + "note": "No numbers.", + "kind": "error" + }, + { + "formula": "=STDEV.P(2,4,4,4,5,5,7,9)", + "expected": 2, + "source": "hand", + "note": "Squares add to 32; over 8 is 4." + }, + { + "formula": "=STDEV.P(1,3)", + "expected": 1, + "source": "hand", + "note": "Deviations of one each." + }, + { + "formula": "=STDEV.P(-1E3,1E3)", + "expected": 1000, + "source": "hand", + "note": "Symmetric about 0.", + "kind": "boundary" + }, + { + "formula": "=STDEV.S()", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=STDEV.S(5)", + "expected": "#DIV/0!", + "source": "hand", + "note": "One value has no sample deviation.", + "kind": "error" + }, + { + "formula": "=STDEV.S(5,5)", + "expected": 0, + "source": "hand", + "note": "No spread.", + "kind": "boundary" + }, + { + "formula": "=STDEV.S(2,4,4,4,5,5,7,9)", + "expected": 2.138089935299395, + "source": "hand", + "note": "Squares add to 32; over 7." + }, + { + "formula": "=STDEV.S(1,3)", + "expected": 1.4142135623730951, + "source": "hand", + "note": "Squares add to 2; over 1." + }, + { + "formula": "=STDEVA()", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=STDEVA(5)", + "expected": "#DIV/0!", + "source": "hand", + "note": "One value has no sample deviation.", + "kind": "error" + }, + { + "formula": "=STDEVA(TRUE,TRUE)", + "expected": 0, + "source": "hand", + "note": "Both count as 1.", + "kind": "boundary" + }, + { + "formula": "=STDEVA(1,3)", + "expected": 1.4142135623730951, + "source": "hand", + "note": "Squares add to 2; over 1." + }, + { + "formula": "=STDEVP()", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=STDEVP({\"a\"})", + "expected": "#DIV/0!", + "source": "hand", + "note": "No numbers.", + "kind": "error" + }, + { + "formula": "=STDEVP(5)", + "expected": 0, + "source": "hand", + "note": "One value.", + "kind": "boundary" + }, + { + "formula": "=STDEVP(1,3)", + "expected": 1, + "source": "hand", + "note": "Deviations of one each." + }, + { + "formula": "=STDEVPA()", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=STDEVPA(1,1/0)", + "expected": "#DIV/0!", + "source": "hand", + "note": "An error among the arguments passes through.", + "kind": "error" + }, + { + "formula": "=STDEVPA(FALSE)", + "expected": 0, + "source": "hand", + "note": "One value.", + "kind": "boundary" + }, + { + "formula": "=STDEVPA(0,2)", + "expected": 1, + "source": "hand", + "note": "Deviations of one each." + }, + { + "formula": "=STDEVPA(TRUE,3)", + "expected": 1, + "source": "hand", + "note": "TRUE counts as 1: values 1 and 3." + }, + { + "formula": "=VAR()", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=VAR(5)", + "expected": "#DIV/0!", + "source": "hand", + "note": "One value has no sample variance.", + "kind": "error" + }, + { + "formula": "=VAR(5,5)", + "expected": 0, + "source": "hand", + "note": "No spread.", + "kind": "boundary" + }, + { + "formula": "=VAR(1,3)", + "expected": 2, + "source": "hand", + "note": "Squares add to 2; over 1." + }, + { + "formula": "=VAR.P()", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=VAR.P({\"a\"})", + "expected": "#DIV/0!", + "source": "hand", + "note": "No numbers.", + "kind": "error" + }, + { + "formula": "=VAR.P(5)", + "expected": 0, + "source": "hand", + "note": "One value.", + "kind": "boundary" + }, + { + "formula": "=VAR.P(1,3)", + "expected": 1, + "source": "hand", + "note": "Squares add to 2; over 2." + }, + { + "formula": "=VAR.P(2,4,4,4,5,5,7,9)", + "expected": 4, + "source": "hand", + "note": "Squares add to 32; over 8." + }, + { + "formula": "=VAR.S()", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=VAR.S(5,5)", + "expected": 0, + "source": "hand", + "note": "No spread.", + "kind": "boundary" + }, + { + "formula": "=VAR.S(1,2,3,4)", + "expected": 1.6666666666666667, + "source": "hand", + "note": "Squares add to 5; over 3." + }, + { + "formula": "=VAR.S(2,4,4,4,5,5,7,9)", + "expected": 4.571428571428571, + "source": "hand", + "note": "Squares about the mean 5 add to 32; over 7." + }, + { + "formula": "=VARA()", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=VARA(5)", + "expected": "#DIV/0!", + "source": "hand", + "note": "One value has no sample variance.", + "kind": "error" + }, + { + "formula": "=VARA(TRUE,TRUE)", + "expected": 0, + "source": "hand", + "note": "Both count as 1.", + "kind": "boundary" + }, + { + "formula": "=VARA(1,3)", + "expected": 2, + "source": "hand", + "note": "Squares add to 2; over 1." + }, + { + "formula": "=VARA(FALSE,2)", + "expected": 2, + "source": "hand", + "note": "FALSE counts as 0: values 0 and 2." + }, + { + "formula": "=VARP()", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=VARP({\"a\"})", + "expected": "#DIV/0!", + "source": "hand", + "note": "No numbers.", + "kind": "error" + }, + { + "formula": "=VARP(5)", + "expected": 0, + "source": "hand", + "note": "One value.", + "kind": "boundary" + }, + { + "formula": "=VARP(1,3)", + "expected": 1, + "source": "hand", + "note": "Squares add to 2; over 2." + }, + { + "formula": "=VARP(1,2,3,4)", + "expected": 1.25, + "source": "hand", + "note": "Squares add to 5; over 4." + }, + { + "formula": "=VARPA()", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=VARPA(1,1/0)", + "expected": "#DIV/0!", + "source": "hand", + "note": "An error among the arguments passes through.", + "kind": "error" + }, + { + "formula": "=VARPA(TRUE)", + "expected": 0, + "source": "hand", + "note": "One value.", + "kind": "boundary" + }, + { + "formula": "=VARPA(TRUE,3)", + "expected": 1, + "source": "hand", + "note": "Values 1 and 3." + }, + { + "formula": "=AVERAGEIF(A1:A3)", + "expected": "#VALUE!", + "cells": { + "A1": 1, + "A2": 5, + "A3": 9, + "B1": "x", + "B2": "y", + "B3": "x" + }, + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=AVERAGEIF(A1:A3,\">9\")", + "expected": "#DIV/0!", + "cells": { + "A1": 1, + "A2": 5, + "A3": 9, + "B1": "x", + "B2": "y", + "B3": "x" + }, + "source": "hand", + "note": "No cell meets the criterion.", + "kind": "error" + }, + { + "formula": "=AVERAGEIF(A1:A3,\">=9\")", + "expected": 9, + "cells": { + "A1": 1, + "A2": 5, + "A3": 9, + "B1": "x", + "B2": "y", + "B3": "x" + }, + "source": "hand", + "note": "The criterion includes its edge.", + "kind": "boundary" + }, + { + "formula": "=AVERAGEIF(A1:A3,\"<9\")", + "expected": 3, + "cells": { + "A1": 1, + "A2": 5, + "A3": 9, + "B1": "x", + "B2": "y", + "B3": "x" + }, + "source": "hand", + "note": "One and five." + }, + { + "formula": "=COUNTIF(A1:A3)", + "expected": "#VALUE!", + "cells": { + "A1": 1, + "A2": 5, + "A3": 9, + "B1": "x", + "B2": "y", + "B3": "x" + }, + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=COUNTIF(A1:A3,1/0)", + "expected": "#DIV/0!", + "cells": { + "A1": 1, + "A2": 5, + "A3": 9, + "B1": "x", + "B2": "y", + "B3": "x" + }, + "source": "hand", + "note": "An error criterion passes through.", + "kind": "error" + }, + { + "formula": "=COUNTIF(A1:A3,\">=9\")", + "expected": 1, + "cells": { + "A1": 1, + "A2": 5, + "A3": 9, + "B1": "x", + "B2": "y", + "B3": "x" + }, + "source": "hand", + "note": "The criterion includes its edge.", + "kind": "boundary" + }, + { + "formula": "=COUNTIF(B1:B3,\"<>x\")", + "expected": 1, + "cells": { + "A1": 1, + "A2": 5, + "A3": 9, + "B1": "x", + "B2": "y", + "B3": "x" + }, + "source": "hand", + "note": "Only y." + }, + { + "formula": "=MAXIFS(A1:A3,B1:B3)", + "expected": "#VALUE!", + "cells": { + "A1": 1, + "A2": 5, + "A3": 9, + "B1": "x", + "B2": "y", + "B3": "x" + }, + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=MAXIFS(A1:A3,B1:B2,\"x\")", + "expected": "#VALUE!", + "cells": { + "A1": 1, + "A2": 5, + "A3": 9, + "B1": "x", + "B2": "y", + "B3": "x" + }, + "source": "hand", + "note": "The ranges differ in size.", + "kind": "error" + }, + { + "formula": "=MAXIFS(A1:A3,A1:A3,\"<=5\")", + "expected": 5, + "cells": { + "A1": 1, + "A2": 5, + "A3": 9, + "B1": "x", + "B2": "y", + "B3": "x" + }, + "source": "hand", + "note": "The criterion includes its edge.", + "kind": "boundary" + }, + { + "formula": "=MAXIFS(A1:A3,B1:B3,\"y\")", + "expected": 5, + "cells": { + "A1": 1, + "A2": 5, + "A3": 9, + "B1": "x", + "B2": "y", + "B3": "x" + }, + "source": "hand", + "note": "Only row 2." + }, + { + "formula": "=MINIFS(A1:A3,B1:B3)", + "expected": "#VALUE!", + "cells": { + "A1": 1, + "A2": 5, + "A3": 9, + "B1": "x", + "B2": "y", + "B3": "x" + }, + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=MINIFS(A1:A3,B1:B2,\"x\")", + "expected": "#VALUE!", + "cells": { + "A1": 1, + "A2": 5, + "A3": 9, + "B1": "x", + "B2": "y", + "B3": "x" + }, + "source": "hand", + "note": "The ranges differ in size.", + "kind": "error" + }, + { + "formula": "=MINIFS(A1:A3,A1:A3,\">=5\")", + "expected": 5, + "cells": { + "A1": 1, + "A2": 5, + "A3": 9, + "B1": "x", + "B2": "y", + "B3": "x" + }, + "source": "hand", + "note": "The criterion includes its edge.", + "kind": "boundary" + }, + { + "formula": "=MINIFS(A1:A3,B1:B3,\"z\")", + "expected": 0, + "cells": { + "A1": 1, + "A2": 5, + "A3": 9, + "B1": "x", + "B2": "y", + "B3": "x" + }, + "source": "hand", + "note": "No match gives 0." + }, + { + "formula": "=AVERAGEIFS(A1:A4,B1:B4)", + "expected": "#VALUE!", + "cells": { + "A1": 10, + "A2": 20, + "A3": 30, + "A4": 40, + "B1": 1, + "B2": 2, + "B3": 3, + "B4": 4, + "C1": "x", + "C2": "y", + "C3": "x", + "C4": "y" + }, + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=AVERAGEIFS(A1:A4,B1:B4,\">9\")", + "expected": "#DIV/0!", + "cells": { + "A1": 10, + "A2": 20, + "A3": 30, + "A4": 40, + "B1": 1, + "B2": 2, + "B3": 3, + "B4": 4, + "C1": "x", + "C2": "y", + "C3": "x", + "C4": "y" + }, + "source": "hand", + "note": "No row meets the criteria.", + "kind": "error" + }, + { + "formula": "=AVERAGEIFS(A1:A4,B1:B4,\">=2\")", + "expected": 30, + "cells": { + "A1": 10, + "A2": 20, + "A3": 30, + "A4": 40, + "B1": 1, + "B2": 2, + "B3": 3, + "B4": 4, + "C1": "x", + "C2": "y", + "C3": "x", + "C4": "y" + }, + "source": "hand", + "note": "The criterion includes its edge: 20, 30, 40.", + "kind": "boundary" + }, + { + "formula": "=AVERAGEIFS(A1:A4,B1:B4,\">1\",C1:C4,\"x\")", + "expected": 30, + "cells": { + "A1": 10, + "A2": 20, + "A3": 30, + "A4": 40, + "B1": 1, + "B2": 2, + "B3": 3, + "B4": 4, + "C1": "x", + "C2": "y", + "C3": "x", + "C4": "y" + }, + "source": "hand", + "note": "Only row 3." + }, + { + "formula": "=AVERAGEIFS(A1:A4,C1:C4,\"x\")", + "expected": 20, + "cells": { + "A1": 10, + "A2": 20, + "A3": 30, + "A4": 40, + "B1": 1, + "B2": 2, + "B3": 3, + "B4": 4, + "C1": "x", + "C2": "y", + "C3": "x", + "C4": "y" + }, + "source": "hand", + "note": "Rows 1 and 3." + }, + { + "formula": "=COUNTIFS(A1:A4)", + "expected": "#VALUE!", + "cells": { + "A1": 10, + "A2": 20, + "A3": 30, + "A4": 40, + "B1": 1, + "B2": 2, + "B3": 3, + "B4": 4, + "C1": "x", + "C2": "y", + "C3": "x", + "C4": "y" + }, + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=COUNTIFS(A1:A4,\">1\",B1:B3,\"x\")", + "expected": "#VALUE!", + "cells": { + "A1": 10, + "A2": 20, + "A3": 30, + "A4": 40, + "B1": 1, + "B2": 2, + "B3": 3, + "B4": 4, + "C1": "x", + "C2": "y", + "C3": "x", + "C4": "y" + }, + "source": "hand", + "note": "The ranges differ in size.", + "kind": "error" + }, + { + "formula": "=COUNTIFS(B1:B4,\">=2\")", + "expected": 3, + "cells": { + "A1": 10, + "A2": 20, + "A3": 30, + "A4": 40, + "B1": 1, + "B2": 2, + "B3": 3, + "B4": 4, + "C1": "x", + "C2": "y", + "C3": "x", + "C4": "y" + }, + "source": "hand", + "note": "The criterion includes its edge.", + "kind": "boundary" + }, + { + "formula": "=COUNTIFS(B1:B4,\">1\",C1:C4,\"x\")", + "expected": 1, + "cells": { + "A1": 10, + "A2": 20, + "A3": 30, + "A4": 40, + "B1": 1, + "B2": 2, + "B3": 3, + "B4": 4, + "C1": "x", + "C2": "y", + "C3": "x", + "C4": "y" + }, + "source": "hand", + "note": "Only row 3." + }, + { + "formula": "=COUNTIFS(C1:C4,\"y\")", + "expected": 2, + "cells": { + "A1": 10, + "A2": 20, + "A3": 30, + "A4": 40, + "B1": 1, + "B2": 2, + "B3": 3, + "B4": 4, + "C1": "x", + "C2": "y", + "C3": "x", + "C4": "y" + }, + "source": "hand", + "note": "Rows 2 and 4." + }, + { + "formula": "=COUNTBLANK()", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=COUNTBLANK(A1:A4)", + "expected": 1, + "cells": { + "A1": 1, + "A2": "x", + "A3": 0, + "B1": 2, + "B2": 3, + "B3": "y" + }, + "source": "hand", + "note": "A4 is empty; text and numbers are not blank.", + "kind": "boundary" + }, + { + "formula": "=COUNTBLANK(C1:C5)", + "expected": 5, + "cells": { + "A1": 1, + "A2": "x", + "A3": 0, + "B1": 2, + "B2": 3, + "B3": "y" + }, + "source": "hand", + "note": "An empty range." + }, + { + "formula": "=COUNTBLANK(A1:B4)", + "expected": 2, + "cells": { + "A1": 1, + "A2": "x", + "A3": 0, + "B1": 2, + "B2": 3, + "B3": "y" + }, + "source": "hand", + "note": "A4 and B4." + }, + { + "formula": "=COUNTBLANK(A1:A2)", + "expected": 1, + "source": "hand", + "note": "A cell holding an error is not blank; A2 is.", + "kind": "error", + "cells": { + "A1": "=1/0" + } + }, + { + "formula": "=CORREL({1,2})", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=CORREL({1,2,3},{2,4,6})", + "expected": 1, + "source": "hand", + "note": "An exact rising line.", + "kind": "boundary" + }, + { + "formula": "=CORREL({1,2,3},{3,2,1})", + "expected": -1, + "source": "hand", + "note": "An exact falling line." + }, + { + "formula": "=CORREL({1,2},{3,3})", + "expected": "#DIV/0!", + "source": "hand", + "note": "No spread in the second array.", + "kind": "error" + }, + { + "formula": "=PEARSON({1,2})", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=PEARSON({1,2,3},{2,4,6})", + "expected": 1, + "source": "hand", + "note": "An exact rising line.", + "kind": "boundary" + }, + { + "formula": "=PEARSON({1,2,3},{3,2,1})", + "expected": -1, + "source": "hand", + "note": "An exact falling line." + }, + { + "formula": "=PEARSON({1,2},{3,3})", + "expected": "#DIV/0!", + "source": "hand", + "note": "No spread in the second array.", + "kind": "error" + }, + { + "formula": "=COVAR({1,2})", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=COVAR({1,2},{1,2,3})", + "expected": "#N/A", + "source": "hand", + "note": "The arrays differ in length.", + "kind": "error" + }, + { + "formula": "=COVAR({5},{7})", + "expected": 0, + "source": "hand", + "note": "One pair: no spread.", + "kind": "boundary" + }, + { + "formula": "=COVAR({1,2,3},{1,2,3})", + "expected": 0.6666666666666666, + "source": "hand", + "note": "Squares about 2 add to 2; over n or n - 1." + }, + { + "formula": "=COVAR({3,2,4,5,6},{9,7,12,15,17})", + "expected": 5.2, + "source": "hand", + "note": "Cross products add to 26; over 5 or 4." + }, + { + "formula": "=COVARIANCE.P({1,2})", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=COVARIANCE.P({1,2},{1,2,3})", + "expected": "#N/A", + "source": "hand", + "note": "The arrays differ in length.", + "kind": "error" + }, + { + "formula": "=COVARIANCE.P({5},{7})", + "expected": 0, + "source": "hand", + "note": "One pair: no spread.", + "kind": "boundary" + }, + { + "formula": "=COVARIANCE.P({1,2,3},{1,2,3})", + "expected": 0.6666666666666666, + "source": "hand", + "note": "Squares about 2 add to 2; over n or n - 1." + }, + { + "formula": "=COVARIANCE.S({1,2})", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=COVARIANCE.S({1,2},{1,2,3})", + "expected": "#N/A", + "source": "hand", + "note": "The arrays differ in length.", + "kind": "error" + }, + { + "formula": "=COVARIANCE.S({5},{7})", + "expected": "#DIV/0!", + "source": "hand", + "note": "One pair has no sample covariance.", + "kind": "boundary" + }, + { + "formula": "=COVARIANCE.S({1,2,3},{1,2,3})", + "expected": 1, + "source": "hand", + "note": "Squares about 2 add to 2; over n or n - 1." + }, + { + "formula": "=COVARIANCE.S({3,2,4,5,6},{9,7,12,15,17})", + "expected": 6.5, + "source": "hand", + "note": "Cross products add to 26; over 5 or 4." + }, + { + "formula": "=FORECAST(30,{6,7})", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=FORECAST(30,{1,2},{3,3})", + "expected": "#DIV/0!", + "source": "hand", + "note": "No spread in x.", + "kind": "error" + }, + { + "formula": "=FORECAST(1,{1,2},{1,2,3})", + "expected": "#N/A", + "source": "hand", + "note": "The arrays differ in length.", + "kind": "error" + }, + { + "formula": "=FORECAST(4,{2,4,6},{1,2,3})", + "expected": 8, + "source": "hand", + "note": "An exact line y = 2x.", + "kind": "boundary" + }, + { + "formula": "=FORECAST(0,{3,5,7},{1,2,3})", + "expected": 1, + "source": "hand", + "note": "y = 2x + 1 at 0." + }, + { + "formula": "=FORECAST.LINEAR(30,{6,7})", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=FORECAST.LINEAR(30,{1,2},{3,3})", + "expected": "#DIV/0!", + "source": "hand", + "note": "No spread in x.", + "kind": "error" + }, + { + "formula": "=FORECAST.LINEAR(1,{1,2},{1,2,3})", + "expected": "#N/A", + "source": "hand", + "note": "The arrays differ in length.", + "kind": "error" + }, + { + "formula": "=FORECAST.LINEAR(4,{2,4,6},{1,2,3})", + "expected": 8, + "source": "hand", + "note": "An exact line y = 2x.", + "kind": "boundary" + }, + { + "formula": "=FORECAST.LINEAR(0,{3,5,7},{1,2,3})", + "expected": 1, + "source": "hand", + "note": "y = 2x + 1 at 0." + }, + { + "formula": "=INTERCEPT({1,2})", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=INTERCEPT({1,2},{3,3})", + "expected": "#DIV/0!", + "source": "hand", + "note": "No spread in x.", + "kind": "error" + }, + { + "formula": "=INTERCEPT({3,5,7},{1,2,3})", + "expected": 1, + "source": "hand", + "note": "y = 2x + 1.", + "kind": "boundary" + }, + { + "formula": "=INTERCEPT({1,2},{1,2,3})", + "expected": "#N/A", + "source": "hand", + "note": "The arrays differ in length.", + "kind": "error" + }, + { + "formula": "=SLOPE({1,2})", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=SLOPE({1,2},{3,3})", + "expected": "#DIV/0!", + "source": "hand", + "note": "No spread in x.", + "kind": "error" + }, + { + "formula": "=SLOPE({3,5,7},{1,2,3})", + "expected": 2, + "source": "hand", + "note": "y = 2x + 1.", + "kind": "boundary" + }, + { + "formula": "=SLOPE({1,2},{1,2,3})", + "expected": "#N/A", + "source": "hand", + "note": "The arrays differ in length.", + "kind": "error" + }, + { + "formula": "=RSQ({1,2})", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=RSQ({1,2},{3,3})", + "expected": "#DIV/0!", + "source": "hand", + "note": "No spread in x.", + "kind": "error" + }, + { + "formula": "=RSQ({3,5,7},{1,2,3})", + "expected": 1, + "source": "hand", + "note": "An exact line.", + "kind": "boundary" + }, + { + "formula": "=RSQ({1,2},{1,2,3})", + "expected": "#N/A", + "source": "hand", + "note": "The arrays differ in length.", + "kind": "error" + }, + { + "formula": "=STEYX({1,2})", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=STEYX({1,2},{3,4})", + "expected": "#DIV/0!", + "source": "hand", + "note": "Fewer than three points.", + "kind": "error" + }, + { + "formula": "=STEYX({3,5,7},{1,2,3})", + "expected": 0, + "source": "hand", + "note": "An exact line.", + "kind": "boundary" + }, + { + "formula": "=STEYX({1,2},{1,2,3})", + "expected": "#N/A", + "source": "hand", + "note": "The arrays differ in length.", + "kind": "error" + }, + { + "formula": "=LINEST()", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=LINEST({3,5,7},{1,2,3})", + "expected": [2, 1], + "source": "hand", + "note": "An exact line y = 2x + 1: slope, then intercept.", + "kind": "boundary" + }, + { + "formula": "=LINEST({2,4,6},{1,2,3},FALSE)", + "expected": [2, 0], + "source": "hand", + "note": "Through the origin." + }, + { + "formula": "=LINEST({1,2,3})", + "expected": [1, 0], + "source": "hand", + "note": "x defaults to 1, 2, 3." + }, + { + "formula": "=LINEST({1,2,3},{1,2})", + "expected": "#REF!", + "source": "hand", + "note": "The arrays differ in size.", + "kind": "error" + }, + { + "formula": "=LOGEST()", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=LOGEST({1,-2,4})", + "expected": "#NUM!", + "source": "hand", + "note": "A value at or below zero has no logarithm.", + "kind": "error" + }, + { + "formula": "=LOGEST({2,4,8},{1,2,3})", + "expected": [2, 1], + "source": "hand", + "note": "Exactly y = 1 times 2^x.", + "kind": "boundary" + }, + { + "formula": "=LOGEST({3,6,12})", + "expected": [2, 1.5], + "source": "hand", + "note": "y = 1.5 times 2^x at x = 1, 2, 3." + }, + { + "formula": "=LOGEST({2,4,8},{1,2,3},FALSE)", + "expected": [2, 1], + "source": "hand", + "note": "b = 1: ln m is the sum of x ln y over the sum of x^2, 14 ln 2 / 14." + }, + { + "formula": "=GROWTH()", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=GROWTH({1,-2,4})", + "expected": "#NUM!", + "source": "hand", + "note": "A value at or below zero has no logarithm.", + "kind": "error" + }, + { + "formula": "=GROWTH({2,4,8},{1,2,3},{4})", + "expected": 16, + "source": "hand", + "note": "Exactly y = 2^x.", + "kind": "boundary" + }, + { + "formula": "=GROWTH({2,4,8},{1,2,3},{0})", + "expected": 1, + "source": "hand", + "note": "2^0." + }, + { + "formula": "=GROWTH({1,10,100},{0,1,2},{3})", + "expected": 1000, + "tolerance": 1e-11, + "source": "hand", + "note": "10^3." + }, + { + "formula": "=TREND()", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=TREND({1,2,3},{1,2})", + "expected": "#REF!", + "source": "hand", + "note": "The arrays differ in size.", + "kind": "error" + }, + { + "formula": "=TREND({3,5,7},{1,2,3},{4})", + "expected": 9, + "source": "hand", + "note": "y = 2x + 1 at 4.", + "kind": "boundary" + }, + { + "formula": "=TREND({3,5,7},{1,2,3},{0})", + "expected": 1, + "source": "hand", + "note": "y = 2x + 1 at 0." + }, + { + "formula": "=TREND({2,4,6},{1,2,3},{4},FALSE)", + "expected": 8, + "source": "hand", + "note": "Through the origin: y = 2x." + }, + { + "formula": "=LARGE({1,2})", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=LARGE({1,2},3)", + "expected": "#NUM!", + "source": "hand", + "note": "k past the count.", + "kind": "error" + }, + { + "formula": "=LARGE({3,5,4},1)", + "expected": 5, + "source": "hand", + "note": "k = 1 is the largest.", + "kind": "boundary" + }, + { + "formula": "=LARGE({1,2},0)", + "expected": "#NUM!", + "source": "hand", + "note": "k below 1.", + "kind": "error" + }, + { + "formula": "=SMALL({1,2})", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=SMALL({1,2},0)", + "expected": "#NUM!", + "source": "hand", + "note": "k below 1.", + "kind": "error" + }, + { + "formula": "=SMALL({3,5,4},1)", + "expected": 3, + "source": "hand", + "note": "k = 1 is the smallest.", + "kind": "boundary" + }, + { + "formula": "=SMALL({3,5,4},3)", + "expected": 5, + "source": "hand", + "note": "k = n is the largest." + }, + { + "formula": "=PERCENTILE({1,2})", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=PERCENTILE({1,2,3},1.5)", + "expected": "#NUM!", + "source": "hand", + "note": "k above 1.", + "kind": "error" + }, + { + "formula": "=PERCENTILE({1,2,3,4},0)", + "expected": 1, + "source": "hand", + "note": "k = 0 is the smallest.", + "kind": "boundary" + }, + { + "formula": "=PERCENTILE({1,2,3,4},1)", + "expected": 4, + "source": "hand", + "note": "k = 1 is the largest." + }, + { + "formula": "=PERCENTILE.EXC({1,2})", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=PERCENTILE.EXC({1,2,3,4},0.2)", + "expected": 1, + "source": "hand", + "note": "Rank 0.2 times 5 = 1: the smallest, the lowest k allowed.", + "kind": "boundary" + }, + { + "formula": "=PERCENTILE.EXC({1,2,3,4},0.5)", + "expected": 2.5, + "source": "hand", + "note": "Rank 2.5." + }, + { + "formula": "=PERCENTILE.INC({1,2})", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=PERCENTILE.INC({1,2,3,4},0)", + "expected": 1, + "source": "hand", + "note": "k = 0 is the smallest.", + "kind": "boundary" + }, + { + "formula": "=PERCENTILE.INC({1,2,3,4},0.5)", + "expected": 2.5, + "source": "hand", + "note": "Rank 1 + 0.5 times 3." + }, + { + "formula": "=PERCENTILE.INC({1,2,3,4},1)", + "expected": 4, + "source": "hand", + "note": "k = 1 is the largest." + }, + { + "formula": "=PERCENTRANK({1,2})", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=PERCENTRANK({1,2,3},4)", + "expected": "#N/A", + "source": "hand", + "note": "x past the largest.", + "kind": "error" + }, + { + "formula": "=PERCENTRANK({1,2,3},2,0)", + "expected": "#NUM!", + "source": "hand", + "note": "Significance below 1.", + "kind": "error" + }, + { + "formula": "=PERCENTRANK({1,2,3,4,5},1)", + "expected": 0, + "source": "hand", + "note": "The smallest ranks 0.", + "kind": "boundary" + }, + { + "formula": "=PERCENTRANK({1,2,3,4,5},5)", + "expected": 1, + "source": "hand", + "note": "The largest ranks 1." + }, + { + "formula": "=PERCENTRANK({1,2,3,4,5},2)", + "expected": 0.25, + "source": "hand", + "note": "One value below, over n - 1 = 4." + }, + { + "formula": "=PERCENTRANK.EXC({1,2})", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=PERCENTRANK.EXC({1,2,3},4)", + "expected": "#N/A", + "source": "hand", + "note": "x past the largest.", + "kind": "error" + }, + { + "formula": "=PERCENTRANK.EXC({1,2,3},1)", + "expected": 0.25, + "source": "hand", + "note": "The smallest: rank 1 over n + 1 = 4.", + "kind": "boundary" + }, + { + "formula": "=PERCENTRANK.INC({1,2})", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=PERCENTRANK.INC({1,2,3},1)", + "expected": 0, + "source": "hand", + "note": "The smallest ranks 0.", + "kind": "boundary" + }, + { + "formula": "=QUARTILE({1,2})", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=QUARTILE({1,2,3},5)", + "expected": "#NUM!", + "source": "hand", + "note": "Quart above 4.", + "kind": "error" + }, + { + "formula": "=QUARTILE({1,2,3,4},0)", + "expected": 1, + "source": "hand", + "note": "Quart 0 is the smallest.", + "kind": "boundary" + }, + { + "formula": "=QUARTILE({1,2,4,7,8,9,10,12},1)", + "expected": 3.5, + "source": "hand", + "note": "Rank 1 + 0.25 times 7 = 2.75: between 2 and 4." + }, + { + "formula": "=QUARTILE({1,2,3,4},2)", + "expected": 2.5, + "source": "hand", + "note": "The median." + }, + { + "formula": "=QUARTILE.EXC({1,2})", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=QUARTILE.EXC({1,2,3},0)", + "expected": "#NUM!", + "source": "hand", + "note": "Quart 0 is out of range.", + "kind": "error" + }, + { + "formula": "=QUARTILE.EXC({1,2,3,4,5,6,7},3)", + "expected": 6, + "source": "hand", + "note": "Rank 0.75 times 8 = 6.", + "kind": "boundary" + }, + { + "formula": "=QUARTILE.EXC({6,7,15,36,39,40,41,42,43,47,49},1)", + "expected": 15, + "source": "hand", + "note": "Rank 0.25 times 12 = 3." + }, + { + "formula": "=QUARTILE.INC({1,2})", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=QUARTILE.INC({1,2,3,4},0)", + "expected": 1, + "source": "hand", + "note": "Quart 0 is the smallest.", + "kind": "boundary" + }, + { + "formula": "=QUARTILE.INC({1,2,4,7,8,9,10,12},1)", + "expected": 3.5, + "source": "hand", + "note": "Rank 2.75: between 2 and 4." + }, + { + "formula": "=RANK(7)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=RANK(9,{7,3.5,3.5,1,2})", + "expected": "#N/A", + "source": "hand", + "note": "The number is not in the list.", + "kind": "error" + }, + { + "formula": "=RANK(7,{7,3.5,3.5,1,2})", + "expected": 1, + "source": "hand", + "note": "The largest ranks 1.", + "kind": "boundary" + }, + { + "formula": "=RANK(3.5,{7,3.5,3.5,1,2})", + "expected": 2, + "source": "hand", + "note": "Ties share the top rank." + }, + { + "formula": "=RANK(7,{7,3.5,3.5,1,2},1)", + "expected": 5, + "source": "hand", + "note": "Ascending: the largest ranks last." + }, + { + "formula": "=RANK.AVG(7)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=RANK.AVG(9,{7,3.5,3.5,1,2})", + "expected": "#N/A", + "source": "hand", + "note": "The number is not in the list.", + "kind": "error" + }, + { + "formula": "=RANK.AVG(7,{7,3.5,3.5,1,2})", + "expected": 1, + "source": "hand", + "note": "The largest ranks 1.", + "kind": "boundary" + }, + { + "formula": "=RANK.AVG(3.5,{7,3.5,3.5,1,2},1)", + "expected": 3.5, + "source": "hand", + "note": "Ascending ranks 3 and 4, averaged." + }, + { + "formula": "=RANK.EQ(7)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=RANK.EQ(7,{7,3.5,3.5,1,2})", + "expected": 1, + "source": "hand", + "note": "The largest ranks 1.", + "kind": "boundary" + }, + { + "formula": "=TRIMMEAN({1,2})", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=TRIMMEAN({1,2,3,4},0)", + "expected": 2.5, + "source": "hand", + "note": "Nothing trimmed.", + "kind": "boundary" + }, + { + "formula": "=TRIMMEAN({1,2,3,4,100},0.4)", + "expected": 3, + "source": "hand", + "note": "0.4 times 5 = 2: one from each end." + }, + { + "formula": "=TRIMMEAN({1,2},-0.1)", + "expected": "#NUM!", + "source": "hand", + "note": "Percent below 0.", + "kind": "error" + }, + { + "formula": "=FREQUENCY({1,2})", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=FREQUENCY(1/0,{1})", + "expected": "#DIV/0!", + "source": "hand", + "note": "An error among the arguments passes through.", + "kind": "error" + }, + { + "formula": "=FREQUENCY({5},{5})", + "expected": [1, 0], + "source": "hand", + "note": "A value on an edge falls in its bin.", + "kind": "boundary" + }, + { + "formula": "=FREQUENCY({1,2,2,3,5,9},{2,5,2,0})", + "expected": [3, 2, 0, 0, 1], + "source": "hand", + "note": "A repeated edge counts once, at its first place." + }, + { + "formula": "=FREQUENCY({1,2,3},{\"a\"})", + "expected": [3], + "source": "hand", + "note": "No bins: one count of every value." + }, + { + "formula": "=PROB({0,1},{0.5,0.5})", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=PROB({0,1},{0.5,0.5},5)", + "expected": 0, + "source": "hand", + "note": "The lower limit is not among the values.", + "kind": "boundary" + }, + { + "formula": "=STANDARDIZE(1,2)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=STANDARDIZE(40,40,1.5)", + "expected": 0, + "source": "hand", + "note": "At the mean.", + "kind": "boundary" + }, + { + "formula": "=STANDARDIZE(37,40,1.5)", + "expected": -2, + "source": "hand", + "note": "Two deviations below." + }, + { + "formula": "=STANDARDIZE(1,0,-1)", + "expected": "#NUM!", + "source": "hand", + "note": "A negative deviation.", + "kind": "error" + }, + { + "formula": "=FISHER()", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=FISHER(0)", + "expected": 0, + "source": "hand", + "note": "atanh 0.", + "kind": "boundary" + }, + { + "formula": "=FISHER(-0.75)", + "expected": -0.9729550745276566, + "source": "hand", + "note": "atanh(-0.75) = -ln(7) / 2." + }, + { + "formula": "=FISHER(-1)", + "expected": "#NUM!", + "source": "hand", + "note": "At -1.", + "kind": "error" + }, + { + "formula": "=FISHERINV()", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=FISHERINV(\"a\")", + "expected": "#VALUE!", + "source": "hand", + "note": "Text that is not a number.", + "kind": "error" + }, + { + "formula": "=FISHERINV(0)", + "expected": 0, + "source": "hand", + "note": "tanh 0.", + "kind": "boundary" + }, + { + "formula": "=FISHERINV(-0.5*LN(7))", + "expected": -0.75, + "source": "hand", + "note": "tanh(-ln(7) / 2) = -6/8." + }, + { + "formula": "=FISHERINV(1E3)", + "expected": 1, + "source": "hand", + "note": "tanh of a large value." + }, + { + "formula": "=GAMMA()", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=GAMMA(1)", + "expected": 1, + "source": "hand", + "note": "0! = 1.", + "kind": "boundary" + }, + { + "formula": "=GAMMALN()", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=GAMMALN(1)", + "expected": 0, + "source": "hand", + "note": "ln 0!.", + "kind": "boundary" + }, + { + "formula": "=GAMMALN(10)", + "expected": 12.801827480081469, + "source": "hand", + "note": "ln 9!." + }, + { + "formula": "=GAMMALN(0.5)", + "expected": 0.5723649429247001, + "source": "hand", + "note": "Gamma(1/2) = sqrt(pi)." + }, + { + "formula": "=GAMMALN.PRECISE()", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=GAMMALN.PRECISE(-1)", + "expected": "#NUM!", + "source": "hand", + "note": "Below zero.", + "kind": "error" + }, + { + "formula": "=GAMMALN.PRECISE(2)", + "expected": 0, + "source": "hand", + "note": "ln 1!.", + "kind": "boundary" + }, + { + "formula": "=GAMMALN.PRECISE(0.5)", + "expected": 0.5723649429247001, + "source": "hand", + "note": "Gamma(1/2) = sqrt(pi)." + }, + { + "formula": "=PERMUT(5)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=PERMUT(5,0)", + "expected": 1, + "source": "hand", + "note": "Choosing none.", + "kind": "boundary" + }, + { + "formula": "=PERMUT(5,5)", + "expected": 120, + "source": "hand", + "note": "5! = 120." + }, + { + "formula": "=PERMUTATIONA(3)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=PERMUTATIONA(-1,2)", + "expected": "#NUM!", + "source": "hand", + "note": "A negative count.", + "kind": "error" + }, + { + "formula": "=PERMUTATIONA(3,0)", + "expected": 1, + "source": "hand", + "note": "3^0.", + "kind": "boundary" + }, + { + "formula": "=PERMUTATIONA(2,10)", + "expected": 1024, + "source": "hand", + "note": "2^10." + }, + { + "formula": "=PHI()", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=PHI(\"a\")", + "expected": "#VALUE!", + "source": "hand", + "note": "Text that is not a number.", + "kind": "error" + }, + { + "formula": "=PHI(0)", + "expected": 0.3989422804014327, + "source": "hand", + "note": "The peak 1 / sqrt(2 pi).", + "kind": "boundary" + }, + { + "formula": "=PHI(-0.75)", + "expected": 0.30113743215480443, + "source": "hand", + "note": "The density is even." + }, + { + "formula": "=GAUSS()", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=GAUSS(\"a\")", + "expected": "#VALUE!", + "source": "hand", + "note": "Text that is not a number.", + "kind": "error" + }, + { + "formula": "=GAUSS(0)", + "expected": 0, + "source": "hand", + "note": "Phi(0) - 1/2.", + "kind": "boundary" + }, + { + "formula": "=GAUSS(-1)", + "expected": -0.3413447460685429, + "source": "hand", + "note": "Phi(-1) - 1/2 from erfc." + }, + { + "formula": "=NORM.DIST(42,40,1.5)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=NORM.DIST(40,40,1.5,TRUE)", + "expected": 0.5, + "source": "hand", + "note": "At the mean.", + "kind": "boundary" + }, + { + "formula": "=NORM.INV(0.5,40)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=NORM.INV(0,0,1)", + "expected": "#NUM!", + "source": "hand", + "note": "Probability 0.", + "kind": "error" + }, + { + "formula": "=NORM.INV(0.5,40,1.5)", + "expected": 40, + "source": "hand", + "note": "The median is the mean.", + "kind": "boundary" + }, + { + "formula": "=NORM.INV(0.8413447460685429,0,1)", + "expected": 1, + "tolerance": 1e-12, + "source": "hand", + "note": "Phi(1) from the table." + }, + { + "formula": "=NORM.S.DIST(1)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=NORM.S.DIST(\"a\",TRUE)", + "expected": "#VALUE!", + "source": "hand", + "note": "Text that is not a number.", + "kind": "error" + }, + { + "formula": "=NORM.S.DIST(0,TRUE)", + "expected": 0.5, + "source": "hand", + "note": "At the mean.", + "kind": "boundary" + }, + { + "formula": "=NORM.S.INV()", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=NORM.S.INV(0)", + "expected": "#NUM!", + "source": "hand", + "note": "Probability 0.", + "kind": "error" + }, + { + "formula": "=NORM.S.INV(0.977249868051821)", + "expected": 2, + "tolerance": 1e-12, + "source": "hand", + "note": "Phi(2) from the table." + }, + { + "formula": "=NORMDIST(42,40,1.5)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=NORMDIST(1,0,-1,TRUE)", + "expected": "#NUM!", + "source": "hand", + "note": "A negative deviation.", + "kind": "error" + }, + { + "formula": "=NORMDIST(0,0,1,FALSE)", + "expected": 0.3989422804014327, + "source": "hand", + "note": "The peak.", + "kind": "boundary" + }, + { + "formula": "=NORMDIST(1,0,1,TRUE)", + "expected": 0.8413447460685429, + "source": "hand", + "note": "Phi(1) from the table." + }, + { + "formula": "=NORMINV(0.5,0)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=NORMINV(1,0,1)", + "expected": "#NUM!", + "source": "hand", + "note": "Probability 1.", + "kind": "error" + }, + { + "formula": "=NORMINV(0.5,3,2)", + "expected": 3, + "source": "hand", + "note": "The median is the mean.", + "kind": "boundary" + }, + { + "formula": "=NORMINV(0.15865525393145707,0,1)", + "expected": -1, + "tolerance": 1e-12, + "source": "hand", + "note": "Phi(-1) from the table." + }, + { + "formula": "=NORMINV(0.8413447460685429,5,2)", + "expected": 7, + "tolerance": 1e-12, + "source": "hand", + "note": "One deviation up." + }, + { + "formula": "=NORMSDIST()", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=NORMSDIST(\"a\")", + "expected": "#VALUE!", + "source": "hand", + "note": "Text that is not a number.", + "kind": "error" + }, + { + "formula": "=NORMSDIST(0)", + "expected": 0.5, + "source": "hand", + "note": "At the mean.", + "kind": "boundary" + }, + { + "formula": "=NORMSDIST(1)", + "expected": 0.8413447460685429, + "source": "hand", + "note": "Phi(1) from the table." + }, + { + "formula": "=NORMSDIST(-2)", + "expected": 0.022750131948179195, + "source": "hand", + "note": "Phi(-2) from the table." + }, + { + "formula": "=NORMSINV()", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=NORMSINV(1)", + "expected": "#NUM!", + "source": "hand", + "note": "Probability 1.", + "kind": "error" + }, + { + "formula": "=NORMSINV(0.5)", + "expected": 0, + "source": "hand", + "note": "The median.", + "kind": "boundary" + }, + { + "formula": "=NORMSINV(0.8413447460685429)", + "expected": 1, + "tolerance": 1e-12, + "source": "hand", + "note": "Phi(1) from the table." + }, + { + "formula": "=NORMSINV(0.022750131948179195)", + "expected": -2, + "tolerance": 1e-12, + "source": "hand", + "note": "Phi(-2) from the table." + }, + { + "formula": "=LOGNORM.DIST(1,0,1)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=LOGNORM.DIST(1,0,1,TRUE)", + "expected": 0.5, + "source": "hand", + "note": "ln 1 = 0 is the median.", + "kind": "boundary" + }, + { + "formula": "=LOGNORM.INV(0.5,0)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=LOGNORM.INV(0.5,0,0)", + "expected": "#NUM!", + "source": "hand", + "note": "Deviation 0.", + "kind": "error" + }, + { + "formula": "=LOGNORM.INV(0.5,0,1)", + "expected": 1, + "source": "hand", + "note": "The median e^0.", + "kind": "boundary" + }, + { + "formula": "=LOGNORM.INV(0.5,2,3)", + "expected": 7.38905609893065, + "source": "hand", + "note": "The median e^mean." + }, + { + "formula": "=LOGINV(0.5,0)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=LOGINV(0,0,1)", + "expected": "#NUM!", + "source": "hand", + "note": "Probability 0.", + "kind": "error" + }, + { + "formula": "=LOGINV(0.5,0,1)", + "expected": 1, + "source": "hand", + "note": "The median e^0.", + "kind": "boundary" + }, + { + "formula": "=LOGINV(0.5,LN(5),2)", + "expected": 5, + "source": "hand", + "note": "The median e^mean." + }, + { + "formula": "=LOGINV(0.8413447460685429,0,1)", + "expected": 2.718281828459045, + "tolerance": 1e-12, + "source": "hand", + "note": "Phi(1) from the table: e^1." + }, + { + "formula": "=LOGNORMDIST(1,0)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=LOGNORMDIST(0,0,1)", + "expected": "#NUM!", + "source": "hand", + "note": "x at zero.", + "kind": "error" + }, + { + "formula": "=LOGNORMDIST(1,0,1)", + "expected": 0.5, + "source": "hand", + "note": "ln 1 = 0 is the median.", + "kind": "boundary" + }, + { + "formula": "=LOGNORMDIST(EXP(1),0,1)", + "expected": 0.8413447460685429, + "source": "hand", + "note": "Phi(1) from the table." + }, + { + "formula": "=LOGNORMDIST(EXP(-2),0,2)", + "expected": 0.15865525393145707, + "source": "hand", + "note": "Phi(-1) from the table." + }, + { + "formula": "=CONFIDENCE(0.05,2.5)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=CONFIDENCE(0,2.5,50)", + "expected": "#NUM!", + "source": "hand", + "note": "Alpha 0.", + "kind": "error" + }, + { + "formula": "=CONFIDENCE(0.05,2.5,1)", + "expected": 4.899909961350135, + "source": "hand", + "note": "n = 1: z(0.975) times the deviation.", + "kind": "boundary" + }, + { + "formula": "=CONFIDENCE(0.05,2.5,50)", + "expected": 0.6929519121748389, + "source": "hand", + "note": "z(0.975) = 1.959963984540054 from the table, times 2.5 over sqrt(50)." + }, + { + "formula": "=CONFIDENCE(0.05,0,50)", + "expected": "#NUM!", + "source": "hand", + "note": "Deviation 0.", + "kind": "error" + }, + { + "formula": "=CONFIDENCE.NORM(0.05,2.5)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=CONFIDENCE.NORM(1,2.5,50)", + "expected": "#NUM!", + "source": "hand", + "note": "Alpha 1.", + "kind": "error" + }, + { + "formula": "=CONFIDENCE.NORM(0.05,2.5,1)", + "expected": 4.899909961350135, + "source": "hand", + "note": "n = 1.", + "kind": "boundary" + }, + { + "formula": "=CONFIDENCE.NORM(0.31731050786291415,1,4)", + "expected": 0.5, + "tolerance": 1e-12, + "source": "hand", + "note": "Alpha = 2(1 - Phi(1)): z = 1, over sqrt(4)." + }, + { + "formula": "=CONFIDENCE.T(0.05,1)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=CONFIDENCE.T(1,1,50)", + "expected": "#NUM!", + "source": "hand", + "note": "Alpha 1.", + "kind": "error" + }, + { + "formula": "=CONFIDENCE.T(0.05,1,2)", + "expected": 8.984643532093754, + "tolerance": 1e-12, + "source": "hand", + "note": "n = 2, one degree of freedom: the t quantile is tan(0.475 pi).", + "kind": "boundary" + }, + { + "formula": "=CONFIDENCE.T(0.5,1,2)", + "expected": 0.7071067811865475, + "tolerance": 1e-12, + "source": "hand", + "note": "One degree of freedom: t(0.75) = tan(pi/4) = 1." + }, + { + "formula": "=EXPON.DIST(1,2)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=EXPON.DIST(1,0,TRUE)", + "expected": "#NUM!", + "source": "hand", + "note": "Rate 0.", + "kind": "error" + }, + { + "formula": "=EXPON.DIST(0,2,TRUE)", + "expected": 0, + "source": "hand", + "note": "At zero.", + "kind": "boundary" + }, + { + "formula": "=EXPON.DIST(0,2,FALSE)", + "expected": 2, + "source": "hand", + "note": "The density at zero is the rate." + }, + { + "formula": "=EXPONDIST(1,2)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=EXPONDIST(-1,2,TRUE)", + "expected": "#NUM!", + "source": "hand", + "note": "Below zero.", + "kind": "error" + }, + { + "formula": "=EXPONDIST(0,2,FALSE)", + "expected": 2, + "source": "hand", + "note": "The density at zero is the rate.", + "kind": "boundary" + }, + { + "formula": "=EXPONDIST(LN(2),1,TRUE)", + "expected": 0.5, + "source": "hand", + "note": "The median ln 2." + }, + { + "formula": "=WEIBULL(1,2,1)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=WEIBULL(-1,2,1,TRUE)", + "expected": "#NUM!", + "source": "hand", + "note": "Below zero.", + "kind": "error" + }, + { + "formula": "=WEIBULL(0,2,1,TRUE)", + "expected": 0, + "source": "hand", + "note": "At zero.", + "kind": "boundary" + }, + { + "formula": "=WEIBULL(1,1,2,TRUE)", + "expected": 0.3934693402873666, + "source": "hand", + "note": "1 - e^-(x/b)^a." + }, + { + "formula": "=WEIBULL.DIST(1,2,1)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=WEIBULL.DIST(1,0,1,TRUE)", + "expected": "#NUM!", + "source": "hand", + "note": "Shape 0.", + "kind": "error" + }, + { + "formula": "=WEIBULL.DIST(0,1,2,FALSE)", + "expected": 0.5, + "source": "hand", + "note": "Shape 1 is the exponential law: density 1/b at zero.", + "kind": "boundary" + }, + { + "formula": "=WEIBULL.DIST(2,2,1,TRUE)", + "expected": 0.9816843611112658, + "source": "hand", + "note": "1 - e^-(x/b)^a." + }, + { + "formula": "=GAMMA.DIST(2,1,2)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=GAMMA.DIST(0,1,2,TRUE)", + "expected": 0, + "source": "hand", + "note": "At zero.", + "kind": "boundary" + }, + { + "formula": "=GAMMA.INV(0.5,1)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=GAMMA.INV(1.5,1,2)", + "expected": "#NUM!", + "source": "hand", + "note": "Probability above 1.", + "kind": "error" + }, + { + "formula": "=GAMMA.INV(0,2,1)", + "expected": 0, + "source": "hand", + "note": "Probability 0.", + "kind": "boundary" + }, + { + "formula": "=GAMMA.INV(0.5,1,1)", + "expected": 0.6931471805599453, + "source": "hand", + "note": "Shape 1: the median ln 2." + }, + { + "formula": "=GAMMADIST(1,1,1)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=GAMMADIST(-1,1,1,TRUE)", + "expected": "#NUM!", + "source": "hand", + "note": "Below zero.", + "kind": "error" + }, + { + "formula": "=GAMMADIST(0,1,1,FALSE)", + "expected": 1, + "source": "hand", + "note": "Shape 1: density 1/b at zero.", + "kind": "boundary" + }, + { + "formula": "=GAMMADIST(1,1,1,TRUE)", + "expected": 0.6321205588285577, + "source": "hand", + "note": "Shape 1: 1 - e^-x." + }, + { + "formula": "=GAMMAINV(0.5,1)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=GAMMAINV(0.5,0,1)", + "expected": "#NUM!", + "source": "hand", + "note": "Shape 0.", + "kind": "error" + }, + { + "formula": "=GAMMAINV(0,1,1)", + "expected": 0, + "source": "hand", + "note": "Probability 0.", + "kind": "boundary" + }, + { + "formula": "=GAMMAINV(0.5,1,2)", + "expected": 1.3862943611198906, + "source": "hand", + "note": "Shape 1, scale 2: the median 2 ln 2." + }, + { + "formula": "=GAMMAINV(1-EXP(-1),1,1)", + "expected": 1, + "tolerance": 1e-12, + "source": "hand", + "note": "Shape 1: 1 - e^-x = 1 - e^-1." + }, + { + "formula": "=BETA.DIST(0.3,2,1)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=BETA.DIST(0,2,1,TRUE)", + "expected": 0, + "source": "hand", + "note": "The lower end.", + "kind": "boundary" + }, + { + "formula": "=BETA.DIST(1,2,1,TRUE)", + "expected": 1, + "source": "hand", + "note": "The upper end." + }, + { + "formula": "=BETA.INV(0.5,2)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=BETA.INV(0,2,1)", + "expected": "#NUM!", + "source": "hand", + "note": "Probability 0.", + "kind": "error" + }, + { + "formula": "=BETA.INV(1,2,1)", + "expected": 1, + "source": "hand", + "note": "Probability 1 is the upper end.", + "kind": "boundary" + }, + { + "formula": "=BETA.INV(0.25,2,1)", + "expected": 0.5, + "source": "hand", + "note": "With b = 1 the law is x^a: x = sqrt(0.25)." + }, + { + "formula": "=BETADIST(0.3,2)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=BETADIST(0.3,0,1)", + "expected": "#NUM!", + "source": "hand", + "note": "alpha 0.", + "kind": "error" + }, + { + "formula": "=BETADIST(1,2,1)", + "expected": 1, + "source": "hand", + "note": "The upper end.", + "kind": "boundary" + }, + { + "formula": "=BETADIST(0.3,2,1)", + "expected": 0.09, + "source": "hand", + "note": "x^2." + }, + { + "formula": "=BETADIST(3,2,1,2,4)", + "expected": 0.25, + "source": "hand", + "note": "Scaled to [2, 4]: (1/2)^2." + }, + { + "formula": "=BETAINV(0.5,2)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=BETAINV(1.5,2,1)", + "expected": "#NUM!", + "source": "hand", + "note": "Probability above 1.", + "kind": "error" + }, + { + "formula": "=BETAINV(1,2,1,2,4)", + "expected": 4, + "source": "hand", + "note": "Probability 1 is the upper end.", + "kind": "boundary" + }, + { + "formula": "=BETAINV(0.25,2,1)", + "expected": 0.5, + "source": "hand", + "note": "x^2 = 0.25." + }, + { + "formula": "=CHIDIST(2)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=CHIDIST(-1,2)", + "expected": "#NUM!", + "source": "hand", + "note": "Below zero.", + "kind": "error" + }, + { + "formula": "=CHIDIST(0,2)", + "expected": 1, + "source": "hand", + "note": "At zero.", + "kind": "boundary" + }, + { + "formula": "=CHIDIST(4,4)", + "expected": 0.4060058497098381, + "source": "hand", + "note": "Four degrees: e^-x/2 (1 + x/2)." + }, + { + "formula": "=CHIINV(0.5)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=CHIINV(1.5,2)", + "expected": "#NUM!", + "source": "hand", + "note": "Probability above 1.", + "kind": "error" + }, + { + "formula": "=CHIINV(1,2)", + "expected": 0, + "source": "hand", + "note": "Probability 1.", + "kind": "boundary" + }, + { + "formula": "=CHIINV(EXP(-1),2)", + "expected": 2, + "tolerance": 1e-12, + "source": "hand", + "note": "Two degrees: e^-x/2 = e^-1." + }, + { + "formula": "=CHIINV(0.05,1)", + "expected": 3.8414588206941254, + "tolerance": 1e-12, + "source": "hand", + "note": "One degree: the square of z(0.975) from the table." + }, + { + "formula": "=CHISQ.DIST(2,2)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=CHISQ.DIST(0,2,TRUE)", + "expected": 0, + "source": "hand", + "note": "At zero.", + "kind": "boundary" + }, + { + "formula": "=CHISQ.DIST.RT(2)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=CHISQ.DIST.RT(2,0)", + "expected": "#NUM!", + "source": "hand", + "note": "Zero degrees.", + "kind": "error" + }, + { + "formula": "=CHISQ.DIST.RT(0,5)", + "expected": 1, + "source": "hand", + "note": "At zero.", + "kind": "boundary" + }, + { + "formula": "=CHISQ.DIST.RT(1,1)", + "expected": 0.31731050786291415, + "source": "hand", + "note": "One degree: 2(1 - Phi(1))." + }, + { + "formula": "=CHISQ.INV(0.5)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=CHISQ.INV(1,2)", + "expected": "#NUM!", + "source": "hand", + "note": "Probability 1.", + "kind": "error" + }, + { + "formula": "=CHISQ.INV(0,2)", + "expected": 0, + "source": "hand", + "note": "Probability 0.", + "kind": "boundary" + }, + { + "formula": "=CHISQ.INV.RT(0.5)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=CHISQ.INV.RT(0,2)", + "expected": "#NUM!", + "source": "hand", + "note": "Probability 0.", + "kind": "error" + }, + { + "formula": "=CHISQ.INV.RT(1,2)", + "expected": 0, + "source": "hand", + "note": "Probability 1.", + "kind": "boundary" + }, + { + "formula": "=CHISQ.INV.RT(EXP(-1),2)", + "expected": 2, + "tolerance": 1e-12, + "source": "hand", + "note": "Two degrees: e^-x/2 = e^-1." + }, + { + "formula": "=F.DIST(3,2,2)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=F.DIST(-1,2,2,TRUE)", + "expected": "#NUM!", + "source": "hand", + "note": "Below zero.", + "kind": "error" + }, + { + "formula": "=F.DIST(0,2,2,TRUE)", + "expected": 0, + "source": "hand", + "note": "At zero.", + "kind": "boundary" + }, + { + "formula": "=F.DIST.RT(3,2)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=F.DIST.RT(3,0,2)", + "expected": "#NUM!", + "source": "hand", + "note": "Zero degrees.", + "kind": "error" + }, + { + "formula": "=F.DIST.RT(0,2,2)", + "expected": 1, + "source": "hand", + "note": "At zero.", + "kind": "boundary" + }, + { + "formula": "=F.DIST.RT(1,2,2)", + "expected": 0.5, + "source": "hand", + "note": "Two and two degrees: 1 / (1 + x)." + }, + { + "formula": "=F.DIST.RT(2,2,4)", + "expected": 0.25, + "source": "hand", + "note": "d1 = 2: (d2 / (d2 + 2x))^(d2/2) = (1/2)^2." + }, + { + "formula": "=F.INV(0.5,2)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=F.INV(1.5,2,2)", + "expected": "#NUM!", + "source": "hand", + "note": "Probability above 1.", + "kind": "error" + }, + { + "formula": "=F.INV(0,2,2)", + "expected": 0, + "source": "hand", + "note": "Probability 0.", + "kind": "boundary" + }, + { + "formula": "=F.INV(0.5,2,2)", + "expected": 1, + "tolerance": 1e-12, + "source": "hand", + "note": "x / (1 + x) = 1/2." + }, + { + "formula": "=F.INV(0.75,2,4)", + "expected": 2, + "tolerance": 1e-12, + "source": "hand", + "note": "d1 = 2: upper tail (1/2)^2 = 0.25." + }, + { + "formula": "=F.INV.RT(0.5,2)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=F.INV.RT(0,2,2)", + "expected": "#NUM!", + "source": "hand", + "note": "Probability 0.", + "kind": "error" + }, + { + "formula": "=F.INV.RT(1,2,2)", + "expected": 0, + "source": "hand", + "note": "Probability 1.", + "kind": "boundary" + }, + { + "formula": "=F.INV.RT(0.25,2,4)", + "expected": 2, + "tolerance": 1e-12, + "source": "hand", + "note": "d1 = 2: (4 / (4 + 2x))^2 = 0.25." + }, + { + "formula": "=F.INV.RT(0.5,2,2)", + "expected": 1, + "tolerance": 1e-12, + "source": "hand", + "note": "1 / (1 + x) = 1/2." + }, + { + "formula": "=FDIST(3,2)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=FDIST(-1,2,2)", + "expected": "#NUM!", + "source": "hand", + "note": "Below zero.", + "kind": "error" + }, + { + "formula": "=FDIST(0,2,2)", + "expected": 1, + "source": "hand", + "note": "At zero.", + "kind": "boundary" + }, + { + "formula": "=FDIST(3,2,2)", + "expected": 0.25, + "source": "hand", + "note": "1 / (1 + 3)." + }, + { + "formula": "=FDIST(2,2,4)", + "expected": 0.25, + "source": "hand", + "note": "d1 = 2: (4 / 8)^2." + }, + { + "formula": "=FINV(0.5,2)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=FINV(0,2,2)", + "expected": "#NUM!", + "source": "hand", + "note": "Probability 0.", + "kind": "error" + }, + { + "formula": "=FINV(1,2,2)", + "expected": 0, + "source": "hand", + "note": "Probability 1.", + "kind": "boundary" + }, + { + "formula": "=FINV(0.25,2,2)", + "expected": 3, + "tolerance": 1e-12, + "source": "hand", + "note": "1 / (1 + x) = 1/4." + }, + { + "formula": "=FINV(0.25,2,4)", + "expected": 2, + "tolerance": 1e-12, + "source": "hand", + "note": "d1 = 2: (4 / (4 + 2x))^2 = 1/4." + }, + { + "formula": "=T.DIST(1,1)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=T.DIST(1,0,TRUE)", + "expected": "#NUM!", + "source": "hand", + "note": "Zero degrees.", + "kind": "error" + }, + { + "formula": "=T.DIST(0,5,TRUE)", + "expected": 0.5, + "source": "hand", + "note": "At zero.", + "kind": "boundary" + }, + { + "formula": "=T.DIST.2T(1)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=T.DIST.2T(0,3)", + "expected": 1, + "source": "hand", + "note": "At zero both tails are everything.", + "kind": "boundary" + }, + { + "formula": "=T.DIST.2T(1,1)", + "expected": 0.5, + "source": "hand", + "note": "Cauchy: 1 - 2 atan(1) / pi." + }, + { + "formula": "=T.DIST.RT(1)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=T.DIST.RT(1,0)", + "expected": "#NUM!", + "source": "hand", + "note": "Zero degrees.", + "kind": "error" + }, + { + "formula": "=T.DIST.RT(0,4)", + "expected": 0.5, + "source": "hand", + "note": "At zero.", + "kind": "boundary" + }, + { + "formula": "=T.DIST.RT(1,1)", + "expected": 0.25, + "source": "hand", + "note": "Cauchy: 1/2 - atan(1) / pi." + }, + { + "formula": "=T.INV(0.5)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=T.INV(0.5,7)", + "expected": 0, + "source": "hand", + "note": "The median.", + "kind": "boundary" + }, + { + "formula": "=T.INV.2T(0.5)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=T.INV.2T(0,2)", + "expected": "#NUM!", + "source": "hand", + "note": "Probability 0.", + "kind": "error" + }, + { + "formula": "=T.INV.2T(1,5)", + "expected": 0, + "source": "hand", + "note": "Probability 1.", + "kind": "boundary" + }, + { + "formula": "=T.INV.2T(0.5,1)", + "expected": 1, + "tolerance": 1e-12, + "source": "hand", + "note": "Cauchy: tan(pi/4)." + }, + { + "formula": "=TDIST(1,1)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=TDIST(0,3,1)", + "expected": 0.5, + "source": "hand", + "note": "At zero, one tail.", + "kind": "boundary" + }, + { + "formula": "=TDIST(1,1,1)", + "expected": 0.25, + "source": "hand", + "note": "Cauchy, one tail." + }, + { + "formula": "=TINV(0.5)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=TINV(0,1)", + "expected": "#NUM!", + "source": "hand", + "note": "Probability 0.", + "kind": "error" + }, + { + "formula": "=TINV(1,4)", + "expected": 0, + "source": "hand", + "note": "Probability 1.", + "kind": "boundary" + }, + { + "formula": "=TINV(1-1/SQRT(3),2)", + "expected": 1, + "tolerance": 1e-12, + "source": "hand", + "note": "Two degrees: two tails 1 - t / sqrt(2 + t^2)." + }, + { + "formula": "=BINOM.DIST(6,10,0.5)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=BINOM.DIST(10,10,0.5,TRUE)", + "expected": 1, + "source": "hand", + "note": "Every outcome.", + "kind": "boundary" + }, + { + "formula": "=BINOM.DIST(0,10,0.5,FALSE)", + "expected": 0.0009765625, + "source": "hand", + "note": "2^-10." + }, + { + "formula": "=BINOM.DIST(6,10,0.5,FALSE)", + "expected": 0.205078125, + "source": "hand", + "note": "C(10,6) / 2^10." + }, + { + "formula": "=BINOM.DIST(1,10,1.5,TRUE)", + "expected": "#NUM!", + "source": "hand", + "note": "Probability above 1.", + "kind": "error" + }, + { + "formula": "=BINOM.DIST.RANGE(10,0.5)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=BINOM.DIST.RANGE(10,0.5,0,10)", + "expected": 1, + "source": "hand", + "note": "Every outcome.", + "kind": "boundary" + }, + { + "formula": "=BINOM.DIST.RANGE(4,0.5,0)", + "expected": 0.0625, + "source": "hand", + "note": "2^-4." + }, + { + "formula": "=BINOM.INV(6,0.5)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=BINOM.INV(6,0.5,0)", + "expected": 0, + "source": "hand", + "note": "Alpha 0.", + "kind": "boundary" + }, + { + "formula": "=BINOM.INV(10,0.5,0.5)", + "expected": 5, + "source": "hand", + "note": "P(X <= 4) = 386/1024, P(X <= 5) = 638/1024." + }, + { + "formula": "=BINOM.INV(6,0.5,1)", + "expected": 6, + "source": "hand", + "note": "Alpha 1 needs every outcome." + }, + { + "formula": "=BINOMDIST(6,10,0.5)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=BINOMDIST(-1,10,0.5,TRUE)", + "expected": "#NUM!", + "source": "hand", + "note": "Negative successes.", + "kind": "error" + }, + { + "formula": "=BINOMDIST(0,10,0.5,FALSE)", + "expected": 0.0009765625, + "source": "hand", + "note": "2^-10.", + "kind": "boundary" + }, + { + "formula": "=BINOMDIST(6,10,0.5,TRUE)", + "expected": 0.828125, + "source": "hand", + "note": "1 - (C(10,7) + C(10,8) + C(10,9) + 1) / 1024." + }, + { + "formula": "=CRITBINOM(6,0.5)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=CRITBINOM(6,0.5,1.5)", + "expected": "#NUM!", + "source": "hand", + "note": "Alpha above 1.", + "kind": "error" + }, + { + "formula": "=CRITBINOM(6,0.5,0)", + "expected": 0, + "source": "hand", + "note": "Alpha 0.", + "kind": "boundary" + }, + { + "formula": "=CRITBINOM(6,0.5,0.75)", + "expected": 4, + "source": "hand", + "note": "P(X <= 3) = 42/64, P(X <= 4) = 57/64." + }, + { + "formula": "=CRITBINOM(10,0.5,0.5)", + "expected": 5, + "source": "hand", + "note": "P(X <= 5) = 638/1024." + }, + { + "formula": "=NEGBINOM.DIST(10,5,0.25)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=NEGBINOM.DIST(0,1,0.5,FALSE)", + "expected": 0.5, + "source": "hand", + "note": "Success at once.", + "kind": "boundary" + }, + { + "formula": "=NEGBINOMDIST(10,5)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=NEGBINOMDIST(1,0,0.5)", + "expected": "#NUM!", + "source": "hand", + "note": "Zero successes.", + "kind": "error" + }, + { + "formula": "=NEGBINOMDIST(0,3,0.5)", + "expected": 0.125, + "source": "hand", + "note": "Three successes at once.", + "kind": "boundary" + }, + { + "formula": "=NEGBINOMDIST(10,5,0.25)", + "expected": 0.05504866037517786, + "source": "hand", + "note": "C(14,4) 0.25^5 0.75^10." + }, + { + "formula": "=HYPGEOM.DIST(1,4,8,20)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=HYPGEOM.DIST(4,4,8,20,TRUE)", + "expected": 1, + "source": "hand", + "note": "Every outcome.", + "kind": "boundary" + }, + { + "formula": "=HYPGEOMDIST(1,4,8)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=HYPGEOMDIST(5,4,8,20)", + "expected": "#NUM!", + "source": "hand", + "note": "More successes than draws.", + "kind": "error" + }, + { + "formula": "=HYPGEOMDIST(0,4,8,20)", + "expected": 0.1021671826625387, + "source": "hand", + "note": "C(12,4) / C(20,4).", + "kind": "boundary" + }, + { + "formula": "=HYPGEOMDIST(1,4,8,20)", + "expected": 0.3632610939112487, + "source": "hand", + "note": "C(8,1) C(12,3) / C(20,4)." + }, + { + "formula": "=POISSON(2,5)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=POISSON(2,-1,TRUE)", + "expected": "#NUM!", + "source": "hand", + "note": "A negative mean.", + "kind": "error" + }, + { + "formula": "=POISSON(0,5,FALSE)", + "expected": 0.006737946999085467, + "source": "hand", + "note": "e^-5.", + "kind": "boundary" + }, + { + "formula": "=POISSON.DIST(2,5)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=POISSON.DIST(0,0,TRUE)", + "expected": 1, + "source": "hand", + "note": "Mean 0.", + "kind": "boundary" + }, + { + "formula": "=PROB({0,1,2},{0.5,0.25,0.25},0,2)", + "expected": 1, + "source": "hand", + "note": "Every value." + }, + { + "formula": "=CHISQ.TEST({1,2})", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=CHISQ.TEST({10,10},{10,10})", + "expected": 1, + "source": "hand", + "note": "Observed equals expected.", + "kind": "boundary" + }, + { + "formula": "=CHISQ.TEST({12,8},{10,10})", + "expected": 0.3710933695226979, + "source": "hand", + "note": "Chi-squared 0.8 on one degree: 2(1 - Phi(sqrt(0.8)))." + }, + { + "formula": "=CHISQ.TEST({1,2},{1,2,3})", + "expected": "#N/A", + "source": "hand", + "note": "The arrays differ in size.", + "kind": "error" + }, + { + "formula": "=CHITEST({1,2})", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=CHITEST({1,2},{1,0})", + "expected": "#DIV/0!", + "source": "hand", + "note": "An expected count of 0.", + "kind": "error" + }, + { + "formula": "=CHITEST({10,10},{10,10})", + "expected": 1, + "source": "hand", + "note": "Observed equals expected.", + "kind": "boundary" + }, + { + "formula": "=CHITEST({12,8,10},{10,10,10})", + "expected": 0.6703200460356393, + "source": "hand", + "note": "Chi-squared 0.8 on two degrees: e^-0.4." + }, + { + "formula": "=CHITEST({1,2},{1,2,3})", + "expected": "#N/A", + "source": "hand", + "note": "The arrays differ in size.", + "kind": "error" + }, + { + "formula": "=F.TEST({1,2})", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=F.TEST({1,2,3},{4,5,6})", + "expected": 1, + "source": "hand", + "note": "Equal variances.", + "kind": "boundary" + }, + { + "formula": "=F.TEST({1,3},{1,2})", + "expected": 0.590334470601733, + "source": "hand", + "note": "F = 4 on (1, 1) degrees: the law is (2/pi) atan(sqrt(F))." + }, + { + "formula": "=FTEST({1,2})", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=FTEST({1},{2,3})", + "expected": "#DIV/0!", + "source": "hand", + "note": "One value has no variance.", + "kind": "error" + }, + { + "formula": "=FTEST({1,2,3},{4,5,6})", + "expected": 1, + "source": "hand", + "note": "Equal variances.", + "kind": "boundary" + }, + { + "formula": "=FTEST({1,2,3},{2,4,6})", + "expected": 0.4, + "source": "hand", + "note": "F = 1/4 on (2, 2) degrees: 2 times x / (1 + x) = 0.4." + }, + { + "formula": "=FTEST({1,3},{1,2})", + "expected": 0.590334470601733, + "source": "hand", + "note": "F = 4 on (1, 1) degrees." + }, + { + "formula": "=T.TEST({1,2},{3,4},2)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=T.TEST({1,3},{1,3},2,2)", + "expected": 1, + "source": "hand", + "note": "Equal means: t = 0.", + "kind": "boundary" + }, + { + "formula": "=TTEST({1,2},{3,4},2)", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=TTEST({1,3},{3,5},3,2)", + "expected": "#NUM!", + "source": "hand", + "note": "Three tails.", + "kind": "error" + }, + { + "formula": "=TTEST({1,3},{1,3},2,2)", + "expected": 1, + "source": "hand", + "note": "Equal means: t = 0.", + "kind": "boundary" + }, + { + "formula": "=TTEST({1,3},{3,5},2,2)", + "expected": 0.29289321881345254, + "source": "hand", + "note": "t = -sqrt(2) on two degrees: 1 - t / sqrt(2 + t^2) with |t|." + }, + { + "formula": "=TTEST({2,4},{1,1},1,1)", + "expected": 0.14758361765043326, + "source": "hand", + "note": "Paired differences 1 and 3: t = 2 on one degree, one tail." + }, + { + "formula": "=Z.TEST({1,2})", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=Z.TEST({1},1)", + "expected": "#DIV/0!", + "source": "hand", + "note": "One value has no sample deviation.", + "kind": "error" + }, + { + "formula": "=Z.TEST({1,2,3},2)", + "expected": 0.5, + "source": "hand", + "note": "x at the mean.", + "kind": "boundary" + }, + { + "formula": "=Z.TEST({1,2,3},1,1)", + "expected": 0.041632258331775196, + "source": "hand", + "note": "1 - Phi((2 - 1) / (1 / sqrt(3)))." + }, + { + "formula": "=ZTEST({1,2})", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=ZTEST({1},1)", + "expected": "#DIV/0!", + "source": "hand", + "note": "One value has no sample deviation.", + "kind": "error" + }, + { + "formula": "=ZTEST({1,2,3},2)", + "expected": 0.5, + "source": "hand", + "note": "x at the mean.", + "kind": "boundary" + }, + { + "formula": "=ZTEST({1,2,3},1,1)", + "expected": 0.041632258331775196, + "source": "hand", + "note": "1 - Phi(sqrt(3))." + }, + { + "formula": "=ZTEST({3,6,7,8,6,5,4,2,1,9},4)", + "expected": 0.090574, + "tolerance": 1e-6, + "source": "hand", + "note": "The documented example." + }, + { + "formula": "=FORECAST.ETS(4,{1,2,3})", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=FORECAST.ETS(4,{1,2,3},{1,2,3},-1)", + "expected": "#NUM!", + "source": "hand", + "note": "A negative season length.", + "kind": "error" + }, + { + "formula": "=FORECAST.ETS(5,{1,2,3,4},{1,2,3,4})", + "expected": 5, + "tolerance": 1e-9, + "source": "hand", + "note": "An exact line continues.", + "kind": "boundary" + }, + { + "formula": "=FORECAST.ETS(6,{2,4,6,8,10},{1,2,3,4,5},1)", + "expected": 12, + "tolerance": 1e-9, + "source": "hand", + "note": "An exact line with no season." + }, + { + "formula": "=FORECAST.ETS.CONFINT(4,{1,2,3})", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=FORECAST.ETS.CONFINT(4,{1,2,3},{1,2,3},1.5)", + "expected": "#NUM!", + "source": "hand", + "note": "A confidence level above 1.", + "kind": "error" + }, + { + "formula": "=FORECAST.ETS.CONFINT(5,{1,2,3,4},{1,2,3,4})", + "expected": 0, + "tolerance": 1e-9, + "source": "hand", + "note": "An exact line has no error.", + "kind": "boundary" + }, + { + "formula": "=FORECAST.ETS.CONFINT(10,{2,4,6,8,10,12},{1,2,3,4,5,6},0.9)", + "expected": 0, + "tolerance": 1e-9, + "source": "hand", + "note": "An exact line has no error at any level." + }, + { + "formula": "=FORECAST.ETS.CONFINT(5,{1,2,3},{1,2,1E9})", + "expected": "#NUM!", + "source": "hand", + "note": "A timeline past ten thousand steps.", + "kind": "error" + }, + { + "formula": "=FORECAST.ETS.SEASONALITY({1,2,3})", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=FORECAST.ETS.SEASONALITY({1,2,3},{1,2,1E9})", + "expected": "#NUM!", + "source": "hand", + "note": "A timeline past ten thousand steps.", + "kind": "error" + }, + { + "formula": "=FORECAST.ETS.SEASONALITY({1,2,3,4,5,6},{1,2,3,4,5,6})", + "expected": 1, + "source": "hand", + "note": "A line has no season.", + "kind": "boundary" + }, + { + "formula": "=FORECAST.ETS.SEASONALITY({1,5,9,1,5,9,1,5,9,1,5,9},{1,2,3,4,5,6,7,8,9,10,11,12})", + "expected": 3, + "source": "hand", + "note": "The pattern 1, 5, 9 repeats." + }, + { + "formula": "=FORECAST.ETS.SEASONALITY({1,3,7,2,1,3,7,2,1,3,7,2,1,3,7,2},{1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16})", + "expected": 4, + "source": "hand", + "note": "The pattern 1, 3, 7, 2 repeats." + }, + { + "formula": "=FORECAST.ETS.STAT({1,2},{1,2})", + "expected": "#VALUE!", + "source": "hand", + "note": "Too few arguments: the call does not compile, so it gives #VALUE!.", + "kind": "arity" + }, + { + "formula": "=FORECAST.ETS.STAT({1,2,3},{1,2,3},9)", + "expected": "#NUM!", + "source": "hand", + "note": "Statistic 9 does not exist.", + "kind": "error" + }, + { + "formula": "=FORECAST.ETS.STAT({1,2,3},{1,2,3},0)", + "expected": "#NUM!", + "source": "hand", + "note": "Statistic 0 does not exist.", + "kind": "boundary" + }, + { + "formula": "=FORECAST.ETS.STAT({2,4,6,8},{1,2,3,4},8)", + "expected": 1, + "source": "hand", + "note": "Statistic 8 is the step of the timeline." + }, + { + "formula": "=FORECAST.ETS.STAT({2,4,6,8},{0,5,10,15},8)", + "expected": 5, + "source": "hand", + "note": "Statistic 8 is the step of the timeline." + } + ] +} diff --git a/app/src/features/tables/fixtures/numfmt/DIFFERENCES.md b/app/src/features/tables/fixtures/numfmt/DIFFERENCES.md index 1d2b29818..858982f75 100644 --- a/app/src/features/tables/fixtures/numfmt/DIFFERENCES.md +++ b/app/src/features/tables/fixtures/numfmt/DIFFERENCES.md @@ -3,18 +3,18 @@ OpenNote follows Excel's documented behavior for number format codes (ADR 0057) except where a line below says otherwise. Each fixture file says how its expected values were worked out. -| Case | Excel | OpenNote | -| ------------------------------------------------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------- | -| Dates before 1900-03-01 | One day later, for a 1900-02-29 that never was | The true calendar, day 0 on 1899-12-30, as the engine counts (fn/DIFFERENCES.md) | -| Negative serials with a date code | A row of `#` | The date before 1899-12-30, as Sheets shows it | -| `[Red]` and the other colors | Fixed colors | Theme tokens with the same names, so they keep contrast in dark mode | -| General width | Depends on the column width | Always 11 characters, the sign included, as Sheets' Automatic ignores the width | -| Percent in a region that writes a space before `%` | No space (`25,60%` in German Excel) | The region's space (`25,60 %` in de-DE), as the column showed before codes | -| Built-in ID 22 | The region's short date and `h:mm` | The same, read from `Intl` | -| East Asian built-in IDs 27 to 36 and 50 to 81 outside their regions | The region's own codes | The short date, or the nearest region-neutral time or number code | -| A section with a condition | Not fully documented for negative values | A minus sign, unless the condition is `<` or `<=` a number at or below zero | -| Localized date letters (`TT.MM.JJJJ` in German) | Shown and typed in the region's letters | Not converted: date codes show and are typed with the invariant letters | -| A cell that doesn't fit | `#` fills the column width | `#` repeats `maxChars` times when the caller gives a width, and 8 times otherwise | +| Case | Excel | OpenNote | +| ------------------------------------------------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------ | +| Dates before 1900-03-01 | One day later, for a 1900-02-29 that never was | The true calendar, day 0 on 1899-12-30, as the engine counts (fn/DIFFERENCES.md) | +| Negative serials with a date code | A row of `#` | The date before 1899-12-30, as Sheets shows it | +| `[Red]` and the other colors | Fixed colors | Theme tokens with the same names, so they keep contrast in dark mode | +| General width | Depends on the column width | Always 11 characters, the sign included, as Sheets' Automatic ignores the width | +| Percent in a region that writes a space before `%` | No space (`25,60%` in German Excel) | The region's space (`25,60 %` in de-DE), as the column showed before codes | +| Built-in ID 22 | The region's short date and `h:mm` | The same, read from `Intl` | +| East Asian built-in IDs 27 to 36 and 50 to 81 outside their regions | The region's own codes | The short date, or the nearest region-neutral time or number code | +| A section with a condition | Not fully documented for negative values | A minus sign, unless the condition is `<` or `<=` a number at or below zero | +| Localized date letters (`TT.MM.JJJJ` in German) | Shown and typed in the region's letters | Not converted: date codes show and are typed with the invariant letters | +| A cell that doesn't fit | `#` fills the column width | `#` repeats `maxChars` times when the caller gives a width, and 8 times otherwise | | The date column preset | No preset: a date cell gets the short date | The region's medium date (`mmm d, yyyy` in en-US), as the column showed before codes | -| A tag with extension subtags (`[$-en-US-u-ca-islamic]`) | Not documented | The extension is dropped: names always come from the Gregorian calendar | -| Spanish (Spain) currency presets | `[$€-C0A]` (modern sort) | The same: es-ES writes 0x0C0A, and both 0x040A and 0x0C0A read as es-ES | +| A tag with extension subtags (`[$-en-US-u-ca-islamic]`) | Not documented | The extension is dropped: names always come from the Gregorian calendar | +| Spanish (Spain) currency presets | `[$€-C0A]` (modern sort) | The same: es-ES writes 0x0C0A, and both 0x040A and 0x0C0A read as es-ES | diff --git a/app/src/features/tables/index.ts b/app/src/features/tables/index.ts index cd16f3471..46fd2b753 100644 --- a/app/src/features/tables/index.ts +++ b/app/src/features/tables/index.ts @@ -5,8 +5,14 @@ export { attachSmart } from './smart/attach'; export { runSmartCommand } from './smart/commands'; export type { SmartCommand } from './smart/commands'; export { exportTableCharts } from './smart/exportCharts'; +/** The cell format bar loads on demand, so the tables chunk stays small. */ +export const loadCellFormatBar = () => import('./ui/toolbar/CellFormatBar'); +export type { CellFormatBarProps } from './ui/toolbar'; +// What the page's exports read of a table block: the sheet model, the row height limits, and number text. +export { MAX_HEIGHT, MIN_HEIGHT, renderNumberFormat, sheetModel } from './exportView'; +export type { SheetBox, SheetCellStyle, SheetTableLike } from './exportView'; // The structure commands and the reference adjustment register themselves with the table commands of the editor. import './sheet/structure/hooks'; export { readSheet, tableName } from './sheet/model'; export { canvasMeasure } from './sheet/structure/fit'; -export { MAX_HEIGHT, MIN_HEIGHT, rowHeight } from './sheet/structure/lines'; +export { rowHeight } from './sheet/structure/lines'; diff --git a/app/src/features/tables/keys/KeysTable.tsx b/app/src/features/tables/keys/KeysTable.tsx new file mode 100644 index 000000000..0fcbb3bdd --- /dev/null +++ b/app/src/features/tables/keys/KeysTable.tsx @@ -0,0 +1,50 @@ +// The Table keys section of the shortcut list (ADR 0063): the fixed keys of Grid mode and Edit mode. The keys a +// person may change are commands, so they are in the list's Table group with the others. +import { formatChord } from '../../../commands/keymap'; +import type { Chord } from '../../../commands/types'; +import { t } from '../../../strings/t'; +import type { MessageKey } from '../../../strings/t'; +import { SHEET_FIXED_KEYS } from './bindings'; +import styles from './keys.module.css'; + +function Keys({ keys }: { keys: readonly string[] | MessageKey }) { + if (typeof keys === 'string') return {t(keys)}; + return ( + + {keys.map((key, i) => ( + + {i > 0 && {t('shortcuts.or')}} + {formatChord(key as Chord)} + + ))} + + ); +} + +export function SheetKeysTable() { + return ( + <> +

{t('sheetKeys.fixed.description')}

+ + + + + + + + + {SHEET_FIXED_KEYS.map((row) => ( + + + + + ))} + +
+ {t(row.action)} + + +
+ + ); +} diff --git a/app/src/features/tables/keys/Settings.test.tsx b/app/src/features/tables/keys/Settings.test.tsx new file mode 100644 index 000000000..ab638af15 --- /dev/null +++ b/app/src/features/tables/keys/Settings.test.tsx @@ -0,0 +1,27 @@ +// Settings, then Tables: a person chooses the keys new tables use (DESIGN.md section 11.1, PLAN.md decision 4). +import { screen, waitFor, within } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; +import { userEvent } from 'vitest/browser'; +import { navigate } from '../../../app/location'; +import { getSettings } from '../../../state/settings'; +import { disposeApp, expectNoAxeViolations, renderApp } from '../../../test'; + +afterEach(() => disposeApp()); + +describe('the Tables settings section', () => { + it('lists Tables in the sections and sets the keys new tables use', async () => { + await renderApp(); + navigate({ view: 'settings', section: 'tables' }); + const group = await screen.findByRole('radiogroup', { name: 'New tables use spreadsheet keys' }); + const spreadsheet = within(group).getByRole('radio', { name: /Spreadsheet keys/ }); + const document_ = within(group).getByRole('radio', { name: /Document keys/ }); + expect(spreadsheet.getAttribute('aria-checked')).toBe('true'); + expect(getSettings().tables.newTableKeys).toBe('spreadsheet'); + await userEvent.click(document_); + await waitFor(() => expect(getSettings().tables.newTableKeys).toBe('document')); + expect(document_.getAttribute('aria-checked')).toBe('true'); + await userEvent.click(spreadsheet); + await waitFor(() => expect(getSettings().tables.newTableKeys).toBe('spreadsheet')); + await expectNoAxeViolations(document.body); + }); +}); diff --git a/app/src/features/tables/keys/Settings.tsx b/app/src/features/tables/keys/Settings.tsx new file mode 100644 index 000000000..3ee606ed6 --- /dev/null +++ b/app/src/features/tables/keys/Settings.tsx @@ -0,0 +1,33 @@ +// Settings, then Tables: which keys a new table uses (ADR 0063, DESIGN.md section 11.1, PLAN.md decision 4). +// A table stores its choice when it is made, so this setting changes only tables made after it. +import type { TableKeys } from '../../../platform/bindings/TableKeys'; +import { updateSettings, useSettings } from '../../../state/settings'; +import { t } from '../../../strings/t'; +import { RadioCard, RadioGroup } from '../../../ui'; +import styles from './keys.module.css'; + +export default function TablesSettings() { + const keys = useSettings((settings) => settings.tables.newTableKeys); + return ( +
+

{t('sheetKeys.settings.title')}

+ + label={t('sheetKeys.settings.newTableKeys')} + value={keys} + onChange={(next) => void updateSettings({ tables: { newTableKeys: next } })} + > + + + +

{t('sheetKeys.settings.help')}

+
+ ); +} diff --git a/app/src/features/tables/keys/actions.ts b/app/src/features/tables/keys/actions.ts new file mode 100644 index 000000000..3a015d785 --- /dev/null +++ b/app/src/features/tables/keys/actions.ts @@ -0,0 +1,276 @@ +// The actions of the sheet commands (ADR 0063): entries, fills, and clearing. Also the date and the time, the +// cell above, and the switch to document keys. Each runs on a view, in Grid mode or Edit mode. +import { Fragment } from '@tiptap/pm/model'; +import type { Node as PMNode } from '@tiptap/pm/model'; +import { TextSelection } from '@tiptap/pm/state'; +import { CellSelection } from '@tiptap/pm/tables'; +import type { EditorView } from '@tiptap/pm/view'; +import type { CommandId } from '../../../commands/types'; +import { t } from '../../../strings/t'; +import { daysToYmd, todayDays } from '../engine/dates'; +import type { DateOrder } from '../engine/locale'; +import { filledText } from '../smart/fill'; +import { address, lastUsed } from './grid'; +import type { Box, GridCell } from './grid'; +import { advance, commitBy, extend, extendBy, jumpBy, move, moveBy, pageOf, selectBox, selectRegion } from './moves'; +import type { Ctx } from './moves'; +import { + activeCell, + caretAtEnd, + contentOf, + gridOf, + hasHeader, + modeOf, + placedAt, + setCellContent, + sheetKeysKey, + textRange, + textsOf, +} from './state'; +import type { Entry, SheetKeysOptions, SheetMeta, SheetMode } from './state'; + +/** Starts an entry: the cell's content replaced (or kept), the caret at its end. */ +export function startEntry(ctx: Ctx, at: GridCell, content: Fragment | string | null, mode: Entry['mode']): boolean { + const before = contentOf(ctx.state.doc, at); + const tr = ctx.state.tr; + if (content !== null) setCellContent(tr, at, content); + caretAtEnd(tr, at); + ctx.dispatch(tr.setMeta(sheetKeysKey, { entry: { ...at, before, mode }, active: null } satisfies SheetMeta)); + return true; +} + +export function typeOver(ctx: Ctx, text: string): boolean { + const at = activeCell(ctx.state); + return at ? startEntry(ctx, at, text, 'enter') : false; +} + +export function cancel(ctx: Ctx): boolean { + const at = activeCell(ctx.state); + if (!at) return false; + const entry = sheetKeysKey.getState(ctx.state)?.entry; + const tr = ctx.state.tr; + const before = entry && entry.row === at.row && entry.column === at.column ? entry.before : null; + // Nothing to put back when the text is as it was, such as after a click into the cell and no typing. + const restore = before !== null && !before.eq(contentOf(ctx.state.doc, at)); + if (restore) setCellContent(tr, at, before); + const cell = placedAt(tr.doc, at); + if (cell) tr.setSelection(CellSelection.create(tr.doc, cell.pos)); + ctx.dispatch(tr.setMeta(sheetKeysKey, { entry: null, active: null } satisfies SheetMeta)); + if (restore) ctx.options.announce(t('sheetKeys.announce.canceled')); + return true; +} + +/** Each cell of the box, top to bottom and left to right. */ +export function* cellsIn(box: Box): Generator { + for (let row = box.top; row <= box.bottom; row++) + for (let column = box.left; column <= box.right; column++) yield { row, column }; +} + +/** What a cell becomes when filled from another: a formula moves its references, other content is copied. */ +function filledFrom(doc: PMNode, source: GridCell, to: GridCell): Fragment | string { + const text = placedAt(doc, source)?.node.textContent ?? ''; + if (text.trim().startsWith('=')) return filledText(text.trim(), to.row - source.row, to.column - source.column); + return contentOf(doc, source); +} + +/** Fills cells of a box, each from the source `source(cell)` names. One undo step. */ +function fill(ctx: Ctx, targets: Iterable, source: (at: GridCell) => GridCell | null): number { + const { state } = ctx; + const tr = state.tr; + let count = 0; + for (const at of targets) { + const from = source(at); + if (!from || (from.row === at.row && from.column === at.column)) continue; + setCellContent(tr, at, filledFrom(state.doc, from, at)); + count++; + } + if (count > 0) { + ctx.dispatch(tr); + if (count > 1) ctx.options.announce(t('sheetKeys.announce.filled', { count })); + } + return count; +} + +/** Ctrl+D and Ctrl+R: the range's first row (or column) into the rest; one cell takes the cell above (or left). */ +function fillAlong(ctx: Ctx, down: boolean): boolean { + const grid = gridOf(ctx.state); + if (!grid) return false; + const { box } = grid; + const single = down ? box.top === box.bottom : box.left === box.right; + if (single && (down ? box.top === 0 : box.left === 0)) return true; + fill(ctx, cellsIn(box), (at) => + down + ? { row: single ? at.row - 1 : box.top, column: at.column } + : { row: at.row, column: single ? at.column - 1 : box.left }, + ); + return true; +} + +function clearCells(ctx: Ctx, box: Box): boolean { + const tr = ctx.state.tr; + let count = 0; + for (const at of cellsIn(box)) { + if (contentOf(ctx.state.doc, at).size === 0) continue; + setCellContent(tr, at, ''); + count++; + } + if (count === 0) return true; + ctx.dispatch(tr); + if (count > 1) ctx.options.announce(t('sheetKeys.announce.cleared', { count })); + return true; +} + +const pad = (n: number) => String(n).padStart(2, '0'); + +/** Today's date as a person types it in the table's locale, so the engine reads it as a date. */ +export function dateText(days: number, order: DateOrder): string { + const { y, m, d } = daysToYmd(days); + if (order === 'ymd') return `${y}-${pad(m)}-${pad(d)}`; + return order === 'dmy' ? `${d}/${m}/${y}` : `${m}/${d}/${y}`; +} + +/** The time as a person types it: "1:05 PM" in American English. */ +export function timeText(now: Date, locale: string): string { + return now.toLocaleTimeString(locale, { hour: 'numeric', minute: '2-digit' }).replace(/[\u202f\u00a0]/g, ' '); +} + +function insertStamp(ctx: Ctx, mode: SheetMode, text: string): boolean { + if (mode === 'edit') { + ctx.dispatch(ctx.state.tr.insertText(text).scrollIntoView()); + return true; + } + return typeOver(ctx, text); +} + +/** Ctrl+' and Ctrl+Shift+": the formula, or the shown value, of the cell above, in Edit mode. */ +function copyAbove(ctx: Ctx, value: boolean): boolean { + const at = activeCell(ctx.state); + if (!at || at.row === 0) return true; + const above = { row: at.row - 1, column: at.column }; + const text = value + ? shownText(ctx.view, ctx.options, above) + : (placedAt(ctx.state.doc, above)?.node.textContent ?? ''); + return startEntry(ctx, at, text, 'edit'); +} + +export function shownText(view: EditorView, options: SheetKeysOptions, at: GridCell): string { + const shown = (options.shown ?? domShown)(view, at); + return shown ?? placedAt(view.state.doc, at)?.node.textContent ?? ''; +} + +/** A smart table draws a formula's result from the cell's data-shown attribute (smart/display.ts). */ +function domShown(view: EditorView, at: GridCell): string | null { + const cell = placedAt(view.state.doc, at); + const dom = cell ? view.nodeDOM(cell.pos) : null; + return dom instanceof HTMLElement ? dom.getAttribute('data-shown') || null : null; +} + +function setSpreadsheet(ctx: Ctx, spreadsheet: boolean): void { + const tr = ctx.state.tr.setMeta(sheetKeysKey, { spreadsheet, entry: null, active: null } satisfies SheetMeta); + const at = activeCell(ctx.state); + const cell = at && placedAt(ctx.state.doc, at); + // Document keys have no Grid mode, so the cells become a caret in the active cell. + if (!spreadsheet && cell && ctx.state.selection instanceof CellSelection) + tr.setSelection(TextSelection.create(tr.doc, textRange(cell).to)); + ctx.dispatch(tr); +} + +type Action = (ctx: Ctx, mode: SheetMode) => boolean; + +const grid = + (action: (ctx: Ctx) => boolean): Action => + (ctx, mode) => + mode === 'grid' ? action(ctx) : false; +const edit = + (action: (ctx: Ctx) => boolean): Action => + (ctx, mode) => + mode === 'edit' ? action(ctx) : false; + +/** The registered commands' actions, by command ID. */ +const ACTIONS: Readonly> = { + 'table.sheet.extendUp': grid((ctx) => extendBy(ctx, -1, 0)), + 'table.sheet.extendDown': grid((ctx) => extendBy(ctx, 1, 0)), + 'table.sheet.extendLeft': grid((ctx) => extendBy(ctx, 0, -1)), + 'table.sheet.extendRight': grid((ctx) => extendBy(ctx, 0, 1)), + 'table.sheet.jumpUp': grid((ctx) => jumpBy(ctx, -1, 0, false)), + 'table.sheet.jumpDown': grid((ctx) => jumpBy(ctx, 1, 0, false)), + 'table.sheet.jumpLeft': grid((ctx) => jumpBy(ctx, 0, -1, false)), + 'table.sheet.jumpRight': grid((ctx) => jumpBy(ctx, 0, 1, false)), + 'table.sheet.extendJumpUp': grid((ctx) => jumpBy(ctx, -1, 0, true)), + 'table.sheet.extendJumpDown': grid((ctx) => jumpBy(ctx, 1, 0, true)), + 'table.sheet.extendJumpLeft': grid((ctx) => jumpBy(ctx, 0, -1, true)), + 'table.sheet.extendJumpRight': grid((ctx) => jumpBy(ctx, 0, 1, true)), + 'table.sheet.firstCell': grid((ctx) => move(ctx, { row: 0, column: 0 })), + 'table.sheet.lastCell': grid((ctx) => move(ctx, lastUsed(ctx.texts))), + 'table.sheet.extendFirstCell': grid((ctx) => extend(ctx, { row: 0, column: 0 })), + 'table.sheet.extendLastCell': grid((ctx) => extend(ctx, lastUsed(ctx.texts))), + 'table.sheet.extendRowStart': grid((ctx) => extend(ctx, { row: gridOf(ctx.state)!.head.row, column: 0 })), + 'table.sheet.screenLeft': grid((ctx) => moveBy(ctx, 0, -pageOf(ctx.view, false))), + 'table.sheet.screenRight': grid((ctx) => moveBy(ctx, 0, pageOf(ctx.view, false))), + 'table.sheet.moveUp': grid((ctx) => advance(ctx, 'column', true)), + 'table.sheet.fillEntry': grid((ctx) => { + const g = gridOf(ctx.state)!; + fill(ctx, cellsIn(g.box), () => g.active); + return true; + }), + 'table.sheet.edit': grid((ctx) => { + const at = activeCell(ctx.state)!; + ctx.options.announce(t('sheetKeys.announce.editing', { address: address(at, hasHeader(ctx.state.doc)) })); + return startEntry(ctx, at, null, 'edit'); + }), + 'table.sheet.clear': grid((ctx) => clearCells(ctx, gridOf(ctx.state)!.box)), + 'table.sheet.clearAndEdit': grid((ctx) => startEntry(ctx, activeCell(ctx.state)!, '', 'enter')), + 'table.sheet.selectColumn': grid((ctx) => { + const g = gridOf(ctx.state)!; + return selectBox(ctx, { ...g.box, top: 0, bottom: ctx.texts.length - 1 }, g.active); + }), + 'table.sheet.selectRegion': grid(selectRegion), + 'table.sheet.fillDown': grid((ctx) => fillAlong(ctx, true)), + 'table.sheet.fillRight': grid((ctx) => fillAlong(ctx, false)), + 'table.sheet.copyFormulaAbove': grid((ctx) => copyAbove(ctx, false)), + 'table.sheet.copyValueAbove': grid((ctx) => copyAbove(ctx, true)), + 'table.sheet.insertDate': (ctx, mode) => + insertStamp(ctx, mode, dateText(todayDays(ctx.options.now?.()), ctx.options.dateOrder?.() ?? 'mdy')), + 'table.sheet.insertTime': (ctx, mode) => + insertStamp(ctx, mode, timeText(ctx.options.now?.() ?? new Date(), ctx.options.locale?.() ?? 'en-US')), + 'table.sheet.commitUp': edit((ctx) => commitBy(ctx, -1, 0)), + 'table.sheet.commitStay': edit((ctx) => commitBy(ctx, 0, 0)), + 'table.sheet.lineBreak': edit((ctx) => { + const hardBreak = ctx.state.schema.nodes.hardBreak; + if (!hardBreak) return false; + ctx.dispatch(ctx.state.tr.replaceSelectionWith(hardBreak.create()).scrollIntoView()); + return true; + }), + 'table.sheet.toggleEnterMode': edit((ctx) => { + const entry = sheetKeysKey.getState(ctx.state)?.entry; + const at = activeCell(ctx.state)!; + const mode = entry?.mode === 'enter' ? 'edit' : 'enter'; + const next: Entry = entry ? { ...entry, mode } : { ...at, before: null, mode }; + ctx.dispatch(ctx.state.tr.setMeta(sheetKeysKey, { entry: next } satisfies SheetMeta)); + ctx.options.announce(t(mode === 'enter' ? 'sheetKeys.announce.enterMode' : 'sheetKeys.announce.editMode')); + return true; + }), +}; + +/** Runs a sheet command on the view. Document keys work in every table; the rest need spreadsheet keys. */ +export function runSheetCommand(view: EditorView, options: SheetKeysOptions, id: CommandId): boolean { + const state = view.state; + const ctx: Ctx = { view, state, dispatch: (tr) => view.dispatch(tr), options, texts: textsOf(state.doc) }; + if (id === 'table.sheet.documentKeys') { + const spreadsheet = !sheetKeysKey.getState(state)?.spreadsheet; + setSpreadsheet(ctx, spreadsheet); + options.setKeys?.(spreadsheet ? 'spreadsheet' : 'document'); + options.announce(t(spreadsheet ? 'sheetKeys.announce.documentKeysOff' : 'sheetKeys.announce.documentKeysOn')); + return true; + } + const mode = modeOf(state); + const action = ACTIONS[id]; + return mode !== null && action !== undefined && action(ctx, mode); +} + +/** Turns spreadsheet keys on or off, such as when another window changed the table's choice. */ +export function setSheetKeys(view: EditorView, spreadsheet: boolean): void { + if (sheetKeysKey.getState(view.state)?.spreadsheet === spreadsheet) return; + const options: SheetKeysOptions = { spreadsheet, announce: () => {} }; + setSpreadsheet({ view, state: view.state, dispatch: (tr) => view.dispatch(tr), options, texts: [] }, spreadsheet); +} diff --git a/app/src/features/tables/keys/active.ts b/app/src/features/tables/keys/active.ts new file mode 100644 index 000000000..44d72408e --- /dev/null +++ b/app/src/features/tables/keys/active.ts @@ -0,0 +1,25 @@ +// The spreadsheet table that keys and the palette act on: the one that had focus last. A light module, so the +// commands can ask it at start-up without loading the editor code. +import type { CommandId } from '../../../commands/types'; + +export interface SheetRunner { + /** Runs a sheet command on this table. False when it does nothing here. */ + run(id: CommandId): boolean; + /** Whether this table uses document keys. */ + documentKeys(): boolean; +} + +let current: SheetRunner | null = null; + +export function activeSheet(): SheetRunner | null { + return current; +} + +export function setActiveSheet(runner: SheetRunner): void { + current = runner; +} + +/** Forgets the runner when it is the active one, such as when its table goes. */ +export function clearActiveSheet(runner: SheetRunner): void { + if (current === runner) current = null; +} diff --git a/app/src/features/tables/keys/announce.test.ts b/app/src/features/tables/keys/announce.test.ts new file mode 100644 index 000000000..0c1b2a2bb --- /dev/null +++ b/app/src/features/tables/keys/announce.test.ts @@ -0,0 +1,35 @@ +// What a screen reader hears in Grid mode (DESIGN.md section 17). The expected sentences are the plan's. +import { describe, expect, it } from 'vitest'; +import { cellAnnouncement, formulaWords, rangeAnnouncement } from './announce'; + +describe('cell announcements', () => { + it('reads the address, header, value, and formula', () => { + const said = cellAnnouncement({ address: 'C4', header: 'Total', shown: '10', formula: '=B4*2' }); + expect(said).toBe('C4, Total, 10, formula equals B4 times 2'); + }); + + it('leaves out what a cell does not have', () => { + expect(cellAnnouncement({ address: 'C4', header: 'Total', shown: '10', formula: null })).toBe('C4, Total, 10'); + expect(cellAnnouncement({ address: 'C4', header: null, shown: '', formula: null })).toBe('C4, blank'); + expect(cellAnnouncement({ address: 'C4', header: ' ', shown: '3', formula: '=1+2' })).toBe( + 'C4, 3, formula equals 1 plus 2', + ); + expect(cellAnnouncement({ address: 'C', header: 'Total', shown: 'Total', formula: null, isHeader: true })).toBe( + 'Column C header, Total', + ); + }); + + it('reads a range', () => { + expect(rangeAnnouncement('B2', 'C3', 4)).toBe('B2 through C3 selected, 4 cells'); + }); +}); + +describe('formulas as words', () => { + it('reads every operator, the longest first, and leaves quoted text alone', () => { + expect(formulaWords('=SUM(B2:B4)/2')).toBe('equals SUM(B2 through B4) divided by 2'); + expect(formulaWords('=A1<=3')).toBe('equals A1 is less than or equal to 3'); + expect(formulaWords('=A1<>B1')).toBe('equals A1 is not equal to B1'); + expect(formulaWords('=A1&" + "&B1')).toBe('equals A1 joined with " + " joined with B1'); + expect(formulaWords('=-A1^2%')).toBe('equals minus A1 to the power of 2 percent'); + }); +}); diff --git a/app/src/features/tables/keys/announce.ts b/app/src/features/tables/keys/announce.ts new file mode 100644 index 000000000..88915c187 --- /dev/null +++ b/app/src/features/tables/keys/announce.ts @@ -0,0 +1,75 @@ +// What a screen reader hears in a table's Grid mode (DESIGN.md section 17): the address, the column header, the +// shown value, and "formula" with the formula read as words. "C4, Total, 10, formula equals B4 times 2". +import { t } from '../../../strings/t'; +import type { MessageKey } from '../../../strings/t'; + +const QUOTED = /("(?:[^"]|"")*")/; +// Longest first, so "<=" is read before "<". +const OPERATORS: readonly (readonly [RegExp, MessageKey])[] = [ + [/<>/g, 'sheetKeys.words.notEqual'], + [/<=/g, 'sheetKeys.words.lessOrEqual'], + [/>=/g, 'sheetKeys.words.greaterOrEqual'], + [//g, 'sheetKeys.words.greater'], + [/=/g, 'sheetKeys.words.equals'], + [/\+/g, 'sheetKeys.words.plus'], + [/-/g, 'sheetKeys.words.minus'], + [/\*/g, 'sheetKeys.words.times'], + [/\//g, 'sheetKeys.words.dividedBy'], + [/\^/g, 'sheetKeys.words.power'], + [/&/g, 'sheetKeys.words.join'], + [/%/g, 'sheetKeys.words.percent'], + [/:/g, 'sheetKeys.words.through'], +]; + +/** + * A formula read as words: "=B4*2" is "equals B4 times 2" and "=SUM(B2:B4)" is "equals SUM(B2 through B4)". Text + * in quotes is read as it is. + */ +export function formulaWords(formula: string): string { + return formula + .split(QUOTED) + .map((part, i) => { + if (i % 2 === 1) return part; + // Each operator becomes a private-use mark first, so a word never holds another operator's sign. + const words: string[] = []; + let marked = part; + for (const [sign, key] of OPERATORS) { + marked = marked.replace(sign, () => ` \u{E000}${words.push(t(key)) - 1}\u{E001} `); + } + return marked.replace(/\u{E000}(\d+)\u{E001}/gu, (_, n: string) => words[Number(n)]); + }) + .join('') + .replace(/\s+/g, ' ') + .trim(); +} + +export interface CellSpeech { + /** "C4", or the column letter alone in the header row. */ + address: string; + /** The column's header text, or null when the table has no header row. */ + header: string | null; + /** What the cell shows: a formula's result, or its text. */ + shown: string; + /** The formula, with its "=", when the cell holds one. */ + formula: string | null; + /** Whether the cell is in the header row. */ + isHeader?: boolean; +} + +/** The announcement for a cell that became the active cell. */ +export function cellAnnouncement(cell: CellSpeech): string { + const value = cell.shown.trim() === '' ? t('sheetKeys.announce.blank') : cell.shown.trim(); + if (cell.isHeader) return t('sheetKeys.announce.headerCell', { address: cell.address, value }); + const header = cell.header?.trim() || null; + const formula = cell.formula ? formulaWords(cell.formula) : null; + if (header && formula) return t('sheetKeys.announce.cellFormula', { address: cell.address, header, value, formula }); + if (header) return t('sheetKeys.announce.cell', { address: cell.address, header, value }); + if (formula) return t('sheetKeys.announce.cellNoHeaderFormula', { address: cell.address, value, formula }); + return t('sheetKeys.announce.cellNoHeader', { address: cell.address, value }); +} + +/** The announcement for a range: "B2 through C3 selected, 4 cells". */ +export function rangeAnnouncement(from: string, to: string, count: number): string { + return t('sheetKeys.announce.range', { from, to, count }); +} diff --git a/app/src/features/tables/keys/attach.ts b/app/src/features/tables/keys/attach.ts new file mode 100644 index 000000000..597a09148 --- /dev/null +++ b/app/src/features/tables/keys/attach.ts @@ -0,0 +1,39 @@ +// Gives a table block's editor its spreadsheet keys (ADR 0063). The table extra loads this when the block's editor +// mounts. The table's choice of keys comes from its data, and the Document keys command stores a new one. +import type { TableExtraHandle, TableExtraHost } from '../../page'; +import { appLocale } from '../smart/locale'; +import { DOCUMENT_KEYS_COMMAND } from './bindings'; +import { keysOf, keysPatch, storedKeys } from './mode'; +import { runSheetCommand, setSheetKeys, sheetKeysKey, sheetKeysPlugin, textsOf } from './plugin'; +import type { SheetKeysOptions } from './plugin'; +import { keysTip } from './tip'; + +export function attachSheetKeys(host: TableExtraHost): TableExtraHandle { + const { editor } = host; + const texts = textsOf(editor.state.doc); + const keys = keysOf(host.data(), texts); + const options: SheetKeysOptions = { + spreadsheet: keys === 'spreadsheet', + announce: (text) => host.announce(text), + setKeys: (next) => void host.patch(keysPatch(next)), + dateOrder: () => appLocale().dateOrder, + locale: () => appLocale().tag, + }; + const plugin = sheetKeysPlugin(options); + // First, so Grid mode's keys come before the table's text keys and the editor's own. + editor.registerPlugin(plugin, (added, plugins) => [added, ...plugins]); + keysTip(host.data(), texts, () => { + // The command switches the keys, so it runs only while the table still has spreadsheet keys. + if (!editor.isDestroyed && sheetKeysKey.getState(editor.state)?.spreadsheet) + runSheetCommand(editor.view, options, DOCUMENT_KEYS_COMMAND); + }); + return { + update(block) { + const stored = storedKeys(block.data); + if (stored && !editor.isDestroyed) setSheetKeys(editor.view, stored === 'spreadsheet'); + }, + destroy() { + if (!editor.isDestroyed) editor.unregisterPlugin(sheetKeysKey); + }, + }; +} diff --git a/app/src/features/tables/keys/bindings.test.ts b/app/src/features/tables/keys/bindings.test.ts new file mode 100644 index 000000000..73c55172b --- /dev/null +++ b/app/src/features/tables/keys/bindings.test.ts @@ -0,0 +1,18 @@ +// The key scope fixture lists the registered sheet commands. So commands/scopes.test.ts checks the real chords +// against the text and code programs. This test keeps the fixture in step with bindings.ts. +import { describe, expect, it } from 'vitest'; +import { tablesScopes } from '../../../commands/fixtures/scopes/tables'; +import { SHEET_KEYS } from './bindings'; + +describe('the tables key scope fixture', () => { + it('lists every sheet command that has a chord, with its scope, chords, and refined command', () => { + const listed = tablesScopes.commands.filter((command) => command.id.startsWith('table.sheet.')); + const registered = SHEET_KEYS.filter((def) => def.keys.length > 0).map((def) => ({ + id: def.id, + scope: def.scope, + keys: def.keys, + ...(def.refines ? { refines: def.refines } : {}), + })); + expect(listed).toEqual(registered); + }); +}); diff --git a/app/src/features/tables/keys/bindings.ts b/app/src/features/tables/keys/bindings.ts new file mode 100644 index 000000000..b29075c22 --- /dev/null +++ b/app/src/features/tables/keys/bindings.ts @@ -0,0 +1,110 @@ +// The spreadsheet keys of a table (DESIGN.md section 11.2, ADR 0063), as data. Chords that a person may change +// are commands in the one command registry, scoped to Grid mode (tableGrid), Edit mode (tableEdit), or both +// (editor.table). The navigation keys that reserved.ts keeps free are fixed, as in every other editor. +// +// Keys that feature lanes own are not here. They register their own commands in the same scopes: +// - Ctrl+1: format-dialog. F4, Alt+=, Shift+F3, and F9: formula-bar and the engine. +// - Number format and border chords: toolbar. Ctrl+9, Ctrl+0, and the insert and delete chords: structure. +// - Ctrl+Shift+L: data-tools. Shift+F2: notes. The paste chords: clipboard. +// Ctrl+5 is not here: Ctrl+1 to Ctrl+9 stay free for tags, and +// Ctrl+B, Ctrl+I, Ctrl+U, and the strikethrough command already format the text of selected cells. +import type { CommandId, KeyScope } from '../../../commands/types'; +import type { MessageKey } from '../../../strings/t'; + +export type SheetScope = Extract; + +export interface SheetKeyDef { + id: CommandId; + title: MessageKey; + keys: readonly string[]; + scope: SheetScope; + /** Shown in the command palette. Moves are not; fills, dates, and the mode switch are. */ + palette?: boolean; + /** The less specific command that shares the chord outside this mode (AMENDMENTS.md P2-2). */ + refines?: CommandId; +} + +const def = ( + name: string, + keys: readonly string[], + scope: SheetScope, + palette = false, + refines?: CommandId, +): SheetKeyDef => ({ + id: `table.sheet.${name}`, + title: `sheetKeys.commands.${name}` as MessageKey, + keys, + scope, + palette, + ...(refines ? { refines } : {}), +}); + +const GRID = 'tableGrid'; +const EDIT = 'tableEdit'; +const BOTH = 'editor.table'; + +export const SHEET_KEYS: readonly SheetKeyDef[] = [ + def('extendUp', ['Shift+Up'], GRID), + def('extendDown', ['Shift+Down'], GRID), + def('extendLeft', ['Shift+Left'], GRID), + def('extendRight', ['Shift+Right'], GRID), + def('jumpUp', ['Ctrl+Up'], GRID), + def('jumpDown', ['Ctrl+Down'], GRID), + def('jumpLeft', ['Ctrl+Left'], GRID), + def('jumpRight', ['Ctrl+Right'], GRID), + def('extendJumpUp', ['Ctrl+Shift+Up'], GRID), + def('extendJumpDown', ['Ctrl+Shift+Down'], GRID), + def('extendJumpLeft', ['Ctrl+Shift+Left'], GRID), + def('extendJumpRight', ['Ctrl+Shift+Right'], GRID), + def('firstCell', ['Ctrl+Home'], GRID), + def('lastCell', ['Ctrl+End'], GRID), + def('extendFirstCell', ['Ctrl+Shift+Home'], GRID), + def('extendLastCell', ['Ctrl+Shift+End'], GRID), + def('extendRowStart', ['Shift+Home'], GRID), + def('screenLeft', ['Alt+PageUp'], GRID), + def('screenRight', ['Alt+PageDown'], GRID), + def('moveUp', ['Shift+Enter'], GRID), + def('fillEntry', ['Ctrl+Enter'], GRID, false, 'table.rowBelow'), + def('edit', ['F2'], GRID), + def('clear', ['Delete'], GRID), + def('clearAndEdit', ['Backspace'], GRID), + def('selectColumn', ['Ctrl+Space'], GRID, false, 'format.clearCharacter'), + def('selectRegion', ['Ctrl+A'], GRID), + def('fillDown', ['Ctrl+D'], GRID, true, 'table.fillDown'), + def('fillRight', ['Ctrl+R'], GRID, true, 'table.fillRight'), + def('copyFormulaAbove', ["Ctrl+'"], GRID), + def('copyValueAbove', ['Ctrl+"'], GRID), + def('insertDate', ['Ctrl+;'], BOTH, true), + // Ctrl+Shift+; types ":" on a US keyboard, so the chord names the typed character. + def('insertTime', ['Ctrl+:'], BOTH, true), + def('commitUp', ['Shift+Enter'], EDIT), + def('commitStay', ['Ctrl+Enter'], EDIT, false, 'table.rowBelow'), + def('lineBreak', ['Alt+Enter'], EDIT), + def('toggleEnterMode', ['F2'], EDIT), + def('documentKeys', [], BOTH, true), +]; + +/** The command that turns document keys on and off for the table with focus. */ +export const DOCUMENT_KEYS_COMMAND: CommandId = 'table.sheet.documentKeys'; + +export interface SheetFixedRow { + readonly id: string; + /** Chords in the canonical form, or a message for keys that aren't one. */ + readonly keys: readonly string[] | MessageKey; + readonly action: MessageKey; +} + +/** The fixed keys of a spreadsheet table, for the shortcut list. The plugin handles them itself. */ +export const SHEET_FIXED_KEYS: readonly SheetFixedRow[] = [ + { id: 'gridArrows', keys: ['Up', 'Down', 'Left', 'Right'], action: 'sheetKeys.fixed.gridArrows' }, + { id: 'gridEnter', keys: ['Enter'], action: 'sheetKeys.fixed.gridEnter' }, + { id: 'gridTab', keys: ['Tab', 'Shift+Tab'], action: 'sheetKeys.fixed.gridTab' }, + { id: 'gridHome', keys: ['Home'], action: 'sheetKeys.fixed.gridHome' }, + { id: 'gridPage', keys: ['PageUp', 'PageDown'], action: 'sheetKeys.fixed.gridPage' }, + { id: 'gridEscape', keys: ['Escape'], action: 'sheetKeys.fixed.gridEscape' }, + { id: 'gridSpace', keys: ['Shift+Space'], action: 'sheetKeys.fixed.gridSpace' }, + { id: 'gridType', keys: 'sheetKeys.fixed.typeKey', action: 'sheetKeys.fixed.gridType' }, + { id: 'editEnter', keys: ['Enter'], action: 'sheetKeys.fixed.editEnter' }, + { id: 'editEscape', keys: ['Escape'], action: 'sheetKeys.fixed.editEscape' }, + { id: 'editArrows', keys: ['Up', 'Down', 'Left', 'Right'], action: 'sheetKeys.fixed.editArrows' }, +]; diff --git a/app/src/features/tables/keys/commands.test.ts b/app/src/features/tables/keys/commands.test.ts new file mode 100644 index 000000000..b9ca63b2f --- /dev/null +++ b/app/src/features/tables/keys/commands.test.ts @@ -0,0 +1,80 @@ +// @vitest-environment jsdom +// The sheet commands in the registry (ADR 0063): Grid mode's Ctrl+D comes before the page's, applies only to a +// table with spreadsheet keys that had focus, leaves Ctrl+K to the palette, and follows the person's own chord. +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { initFlags } from '../../../app/flags'; +import { commandForKey } from '../../../commands/dispatcher'; +import { chord, configureCommands, defineCommand } from '../../../commands/registry'; +import type { CommandDef } from '../../../commands/types'; +import { commands } from '../../../registries'; +import { createMemoryNotesService } from '../../../services/notes/memory'; +import { setShortcut } from '../../../state/keymap'; +import { resetStores } from '../../../state/store'; +import { createTestPlatform } from '../../../test/platform'; +import { activeSheet, clearActiveSheet, setActiveSheet } from './active'; +import type { SheetRunner } from './active'; +import { registerSheetKeyCommands } from './commands'; + +const stops: (() => void)[] = []; +const ran: string[] = []; +let documentKeys = false; +const sheet: SheetRunner = { run: (id) => (ran.push(id), true), documentKeys: () => documentKeys }; + +function add(id: string, rest: Partial) { + stops.push( + commands.register( + defineCommand({ id: `test.${id}`, title: 'theme.commands.toggle', category: 'general', run() {}, ...rest }), + ), + ); +} + +beforeAll(() => { + initFlags('dev'); + configureCommands({ platform: createTestPlatform(), notes: createMemoryNotesService({ seed: 'sample' }) }); +}); + +afterEach(() => { + stops.splice(0).forEach((stop) => stop()); + clearActiveSheet(sheet); + documentKeys = false; + resetStores(); +}); + +function press(scope: string, key: string, code: string, mods: string) { + document.body.innerHTML = `
`; + const target = document.querySelector('[contenteditable]'); + const has = (name: string) => mods.includes(name); + const event = { key, code, ctrlKey: has('ctrl'), altKey: has('alt'), shiftKey: false, metaKey: false }; + return commandForKey({ ...event, isComposing: false, repeat: false, target })?.id ?? null; +} + +const GRID = 'editor editor.table tableGrid'; +const EDIT = 'editor editor.table tableEdit'; + +describe('the sheet commands', () => { + it('run in Grid mode before the page, only for a spreadsheet table with focus', () => { + stops.push(registerSheetKeyCommands()); + add('pageFill', { keys: [chord('Ctrl+D')], scope: 'page', allowInTextInput: true }); + add('palette', { keys: [chord('Ctrl+K')], allowInTextInput: true }); + expect(press(GRID, 'd', 'KeyD', 'ctrl')).toBe('test.pageFill'); + setActiveSheet(sheet); + expect(activeSheet()).toBe(sheet); + expect(press(GRID, 'd', 'KeyD', 'ctrl')).toBe('table.sheet.fillDown'); + expect(press(EDIT, 'd', 'KeyD', 'ctrl')).toBe('test.pageFill'); + expect(press(GRID, 'k', 'KeyK', 'ctrl')).toBe('test.palette'); + expect(press(EDIT, ';', 'Semicolon', 'ctrl')).toBe('table.sheet.insertDate'); + documentKeys = true; + expect(press(GRID, 'd', 'KeyD', 'ctrl')).toBe('test.pageFill'); + }); + + it('follow the chord the person gives them', async () => { + stops.push(registerSheetKeyCommands()); + add('pageFill', { keys: [chord('Ctrl+D')], scope: 'page', allowInTextInput: true }); + setActiveSheet(sheet); + await setShortcut('table.sheet.fillDown', [chord('Ctrl+Alt+D')]); + expect(press(GRID, 'd', 'KeyD', 'ctrl')).toBe('test.pageFill'); + expect(press(GRID, 'd', 'KeyD', 'ctrl alt')).toBe('table.sheet.fillDown'); + void commands.get('table.sheet.fillDown')?.run({} as never, undefined); + expect(ran).toEqual(['table.sheet.fillDown']); + }); +}); diff --git a/app/src/features/tables/keys/commands.ts b/app/src/features/tables/keys/commands.ts new file mode 100644 index 000000000..333ecf529 --- /dev/null +++ b/app/src/features/tables/keys/commands.ts @@ -0,0 +1,70 @@ +// The spreadsheet keys' commands (ADR 0063): one per changeable chord, and the table menu's Document keys item. +// Each runs on the table that had focus last (active.ts). Behind the flag tables.sheetWrites, so Stable keeps +// today's table keys. +import { chord } from '../../../commands/chords'; +import { commands } from '../../../registries'; +import type { CommandDef } from '../../../commands/types'; +import { contribute } from '../sheet/registry'; +import type { SheetContribution } from '../sheet/registry'; +import { activeSheet } from './active'; +import { DOCUMENT_KEYS_COMMAND, SHEET_KEYS } from './bindings'; +import type { SheetKeyDef } from './bindings'; + +export const SHEET_KEYS_FLAG = 'tables.sheetWrites' as const; +const FLAG = SHEET_KEYS_FLAG; + +/** A sheet command applies to the table that had focus last, and only while it uses spreadsheet keys. */ +function applies(id: string): boolean { + const sheet = activeSheet(); + return sheet !== null && (id === DOCUMENT_KEYS_COMMAND || !sheet.documentKeys()); +} + +/** + * Add row below (Ctrl+Enter) registers when a table first loads, and it refines Check or uncheck. Until it exists, + * the Ctrl+Enter commands refine Check or uncheck themselves, so the chord is shared in every state of the registry. + */ +function withRefines(def: CommandDef, refines: SheetKeyDef['refines']): CommandDef { + if (refines !== 'table.rowBelow') return refines ? { ...def, refines } : def; + return Object.defineProperty(def, 'refines', { + enumerable: true, + get: () => (commands.get('table.rowBelow') ? 'table.rowBelow' : 'block.toggleCheck'), + }); +} + +function commandOf(key: SheetKeyDef): CommandDef { + return withRefines( + { + id: key.id, + title: key.title, + keywords: 'sheetKeys.keywords', + category: 'table', + keys: key.keys.map(chord), + scope: key.scope, + allowInTextInput: true, + palette: key.palette, + flag: FLAG, + when: () => applies(key.id), + ...(key.id === DOCUMENT_KEYS_COMMAND ? { checked: () => activeSheet()?.documentKeys() ?? false } : {}), + run: () => { + activeSheet()?.run(key.id); + }, + }, + key.refines, + ); +} + +const items: SheetContribution[] = SHEET_KEYS.map((key) => ({ + kind: 'command', + lane: 'keys', + command: commandOf(key), +})); +items.push({ + kind: 'contextMenu', + lane: 'keys', + item: { id: DOCUMENT_KEYS_COMMAND, command: DOCUMENT_KEYS_COMMAND, group: 'table', order: 50, flag: FLAG }, +}); + +/** Registers the commands. Returns a function that takes them back. */ +export function registerSheetKeyCommands(): () => void { + return contribute(...items); +} diff --git a/app/src/features/tables/keys/grid.test.ts b/app/src/features/tables/keys/grid.test.ts new file mode 100644 index 000000000..6cc1fcdd7 --- /dev/null +++ b/app/src/features/tables/keys/grid.test.ts @@ -0,0 +1,83 @@ +// The grid's spreadsheet moves, against hand-worked grids. Expected cells follow the documented behavior of +// Ctrl+arrow, Ctrl+End, Ctrl+A, and Enter in a selection (DESIGN.md section 11.2), worked out by hand. +import { describe, expect, it } from 'vitest'; +import { address, boxOf, cycle, jump, lastUsed, region, step } from './grid'; + +// A 5 by 5 grid with data in A1:C3. +const DATA = [ + ['1', '2', '3', '', ''], + ['4', '5', '6', '', ''], + ['7', '8', '9', '', ''], + ['', '', '', '', ''], + ['', '', '', '', ''], +]; + +const A1 = { row: 0, column: 0 }; + +describe('the data edges', () => { + it('jumps to the end of a run of data, then to the grid edge past it', () => { + expect(jump(DATA, A1, 1, 0)).toEqual({ row: 2, column: 0 }); + expect(jump(DATA, A1, 0, 1)).toEqual({ row: 0, column: 2 }); + expect(jump(DATA, { row: 2, column: 0 }, 1, 0)).toEqual({ row: 4, column: 0 }); + expect(jump(DATA, { row: 4, column: 4 }, -1, 0)).toEqual({ row: 0, column: 4 }); + }); + + it('jumps from an empty cell to the next filled one', () => { + const gaps = [['a', '', '', 'b', 'c']]; + expect(jump(gaps, { row: 0, column: 0 }, 0, 1)).toEqual({ row: 0, column: 3 }); + expect(jump(gaps, { row: 0, column: 1 }, 0, 1)).toEqual({ row: 0, column: 3 }); + expect(jump(gaps, { row: 0, column: 3 }, 0, 1)).toEqual({ row: 0, column: 4 }); + expect(jump(gaps, { row: 0, column: 4 }, 0, 1)).toEqual({ row: 0, column: 4 }); + }); + + it('finds the last used cell and the region around a cell', () => { + expect(lastUsed(DATA)).toEqual({ row: 2, column: 2 }); + expect( + lastUsed([ + ['', ''], + ['', ''], + ]), + ).toEqual(A1); + expect(region(DATA, { row: 1, column: 1 })).toEqual({ top: 0, left: 0, bottom: 2, right: 2 }); + expect(region(DATA, { row: 4, column: 4 })).toEqual({ top: 4, left: 4, bottom: 4, right: 4 }); + // A diagonal neighbor joins the region. + expect( + region( + [ + ['a', ''], + ['', 'b'], + ], + A1, + ), + ).toEqual({ top: 0, left: 0, bottom: 1, right: 1 }); + }); + + it('keeps a step inside the grid', () => { + expect(step(DATA, A1, -1, 0)).toEqual(A1); + expect(step(DATA, { row: 4, column: 4 }, 3, 3)).toEqual({ row: 4, column: 4 }); + }); +}); + +describe('a range', () => { + const box = boxOf({ row: 1, column: 1 }, { row: 2, column: 2 }); + + it('goes down each column with Enter, then on to the next column, and wraps', () => { + const order = [{ row: 1, column: 1 }]; + for (let i = 0; i < 4; i++) order.push(cycle(box, order[order.length - 1], 'column')); + expect(order.map((at) => address(at, false))).toEqual(['B2', 'B3', 'C2', 'C3', 'B2']); + expect(address(cycle(box, { row: 1, column: 1 }, 'column', true), false)).toBe('C3'); + }); + + it('goes along each row with Tab', () => { + expect(address(cycle(box, { row: 1, column: 1 }, 'row'), false)).toBe('C2'); + expect(address(cycle(box, { row: 1, column: 2 }, 'row'), false)).toBe('B3'); + }); +}); + +describe('addresses', () => { + it('numbers rows from 1, under the header row when there is one', () => { + expect(address({ row: 3, column: 2 }, false)).toBe('C4'); + expect(address({ row: 4, column: 2 }, true)).toBe('C4'); + expect(address({ row: 0, column: 27 }, true)).toBe('AB'); + }); +}); diff --git a/app/src/features/tables/keys/grid.ts b/app/src/features/tables/keys/grid.ts new file mode 100644 index 000000000..212655afb --- /dev/null +++ b/app/src/features/tables/keys/grid.ts @@ -0,0 +1,154 @@ +// The spreadsheet moves of a table's grid (ADR 0063), as pure functions of the cells' text. Ctrl+arrows jump to +// the edge of the data, Ctrl+End finds the last used cell, Ctrl+A finds the current region, and Enter and Tab +// cycle inside a range. A cell is filled when its text has anything but spaces. +import { lettersFromColumn } from '../../../core/expr'; + +/** A cell of the grid, counted from 0. Row 0 is the header row when the table has one. */ +export interface GridCell { + row: number; + column: number; +} + +/** A block of cells, inclusive on every side. */ +export interface Box { + top: number; + left: number; + bottom: number; + right: number; +} + +/** The text of every cell, row by row. Rows may be short; a missing cell is empty. */ +export type Texts = readonly (readonly string[])[]; + +export const heightOf = (texts: Texts): number => texts.length; +export const widthOf = (texts: Texts): number => Math.max(0, ...texts.map((row) => row.length)); + +const filled = (texts: Texts, row: number, column: number): boolean => (texts[row]?.[column] ?? '').trim() !== ''; + +const inside = (texts: Texts, row: number, column: number): boolean => + row >= 0 && column >= 0 && row < heightOf(texts) && column < widthOf(texts); + +/** The box with `a` and `b` at opposite corners. */ +export function boxOf(a: GridCell, b: GridCell): Box { + return { + top: Math.min(a.row, b.row), + left: Math.min(a.column, b.column), + bottom: Math.max(a.row, b.row), + right: Math.max(a.column, b.column), + }; +} + +export const boxSize = (box: Box): number => (box.bottom - box.top + 1) * (box.right - box.left + 1); + +export const inBox = (box: Box, at: GridCell): boolean => + at.row >= box.top && at.row <= box.bottom && at.column >= box.left && at.column <= box.right; + +/** One step in a direction, kept inside the grid. */ +export function step(texts: Texts, from: GridCell, rows: number, columns: number): GridCell { + const row = Math.min(Math.max(from.row + rows, 0), Math.max(heightOf(texts) - 1, 0)); + const column = Math.min(Math.max(from.column + columns, 0), Math.max(widthOf(texts) - 1, 0)); + return { row, column }; +} + +/** + * Ctrl+arrow: the edge of the data in a direction. Inside a run of filled cells it stops at the run's last cell. + * From an empty cell, or from the last cell of a run, it stops at the next filled cell, or at the grid's edge. + */ +export function jump(texts: Texts, from: GridCell, rows: -1 | 0 | 1, columns: -1 | 0 | 1): GridCell { + let { row, column } = from; + if (!inside(texts, row + rows, column + columns)) return { row, column }; + if (filled(texts, row, column) && filled(texts, row + rows, column + columns)) { + while (inside(texts, row + rows, column + columns) && filled(texts, row + rows, column + columns)) { + row += rows; + column += columns; + } + return { row, column }; + } + row += rows; + column += columns; + while (!filled(texts, row, column) && inside(texts, row + rows, column + columns)) { + row += rows; + column += columns; + } + return { row, column }; +} + +/** Ctrl+End: the last row that has data, in the last column that has data. A1 when the grid is empty. */ +export function lastUsed(texts: Texts): GridCell { + let row = 0; + let column = 0; + texts.forEach((line, r) => + line.forEach((_, c) => { + if (!filled(texts, r, c)) return; + row = Math.max(row, r); + column = Math.max(column, c); + }), + ); + return { row, column }; +} + +/** Whether any cell of the box is filled. */ +function anyFilled(texts: Texts, box: Box): boolean { + for (let r = Math.max(box.top, 0); r <= box.bottom; r++) + for (let c = Math.max(box.left, 0); c <= box.right; c++) if (filled(texts, r, c)) return true; + return false; +} + +/** + * Ctrl+A: the current region, the block of data around a cell up to empty rows and columns, diagonals included. + * An empty cell with no data next to it is a region of one cell. + */ +export function region(texts: Texts, at: GridCell): Box { + const box: Box = { top: at.row, left: at.column, bottom: at.row, right: at.column }; + const last = { row: heightOf(texts) - 1, column: widthOf(texts) - 1 }; + for (let grew = true; grew;) { + grew = false; + const wide = { left: Math.max(box.left - 1, 0), right: Math.min(box.right + 1, last.column) }; + const tall = { top: Math.max(box.top - 1, 0), bottom: Math.min(box.bottom + 1, last.row) }; + if (box.top > 0 && anyFilled(texts, { ...wide, top: box.top - 1, bottom: box.top - 1 })) { + box.top--; + grew = true; + } + if (box.bottom < last.row && anyFilled(texts, { ...wide, top: box.bottom + 1, bottom: box.bottom + 1 })) { + box.bottom++; + grew = true; + } + if (box.left > 0 && anyFilled(texts, { ...tall, left: box.left - 1, right: box.left - 1 })) { + box.left--; + grew = true; + } + if (box.right < last.column && anyFilled(texts, { ...tall, left: box.right + 1, right: box.right + 1 })) { + box.right++; + grew = true; + } + } + return box; +} + +/** + * Enter and Tab inside a range: the next cell of the box, wrapping. Enter goes down each column, then on to the + * next column; Tab goes along each row, then on to the next row. `back` is Shift+Enter and Shift+Tab. + */ +export function cycle(box: Box, from: GridCell, by: 'column' | 'row', back = false): GridCell { + const rows = box.bottom - box.top + 1; + const columns = box.right - box.left + 1; + const total = rows * columns; + const index = + by === 'column' + ? (from.column - box.left) * rows + (from.row - box.top) + : (from.row - box.top) * columns + (from.column - box.left); + const next = (index + (back ? -1 : 1) + total) % total; + return by === 'column' + ? { row: box.top + (next % rows), column: box.left + Math.floor(next / rows) } + : { row: box.top + Math.floor(next / columns), column: box.left + (next % columns) }; +} + +/** + * The cell's address, as formulas name it: "C4". With a header row, the header row has no number and the first + * row under it is row 1, so formulas see the data as a sheet does. The header row's address is the column alone. + */ +export function address(at: GridCell, header: boolean): string { + const letters = lettersFromColumn(at.column); + if (header && at.row === 0) return letters; + return `${letters}${header ? at.row : at.row + 1}`; +} diff --git a/app/src/features/tables/keys/keys.module.css b/app/src/features/tables/keys/keys.module.css new file mode 100644 index 000000000..1dea8d978 --- /dev/null +++ b/app/src/features/tables/keys/keys.module.css @@ -0,0 +1,109 @@ +/* + * The active cell of a table with spreadsheet keys (ADR 0063). Grid mode draws a thick outline inside the cell, Edit + * mode a thin one, so the mode shows without color alone. Outlines stay visible in forced colors. + */ +@layer components { + /* Settings, then Tables. */ + .settings { + display: grid; + gap: var(--space-4); + max-inline-size: var(--size-reading-measure); + } + + .settings h2 { + margin: 0; + font-size: var(--type-subtitle-size); + line-height: var(--type-subtitle-line); + } + + .help { + margin: 0; + color: var(--color-text-secondary); + font-size: var(--type-small-size); + line-height: var(--type-small-line); + } + + .active { + outline: calc(var(--size-focus-ring-width) * 1.5) solid var(--color-focus-ring); + outline-offset: calc(var(--size-focus-ring-width) * -1.5); + } + + .editing { + outline: 1px solid var(--color-focus-ring); + outline-offset: -1px; + } + + @media (forced-colors: active) { + .active, + .editing { + outline-color: Highlight; + } + } +} + +/* The Table keys section of the shortcut list, drawn like the list's own tables (ShortcutList.module.css). */ +@layer components { + .note { + margin-block: 0 var(--space-2); + color: var(--color-text-secondary); + } + + .table { + inline-size: 100%; + border-collapse: collapse; + } + + .table th, + .table td { + padding: var(--space-2) var(--space-3); + border-block-end: 1px solid var(--color-border-subtle); + text-align: start; + vertical-align: middle; + } + + .table th:first-child, + .table td:first-child { + padding-inline-start: 0; + } + + .name { + font-weight: var(--font-weight-regular); + } + + .or { + margin-inline: var(--space-2); + color: var(--color-text-secondary); + } + + .kbd { + padding: var(--space-1) var(--space-3); + border: 1px solid var(--color-border-control); + border-radius: var(--radius-sm); + background: var(--color-surface-sunken); + color: var(--color-text-primary); + font-family: var(--font-ui); + white-space: nowrap; + } + + /* In a window under 30rem the two columns cannot both fit beside the key caps, so each row stacks: the name, then + the keys. */ + @media (max-width: 30rem) { + .table tbody, + .table tr, + .table th, + .table td { + display: block; + } + + .table tr { + padding-block: var(--space-2); + border-block-end: 1px solid var(--color-border-subtle); + } + + .table th, + .table td { + padding: var(--space-1) 0; + border-block-end: 0; + } + } +} diff --git a/app/src/features/tables/keys/keys.ts b/app/src/features/tables/keys/keys.ts new file mode 100644 index 000000000..a52f54eab --- /dev/null +++ b/app/src/features/tables/keys/keys.ts @@ -0,0 +1,159 @@ +// The keys of Grid mode and Edit mode (ADR 0063). Chords a person may change come from the command registry, so +// rebinding works; the navigation keys that reserved.ts keeps free are fixed here. Typing in Grid mode replaces the +// active cell. +import type { EditorState } from '@tiptap/pm/state'; +import type { EditorView } from '@tiptap/pm/view'; +import { isEnabled } from '../../../app/flags'; +import { chordMatches } from '../../../commands/chords'; +import { commandsForChord } from '../../../commands/keymap'; +import type { CommandId } from '../../../commands/types'; +import { cancel, runSheetCommand, typeOver } from './actions'; +import { SHEET_KEYS } from './bindings'; +import type { SheetScope } from './bindings'; +import { boxSize } from './grid'; +import { advance, commitBy, move, moveBy, nextInRow, pageOf, selectBox } from './moves'; +import type { Ctx } from './moves'; +import { activeCell, gridOf, modeOf, sheetKeysKey, textsOf } from './state'; +import type { SheetKeysOptions, SheetMode } from './state'; + +const SCOPES: Readonly> = { + grid: ['tableGrid', 'editor.table'], + edit: ['tableEdit', 'editor.table'], +}; +const SHEET_IDS = new Set(SHEET_KEYS.map((key) => key.id)); + +/** The sheet command a key press names in this mode, with the person's own chords. */ +function commandFor(event: KeyboardEvent, mode: SheetMode): CommandId | null { + for (const { chord } of chordMatches(event)) { + const def = commandsForChord(chord).find( + (one) => + SHEET_IDS.has(one.id) && SCOPES[mode].includes(one.scope as SheetScope) && (!one.flag || isEnabled(one.flag)), + ); + if (def) return def.id; + } + return null; +} + +/** A key press that types a character: no Ctrl, Alt, or Meta, unless AltGr makes it. */ +function typedText(event: KeyboardEvent): string | null { + if (event.isComposing || [...event.key].length !== 1) return null; + const altGraph = event.getModifierState?.('AltGraph') ?? false; + if (event.metaKey || ((event.ctrlKey || event.altKey) && !altGraph)) return null; + return event.key; +} + +const ARROWS: Readonly> = { + ArrowUp: [-1, 0], + ArrowDown: [1, 0], + ArrowLeft: [0, -1], + ArrowRight: [0, 1], +}; + +const plain = (event: KeyboardEvent) => !event.ctrlKey && !event.altKey && !event.metaKey; + +/** The fixed keys of Grid mode. */ +function gridKey(ctx: Ctx, event: KeyboardEvent): boolean { + const arrow = ARROWS[event.key]; + if (arrow && plain(event) && !event.shiftKey) return moveBy(ctx, arrow[0], arrow[1]); + if (!plain(event)) return false; + switch (event.key) { + case 'Enter': + return event.shiftKey ? false : advance(ctx, 'column', false); + case 'Tab': + return advance(ctx, 'row', event.shiftKey); + case 'Home': + return event.shiftKey ? false : moveBy(ctx, 0, -(activeCell(ctx.state)?.column ?? 0)); + case 'PageUp': + case 'PageDown': + return event.shiftKey ? false : moveBy(ctx, (event.key === 'PageUp' ? -1 : 1) * pageOf(ctx.view, true), 0); + case 'Escape': { + const g = gridOf(ctx.state); + // One cell: Esc goes on to the editor, which selects the table as an object, so the keyboard can leave. + return g !== null && boxSize(g.box) > 1 && move(ctx, g.active); + } + case ' ': + if (!event.shiftKey) return false; + return selectBox(ctx, { + ...gridOf(ctx.state)!.box, + left: 0, + right: Math.max(0, ...ctx.texts.map((r) => r.length)) - 1, + }); + default: + return false; + } +} + +/** Whether the caret is at the start or end of its cell's text. */ +function atTextEdge(state: EditorState, end: boolean): boolean { + const { $head, empty } = state.selection; + return empty && (end ? $head.parentOffset === $head.parent.content.size : $head.parentOffset === 0); +} + +function atLineEdge(view: EditorView, down: boolean): boolean { + try { + return view.endOfTextblock(down ? 'down' : 'up'); + } catch { + return true; + } +} + +/** + * Whether an arrow leaves the table from this cell: up from the first row, down from the last, left from the first + * cell, or right from the last. The table block's edge keys then move to the block beside the table. + */ +function leavesTable(ctx: Ctx, rows: number, columns: number): boolean { + const at = activeCell(ctx.state); + const last = ctx.texts.length - 1; + if (!at) return false; + if (rows !== 0) return at.row === (rows < 0 ? 0 : last); + const width = ctx.texts[last]?.length ?? 0; + return columns < 0 ? at.row === 0 && at.column === 0 : at.row === last && at.column === width - 1; +} + +/** The fixed keys of Edit mode. */ +function editKey(ctx: Ctx, event: KeyboardEvent): boolean { + if (!plain(event)) return false; + const entry = sheetKeysKey.getState(ctx.state)?.entry; + const arrow = ARROWS[event.key]; + if (arrow && !event.shiftKey) { + if (entry?.mode === 'enter') return commitBy(ctx, arrow[0], arrow[1]); + // Edit mode keeps the caret in the cell, as a spreadsheet's does, except where the arrow leaves the table. + const edge = arrow[1] !== 0 ? atTextEdge(ctx.state, arrow[1] > 0) : atLineEdge(ctx.view, arrow[0] > 0); + return edge && !leavesTable(ctx, arrow[0], arrow[1]); + } + switch (event.key) { + case 'Enter': + return event.shiftKey ? false : commitBy(ctx, 1, 0); + case 'Tab': { + // Finish the entry and move as Tab does in Grid mode. + const at = activeCell(ctx.state); + return at !== null && nextInRow(ctx, at, event.shiftKey); + } + case 'Escape': + return cancel(ctx); + default: + return false; + } +} + +export function handleKey(view: EditorView, event: KeyboardEvent, options: SheetKeysOptions): boolean { + const mode = modeOf(view.state); + if (!mode || event.isComposing) return false; + const id = commandFor(event, mode); + if (id) { + // The app's key handler ran it already when the chord reaches commands from a text field. + if (event.defaultPrevented) return true; + if (runSheetCommand(view, options, id)) return true; + } + const ctx: Ctx = { + view, + state: view.state, + dispatch: (tr) => view.dispatch(tr), + options, + texts: textsOf(view.state.doc), + }; + if (mode === 'edit') return editKey(ctx, event); + if (gridKey(ctx, event)) return true; + const text = typedText(event); + return text !== null && typeOver(ctx, text); +} diff --git a/app/src/features/tables/keys/mode.test.ts b/app/src/features/tables/keys/mode.test.ts new file mode 100644 index 000000000..e72011ed7 --- /dev/null +++ b/app/src/features/tables/keys/mode.test.ts @@ -0,0 +1,43 @@ +// Which keys a table uses (PLAN.md decision 4): its stored choice, else spreadsheet keys when it holds a number or a +// formula. The choice is a key of data.sheet.table that the sheet model accepts (R-SHT-10). +import { describe, expect, it } from 'vitest'; +import { sheetProblems } from '../sheet/model'; +import { isSheetValue, keysOf, keysPatch, storedKeys } from './mode'; + +describe('table keys', () => { + it('reads a cell of a digit, a long run of spaces, and a letter quickly, as text', () => { + const start = performance.now(); + expect(isSheetValue(`1${' '.repeat(100_000)}a`)).toBe(false); + expect(performance.now() - start).toBeLessThan(200); + }); + + it('uses the stored choice first', () => { + expect(keysOf({ sheet: { table: { keys: 'document' } } }, [['=1+2']])).toBe('document'); + expect(keysOf({ sheet: { table: { keys: 'spreadsheet' } } }, [['words']])).toBe('spreadsheet'); + expect(storedKeys({ sheet: { table: { keys: 'later' } } })).toBeNull(); + }); + + it('gives an older table spreadsheet keys only when it holds a number or a formula', () => { + expect( + keysOf({}, [ + ['Stage', 'Where'], + ['Prophase', 'Nucleus'], + ]), + ).toBe('document'); + expect( + keysOf({}, [ + ['Item', 'Cost'], + ['Pens', '$1,250.50'], + ]), + ).toBe('spreadsheet'); + expect(keysOf({}, [['Total'], ['=SUM(A1:A2)']])).toBe('spreadsheet'); + expect(['12', '-3.5', '45%', '1e3', '€ 9'].every(isSheetValue)).toBe(true); + expect(['', '=', 'Room 12', '12 apples', '2026-11-07'].some(isSheetValue)).toBe(false); + }); + + it('stores the choice as a merge patch that the sheet model accepts', () => { + expect(keysPatch('document')).toEqual({ sheet: { table: { keys: 'document' } } }); + expect(sheetProblems({ table: { keys: 'document' } }, 'sheet')).toEqual([]); + expect(sheetProblems({ table: { keys: 'grid' } }, 'sheet')).not.toEqual([]); + }); +}); diff --git a/app/src/features/tables/keys/mode.ts b/app/src/features/tables/keys/mode.ts new file mode 100644 index 000000000..df1513a96 --- /dev/null +++ b/app/src/features/tables/keys/mode.ts @@ -0,0 +1,41 @@ +// Which keys a table uses (ADR 0063, PLAN.md decision 4). A table stores its choice in `data.sheet.table.keys`. +// New tables store the setting "New tables use spreadsheet keys" when they are made. A table that stores nothing +// is an older one: it keeps document keys when it holds only text, and uses spreadsheet keys when it holds a +// number or a formula. +import type { Texts } from './grid'; + +export type TableKeys = 'spreadsheet' | 'document'; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +/** The choice the table stores, or null when it stores none (or one this version doesn't know). */ +export function storedKeys(data: Record): TableKeys | null { + const sheet = data.sheet; + const table = isRecord(sheet) ? sheet.table : undefined; + const keys = isRecord(table) ? table.keys : undefined; + return keys === 'spreadsheet' || keys === 'document' ? keys : null; +} + +// A plain number as people type one. It may start with a sign or a currency sign and end with a percent sign. +const NUMBER = /^[-+]?[$€£¥]?\s*(?:\d[\d,.\u00a0 ]*|[.,]\d+)(?:[eE][-+]?\d+)?\s*%?$/; +// No number people type is longer. The cap also keeps the pattern's backtracking short on a long run of spaces. +const NUMBER_MAX = 64; + +/** Whether a cell's text is a number or a formula, which makes an older table a spreadsheet. */ +export function isSheetValue(text: string): boolean { + const trimmed = text.trim(); + return trimmed.startsWith('=') ? trimmed.length > 1 : trimmed.length <= NUMBER_MAX && NUMBER.test(trimmed); +} + +/** The keys the table uses: its stored choice, else spreadsheet keys when it holds a number or a formula. */ +export function keysOf(data: Record, texts: Texts): TableKeys { + const stored = storedKeys(data); + if (stored) return stored; + return texts.some((row) => row.some(isSheetValue)) ? 'spreadsheet' : 'document'; +} + +/** The merge patch of the block's data that stores a choice. Other keys of `sheet` stay as they are. */ +export function keysPatch(keys: TableKeys): Record { + return { sheet: { table: { keys } } }; +} diff --git a/app/src/features/tables/keys/moves.ts b/app/src/features/tables/keys/moves.ts new file mode 100644 index 000000000..e60daa48d --- /dev/null +++ b/app/src/features/tables/keys/moves.ts @@ -0,0 +1,136 @@ +// Grid mode's moves (ADR 0063). Arrows move one cell, and Ctrl+arrows jump to the data's edges. Enter and Tab +// go through a range, and Tab in the last cell adds a row. Ctrl+Space, Shift+Space, and Ctrl+A select. +import type { Node as PMNode } from '@tiptap/pm/model'; +import type { EditorState } from '@tiptap/pm/state'; +import { CellSelection } from '@tiptap/pm/tables'; +import type { EditorView } from '@tiptap/pm/view'; +import { boxSize, cycle, jump, region, step } from './grid'; +import type { Box, GridCell, Texts } from './grid'; +import { activeCell, cellsOf, gridOf, placedAt, selectCells, sheetKeysKey } from './state'; +import type { Dispatch, SheetKeysOptions, SheetMeta } from './state'; + +export interface Ctx { + view: EditorView; + state: EditorState; + dispatch: Dispatch; + options: SheetKeysOptions; + texts: Texts; +} + +export function move(ctx: Ctx, to: GridCell): boolean { + const tr = selectCells(ctx.state, to); + if (tr) ctx.dispatch(tr); + return true; +} + +export function extend(ctx: Ctx, head: GridCell): boolean { + const grid = gridOf(ctx.state); + if (!grid) return false; + const tr = selectCells(ctx.state, grid.active, head, grid.active); + if (tr) ctx.dispatch(tr); + return true; +} + +/** Arrow: one cell from the active cell, the range collapsed. */ +export function moveBy(ctx: Ctx, rows: number, columns: number): boolean { + const from = activeCell(ctx.state); + return from ? move(ctx, step(ctx.texts, from, rows, columns)) : false; +} + +export function extendBy(ctx: Ctx, rows: number, columns: number): boolean { + const grid = gridOf(ctx.state); + return grid ? extend(ctx, step(ctx.texts, grid.head, rows, columns)) : false; +} + +export function jumpBy(ctx: Ctx, rows: -1 | 0 | 1, columns: -1 | 0 | 1, extending: boolean): boolean { + const grid = gridOf(ctx.state); + if (!grid) return false; + if (extending) return extend(ctx, jump(ctx.texts, grid.head, rows, columns)); + return move(ctx, jump(ctx.texts, grid.active, rows, columns)); +} + +/** Enter, Shift+Enter, Tab, and Shift+Tab in Grid mode: through a range's cells, or on to the next cell. */ +export function advance(ctx: Ctx, by: 'column' | 'row', back: boolean): boolean { + const grid = gridOf(ctx.state); + if (!grid) return false; + if (boxSize(grid.box) > 1) { + const next = cycle(grid.box, grid.active, by, back); + const tr = selectCells(ctx.state, grid.anchor, grid.head, next); + if (tr) ctx.dispatch(tr); + return true; + } + return by === 'column' ? moveBy(ctx, back ? -1 : 1, 0) : nextInRow(ctx, grid.active, back); +} + +/** Tab: the next cell along the row, then the next row's first cell. Tab in the last cell adds a row, as today. */ +export function nextInRow(ctx: Ctx, from: GridCell, back: boolean): boolean { + const cells = cellsOf(ctx.state.doc); + const width = cells[from.row]?.length ?? 0; + const index = from.row * width + from.column + (back ? -1 : 1); + if (index < 0) return move(ctx, from); + if (index < cells.length * width) return move(ctx, { row: Math.floor(index / width), column: index % width }); + return addRow(ctx); +} + +/** A new empty row under the table, with its first cell active. The normalizer gives it its IDs. */ +export function addRow(ctx: Ctx): boolean { + const { state } = ctx; + const table = state.doc.firstChild; + const grid = cellsOf(state.doc); + const last = grid[grid.length - 1]; + if (!table || !last) return false; + const { nodes } = state.schema; + // One cell for each column of the table's map, so a merged cell above doesn't make the row short. + const cells: PMNode[] = last.map(({ node }) => { + const single = Number(node.attrs.colspan ?? 1) === 1; + const attrs = { + colwidth: single ? (node.attrs.colwidth as unknown) : null, + colId: single ? (node.attrs.colId as unknown) : null, + }; + return nodes.tableCell.create(attrs, nodes.paragraph.create()); + }); + const tr = state.tr.insert(1 + table.content.size, nodes.tableRow.create(null, cells)); + const first = placedAt(tr.doc, { row: grid.length, column: 0 }); + if (first) tr.setSelection(CellSelection.create(tr.doc, first.pos)); + ctx.dispatch(tr.setMeta(sheetKeysKey, { active: null, entry: null } satisfies SheetMeta).scrollIntoView()); + return true; +} + +/** Leaves Edit mode keeping the text, and goes to a cell in Grid mode. */ +export function commitTo(ctx: Ctx, to: GridCell): boolean { + return move(ctx, to); +} + +export function commitBy(ctx: Ctx, rows: number, columns: number): boolean { + const from = activeCell(ctx.state); + return from ? commitTo(ctx, step(ctx.texts, from, rows, columns)) : false; +} + +export function selectBox(ctx: Ctx, box: Box, active?: GridCell): boolean { + const tr = selectCells(ctx.state, { row: box.top, column: box.left }, { row: box.bottom, column: box.right }, active); + if (tr) ctx.dispatch(tr); + return true; +} + +export function wholeBox(texts: Texts): Box { + return { top: 0, left: 0, bottom: texts.length - 1, right: Math.max(0, ...texts.map((row) => row.length)) - 1 }; +} + +/** Ctrl+A: the data around the active cell, then the whole table. */ +export function selectRegion(ctx: Ctx): boolean { + const grid = gridOf(ctx.state); + if (!grid) return false; + const around = region(ctx.texts, grid.active); + const same = (a: Box, b: Box) => a.top === b.top && a.left === b.left && a.bottom === b.bottom && a.right === b.right; + const next = same(around, grid.box) || boxSize(around) === 1 ? wholeBox(ctx.texts) : around; + return selectBox(ctx, next, grid.active); +} + +/** About a screen of rows, or of columns, from the size the table shows at. */ +export function pageOf(view: EditorView, rows: boolean): number { + const cell = view.dom.querySelector('td, th'); + const rect = cell?.getBoundingClientRect(); + const size = rows ? rect?.height : rect?.width; + const room = rows ? window.innerHeight : (view.dom.parentElement?.clientWidth ?? 0); + return size && room ? Math.max(1, Math.floor(room / size) - 1) : rows ? 10 : 5; +} diff --git a/app/src/features/tables/keys/plugin.test.ts b/app/src/features/tables/keys/plugin.test.ts new file mode 100644 index 000000000..a97e67e26 --- /dev/null +++ b/app/src/features/tables/keys/plugin.test.ts @@ -0,0 +1,551 @@ +// @vitest-environment jsdom +// Grid mode and Edit mode in a real table editor (ADR 0063). Keys go to the editor as key presses, so the plugin, +// the table's own keys, and the registered sheet commands all take part. +import type { Editor } from '@tiptap/core'; +import type { Node as PMNode } from '@tiptap/pm/model'; +import { Plugin } from '@tiptap/pm/state'; +import { CellSelection } from '@tiptap/pm/tables'; +import { Decoration } from '@tiptap/pm/view'; +import axe from 'axe-core'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { initFlags } from '../../../app/flags'; +import { linkApplies } from '../../../editor/commands/state'; +import { createBlockEditor } from '../../../editor/extensions/kit'; +import { DEFAULT_EDITING_VIEW } from '../../../editor/host'; +import type { EditorHost } from '../../../editor/host'; +import { tableSchema } from '../../../editor/schema/schema'; +import { parseDate, todayDays } from '../engine/dates'; +import { registerSheetKeyCommands } from './commands'; +import { activeCell, gridOf, insideOneCell, modeOf, sheetKeysPlugin, textsOf } from './plugin'; +import type { SheetKeysOptions } from './plugin'; +import { address } from './grid'; +import { sheetKeysKey } from './state'; + +const host: EditorHost = { + settings: () => DEFAULT_EDITING_VIEW, + flag: () => true, + announce: () => {}, + openMenu: () => Promise.resolve(null), + screenReader: () => false, + spelling: () => null, + selectBlocks: () => {}, +}; + +const { nodes } = tableSchema; + +function tableDoc(rows: string[][], header: boolean): PMNode { + const made = rows.map((line, r) => + nodes.tableRow.create( + null, + line.map((text) => + (header && r === 0 ? nodes.tableHeader : nodes.tableCell).create( + null, + nodes.paragraph.create(null, text ? tableSchema.text(text) : null), + ), + ), + ), + ); + return nodes.doc.create(null, nodes.table.create(null, made)); +} + +let editors: Editor[] = []; +let stopCommands = () => {}; +beforeAll(() => { + initFlags('dev'); + stopCommands = registerSheetKeyCommands(); +}); +afterAll(() => stopCommands()); +afterEach(() => { + editors.forEach((editor) => { + editor.view.dom.remove(); + editor.destroy(); + }); + editors = []; +}); + +interface Harness { + editor: Editor; + said: string[]; + press(key: string, mods?: string): void; + type(text: string): void; + select(from: string, to?: string): void; + caretIn(cell: string): void; + texts(): string[][]; + at(): string | null; + range(): string | null; +} + +/** "B2" as a cell of a table without a header row, or with one. */ +function cellOf(name: string, header: boolean) { + const column = name.charCodeAt(0) - 65; + const number = Number(name.slice(1)); + return { row: header ? number : number - 1, column }; +} + +interface MountOptions extends Partial { + header?: boolean; + /** The document instead of one made from the rows. */ + doc?: PMNode; + /** Whether the editor gets focus, as when a person works in the table. Default: true. */ + focus?: boolean; +} + +function mount(rows: string[][], options: MountOptions = {}): Harness { + const header = options.header ?? false; + const root = document.body.appendChild(document.createElement('div')); + const editor = createBlockEditor(root, options.doc ?? tableDoc(rows, header), { kind: 'table', block: 'b1', host }); + editors.push(editor); + const said: string[] = []; + const plugin = sheetKeysPlugin({ spreadsheet: true, announce: (text) => said.push(text), ...options }); + editor.registerPlugin(plugin, (added, plugins) => [added, ...plugins]); + const { view } = editor; + if (options.focus !== false) view.dom.dispatchEvent(new FocusEvent('focus')); + const cellPos = (name: string) => { + const at = cellOf(name, header); + let pos = 1; + const table = view.state.doc.firstChild!; + for (let r = 0; r < at.row; r++) pos += table.child(r).nodeSize; + pos += 1; + for (let c = 0; c < at.column; c++) pos += table.child(at.row).child(c).nodeSize; + return pos; + }; + const press = (key: string, mods = '') => { + const has = (name: string) => mods.includes(name); + const code = key.length === 1 ? (/\d/.test(key) ? `Digit${key}` : `Key${key.toUpperCase()}`) : key; + const event = new KeyboardEvent('keydown', { + key, + code: key === ';' ? 'Semicolon' : code, + ctrlKey: has('ctrl'), + shiftKey: has('shift'), + altKey: has('alt'), + bubbles: true, + cancelable: true, + }); + view.dom.dispatchEvent(event); + }; + return { + editor, + said, + press, + type: (text) => [...text].forEach((key) => press(key)), + select(from, to = from) { + view.dispatch(view.state.tr.setSelection(CellSelection.create(view.state.doc, cellPos(from), cellPos(to)))); + }, + caretIn(name) { + editor.commands.setTextSelection(cellPos(name) + 2); + }, + texts: () => textsOf(view.state.doc), + at: () => { + const at = activeCell(view.state); + return at && address(at, header); + }, + range: () => { + const grid = gridOf(view.state); + if (!grid) return null; + const { box } = grid; + return `${address({ row: box.top, column: box.left }, header)}:${address({ row: box.bottom, column: box.right }, header)}`; + }, + }; +} + +const FIVE = [ + ['1', '2', '3', '', ''], + ['4', '5', '6', '', ''], + ['7', '8', '9', '', ''], + ['', '', '', '', ''], + ['', '', '', '', ''], +]; + +describe('Grid mode moves', () => { + it('jumps to the data edges and selects to the last used cell', () => { + const sheet = mount(FIVE); + sheet.select('A1'); + sheet.press('ArrowDown', 'ctrl'); + expect(sheet.at()).toBe('A3'); + sheet.select('A1'); + sheet.press('ArrowRight', 'ctrl'); + expect(sheet.at()).toBe('C1'); + sheet.select('A1'); + sheet.press('End', 'ctrl shift'); + expect(sheet.range()).toBe('A1:C3'); + expect(sheet.at()).toBe('A1'); + }); + + it('moves with arrows, extends with Shift, and collapses with Esc', () => { + const sheet = mount(FIVE); + sheet.select('B2'); + sheet.press('ArrowRight'); + expect(sheet.at()).toBe('C2'); + sheet.press('ArrowDown', 'shift'); + sheet.press('ArrowLeft', 'shift'); + expect(sheet.range()).toBe('B2:C3'); + sheet.press('Escape'); + expect(sheet.range()).toBe('C2:C2'); + sheet.press('Home'); + expect(sheet.at()).toBe('A2'); + sheet.press(' ', 'shift'); + expect(sheet.range()).toBe('A2:E2'); + }); + + it('selects the column, the region, then the table', () => { + const sheet = mount(FIVE); + sheet.select('B2'); + sheet.press(' ', 'ctrl'); + expect(sheet.range()).toBe('B1:B5'); + sheet.select('B2'); + sheet.press('a', 'ctrl'); + expect(sheet.range()).toBe('A1:C3'); + sheet.press('a', 'ctrl'); + expect(sheet.range()).toBe('A1:E5'); + }); +}); + +describe('entries', () => { + it('types over a cell, cancels with Esc, edits with F2, and commits with Enter', () => { + const sheet = mount(FIVE); + sheet.select('B2'); + sheet.type('7'); + expect(sheet.texts()[1][1]).toBe('7'); + expect(modeOf(sheet.editor.state)).toBe('edit'); + sheet.press('Escape'); + expect(sheet.texts()[1][1]).toBe('5'); + expect(modeOf(sheet.editor.state)).toBe('grid'); + expect(sheet.at()).toBe('B2'); + sheet.press('F2'); + const { selection } = sheet.editor.state; + expect(modeOf(sheet.editor.state)).toBe('edit'); + expect(selection.empty && selection.$head.parentOffset === selection.$head.parent.content.size).toBe(true); + // In Edit mode the browser types the text; jsdom has no text input, so the test inserts it. + sheet.editor.view.dispatch(sheet.editor.state.tr.insertText('9')); + sheet.press('Enter'); + expect(sheet.texts()[1][1]).toBe('59'); + expect(modeOf(sheet.editor.state)).toBe('grid'); + expect(sheet.at()).toBe('B3'); + }); + + it('goes through a range with Enter: B2, B3, C2, C3', () => { + const sheet = mount(FIVE); + sheet.select('B2', 'C3'); + const seen = [sheet.at()]; + for (let i = 0; i < 4; i++) { + sheet.press('Enter'); + seen.push(sheet.at()); + } + expect(seen).toEqual(['B2', 'B3', 'C2', 'C3', 'B2']); + expect(sheet.range()).toBe('B2:C3'); + }); + + it('finishes an Enter mode entry with an arrow, and keeps the caret in the cell in Edit mode', () => { + const sheet = mount(FIVE); + sheet.select('A1'); + sheet.type('x'); + sheet.press('ArrowRight'); + expect(sheet.texts()[0][0]).toBe('x'); + expect(sheet.at()).toBe('B1'); + sheet.caretIn('C1'); + sheet.press('ArrowLeft'); + expect(modeOf(sheet.editor.state)).toBe('edit'); + expect(sheet.at()).toBe('C1'); + sheet.press('Tab'); + expect(sheet.at()).toBe('D1'); + expect(modeOf(sheet.editor.state)).toBe('grid'); + }); + + it('clears the range with Delete and the active cell with Backspace', () => { + const sheet = mount(FIVE); + sheet.select('A1', 'B2'); + sheet.press('Delete'); + expect(sheet.texts().slice(0, 2)).toEqual([ + ['', '', '3', '', ''], + ['', '', '6', '', ''], + ]); + sheet.select('C3'); + sheet.press('Backspace'); + expect(sheet.texts()[2][2]).toBe(''); + expect(modeOf(sheet.editor.state)).toBe('edit'); + }); + + it('adds a row with Tab in the last cell', () => { + const sheet = mount([['a', 'b']]); + sheet.select('B1'); + sheet.press('Tab'); + expect(sheet.texts()).toEqual([ + ['a', 'b'], + ['', ''], + ]); + expect(sheet.at()).toBe('A2'); + }); +}); + +describe('line breaks', () => { + const breaks = (editor: Editor) => { + let count = 0; + editor.state.doc.descendants((node) => { + if (node.type.name === 'hardBreak') count++; + }); + return count; + }; + + it('adds a line break with Alt+Enter in Edit mode', () => { + const sheet = mount(FIVE); + sheet.caretIn('A1'); + sheet.press('Enter', 'alt'); + expect(breaks(sheet.editor)).toBe(1); + expect(sheet.at()).toBe('A1'); + }); + + it('keeps today’s Enter in a table set to document keys', () => { + const sheet = mount(FIVE, { spreadsheet: false }); + sheet.caretIn('A1'); + sheet.press('Enter'); + expect(breaks(sheet.editor)).toBe(1); + expect(sheet.editor.state.selection instanceof CellSelection).toBe(false); + sheet.press('ArrowDown', 'ctrl'); + expect(modeOf(sheet.editor.state)).toBeNull(); + }); +}); + +describe('fills and stamps', () => { + it('fills B2 down into B3:B4 and moves its references', () => { + const sheet = mount([ + ['1', '', ''], + ['2', '=A2', ''], + ['3', '', ''], + ['4', 'old', ''], + ]); + sheet.select('B2', 'B4'); + sheet.press('d', 'ctrl'); + expect(sheet.texts().map((row) => row[1])).toEqual(['', '=A2', '=A3', '=A4']); + sheet.select('B2', 'C2'); + sheet.press('r', 'ctrl'); + expect(sheet.texts()[1][2]).toBe('=B2'); + }); + + it('enters today’s date as a date the engine reads', () => { + const now = new Date(2026, 10, 7, 13, 5); + const sheet = mount(FIVE, { now: () => now }); + sheet.select('D4'); + sheet.press(';', 'ctrl'); + sheet.press('Enter'); + const text = sheet.texts()[3][3]; + expect(text).toBe('11/7/2026'); + expect(parseDate(text, 'mdy')).toBe(todayDays(now)); + }); + + it('copies the formula and the value from the cell above', () => { + const sheet = mount( + [ + ['=1+1', ''], + ['', ''], + ], + { shown: (_view, at) => (at.row === 0 && at.column === 0 ? '2' : null) }, + ); + sheet.select('A2'); + sheet.press("'", 'ctrl'); + sheet.press('Enter'); + expect(sheet.texts()[1][0]).toBe('=1+1'); + }); +}); + +describe('screen readers', () => { + it('announces the address, header, value, and formula of the new active cell', () => { + const sheet = mount( + [ + ['Item', 'Qty', 'Total'], + ['a', '1', '=B1*2'], + ['b', '2', '=B2*2'], + ['c', '3', '=B3*2'], + ['d', '5', '=B4*2'], + ], + { header: true, shown: (_view, at) => (at.column === 2 && at.row > 0 ? '10' : null) }, + ); + sheet.select('C3'); + sheet.press('ArrowDown'); + expect(sheet.said.at(-1)).toBe('C4, Total, 10, formula equals B4 times 2'); + }); + + it('numbers every cell and marks the range and the active cell', () => { + const sheet = mount(FIVE); + sheet.select('B2', 'C3'); + const dom = sheet.editor.view.dom; + const b2 = dom.querySelectorAll('tr')[1].querySelectorAll('td')[1]; + expect(b2.getAttribute('aria-rowindex')).toBe('2'); + expect(b2.getAttribute('aria-colindex')).toBe('2'); + expect(dom.querySelectorAll('[aria-selected="true"]')).toHaveLength(4); + expect(b2.className).toMatch(/active/); + expect(dom.getAttribute('data-scope')).toBe('editor editor.table tableGrid'); + sheet.caretIn('A1'); + expect(dom.getAttribute('data-scope')).toBe('editor editor.table tableEdit'); + }); +}); + +describe('the grid that screen readers read', () => { + it('makes a table with spreadsheet keys a grid, so aria-selected is allowed on its cells', async () => { + const sheet = mount( + [ + ['Item', 'Qty'], + ['a', '1'], + ['b', '2'], + ], + { header: true }, + ); + const dom = sheet.editor.view.dom; + expect(dom.querySelector('table')?.getAttribute('role')).toBe('grid'); + expect(dom.querySelector('th')?.getAttribute('role')).toBe('columnheader'); + expect(dom.querySelector('td')?.getAttribute('role')).toBe('gridcell'); + sheet.select('A1', 'B2'); + expect(dom.querySelectorAll('td[aria-selected="true"]')).toHaveLength(4); + const rules = ['aria-allowed-attr', 'aria-required-children', 'aria-required-parent', 'aria-allowed-role']; + const results = await axe.run(dom, { runOnly: { type: 'rule', values: rules } }); + expect(results.violations.map((violation) => violation.id)).toEqual([]); + sheet.editor.view.dispatch(sheet.editor.state.tr.setMeta(sheetKeysKey, { spreadsheet: false })); + expect(dom.querySelector('table')?.getAttribute('role')).toBeNull(); + expect(dom.querySelector('td')?.getAttribute('role')).toBeNull(); + expect(dom.querySelector('td')?.getAttribute('aria-rowindex')).toBe('2'); + }); + + it('marks no cell selected while focus is outside the editor, as when the table is selected as an object', () => { + const sheet = mount(FIVE); + sheet.select('B2', 'C3'); + const dom = sheet.editor.view.dom; + expect(dom.querySelectorAll('[aria-selected="true"]')).toHaveLength(4); + dom.dispatchEvent(new FocusEvent('blur')); + expect(dom.querySelectorAll('[aria-selected]')).toHaveLength(0); + dom.dispatchEvent(new FocusEvent('focus')); + expect(dom.querySelectorAll('[aria-selected="true"]')).toHaveLength(4); + }); + + it('maps the cell numbers through typing instead of rebuilding them for every cell', () => { + const rows = Array.from({ length: 30 }, () => Array.from({ length: 10 }, () => 'x')); + const sheet = mount(rows); + sheet.caretIn('B2'); + // The first change gives the rows their IDs, which moves no cell but changes the rows; later ones don't. + sheet.editor.commands.insertContent('-'); + const node = vi.spyOn(Decoration, 'node'); + sheet.editor.commands.insertContent('typed'); + // Only the active cell's outline is drawn again; the 300 cells' numbers are mapped. + expect(node.mock.calls.length).toBeLessThan(10); + node.mockRestore(); + const b2 = sheet.editor.view.dom.querySelectorAll('tr')[1].querySelectorAll('td')[1]; + expect(b2.textContent).toBe('-typedx'); + expect(b2.getAttribute('aria-colindex')).toBe('2'); + }); + + it('numbers the cells again when a row is added', () => { + const sheet = mount([ + ['1', '2'], + ['3', '4'], + ]); + sheet.caretIn('A1'); + sheet.editor.commands.addRowBefore(); + const rows = sheet.editor.view.dom.querySelectorAll('tr'); + expect(rows).toHaveLength(3); + expect(rows[2].querySelectorAll('td')[1].getAttribute('aria-rowindex')).toBe('3'); + expect(rows[0].querySelectorAll('td')[1].getAttribute('aria-colindex')).toBe('2'); + expect(rows[0].querySelectorAll('td')[1].getAttribute('role')).toBe('gridcell'); + }); + + it('tells a change inside one cell from one that moves cells', () => { + const sheet = mount([ + ['1', '2'], + ['3', '4'], + ]); + const { state } = sheet.editor; + expect(insideOneCell(state.tr.insertText('a', 4))).toBe(true); + expect(insideOneCell(state.tr.delete(4, 5))).toBe(true); + // From A1's text to B1's text crosses a cell's edge. + expect(insideOneCell(state.tr.delete(4, 10))).toBe(false); + expect(insideOneCell(state.tr.delete(1, 1 + state.doc.firstChild!.firstChild!.nodeSize))).toBe(false); + }); + + it('leaves Ctrl+K to the palette when cells are selected', () => { + const sheet = mount(FIVE); + sheet.select('A1', 'B2'); + expect(linkApplies(sheet.editor.state)).toBe(false); + }); +}); + +describe('merged cells', () => { + // A1 spans two columns: A1 (A1:B1), C1 / A2, B2, C2. Pasted HTML can make one. + function mergedDoc(): PMNode { + const cell = (text: string, attrs: Record | null = null) => + nodes.tableCell.create(attrs, nodes.paragraph.create(null, tableSchema.text(text))); + return nodes.doc.create( + null, + nodes.table.create(null, [ + nodes.tableRow.create(null, [cell('wide', { colspan: 2 }), cell('c1')]), + nodes.tableRow.create(null, [cell('a2'), cell('b2'), cell('c2')]), + ]), + ); + } + + it('numbers the cells by the table’s columns, so the cell after a merged one is column 3', () => { + const sheet = mount([], { doc: mergedDoc() }); + const first = sheet.editor.view.dom.querySelectorAll('tr')[0].querySelectorAll('td'); + expect(first[1].textContent).toBe('c1'); + expect(first[1].getAttribute('aria-colindex')).toBe('3'); + expect(sheet.texts()[0]).toEqual(['wide', 'wide', 'c1']); + }); + + it('uses document keys while a cell is merged, so keys never land on the wrong cell', () => { + const sheet = mount([], { doc: mergedDoc() }); + const { view } = sheet.editor; + sheet.caretIn('A2'); + expect(modeOf(view.state)).toBeNull(); + expect(view.dom.getAttribute('data-scope')).toBe('editor editor.table'); + expect(view.dom.querySelector('table')?.getAttribute('role')).toBeNull(); + expect(view.dom.querySelector('td')?.getAttribute('role')).toBeNull(); + }); +}); + +describe('leaving and canceling', () => { + it('lets an arrow at the table’s edge in Edit mode go on, so the keyboard can leave the table', () => { + const sheet = mount([ + ['1', '2'], + ['3', '4'], + ]); + const seen: string[] = []; + // Stands in for the table block's edge keys: the next plugin after the sheet keys. + sheet.editor.registerPlugin( + new Plugin({ props: { handleKeyDown: (_view, event) => (seen.push(event.key), true) } }), + (added, plugins) => [plugins[0], added, ...plugins.slice(1)], + ); + sheet.caretIn('B2'); + sheet.editor.commands.setTextSelection(sheet.editor.state.selection.head + 1); + sheet.press('ArrowRight'); + sheet.press('ArrowDown'); + expect(seen).toEqual(['ArrowRight', 'ArrowDown']); + // Inside the table the caret stays in its cell. + sheet.caretIn('A2'); + sheet.editor.commands.setTextSelection(sheet.editor.state.selection.head + 1); + sheet.press('ArrowRight'); + expect(seen).toHaveLength(2); + }); + + it('puts the text back with Esc after a click into the cell and typing', () => { + const sheet = mount(FIVE); + sheet.caretIn('B2'); + sheet.editor.view.dispatch(sheet.editor.state.tr.insertText('9')); + expect(sheet.texts()[1][1]).toBe('95'); + sheet.press('Escape'); + expect(sheet.texts()[1][1]).toBe('5'); + expect(modeOf(sheet.editor.state)).toBe('grid'); + expect(sheet.said).toContain('Entry canceled.'); + }); + + it('says nothing was canceled when Esc follows a click with no typing', () => { + const sheet = mount(FIVE); + sheet.caretIn('B2'); + sheet.press('Escape'); + expect(sheet.texts()[1][1]).toBe('5'); + expect(sheet.said).not.toContain('Entry canceled.'); + }); +}); + +describe('focus', () => { + it('marks no cell selected before the editor ever had focus', () => { + const sheet = mount(FIVE, { focus: false }); + sheet.select('B2', 'C3'); + expect(sheet.editor.view.dom.querySelectorAll('[aria-selected]')).toHaveLength(0); + }); +}); diff --git a/app/src/features/tables/keys/plugin.ts b/app/src/features/tables/keys/plugin.ts new file mode 100644 index 000000000..e2f60b928 --- /dev/null +++ b/app/src/features/tables/keys/plugin.ts @@ -0,0 +1,265 @@ +// Grid mode and Edit mode in a table block's editor (ADR 0063, DESIGN.md section 11). Grid mode is a cell +// selection: one active cell, or a range with the active cell inside it. Edit mode is the caret in a cell's text, +// as in a text editor. An entry started by typing over a cell, or by Backspace, is Enter mode: arrows finish it +// and move. Esc cancels an entry that F2, typing, or Backspace started, and puts the old text back. +// +// The plugin runs first among the editor's plugins. Chords a person may change come from the command registry +// (bindings.ts), so rebinding works; the navigation keys are fixed. A table set to document keys keeps today's +// keys: the plugin then only adds the cells' row and column numbers for screen readers. +import type { Node as PMNode } from '@tiptap/pm/model'; +import { Plugin, TextSelection } from '@tiptap/pm/state'; +import type { EditorState, Transaction } from '@tiptap/pm/state'; +import { CellSelection } from '@tiptap/pm/tables'; +import { Decoration, DecorationSet } from '@tiptap/pm/view'; +import type { EditorView } from '@tiptap/pm/view'; +import { clearActiveSheet, setActiveSheet } from './active'; +import type { SheetRunner } from './active'; +import { cellsIn, runSheetCommand, shownText, typeOver } from './actions'; +import { cellAnnouncement, rangeAnnouncement } from './announce'; +import { address, boxSize } from './grid'; +import type { GridCell } from './grid'; +import { handleKey } from './keys'; +import type { Ctx } from './moves'; +import { + activeCell, + cellOfPos, + cellsOf, + contentOf, + gridOf, + hasHeader, + hasMerged, + keysOn, + modeOf, + placedAt, + selectCells, + sheetKeysKey, +} from './state'; +import type { SheetKeysOptions, SheetMeta, SheetState } from './state'; +import styles from './keys.module.css'; + +export { activeCell, gridOf, modeOf, sheetKeysKey, textsOf } from './state'; +export type { SheetKeysOptions, SheetMode } from './state'; +export { dateText, runSheetCommand, setSheetKeys, timeText } from './actions'; + +// ---- Decorations and announcements + +/** + * aria-rowindex and aria-colindex on every cell, counted from 1. A merged cell takes its top left slot's. With + * spreadsheet keys each cell is a "gridcell" or a "columnheader" of the grid that markGrid makes the table, so Grid + * mode's aria-selected is allowed. + */ +function indexesOf(doc: PMNode, spreadsheet: boolean): DecorationSet { + const decorations: Decoration[] = []; + const done = new Set(); + cellsOf(doc).forEach((row, r) => + row.forEach((cell, c) => { + if (done.has(cell.pos)) return; + done.add(cell.pos); + const attrs: Record = { 'aria-rowindex': String(r + 1), 'aria-colindex': String(c + 1) }; + if (spreadsheet) attrs.role = cell.node.type.name === 'tableHeader' ? 'columnheader' : 'gridcell'; + decorations.push(Decoration.node(cell.pos, cell.pos + cell.node.nodeSize, attrs)); + }), + ); + return DecorationSet.create(doc, decorations); +} + +/** Whether each step changed only the inside of one cell, so every cell keeps its row and column. */ +export function insideOneCell(tr: Transaction): boolean { + return tr.steps.every((step, i) => { + const doc = tr.docs[i]; + let inside = true; + step.getMap().forEach((from, to) => { + if (!inside) return; + const $from = doc.resolve(from); + const $to = doc.resolve(to); + // A cell's content sits at depth 3: the document, the table, the row, then the cell. + inside = $from.depth >= 3 && $to.depth >= 3 && $from.start(3) === $to.start(3); + }); + return inside; + }); +} + +/** What a screen reader hears when the active cell or the range changes. */ +function speech(view: EditorView, options: SheetKeysOptions): string | null { + const g = gridOf(view.state); + if (!g) return null; + const header = hasHeader(view.state.doc); + if (boxSize(g.box) > 1 && g.active.row === g.anchor.row && g.active.column === g.anchor.column) + return rangeAnnouncement( + address({ row: g.box.top, column: g.box.left }, header), + address({ row: g.box.bottom, column: g.box.right }, header), + boxSize(g.box), + ); + const text = placedAt(view.state.doc, g.active)?.node.textContent ?? ''; + const isFormula = text.trim().startsWith('='); + return cellAnnouncement({ + address: address(g.active, header), + header: header ? (placedAt(view.state.doc, { row: 0, column: g.active.column })?.node.textContent ?? null) : null, + shown: isFormula ? shownText(view, options, g.active) : text, + formula: isFormula ? text.trim() : null, + isHeader: header && g.active.row === 0, + }); +} + +/** The key of what was announced: the range and the active cell. */ +function spoken(state: EditorState): string | null { + const g = gridOf(state); + return g ? `${g.box.top},${g.box.left},${g.box.bottom},${g.box.right}:${g.active.row},${g.active.column}` : null; +} + +/** The plugin state after a transaction: meta from the actions, and an entry that ends when the caret leaves. */ +function applyMeta(tr: Transaction, value: SheetState, state: EditorState): SheetState { + const meta = tr.getMeta(sheetKeysKey) as SheetMeta | undefined; + const next: SheetState = { ...value }; + if (meta?.spreadsheet !== undefined) next.spreadsheet = meta.spreadsheet; + if (meta?.focused !== undefined) next.focused = meta.focused; + // Typing maps the cells' numbers through the change, so a keystroke costs the same in a big table as in a small one. + if (next.spreadsheet !== value.spreadsheet || (tr.docChanged && !insideOneCell(tr))) + next.indexes = indexesOf(state.doc, next.spreadsheet && !hasMerged(state.doc)); + else if (tr.docChanged) next.indexes = value.indexes.map(tr.mapping, tr.doc); + if (meta && 'active' in meta) next.active = meta.active ?? null; + else if (tr.selectionSet) next.active = null; + if (meta && 'entry' in meta) next.entry = meta.entry ?? null; + else if (next.entry) { + const at = state.selection instanceof CellSelection ? null : cellOfPos(state.doc, state.selection.head); + if (!at || at.row !== next.entry.row || at.column !== next.entry.column) next.entry = null; + } + // A click or Tab that puts the caret in a cell starts an Edit mode entry, so Esc puts the cell's text back. + if ( + !next.entry && + tr.selectionSet && + !tr.docChanged && + next.spreadsheet && + state.selection instanceof TextSelection + ) { + const at = cellOfPos(state.doc, state.selection.head); + if (at && !hasMerged(state.doc)) next.entry = { ...at, before: contentOf(state.doc, at), mode: 'edit' }; + } + return next; +} + +/** + * The cell numbers on every cell; in Grid mode the range's aria-selected while the editor has focus; and the active + * cell's outline. + */ +function decorationsOf(state: EditorState): DecorationSet | null { + const sheet = sheetKeysKey.getState(state); + if (!sheet) return null; + const mode = modeOf(state); + if (!mode) return sheet.indexes; + const extra: Decoration[] = []; + const mark = (at: GridCell, attrs: Record) => { + const cell = placedAt(state.doc, at); + if (cell) extra.push(Decoration.node(cell.pos, cell.pos + cell.node.nodeSize, attrs)); + }; + const g = gridOf(state); + if (g && sheet.focused) for (const at of cellsIn(g.box)) mark(at, { 'aria-selected': 'true' }); + const active = activeCell(state); + if (active) mark(active, { class: mode === 'grid' ? styles.active : styles.editing }); + return sheet.indexes.add(state.doc, extra); +} + +/** Notes when the editor gains or loses focus, so a cell isn't announced as selected while focus is elsewhere. */ +function noteFocus(view: EditorView, focused: boolean): boolean { + const sheet = sheetKeysKey.getState(view.state); + if (!view.isDestroyed && sheet && sheet.focused !== focused) + view.dispatch(view.state.tr.setMeta(sheetKeysKey, { focused } satisfies SheetMeta)); + return false; +} + +/** A click on a cell's padding, not its text, selects the cell in Grid mode. */ +function clickPadding(view: EditorView, event: MouseEvent): boolean { + const target = event.target; + if (!keysOn(view.state) || event.button !== 0 || event.shiftKey) return false; + if (!(target instanceof HTMLTableCellElement) || !view.dom.contains(target)) return false; + const at = cellOfPos(view.state.doc, view.posAtDOM(target, 0)); + const tr = at && selectCells(view.state, at); + if (!tr) return false; + event.preventDefault(); + view.focus(); + view.dispatch(tr); + return true; +} + +/** The mode's key scope on the editor, so the app's key handler finds Grid mode's and Edit mode's commands. */ +function scopeOf(state: EditorState): Record { + const mode = modeOf(state); + const scope = mode === 'grid' ? ' tableGrid' : mode === 'edit' ? ' tableEdit' : ''; + return { 'data-scope': `editor editor.table${scope}` }; +} + +/** + * role="grid" on the table element with spreadsheet keys. The table's node view draws the element, so a node + * decoration would land on its wrapper; the view ignores attribute changes on the element. + */ +function markGrid(view: EditorView, on: boolean): void { + const table = view.dom.querySelector('table'); + if (!table || (table.getAttribute('role') === 'grid') === on) return; + if (on) table.setAttribute('role', 'grid'); + else table.removeAttribute('role'); +} + +/** Makes this table the one commands act on while it has focus, marks it a grid, and says each move. */ +function sheetView(view: EditorView, options: SheetKeysOptions) { + const runner: SheetRunner = { + run: (command) => runSheetCommand(view, options, command), + documentKeys: () => !keysOn(view.state), + }; + const focus = () => setActiveSheet(runner); + view.dom.addEventListener('focusin', focus); + if (view.hasFocus()) { + focus(); + // The state starts unfocused. Note focus after this update, since a view can't dispatch while it updates. + queueMicrotask(() => noteFocus(view, view.hasFocus())); + } + markGrid(view, keysOn(view.state)); + let said = spoken(view.state); + return { + update(next: EditorView) { + markGrid(next, keysOn(next.state)); + const now = spoken(next.state); + if (now === said) return; + said = now; + const text = now === null ? null : speech(next, options); + if (text) options.announce(text); + }, + destroy() { + markGrid(view, false); + view.dom.removeEventListener('focusin', focus); + clearActiveSheet(runner); + }, + }; +} + +export function sheetKeysPlugin(options: SheetKeysOptions): Plugin { + return new Plugin({ + key: sheetKeysKey, + state: { + init: (_config, state) => ({ + spreadsheet: options.spreadsheet, + entry: null, + active: null, + indexes: indexesOf(state.doc, options.spreadsheet && !hasMerged(state.doc)), + focused: false, + }), + apply: (tr, value, _old, state) => applyMeta(tr, value, state), + }, + props: { + handleKeyDown: (view, event) => handleKey(view, event, options), + handleTextInput(view, _from, _to, text) { + // Text that arrives without a key press, such as from a phone keyboard, types over the cell too. + if (modeOf(view.state) !== 'grid') return false; + const ctx: Ctx = { view, state: view.state, dispatch: (tr) => view.dispatch(tr), options, texts: [] }; + return typeOver(ctx, text); + }, + handleDOMEvents: { + mousedown: clickPadding, + focus: (view) => noteFocus(view, true), + blur: (view) => noteFocus(view, false), + }, + attributes: scopeOf, + decorations: decorationsOf, + }, + view: (view) => sheetView(view, options), + }); +} diff --git a/app/src/features/tables/keys/register.ts b/app/src/features/tables/keys/register.ts new file mode 100644 index 000000000..12c85d3eb --- /dev/null +++ b/app/src/features/tables/keys/register.ts @@ -0,0 +1,32 @@ +// Registers the spreadsheet keys at start-up (ADR 0063): their commands, their section of the shortcut list, and +// the table extra that gives each table block its Grid and Edit modes. The editor code loads when a table first shows. +import { settingsSections, shortcutListSections } from '../../../registries'; +import { tableExtras } from '../../page'; +import { registerSheetKeyCommands, SHEET_KEYS_FLAG } from './commands'; +import { SheetKeysTable } from './KeysTable'; + +registerSheetKeyCommands(); + +tableExtras.register({ + id: 'sheetKeys', + flag: SHEET_KEYS_FLAG, + attach: async (host) => (await import('./attach')).attachSheetKeys(host), +}); + +shortcutListSections.register({ + id: 'sheetKeys', + title: 'sheetKeys.fixed.title', + order: 20, + flag: SHEET_KEYS_FLAG, + Component: SheetKeysTable, +}); + +// Settings, then Tables: the keys new tables use. tables:formula-bar adds its switch to this section. +settingsSections.register({ + id: 'tables', + title: 'sheetKeys.settings.title', + icon: 'Table', + order: 25.5, + flag: SHEET_KEYS_FLAG, + load: () => import('./Settings'), +}); diff --git a/app/src/features/tables/keys/state.ts b/app/src/features/tables/keys/state.ts new file mode 100644 index 000000000..0377433ae --- /dev/null +++ b/app/src/features/tables/keys/state.ts @@ -0,0 +1,200 @@ +// The state of a table's spreadsheet keys (ADR 0063): the plugin key, the entry in progress, the active cell inside +// a range, and the cells of the document. Grid mode is a cell selection; Edit mode is the caret in a cell's text. +import { Fragment } from '@tiptap/pm/model'; +import type { Node as PMNode } from '@tiptap/pm/model'; +import { PluginKey, TextSelection } from '@tiptap/pm/state'; +import type { EditorState, Transaction } from '@tiptap/pm/state'; +import { CellSelection, TableMap } from '@tiptap/pm/tables'; +import type { DecorationSet, EditorView } from '@tiptap/pm/view'; +import type { DateOrder } from '../engine/locale'; +import { boxOf, inBox } from './grid'; +import type { Box, GridCell } from './grid'; +import type { TableKeys } from './mode'; + +/** An entry in a cell: where, the cell's content before it, and whether arrows finish it (Enter mode). */ +export interface Entry { + row: number; + column: number; + before: Fragment | null; + mode: 'enter' | 'edit'; +} + +export interface SheetState { + spreadsheet: boolean; + entry: Entry | null; + /** The active cell inside a range, once Enter or Tab moved it; null means the range's anchor. */ + active: GridCell | null; + /** aria-rowindex and aria-colindex on every cell, and the grid roles with spreadsheet keys. Mapped through a + * change inside one cell; rebuilt when cells move or the keys change. */ + indexes: DecorationSet; + /** Whether the editor has focus. A table selected as an object, or focus elsewhere, marks no cell selected. */ + focused: boolean; +} + +export interface SheetMeta { + spreadsheet?: boolean; + entry?: Entry | null; + active?: GridCell | null; + focused?: boolean; +} + +export interface SheetKeysOptions { + spreadsheet: boolean; + announce(text: string): void; + /** Stores the table's choice of keys in its data. */ + setKeys?(keys: TableKeys): void; + /** What a cell shows: a formula's result. Null means its text. The default reads the cell's data-shown. */ + shown?(view: EditorView, at: GridCell): string | null; + /** The order of today's date. Default: month, day, year. */ + dateOrder?(): DateOrder; + /** The language tag for the time. Default: en-US. */ + locale?(): string; + /** Now, for tests. */ + now?(): Date; +} + +export const sheetKeysKey = new PluginKey('opennote.sheetKeys'); + +// ---- The grid of the document + +export interface Placed { + pos: number; + node: PMNode; +} + +const layouts = new WeakMap(); + +/** + * Every grid slot's cell, row by row: its position (before the cell) and node. The slots come from the table's map, + * so a merged cell fills each slot it covers. A table block's document is one table. + */ +export function cellsOf(doc: PMNode): Placed[][] { + const known = layouts.get(doc); + if (known) return known; + const table = doc.firstChild; + const rows: Placed[][] = []; + layouts.set(doc, rows); + if (!table || table.childCount === 0) return rows; + const map = TableMap.get(table); + for (let row = 0; row < map.height; row++) { + const cells: Placed[] = []; + for (let column = 0; column < map.width; column++) { + const offset = map.map[row * map.width + column]; + const node = table.nodeAt(offset); + if (node) cells.push({ pos: offset + 1, node }); + } + rows.push(cells); + } + return rows; +} + +const merges = new WeakMap(); + +/** Whether a cell spans more than one row or column. Spreadsheet keys step one slot at a time, so they wait. */ +export function hasMerged(doc: PMNode): boolean { + const known = merges.get(doc); + if (known !== undefined) return known; + let merged = false; + doc.firstChild?.forEach((row) => + row.forEach((cell) => { + if (Number(cell.attrs.colspan ?? 1) > 1 || Number(cell.attrs.rowspan ?? 1) > 1) merged = true; + }), + ); + merges.set(doc, merged); + return merged; +} + +/** Whether the table's keys are spreadsheet keys now: chosen, and no merged cells. Otherwise document keys work. */ +export function keysOn(state: EditorState): boolean { + return (sheetKeysKey.getState(state)?.spreadsheet ?? false) && !hasMerged(state.doc); +} + +export function textsOf(doc: PMNode): string[][] { + return cellsOf(doc).map((row) => row.map((cell) => cell.node.textContent)); +} + +export const hasHeader = (doc: PMNode) => doc.firstChild?.firstChild?.firstChild?.type.name === 'tableHeader'; + +/** The grid cell of a position inside or before a cell. A merged cell's is its top left slot. */ +export function cellOfPos(doc: PMNode, pos: number): GridCell | null { + const cells = cellsOf(doc); + for (let row = 0; row < cells.length; row++) + for (let column = 0; column < cells[row].length; column++) { + const { pos: start, node } = cells[row][column]; + if (pos >= start && pos < start + node.nodeSize) return { row, column }; + } + return null; +} + +export const placedAt = (doc: PMNode, at: GridCell): Placed | undefined => cellsOf(doc)[at.row]?.[at.column]; + +/** The range of the paragraph's content inside a cell: cells hold one paragraph. */ +export function textRange(cell: Placed): { from: number; to: number } { + const from = cell.pos + 2; + return { from, to: from + (cell.node.firstChild?.content.size ?? 0) }; +} + +// ---- Modes + +export type SheetMode = 'grid' | 'edit'; + +export function modeOf(state: EditorState): SheetMode | null { + if (!keysOn(state)) return null; + if (state.selection instanceof CellSelection) return 'grid'; + return cellOfPos(state.doc, state.selection.head) ? 'edit' : null; +} + +/** Grid mode's range and active cell, or null outside Grid mode. */ +export function gridOf(state: EditorState): { box: Box; anchor: GridCell; head: GridCell; active: GridCell } | null { + const selection = state.selection; + if (!(selection instanceof CellSelection)) return null; + const anchor = cellOfPos(state.doc, selection.$anchorCell.pos); + const head = cellOfPos(state.doc, selection.$headCell.pos); + if (!anchor || !head) return null; + const box = boxOf(anchor, head); + const kept = sheetKeysKey.getState(state)?.active; + const active = kept && inBox(box, kept) ? kept : anchor; + return { box, anchor, head, active }; +} + +/** The cell keys act on: Grid mode's active cell, or the cell with the caret. */ +export function activeCell(state: EditorState): GridCell | null { + return gridOf(state)?.active ?? cellOfPos(state.doc, state.selection.head); +} + +// ---- Transactions + +export function selectCells( + state: EditorState, + anchor: GridCell, + head: GridCell = anchor, + active?: GridCell, +): Transaction | null { + const a = placedAt(state.doc, anchor); + const h = placedAt(state.doc, head); + if (!a || !h) return null; + const tr = state.tr.setSelection(CellSelection.create(state.doc, a.pos, h.pos)); + return tr.setMeta(sheetKeysKey, { active: active ?? null, entry: null } satisfies SheetMeta).scrollIntoView(); +} + +/** The caret at the end of a cell's text, in Edit mode. */ +export function caretAtEnd(tr: Transaction, at: GridCell): Transaction { + const cell = placedAt(tr.doc, at); + if (cell) tr.setSelection(TextSelection.create(tr.doc, textRange(cell).to)); + return tr.scrollIntoView(); +} + +/** Replaces a cell's content with text, or with the given content. */ +export function setCellContent(tr: Transaction, at: GridCell, content: Fragment | string): Transaction { + const cell = placedAt(tr.doc, at); + if (!cell) return tr; + const { from, to } = textRange(cell); + const nodes = typeof content === 'string' ? (content ? tr.doc.type.schema.text(content) : Fragment.empty) : content; + return tr.replaceWith(from, to, nodes); +} + +/** The content of a cell's paragraph. */ +export const contentOf = (doc: PMNode, at: GridCell): Fragment => + placedAt(doc, at)?.node.firstChild?.content ?? Fragment.empty; + +export type Dispatch = (tr: Transaction) => void; diff --git a/app/src/features/tables/keys/tip.test.ts b/app/src/features/tables/keys/tip.test.ts new file mode 100644 index 000000000..28df23288 --- /dev/null +++ b/app/src/features/tables/keys/tip.test.ts @@ -0,0 +1,48 @@ +// @vitest-environment jsdom +// The one-time tip when an older table with numbers switches to spreadsheet keys (PLAN.md decision 4). +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const shown = vi.hoisted(() => [] as { message: string; action?: { label: string; run(): void } }[]); +vi.mock('../../../ui/toast', () => ({ + showToast: (spec: (typeof shown)[number]) => { + shown.push(spec); + return { dismiss: () => {} }; + }, +})); + +import { KEYS_TIP_KEY, keysTip } from './tip'; + +const NUMBERS = [ + ['Item', 'Price'], + ['Tea', '3.50'], +]; +const WORDS = [ + ['Item', 'Note'], + ['Tea', 'green'], +]; + +beforeEach(() => { + localStorage.clear(); + shown.length = 0; +}); +afterEach(() => localStorage.clear()); + +describe('the spreadsheet keys tip', () => { + it('shows once, names the Document keys command, and offers to switch back', () => { + const use = vi.fn(); + expect(keysTip({}, NUMBERS, use)).toBe(true); + expect(shown).toHaveLength(1); + expect(shown[0].message).toContain('Use document keys in this table'); + shown[0].action?.run(); + expect(use).toHaveBeenCalledOnce(); + expect(localStorage.getItem(KEYS_TIP_KEY)).toBe('1'); + expect(keysTip({}, NUMBERS, use)).toBe(false); + expect(shown).toHaveLength(1); + }); + + it('stays quiet for a table that stores its keys or holds only text', () => { + expect(keysTip({ sheet: { table: { keys: 'spreadsheet' } } }, NUMBERS, () => {})).toBe(false); + expect(keysTip({}, WORDS, () => {})).toBe(false); + expect(shown).toHaveLength(0); + }); +}); diff --git a/app/src/features/tables/keys/tip.ts b/app/src/features/tables/keys/tip.ts new file mode 100644 index 000000000..e0df323f3 --- /dev/null +++ b/app/src/features/tables/keys/tip.ts @@ -0,0 +1,37 @@ +// The one-time tip of PLAN.md decision 4. An older table stores no choice of keys. When it holds a number or a +// formula, it switches to spreadsheet keys. The first time that happens, a toast says so. It names the command that +// gives the table document keys again. The device remembers that it showed the tip. +import { t } from '../../../strings/t'; +import { showToast } from '../../../ui/toast'; +import type { Texts } from './grid'; +import { keysOf, storedKeys } from './mode'; + +export const KEYS_TIP_KEY = 'opennote.sheetKeys.tipShown'; + +function shownBefore(): boolean { + try { + return localStorage.getItem(KEYS_TIP_KEY) === '1'; + } catch { + return false; + } +} + +function remember(): void { + try { + localStorage.setItem(KEYS_TIP_KEY, '1'); + } catch { + // The tip may show again in the next session. + } +} + +/** Shows the tip when this table switched to spreadsheet keys on its own and the tip never showed. */ +export function keysTip(data: Record, texts: Texts, useDocumentKeys: () => void): boolean { + if (storedKeys(data) !== null || keysOf(data, texts) !== 'spreadsheet' || shownBefore()) return false; + remember(); + showToast({ + id: 'sheetKeysTip', + message: t('sheetKeys.tip.switched'), + action: { label: t('sheetKeys.tip.useDocument'), run: useDocumentKeys }, + }); + return true; +} diff --git a/app/src/features/tables/register.ts b/app/src/features/tables/register.ts new file mode 100644 index 000000000..29f1c869e --- /dev/null +++ b/app/src/features/tables/register.ts @@ -0,0 +1,4 @@ +// The spreadsheet tables' start-up registrations (ARCHITECTURE.md section 7.1). Each lane that registers at start-up +// adds one import here. The rest of the feature loads when a table first shows. +import './ui/toolbar/register'; +import './keys/register'; diff --git a/app/src/features/tables/sheet/conditional/conditional.test.ts b/app/src/features/tables/sheet/conditional/conditional.test.ts new file mode 100644 index 000000000..aed64f648 --- /dev/null +++ b/app/src/features/tables/sheet/conditional/conditional.test.ts @@ -0,0 +1,637 @@ +// checks-disable-file brand-consistency: the colors here are document data of the file format, not interface styling + +// Conditional formats: the five lane tests, then the rule types, dates, formulas, and drawing. Expected values come +// from hand work: OKLab with Ottosson's published constants (written here as matrices, a different shape from +// oklab.ts), percentiles and averages worked on paper, and spreadsheet serial days counted from 2000-01-01 = 36526. + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { resolveColor } from '../../../../ui/color/parse'; +import { resolve } from '../model/resolve'; +import { rulesAt, setLayerScheme } from '../model/rules'; +import { writeValues } from '../model/cache'; +import { intern } from '../model/styles'; +import { K, R, range, specCase } from '../model/testing'; +import type { CellStyle, Sheet, TableLike } from '../model/types'; +import { conditionalLayer, drawnLayer, barCss } from './layer'; +import { hexToLab, labToHex } from './oklab'; +import { setToday } from './match'; +import { evaluateFormula, setFormulaEvaluator } from './formula'; +import { hybridEvaluator } from './engineEval'; +import { ICON_SETS, barOf } from './visual'; + +const COL = K(1); +const rowsOf = (values: (string | number)[], more: Record[] = []): TableLike => ({ + header: false, + columns: [{ id: COL }, ...more.map((_, i) => ({ id: K(i + 2) }))], + rows: values.map((v, i) => ({ + id: R(i + 1), + cells: { + [COL]: { markdown: String(v) }, + ...Object.fromEntries(more.map((m, j) => [K(j + 2), { markdown: m[i] ?? '' }])), + }, + })), +}); +const withRules = (rules: Record): Sheet => ({ + conditional: Object.fromEntries(Object.entries(rules).map(([id, r]) => [id, { ranges: [{ col: COL }], ...r }])), +}); +const styleRef = (sheet: Sheet, style: CellStyle): string => { + const id = intern(sheet, style); + if (!id) throw new Error('empty style'); + return id; +}; +const at = (sheet: Sheet, table: TableLike, row: number, col = COL) => + resolve(sheet, table, R(row), col, { conditional: conditionalLayer }).style; +const fills = (sheet: Sheet, table: TableLike) => table.rows.map((_, i) => at(sheet, table, i + 1).fill); + +const RED: CellStyle = { fill: '#ff0000' }; +const BLUE: CellStyle = { fill: '#0000ff' }; + +beforeEach(() => setLayerScheme('light')); +afterEach(() => { + setToday(null); + setFormulaEvaluator(); +}); + +/** OKLab by Ottosson's published matrices, written as matrix products. */ +function oklabByMatrix(hex: string): number[] { + const lin = [1, 3, 5].map((i) => { + const c = parseInt(hex.slice(i, i + 2), 16) / 255; + return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; + }); + const M1 = [ + [0.4122214708, 0.5363325363, 0.0514459929], + [0.2119034982, 0.6806995451, 0.1073969566], + [0.0883024619, 0.2817188376, 0.6299787005], + ]; + const M2 = [ + [0.2104542553, 0.793617785, -0.0040720468], + [1.9779984951, -2.428592205, 0.4505937099], + [0.0259040371, 0.7827717662, -0.808675766], + ]; + const lms = M1.map((row) => Math.cbrt(row.reduce((s, m, j) => s + m * lin[j], 0))); + return M2.map((row) => row.reduce((s, m, j) => s + m * lms[j], 0)); +} + +const channels = (hex: string) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)); + +describe('color scales', () => { + const brick = resolveColor('brick', 'light') as string; + const amber = resolveColor('amber', 'light') as string; + const fern = resolveColor('fern', 'light') as string; + const stops = [ + { at: 'min', color: 'brick' }, + { at: 'percentile', value: '50', color: 'amber' }, + { at: 'max', color: 'fern' }, + ]; + const sheet = withRules({ c1: { order: 1, type: 'scale', stops } }); + const table = rowsOf([...range(1, 10), 5.5]); + + it('draws the end, middle, and end colors, and OKLab mixes in between', () => { + expect(at(sheet, table, 1).fill).toBe(brick); + expect(at(sheet, table, 11).fill).toBe(amber); // 5.5 is the 50th percentile of 1..10 and 5.5 + expect(at(sheet, table, 10).fill).toBe(fern); + // 3 is (3 - 1) / (5.5 - 1) of the way from brick to amber. + const t = (3 - 1) / (5.5 - 1); + const a = oklabByMatrix(brick); + const b = oklabByMatrix(amber); + const mixed = a.map((x, i) => x + (b[i] - x) * t); + const got = hexToLab(at(sheet, table, 3).fill as string) as unknown as number[]; + got.forEach((x, i) => expect(x).toBeCloseTo(mixed[i], 2)); + // The byte values agree with the mix of the independent lab values to one step. + const want = channels(labToHex(mixed as [number, number, number])); + channels(at(sheet, table, 3).fill as string).forEach((x, i) => + expect(Math.abs(x - want[i])).toBeLessThanOrEqual(1), + ); + }); + + it('skips text and blank cells, and the header row', () => { + const t = rowsOf(['Name', 1, 'x', '', 3]); + const s = withRules({ c1: { order: 1, type: 'scale', stops: stops.slice(0, 1).concat(stops.slice(2)) } }); + expect(at(s, { ...t, header: true }, 1).fill).toBeUndefined(); + expect(at(s, t, 3).fill).toBeUndefined(); + expect(at(s, t, 4).fill).toBeUndefined(); + }); + + it('follows the dark scheme', () => { + setLayerScheme('dark'); + expect(at(sheet, table, 1).fill).toBe(resolveColor('brick', 'dark')); + }); + + it('known OKLab values: white is 1, 0, 0 and black is 0', () => { + expect(hexToLab('#ffffff')?.map((x) => Math.round(x * 1000) / 1000)).toEqual([1, 0, 0]); + expect(hexToLab('#000000')).toEqual([0, 0, 0]); + }); +}); + +describe('top, bottom, and average', () => { + it('top 10 percent of 1 to 20 is 19 and 20', () => { + const sheet = withRules({ c1: { order: 1, type: 'top', rank: 10, percent: true } }); + sheet.styles = {}; + (sheet.conditional as Record).c1.style = styleRef(sheet, RED); + const table = rowsOf(range(1, 20)); + const hit = table.rows.flatMap((_, i) => (at(sheet, table, i + 1).fill ? [i + 1] : [])); + expect(hit).toEqual([19, 20]); + }); + + it('top 3 and bottom 2 items, with ties kept', () => { + const sheet = withRules({ + c1: { order: 1, type: 'top', rank: 3 }, + c2: { order: 2, type: 'top', rank: 2, bottom: true }, + }); + sheet.styles = {}; + const c = sheet.conditional as Record; + c.c1.style = styleRef(sheet, RED); + c.c2.style = styleRef(sheet, BLUE); + const table = rowsOf([5, 9, 1, 9, 3, 7, 1]); + // top 3 of the sorted values 9, 9, 7 is everything at, or above, 7; bottom 2 is everything at or below 1. + expect(fills(sheet, table)).toEqual([undefined, '#ff0000', '#0000ff', '#ff0000', undefined, '#ff0000', '#0000ff']); + }); + + it('above average of 1, 2, 3, and 10 is 10 only; below average is the three small ones', () => { + const table = rowsOf([1, 2, 3, 10]); + const above = withRules({ c1: { order: 1, type: 'average' } }); + above.styles = {}; + (above.conditional as Record).c1.style = styleRef(above, RED); + expect(fills(above, table)).toEqual([undefined, undefined, undefined, '#ff0000']); + const below = withRules({ c1: { order: 1, type: 'average', below: true } }); + below.styles = {}; + (below.conditional as Record).c1.style = styleRef(below, RED); + expect(fills(below, table)).toEqual(['#ff0000', '#ff0000', '#ff0000', undefined]); + }); + + it('one standard deviation above the average: 1..9 has sample deviation sqrt(7.5)', () => { + const sheet = withRules({ c1: { order: 1, type: 'average', stdDev: 1 } }); + sheet.styles = {}; + (sheet.conditional as Record).c1.style = styleRef(sheet, RED); + // average 5, deviation 2.7386, so the edge is 7.7386: only 8 and 9 are over it. + const table = rowsOf(range(1, 9)); + expect(table.rows.flatMap((_, i) => (at(sheet, table, i + 1).fill ? [i + 1] : []))).toEqual([8, 9]); + }); +}); + +describe('priority and stop if true', () => { + const build = (stop: boolean) => { + const sheet = withRules({ + c1: { order: 1, type: 'formula', a: 'A1>5', stop }, + c2: { order: 2, type: 'cell', op: 'greater', a: '0' }, + }); + sheet.styles = {}; + const c = sheet.conditional as Record; + c.c1.style = styleRef(sheet, { font: { bold: true } }); + c.c2.style = styleRef(sheet, BLUE); + return sheet; + }; + const table = rowsOf([3, 8]); + + it('a matching rule with stop prevents the fill of a lower rule', () => { + const sheet = build(true); + expect(at(sheet, table, 2).font?.bold).toBe(true); + expect(at(sheet, table, 2).fill).toBeUndefined(); + expect(at(sheet, table, 1).fill).toBe('#0000ff'); // 3 does not match the formula, so the lower rule applies + }); + + it('without stop both apply, and the lower number wins a clash', () => { + const sheet = build(false); + expect(at(sheet, table, 2).font?.bold).toBe(true); + expect(at(sheet, table, 2).fill).toBe('#0000ff'); + sheet.conditional = { + c1: { ...(sheet.conditional as Record).c1, order: 2 }, + c2: { ...(sheet.conditional as Record).c2, order: 1 }, + }; + const red = styleRef(sheet, RED); + (sheet.conditional as Record).c1.style = red; + expect(at(sheet, table, 2).fill).toBe('#0000ff'); // order 1 (c2) beats order 2 (c1) + }); +}); + +describe('data bars', () => { + const bar = { + order: 1, + type: 'bar', + color: 'indigo', + min: { at: 'num', value: '0' }, + max: { at: 'num', value: '100' }, + }; + const decorate = (sheet: Sheet, table: TableLike, row: number) => + drawnLayer({ row: row - 1, col: 0 }, sheet, table, 'light'); + + it('0 to 100 with 25 draws a bar 25 percent wide from the left edge', () => { + const sheet = withRules({ c1: bar }); + const table = rowsOf([25, 100, 0]); + const d = decorate(sheet, table, 1); + expect(d?.data['data-bar']).toBe('right'); + expect(d?.vars['--cell-bar']).toContain('25%'); + expect(decorate(sheet, table, 2)?.vars['--cell-bar']).toContain('100%'); + }); + + it('a negative value draws left of the axis', () => { + const sheet = withRules({ c1: { ...bar, min: { at: 'num', value: '-50' }, max: { at: 'num', value: '50' } } }); + const table = rowsOf([-25, 25]); + expect(decorate(sheet, table, 1)?.data['data-bar']).toBe('left'); + expect(decorate(sheet, table, 2)?.data['data-bar']).toBe('right'); + expect(decorate(sheet, table, 1)?.vars['--cell-bar']).toContain('to left'); + }); + + it('automatic ends include zero; bar only hides the value; border and solid fill', () => { + const sheet = withRules({ + c1: { order: 1, type: 'bar', color: 'fern', barOnly: true, gradient: false, border: 'ink' }, + }); + const table = rowsOf([2, 4]); + const d = decorate(sheet, table, 1); + expect(d?.data['data-bar-only']).toBe(''); + expect(d?.vars['--cell-bar']).toContain('50%'); + expect(decorate(sheet, rowsOf(['text']), 1)).toBeUndefined(); + }); + + it('barCss keeps the bar inside the cell', () => { + const css = barCss({ + left: 40, + width: 60, + side: 'right', + axis: 40, + color: '#336699', + gradient: true, + barOnly: false, + }); + expect(css).toContain('transparent 0 40%'); + }); +}); + +describe('icon sets', () => { + it('3 arrows on 1, 50, and 99 give down, side, and up, each with its text', () => { + const sheet = withRules({ c1: { order: 1, type: 'icons', set: '3Arrows' } }); + const table = rowsOf([1, 50, 99]); + const d = [1, 2, 3].map((r) => drawnLayer({ row: r - 1, col: 0 }, sheet, table, 'light')); + expect(d.map((x) => x?.data['data-icon-glyph'])).toEqual(['▼', '▶', '▲']); + expect(d.map((x) => x?.data['data-icon-text'])).toEqual(['Down arrow', 'Sideways arrow', 'Up arrow']); + expect(d[0]?.vars['--cell-icon-color']).toBe(resolveColor('brick', 'light')); + }); + + it('reverse, icon only, and numeric thresholds with gte false', () => { + const sheet = withRules({ + c1: { + order: 1, + type: 'icons', + set: '3Arrows', + reverse: true, + iconOnly: true, + thresholds: [ + { at: 'num', value: '10' }, + { at: 'num', value: '20', gte: false }, + ], + }, + }); + const table = rowsOf([5, 10, 20, 21]); + const idx = [1, 2, 3, 4].map( + (r) => drawnLayer({ row: r - 1, col: 0 }, sheet, table, 'light')?.data['data-icon-index'], + ); + // Without reverse: 5 -> 0, 10 -> 1, 20 -> 1 (not above 20), 21 -> 2. Reversed: 2, 1, 1, 0. + expect(idx).toEqual(['2', '1', '1', '0']); + expect(drawnLayer({ row: 0, col: 0 }, sheet, table, 'light')?.data['data-icon-only']).toBe(''); + }); + + it('every set has icons from 3 to 5 with text', () => { + for (const [name, set] of Object.entries(ICON_SETS)) { + expect(set.length).toBe(Number(name[0])); + set.forEach((icon) => expect(icon.text().length).toBeGreaterThan(0)); + } + expect(Object.keys(ICON_SETS)).toHaveLength(13); + }); + + it('5 quarters have level text', () => { + expect(ICON_SETS['5Quarters'][2].text()).toBe('Quarter fill 3 of 5'); + }); +}); + +describe('other rule types', () => { + const run = (rule: object, values: (string | number)[], extra?: Record[]): number[] => { + const sheet = withRules({ c1: { order: 1, ...rule } }); + sheet.styles = {}; + (sheet.conditional as Record).c1.style = styleRef(sheet, RED); + const table = rowsOf(values, extra); + return table.rows.flatMap((_, i) => (at(sheet, table, i + 1).fill ? [i + 1] : [])); + }; + + it('cell value operators', () => { + const v = [1, 5, 10, 'x', '']; + expect(run({ type: 'cell', op: 'greater', a: '4' }, v)).toEqual([2, 3, 4]); // text sorts after numbers + expect(run({ type: 'cell', op: 'between', a: '2', b: '10' }, v)).toEqual([2, 3]); + expect(run({ type: 'cell', op: 'notBetween', a: '2', b: '10' }, v)).toEqual([1, 4, 5]); + expect(run({ type: 'cell', op: 'equal', a: '5' }, v)).toEqual([2]); + expect(run({ type: 'cell', op: 'notEqual', a: '5' }, v)).toEqual([1, 3, 4, 5]); + expect(run({ type: 'cell', op: 'lessOrEqual', a: '5' }, v)).toEqual([1, 2, 5]); + expect(run({ type: 'cell', op: 'equal', a: '"X"' }, v)).toEqual([4]); // text compares without case + }); + + it('text rules ignore case and skip blanks', () => { + const v = ['Apple', 'banana', 'Grape', '']; + expect(run({ type: 'text', op: 'contains', text: 'AP' }, v)).toEqual([1, 3]); + expect(run({ type: 'text', op: 'notContains', text: 'ap' }, v)).toEqual([2]); + expect(run({ type: 'text', op: 'begins', text: 'b' }, v)).toEqual([2]); + expect(run({ type: 'text', op: 'ends', text: 'E' }, v)).toEqual([1, 3]); + }); + + it('blanks, errors, duplicates, and unique values', () => { + const v = ['a', '', 'A', '#N/A', 'b', '#DIV/0!']; + expect(run({ type: 'blank' }, v)).toEqual([2]); + expect(run({ type: 'noBlank' }, v)).toEqual([1, 3, 4, 5, 6]); + expect(run({ type: 'error' }, v)).toEqual([4, 6]); + expect(run({ type: 'noError' }, v)).toEqual([1, 2, 3, 5]); + expect(run({ type: 'duplicate' }, v)).toEqual([1, 3]); + expect(run({ type: 'unique' }, v)).toEqual([4, 5, 6]); + }); + + it('a rule on more than one range counts them together', () => { + const sheet = withRules({ c1: { order: 1, type: 'duplicate', ranges: [{ col: COL }, { col: K(2) }] } }); + sheet.styles = {}; + (sheet.conditional as Record).c1.style = styleRef(sheet, RED); + const table = rowsOf(['a', 'b'], [{ 0: 'b', 1: 'c' }]); + expect(at(sheet, table, 2).fill).toBe('#ff0000'); + expect(at(sheet, table, 1).fill).toBeUndefined(); + expect(at(sheet, table, 1, K(2)).fill).toBe('#ff0000'); + }); + + it('an unknown rule type and a missing style do nothing harmful', () => { + expect(run({ type: 'future' }, [1, 2])).toEqual([]); + const sheet = withRules({ c1: { order: 1, type: 'blank' } }); + expect(at(sheet, rowsOf(['']), 1).fill).toBeUndefined(); + }); +}); + +describe('dates', () => { + // 2026-11-08 is a Sunday. Serial 46334: 2026-01-01 is 36526 + 26 * 365 + 7 leap days = 46023, plus 311 days. + const TODAY = 46334; + const day = (offset: number) => { + const d = new Date(Date.UTC(2026, 10, 8 + offset)); + return d.toISOString().slice(0, 10); + }; + const dates = [-8, -7, -2, -1, 0, 1, 6, 7, -20, 30].map(day); + const run = (period: string): number[] => { + setToday(() => TODAY); + const sheet = withRules({ c1: { order: 1, type: 'date', period } }); + sheet.styles = {}; + (sheet.conditional as Record).c1.style = styleRef(sheet, RED); + const table = rowsOf(dates); + return table.rows.flatMap((_, i) => (at(sheet, table, i + 1).fill ? [i + 1] : [])); + }; + + it('reads an ISO date as the serial day', () => { + setToday(() => TODAY); + const sheet = withRules({ c1: { order: 1, type: 'date', period: 'today' } }); + sheet.styles = {}; + (sheet.conditional as Record).c1.style = styleRef(sheet, RED); + expect(at(sheet, rowsOf(['2026-11-08']), 1).fill).toBe('#ff0000'); + }); + + it('days and weeks (weeks start on Sunday)', () => { + expect(run('today')).toEqual([5]); + expect(run('yesterday')).toEqual([4]); + expect(run('tomorrow')).toEqual([6]); + expect(run('last7Days')).toEqual([3, 4, 5]); // -2, -1, 0; -7 is eight days back counting today as one + expect(run('thisWeek')).toEqual([5, 6, 7]); // Sunday the 8th to Saturday the 14th + expect(run('lastWeek')).toEqual([2, 3, 4]); // Sunday the 1st to Saturday the 7th: -7, -2, -1 + expect(run('nextWeek')).toEqual([8]); // Sunday the 15th to Saturday the 21st: +7 + }); + + it('months', () => { + expect(run('thisMonth')).toEqual([2, 3, 4, 5, 6, 7, 8]); // November 1 to 15 + expect(run('lastMonth')).toEqual([1, 9]); // October 31 and October 19 + expect(run('nextMonth')).toEqual([10]); // +30 is December 8 + }); +}); + +describe('formula rules', () => { + const run = (a: string, table: TableLike): number[] => { + const sheet = withRules({ c1: { order: 1, type: 'formula', a } }); + sheet.styles = {}; + (sheet.conditional as Record).c1.style = styleRef(sheet, RED); + return table.rows.flatMap((_, i) => (at(sheet, table, i + 1).fill ? [i + 1] : [])); + }; + + it('references are relative to the first range top-left cell', () => { + const table = rowsOf([1, 2, 3, 4], [{ 0: '5', 1: '1', 2: '9', 3: '2' }]); + expect(run('A1>B1', table)).toEqual([2, 4]); // 2 > 1 and 4 > 2 + expect(run('B1>A1', table)).toEqual([1, 3]); + expect(run('$B$1>A1', table)).toEqual([1, 2, 3, 4]); // B1 is 5, above every value of the first column + }); + + it('operators, functions, and text', () => { + const table = rowsOf([1, 2, 3, 4, 5, 6]); + expect(run('MOD(ROW(),2)=0', table)).toEqual([2, 4, 6]); + expect(run('AND(A1>2,A1<5)', table)).toEqual([3, 4]); + expect(run('OR(A1=1,A1=6)', table)).toEqual([1, 6]); + expect(run('NOT(ISEVEN(A1))', table)).toEqual([1, 3, 5]); + expect(run('A1*2+1>9', table)).toEqual([5, 6]); + expect(run('IF(A1>3,TRUE,FALSE)', table)).toEqual([4, 5, 6]); + expect(run('COUNTIF($A$1:$A$6,A1)>1', rowsOf([1, 2, 1]))).toEqual([1, 3]); + expect(run('A1>AVERAGE($A$1:$A$6)', table)).toEqual([4, 5, 6]); + expect(run('LEN(A1)>3', rowsOf(['ab', 'abcd']))).toEqual([2]); + expect(run('A1&"x"="bx"', rowsOf(['a', 'b']))).toEqual([2]); + expect(run('ISBLANK(A1)', rowsOf(['a', '']))).toEqual([2]); + }); + + it('a formula it cannot read matches nothing, and the engine can replace the evaluator', () => { + const table = rowsOf([1, 2]); + expect(run('SUM(', table)).toEqual([]); + expect(run('NOSUCH(A1)', table)).toEqual([]); + setFormulaEvaluator(() => ({ kind: 'bool', b: true })); + expect(run('anything', table)).toEqual([1, 2]); + }); +}); + +describe('the rule applies to the right cells', () => { + it('skips the header row and cells outside the ranges', () => { + const sheet = withRules({ c1: { order: 1, type: 'cell', op: 'greater', a: '0' } }); + sheet.styles = {}; + (sheet.conditional as Record).c1.style = styleRef(sheet, RED); + const table = { ...rowsOf([5, 5]), header: true }; + expect(at(sheet, table, 1).fill).toBeUndefined(); + expect(at(sheet, table, 2).fill).toBe('#ff0000'); + }); +}); + +const decorate = (sheet: Sheet, table: TableLike, row: number) => + drawnLayer({ row: row - 1, col: 0 }, sheet, table, 'light'); +const barAt = (sheet: Sheet, table: TableLike, row: number) => { + const rule = rulesAt(sheet, table, 'conditional', row - 1, 0)[0]; + return barOf({ sheet, table, rule, row: row - 1, col: 0 }, 'light'); +}; +const styled = (rules: Record): Sheet => { + const sheet = withRules(rules); + sheet.styles = {}; + const id = styleRef(sheet, RED); + for (const r of Object.values(sheet.conditional as Record)) + if (!['bar', 'icons', 'scale'].includes(r.type)) r.style = id; + return sheet; +}; + +describe('verification fixes 1', () => { + it('an icon set named after an Object.prototype member draws nothing and cannot break the draw', () => { + const table = rowsOf([1, 2, 3]); + for (const set of ['constructor', 'toString', '__proto__', 'hasOwnProperty']) { + const sheet = withRules({ c1: { order: 1, type: 'icons', set } }); + expect(decorate(sheet, table, 2), set).toBeUndefined(); + } + }); + + it('a layer that throws leaves the cell plain', () => { + const table = rowsOf([1]); + const sheet = withRules({ c1: { order: 1, type: 'bar' } }); + const hostile = { ...table, columns: undefined } as unknown as TableLike; + expect(() => decorate(sheet, hostile, 1)).not.toThrow(); + }); + + it('a bar grows from the minimum: 50 to 100 draws 0, 50, and 100 percent', () => { + const sheet = withRules({ + c1: { order: 1, type: 'bar', min: { at: 'num', value: '50' }, max: { at: 'num', value: '100' } }, + }); + const table = rowsOf([50, 75, 100]); + expect([1, 2, 3].map((r) => barAt(sheet, table, r)?.width)).toEqual([0, 50, 100]); + }); + + it('a bar with the minimum at "min" on 2, 3, 4 draws 0, 50, and 100 percent', () => { + const sheet = withRules({ c1: { order: 1, type: 'bar', min: { at: 'min' }, max: { at: 'max' } } }); + const table = rowsOf([2, 3, 4]); + expect([1, 2, 3].map((r) => barAt(sheet, table, r)?.width)).toEqual([0, 50, 100]); + }); + + it('a bar with the minimum as a percent of the range starts there', () => { + // 10..30: 50 percent is 20, so 20 draws 0 and 30 draws full width. + const sheet = withRules({ + c1: { order: 1, type: 'bar', min: { at: 'percent', value: '50' }, max: { at: 'max' } }, + }); + const table = rowsOf([10, 20, 30]); + expect([1, 2, 3].map((r) => barAt(sheet, table, r)?.width)).toEqual([0, 0, 100]); + }); +}); + +describe('verification fixes 2', () => { + it('a matching rule with stop hides the bars and icons below it', () => { + const sheet = styled({ + c1: { order: 1, type: 'formula', a: 'A1>5', stop: true }, + c2: { order: 2, type: 'bar' }, + c3: { order: 3, type: 'icons', set: '3Arrows' }, + }); + const table = rowsOf([10, 1, 20]); + expect(decorate(sheet, table, 1)).toBeUndefined(); + expect(decorate(sheet, table, 3)).toBeUndefined(); + const small = decorate(sheet, table, 2); + expect(small?.data['data-bar']).toBe('right'); + expect(small?.data['data-icon-glyph']).toBeDefined(); + }); + + it('a rule that matches without stop leaves the bars below it', () => { + const sheet = styled({ + c1: { order: 1, type: 'formula', a: 'A1>5' }, + c2: { order: 2, type: 'bar' }, + }); + expect(decorate(sheet, rowsOf([10, 1]), 1)?.data['data-bar']).toBe('right'); + }); + + it('whole column and whole row references read the table', () => { + const table = rowsOf([1, 2, 3], [{ 0: '10', 1: '20', 2: '90' }]); + const ctx = (row: number) => ({ + sheet: {} as Sheet, + table, + at: { row, col: 0 }, + anchor: { row: 0, col: 0 }, + }); + expect(evaluateFormula('MAX(B:B)', ctx(0))).toEqual({ kind: 'num', n: 90 }); + expect(evaluateFormula('AVERAGE($A:$A)', ctx(0))).toEqual({ kind: 'num', n: 2 }); + expect(evaluateFormula('SUM(1:1)', ctx(0))).toEqual({ kind: 'num', n: 11 }); + expect(evaluateFormula('SUM($2:$3)', ctx(0))).toEqual({ kind: 'num', n: 2 + 20 + 3 + 90 }); + }); +}); + +describe('verification fixes 3', () => { + it('every formula level of the spec fixture of rule types reads without #NAME?', () => { + const c = specCase('conditional-types.json'); + const table = c.data; + const sheet = c.data.sheet as Sheet; + const seen: string[] = []; + const visit = (v: unknown): void => { + if (Array.isArray(v)) v.forEach(visit); + else if (v && typeof v === 'object') { + const o = v as Record; + if (o.at === 'formula' && typeof o.value === 'string') seen.push(o.value); + if (o.type === 'formula' && typeof o.a === 'string') seen.push(o.a); + Object.values(o).forEach(visit); + } + }; + visit(sheet.conditional); + expect(seen).toContain('MAX(B:B)'); + expect(seen).toContain('AVERAGE(B:B)'); + for (const f of seen) { + const v = evaluateFormula(f, { sheet, table, at: { row: 1, col: 1 }, anchor: { row: 0, col: 0 } }); + expect(v, f).not.toEqual({ kind: 'error', e: '#NAME?' }); + } + }); + + it('a range far wider than the table is clamped and does not stall the draw', () => { + const table = rowsOf(Array.from({ length: 200 }, (_, i) => i)); + const sheet = styled({ c1: { order: 1, type: 'formula', a: 'SUM($A$1:$ZZZ$1048576)>0' } }); + const started = performance.now(); + for (let i = 1; i <= 200; i++) at(sheet, table, i); + expect(performance.now() - started).toBeLessThan(1500); + }); +}); + +describe('verification fixes 4', () => { + it('statistics follow the value cache when it is written in place', () => { + const cells = ['=1', '=2', '=3']; + const table: TableLike = { + header: false, + columns: [{ id: COL }], + rows: cells.map((m, i) => ({ id: R(i + 1), cells: { [COL]: { markdown: m } } })), + }; + const sheet = styled({ c1: { order: 1, type: 'top', rank: 1 } }); + const write = (values: number[]) => + writeValues(sheet, table, new Map(values.map((n, i) => [R(i + 1), new Map([[COL, n]])]))); + write([1, 2, 3]); + expect(fills(sheet, table)).toEqual([undefined, undefined, '#ff0000']); + write([9, 2, 3]); + expect(fills(sheet, table)).toEqual(['#ff0000', undefined, undefined]); + }); + + it('a duplicate rule over 2,000 rows draws within a budget', () => { + const table = rowsOf(Array.from({ length: 2000 }, (_, i) => i % 1000)); + const sheet = styled({ c1: { order: 1, type: 'formula', a: 'COUNTIF($A$1:$A$2000,A1)>1' } }); + const started = performance.now(); + for (let i = 1; i <= 2000; i++) at(sheet, table, i); + const first = performance.now() - started; + expect(at(sheet, table, 1).fill).toBe('#ff0000'); + expect(first).toBeLessThan(2500); + }); + + it('the full column and the full sheet read within a budget', () => { + const table = rowsOf(Array.from({ length: 300 }, (_, i) => i)); + const sheet = styled({ c1: { order: 1, type: 'formula', a: 'COUNTIF($A:$A,A1)>0' } }); + const started = performance.now(); + for (let i = 1; i <= 300; i++) at(sheet, table, i); + expect(performance.now() - started).toBeLessThan(1500); + }); +}); + +describe('verification fixes 5', () => { + it('a formula with a function the built-in reader lacks runs on the engine', () => { + setFormulaEvaluator(hybridEvaluator); + const table = rowsOf([1, 2, 3, 10]); + // MEDIAN of 1, 2, 3, 10 is 2.5 (hand-worked), so the rows above it are 3 and 10. + const sheet = styled({ c1: { order: 1, type: 'formula', a: 'A1>MEDIAN($A$1:$A$4)' } }); + expect(fills(sheet, table)).toEqual([undefined, undefined, '#ff0000', '#ff0000']); + }); + + it('an unknown function still matches nothing and does not throw on the engine', () => { + setFormulaEvaluator(hybridEvaluator); + const table = rowsOf([1, 2]); + const sheet = styled({ c1: { order: 1, type: 'formula', a: 'NOSUCHFUNCTION(A1)' } }); + expect(fills(sheet, table)).toEqual([undefined, undefined]); + }); + + it('the levels of the color-only icon sets differ by shape', () => { + for (const name of ['3TrafficLights', '3Flags']) { + const glyphs = ICON_SETS[name].map((d) => d.glyph); + expect(new Set(glyphs).size, name).toBe(3); + } + }); +}); diff --git a/app/src/features/tables/sheet/conditional/contribute.test.ts b/app/src/features/tables/sheet/conditional/contribute.test.ts new file mode 100644 index 000000000..012d02e3a --- /dev/null +++ b/app/src/features/tables/sheet/conditional/contribute.test.ts @@ -0,0 +1,18 @@ +// @vitest-environment jsdom +import { describe, expect, it } from 'vitest'; +import { commands } from '../../../../registries'; +import { sheetMenu } from '../registry'; +import { MANAGE_COMMAND, MANAGE_EVENT } from './contribute'; + +describe('the conditional lane contribution', () => { + it('registers the manage command, a Format menu item, and a context menu entry, and the command asks for the manager', () => { + expect(commands.get(MANAGE_COMMAND)).toBeDefined(); + expect(sheetMenu('format').some((i) => i.command === MANAGE_COMMAND)).toBe(true); + let asked = 0; + const listen = () => asked++; + document.addEventListener(MANAGE_EVENT, listen); + void commands.get(MANAGE_COMMAND)?.run({} as never, undefined); + document.removeEventListener(MANAGE_EVENT, listen); + expect(asked).toBe(1); + }); +}); diff --git a/app/src/features/tables/sheet/conditional/contribute.ts b/app/src/features/tables/sheet/conditional/contribute.ts new file mode 100644 index 000000000..e83a1ec6f --- /dev/null +++ b/app/src/features/tables/sheet/conditional/contribute.ts @@ -0,0 +1,43 @@ +// The conditional lane's contribution to the spreadsheet (registry.ts): it connects the rules to the model's +// conditional layer and to the render lane's cells, and adds the command that opens the rule manager with its +// Format menu item and its table context menu entry. +import { contribute } from '../registry'; +import { installConditional } from './layer'; + +installConditional(); + +/** The command that opens the rule manager. The sheet host listens for the event and shows the manager. */ +export const MANAGE_COMMAND = 'sheet.conditional.manage'; +export const MANAGE_EVENT = 'opennote:sheet-conditional-manage'; + +contribute( + { + kind: 'command', + lane: 'conditional', + command: { + id: MANAGE_COMMAND, + title: 'sheetConditional.command.manage', + keywords: 'sheetConditional.command.manageKeywords', + category: 'table', + run: () => { + document.dispatchEvent(new CustomEvent(MANAGE_EVENT)); + }, + }, + }, + { + kind: 'menuItem', + item: { + id: 'conditional.manage', + lane: 'conditional', + menu: 'format', + command: MANAGE_COMMAND, + group: 'conditional', + order: 1, + }, + }, + { + kind: 'contextMenu', + lane: 'conditional', + item: { id: 'conditional.manage', command: MANAGE_COMMAND, group: 'format', order: 50 }, + }, +); diff --git a/app/src/features/tables/sheet/conditional/engineEval.ts b/app/src/features/tables/sheet/conditional/engineEval.ts new file mode 100644 index 000000000..c69a625e0 --- /dev/null +++ b/app/src/features/tables/sheet/conditional/engineEval.ts @@ -0,0 +1,136 @@ +// Formulas of rules that run on the engine (DESIGN 13.5). The built-in reader (formula.ts) is fast for the forms +// rules use most. Any formula it cannot read, such as one that calls WEEKDAY, VLOOKUP, or SUMPRODUCT, is compiled by +// the engine instead: parsed with its parser, shifted from the rule's anchor cell to the asked cell, and run on the +// table's values with the engine's own functions. + +import type { Node } from '../../../../core/expr'; +import { compile, newCtx } from '../../engine/formula/compile'; +import { parseFormula } from '../../engine/formula/parser'; +import { DEFAULT_ENV } from '../../engine/recalc'; +import { ERROR_TOKENS, errorFromToken, isError, type Value } from '../../engine/values'; +import { own } from '../model/json'; +import { simpleEvaluator, type FormulaContext, type FormulaEvaluator } from './formula'; +import { BLANK, valueOfCell, type CellValue } from './value'; + +interface Kept { + rows: unknown; + values: unknown; + stamp: unknown; + cols: Value[][]; + compiled: Map | null>; +} +const kept = new WeakMap(); +const MAX_COMPILED = 512; + +function toValue(v: CellValue): Value { + switch (v.kind) { + case 'num': + return v.n; + case 'text': + return v.s; + case 'bool': + return v.b; + case 'error': + return errorFromToken(v.e) ?? errorFromToken('#VALUE!'); + default: + return null; + } +} + +function keptFor(ctx: FormulaContext): Kept { + const values = own(ctx.sheet, 'values'); + const stamp = own(ctx.sheet, 'valuesOf'); + const known = kept.get(ctx.table); + if (known && known.rows === ctx.table.rows && known.values === values && known.stamp === stamp) return known; + const cols: Value[][] = ctx.table.columns.map((_, c) => + ctx.table.rows.map((_r, r) => toValue(valueOfCell(ctx.sheet, ctx.table, r, c))), + ); + const fresh: Kept = { rows: ctx.table.rows, values, stamp, cols, compiled: new Map() }; + kept.set(ctx.table, fresh); + return fresh; +} + +/** A copy of the tree with every relative cell and range moved by the offset. Whole columns and rows stay. */ +function shifted(node: Node, dr: number, dc: number): Node { + if (node.type === 'cell') { + return { + ...node, + col: node.abs?.col ? node.col : Math.max(0, node.col + dc), + row: node.abs?.row ? node.row : Math.max(0, node.row + dr), + }; + } + if (node.type === 'range') { + const [a, b] = node.abs ?? [ + { col: false, row: false }, + { col: false, row: false }, + ]; + const wholeCol = node.row2 === Infinity; + const wholeRow = node.col2 === Infinity; + return { + ...node, + col1: a.col || wholeRow ? node.col1 : Math.max(0, node.col1 + dc), + row1: a.row || wholeCol ? node.row1 : Math.max(0, node.row1 + dr), + col2: b.col || wholeRow ? node.col2 : Math.max(0, node.col2 + dc), + row2: b.row || wholeCol ? node.row2 : Math.max(0, node.row2 + dr), + }; + } + const out: Record = { ...node }; + for (const [key, value] of Object.entries(node)) { + if (Array.isArray(value)) out[key] = value.map((x) => (isNode(x) ? shifted(x, dr, dc) : x)); + else if (isNode(value)) out[key] = shifted(value, dr, dc); + } + return out as unknown as Node; +} + +const isNode = (x: unknown): x is Node => + typeof x === 'object' && x !== null && typeof (x as { type?: unknown }).type === 'string'; + +function toCell(v: Value): CellValue { + if (isError(v)) return { kind: 'error', e: ERROR_TOKENS[v.error] }; + if (typeof v === 'number') return Number.isFinite(v) ? { kind: 'num', n: v } : { kind: 'error', e: '#NUM!' }; + if (typeof v === 'boolean') return { kind: 'bool', b: v }; + if (typeof v === 'string') return v === '' ? BLANK : { kind: 'text', s: v }; + if (v !== null && typeof v === 'object' && 'data' in v) return toCell((v.data[0] ?? null) as Value); + return BLANK; +} + +/** Runs one formula on the engine; an error value when it does not read or run. */ +export const engineEvaluator: FormulaEvaluator = (formula, fctx) => { + const state = keptFor(fctx); + const dr = fctx.at.row - fctx.anchor.row; + const dc = fctx.at.col - fctx.anchor.col; + const key = `${formula}|${dr}|${dc}`; + let fn = state.compiled.get(key); + if (fn === undefined) { + const parsed = parseFormula(formula); + fn = null; + if (parsed.ok) { + const scope = { + columns: fctx.table.columns.map((col) => ({ id: col.id, name: col.id })), + rowCount: fctx.table.rows.length, + env: DEFAULT_ENV, + }; + fn = compile(shifted(parsed.node, dr, dc), scope, { col: fctx.at.col, row: fctx.at.row }); + } + if (state.compiled.size >= MAX_COMPILED) state.compiled.clear(); + state.compiled.set(key, fn); + } + if (!fn) return { kind: 'error', e: '#NAME?' }; + const ctx = newCtx(state.cols); + ctx.row = fctx.at.row; + ctx.col = fctx.at.col; + return toCell(fn.fn(ctx)); +}; + +/** The simple reader first; the engine for what it cannot read, so no engine function gives #NAME? to a rule. */ +export const hybridEvaluator: FormulaEvaluator = (formula, ctx) => { + const quick = simpleEvaluator(formula, ctx); + if (quick.kind === 'error' && quick.e === '#NAME?') { + try { + return engineEvaluator(formula, ctx); + } catch { + return quick; + } + } + return quick; +}; diff --git a/app/src/features/tables/sheet/conditional/formula.ts b/app/src/features/tables/sheet/conditional/formula.ts new file mode 100644 index 000000000..d67cd9fde --- /dev/null +++ b/app/src/features/tables/sheet/conditional/formula.ts @@ -0,0 +1,445 @@ +// The formula of a conditional rule: a custom formula rule, and the constants of cell value rules, scale stops, bar +// ends, and icon thresholds. The engine owns the real formula language. Until the page's engine is connected with +// `setFormulaEvaluator` (the sheet integration does it), this small evaluator reads the forms that rules use: +// numbers, text, TRUE, FALSE, A1 references with `$` anchors (relative to the first range's top-left cell), +// ranges, `+ - * / ^ & = <> < > <= >=`, and a short list of functions. + +import { letterToColumn } from '../../engine/model'; +import { own } from '../model/json'; +import type { Sheet, TableLike } from '../model/types'; +import { BLANK, textOf, valueOfCell, type CellValue } from './value'; + +export interface FormulaContext { + sheet: Sheet; + table: TableLike; + /** The cell the rule is asked about. */ + at: { row: number; col: number }; + /** The top-left cell of the rule's first range: the formula's references are written for this cell. */ + anchor: { row: number; col: number }; + /** Today as a spreadsheet serial day, for TODAY(). */ + today?: number; +} + +export type FormulaEvaluator = (formula: string, ctx: FormulaContext) => CellValue; + +type V = CellValue | { kind: 'array'; items: CellValue[] }; +interface Ref { + t: 'ref'; + c: number; + cAbs: boolean; + r: number; + rAbs: boolean; + to?: Ref; +} +type Node = + | { t: 'val'; v: CellValue } + | Ref + | { t: 'un'; op: string; a: Node } + | { t: 'bin'; op: string; a: Node; b: Node } + | { t: 'fn'; name: string; args: Node[] }; + +const num = (n: number): CellValue => ({ kind: 'num', n }); +const bool = (b: boolean): CellValue => ({ kind: 'bool', b }); +const fail = (e: string): CellValue => ({ kind: 'error', e }); +const NOTHING: Node = { t: 'val', v: BLANK }; + +const TOKEN = new RegExp( + [ + String.raw`\s*(?:(\$?\d+:\$?\d+)(?![\d.])`, // whole rows: 1:1 + String.raw`(\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)`, // a number + String.raw`"((?:[^"]|"")*)"`, // text + String.raw`(\$?[A-Za-z]{1,3}\$?\d+)(?![A-Za-z0-9_(])`, // a cell + String.raw`(\$?[A-Za-z]{1,3}:\$?[A-Za-z]{1,3})(?![A-Za-z0-9_(.])`, // whole columns: A:A + String.raw`([A-Za-z_][A-Za-z0-9_.]*)\s*(\()?`, // a name or a function + String.raw`(<=|>=|<>|[-+*/^&=<>(),:%]))`, // an operator + ].join('|'), + 'y', +); + +function tokenize(text: string): string[][] | null { + const out: string[][] = []; + TOKEN.lastIndex = 0; + while (TOKEN.lastIndex < text.length) { + if (/^\s*$/.test(text.slice(TOKEN.lastIndex))) break; + const m = TOKEN.exec(text); + if (!m) return null; + if (m[1] !== undefined) out.push(['rr', m[1]]); + else if (m[2] !== undefined) out.push(['n', m[2]]); + else if (m[3] !== undefined) out.push(['s', m[3].replace(/""/g, '"')]); + else if (m[4] !== undefined) out.push(['ref', m[4]]); + else if (m[5] !== undefined) out.push(['cr', m[5]]); + else if (m[6] !== undefined) out.push([m[7] ? 'fn' : 'name', m[6]]); + else out.push(['op', m[8]]); + } + return out; +} + +function refOf(text: string): Ref { + const m = /^(\$?)([A-Za-z]{1,3})(\$?)(\d+)$/.exec(text) as RegExpExecArray; + return { + t: 'ref', + cAbs: m[1] === '$', + c: letterToColumn(m[2].toUpperCase()), + rAbs: m[3] === '$', + r: Number(m[4]) - 1, + }; +} + +/** The last row and column a sheet has (1,048,576 rows by 16,384 columns); a whole column or row reaches them. */ +const LAST_ROW = 1_048_575; +const LAST_COL = 16_383; + +/** A whole column (A:B) or whole row (1:3) as a range; the read clamps it to the table. */ +function wholeOf(text: string, rows: boolean): Ref { + const [x, y] = text.split(':'); + const side = (part: string) => { + const abs = part.startsWith('$'); + const body = abs ? part.slice(1) : part; + return rows ? { abs, n: Number(body) - 1 } : { abs, n: letterToColumn(body.toUpperCase()) }; + }; + const a = side(x); + const b = side(y); + const end: Ref = rows + ? { t: 'ref', c: LAST_COL, cAbs: true, r: b.n, rAbs: b.abs } + : { t: 'ref', c: b.n, cAbs: b.abs, r: LAST_ROW, rAbs: true }; + return rows + ? { t: 'ref', c: 0, cAbs: true, r: a.n, rAbs: a.abs, to: end } + : { t: 'ref', c: a.n, cAbs: a.abs, r: 0, rAbs: true, to: end }; +} + +const cache = new Map(); + +function build(tokens: string[][]): Node | null { + let i = 0; + const op = (...ops: string[]) => tokens[i]?.[0] === 'op' && ops.includes(tokens[i][1]); + const bad = (): never => { + throw new Error('formula'); + }; + const level = + (next: () => Node, ...ops: string[]) => + (): Node => { + let a = next(); + while (op(...ops)) { + const o = tokens[i++][1]; + a = { t: 'bin', op: o, a, b: next() }; + } + return a; + }; + const primary = (): Node => { + const tok = tokens[i++]; + if (!tok) return bad(); + if (tok[0] === 'n') return { t: 'val', v: num(Number(tok[1])) }; + if (tok[0] === 's') return { t: 'val', v: { kind: 'text', s: tok[1] } }; + if (tok[0] === 'rr' || tok[0] === 'cr') return wholeOf(tok[1], tok[0] === 'rr'); + if (tok[0] === 'ref') { + const from = refOf(tok[1]); + if (!op(':')) return from; + i++; + const to = tokens[i++]; + return to?.[0] === 'ref' ? { ...from, to: refOf(to[1]) } : bad(); + } + if (tok[0] === 'name') { + const up = tok[1].toUpperCase(); + return up === 'TRUE' || up === 'FALSE' ? { t: 'val', v: bool(up === 'TRUE') } : bad(); + } + if (tok[0] === 'fn') { + const args: Node[] = []; + if (!op(')')) { + args.push(or()); + while (op(',')) { + i++; + args.push(or()); + } + } + if (!op(')')) return bad(); + i++; + return { t: 'fn', name: tok[1].toUpperCase(), args }; + } + if (tok[1] !== '(') return bad(); + const inner = or(); + if (!op(')')) return bad(); + i++; + return inner; + }; + const postfix = (): Node => { + let a = primary(); + while (op('%')) { + i++; + a = { t: 'bin', op: '/', a, b: { t: 'val', v: num(100) } }; + } + return a; + }; + const power = level(postfix, '^'); + const unary = (): Node => { + if (!op('-', '+')) return power(); + const o = tokens[i++][1]; + return { t: 'un', op: o, a: unary() }; + }; + const or = level(level(level(level(unary, '*', '/'), '+', '-'), '&'), '=', '<>', '<', '>', '<=', '>='); + try { + const node = or(); + return i < tokens.length ? null : node; + } catch { + return null; + } +} + +function parse(text: string): Node | null { + if (cache.has(text)) return cache.get(text) ?? null; + const tokens = tokenize(text.replace(/^\s*=/, '')); + const node = tokens && tokens.length > 0 ? build(tokens) : null; + if (cache.size > 512) cache.clear(); + cache.set(text, node); + return node; +} + +const asNum = (v: CellValue): number | CellValue => { + if (v.kind === 'num') return v.n; + if (v.kind === 'blank') return 0; + if (v.kind === 'bool') return v.b ? 1 : 0; + if (v.kind === 'error') return v; + const n = Number(v.s); + return v.s.trim() !== '' && Number.isFinite(n) ? n : fail('#VALUE!'); +}; +const asBool = (v: CellValue): boolean | CellValue => { + if (v.kind === 'bool') return v.b; + if (v.kind === 'num') return v.n !== 0; + if (v.kind === 'blank') return false; + if (v.kind === 'error') return v; + return fail('#VALUE!'); +}; + +/** Excel's order of types: numbers before text before booleans. Blank takes the type of what it is compared with. */ +export function compareValues(a: CellValue, b: CellValue): number { + const rank = (v: CellValue) => (v.kind === 'num' ? 0 : v.kind === 'text' ? 1 : 2); + const like = (other: CellValue): CellValue => + other.kind === 'text' ? { kind: 'text', s: '' } : other.kind === 'bool' ? bool(false) : num(0); + const x = a.kind === 'blank' ? like(b) : a; + const y = b.kind === 'blank' ? like(a) : b; + if (rank(x) !== rank(y)) return rank(x) - rank(y); + if (x.kind === 'num' && y.kind === 'num') return x.n - y.n; + if (x.kind === 'bool' && y.kind === 'bool') return Number(x.b) - Number(y.b); + const s = textOf(x).toLowerCase(); + const t = textOf(y).toLowerCase(); + return s < t ? -1 : s > t ? 1 : 0; +} + +const COMPARE: Record boolean> = { + '=': (d) => d === 0, + '<>': (d) => d !== 0, + '<': (d) => d < 0, + '>': (d) => d > 0, + '<=': (d) => d <= 0, + '>=': (d) => d >= 0, +}; + +interface Memo { + rows: unknown; + values: unknown; + stamp: unknown; + ranges: Map; + results: Map; +} +const memos = new WeakMap(); + +/** What was read from a table, kept while its rows and its value cache are the same objects. */ +function memoOf(ctx: FormulaContext): Memo { + const known = memos.get(ctx.table); + const values = own(ctx.sheet, 'values'); + const stamp = own(ctx.sheet, 'valuesOf'); + if (known && known.rows === ctx.table.rows && known.values === values && known.stamp === stamp) return known; + const fresh: Memo = { rows: ctx.table.rows, values, stamp, ranges: new Map(), results: new Map() }; + memos.set(ctx.table, fresh); + return fresh; +} + +/** True when the formula reads the same cells for every cell of the rule, so one result serves them all. */ +function isAbsolute(node: Node): boolean { + switch (node.t) { + case 'val': + return true; + case 'ref': + return node.cAbs && node.rAbs && (!node.to || (node.to.cAbs && node.to.rAbs)); + case 'un': + return isAbsolute(node.a); + case 'bin': + return isAbsolute(node.a) && isAbsolute(node.b); + case 'fn': + return node.name !== 'ROW' && node.name !== 'COLUMN' && node.args.every(isAbsolute); + } +} +const absolute = new WeakMap(); + +function run(node: Node, ctx: FormulaContext, depth: number): V { + if (depth > 64) return fail('#VALUE!'); + const dr = ctx.at.row - ctx.anchor.row; + const dc = ctx.at.col - ctx.anchor.col; + const at = (n: Ref) => ({ r: n.rAbs ? n.r : n.r + dr, c: n.cAbs ? n.c : n.c + dc }); + const scalar = (n: Node): CellValue => { + const v = run(n, ctx, depth + 1); + return v.kind === 'array' ? (v.items[0] ?? BLANK) : v; + }; + switch (node.t) { + case 'val': + return node.v; + case 'ref': { + const a = at(node); + if (!node.to) return a.r < 0 || a.c < 0 ? fail('#REF!') : valueOfCell(ctx.sheet, ctx.table, a.r, a.c); + const b = at(node.to); + const r0 = Math.max(0, Math.min(a.r, b.r)); + const r1 = Math.min(ctx.table.rows.length - 1, Math.max(a.r, b.r)); + const c0 = Math.max(0, Math.min(a.c, b.c)); + const c1 = Math.min(ctx.table.columns.length - 1, Math.max(a.c, b.c)); + const memo = memoOf(ctx); + const key = `${r0},${r1},${c0},${c1}`; + const known = memo.ranges.get(key); + if (known) return { kind: 'array', items: known }; + const items: CellValue[] = []; + outer: for (let r = r0; r <= r1; r++) + for (let c = c0; c <= c1; c++) { + if (items.length >= 100_000) break outer; + items.push(valueOfCell(ctx.sheet, ctx.table, r, c)); + } + memo.ranges.set(key, items); + return { kind: 'array', items }; + } + case 'un': { + const n = asNum(scalar(node.a)); + return typeof n !== 'number' ? n : num(node.op === '-' ? -n : n); + } + case 'bin': { + const a = scalar(node.a); + const b = scalar(node.b); + if (a.kind === 'error') return a; + if (b.kind === 'error') return b; + if (node.op === '&') return { kind: 'text', s: textOf(a) + textOf(b) }; + if (Object.hasOwn(COMPARE, node.op)) return bool(COMPARE[node.op](compareValues(a, b))); + const x = asNum(a); + const y = asNum(b); + if (typeof x !== 'number') return x; + if (typeof y !== 'number') return y; + if (node.op === '/') return y === 0 ? fail('#DIV/0!') : num(x / y); + const r = node.op === '+' ? x + y : node.op === '-' ? x - y : node.op === '*' ? x * y : x ** y; + return Number.isFinite(r) ? num(r) : fail('#NUM!'); + } + case 'fn': + return call(node.name, node.args, ctx, depth, scalar); + } +} + +function call(name: string, args: Node[], ctx: FormulaContext, depth: number, scalar: (n: Node) => CellValue): V { + const arg = (k: number) => args[k] ?? NOTHING; + const items = () => + args.flatMap((a) => { + const v = run(a, ctx, depth + 1); + return v.kind === 'array' ? v.items : [v]; + }); + const numbers = () => items().flatMap((v) => (v.kind === 'num' ? [v.n] : [])); + const one = (f: (n: number) => CellValue): CellValue => { + const n = asNum(scalar(arg(0))); + return typeof n === 'number' ? f(n) : n; + }; + switch (name) { + case 'AND': + case 'OR': { + const bools = items().flatMap((v) => (v.kind === 'blank' || v.kind === 'text' ? [] : [asBool(v)])); + const bad = bools.find((b) => typeof b !== 'boolean'); + if (bad) return bad as CellValue; + return bool(name === 'AND' ? bools.every(Boolean) : bools.some(Boolean)); + } + case 'NOT': { + const b = asBool(scalar(arg(0))); + return typeof b === 'boolean' ? bool(!b) : b; + } + case 'IF': { + const b = asBool(scalar(arg(0))); + if (typeof b !== 'boolean') return b; + return b ? scalar(args[1] ?? { t: 'val', v: bool(true) }) : scalar(args[2] ?? { t: 'val', v: bool(false) }); + } + case 'ISBLANK': + return bool(scalar(arg(0)).kind === 'blank'); + case 'ISNUMBER': + return bool(scalar(arg(0)).kind === 'num'); + case 'ISTEXT': + return bool(scalar(arg(0)).kind === 'text'); + case 'ISERROR': + return bool(scalar(arg(0)).kind === 'error'); + case 'ISEVEN': + return one((n) => bool(Math.trunc(n) % 2 === 0)); + case 'ISODD': + return one((n) => bool(Math.trunc(n) % 2 !== 0)); + case 'ABS': + return one((n) => num(Math.abs(n))); + case 'LEN': + return num(textOf(scalar(arg(0))).length); + case 'MOD': { + const x = asNum(scalar(arg(0))); + const y = asNum(scalar(arg(1))); + if (typeof x !== 'number') return x; + if (typeof y !== 'number') return y; + return y === 0 ? fail('#DIV/0!') : num(x - y * Math.floor(x / y)); + } + case 'ROW': + return num(ctx.at.row + 1); + case 'COLUMN': + return num(ctx.at.col + 1); + case 'TODAY': + return num(ctx.today ?? Math.floor(Date.now() / 86_400_000) + 25569); + case 'SUM': + return num(numbers().reduce((a, b) => a + b, 0)); + case 'MIN': + return num(numbers().reduce((a, b) => Math.min(a, b), numbers()[0] ?? 0)); + case 'MAX': + return num(numbers().reduce((a, b) => Math.max(a, b), numbers()[0] ?? 0)); + case 'COUNT': + return num(numbers().length); + case 'COUNTA': + return num(items().filter((v) => v.kind !== 'blank').length); + case 'AVERAGE': { + const n = numbers(); + return n.length === 0 ? fail('#DIV/0!') : num(n.reduce((a, b) => a + b, 0) / n.length); + } + case 'COUNTIF': { + const range = run(arg(0), ctx, depth + 1); + const crit = scalar(arg(1)); + const list = range.kind === 'array' ? range.items : [range]; + return num( + list.filter((v) => (v.kind === 'blank') === (crit.kind === 'blank') && compareValues(v, crit) === 0).length, + ); + } + default: + return fail('#NAME?'); + } +} + +/** The built-in evaluator. A formula it cannot read is an error value, so its rule matches nothing. */ +export const simpleEvaluator: FormulaEvaluator = (formula, ctx) => { + const node = parse(formula); + if (!node) return fail('#NAME?'); + let abs = absolute.get(node); + if (abs === undefined) absolute.set(node, (abs = isAbsolute(node))); + const memo = abs ? memoOf(ctx) : undefined; + const key = `${formula}|${ctx.today ?? ''}`; + const hit = memo?.results.get(key); + if (hit) return hit; + const v = run(node, ctx, 0); + const out = v.kind === 'array' ? (v.items[0] ?? BLANK) : v; + memo?.results.set(key, out); + return out; +}; + +let evaluator: FormulaEvaluator = simpleEvaluator; + +/** Connects the page's engine (or any evaluator) to the rules; with no argument it goes back to the built-in one. */ +export function setFormulaEvaluator(next: FormulaEvaluator = simpleEvaluator): void { + evaluator = next; +} + +export function evaluateFormula(formula: string, ctx: FormulaContext): CellValue { + return evaluator(formula, ctx); +} + +/** True when a value is TRUE or a nonzero number: what a formula rule takes as a match. */ +export function isTrue(v: CellValue): boolean { + return v.kind === 'bool' ? v.b : v.kind === 'num' ? v.n !== 0 : false; +} diff --git a/app/src/features/tables/sheet/conditional/index.ts b/app/src/features/tables/sheet/conditional/index.ts new file mode 100644 index 000000000..dc0872fa6 --- /dev/null +++ b/app/src/features/tables/sheet/conditional/index.ts @@ -0,0 +1,14 @@ +// Conditional formatting (format spec R-SHT-15, DESIGN 13.5): rules that give styles, color scales, data bars, and +// icon sets, evaluated for each cell as the model resolves it. + +export { conditionalLayer, drawnLayer, installConditional, barCss } from './layer'; +export { ruleMatches, setToday, inPeriod, operandOf } from './match'; +export type { RuleContext } from './match'; +export { barOf, iconOf, scaleColor, levelOf, ICON_SETS } from './visual'; +export type { Bar, Icon } from './visual'; +export { mixLab, hexToLab, labToHex } from './oklab'; +export { setFormulaEvaluator, simpleEvaluator, evaluateFormula } from './formula'; +export type { FormulaContext, FormulaEvaluator } from './formula'; +export { valueOfCell, readText } from './value'; +export type { CellValue } from './value'; +export { statsOf, percentile } from './stats'; diff --git a/app/src/features/tables/sheet/conditional/layer.ts b/app/src/features/tables/sheet/conditional/layer.ts new file mode 100644 index 000000000..ecdaab476 --- /dev/null +++ b/app/src/features/tables/sheet/conditional/layer.ts @@ -0,0 +1,154 @@ +// The conditional layer the model and the render lane call (rules.ts hooks). The model hands over the rules that +// cover a cell in the order they apply. The layer gives the styles of the rules that match, lowest priority first +// so the rule at order 1 wins a clash, and stops at a matching rule with `stop` (R-SHT-15). Data bars and icons are +// drawn as CSS custom properties and data attributes beside the style. + +import { + setConditionalLayer, + setDrawnLayer, + getLayerScheme, + rulesAt, + type ConditionalLayer, + type DrawnDecoration, + type DrawnLayer, + type Rule, +} from '../model/rules'; +import { styleById } from '../model/styles'; +import type { CellStyle, Sheet, TableLike } from '../model/types'; +import { own } from '../model/json'; +import { hybridEvaluator } from './engineEval'; +import { setFormulaEvaluator } from './formula'; +import { ruleMatches, type RuleContext } from './match'; +import { barOf, iconOf, scaleColor, type Bar } from './visual'; + +/** The types that draw instead of giving a style. */ +const DRAWN = new Set(['scale', 'bar', 'icons']); + +const styleLayer: ConditionalLayer = (rules, cell, sheet, table) => { + const found: CellStyle[] = []; + if (table.header === true && cell.row === 0) return found; + const scheme = getLayerScheme(); + for (const rule of rules) { + const type = own(rule.rule, 'type'); + const c: RuleContext = { sheet, table, rule, row: cell.row, col: cell.col }; + let style: CellStyle | undefined; + if (type === 'scale') { + const fill = scaleColor(c, scheme); + if (fill === undefined) continue; + style = { fill }; + } else if (typeof type === 'string' && !DRAWN.has(type)) { + if (!ruleMatches(c)) continue; + const id = own(rule.rule, 'style'); + style = (typeof id === 'string' ? styleById(sheet, id) : undefined) ?? {}; + } else if (DRAWN.has(String(type))) { + // A bar or an icon set draws; it matches a cell that holds a number. + const shown = type === 'bar' ? barOf(c, scheme) : iconOf(c, scheme); + if (!shown) continue; + } else continue; + if (style && Object.keys(style).length > 0) found.push(style); + if (rule.rule.stop === true) break; + } + // The rules came in priority order, highest first; the model wants the lowest first. + return found.reverse(); +}; + +/** A corrupt or hostile rule must not stop a table from drawing: a cell whose rules throw gets no conditional style. */ +export const conditionalLayer: ConditionalLayer = (rules, cell, sheet, table) => { + try { + return styleLayer(rules, cell, sheet, table); + } catch { + return []; + } +}; + +/** CSS for a bar: a gradient that is clear outside the bar, with an optional border drawn on its four edges. */ +export function barCss(bar: Bar): string { + const from = bar.left; + const to = bar.left + bar.width; + const toward = bar.side === 'right' ? 'to right' : 'to left'; + const a = bar.side === 'right' ? from : 100 - to; + const b = bar.side === 'right' ? to : 100 - from; + const fade = `color-mix(in srgb, ${bar.color} 30%, transparent)`; + const inner = bar.gradient ? `${bar.color} ${a}%, ${fade} ${b}%` : `${bar.color} ${a}% ${b}%`; + const fill = `linear-gradient(${toward}, transparent 0 ${a}%, ${inner}, transparent ${b}% 100%)`; + if (!bar.border || bar.width <= 0) return fill; + // A one pixel line along the top, bottom, left, and right edges of the bar. + const line = (size: string, at: string) => `linear-gradient(${bar.border}, ${bar.border}) ${at} / ${size} no-repeat`; + const pos = (x: number) => `${bar.width >= 100 ? 0 : (x / (100 - bar.width)) * 100}%`; + return [ + line(`${bar.width}% 1px`, `${pos(from)} 0`), + line(`${bar.width}% 1px`, `${pos(from)} 100%`), + line(`1px 100%`, `${pos(from)} 0`), + line(`1px 100%`, `${pos(to)} 0`), + fill, + ].join(', '); +} + +/** A CSS string in single quotes, so it sits inside a style attribute written with double quotes. */ +function cssString(text: string): string { + return `'${text.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`; +} + +const decorate: DrawnLayer = (cell, sheet: Sheet, table: TableLike, scheme) => { + const out: DrawnDecoration = { vars: {}, data: {} }; + if (table.header === true && cell.row === 0) return undefined; + const rules = rulesFor(sheet, table, cell); + let any = false; + for (const rule of rules) { + const type = own(rule.rule, 'type'); + const c: RuleContext = { sheet, table, rule, row: cell.row, col: cell.col }; + // One walk in priority order, as conditionalLayer makes it: a matching rule with `stop` ends it for the bars + // and icons below too (R-SHT-15). + let matched = false; + if (type === 'scale') { + matched = scaleColor(c, scheme) !== undefined; + } else if (typeof type === 'string' && !DRAWN.has(type)) { + matched = ruleMatches(c); + } else if (type === 'bar') { + const bar = barOf(c, scheme); + matched = bar !== undefined; + if (bar && out.vars['--cell-bar'] === undefined) { + out.vars['--cell-bar'] = barCss(bar); + out.data['data-bar'] = bar.side; + if (bar.barOnly) out.data['data-bar-only'] = ''; + any = true; + } + } else if (type === 'icons') { + const icon = iconOf(c, scheme); + matched = icon !== undefined; + if (icon && out.data['data-icon-glyph'] === undefined) { + out.data['data-icon-glyph'] = icon.glyph; + out.data['data-icon-text'] = icon.text; + out.data['data-icon-index'] = String(icon.index); + // The stylesheet reads these as quoted strings: the glyph, then its text as the alternative for screen readers. + out.vars['--cell-icon-glyph'] = cssString(icon.glyph); + out.vars['--cell-icon-text'] = cssString(icon.text); + if (icon.color) out.vars['--cell-icon-color'] = icon.color; + if (icon.iconOnly) out.data['data-icon-only'] = ''; + any = true; + } + } + if (rule.rule.stop === true && matched) break; + } + return any ? out : undefined; +}; + +/** The bars and icons of a cell; a rule that throws leaves the cell without them instead of stopping the draw. */ +export const drawnLayer: DrawnLayer = (cell, sheet, table, scheme) => { + try { + return decorate(cell, sheet, table, scheme); + } catch { + return undefined; + } +}; + +function rulesFor(sheet: Sheet, table: TableLike, cell: { row: number; col: number }): Rule[] { + return rulesAt(sheet, table, 'conditional', cell.row, cell.col); +} + +/** Connects the conditional formats to the model and the render lane. Safe to call again. */ +export function installConditional(): void { + setConditionalLayer(conditionalLayer); + setDrawnLayer(drawnLayer); + setFormulaEvaluator(hybridEvaluator); +} diff --git a/app/src/features/tables/sheet/conditional/match.ts b/app/src/features/tables/sheet/conditional/match.ts new file mode 100644 index 000000000..aba5bccf7 --- /dev/null +++ b/app/src/features/tables/sheet/conditional/match.ts @@ -0,0 +1,191 @@ +// Which cells a rule that gives a style matches (R-SHT-15): cell value, text, dates, blanks, errors, top and +// bottom, average, duplicates, and custom formulas. Each is worked from its documented behavior. + +import { daysToIso, todayDays, weekdayOf } from '../../engine/dates'; +import { own, type JsonObject } from '../model/json'; +import type { Rule } from '../model/rules'; +import type { Sheet, TableLike } from '../model/types'; +import { evaluateFormula, isTrue, compareValues, type FormulaContext } from './formula'; +import { statsOf } from './stats'; +import { numberOf, readText, textOf, valueOfCell, type CellValue } from './value'; + +let today: () => number = () => todayDays(); + +/** Sets what "today" means, as a spreadsheet serial day: tests fix it, and a page can follow the clock. */ +export function setToday(next: (() => number) | null): void { + today = next ?? (() => todayDays()); +} + +export function todaySerial(): number { + return today(); +} + +export interface RuleContext { + sheet: Sheet; + table: TableLike; + rule: Rule; + row: number; + col: number; +} + +/** The context a formula in a rule is read in: the cell asked about, and the first range's top-left cell. */ +export function formulaContext(c: RuleContext): FormulaContext { + const box = c.rule.boxes[0]; + return { + sheet: c.sheet, + table: c.table, + at: { row: c.row, col: c.col }, + anchor: { row: box?.top ?? 0, col: box?.left ?? 0 }, + today: today(), + }; +} + +/** The value of an operand: a formula, a quoted text, or typed text read as a number, boolean, or text. */ +export function operandOf(text: unknown, c: RuleContext): CellValue { + if (typeof text !== 'string') return { kind: 'blank' }; + const t = text.trim(); + if (t.startsWith('=')) return evaluateFormula(t, formulaContext(c)); + if (t.length >= 2 && t.startsWith('"') && t.endsWith('"')) + return { kind: 'text', s: t.slice(1, -1).replace(/""/g, '"') }; + return readText(t); +} + +function cellMatches(rule: JsonObject, v: CellValue, c: RuleContext): boolean { + if (v.kind === 'error') return false; + const op = own(rule, 'op') ?? 'between'; + const a = operandOf(own(rule, 'a'), c); + if (op === 'between' || op === 'notBetween') { + const b = operandOf(own(rule, 'b'), c); + const inside = compareValues(v, a) >= 0 && compareValues(v, b) <= 0; + return op === 'between' ? inside : !inside; + } + const d = compareValues(v, a); + switch (op) { + case 'equal': + return d === 0; + case 'notEqual': + return d !== 0; + case 'greater': + return d > 0; + case 'less': + return d < 0; + case 'greaterOrEqual': + return d >= 0; + case 'lessOrEqual': + return d <= 0; + default: + return false; + } +} + +function textMatches(rule: JsonObject, v: CellValue): boolean { + const needle = String(own(rule, 'text') ?? '').toLowerCase(); + const hay = textOf(v).toLowerCase(); + switch (own(rule, 'op')) { + case 'contains': + return hay.includes(needle); + case 'notContains': + return !hay.includes(needle); + case 'begins': + return hay.startsWith(needle); + case 'ends': + return hay.endsWith(needle); + default: + return false; + } +} + +/** The month of a serial day as year * 12 + month index, so "last month" and "next month" are plus and minus one. */ +function monthIndex(serial: number): number { + const [y, m] = daysToIso(Math.floor(serial)).split('-').map(Number); + return y * 12 + (m - 1); +} + +/** True when a serial day falls in a period, counting weeks from Sunday as Excel's default does. */ +export function inPeriod(period: unknown, serial: number, now: number): boolean { + const day = Math.floor(serial); + const start = now - weekdayOf(now); + switch (period) { + case 'yesterday': + return day === now - 1; + case 'today': + return day === now; + case 'tomorrow': + return day === now + 1; + case 'last7Days': + return day >= now - 6 && day <= now; + case 'lastWeek': + return day >= start - 7 && day < start; + case 'thisWeek': + return day >= start && day < start + 7; + case 'nextWeek': + return day >= start + 7 && day < start + 14; + case 'lastMonth': + return monthIndex(day) === monthIndex(now) - 1; + case 'thisMonth': + return monthIndex(day) === monthIndex(now); + case 'nextMonth': + return monthIndex(day) === monthIndex(now) + 1; + default: + return false; + } +} + +function rankMatches(rule: JsonObject, n: number, c: RuleContext): boolean { + const stats = statsOf(c.sheet, c.table, c.rule); + const count = stats.numbers.length; + if (count === 0) return false; + const rank = typeof rule.rank === 'number' ? rule.rank : 10; + // Top 10 percent of 20 values is 2 values; there is always at least one. + const take = rule.percent === true ? Math.max(1, Math.floor((count * rank) / 100)) : Math.min(rank, count); + return rule.bottom === true ? n <= stats.numbers[take - 1] : n >= stats.numbers[count - take]; +} + +function averageMatches(rule: JsonObject, n: number, c: RuleContext): boolean { + const stats = statsOf(c.sheet, c.table, c.rule); + const k = typeof rule.stdDev === 'number' ? rule.stdDev : 0; + const equal = rule.equal === true; + if (rule.below === true) { + const edge = stats.average - k * stats.deviation; + return equal ? n <= edge : n < edge; + } + const edge = stats.average + k * stats.deviation; + return equal ? n >= edge : n > edge; +} + +/** True when a style rule matches the cell. The scale, bar, and icon types are not here: they draw (visual.ts). */ +export function ruleMatches(c: RuleContext): boolean { + const rule = c.rule.rule; + const v = valueOfCell(c.sheet, c.table, c.row, c.col); + const n = numberOf(v); + switch (own(rule, 'type')) { + case 'cell': + return cellMatches(rule, v, c); + case 'text': + return v.kind !== 'blank' && textMatches(rule, v); + case 'date': + return n !== undefined && inPeriod(own(rule, 'period'), n, today()); + case 'blank': + return v.kind === 'blank'; + case 'noBlank': + return v.kind !== 'blank'; + case 'error': + return v.kind === 'error'; + case 'noError': + return v.kind !== 'error'; + case 'top': + return n !== undefined && rankMatches(rule, n, c); + case 'average': + return n !== undefined && averageMatches(rule, n, c); + case 'duplicate': + case 'unique': { + if (v.kind === 'blank') return false; + const count = statsOf(c.sheet, c.table, c.rule).counts.get(textOf(v).toLowerCase()) ?? 0; + return own(rule, 'type') === 'duplicate' ? count > 1 : count === 1; + } + case 'formula': + return isTrue(evaluateFormula(String(own(rule, 'a') ?? ''), formulaContext(c))); + default: + return false; + } +} diff --git a/app/src/features/tables/sheet/conditional/oklab.ts b/app/src/features/tables/sheet/conditional/oklab.ts new file mode 100644 index 000000000..5deadf537 --- /dev/null +++ b/app/src/features/tables/sheet/conditional/oklab.ts @@ -0,0 +1,51 @@ +// OKLab color interpolation for color scales (DESIGN 13.5). Worked from Bjorn Ottosson's published definition of +// OKLab (2020): sRGB to linear light, to LMS, a cube root, then to L, a, b. No lookup tables. + +import { hexToRgb, rgbToHex } from '../../../../ui/color/math'; + +export type Lab = readonly [number, number, number]; + +const toLinear = (c: number): number => { + const s = c / 255; + return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; +}; +const fromLinear = (x: number): number => { + const c = x <= 0.0031308 ? 12.92 * x : 1.055 * x ** (1 / 2.4) - 0.055; + return Math.min(255, Math.max(0, c * 255)); +}; + +/** `#rrggbb` as OKLab, or null for another value. */ +export function hexToLab(hex: string): Lab | null { + const rgb = hexToRgb(hex); + if (!rgb) return null; + const [r, g, b] = rgb.map(toLinear); + const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b); + const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b); + const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b); + return [ + 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s, + 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s, + 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s, + ]; +} + +/** OKLab as lowercase `#rrggbb`, clamped into the sRGB gamut by channel. */ +export function labToHex([L, a, b]: Lab): string { + const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3; + const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3; + const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3; + return rgbToHex([ + fromLinear(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s), + fromLinear(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s), + fromLinear(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s), + ]); +} + +/** The color a fraction `t` (0 to 1) of the way from `from` to `to`, mixed in OKLab. Falls back to `from`. */ +export function mixLab(from: string, to: string, t: number): string { + const a = hexToLab(from); + const b = hexToLab(to); + if (!a || !b) return from; + const k = Math.min(1, Math.max(0, t)); + return labToHex([a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k, a[2] + (b[2] - a[2]) * k]); +} diff --git a/app/src/features/tables/sheet/conditional/stats.ts b/app/src/features/tables/sheet/conditional/stats.ts new file mode 100644 index 000000000..e76d16162 --- /dev/null +++ b/app/src/features/tables/sheet/conditional/stats.ts @@ -0,0 +1,90 @@ +// What the rules that look across a range need: the numbers in it (sorted), their average and spread, and how often +// each text occurs. Worked out once per rule and table object, as the model reads rules (rules.ts). + +import type { Rule } from '../model/rules'; +import { own } from '../model/json'; +import type { Sheet, TableLike } from '../model/types'; +import { textOf, valueOfCell, type CellValue } from './value'; + +export interface Stats { + /** The numbers in the rule's ranges, from least to greatest. */ + numbers: number[]; + min: number; + max: number; + average: number; + /** The sample standard deviation (STDEV.S); 0 for fewer than two numbers. */ + deviation: number; + /** How many times each value occurs, by lowercase text, for duplicate and unique. */ + counts: Map; +} + +/** True for the header row, which no rule formats or counts. */ +export function isHeaderRow(table: TableLike, row: number): boolean { + return table.header === true && row === 0; +} + +/** Calls `visit` for each cell of the rule's ranges once, never for the header row. */ +export function eachCell(rule: Rule, table: TableLike, visit: (row: number, col: number) => void): void { + const seen = new Set(); + const cols = table.columns.length; + for (const box of rule.boxes) { + for ( + let r = Math.max(box.top, table.header === true ? 1 : 0); + r <= Math.min(box.bottom, table.rows.length - 1); + r++ + ) + for (let c = box.left; c <= Math.min(box.right, cols - 1); c++) { + const key = r * cols + c; + if (seen.has(key)) continue; + seen.add(key); + visit(r, c); + } + } +} + +interface Kept { + stats: Stats; + rows: unknown; + values: unknown; + stamp: unknown; +} +const kept = new WeakMap>(); + +/** The statistics of a rule's range. */ +export function statsOf(sheet: Sheet, table: TableLike, rule: Rule): Stats { + const byTable = kept.get(rule.rule) ?? new WeakMap(); + kept.set(rule.rule, byTable); + // The value cache is written in place (writeValues), so what was worked out is kept only while the rows and the + // cached values are the same objects. + const values = own(sheet, 'values'); + const stamp = own(sheet, 'valuesOf'); + const known = byTable.get(table); + if (known && known.rows === table.rows && known.values === values && known.stamp === stamp) return known.stats; + const numbers: number[] = []; + const counts = new Map(); + eachCell(rule, table, (r, c) => { + const v: CellValue = valueOfCell(sheet, table, r, c); + if (v.kind === 'num') numbers.push(v.n); + if (v.kind !== 'blank') { + const key = textOf(v).toLowerCase(); + counts.set(key, (counts.get(key) ?? 0) + 1); + } + }); + numbers.sort((a, b) => a - b); + const n = numbers.length; + const sum = numbers.reduce((a, b) => a + b, 0); + const average = n === 0 ? 0 : sum / n; + const deviation = n < 2 ? 0 : Math.sqrt(numbers.reduce((a, b) => a + (b - average) ** 2, 0) / (n - 1)); + const stats: Stats = { numbers, min: numbers[0] ?? 0, max: numbers[n - 1] ?? 0, average, deviation, counts }; + byTable.set(table, { stats, rows: table.rows, values, stamp }); + return stats; +} + +/** The p-th percentile (0 to 1) of sorted numbers by linear interpolation, as PERCENTILE.INC works. */ +export function percentile(sorted: readonly number[], p: number): number { + if (sorted.length === 0) return 0; + const k = Math.min(1, Math.max(0, p)) * (sorted.length - 1); + const lo = Math.floor(k); + const hi = Math.ceil(k); + return sorted[lo] + (sorted[hi] - sorted[lo]) * (k - lo); +} diff --git a/app/src/features/tables/sheet/conditional/value.ts b/app/src/features/tables/sheet/conditional/value.ts new file mode 100644 index 000000000..6a6dfca5a --- /dev/null +++ b/app/src/features/tables/sheet/conditional/value.ts @@ -0,0 +1,74 @@ +// What a rule reads from a cell: a number, text, a boolean, an error, or nothing. A formula's value comes from the +// value cache when it is fresh (R-SHT-17); a typed cell is read from its text, as a spreadsheet reads it. + +import { ERROR_TOKENS, textToNumber } from '../../engine/values'; +import { readableText, valueAt } from '../model/cache'; +import type { Sheet, TableLike } from '../model/types'; + +export type CellValue = + | { kind: 'blank' } + | { kind: 'num'; n: number } + | { kind: 'text'; s: string } + | { kind: 'bool'; b: boolean } + | { kind: 'error'; e: string }; + +export const BLANK: CellValue = { kind: 'blank' }; + +const TOKENS: ReadonlySet = new Set(Object.values(ERROR_TOKENS)); +const MARKUP = /[*_~`[\]<\\&]/; + +/** The value of typed text. Spaces only is blank; `TRUE` and `FALSE` are booleans; an error token is an error. */ +export function readText(text: string): CellValue { + const t = text.trim(); + if (t === '') return BLANK; + const upper = t.toUpperCase(); + if (upper === 'TRUE' || upper === 'FALSE') return { kind: 'bool', b: upper === 'TRUE' }; + if (TOKENS.has(upper)) return { kind: 'error', e: upper }; + const n = textToNumber(t); + return n === null ? { kind: 'text', s: t } : { kind: 'num', n }; +} + +/** The value a cached result stands for. */ +function readCached(cached: unknown): CellValue { + if (cached === null || cached === undefined) return BLANK; + if (typeof cached === 'number') return { kind: 'num', n: cached }; + if (typeof cached === 'boolean') return { kind: 'bool', b: cached }; + if (typeof cached === 'string') return cached === '' ? BLANK : { kind: 'text', s: cached }; + const e = (cached as { e?: unknown }).e; + return { kind: 'error', e: typeof e === 'string' ? e : '#VALUE!' }; +} + +/** The value of the cell at a row and column position. A position outside the table is blank. */ +export function valueOfCell(sheet: Sheet, table: TableLike, row: number, col: number): CellValue { + const r = table.rows[row]; + const c = table.columns[col]; + if (!r || !c) return BLANK; + const markdown = Object.hasOwn(r.cells, c.id) ? r.cells[c.id].markdown : ''; + if (markdown.startsWith('=') || markdown === '') { + const cached = valueAt(sheet, table, r.id, c.id); + if (cached !== undefined && cached !== null) return readCached(cached); + // A stale cache or a formula that was never calculated reads as text, which is what the cell shows. + } + return readText(MARKUP.test(markdown) ? readableText(markdown) : markdown); +} + +/** The number a cell holds, or undefined for any other value. */ +export function numberOf(value: CellValue): number | undefined { + return value.kind === 'num' ? value.n : undefined; +} + +/** The text a cell shows, for text rules and for finding duplicates. */ +export function textOf(value: CellValue): string { + switch (value.kind) { + case 'blank': + return ''; + case 'num': + return String(value.n); + case 'text': + return value.s; + case 'bool': + return value.b ? 'TRUE' : 'FALSE'; + default: + return value.e; + } +} diff --git a/app/src/features/tables/sheet/conditional/visual.ts b/app/src/features/tables/sheet/conditional/visual.ts new file mode 100644 index 000000000..b271027e1 --- /dev/null +++ b/app/src/features/tables/sheet/conditional/visual.ts @@ -0,0 +1,262 @@ +// The rules that draw: color scales, data bars, and icon sets (R-SHT-15). Worked from their documented behavior: +// a scale mixes its stop colors in OKLab, a bar is as wide as the value's share of the range from the axis, and an +// icon is chosen by the thresholds. Colors are resolved for the scheme in use. + +import { resolveColor, type Scheme } from '../../../../ui/color/parse'; +import { t } from '../../../../strings/t'; +import { isObject, own, type JsonObject } from '../model/json'; +import { numberOf, valueOfCell, type CellValue } from './value'; +import { mixLab } from './oklab'; +import { evaluateFormula } from './formula'; +import { formulaContext, operandOf, type RuleContext } from './match'; +import { percentile, statsOf, type Stats } from './stats'; + +/** A level of a scale, a bar end, or an icon threshold, as a number. Undefined when it cannot be read. */ +export function levelOf(at: unknown, value: unknown, stats: Stats, c: RuleContext): number | undefined { + const read = (): number | undefined => { + const v = operandOf(value, c); + return v.kind === 'num' ? v.n : undefined; + }; + switch (at) { + case 'min': + return stats.min; + case 'max': + return stats.max; + case 'num': + return read(); + case 'percent': { + const p = read(); + return p === undefined ? undefined : stats.min + (p / 100) * (stats.max - stats.min); + } + case 'percentile': { + const p = read(); + return p === undefined ? undefined : percentile(stats.numbers, p / 100); + } + case 'formula': { + const v = typeof value === 'string' ? evaluateFormula(value, formulaContext(c)) : undefined; + return v?.kind === 'num' ? v.n : undefined; + } + default: + return undefined; + } +} + +function colorFor(value: unknown, scheme: Scheme, fallback: string): string { + return (typeof value === 'string' ? resolveColor(value, scheme) : null) ?? (resolveColor(fallback, scheme) as string); +} + +function numberAt(c: RuleContext): number | undefined { + return numberOf(valueOfCell(c.sheet, c.table, c.row, c.col)); +} + +/** The fill a color scale gives a number cell, as `#rrggbb`. Undefined for a cell that is not a number. */ +export function scaleColor(c: RuleContext, scheme: Scheme): string | undefined { + const n = numberAt(c); + const stops = own(c.rule.rule, 'stops'); + if (n === undefined || !Array.isArray(stops) || stops.length < 2) return undefined; + const stats = statsOf(c.sheet, c.table, c.rule); + const fallbacks = ['brick', 'amber', 'fern']; + const list = stops.filter(isObject).map((s, i) => ({ + at: levelOf(s.at, s.value, stats, c), + color: colorFor(s.color, scheme, fallbacks[i] ?? 'fern'), + })); + // A stop that cannot be read takes its neighbor's level, so the scale still draws. + const levels = list.map((s, i) => s.at ?? (i === 0 ? stats.min : (list[i - 1].at ?? stats.min))); + for (let i = 1; i < levels.length; i++) levels[i] = Math.max(levels[i], levels[i - 1]); + const last = list.length - 1; + if (n <= levels[0]) return list[0].color; + if (n >= levels[last]) return list[last].color; + let i = 0; + while (n > levels[i + 1]) i++; + const span = levels[i + 1] - levels[i]; + return mixLab(list[i].color, list[i + 1].color, span === 0 ? 1 : (n - levels[i]) / span); +} + +export interface Bar { + /** Where the bar starts and how wide it is, as a percent of the cell's width. */ + left: number; + width: number; + /** The side of the axis the bar grows to. */ + side: 'right' | 'left'; + /** Where the axis is, as a percent of the cell's width. */ + axis: number; + color: string; + gradient: boolean; + border?: string; + barOnly: boolean; +} + +/** The data bar of a number cell, or undefined when the cell is not a number. */ +export function barOf(c: RuleContext, scheme: Scheme): Bar | undefined { + const n = numberAt(c); + if (n === undefined) return undefined; + const rule = c.rule.rule; + const stats = statsOf(c.sheet, c.table, c.rule); + const end = (key: 'min' | 'max'): number => { + const e = own(rule, key); + const at = isObject(e) ? e.at : 'auto'; + const found = at === 'auto' ? undefined : levelOf(at, isObject(e) ? e.value : undefined, stats, c); + // Automatic ends include zero, so a bar of all positive numbers starts at the left edge. + return found ?? (key === 'min' ? Math.min(0, stats.min) : Math.max(0, stats.max)); + }; + const min = end('min'); + const max = Math.max(end('max'), min); + const span = max - min; + const axisMode = + own(rule, 'axis') === 'middle' || own(rule, 'axis') === 'none' ? (own(rule, 'axis') as string) : 'auto'; + let axis: number; + let unit: number; + if (axisMode === 'middle') { + axis = 50; + unit = Math.max(Math.abs(min), Math.abs(max)) === 0 ? 0 : 50 / Math.max(Math.abs(min), Math.abs(max)); + } else if (axisMode === 'none' || min >= 0) { + axis = 0; + unit = span === 0 ? 0 : 100 / span; + } else if (max <= 0) { + axis = 100; + unit = 100 / -min; + } else { + axis = (-min / span) * 100; + unit = 100 / span; + } + const value = Math.min(max, Math.max(min, n)); + const negative = value < 0 && axisMode !== 'none'; + // With no negative side the bar grows from the minimum: a minimum of 50 and a maximum of 100 draw 75 at half width. + const fromMin = axisMode === 'none' || (axisMode === 'auto' && min >= 0); + let width = fromMin + ? span === 0 + ? axisMode === 'none' + ? 100 + : 0 + : ((value - min) / span) * 100 + : Math.abs(value) * unit; + if (fromMin) width = Math.min(100, width); + else width = Math.min(width, negative ? axis : 100 - axis); + width = Math.max(0, width); + const color = colorFor(n < 0 ? own(rule, 'negative') : own(rule, 'color'), scheme, n < 0 ? 'brick' : 'indigo'); + const border = own(rule, 'border'); + return { + left: negative ? axis - width : axis, + width, + side: negative ? 'left' : 'right', + axis, + color, + gradient: own(rule, 'gradient') !== false, + border: typeof border === 'string' ? (resolveColor(border, scheme) ?? undefined) : undefined, + barOnly: own(rule, 'barOnly') === true, + }; +} + +interface IconDef { + glyph: string; + /** A theme color name for the glyph, or none for the text color. */ + tone?: 'brick' | 'amber' | 'fern'; + text: () => string; +} + +const level = (key: 'rating' | 'quarter' | 'box' | 'star', glyphs: string[]): IconDef[] => + glyphs.map((glyph, i) => ({ + glyph, + text: () => t(`sheetConditional.icon.${key}`, { level: i + 1, count: glyphs.length }), + })); +type IconKey = Extract[0], `sheetConditional.icon.${string}`>; +const icon = (glyph: string, key: IconKey, tone?: IconDef['tone']): IconDef => ({ + glyph, + tone, + text: () => t(key), +}); + +/** The icon sets, each from the lowest icon to the highest. */ +export const ICON_SETS: Record = { + '3Arrows': [ + icon('▼', 'sheetConditional.icon.arrowDown', 'brick'), + icon('▶', 'sheetConditional.icon.arrowSide', 'amber'), + icon('▲', 'sheetConditional.icon.arrowUp', 'fern'), + ], + '3Triangles': [ + icon('▼', 'sheetConditional.icon.triangleDown', 'brick'), + icon('▬', 'sheetConditional.icon.triangleFlat', 'amber'), + icon('▲', 'sheetConditional.icon.triangleUp', 'fern'), + ], + '3TrafficLights': [ + icon('○', 'sheetConditional.icon.lightRed', 'brick'), + icon('◐', 'sheetConditional.icon.lightYellow', 'amber'), + icon('●', 'sheetConditional.icon.lightGreen', 'fern'), + ], + '3Signs': [ + icon('◆', 'sheetConditional.icon.signRed', 'brick'), + icon('▲', 'sheetConditional.icon.signYellow', 'amber'), + icon('●', 'sheetConditional.icon.signGreen', 'fern'), + ], + '3Symbols': [ + icon('✖', 'sheetConditional.icon.symbolCross', 'brick'), + icon('!', 'sheetConditional.icon.symbolWarning', 'amber'), + icon('✔', 'sheetConditional.icon.symbolCheck', 'fern'), + ], + '3Flags': [ + icon('⚑', 'sheetConditional.icon.flagRed', 'brick'), + icon('⚑⚑', 'sheetConditional.icon.flagYellow', 'amber'), + icon('⚑⚑⚑', 'sheetConditional.icon.flagGreen', 'fern'), + ], + '3Stars': level('star', ['☆', '✩', '★']), + '4Arrows': [ + icon('▼', 'sheetConditional.icon.arrowDown', 'brick'), + icon('↘', 'sheetConditional.icon.arrowDownRight', 'amber'), + icon('↗', 'sheetConditional.icon.arrowUpRight', 'amber'), + icon('▲', 'sheetConditional.icon.arrowUp', 'fern'), + ], + '4Rating': level('rating', ['▁', '▃', '▅', '▇']), + '5Arrows': [ + icon('▼', 'sheetConditional.icon.arrowDown', 'brick'), + icon('↘', 'sheetConditional.icon.arrowDownRight', 'amber'), + icon('▶', 'sheetConditional.icon.arrowSide', 'amber'), + icon('↗', 'sheetConditional.icon.arrowUpRight', 'fern'), + icon('▲', 'sheetConditional.icon.arrowUp', 'fern'), + ], + '5Rating': level('rating', ['▁', '▂', '▄', '▆', '█']), + '5Quarters': level('quarter', ['○', '◔', '◑', '◕', '●']), + '5Boxes': level('box', ['□', '▤', '▥', '▦', '■']), +}; + +export interface Icon { + glyph: string; + /** What a screen reader says for the icon. */ + text: string; + /** The theme color of the glyph, resolved for the scheme, or undefined for the text color. */ + color?: string; + /** The position of the icon in its set, 0 for the lowest. */ + index: number; + iconOnly: boolean; +} + +/** The icon of a number cell by the rule's thresholds, or undefined when the cell is not a number. */ +export function iconOf(c: RuleContext, scheme: Scheme): Icon | undefined { + const n = numberAt(c); + const rule = c.rule.rule; + const name = String(own(rule, 'set') ?? '3Arrows'); + // An unknown set, or a name such as `constructor` from a hostile note, makes the rule do nothing. + const set = Object.hasOwn(ICON_SETS, name) ? ICON_SETS[name] : undefined; + if (n === undefined || !set) return undefined; + const stats = statsOf(c.sheet, c.table, c.rule); + const cuts = (Array.isArray(own(rule, 'thresholds')) ? (own(rule, 'thresholds') as unknown[]) : []).filter(isObject); + // Without thresholds the range splits in equal percents; each threshold is the bottom of the icon above it. + const edges = set.slice(1).map((_, i) => { + const cut: JsonObject = cuts[i] ?? { at: 'percent', value: String(((i + 1) * 100) / set.length) }; + return { at: levelOf(cut.at, cut.value, stats, c), gte: cut.gte !== false }; + }); + let index = 0; + edges.forEach((e, i) => { + if (e.at !== undefined && (e.gte ? n >= e.at : n > e.at)) index = i + 1; + }); + if (own(rule, 'reverse') === true) index = set.length - 1 - index; + const def = set[index]; + return { + glyph: def.glyph, + text: def.text(), + color: def.tone ? (resolveColor(def.tone, scheme) ?? undefined) : undefined, + index, + iconOnly: own(rule, 'iconOnly') === true, + }; +} + +export type { CellValue }; diff --git a/app/src/features/tables/sheet/formatTarget.test.ts b/app/src/features/tables/sheet/formatTarget.test.ts new file mode 100644 index 000000000..312a19143 --- /dev/null +++ b/app/src/features/tables/sheet/formatTarget.test.ts @@ -0,0 +1,288 @@ +// @vitest-environment jsdom +// The cell-range format target and the cell format actions in a real table editor (DESIGN 10.1, ADR 0056). A small +// in-memory block stands in for the page's table block: it keeps the block data, applies merge patches as the page +// does, and counts them, so "one patch" means one data patch (one undo step). +import type { Editor } from '@tiptap/core'; +import { TextSelection } from '@tiptap/pm/state'; +import { CellSelection } from '@tiptap/pm/tables'; +import { afterEach, describe, expect, it } from 'vitest'; +import { runEditorCommand } from '../../../editor/commands/catalog'; +import { createBlockEditor } from '../../../editor/extensions/kit'; +import { DEFAULT_EDITING_VIEW } from '../../../editor/host'; +import type { EditorHost } from '../../../editor/host'; +import { createMarkdownCache } from '../../../editor/markdown'; +import type { TableData } from '../../../editor/schema/specs'; +import { tableDataToDoc } from '../../../editor/table/mapping'; +import { applyMergePatch } from '../../../platform/mergePatch'; +import { setBorders, setFill, setFormat } from '../ui/toolbar/actions'; +import { createCellRangeTarget, boxOfSelection } from './formatTarget'; +import type { CellHost } from './formatTarget'; +import { readSheet, resolve, sheetPatch } from './model'; +import type { CellStyle } from './model'; +import { registerFormatTarget } from '../../../editor/commands/formatTarget'; +import type { TableExtraHost } from '../../page'; + +const editorHost: EditorHost = { + settings: () => DEFAULT_EDITING_VIEW, + flag: () => true, + announce: () => {}, + openMenu: () => Promise.resolve(null), + screenReader: () => false, + spelling: () => null, + selectBlocks: () => {}, +}; + +const LETTERS = 'ABCD'; + +/** A 4 by 4 table whose cells read A1 to D4. */ +function sample(): TableData { + return { + header: false, + columns: [0, 1, 2, 3].map((c) => ({ id: `col${c}`, width: 100 })), + rows: [0, 1, 2, 3].map((r) => ({ + id: `row${r}`, + cells: Object.fromEntries([0, 1, 2, 3].map((c) => [`col${c}`, { markdown: `${LETTERS[c]}${r + 1}` }])), + })), + }; +} + +interface Rig { + editor: Editor; + host: TableExtraHost; + patches: Record[]; + block(): Record; + style(cell: string): CellStyle; + select(from: string, to?: string): void; +} + +let rigs: Rig[] = []; +let stops: (() => void)[] = []; +afterEach(() => { + rigs.forEach((rig) => { + rig.editor.view.dom.remove(); + rig.editor.destroy(); + }); + stops.forEach((stop) => stop()); + rigs = []; + stops = []; +}); + +const at = (name: string): [number, number] => [Number(name.slice(1)) - 1, LETTERS.indexOf(name[0])]; + +function rig(markdown: Record = {}): Rig { + let block: Record = { ...sample() }; + for (const [cell, text] of Object.entries(markdown)) { + const [row, col] = at(cell); + const data = block as unknown as TableData; + data.rows[row].cells[`col${col}`] = { markdown: text }; + } + const patches: Record[] = []; + const root = document.createElement('div'); + document.body.append(root); + const editor = createBlockEditor(root, tableDataToDoc(block as unknown as TableData), { + kind: 'table', + block: 'b1', + host: editorHost, + }); + const table = () => block as unknown as TableData; + const host: CellHost = { + editor, + cache: createMarkdownCache(), + data: () => block, + table, + apply: (change, extra) => { + const next = change(table()); + if (!next) return Promise.resolve(false); + block = { ...block, ...next }; + if (extra) { + patches.push(extra); + block = applyMergePatch(block, extra); + } + return Promise.resolve(true); + }, + patch: (data) => { + patches.push(data); + block = applyMergePatch(block, data); + return Promise.resolve(undefined); + }, + announce: () => {}, + }; + const full = host as unknown as TableExtraHost; + stops.push( + registerFormatTarget(createCellRangeTarget((state) => (state.doc === editor.state.doc ? host : undefined))), + ); + const made: Rig = { + editor, + host: full, + patches, + block: () => block, + style(cell) { + const [row, col] = at(cell); + return resolve(readSheet(block), table(), `row${row}`, `col${col}`).style; + }, + select(from, to = from) { + const cellPos = (name: string) => { + const [row, col] = at(name); + let found = -1; + editor.state.doc.descendants((node, pos) => { + if (node.type.name === 'tableRow' && node.attrs.rowId === `row${row}`) { + node.forEach((cell, offset) => { + if (cell.attrs.colId === `col${col}`) found = pos + 1 + offset; + }); + } + return found === -1; + }); + return found; + }; + const state = editor.state; + editor.view.dispatch(state.tr.setSelection(CellSelection.create(state.doc, cellPos(from), cellPos(to)))); + }, + }; + rigs.push(made); + return made; +} + +describe('the cell range format target', () => { + it('writes one patch that gives B2:C3 one style, and the inverse patch takes it back', async () => { + const r = rig(); + r.select('B2', 'C3'); + const before = readSheet(r.block()); + await setFill(r.host, 'theme:accent5'); + expect(r.patches).toHaveLength(1); + const sheet = readSheet(r.block()); + const cells = sheet.cells as Record>; + const ids = ['B2', 'C2', 'B3', 'C3'].map((name) => { + const [row, col] = at(name); + return cells[`row${row}`][`col${col}`]; + }); + expect(new Set(ids).size).toBe(1); + expect(Object.keys(sheet.styles as object)).toHaveLength(1); + expect(r.style('A1').fill).toBeUndefined(); + expect(r.style('B2').fill).toBe('theme:accent5'); + // Undo sends the inverse merge patch, which removes the cells and the style. + const undo = sheetPatch(sheet, before); + expect(readSheet(applyMergePatch(r.block(), undo))).toEqual(before); + }); +}); + +describe('cell formats on whole cells', () => { + it('sets font.bold on a selected cell and takes the bold marks off its runs', async () => { + const r = rig({ B2: '**bold** and plain' }); + r.select('B2'); + expect(runEditorCommand(r.editor, 'format.bold')).toBe(true); + await Promise.resolve(); + await Promise.resolve(); + expect(r.style('B2').font?.bold).toBe(true); + const row = (r.block() as unknown as TableData).rows[1]; + expect(row.cells.col1.markdown).toBe('bold and plain'); + expect(r.patches).toHaveLength(1); + }); + + it('toggles the bold mark only when text in a cell is selected', async () => { + const r = rig({ B2: 'text' }); + const state = r.editor.state; + let start = -1; + state.doc.descendants((node, pos) => { + if (node.isText && node.text === 'text') start = pos; + }); + r.editor.view.dispatch(state.tr.setSelection(TextSelection.create(state.doc, start, start + 4))); + runEditorCommand(r.editor, 'format.bold'); + await Promise.resolve(); + expect(r.patches).toHaveLength(0); + expect(r.style('B2').font?.bold).toBeUndefined(); + let marked = false; + r.editor.state.doc.descendants((node) => { + if (node.isText && node.marks.some((mark) => mark.type.name === 'bold')) marked = true; + }); + expect(marked).toBe(true); + }); +}); + +describe('the cell range target', () => { + it('claims whole cells only, and reports the box of a caret or a range', () => { + const r = rig(); + r.select('B2', 'C3'); + expect(boxOfSelection(r.editor.state, r.block() as unknown as TableData)).toEqual({ + top: 1, + left: 1, + bottom: 2, + right: 2, + }); + }); +}); + +describe('borders and number formats', () => { + it('runs Outside borders on B2:C3: sides of the edge cells only, nothing inside', async () => { + const r = rig(); + r.select('B2', 'C3'); + await setBorders(r.host, 'outside'); + const side = (cell: string) => Object.keys(r.style(cell).border ?? {}).sort(); + expect(side('B2')).toEqual(['l', 't']); + expect(side('C2')).toEqual(['r', 't']); + expect(side('B3')).toEqual(['b', 'l']); + expect(side('C3')).toEqual(['b', 'r']); + for (const cell of ['A1', 'A2', 'D3', 'B4', 'C1']) expect(side(cell)).toEqual([]); + expect(r.patches).toHaveLength(1); + expect(r.style('B2').border?.t).toEqual({ style: 'thin' }); + }); + + it('clears the borders of the box with No border', async () => { + const r = rig(); + r.select('B2', 'C3'); + await setBorders(r.host, 'all'); + expect(Object.keys(r.style('B2').border ?? {}).sort()).toEqual(['b', 'l', 'r', 't']); + await setBorders(r.host, 'none'); + expect(r.style('B2').border).toBeUndefined(); + }); + + it('writes a number format and General takes it away', async () => { + const r = rig(); + r.select('B2'); + await setFormat(r.host, '0.00%'); + expect(r.style('B2').fmt).toBe('0.00%'); + await setFormat(r.host, null); + expect(r.style('B2').fmt).toBeUndefined(); + }); + + it('sets a text size one point up and a cell alignment through the shared commands', async () => { + const r = rig(); + r.select('A1'); + runEditorCommand(r.editor, 'format.fontSizeUp'); + await Promise.resolve(); + await Promise.resolve(); + expect(r.style('A1').font?.size).toBe(13); + runEditorCommand(r.editor, 'para.alignCenter'); + await Promise.resolve(); + await Promise.resolve(); + expect(r.style('A1').align?.h).toBe('center'); + }); +}); + +describe('requests on whole cells that the cell style does not cover', () => { + it('keeps the other marks when Superscript goes on a whole cell', async () => { + const r = rig({ B2: '**bold** and *it*' }); + r.select('B2'); + expect(runEditorCommand(r.editor, 'format.superscript')).toBe(true); + await Promise.resolve(); + await Promise.resolve(); + expect(r.style('B2').font?.script).toBe('super'); + expect((r.block() as unknown as TableData).rows[1].cells.col1.markdown).toBe('**bold** and *it*'); + }); + + it('sets the font family of whole cells from the Home tab command', async () => { + const r = rig(); + r.select('B2', 'C2'); + expect(runEditorCommand(r.editor, 'format.font', { value: 'Georgia' })).toBe(true); + await Promise.resolve(); + await Promise.resolve(); + expect(r.style('B2').font?.family).toBe('Georgia'); + expect(r.style('C2').font?.family).toBe('Georgia'); + }); + + it('lets the text take Highlight on whole cells, which the cell style has no field for', () => { + const r = rig({ B2: 'text' }); + r.select('B2'); + expect(runEditorCommand(r.editor, 'format.highlight')).toBe(true); + expect(r.patches).toHaveLength(0); + }); +}); diff --git a/app/src/features/tables/sheet/formatTarget.ts b/app/src/features/tables/sheet/formatTarget.ts new file mode 100644 index 000000000..b5fd8d3ac --- /dev/null +++ b/app/src/features/tables/sheet/formatTarget.ts @@ -0,0 +1,298 @@ +// The cell-range format target (owner: tables:toolbar). The shared format commands (Bold, Italic, Text color, size, +// alignment, Clear formatting) ask the format target seam first (editor/commands/formatTarget.ts). With whole cells +// selected, this target claims the request and writes a cell style into `data.sheet` instead of marks into the +// text. With a caret or selected text in a cell, no target claims it, and the mark is toggled as before. +// +// The planning is pure (`planFormat`, `boxOfSelection`), so tests run it without an editor. `writeFormat` sends +// the change as one data patch, so Undo takes it back in one step. + +import type { Editor } from '@tiptap/core'; +import { Fragment } from '@tiptap/pm/model'; +import type { Node as PMNode } from '@tiptap/pm/model'; +import { CellSelection, TableMap } from '@tiptap/pm/tables'; +import type { EditorState } from '@tiptap/pm/state'; +import { registerFormatTarget } from '../../../editor/commands/formatTarget'; +import type { FormatRequest, FormatTarget } from '../../../editor/commands/formatTarget'; +import { BODY_POINTS, nudgeSize, parseSizeInput, pointsOf, stepSize } from '../../../editor/commands/sizes'; +import { parseCell, serializeCell } from '../../../editor/markdown'; +import type { MarkdownCache } from '../../../editor/markdown'; +import type { TableData } from '../../../editor/schema/specs'; +import { t } from '../../../strings/t'; +import { applyToRange, readSheet, resolve, sheetPatch } from './model'; +import type { Box, CellStyle, Sheet, StyleChange } from './model'; + +/** What a format needs of the table block that holds the cells (the table extra's host has all of it). */ +export interface CellHost { + readonly editor: Editor; + readonly cache: MarkdownCache; + data(): Record; + table(): TableData; + apply(change: (data: TableData) => TableData | null, extra?: Record): Promise; + patch(data: Record): Promise; + announce(text: string): void; +} + +/** The cells of a box, by position, both ends included. */ +export type CellBox = Box; + +/** A box with the change to apply to its cells. */ +export interface Step { + box: CellBox; + change: StyleChange; +} + +/** What one format does: cell style changes, and the text marks the cell's runs lose so the style shows. */ +export interface Plan { + steps: Step[]; + strip: readonly string[]; +} + +/** The marks of a cell's runs that the cell-wide format of the same name replaces. */ +const STRIPPED: Readonly> = { + bold: ['bold', 'weight'], + italic: ['italic'], + underline: ['underline'], + strike: ['strike'], + textColor: ['textColor'], + textSize: ['textSize'], + script: ['subscript', 'superscript'], + clear: [ + 'bold', + 'weight', + 'italic', + 'underline', + 'strike', + 'textColor', + 'textSize', + 'font', + 'subscript', + 'superscript', + ], +}; + +/** The box of the selected cells, or of the cell holding the caret, or null outside a table of this data. */ +export function boxOfSelection(state: EditorState, table: TableData): CellBox | null { + const { selection } = state; + const rows = table.rows.length; + const cols = table.columns.length; + let anchor: number; + let head: number; + let node: PMNode; + let start: number; + if (selection instanceof CellSelection) { + anchor = selection.$anchorCell.pos; + head = selection.$headCell.pos; + node = selection.$anchorCell.node(-1); + start = selection.$anchorCell.start(-1); + } else { + const $from = selection.$from; + let depth = $from.depth; + while ( + depth > 0 && + $from.node(depth).type.spec.tableRole !== 'cell' && + $from.node(depth).type.spec.tableRole !== 'header_cell' + ) + depth--; + if (depth < 1) return null; + anchor = head = $from.before(depth); + node = $from.node(depth - 2); + start = $from.start(depth - 2); + } + if (node.type.spec.tableRole !== 'table') return null; + const map = TableMap.get(node); + if (map.height !== rows || map.width !== cols) return null; + const rect = map.rectBetween(anchor - start, head - start); + return { top: rect.top, left: rect.left, bottom: rect.bottom - 1, right: rect.right - 1 }; +} + +const sizeOf = (style: CellStyle): number => style.font?.size ?? BODY_POINTS; + +/** The style of the cell at a position, as the table draws it. */ +export function styleAt(sheet: Sheet, table: TableData, row: number, col: number): CellStyle { + return resolve(sheet, table, table.rows[row].id, table.columns[col].id).style; +} + +/** The cell style changes for a request, or null when the cell does not take it. `style` is the active cell's. */ +export function planFormat(request: FormatRequest, box: CellBox, style: CellStyle): Plan | null { + const whole = (change: StyleChange, strip: readonly string[] = []): Plan => ({ steps: [{ box, change }], strip }); + switch (request.format) { + case 'bold': + return whole({ font: { bold: !style.font?.bold } }, STRIPPED.bold); + case 'italic': + return whole({ font: { italic: !style.font?.italic } }, STRIPPED.italic); + case 'underline': { + const on = style.font?.underline !== undefined && style.font.underline !== 'none'; + return whole({ font: { underline: on ? 'none' : 'single' } }, STRIPPED.underline); + } + case 'strike': + return whole({ font: { strike: style.font?.strike === 'single' ? 'none' : 'single' } }, STRIPPED.strike); + case 'subscript': + return whole({ font: { script: style.font?.script === 'sub' ? 'none' : 'sub' } }, STRIPPED.script); + case 'superscript': + return whole({ font: { script: style.font?.script === 'super' ? 'none' : 'super' } }, STRIPPED.script); + case 'textColor': + return whole({ font: { color: request.color } }, STRIPPED.textColor); + case 'textSize': { + if (request.size === null) return whole({ font: { size: null } }, STRIPPED.textSize); + const points = pointsOf(request.size) ?? parseSizeInput(request.size); + return points === null ? null : whole({ font: { size: points } }, STRIPPED.textSize); + } + case 'textSizeStep': { + const now = sizeOf(style); + const next = request.by === 'point' ? nudgeSize(now, request.direction) : stepSize(now, request.direction); + return whole({ font: { size: next } }, STRIPPED.textSize); + } + case 'run': { + if (request.mark !== 'font') return null; + const name = request.attrs?.font; + return whole({ font: { family: typeof name === 'string' ? name : null } }, ['font']); + } + case 'clear': + return whole({ fmt: null, font: null, fill: null, border: null, align: null, pad: null }, STRIPPED.clear); + case 'align': { + const h = { + left: 'left', + center: 'center', + right: 'right', + justify: 'justify', + distribute: 'distributed', + } as const; + return whole({ align: { h: h[request.align] } }); + } + default: + return null; + } +} + +/** The ID corners of a box. */ +const corners = (table: TableData, box: CellBox): { from: [string, string]; to: [string, string] } => ({ + from: [table.rows[box.top].id, table.columns[box.left].id], + to: [table.rows[box.bottom].id, table.columns[box.right].id], +}); + +export interface Written { + sheet: Sheet; + /** The merge patch of the block's data, or null when nothing changed. */ + patch: Record | null; + refused?: 'styles'; +} + +/** Applies each step in turn to a copy of the sheet. One patch covers them all. */ +export function writeSteps(before: Sheet, table: TableData, steps: readonly Step[]): Written { + let sheet = before; + for (const step of steps) { + const { from, to } = corners(table, step.box); + const applied = applyToRange(sheet, table, from, to, step.change); + if (applied.refused) return { sheet: before, patch: null, refused: applied.refused }; + sheet = applied.sheet; + } + return { sheet, patch: sheetPatch(before, sheet) }; +} + +/** The table without the marks `marks` in the runs of the cells of `box`, or null when no cell had one. */ +export function stripMarks( + data: TableData, + box: CellBox, + marks: readonly string[], + cache: MarkdownCache, +): TableData | null { + if (marks.length === 0) return null; + let changed = false; + const rows = data.rows.map((row, r) => { + if (r < box.top || r > box.bottom) return row; + let cells = row.cells; + for (let c = box.left; c <= box.right; c++) { + const id = data.columns[c].id; + const markdown = cells[id]?.markdown ?? ''; + if (markdown === '') continue; + const paragraph = parseCell(markdown); + const kids: PMNode[] = []; + let touched = false; + paragraph.forEach((child) => { + const kept = child.marks.filter((mark) => !marks.includes(mark.type.name)); + if (kept.length !== child.marks.length) touched = true; + kids.push(child.mark(kept)); + }); + if (!touched) continue; + const next = serializeCell(paragraph.copy(Fragment.from(kids)), cache); + if (next === markdown) continue; + changed = true; + cells = { ...cells, [id]: { ...cells[id], markdown: next } }; + } + return cells === row.cells ? row : { ...row, cells }; + }); + return changed ? { ...data, rows } : null; +} + +/** Selects the cells of a box again, as whole cells. */ +export function selectBox(editor: Editor, box: CellBox): void { + const { doc } = editor.state; + let table: { node: PMNode; pos: number } | null = null; + doc.descendants((node, pos) => { + if (table || node.type.spec.tableRole !== 'table') return table === null; + table = { node, pos }; + return false; + }); + const found = table as { node: PMNode; pos: number } | null; + if (!found) return; + const map = TableMap.get(found.node); + if (box.bottom >= map.height || box.right >= map.width) return; + const start = found.pos + 1; + const anchor = start + map.map[box.top * map.width + box.left]; + const head = start + map.map[box.bottom * map.width + box.right]; + editor.view.dispatch( + editor.state.tr.setSelection(CellSelection.create(doc, anchor, head)).setMeta('addToHistory', false), + ); +} + +/** Writes steps and the mark removal as one change. Returns whether anything changed. */ +export async function writeFormat( + host: CellHost, + steps: readonly Step[], + strip: readonly string[] = [], + box: CellBox = steps[0].box, +): Promise { + const table = host.table(); + const written = writeSteps(readSheet(host.data()), table, steps); + if (written.refused) { + host.announce(t('sheetToolbar.announce.tooMany')); + return false; + } + const stripped = stripMarks(table, box, strip, host.cache); + if (stripped) { + const whole = host.editor.state.selection instanceof CellSelection; + const ok = await host.apply(() => stripped, written.patch ?? {}); + // The block shows its new rows by replacing the document, which leaves a caret: put the cells back. + if (whole && ok) selectBox(host.editor, box); + return ok; + } + if (!written.patch) return false; + await host.patch(written.patch); + return true; +} + +/** The cell target: claims whole cells of a table block that has a host, and writes the request as a cell style. */ +export function createCellRangeTarget(hostOf: (state: EditorState) => CellHost | undefined): FormatTarget { + return { + id: 'tables.cellRange', + claims: (state) => state.selection instanceof CellSelection && hostOf(state) !== undefined, + apply(request, state, dispatch) { + const host = hostOf(state); + if (!host) return false; + const table = host.table(); + const box = boxOfSelection(state, table); + if (!box) return false; + const style = styleAt(readSheet(host.data()), table, box.top, box.left); + const plan = planFormat(request, box, style); + if (!plan) return false; + // With no dispatch the command only asks whether it could. The write goes through the block's own sync. + if (dispatch) void writeFormat(host, plan.steps, plan.strip, box); + return true; + }, + }; +} + +/** Adds the target for the hosts `hostOf` knows. Returns a function that takes it away. */ +export function registerCellRangeTarget(hostOf: (state: EditorState) => CellHost | undefined): () => void { + return registerFormatTarget(createCellRangeTarget(hostOf)); +} diff --git a/app/src/features/tables/sheet/model/index.ts b/app/src/features/tables/sheet/model/index.ts index 5eca78579..6efbe6958 100644 --- a/app/src/features/tables/sheet/model/index.ts +++ b/app/src/features/tables/sheet/model/index.ts @@ -18,6 +18,11 @@ export { validationAt, applyOrder, setConditionalLayer, + getConditionalLayer, + setDrawnLayer, + getDrawnLayer, + setLayerScheme, + getLayerScheme, freeRuleId, setRule, removeRule, @@ -25,7 +30,7 @@ export { wholeTable, RULE_LIMIT, } from './rules'; -export type { Rule, RuleKind, ConditionalLayer } from './rules'; +export type { Rule, RuleKind, ConditionalLayer, DrawnDecoration, DrawnLayer } from './rules'; export { namesOf, findName, setName, deleteName, pageNames, lookupName, tableName, NAME_LIMIT } from './names'; export type { NamedRef, NameRefusal, PageName } from './names'; export { diff --git a/app/src/features/tables/sheet/model/rules.ts b/app/src/features/tables/sheet/model/rules.ts index 0a3f6738b..fd097195f 100644 --- a/app/src/features/tables/sheet/model/rules.ts +++ b/app/src/features/tables/sheet/model/rules.ts @@ -182,6 +182,43 @@ export function getConditionalLayer(): ConditionalLayer | null { return conditionalLayer; } +/** + * What the conditional lane draws besides styles (bars and icons): CSS custom properties and data attributes for a + * cell, in the shape of the render lane's `Drawn`. + */ +export interface DrawnDecoration { + vars: Record; + data: Record; +} + +export type DrawnLayer = ( + cell: { row: number; col: number }, + sheet: Sheet, + table: TableLike, + scheme: 'light' | 'dark', +) => DrawnDecoration | undefined; + +let drawnLayer: DrawnLayer | null = null; +let layerScheme: 'light' | 'dark' = 'light'; + +/** Lets the conditional lane draw data bars and icons in the render lane's cells. */ +export function setDrawnLayer(layer: DrawnLayer | null): void { + drawnLayer = layer; +} + +export function getDrawnLayer(): DrawnLayer | null { + return drawnLayer; +} + +/** The color scheme the render lane is drawing, which the conditional layer needs for color scales. */ +export function setLayerScheme(scheme: 'light' | 'dark'): void { + layerScheme = scheme; +} + +export function getLayerScheme(): 'light' | 'dark' { + return layerScheme; +} + /** A free rule ID: the prefix and the lowest number not in use. */ export function freeRuleId(sheet: Sheet, kind: RuleKind, prefix = kind === 'validation' ? 'v' : 'c'): string { const rules = ownObject(sheet, kind) ?? {}; diff --git a/app/src/features/tables/sheet/model/schema.ts b/app/src/features/tables/sheet/model/schema.ts index 639383046..4c57a9ee6 100644 --- a/app/src/features/tables/sheet/model/schema.ts +++ b/app/src/features/tables/sheet/model/schema.ts @@ -179,6 +179,7 @@ const TABLE = obj({ pad: PAD, grid: BOOL, name: ok(isName, 'a name (R-SHT-13)'), + keys: oneOf('spreadsheet', 'document'), }); const COL = obj({ style: STYLE_REF, hidden: BOOL, level: integer(0, 7), collapsed: BOOL }); const ROW = obj({ diff --git a/app/src/features/tables/sheet/registry.test.ts b/app/src/features/tables/sheet/registry.test.ts index 1b399938b..b41bb1127 100644 --- a/app/src/features/tables/sheet/registry.test.ts +++ b/app/src/features/tables/sheet/registry.test.ts @@ -29,8 +29,12 @@ describe('the sheet contribution registry', () => { ); undo.push(take); expect(commands.get('sheetTest.bold')).toBeDefined(); - expect(sheetMenu('format').map((item) => item.id)).toEqual(['a', 'b']); - // Other lanes contribute to the Data menu from their own contribute.ts, so look only at this test's own item. + // Other lanes contribute to the Format and Data menus from their own contribute.ts, so look only at this test's items. + expect( + sheetMenu('format') + .filter((item) => item.lane === 'merge') + .map((item) => item.id), + ).toEqual(['a', 'b']); expect( sheetMenu('data') .filter((item) => item.id === 'c') @@ -40,7 +44,7 @@ describe('the sheet contribution registry', () => { expect(contextMenus.get('sheetTest.merge')?.menu).toBe(SHEET_CONTEXT_MENU); take(); expect(commands.get('sheetTest.bold')).toBeUndefined(); - expect(sheetMenu('format')).toEqual([]); + expect(sheetMenu('format').filter((item) => item.lane === 'merge')).toEqual([]); expect(contextMenus.get('sheetTest.merge')).toBeUndefined(); }); diff --git a/app/src/features/tables/sheet/render/draw.ts b/app/src/features/tables/sheet/render/draw.ts index 5016c9a46..bde6c0e04 100644 --- a/app/src/features/tables/sheet/render/draw.ts +++ b/app/src/features/tables/sheet/render/draw.ts @@ -5,7 +5,7 @@ import { resolve, type CellStyle, type ResolveHooks, type Sheet, type TableLike } from '../model'; import { own, ownObject } from '../model/json'; -import { getConditionalLayer } from '../model/rules'; +import { getConditionalLayer, getDrawnLayer, setLayerScheme, type DrawnDecoration } from '../model/rules'; import { kindOf, textAlign, verticalAlign, type ValueKind } from './align'; import { cssLine, resolvedSides, type Edge } from './borders'; import { automaticText, colorOf, drawFill, type Scheme } from './color'; @@ -169,6 +169,8 @@ interface View { visCols: number[]; styles: CellStyle[][]; texts: RowCell[][]; + /** What the conditional lane draws in each shown cell besides its style: data bars and icons. */ + drawn: (DrawnDecoration | undefined)[][]; } /** The text and kind of each shown cell of one shown row. */ @@ -186,6 +188,7 @@ function textRow(input: DrawInput, v: Pick, r: numbe function viewOf(input: DrawInput): View { const { sheet, table, scheme } = input; + setLayerScheme(scheme); const settings = ownObject(sheet, 'table'); const freeze = ownObject(settings, 'freeze'); const rowHidden = table.rows.map((r) => hiddenOf(sheet, 'rows', r.id, input.hiddenRows)); @@ -218,7 +221,10 @@ function viewOf(input: DrawInput): View { visCols, styles, texts: [], + drawn: [], }; + const drawn = ownObject(sheet, 'conditional') === undefined ? null : getDrawnLayer(); + if (drawn) v.drawn = visRows.map((r) => visCols.map((c) => drawn({ row: r, col: c }, sheet, table, scheme))); v.texts = visRows.map((r, vr) => textRow(input, v, r, vr)); return v; } @@ -277,6 +283,7 @@ function revisedView(last: View, input: DrawInput, changed: Set): View { v.styles.forEach((row, vr) => { const same = JSON.stringify(row) === JSON.stringify(last.styles[vr]) && + JSON.stringify(v.drawn[vr]) === JSON.stringify(last.drawn[vr]) && v.texts[vr].every((t, vc) => t.text === last.texts[vr][vc].text && t.kind === last.texts[vr][vc].kind); if (same) return; for (const k of [vr - 1, vr, vr + 1]) if (v.visRows[k] !== undefined) changed.add(v.visRows[k]); @@ -322,6 +329,11 @@ function drawCell(input: DrawInput, v: View, vr: number, vc: number, d: Drawn): fontVars(style, d.vars, scheme, fill.under); alignVars(style, v.texts[vr][vc].kind, d.vars, d.data); padVars(style, d.vars); + const extra = v.drawn[vr]?.[vc]; + if (extra) { + Object.assign(d.vars, extra.vars); + Object.assign(d.data, extra.data); + } const near = { above: vr > 0 ? v.styles[vr - 1][vc].border : undefined, below: vr < v.visRows.length - 1 ? v.styles[vr + 1][vc].border : undefined, diff --git a/app/src/features/tables/sheet/render/plugin.ts b/app/src/features/tables/sheet/render/plugin.ts index 2704286fc..cf80463a9 100644 --- a/app/src/features/tables/sheet/render/plugin.ts +++ b/app/src/features/tables/sheet/render/plugin.ts @@ -56,7 +56,8 @@ interface Meta { export function currentScheme(): Scheme { const set = document.documentElement.dataset.theme; if (set === 'dark' || set === 'light') return set; - return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + // A document with no media queries (a test document) is light. + return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; } const attrsOf = (d: Drawn, extra: Record = {}, measured?: CellProps): Record => { diff --git a/app/src/features/tables/sheet/render/render.css b/app/src/features/tables/sheet/render/render.css index 509e2006e..0164579a0 100644 --- a/app/src/features/tables/sheet/render/render.css +++ b/app/src/features/tables/sheet/render/render.css @@ -27,7 +27,7 @@ padding-inline: calc(var(--cell-pad-l, 4pt) + var(--cell-indent-l, 0px)) calc(var(--cell-pad-r, 4pt) + var(--cell-indent-r, 0px)); background-color: var(--cell-fill, transparent); - background-image: var(--cell-diag, none), var(--cell-fill-image, none); + background-image: var(--cell-bar, none), var(--cell-diag, none), var(--cell-fill-image, none); color: var(--cell-color, inherit); font-family: var(--cell-font, inherit); font-size: var(--cell-size, inherit); @@ -45,6 +45,22 @@ font-weight: var(--cell-weight, 700); } + /* Conditional icon sets: the glyph comes first, and its text after the slash is what a screen reader says. */ + table[data-sheet] :is(td, th)[data-cell][data-icon-glyph] > p:first-child::before { + content: var(--cell-icon-glyph) / var(--cell-icon-text); + color: var(--cell-icon-color, var(--cell-color, var(--sheet-ink, CanvasText))); + margin-inline-end: 0.35em; + } + + /* Icon only and bar only keep the value for assistive technology and hide it from the eye. */ + table[data-sheet] :is(td, th)[data-cell]:is([data-icon-only], [data-bar-only]) > p { + color: transparent; + } + + table[data-sheet] :is(td, th)[data-cell][data-icon-only] > p:first-child::before { + margin-inline-end: 0; + } + table[data-sheet] :is(td, th)[data-cell][data-distributed] { text-align-last: justify; } @@ -229,6 +245,24 @@ background-color: Canvas; } + /* A data bar stays: forced colors keep it as a gradient, with an outline so it shows without its color, and the + value of a bar-only cell stays readable. Icon sets differ by shape, not by color alone (R-SHT-15). */ + table[data-sheet] :is(td, th)[data-cell][data-bar] { + background-image: var(--cell-bar); + forced-color-adjust: none; + color: CanvasText; + outline: 1px solid CanvasText; + outline-offset: -2px; + } + + table[data-sheet] :is(td, th)[data-cell]:is([data-icon-only], [data-bar-only]) > p { + color: CanvasText; + } + + table[data-sheet] :is(td, th)[data-cell][data-icon-glyph] > p:first-child::before { + color: CanvasText; + } + table[data-sheet] :is(td, th)[data-cell][data-filled] { outline: 1px solid CanvasText; outline-offset: -2px; diff --git a/app/src/features/tables/smart/attach.ts b/app/src/features/tables/smart/attach.ts index c92ddb604..45a27b2ad 100644 --- a/app/src/features/tables/smart/attach.ts +++ b/app/src/features/tables/smart/attach.ts @@ -11,7 +11,7 @@ import { openMenu } from '../../../ui'; import type { TableData } from '../../../editor/schema/specs'; import type { BlockJson } from '../../../services/pages/types'; import type { TableExtraHandle, TableExtraHost } from '../../page'; -import { columnLetter, formatValue, totalsRow, hasTotals } from '../engine'; +import { columnLetter, formatValue, onStatisticsLoaded, totalsRow, hasTotals } from '../engine'; import type { Table } from '../engine'; import { readSheet } from '../sheet/model'; import type { Sheet } from '../sheet/model'; @@ -149,6 +149,8 @@ class SmartTable implements SmartInstance, TableExtraHandle, BookMember { private menuOpen = false; private timer: ReturnType | undefined; private gone = false; + // Formulas that call a lazily loaded function show #BUSY! until it lands; then the table calculates again. + private readonly stopLoaded = onStatisticsLoaded(() => this.refresh()); private readonly mount = document.createElement('div'); private readonly root: Root = createRoot(this.mount); private readonly editor: TableExtraHost['editor']; @@ -414,6 +416,7 @@ class SmartTable implements SmartInstance, TableExtraHandle, BookMember { destroy(): void { this.gone = true; + this.stopLoaded(); clearTimeout(this.timer); live.delete(this); this.book.leave(this); diff --git a/app/src/features/tables/ui/conditional/RuleManager.module.css b/app/src/features/tables/ui/conditional/RuleManager.module.css new file mode 100644 index 000000000..5cf184614 --- /dev/null +++ b/app/src/features/tables/ui/conditional/RuleManager.module.css @@ -0,0 +1,75 @@ +.manager { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px; + box-sizing: border-box; + max-inline-size: 100%; + background: var(--color-surface-raised); + color: var(--color-text-primary); +} + +.title { + margin: 0; + font-size: 1rem; +} + +.scope { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.select { + min-block-size: var(--target-size, 32px); + font: inherit; + color: inherit; + background: var(--color-surface-page); + border: 1px solid var(--color-border-default); + border-radius: 6px; + padding-inline: 8px; +} + +.list { + display: flex; + flex-direction: column; + gap: 4px; + margin: 0; + padding: 0; + list-style: none; +} + +.rule { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + padding: 6px 8px; + border: 1px solid var(--color-border-default); + border-radius: 6px; +} + +.text { + flex: 1 1 12rem; + min-inline-size: 0; + overflow-wrap: anywhere; +} + +.ranges { + display: block; + font-size: 0.8125rem; + color: var(--color-text-secondary); +} + +.actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; +} + +.empty { + margin: 0; + color: var(--color-text-secondary); +} diff --git a/app/src/features/tables/ui/conditional/RuleManager.test.tsx b/app/src/features/tables/ui/conditional/RuleManager.test.tsx new file mode 100644 index 000000000..75afb59cc --- /dev/null +++ b/app/src/features/tables/ui/conditional/RuleManager.test.tsx @@ -0,0 +1,85 @@ +import { screen } from '@testing-library/react'; +import { userEvent } from 'vitest/browser'; +import { describe, expect, it, vi } from 'vitest'; +import { expectNoAxeViolations, renderUi, setViewport } from '../../../../test'; +import { RuleManager } from './RuleManager'; +import type { ManagedRule } from './manage'; + +const rules: ManagedRule[] = [ + { id: 'c1', summary: 'Cell value is greater than "5"', ranges: 'column A', stop: false }, + { id: 'c2', summary: 'Blank cells', ranges: 'column B', stop: true }, +]; + +const open = (list: ManagedRule[] = rules) => { + const handlers = { onScope: vi.fn(), onMove: vi.fn(), onStop: vi.fn(), onRemove: vi.fn(), onDone: vi.fn() }; + const view = renderUi( +
+ +
, + ); + return { ...handlers, ...view }; +}; + +describe('RuleManager', () => { + it('lists the rules in order with their ranges, and has no accessibility violations', async () => { + const { container } = open(); + const items = screen.getAllByRole('listitem'); + expect(items).toHaveLength(2); + expect(items[0].textContent).toContain('Cell value is greater than "5"'); + expect(items[0].textContent).toContain('Applies to column A'); + await expectNoAxeViolations(container); + }); + + it('reports raise, lower, stop, delete, and scope changes', async () => { + const h = open(); + await userEvent.click(screen.getAllByRole('button', { name: 'Lower priority' })[0]); + expect(h.onMove).toHaveBeenCalledWith({ ruleId: 'c1' }, 1); + await userEvent.click(screen.getAllByRole('button', { name: 'Raise priority' })[1]); + expect(h.onMove).toHaveBeenCalledWith({ ruleId: 'c2' }, -1); + await userEvent.click(screen.getAllByRole('switch', { name: 'Stop if true' })[0]); + expect(h.onStop).toHaveBeenCalledWith({ ruleId: 'c1' }, true); + expect(screen.getAllByRole('switch', { name: 'Stop if true' })[1]).toBeChecked(); + await userEvent.click(screen.getAllByRole('button', { name: 'Delete rule' })[1]); + expect(h.onRemove).toHaveBeenCalledWith({ ruleId: 'c2' }); + await userEvent.selectOptions(screen.getByRole('combobox', { name: 'Show rules for' }), 'page'); + expect(h.onScope).toHaveBeenCalledWith('page'); + }); + + it('the first rule cannot go up and the last cannot go down', () => { + open(); + expect(screen.getAllByRole('button', { name: 'Raise priority' })[0]).toBeDisabled(); + expect(screen.getAllByRole('button', { name: 'Lower priority' })[1]).toBeDisabled(); + }); + + it('says so when there are no rules, and fits a phone', async () => { + await setViewport(320, 640); + const { container } = open([]); + expect(screen.getByText('There are no conditional formatting rules here.')).toBeVisible(); + await expectNoAxeViolations(container); + }); + + it('reports the table with a rule on the page scope, since rule IDs repeat across tables', async () => { + const h = open([ + { id: 'c1', summary: 'One', ranges: 'column A', stop: false, tableId: 'tA', tableLabel: 'Table 1' }, + { id: 'c1', summary: 'Two', ranges: 'column A', stop: false, tableId: 'tB', tableLabel: 'Table 2' }, + ]); + await userEvent.click(screen.getAllByRole('button', { name: 'Delete rule' })[1]); + expect(h.onRemove).toHaveBeenCalledWith({ ruleId: 'c1', tableId: 'tB' }); + }); + + it('keeps a rule inside its table: no move across a table boundary on the page scope', () => { + open([ + { id: 'c1', summary: 'One', ranges: 'column A', stop: false, tableId: 'tA', tableLabel: 'Table 1' }, + { id: 'c1', summary: 'Two', ranges: 'column A', stop: false, tableId: 'tB', tableLabel: 'Table 2' }, + ]); + const down = screen.getAllByRole('button', { name: 'Lower priority' }); + const up = screen.getAllByRole('button', { name: 'Raise priority' }); + expect(down[0]).toBeDisabled(); + expect(up[1]).toBeDisabled(); + }); + + it('says so when a formula does not read', () => { + open([{ id: 'c1', summary: 'Formula', ranges: 'column A', stop: false, problem: true }]); + expect(screen.getByRole('alert').textContent).toContain('cannot be read'); + }); +}); diff --git a/app/src/features/tables/ui/conditional/RuleManager.tsx b/app/src/features/tables/ui/conditional/RuleManager.tsx new file mode 100644 index 000000000..9d21c179a --- /dev/null +++ b/app/src/features/tables/ui/conditional/RuleManager.tsx @@ -0,0 +1,102 @@ +// The conditional formatting rule manager: the rules of the selection, the table, or the page in priority order, +// with raise, lower, stop if true, and delete. It shows what the caller gives it and reports each change, so the +// table's editor applies the change as one undo step (manage.ts works out the patch). + +import { useId } from 'react'; +import { t } from '../../../../strings/t'; +import { Button } from '../../../../ui/Button'; +import { Switch } from '../../../../ui/Switch'; +import { refOf, type ManagedRule, type RuleRef, type Scope } from './manage'; +import styles from './RuleManager.module.css'; + +export interface RuleManagerProps { + rules: readonly ManagedRule[]; + scope: Scope; + onScope(scope: Scope): void; + onMove(rule: RuleRef, by: -1 | 1): void; + onStop(rule: RuleRef, stop: boolean): void; + onRemove(rule: RuleRef): void; + onDone?(): void; +} + +const SCOPES: readonly Scope[] = ['selection', 'table', 'page']; + +export function RuleManager(props: RuleManagerProps) { + const { rules, scope, onScope, onMove, onStop, onRemove, onDone } = props; + const heading = useId(); + const select = useId(); + const label: Record = { + selection: t('sheetConditional.manager.scopeSelection'), + table: t('sheetConditional.manager.scopeTable'), + page: t('sheetConditional.manager.scopePage'), + }; + return ( +
+

+ {t('sheetConditional.manager.title')} +

+
+ + +
+ {rules.length === 0 ? ( +

{t('sheetConditional.manager.empty')}

+ ) : ( +
    + {rules.map((rule, i) => ( +
  1. + + {rule.summary} + {rule.problem ? {t('sheetConditional.manager.problem')} : null} + + {rule.tableLabel ? `${rule.tableLabel}. ` : ''} + {t('sheetConditional.manager.appliesTo', { ranges: rule.ranges })} + + + + + + onStop(refOf(rule), on)} + /> + + +
  2. + ))} +
+ )} + {onDone ? ( + + ) : null} +
+ ); +} diff --git a/app/src/features/tables/ui/conditional/index.ts b/app/src/features/tables/ui/conditional/index.ts new file mode 100644 index 000000000..45890bd82 --- /dev/null +++ b/app/src/features/tables/ui/conditional/index.ts @@ -0,0 +1,13 @@ +export { RuleManager } from './RuleManager'; +export type { RuleManagerProps } from './RuleManager'; +export { + deleteRule, + describeRanges, + describeRule, + managedPageRules, + managedRules, + moveRule, + refOf, + setStop, +} from './manage'; +export type { Changed, ManagedRule, RuleRef, Scope } from './manage'; diff --git a/app/src/features/tables/ui/conditional/manage.test.ts b/app/src/features/tables/ui/conditional/manage.test.ts new file mode 100644 index 000000000..0721b0904 --- /dev/null +++ b/app/src/features/tables/ui/conditional/manage.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; +import { applyPatch, type JsonObject } from '../../sheet/model/json'; +import { K, R } from '../../sheet/model/testing'; +import type { Sheet, TableLike } from '../../sheet/model/types'; +import { + deleteRule, + describeRanges, + describeRule, + managedPageRules, + managedRules, + moveRule, + refOf, + setStop, +} from './manage'; + +const table: TableLike = { + header: false, + columns: [{ id: K(1) }, { id: K(2) }], + rows: [1, 2, 3].map((n) => ({ id: R(n), cells: {} })), +}; +const sheet = (): Sheet => ({ + conditional: { + c1: { ranges: [{ col: K(1) }], order: 1, type: 'cell', op: 'greater', a: '5' }, + c2: { ranges: [{ col: K(2) }], order: 2, type: 'blank', stop: true }, + c3: { ranges: [{ from: [R(1), K(1)], to: [R(2), K(2)] }], order: 3, type: 'top', rank: 5, percent: true }, + }, +}); + +describe('rule manager model', () => { + it('describes rules in sentences', () => { + expect(describeRule({ type: 'cell', op: 'greater', a: '5' })).toBe('Cell value is greater than "5"'); + expect(describeRule({ type: 'cell', op: 'between', a: '1', b: '9' })).toBe('Cell value is between "1" and "9"'); + expect(describeRule({ type: 'text', op: 'begins', text: 'ab' })).toBe('Text begins with "ab"'); + expect(describeRule({ type: 'top', rank: 5, percent: true })).toBe('Top 5 percent'); + expect(describeRule({ type: 'top', rank: 3, bottom: true })).toBe('Bottom 3 items'); + expect(describeRule({ type: 'date', period: 'lastWeek' })).toBe('Dates last week'); + expect(describeRule({ type: 'average', below: true })).toBe('Below average'); + expect(describeRule({ type: 'icons', set: '3Arrows' })).toBe('Icon set 3Arrows'); + expect(describeRule({ type: 'future' })).toBe('A rule from a newer version'); + }); + + it('describes ranges', () => { + expect(describeRanges((sheet().conditional as Record).c1, table)).toBe('column A'); + expect(describeRanges((sheet().conditional as Record).c3, table)).toBe('cells A1 to B2'); + expect(describeRanges({ ranges: [{ row: R(3) }] }, table)).toBe('row 3'); + }); + + it('lists the table in priority order and the selection by overlap', () => { + expect(managedRules(sheet(), table, 'table').map((r) => r.id)).toEqual(['c1', 'c2', 'c3']); + const selection = { top: 2, bottom: 2, left: 1, right: 1 }; + expect(managedRules(sheet(), table, 'selection', selection).map((r) => r.id)).toEqual(['c2']); + expect(managedRules(sheet(), table, 'table')[1]).toMatchObject({ stop: true, summary: 'Blank cells' }); + }); + + it('moves a rule, renumbers the orders, and gives a patch that makes the new sheet', () => { + const before = sheet(); + const moved = moveRule(before, 'c3', -1); + expect(managedRules(moved.sheet, table, 'table').map((r) => r.id)).toEqual(['c1', 'c3', 'c2']); + const orders = Object.values(moved.sheet.conditional as Record).map((r) => r.order); + expect(orders.sort()).toEqual([1, 2, 3]); + expect(applyPatch(before, (moved.patch as { sheet: never }).sheet)).toEqual(moved.sheet); + // The first rule cannot go up: nothing changes. + expect(moveRule(before, 'c1', -1).patch).toBeNull(); + }); + + it('sets and clears stop, and deletes a rule', () => { + const on = setStop(sheet(), 'c1', true); + expect(managedRules(on.sheet, table, 'table')[0].stop).toBe(true); + const off = setStop(on.sheet, 'c1', false); + expect(managedRules(off.sheet, table, 'table')[0].stop).toBe(false); + const gone = deleteRule(sheet(), 'c2'); + expect(managedRules(gone.sheet, table, 'table').map((r) => r.id)).toEqual(['c1', 'c3']); + const empty = deleteRule(deleteRule(deleteRule(sheet(), 'c1').sheet, 'c2').sheet, 'c3'); + expect(empty.sheet.conditional).toBeUndefined(); + }); +}); + +describe('page scope', () => { + it('lists the rules of every table with the table each belongs to, so a repeated rule ID is not ambiguous', () => { + const rules = managedPageRules([ + { tableId: 'tA', label: 'Table 1', table, sheet: sheet() }, + { tableId: 'tB', label: 'Table 2', table, sheet: sheet() }, + ]); + expect(rules.map((r) => `${r.tableId}:${r.id}`)).toEqual(['tA:c1', 'tA:c2', 'tA:c3', 'tB:c1', 'tB:c2', 'tB:c3']); + expect(refOf(rules[3])).toEqual({ ruleId: 'c1', tableId: 'tB' }); + expect(refOf(managedRules(sheet(), table, 'table')[0])).toEqual({ ruleId: 'c1' }); + }); +}); diff --git a/app/src/features/tables/ui/conditional/manage.ts b/app/src/features/tables/ui/conditional/manage.ts new file mode 100644 index 000000000..202f9d26e --- /dev/null +++ b/app/src/features/tables/ui/conditional/manage.ts @@ -0,0 +1,255 @@ +// The rule manager's work, apart from its screen: which rules to list for a scope, how a rule reads in a sentence, +// and the changes (priority, stop if true, delete) as a new sheet and a patch for the table's data. + +import { t } from '../../../../strings/t'; +import { columnLetter } from '../../engine/model'; +import { parseFormula } from '../../engine/formula/parser'; +import { gridOf, type Box } from '../../sheet/model/grid'; +import { isObject, own, type JsonObject } from '../../sheet/model/json'; +import { applyOrder, rangeBox, removeRule, rulesOf, setRule, type Rule } from '../../sheet/model/rules'; +import { readSheet, sheetPatch } from '../../sheet/model/sheet'; +import type { Sheet, TableLike } from '../../sheet/model/types'; + +export type Scope = 'selection' | 'table' | 'page'; + +/** One row of the manager. */ +export interface ManagedRule { + id: string; + summary: string; + ranges: string; + stop: boolean; + /** The table's label, for the page scope. */ + tableLabel?: string; + /** The table's block ID, for the page scope: rule IDs such as `c1` repeat across tables. */ + tableId?: string; + /** True when a formula of the rule does not read, so the rule matches nothing. */ + problem?: boolean; +} + +/** Names one rule for a change: its ID and, on the page scope, the table it belongs to. */ +export interface RuleRef { + ruleId: string; + tableId?: string; +} + +/** The reference a listed rule stands for. */ +export function refOf(rule: ManagedRule): RuleRef { + return rule.tableId === undefined ? { ruleId: rule.id } : { ruleId: rule.id, tableId: rule.tableId }; +} + +const str = (v: unknown): string => (typeof v === 'string' ? v : ''); + +const OPS = [ + 'between', + 'notBetween', + 'equal', + 'notEqual', + 'greater', + 'less', + 'greaterOrEqual', + 'lessOrEqual', + 'contains', + 'notContains', + 'begins', + 'ends', +] as const; +type Op = (typeof OPS)[number]; + +const PERIODS = [ + 'yesterday', + 'today', + 'tomorrow', + 'last7Days', + 'lastWeek', + 'thisWeek', + 'nextWeek', + 'lastMonth', + 'thisMonth', + 'nextMonth', +] as const; +type Period = (typeof PERIODS)[number]; + +function opText(op: string): string { + const known: Op = (OPS as readonly string[]).includes(op) ? (op as Op) : 'equal'; + return t(`sheetConditional.op.${known}`); +} + +function periodText(period: string): string { + const known: Period = (PERIODS as readonly string[]).includes(period) ? (period as Period) : 'today'; + return t(`sheetConditional.period.${known}`); +} + +/** A rule as a sentence, such as `Cell value is greater than 5`. */ +export function describeRule(rule: JsonObject): string { + const type = str(own(rule, 'type')); + const op = str(own(rule, 'op')) || 'between'; + const count = typeof rule.rank === 'number' ? rule.rank : 10; + switch (type) { + case 'cell': + return op === 'between' || op === 'notBetween' + ? t('sheetConditional.type.cellBetween', { op: opText(op), a: str(rule.a), b: str(rule.b) }) + : t('sheetConditional.type.cell', { op: opText(op), a: str(rule.a) }); + case 'text': + return t('sheetConditional.type.text', { op: opText(op), text: str(rule.text) }); + case 'date': + return t('sheetConditional.type.date', { period: periodText(str(rule.period)) }); + case 'blank': + return t('sheetConditional.type.blank'); + case 'noBlank': + return t('sheetConditional.type.noBlank'); + case 'error': + return t('sheetConditional.type.error'); + case 'noError': + return t('sheetConditional.type.noError'); + case 'duplicate': + return t('sheetConditional.type.duplicate'); + case 'unique': + return t('sheetConditional.type.unique'); + case 'scale': + return t('sheetConditional.type.scale'); + case 'bar': + return t('sheetConditional.type.bar'); + case 'top': + if (rule.bottom === true) { + return rule.percent === true + ? t('sheetConditional.type.bottomPercent', { count }) + : t('sheetConditional.type.bottom', { count }); + } + return rule.percent === true + ? t('sheetConditional.type.topPercent', { count }) + : t('sheetConditional.type.top', { count }); + case 'average': + return t(rule.below === true ? 'sheetConditional.type.below' : 'sheetConditional.type.above'); + case 'formula': + return t('sheetConditional.type.formula', { a: str(rule.a) }); + case 'icons': + return t('sheetConditional.type.icons', { set: str(rule.set) }); + default: + return t('sheetConditional.type.unknown'); + } +} + +/** The ranges of a rule in words: "column A", "row 3", "cells A2 to B5". */ +export function describeRanges(rule: JsonObject, table: TableLike): string { + const grid = gridOf(table); + const parts: string[] = []; + for (const range of Array.isArray(rule.ranges) ? rule.ranges : []) { + const box = rangeBox(grid, range); + if (!box || !isObject(range)) continue; + if (typeof range.col === 'string') { + parts.push(t('sheetConditional.manager.wholeColumn', { name: columnLetter(box.left) })); + } else if (typeof range.row === 'string') { + parts.push(t('sheetConditional.manager.wholeRow', { number: box.top + 1 })); + } else { + parts.push( + t('sheetConditional.manager.cells', { + from: `${columnLetter(box.left)}${box.top + 1}`, + to: `${columnLetter(box.right)}${box.bottom + 1}`, + }), + ); + } + } + return parts.join(', '); +} + +/** True when a formula rule's formula does not parse, so the manager can say the rule does nothing. */ +function unreadable(rule: JsonObject): boolean { + if (own(rule, 'type') !== 'formula') return false; + const text = str(own(rule, 'a')).trim().replace(/^=/, ''); + return !parseFormula(text).ok; +} + +function managed(rule: Rule, table: TableLike, tableLabel?: string): ManagedRule { + return { + ...(unreadable(rule.rule) ? { problem: true } : {}), + id: rule.id, + summary: describeRule(rule.rule), + ranges: describeRanges(rule.rule, table), + stop: rule.rule.stop === true, + tableLabel, + }; +} + +function touches(a: Box, b: Box): boolean { + return !(a.bottom < b.top || b.bottom < a.top || a.right < b.left || b.right < a.left); +} + +/** + * The rules of one table in the order they apply, for the cells of a selection or for the whole table. Rules of + * a type this version does not know are not listed here: the model keeps them and does not apply them. + */ +export function managedRules( + sheet: Sheet, + table: TableLike, + scope: Exclude, + selection?: Box, + tableLabel?: string, +): ManagedRule[] { + const byId = new Map(rulesOf(sheet, table, 'conditional').map((r) => [r.id, r])); + return applyOrder(sheet) + .flatMap((id) => { + const rule = byId.get(id); + return rule ? [rule] : []; + }) + .filter((r) => scope === 'table' || !selection || r.boxes.some((b) => touches(b, selection))) + .map((r) => managed(r, table, tableLabel)); +} + +/** The rules of every table on the page, each with the table it belongs to, for the page scope. */ +export function managedPageRules( + tables: readonly { tableId: string; label: string; table: TableLike; sheet: Sheet }[], +): ManagedRule[] { + return tables.flatMap((x) => + managedRules(x.sheet, x.table, 'table', undefined, x.label).map((r) => ({ ...r, tableId: x.tableId })), + ); +} + +export interface Changed { + sheet: Sheet; + /** The patch for the table block's data, or null when nothing changed. */ + patch: JsonObject | null; +} + +function change(sheet: Sheet, edit: (copy: Sheet) => void): Changed { + const copy = readSheet({ sheet }); + edit(copy); + return { sheet: copy, patch: sheetPatch(sheet, copy) }; +} + +function ruleOf(sheet: Sheet, id: string): JsonObject | undefined { + const rules = sheet.conditional; + const rule = isObject(rules) ? own(rules, id) : undefined; + return isObject(rule) ? rule : undefined; +} + +/** Moves a rule up (-1) or down (1) in the priority order and numbers every rule 1 to n, so no two share an order. */ +export function moveRule(sheet: Sheet, id: string, by: -1 | 1): Changed { + return change(sheet, (copy) => { + const ids = applyOrder(copy); + const from = ids.indexOf(id); + const to = from + by; + if (from < 0 || to < 0 || to >= ids.length) return; + ids.splice(to, 0, ...ids.splice(from, 1)); + ids.forEach((rid, i) => { + const rule = ruleOf(copy, rid); + if (rule) setRule(copy, 'conditional', rid, { ...rule, order: i + 1 }); + }); + }); +} + +/** Turns stop if true on or off for a rule. */ +export function setStop(sheet: Sheet, id: string, stop: boolean): Changed { + return change(sheet, (copy) => { + const rule = ruleOf(copy, id); + if (!rule) return; + const next = { ...rule }; + if (stop) next.stop = true; + else delete next.stop; + setRule(copy, 'conditional', id, next); + }); +} + +/** Deletes a rule. */ +export function deleteRule(sheet: Sheet, id: string): Changed { + return change(sheet, (copy) => removeRule(copy, 'conditional', id)); +} diff --git a/app/src/features/tables/ui/conditional/open.test.tsx b/app/src/features/tables/ui/conditional/open.test.tsx new file mode 100644 index 000000000..368a0118f --- /dev/null +++ b/app/src/features/tables/ui/conditional/open.test.tsx @@ -0,0 +1,83 @@ +import { screen, within } from '@testing-library/react'; +import { userEvent } from 'vitest/browser'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { TableExtraHost } from '../../../page'; +import { expectNoAxeViolations } from '../../../../test'; +import { addHost } from '../toolbar/hosts'; +import { changeRule, openRuleManager, rulesFor } from './open'; +import { deleteRule, moveRule } from './manage'; + +const K = (n: number) => `01m3sbcmt0000000000000k${String(n).padStart(3, '0')}`; +const R = (n: number) => `01m3sbcmt0000000000000r${String(n).padStart(3, '0')}`; + +const table = { + header: false, + columns: [{ id: K(1) }, { id: K(2) }], + rows: [1, 2, 3].map((n) => ({ id: R(n), cells: {} })), +}; + +const sheetA = () => ({ + conditional: { + c1: { ranges: [{ col: K(1) }], order: 1, type: 'cell', op: 'greater', a: '5' }, + c2: { ranges: [{ col: K(2) }], order: 2, type: 'blank' }, + }, +}); +const sheetB = () => ({ conditional: { c1: { ranges: [{ col: K(2) }], order: 1, type: 'blank' } } }); + +const removers: (() => void)[] = []; +afterEach(() => removers.splice(0).forEach((remove) => remove())); + +/** A table block's host with no editor selection in a cell, so the manager opens on the whole table. */ +function host(block: string, sheet: Record) { + const patch = vi.fn(async (_data: Record) => true); + const fake = { + block, + page: 'page-1', + element: document.createElement('div'), + editor: { state: { selection: { $from: { depth: 0 } } } }, + data: () => ({ ...table, sheet }), + table: () => table, + patch, + } as unknown as TableExtraHost; + removers.push(addHost(fake)); + return { fake, patch }; +} + +describe('the conditional formatting command', () => { + it('lists the rules of the table, and of every table on the page by its place', () => { + const a = host('block-a', sheetA()); + host('block-b', sheetB()); + expect(rulesFor(a.fake, 'table', null).map((r) => r.id)).toEqual(['c1', 'c2']); + const page = rulesFor(a.fake, 'page', null); + expect(page.map((r) => [r.tableId, r.id, r.tableLabel])).toEqual([ + ['block-a', 'c1', 'Table 1'], + ['block-a', 'c2', 'Table 1'], + ['block-b', 'c1', 'Table 2'], + ]); + }); + + it('writes a change to the table the rule belongs to, as one patch', async () => { + const a = host('block-a', sheetA()); + const b = host('block-b', sheetB()); + expect(await changeRule(a.fake, { ruleId: 'c1', tableId: 'block-b' }, deleteRule)).toBe(true); + expect(a.patch).not.toHaveBeenCalled(); + expect(b.patch).toHaveBeenCalledTimes(1); + expect(b.patch.mock.calls[0][0]).toHaveProperty('sheet'); + // Raising the first rule changes nothing, so nothing is written. + expect(await changeRule(a.fake, { ruleId: 'c1' }, (s, id) => moveRule(s, id, -1))).toBe(false); + expect(a.patch).not.toHaveBeenCalled(); + }); + + it('opens the manager in a dialog that deletes a rule and closes on Done', async () => { + const a = host('block-a', sheetA()); + const closed = openRuleManager(a.fake); + const dialog = await screen.findByRole('dialog', { name: 'Conditional formatting rules' }); + await expectNoAxeViolations(dialog); + expect(within(dialog).getAllByRole('listitem')).toHaveLength(2); + await userEvent.click(within(dialog).getAllByRole('button', { name: 'Delete rule' })[0]); + expect(a.patch).toHaveBeenCalledTimes(1); + await userEvent.click(within(dialog).getByRole('button', { name: 'Done' })); + await closed; + await vi.waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + }); +}); diff --git a/app/src/features/tables/ui/conditional/open.tsx b/app/src/features/tables/ui/conditional/open.tsx new file mode 100644 index 000000000..f5d495923 --- /dev/null +++ b/app/src/features/tables/ui/conditional/open.tsx @@ -0,0 +1,94 @@ +// Opens the conditional formatting rule manager for the current table (the conditional lane's hand-over to the +// cell commands). It lists the rules of the selection, the table, or the page. Each change is one data patch of the +// table the rule belongs to, so Undo takes it back in one step. It loads on first use. +import { useState, useSyncExternalStore } from 'react'; +import { createRoot } from 'react-dom/client'; +import { t } from '../../../../strings/t'; +import { Dialog } from '../../../../ui/Dialog'; +import type { TableExtraHost } from '../../../page'; +import { boxOfSelection } from '../../sheet/formatTarget'; +import { readSheet, type Box } from '../../sheet/model'; +import { hostOfBlock, hostsOfPage, hostsVersion, subscribeHosts } from '../toolbar/hosts'; +import { RuleManager } from './RuleManager'; +import { + deleteRule, + managedPageRules, + managedRules, + moveRule, + setStop, + type Changed, + type ManagedRule, + type RuleRef, + type Scope, +} from './manage'; +import type { Sheet } from '../../sheet/model'; + +const sheetOf = (host: TableExtraHost): Sheet => readSheet(host.data()); + +/** The rules the manager lists for a scope. The page scope names each table by its place on the page. */ +export function rulesFor(host: TableExtraHost, scope: Scope, selection: Box | null): ManagedRule[] { + if (scope !== 'page') return managedRules(sheetOf(host), host.table(), scope, selection ?? undefined); + return managedPageRules( + hostsOfPage(host.page).map((h, i) => ({ + tableId: h.block, + label: t('sheetConditional.manager.tableName', { number: i + 1 }), + table: h.table(), + sheet: sheetOf(h), + })), + ); +} + +/** Applies one change to the table a rule belongs to. Returns false when nothing changed. */ +export async function changeRule( + host: TableExtraHost, + rule: RuleRef, + edit: (sheet: Sheet, id: string) => Changed, +): Promise { + const owner = rule.tableId === undefined ? host : hostOfBlock(rule.tableId); + if (!owner) return false; + const { patch } = edit(sheetOf(owner), rule.ruleId); + if (!patch) return false; + await owner.patch(patch); + return true; +} + +function Manager({ host, selection, close }: { host: TableExtraHost; selection: Box | null; close: () => void }) { + const [scope, setScope] = useState(selection ? 'selection' : 'table'); + // The list follows each write, and undo or another window, through the hosts' version. + useSyncExternalStore(subscribeHosts, hostsVersion); + const rules = rulesFor(host, scope, selection); + const run = (rule: RuleRef, edit: (sheet: Sheet, id: string) => Changed) => void changeRule(host, rule, edit); + return ( + + run(rule, (sheet, id) => moveRule(sheet, id, by))} + onStop={(rule, stop) => run(rule, (sheet, id) => setStop(sheet, id, stop))} + onRemove={(rule) => run(rule, deleteRule)} + onDone={close} + /> + + ); +} + +/** Shows the rule manager for a table. Resolves when it closes. */ +export function openRuleManager(host: TableExtraHost): Promise { + const selection = boxOfSelection(host.editor.state, host.table()); + const mount = document.body.appendChild(document.createElement('div')); + const root = createRoot(mount); + return new Promise((resolve) => { + let done = false; + const close = () => { + if (done) return; + done = true; + queueMicrotask(() => { + root.unmount(); + mount.remove(); + }); + resolve(); + }; + root.render(); + }); +} diff --git a/app/src/features/tables/ui/toolbar/CellFormatBar.test.tsx b/app/src/features/tables/ui/toolbar/CellFormatBar.test.tsx new file mode 100644 index 000000000..1fc5c633e --- /dev/null +++ b/app/src/features/tables/ui/toolbar/CellFormatBar.test.tsx @@ -0,0 +1,281 @@ +// The cell format bar in a real browser: every palette swatch has a name and axe passes with the palette open, the +// bar's buttons move with arrow keys, and at 400 percent zoom (a 320 pixel column) the bar and the toolbar wrap without +// sideways scrolling. +import { screen, within } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { userEvent } from 'vitest/browser'; +import { createBlockEditor } from '../../../../editor/extensions/kit'; +import { DEFAULT_EDITING_VIEW } from '../../../../editor/host'; +import type { EditorHost } from '../../../../editor/host'; +import { createMarkdownCache } from '../../../../editor/markdown'; +import type { TableData } from '../../../../editor/schema/specs'; +import { tableDataToDoc } from '../../../../editor/table/mapping'; +import { installLayerEscape } from '../../../../state/layers'; +import { expectNoAxeViolations, renderUi } from '../../../../test'; +import type { TableExtraHost } from '../../../page'; +// The toolbar's own style module decides when it shows, so the test uses it as the page does. +// eslint-disable-next-line opennote/feature-boundaries +import { TableToolbar } from '../../../page/tables/TableToolbar'; +// eslint-disable-next-line opennote/feature-boundaries +import tableStyles from '../../../page/tables/tables.module.css'; +import { commands } from '../../../../registries'; +import type { CommandContext } from '../../../../commands/types'; +import { BORDER_IDS } from './borders'; +import { CellFormatBar } from './CellFormatBar'; +import { CELL_COMMAND_IDS, registerCellCommands } from './commands'; +import { addHost } from './hosts'; +import { FORMAT_CHOICES } from './numberFormats'; +import { setChoice } from './actions'; +import { currentTable } from '../../../page'; +import type { TableHandle } from '../../../page'; + +const editorHost: EditorHost = { + settings: () => DEFAULT_EDITING_VIEW, + flag: () => true, + announce: () => {}, + openMenu: () => Promise.resolve(null), + screenReader: () => false, + spelling: () => null, + selectBlocks: () => {}, +}; + +const data: TableData = { + header: false, + columns: [0, 1, 2].map((c) => ({ id: `col${c}`, width: 100 })), + rows: [0, 1].map((r) => ({ + id: `row${r}`, + cells: Object.fromEntries([0, 1, 2].map((c) => [`col${c}`, { markdown: `${'ABC'[c]}${r + 1}` }])), + })), +}; + +const cleanup: (() => void)[] = []; +afterEach(() => { + cleanup.splice(0).forEach((stop) => stop()); +}); + +/** A table editor with a caret in A1, registered as the block `block`. */ +function mount(block = 'b1') { + const root = document.createElement('div'); + document.body.append(root); + const editor = createBlockEditor(root, tableDataToDoc(data), { kind: 'table', block, host: editorHost }); + editor.commands.focus('start'); + const host = { + block, + element: root, + editor, + cache: createMarkdownCache(), + data: () => ({ ...data }), + table: () => data, + apply: vi.fn(() => Promise.resolve(true)), + patch: vi.fn(() => Promise.resolve(undefined)), + announce: vi.fn(), + } as unknown as TableExtraHost; + cleanup.push(addHost(host)); + cleanup.push(() => { + editor.destroy(); + root.remove(); + }); + return host; +} + +/** Runs a registered cell command as the palette does (its flag is a feature switch these tests do not set). */ +async function runCommand(id: string): Promise { + const def = commands.get(id); + expect(def?.when?.({} as CommandContext)).toBe(true); + await def!.run({} as CommandContext, undefined as never); +} + +describe('the cell format bar', () => { + it('names every swatch of the fill palette and passes axe in both themes', async () => { + for (const theme of ['light', 'dark'] as const) { + mount(); + const { container, unmount } = renderUi(, { theme }); + await userEvent.click(screen.getByRole('button', { name: 'Fill color' })); + const palette = await screen.findByRole('dialog', { name: 'Fill colors' }); + expect(screen.getByRole('button', { name: 'Amber, lighter 40%' })).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Indigo, darker 25%' })).toBeTruthy(); + for (const swatch of palette.querySelectorAll('button')) { + expect((swatch.getAttribute('aria-label') ?? swatch.textContent ?? '').trim()).not.toBe(''); + } + await expectNoAxeViolations(palette); + expect(container).toBeTruthy(); + unmount(); + cleanup.splice(0).forEach((stop) => stop()); + } + }); + + it('has no axe violations as a bar, and arrow keys move between its buttons', async () => { + mount(); + const { container } = renderUi(); + await expectNoAxeViolations(container); + const bar = screen.getByRole('toolbar', { name: 'Cell format' }); + const boldButton = screen.getByRole('button', { name: 'Bold' }); + boldButton.focus(); + await userEvent.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Italic' })); + expect(bar.contains(document.activeElement)).toBe(true); + }); +}); + +describe('the cell format bar, layout', () => { + it('wraps without sideways scrolling at 400 percent zoom', async () => { + mount(); + renderUi( +
+ true} + header={false} + run={vi.fn()} + more={vi.fn()} + width={100} + widthOpen={false} + openWidth={vi.fn()} + closeWidth={vi.fn()} + /> +
, + ); + const bar = await screen.findByRole('toolbar', { name: 'Cell format' }); + const column = screen.getByTestId('column'); + expect(bar.scrollWidth).toBeLessThanOrEqual(bar.clientWidth); + expect(column.scrollWidth).toBeLessThanOrEqual(column.clientWidth); + expect(bar.getBoundingClientRect().right).toBeLessThanOrEqual(column.getBoundingClientRect().right + 1); + }); +}); + +describe('the cell format bar, keyboard', () => { + it('reaches Bold and Fill color from the keyboard alone', async () => { + mount(); + renderUi(); + const bar = screen.getByRole('toolbar', { name: 'Cell format' }); + // The bar has one tab stop among its buttons. + const stops = [...bar.querySelectorAll('[data-tool]')].filter((button) => button.tabIndex === 0); + expect(stops).toHaveLength(1); + // From the end of the Size box, Right Arrow goes into the buttons. + const size = screen.getByRole('combobox', { name: 'Font size' }) as HTMLInputElement; + // Tab from the Size box lands on the bar's tab stop (the next tabbable element in the page's order). + const tabbable = [...bar.querySelectorAll('input, button')].filter((one) => one.tabIndex >= 0); + expect(tabbable[tabbable.indexOf(size) + 1]).toBe(stops[0]); + // The editor's own focus call from mount() lands in the next frame, so let it pass first. + await new Promise((done) => setTimeout(done, 100)); + size.focus(); + size.setSelectionRange(size.value.length, size.value.length); + await userEvent.keyboard('{ArrowRight}'); + expect(document.activeElement, 'after Right Arrow').toBe(stops[0]); + for (const name of ['Bold', 'Text color', 'Fill color']) { + for (let i = 0; i < 40 && document.activeElement !== screen.getByRole('button', { name }); i++) { + await userEvent.keyboard('{ArrowRight}'); + } + expect(document.activeElement).toBe(screen.getByRole('button', { name })); + } + // The button with focus is the one tab stop, and Shift+Tab then Tab comes back to it. + expect(screen.getByRole('button', { name: 'Fill color' }).tabIndex).toBe(0); + }); +}); + +describe('the cell format bar, palette focus', () => { + it('returns focus to Fill color when Escape closes the palette opened by keyboard', async () => { + const stopEscape = installLayerEscape(); + mount(); + renderUi(); + await new Promise((done) => setTimeout(done, 100)); + const button = screen.getByRole('button', { name: 'Fill color' }); + button.focus(); + await userEvent.keyboard('{Enter}'); + await screen.findByRole('dialog', { name: 'Fill colors' }); + await userEvent.keyboard('{Tab}'); + expect(document.activeElement).not.toBe(document.body); + await userEvent.keyboard('{Escape}'); + await vi.waitFor(() => expect(document.activeElement).toBe(button)); + stopEscape(); + }); +}); + +describe('the cell format bar, palettes and commands', () => { + it('opens the palette and writes to the current table, not the first one on the page', async () => { + const first = mount('b1'); + const second = mount('b2'); + registerCellCommands(); + renderUi( + <> +
+ +
+
+ +
+ , + ); + currentTable.set({ block: 'b2' } as TableHandle); + try { + await runCommand('table.cell.fill'); + const fill = (id: string) => within(screen.getByTestId(id)).getByRole('button', { name: 'Fill color' }); + await vi.waitFor(() => expect(fill('two').getAttribute('aria-expanded')).toBe('true')); + expect(fill('one').getAttribute('aria-expanded')).toBe('false'); + await userEvent.keyboard('{Escape}'); + await runCommand('table.cell.borders'); + expect(first.apply).not.toHaveBeenCalled(); + expect(first.patch).not.toHaveBeenCalled(); + expect( + (second.apply as ReturnType).mock.calls.length + + (second.patch as ReturnType).mock.calls.length, + ).toBeGreaterThan(0); + } finally { + currentTable.set(null); + } + }); + + it('has a command for every border preset and every number format of the menu', () => { + for (const id of BORDER_IDS) + expect(CELL_COMMAND_IDS).toContain(`table.cell.border${id[0].toUpperCase()}${id.slice(1)}`); + for (const choice of FORMAT_CHOICES) { + if (['general', 'currency', 'percent'].includes(choice.id)) continue; + expect(CELL_COMMAND_IDS).toContain(`table.cell.format${choice.id[0].toUpperCase()}${choice.id.slice(1)}`); + } + for (const id of [ + 'alignJustify', + 'rotateUp', + 'rotateStacked', + 'wrapOverflow', + 'wrapClip', + 'lineStyleDashed', + 'borderColor', + ]) + expect(CELL_COMMAND_IDS).toContain(`table.cell.${id}`); + }); +}); + +describe('the cell format bar, names, menus, and width', () => { + it('announces a number format by its name, never its code', async () => { + const host = mount(); + await setChoice(host, 'accounting'); + await setChoice(host, 'percent'); + const said = (host.announce as ReturnType).mock.calls.map((call) => call[0]); + expect(said).toEqual(['Cell format Accounting.', 'Cell format Percent.']); + }); + + it('has the underline kinds menu and the font size buttons', () => { + mount(); + renderUi(); + for (const name of ['Underline style', 'Increase font size', 'Decrease font size']) + expect(screen.getByRole('button', { name })).toBeTruthy(); + }); + + it('collapses into More at 360 pixels, also when the bar shows after the first render', async () => { + // The bar renders nothing until its table has a host, then shows: the width watch has to follow the element. + renderUi( +
+ +
, + ); + expect(screen.queryByRole('toolbar', { name: 'Cell format' })).toBeNull(); + mount(); + await screen.findByRole('toolbar', { name: 'Cell format' }); + expect(await screen.findByRole('button', { name: 'More cell formats' })).toBeTruthy(); + }); +}); diff --git a/app/src/features/tables/ui/toolbar/CellFormatBar.tsx b/app/src/features/tables/ui/toolbar/CellFormatBar.tsx new file mode 100644 index 000000000..a08a83b22 --- /dev/null +++ b/app/src/features/tables/ui/toolbar/CellFormatBar.tsx @@ -0,0 +1,672 @@ +// The cell format bar (DESIGN 10.1): a second row of the table's toolbar with the formats of a cell. Shared formats +// (Bold, Italic, Underline, Strikethrough, Text color) run the shared commands and show the Home tab's glyphs. +// The rest are table-only and write the cell style (actions.ts). In a narrow bar the least used buttons move into +// a More menu. A press never takes focus from the cell, so the caret stays where it was. +import { AlignBottomIcon } from '@phosphor-icons/react/dist/csr/AlignBottom'; +import { AlignCenterVerticalIcon } from '@phosphor-icons/react/dist/csr/AlignCenterVertical'; +import { AlignTopIcon } from '@phosphor-icons/react/dist/csr/AlignTop'; +import { ArrowClockwiseIcon } from '@phosphor-icons/react/dist/csr/ArrowClockwise'; +import { CaretDownIcon } from '@phosphor-icons/react/dist/csr/CaretDown'; +import { CurrencyDollarIcon } from '@phosphor-icons/react/dist/csr/CurrencyDollar'; +import { DotsThreeIcon } from '@phosphor-icons/react/dist/csr/DotsThree'; +import { EraserIcon } from '@phosphor-icons/react/dist/csr/Eraser'; +import { GridFourIcon } from '@phosphor-icons/react/dist/csr/GridFour'; +import { HashIcon } from '@phosphor-icons/react/dist/csr/Hash'; +import { MinusIcon } from '@phosphor-icons/react/dist/csr/Minus'; +import { PaintBucketIcon } from '@phosphor-icons/react/dist/csr/PaintBucket'; +import { PercentIcon } from '@phosphor-icons/react/dist/csr/Percent'; +import { PlusIcon } from '@phosphor-icons/react/dist/csr/Plus'; +import { TextAaIcon } from '@phosphor-icons/react/dist/csr/TextAa'; +import { TextAlignCenterIcon } from '@phosphor-icons/react/dist/csr/TextAlignCenter'; +import { TextAlignJustifyIcon } from '@phosphor-icons/react/dist/csr/TextAlignJustify'; +import { TextAlignLeftIcon } from '@phosphor-icons/react/dist/csr/TextAlignLeft'; +import { TextAlignRightIcon } from '@phosphor-icons/react/dist/csr/TextAlignRight'; +import { useCallback, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react'; +import type { ComponentType, RefObject } from 'react'; +import { commandOn, runEditorCommand } from '../../../../editor/commands/catalog'; +import { BODY_POINTS } from '../../../../editor/commands/sizes'; +import { sessionStore } from '../../../../state/session'; +import { t } from '../../../../strings/t'; +import type { MessageKey } from '../../../../strings/t'; +import { IconButton, Popover, openMenu } from '../../../../ui'; +import type { IconProps, MenuItemSpec } from '../../../../ui'; +import { ColorPalette } from '../../../../ui/color'; +import type { ColorPick, Scheme } from '../../../../ui/color'; +import { iconFor } from '../../../../ui/commandIcons'; +import { FontPicker, SizeBox, useFontCatalog } from '../../../fonts'; +import type { TableExtraHost } from '../../../page'; +import styles from './toolbar.module.css'; +import { currentScheme } from '../../sheet/render/plugin'; +import { appLocale } from '../../smart/locale'; +import { + borderLine, + borderUse, + cellsOf, + changeDecimals, + changeThousands, + clearFormats, + setAlignH, + setAlignV, + setBorderLine, + setBorders, + setCells, + setChoice, + setCurrency, + setFill, + setPercent, + setRotate, + setTextColor, + setUnderline, + setWrap, +} from './actions'; +import type { Cells, UnderlineKind } from './actions'; +import { BORDER_IDS, BORDER_LABELS } from './borders'; +import { addOpener, hostOfBlock, hostsVersion, subscribeHosts } from './hosts'; +import { FORMAT_CHOICES, choiceOf } from './numberFormats'; +import { againstFor, paperOf } from './palette'; +import { moveFocus, rove } from './roving'; + +/** The bar is this narrow (in pixels) or less when the least used buttons move into the More menu. */ +const NARROW = 620; + +type Icon = ComponentType; + +const LINE_STYLES: readonly (readonly [string, MessageKey])[] = [ + ['thin', 'sheetToolbar.styleThin'], + ['medium', 'sheetToolbar.styleMedium'], + ['thick', 'sheetToolbar.styleThick'], + ['hair', 'sheetToolbar.styleHair'], + ['dotted', 'sheetToolbar.styleDotted'], + ['dashed', 'sheetToolbar.styleDashed'], + ['double', 'sheetToolbar.styleDouble'], +]; + +const ROTATIONS: readonly (readonly [number | 'stacked', MessageKey])[] = [ + [0, 'sheetToolbar.rotateNone'], + [45, 'sheetToolbar.rotateUp'], + [-45, 'sheetToolbar.rotateDown'], + [90, 'sheetToolbar.rotateVertical'], + ['stacked', 'sheetToolbar.rotateStacked'], +]; + +const WRAPS: readonly (readonly ['wrap' | 'overflow' | 'clip', MessageKey])[] = [ + ['wrap', 'sheetToolbar.wrapWrap'], + ['overflow', 'sheetToolbar.wrapOverflow'], + ['clip', 'sheetToolbar.wrapClip'], +]; + +const UNDERLINES: readonly (readonly [UnderlineKind, MessageKey])[] = [ + ['none', 'sheetToolbar.underlineNone'], + ['single', 'sheetToolbar.underlineSingle'], + ['double', 'sheetToolbar.underlineDouble'], + ['singleAccounting', 'sheetToolbar.underlineSingleAccounting'], + ['doubleAccounting', 'sheetToolbar.underlineDoubleAccounting'], +]; + +/** The notebook the page is in, whose recent colors the palette keeps. */ +function notebookOf(): string { + const { location } = sessionStore.get(); + return 'notebookId' in location && location.notebookId ? location.notebookId : 'notebook'; +} + +const noFonts = { list: () => Promise.resolve([]) }; + +type Run = (action: unknown) => void; + +function useHost(block: string): TableExtraHost | undefined { + useSyncExternalStore(subscribeHosts, hostsVersion); + return hostOfBlock(block); +} + +interface ToolProps { + tool: string; + name: MessageKey; + icon: Icon | undefined; + /** The element the tool's menu or palette anchors to. */ + onPress(anchor: HTMLElement): void; + pressed?: boolean; + popup?: 'menu' | 'dialog'; + expanded?: boolean; +} + +/** A bar button. Its wrapper carries the tool name, which the palette commands press, and anchors its menu. */ +function Tool({ tool, name, icon, onPress, pressed, popup, expanded }: ToolProps) { + const slot = useRef(null); + return ( + + slot.current && onPress(slot.current)} + pressed={pressed} + hasPopup={popup} + expanded={expanded} + /> + + ); +} + +/** A wrapper ref plus an anchor ref to a button inside it, so a popover anchors to (and returns focus to) a control that can take focus. */ +function useButtonAnchor(last = false) { + const wrap = useRef(null); + const [anchor] = useState( + () => + ({ + get current() { + const all = wrap.current?.querySelectorAll('button'); + return (last ? all?.[all.length - 1] : all?.[0]) ?? wrap.current; + }, + }) as RefObject, + ); + return { wrap, anchor }; +} + +interface PaletteProps { + name: MessageKey; + current?: string; + fill?: boolean; + onPick(choice: ColorPick): void; + /** The element the popover opens under. */ + anchor: RefObject; + open: boolean; + onClose(): void; + /** The color the contrast badge compares with. */ + against?: string; + /** The page's paper, so the Paper column follows the page and not the app's surface. */ + paper?: string; +} + +function PalettePopover({ name, current, fill, onPick, anchor, open, onClose, against, paper }: PaletteProps) { + return ( + + + + ); +} + +interface ColorToolProps { + block: string; + against: string; + paper?: string; + tool: 'fill' | 'textColor'; + name: MessageKey; + paletteName: MessageKey; + icon: Icon | undefined; + current?: string; + onPick(choice: ColorPick): void; +} + +/** A button that opens a color palette in a popover. */ +function ColorTool({ block, against, paper, tool, name, paletteName, icon, current, onPick }: ColorToolProps) { + const { wrap, anchor } = useButtonAnchor(); + const [open, setOpen] = useState(false); + // The palette command opens this table's own palette (hosts.ts). + useEffect(() => addOpener(block, tool, () => setOpen(true)), [block, tool]); + return ( + + setOpen(!open)} /> + setOpen(false)} + onPick={(choice) => { + setOpen(false); + onPick(choice); + }} + /> + + ); +} + +/** The split Borders button: the last preset, and the menu of all of them with line style and color. */ +function BordersTool({ host, run, paper }: { host: TableExtraHost; run: Run; paper?: string }) { + const { wrap, anchor } = useButtonAnchor(true); + const [color, setColor] = useState(false); + useEffect(() => addOpener(host.block, 'borderColor', () => setColor(true)), [host.block]); + const menu = (anchor: HTMLElement) => { + const items: MenuItemSpec[] = BORDER_IDS.map((id) => ({ + id, + label: t(BORDER_LABELS[id]), + onSelect: () => run(setBorders(host, id)), + })); + items.push( + { + id: 'style', + label: t('sheetToolbar.borderStyle'), + separatorBefore: true, + submenu: LINE_STYLES.map(([value, label]) => ({ + id: `style.${value}`, + label: t(label), + kind: 'radio' as const, + checked: borderLine.style === value, + onSelect: () => setBorderLine({ style: value }), + })), + }, + { id: 'color', label: t('sheetToolbar.borderColor'), onSelect: () => setColor(true) }, + ); + void openMenu({ label: t('sheetToolbar.borders'), items, anchor, returnFocus: host.editor.view.dom }); + }; + return ( + + run(setBorders(host, borderUse.last))} + /> + + setColor(false)} + onPick={(choice) => { + setColor(false); + setBorderLine({ color: choice.kind === 'color' ? choice.value : null }); + run(setBorders(host, borderUse.last)); + }} + /> + + ); +} + +/** What every group of the bar reads: the table's host, the cells selected, and how to run an action. */ +interface GroupProps { + host: TableExtraHost; + cells: Cells; + run: Run; + narrow: boolean; + /** The page's paper, for the palettes. */ + paper?: string; +} + +function menuAt(host: TableExtraHost, label: MessageKey, items: MenuItemSpec[], anchor: HTMLElement): void { + void openMenu({ label: t(label), items, anchor, returnFocus: host.editor.view.dom }); +} + +/** Radio items for a list of values; the item that applies a value runs `apply`. */ +function radios( + list: readonly (readonly [V, MessageKey])[], + id: string, + checked: (value: V) => boolean, + apply: (value: V) => void, +): MenuItemSpec[] { + return list.map(([value, label]) => ({ + id: `${id}.${value}`, + label: t(label), + kind: 'radio' as const, + checked: checked(value), + onSelect: () => apply(value), + })); +} + +function FontGroup({ host, cells, run }: GroupProps) { + const catalog = useFontCatalog(noFonts); + const { style, whole } = cells; + const textLevel = !whole && !host.editor.state.selection.empty; + return ( + + + run( + textLevel + ? runEditorCommand(host.editor, 'format.font', { value: name }) + : setCells(host, { font: { family: name } }, ['font']), + ) + } + /> + + run( + textLevel + ? runEditorCommand(host.editor, 'format.fontSize', { value: points }) + : setCells(host, { font: { size: points } }, ['textSize']), + ) + } + /> + run(runEditorCommand(host.editor, 'format.fontSizeUp', {}, { focus: false }))} + /> + run(runEditorCommand(host.editor, 'format.fontSizeDown', {}, { focus: false }))} + /> + + ); +} + +const pickValue = (choice: ColorPick): string | null => (choice.kind === 'color' ? choice.value : null); + +function StyleGroup({ host, cells, run, paper }: GroupProps) { + const { style, whole } = cells; + const scheme: Scheme = currentScheme(); + const { font } = style; + const state = host.editor.state; + const shared = (tool: string, command: string, name: MessageKey, cell: boolean | undefined) => ( + run(runEditorCommand(host.editor, command, {}, { focus: false }))} + /> + ); + return ( + + {shared('bold', 'format.bold', 'sheetToolbar.bold', font?.bold)} + {shared('italic', 'format.italic', 'sheetToolbar.italic', font?.italic)} + {shared( + 'underline', + 'format.underline', + 'sheetToolbar.underline', + !!font?.underline && font.underline !== 'none', + )} + + menuAt( + host, + 'sheetToolbar.underlineMenu', + // Selected text has no accounting kinds. + UNDERLINES.filter( + ([kind]) => whole || host.editor.state.selection.empty || !kind.endsWith('Accounting'), + ).map(([kind, label]) => ({ + id: kind, + label: t(label), + kind: 'radio' as const, + checked: (font?.underline ?? 'none') === kind, + onSelect: () => run(setUnderline(host, kind)), + })), + anchor, + ) + } + /> + {shared('strike', 'format.strike', 'sheetToolbar.strike', !!font?.strike && font.strike !== 'none')} + run(setTextColor(host, pickValue(choice)))} + /> + run(setFill(host, pickValue(choice)))} + /> + + ); +} + +function AlignGroup({ host, cells, run, narrow }: GroupProps) { + const { align } = cells.style; + const h = (value: 'left' | 'center' | 'right' | 'justify', name: MessageKey, icon: Icon) => ( + run(setAlignH(host, value))} + /> + ); + const v = (value: 'top' | 'middle' | 'bottom', name: MessageKey, icon: Icon) => ( + run(setAlignV(host, value))} + /> + ); + const wrapMenu = (anchor: HTMLElement) => + menuAt( + host, + 'sheetToolbar.wrapMenu', + radios( + WRAPS, + 'wrap', + (value) => (align?.wrap ?? 'wrap') === value, + (value) => run(setWrap(host, value)), + ), + anchor, + ); + const rotateMenu = (anchor: HTMLElement) => + menuAt( + host, + 'sheetToolbar.rotate', + radios( + ROTATIONS, + 'rotate', + (value) => (align?.rotate ?? 0) === value, + (value) => run(setRotate(host, value)), + ), + anchor, + ); + return ( + + {h('left', 'sheetToolbar.alignLeft', TextAlignLeftIcon)} + {h('center', 'sheetToolbar.alignCenter', TextAlignCenterIcon)} + {h('right', 'sheetToolbar.alignRight', TextAlignRightIcon)} + {h('justify', 'sheetToolbar.alignJustify', TextAlignJustifyIcon)} + {narrow ? null : ( + <> + {v('top', 'sheetToolbar.alignTop', AlignTopIcon)} + {v('middle', 'sheetToolbar.alignMiddle', AlignCenterVerticalIcon)} + {v('bottom', 'sheetToolbar.alignBottom', AlignBottomIcon)} + + + + )} + + ); +} + +function NumberGroup({ host, cells, run, narrow }: GroupProps) { + const formatMenu = (anchor: HTMLElement) => + menuAt( + host, + 'sheetToolbar.numberFormat', + FORMAT_CHOICES.map((choice) => ({ + id: choice.id, + label: t(choice.label), + kind: 'radio' as const, + checked: choice.id === choiceOf(cells.style.fmt, appLocale()), + onSelect: () => run(setChoice(host, choice.id)), + })), + anchor, + ); + return ( + + + run(setCurrency(host))} + /> + run(setPercent(host))} /> + {narrow ? null : ( + <> + run(changeThousands(host))} + /> + run(changeDecimals(host, -1))} + /> + run(changeDecimals(host, 1))} + /> + + )} + + ); +} + +/** What the narrow bar moves into one menu: the vertical alignments, wrapping, rotation, and the digit buttons. */ +function moreItems({ host, cells, run }: GroupProps, anchor: HTMLElement): MenuItemSpec[] { + const { align } = cells.style; + const wrap = radios( + WRAPS, + 'wrap', + (value) => (align?.wrap ?? 'wrap') === value, + (value) => run(setWrap(host, value)), + ); + const rotate = radios( + ROTATIONS, + 'rotate', + (value) => (align?.rotate ?? 0) === value, + (value) => run(setRotate(host, value)), + ); + const vertical = (['top', 'middle', 'bottom'] as const).map((value) => ({ + id: value, + label: t(`sheetToolbar.align${value[0].toUpperCase()}${value.slice(1)}` as MessageKey), + onSelect: () => run(setAlignV(host, value)), + })); + const digits: MenuItemSpec[] = [ + { + id: 'thousands', + label: t('sheetToolbar.thousands'), + separatorBefore: true, + onSelect: () => run(changeThousands(host)), + }, + { id: 'fewer', label: t('sheetToolbar.fewerDecimals'), onSelect: () => run(changeDecimals(host, -1)) }, + { id: 'more', label: t('sheetToolbar.moreDecimals'), onSelect: () => run(changeDecimals(host, 1)) }, + ]; + const sub = (id: string, label: MessageKey, submenu: MenuItemSpec[]): MenuItemSpec => ({ + id, + label: t(label), + submenu, + onSelect: () => menuAt(host, label, submenu, anchor), + }); + return [ + ...vertical, + sub('wrap', 'sheetToolbar.wrapMenu', wrap), + sub('rotate', 'sheetToolbar.rotate', rotate), + ...digits, + ]; +} + +function ClearGroup(props: GroupProps) { + const { host, run, narrow } = props; + return ( + + run(clearFormats(host))} /> + {narrow ? ( + menuAt(host, 'sheetToolbar.more', moreItems(props, anchor), anchor)} + /> + ) : null} + + ); +} + +/** Whether the bar is narrow: it watches its own width. It follows the element, so a bar that shows later or again is watched. */ +function useNarrow(element: HTMLElement | null): boolean { + const [narrow, setNarrow] = useState(false); + useEffect(() => { + if (!element || typeof ResizeObserver === 'undefined') return; + const watch = new ResizeObserver(() => setNarrow(element.clientWidth <= NARROW)); + watch.observe(element); + return () => watch.disconnect(); + }, [element]); + return narrow; +} + +export interface CellFormatBarProps { + block: string; +} + +export function CellFormatBar({ block }: CellFormatBarProps) { + const host = useHost(block); + // The element is state, not a ref, so the width watch starts when the bar first shows and again after it remounts. + const [bar, setBar] = useState(null); + const narrow = useNarrow(bar); + const cells = host ? cellsOf(host) : null; + const attach = useCallback((element: HTMLDivElement | null) => setBar(element), []); + // Every button is a toolbar item, and the bar has one tab stop (roving.ts). + useLayoutEffect(() => { + bar?.querySelectorAll('[data-cell-tool] button').forEach((one) => one.setAttribute('data-tool', '')); + if (bar) rove(bar); + }); + if (!host || !cells) return null; + const run: Run = (action) => void Promise.resolve(action).then(() => host.editor.commands.focus()); + const group = { host, cells, run, narrow, paper: paperOf(host.element) }; + return ( +
{ + // Whichever button has focus is the tab stop, so Shift+Tab and Tab return to it. + if (event.target instanceof HTMLElement && event.target.hasAttribute('data-tool')) + rove(event.currentTarget, event.target); + }} + onMouseDown={(event) => { + // A press on a button keeps the caret in its cell. + if ((event.target as HTMLElement).closest('button')) event.preventDefault(); + }} + > + + + + + + + + +
+ ); +} diff --git a/app/src/features/tables/ui/toolbar/actions.ts b/app/src/features/tables/ui/toolbar/actions.ts new file mode 100644 index 000000000..5c7da12e3 --- /dev/null +++ b/app/src/features/tables/ui/toolbar/actions.ts @@ -0,0 +1,151 @@ +// What the cell format bar's buttons, the palette commands, and the context menu entries do. Every action reads the +// selection of the table block's own editor: whole cells, or the cell holding the caret. A change to a whole cell is +// one data patch (sheet/formatTarget.ts), so Undo takes it back in one step. Bold, Italic, Underline, Strikethrough, +// and Text color run the shared commands: they write the cell style when whole cells are selected, and a mark when +// text is, which is the format target seam's job. +import { CellSelection } from '@tiptap/pm/tables'; +import { runEditorCommand } from '../../../../editor/commands/catalog'; +import { t } from '../../../../strings/t'; +import type { TableExtraHost } from '../../../page'; +import { appLocale } from '../../smart/locale'; +import { boxOfSelection, planFormat, styleAt, writeFormat } from '../../sheet/formatTarget'; +import type { CellBox } from '../../sheet/formatTarget'; +import { readSheet } from '../../sheet/model'; +import type { CellStyle, StyleChange } from '../../sheet/model'; +import { BORDER_LABELS, bordersFor } from './borders'; +import type { BorderId, Line } from './borders'; +import { FORMAT_CHOICES, choiceOf, stepDecimals, toggleThousands, currencyCode } from './numberFormats'; + +/** The line style and color the borders menu draws with. It stays for the session, as Excel's does. */ +export const borderLine: Line = { style: 'thin' }; + +/** Changes the line the borders menu draws with. A color of null is Automatic. */ +export function setBorderLine(change: { style?: string; color?: string | null }): void { + if (change.style !== undefined) borderLine.style = change.style; + if (change.color === null) delete borderLine.color; + else if (change.color !== undefined) borderLine.color = change.color; +} + +/** The last border preset used, which the Borders button and the Borders command apply again. */ +export const borderUse: { last: BorderId } = { last: 'all' }; + +export interface Cells { + box: CellBox; + /** The style of the first cell, as the table draws it. */ + style: CellStyle; + /** True when whole cells are selected, so the shared commands write cell styles. */ + whole: boolean; +} + +/** The cells the selection is on, or null when the selection is not in the table's cells. */ +export function cellsOf(host: TableExtraHost): Cells | null { + const state = host.editor.state; + const table = host.table(); + const box = boxOfSelection(state, table); + if (!box) return null; + const style = styleAt(readSheet(host.data()), table, box.top, box.left); + return { box, style, whole: state.selection instanceof CellSelection }; +} + +/** Applies a change to the cells and removes the marks `strip` from their text. */ +export async function setCells( + host: TableExtraHost, + change: StyleChange, + strip: readonly string[] = [], +): Promise { + const cells = cellsOf(host); + if (!cells) return false; + return writeFormat(host, [{ box: cells.box, change }], strip, cells.box); +} + +/** Fill color: a stored color, or null for No color (written as `none`, so a look's fill does not show through). */ +export async function setFill(host: TableExtraHost, color: string | null): Promise { + const done = await setCells(host, { fill: color ?? 'none' }); + if (done) + host.announce(color === null ? t('sheetToolbar.announce.fillNone') : t('sheetToolbar.announce.fill', { color })); + return done; +} + +/** Text color through the shared command: the cell's font color for whole cells, a mark for selected text. */ +export function setTextColor(host: TableExtraHost, color: string | null): boolean { + return runEditorCommand(host.editor, 'format.textColor', { color: color as string }); +} + +export async function setBorders(host: TableExtraHost, id: BorderId): Promise { + const cells = cellsOf(host); + if (!cells) return false; + borderUse.last = id; + const done = await writeFormat(host, bordersFor(id, cells.box, borderLine), [], cells.box); + if (done) host.announce(t('sheetToolbar.announce.borders', { name: t(BORDER_LABELS[id]) })); + return done; +} + +export const setAlignH = (host: TableExtraHost, h: 'left' | 'center' | 'right' | 'justify') => + setCells(host, { align: { h } }); + +export const setAlignV = (host: TableExtraHost, v: 'top' | 'middle' | 'bottom') => setCells(host, { align: { v } }); + +export const setWrap = (host: TableExtraHost, wrap: 'wrap' | 'overflow' | 'clip') => + setCells(host, { align: { wrap } }); + +export type UnderlineKind = 'none' | 'single' | 'double' | 'singleAccounting' | 'doubleAccounting'; + +/** An underline kind: the cell's for whole cells, the text's for selected text (which has no accounting kinds). */ +export async function setUnderline(host: TableExtraHost, kind: UnderlineKind): Promise { + const cells = cellsOf(host); + if (!cells) return false; + if (!cells.whole && !host.editor.state.selection.empty) { + if (kind.endsWith('Accounting')) return false; + return runEditorCommand(host.editor, 'format.underlineStyle', { value: kind === 'none' ? null : kind }); + } + return setCells(host, { font: { underline: kind } }, ['underline']); +} + +/** Text rotation: degrees from -90 to 90, or stacked letters. 0 takes the rotation off. */ +export const setRotate = (host: TableExtraHost, rotate: number | 'stacked') => + setCells(host, { align: { rotate: rotate === 0 ? null : rotate } }); + +/** What a screen reader hears for a code: the menu choice's name, or Custom. Never the raw code. */ +function formatName(code: string | null): string { + const id = choiceOf(code ?? undefined, appLocale()); + const choice = FORMAT_CHOICES.find((one) => one.id === id); + return t(choice ? choice.label : 'sheetToolbar.fmtCustom'); +} + +/** A number format code, or null for General. */ +export async function setFormat(host: TableExtraHost, code: string | null): Promise { + const done = await setCells(host, { fmt: code }); + if (done) host.announce(t('sheetToolbar.announce.format', { format: formatName(code) })); + return done; +} + +export const setChoice = (host: TableExtraHost, id: string) => { + const choice = FORMAT_CHOICES.find((one) => one.id === id); + return choice ? setFormat(host, choice.code(appLocale())) : Promise.resolve(false); +}; + +export const setCurrency = (host: TableExtraHost) => setFormat(host, currencyCode(appLocale())); +export const setPercent = (host: TableExtraHost) => setFormat(host, '0%'); + +export function changeDecimals(host: TableExtraHost, direction: 1 | -1): Promise { + const cells = cellsOf(host); + if (!cells) return Promise.resolve(false); + const next = stepDecimals(cells.style.fmt, direction); + return setFormat(host, next); +} + +export function changeThousands(host: TableExtraHost): Promise { + const cells = cellsOf(host); + return cells ? setFormat(host, toggleThousands(cells.style.fmt)) : Promise.resolve(false); +} + +/** Clear formats: every cell format of the cells, and the marks in their text. */ +export async function clearFormats(host: TableExtraHost): Promise { + const cells = cellsOf(host); + if (!cells) return false; + const plan = planFormat({ format: 'clear' }, cells.box, cells.style); + if (!plan) return false; + const done = await writeFormat(host, plan.steps, plan.strip, cells.box); + if (done) host.announce(t('sheetToolbar.announce.cleared')); + return done; +} diff --git a/app/src/features/tables/ui/toolbar/borders.ts b/app/src/features/tables/ui/toolbar/borders.ts new file mode 100644 index 000000000..dc3145592 --- /dev/null +++ b/app/src/features/tables/ui/toolbar/borders.ts @@ -0,0 +1,154 @@ +// The borders menu (DESIGN 10.5): sixteen presets, each a list of steps. A step names a part of the selected box (its +// outer edges, its inner lines, or all of it) and the sides of those cells to set. A border belongs to the cell +// it is a side of (R-SHT-7). So Outside borders writes only the outer sides of the edge cells, and nothing inside. +// An inside line writes both cells that share it, so either neighbor shows the same line. +import type { MessageKey } from '../../../../strings/t'; +import type { CellBox, Step } from '../../sheet/formatTarget'; +import type { BorderSide, StyleChange } from '../../sheet/model'; + +export type BorderId = + | 'all' + | 'outside' + | 'thickOutside' + | 'top' + | 'bottom' + | 'left' + | 'right' + | 'insideH' + | 'insideV' + | 'none' + | 'bottomDouble' + | 'bottomThick' + | 'topBottom' + | 'topThickBottom' + | 'diagonalUp' + | 'diagonalDown'; + +export type Side = 't' | 'r' | 'b' | 'l' | 'du' | 'dd'; + +/** The line a preset draws: the menu's current style and color, with a preset's own override for thick and double. */ +export interface Line { + style: string; + color?: string; +} + +type Part = 'whole' | 'top' | 'bottom' | 'left' | 'right' | 'insideTop' | 'insideBottom' | 'insideLeft' | 'insideRight'; +type Draw = 'line' | 'thick' | 'double' | 'clear'; + +interface Rule { + part: Part; + side: Side; + draw: Draw; +} + +const OUTSIDE: readonly Rule[] = [ + { part: 'top', side: 't', draw: 'line' }, + { part: 'bottom', side: 'b', draw: 'line' }, + { part: 'left', side: 'l', draw: 'line' }, + { part: 'right', side: 'r', draw: 'line' }, +]; + +const outside = (draw: Draw): Rule[] => OUTSIDE.map((rule) => ({ ...rule, draw })); + +/** What each preset sets. Order matters only for presets that set the same side twice. */ +export const PRESETS: Readonly> = { + all: [ + { part: 'whole', side: 't', draw: 'line' }, + { part: 'whole', side: 'r', draw: 'line' }, + { part: 'whole', side: 'b', draw: 'line' }, + { part: 'whole', side: 'l', draw: 'line' }, + ], + outside: outside('line'), + thickOutside: outside('thick'), + top: [{ part: 'top', side: 't', draw: 'line' }], + bottom: [{ part: 'bottom', side: 'b', draw: 'line' }], + left: [{ part: 'left', side: 'l', draw: 'line' }], + right: [{ part: 'right', side: 'r', draw: 'line' }], + insideH: [ + { part: 'insideBottom', side: 'b', draw: 'line' }, + { part: 'insideTop', side: 't', draw: 'line' }, + ], + insideV: [ + { part: 'insideRight', side: 'r', draw: 'line' }, + { part: 'insideLeft', side: 'l', draw: 'line' }, + ], + none: (['t', 'r', 'b', 'l', 'du', 'dd'] as const).map((side) => ({ part: 'whole', side, draw: 'clear' })), + bottomDouble: [{ part: 'bottom', side: 'b', draw: 'double' }], + bottomThick: [{ part: 'bottom', side: 'b', draw: 'thick' }], + topBottom: [ + { part: 'top', side: 't', draw: 'line' }, + { part: 'bottom', side: 'b', draw: 'line' }, + ], + topThickBottom: [ + { part: 'top', side: 't', draw: 'line' }, + { part: 'bottom', side: 'b', draw: 'thick' }, + ], + diagonalUp: [{ part: 'whole', side: 'du', draw: 'line' }], + diagonalDown: [{ part: 'whole', side: 'dd', draw: 'line' }], +}; + +export const BORDER_IDS = Object.keys(PRESETS) as BorderId[]; + +/** The menu label of each preset. */ +export const BORDER_LABELS: Readonly> = { + all: 'sheetToolbar.borderAll', + outside: 'sheetToolbar.borderOutside', + thickOutside: 'sheetToolbar.borderThickOutside', + top: 'sheetToolbar.borderTop', + bottom: 'sheetToolbar.borderBottom', + left: 'sheetToolbar.borderLeft', + right: 'sheetToolbar.borderRight', + insideH: 'sheetToolbar.borderInsideH', + insideV: 'sheetToolbar.borderInsideV', + none: 'sheetToolbar.borderNone', + bottomDouble: 'sheetToolbar.borderBottomDouble', + bottomThick: 'sheetToolbar.borderBottomThick', + topBottom: 'sheetToolbar.borderTopBottom', + topThickBottom: 'sheetToolbar.borderTopThickBottom', + diagonalUp: 'sheetToolbar.borderDiagonalUp', + diagonalDown: 'sheetToolbar.borderDiagonalDown', +}; + +/** The cells of a box a part covers, or null when the box has no such part (an inside line of one row). */ +function partBox(box: CellBox, part: Part): CellBox | null { + const { top, left, bottom, right } = box; + switch (part) { + case 'whole': + return box; + case 'top': + return { top, left, bottom: top, right }; + case 'bottom': + return { top: bottom, left, bottom, right }; + case 'left': + return { top, left, bottom, right: left }; + case 'right': + return { top, left: right, bottom, right }; + case 'insideBottom': + return bottom > top ? { top, left, bottom: bottom - 1, right } : null; + case 'insideTop': + return bottom > top ? { top: top + 1, left, bottom, right } : null; + case 'insideRight': + return right > left ? { top, left, bottom, right: right - 1 } : null; + case 'insideLeft': + return right > left ? { top, left: left + 1, bottom, right } : null; + } +} + +const sideOf = (draw: Draw, line: Line): BorderSide | null => { + if (draw === 'clear') return null; + const style = draw === 'thick' ? 'thick' : draw === 'double' ? 'double' : line.style; + return line.color ? { style, color: line.color } : { style }; +}; + +/** The steps of a preset over a box, drawn with `line`. A step sets one side of the cells it covers. */ +export function bordersFor(id: BorderId, box: CellBox, line: Line): Step[] { + const steps: Step[] = []; + for (const rule of PRESETS[id]) { + const covered = partBox(box, rule.part); + if (!covered) continue; + // A border side the file has no value for is cleared with null; a drawn one replaces the side whole. + const change = { border: { [rule.side]: sideOf(rule.draw, line) } } as StyleChange; + steps.push({ box: covered, change }); + } + return steps; +} diff --git a/app/src/features/tables/ui/toolbar/commands.ts b/app/src/features/tables/ui/toolbar/commands.ts new file mode 100644 index 000000000..ff6280b04 --- /dev/null +++ b/app/src/features/tables/ui/toolbar/commands.ts @@ -0,0 +1,143 @@ +// The cell format commands (DESIGN 10.1, 10.2): each one is in the one command registry, so the palette (Ctrl+K, +// "Cell:") and the table's context menu offer what the format bar does: every button and every choice of its menus. +// They apply while a table with spreadsheet formats is the current table. A palette opens from the current table's +// own bar (hosts.ts addOpener), so with several tables on a page the command acts on the right one. +import type { CommandDef } from '../../../../commands/types'; +import { contribute } from '../../sheet/registry'; +import type { SheetContribution } from '../../sheet/registry'; +import { commandTitle } from './titles'; +import { currentHost, openTool } from './hosts'; +import type { TableExtraHost } from '../../../page'; + +const FLAG = 'tables.sheetWrites' as const; + +type Actions = typeof import('./actions'); + +interface Spec { + id: string; + run(actions: Actions, host: TableExtraHost): unknown; + /** The context menu group, and the place in it. Commands without one are for the palette only. */ + menu?: [group: string, order: number]; +} + +const cap = (id: string) => id[0].toUpperCase() + id.slice(1); + +/** Opens a palette of the current table's own bar, not the first table's (each table has a bar of its own). */ +const open = (tool: string) => (_actions: Actions, host: TableExtraHost) => openTool(host.block, tool); + +/** The border presets and the line styles. The ids are those of borders.ts and the menu's line styles. */ +const BORDERS = [ + 'all', + 'outside', + 'thickOutside', + 'top', + 'bottom', + 'left', + 'right', + 'insideH', + 'insideV', + 'none', + 'bottomDouble', + 'bottomThick', + 'topBottom', + 'topThickBottom', + 'diagonalUp', + 'diagonalDown', +] as const; +const LINES = ['thin', 'medium', 'thick', 'hair', 'dotted', 'dashed', 'double'] as const; +const ROTATIONS: readonly (readonly [string, number | 'stacked'])[] = [ + ['rotateNone', 0], + ['rotateUp', 45], + ['rotateDown', -45], + ['rotateVertical', 90], + ['rotateStacked', 'stacked'], +]; +/** The number formats of the menu that have no button of their own (the ids are those of numberFormats.ts). */ +const FORMATS = ['number', 'accounting', 'shortDate', 'longDate', 'time', 'fraction', 'scientific', 'text'] as const; +const UNDERLINES = ['none', 'single', 'double', 'singleAccounting', 'doubleAccounting'] as const; +/** The context menu places of the presets that are in the menu. */ +const MENU_ORDER: Readonly> = { all: 2, outside: 3, none: 4 }; + +const SPECS: readonly Spec[] = [ + { id: 'fill', run: open('fill') }, + { id: 'textColor', run: open('textColor') }, + { id: 'borders', run: (a, host) => a.setBorders(host, a.borderUse.last) }, + ...BORDERS.map((id): Spec => ({ + id: `border${cap(id)}`, + run: (a, host) => a.setBorders(host, id), + ...(MENU_ORDER[id] ? { menu: ['format', MENU_ORDER[id]] as [string, number] } : {}), + })), + ...LINES.map((style): Spec => ({ id: `lineStyle${cap(style)}`, run: (a) => a.setBorderLine({ style }) })), + { id: 'borderColor', run: open('borderColor') }, + { id: 'wrap', run: (a, host) => a.setWrap(host, 'wrap') }, + { id: 'wrapOverflow', run: (a, host) => a.setWrap(host, 'overflow') }, + { id: 'wrapClip', run: (a, host) => a.setWrap(host, 'clip') }, + ...ROTATIONS.map(([id, value]): Spec => ({ id, run: (a, host) => a.setRotate(host, value) })), + { id: 'alignLeft', run: (a, host) => a.setAlignH(host, 'left') }, + { id: 'alignCenter', run: (a, host) => a.setAlignH(host, 'center') }, + { id: 'alignRight', run: (a, host) => a.setAlignH(host, 'right') }, + { id: 'alignJustify', run: (a, host) => a.setAlignH(host, 'justify') }, + { id: 'alignTop', run: (a, host) => a.setAlignV(host, 'top') }, + { id: 'alignMiddle', run: (a, host) => a.setAlignV(host, 'middle') }, + { id: 'alignBottom', run: (a, host) => a.setAlignV(host, 'bottom') }, + ...UNDERLINES.map((kind): Spec => ({ id: `underline${cap(kind)}`, run: (a, host) => a.setUnderline(host, kind) })), + { id: 'currency', run: (a, host) => a.setCurrency(host), menu: ['format', 5] }, + { id: 'percent', run: (a, host) => a.setPercent(host), menu: ['format', 6] }, + { id: 'general', run: (a, host) => a.setFormat(host, null), menu: ['format', 7] }, + ...FORMATS.map((id): Spec => ({ id: `format${cap(id)}`, run: (a, host) => a.setChoice(host, id) })), + { id: 'thousands', run: (a, host) => a.changeThousands(host) }, + { id: 'moreDecimals', run: (a, host) => a.changeDecimals(host, 1) }, + { id: 'fewerDecimals', run: (a, host) => a.changeDecimals(host, -1) }, + { id: 'clearFormats', run: (a, host) => a.clearFormats(host), menu: ['format', 1] }, + // The conditional formatting rule manager loads with its React tree on first use. + { + id: 'conditionalRules', + run: (_a, host) => import('../conditional/open').then((m) => m.openRuleManager(host)), + menu: ['format', 8], + }, +]; + +/** The ids of the commands, for the tests. */ +export const CELL_COMMAND_IDS: readonly string[] = SPECS.map((spec) => `table.cell.${spec.id}`); + +const commandOf = (spec: Spec): CommandDef => ({ + id: `table.cell.${spec.id}`, + title: commandTitle(spec.id), + keywords: 'sheetToolbar.commands.keywords', + category: 'table', + flag: FLAG, + when: () => currentHost() !== undefined, + run: async () => { + const host = currentHost(); + if (host) await spec.run(await import('./actions'), host); + }, +}); + +const items = (): SheetContribution[] => + SPECS.flatMap((spec): SheetContribution[] => { + const command = { kind: 'command', lane: 'toolbar', command: commandOf(spec) } as const; + if (!spec.menu) return [command]; + const [group, order] = spec.menu; + return [ + command, + { + kind: 'contextMenu', + lane: 'toolbar', + item: { id: `table.cell.${spec.id}`, command: `table.cell.${spec.id}`, group, order, flag: FLAG }, + }, + ]; + }); + +let registered: (() => void) | null = null; + +/** Adds the commands and menu entries once. Calling it again does nothing. */ +export function registerCellCommands(): () => void { + if (!registered) { + const undo = contribute(...items()); + registered = () => { + undo(); + registered = null; + }; + } + return registered; +} diff --git a/app/src/features/tables/ui/toolbar/hosts.ts b/app/src/features/tables/ui/toolbar/hosts.ts new file mode 100644 index 000000000..936c8f594 --- /dev/null +++ b/app/src/features/tables/ui/toolbar/hosts.ts @@ -0,0 +1,112 @@ +// The table blocks that have a cell format bar: the table extra (register.ts) records each block's host here when its +// editor mounts. The bar, the commands, and the format target look hosts up by block, by the editor's state, or +// as the current table. +// +// A block tells its extras about a change from outside (undo, redo, another window), not about the extra's own +// writes. So the host kept here remembers the sheet it wrote, reads it back until the block says otherwise, and +// tells the subscribers, so the bar shows what is on the cell now. +import type { EditorState } from '@tiptap/pm/state'; +import { applyMergePatch } from '../../../../platform/mergePatch'; +import type { BlockJson } from '../../../../services/pages/types'; +import { currentTable } from '../../../page'; +import type { TableExtraHost } from '../../../page'; + +const hosts = new Map(); +/** The sheet of each block as the extra last knew it: from the block, or from its own write. */ +const sheets = new Map(); +const listeners = new Set<() => void>(); +let version = 0; + +/** Tells the subscribers that a block's data changed. */ +export function changed(): void { + version++; + listeners.forEach((listener) => listener()); +} + +/** The block's data as the extra knows it: the page's, with the sheet the extra last wrote. */ +function known(host: TableExtraHost): Record { + const data = host.data(); + return sheets.has(host.block) ? { ...data, sheet: sheets.get(host.block) } : data; +} + +/** Remembers the sheet after a patch of the block's data went through. */ +function wrote(host: TableExtraHost, patch: Record | undefined): void { + if (patch && 'sheet' in patch) sheets.set(host.block, applyMergePatch(known(host).sheet ?? {}, patch.sheet)); + changed(); +} + +/** Records a block's host. The host kept is the same one with a view of the sheet that follows its own writes. */ +export function addHost(host: TableExtraHost): () => void { + const own: TableExtraHost = { + ...host, + data: () => known(host), + patch: async (data) => { + const sent = await host.patch(data); + wrote(host, data); + return sent; + }, + apply: async (change, extra) => { + const done = await host.apply(change, extra); + if (done) wrote(host, extra); + return done; + }, + }; + hosts.set(host.block, own); + sheets.delete(host.block); + changed(); + return () => { + if (hosts.get(host.block) === own) hosts.delete(host.block); + sheets.delete(host.block); + changed(); + }; +} + +/** The block says what it holds now: the page's sheet is the truth again. */ +export function blockChanged(block: BlockJson): void { + sheets.set(block.id, (block.data as Record | undefined)?.sheet); + changed(); +} + +export const subscribeHosts = (listener: () => void): (() => void) => { + listeners.add(listener); + return () => void listeners.delete(listener); +}; + +export const hostsVersion = (): number => version; + +export const hostOfBlock = (block: string): TableExtraHost | undefined => hosts.get(block); + +/** The hosts of the tables on one open page, in the order they attached. */ +export const hostsOfPage = (page: string): TableExtraHost[] => [...hosts.values()].filter((h) => h.page === page); + +/** The host whose editor holds this state's document. */ +export function hostOfState(state: EditorState): TableExtraHost | undefined { + for (const host of hosts.values()) if (host.editor.state.doc === state.doc) return host; + return undefined; +} + +/** The host of the table the table commands act on now. */ +export function currentHost(): TableExtraHost | undefined { + const handle = currentTable.get(); + return handle ? hosts.get(handle.block) : undefined; +} + +/** What a block's bar opens by name (its palettes). The bar adds these while it shows, so a command opens its own table's. */ +const openers = new Map void>>(); + +/** Adds a way to open a palette of a block's bar. Returns the call that takes it away. */ +export function addOpener(block: string, tool: string, open: () => void): () => void { + const own = openers.get(block) ?? new Map void>(); + openers.set(block, own); + own.set(tool, open); + return () => { + if (own.get(tool) === open) own.delete(tool); + }; +} + +/** Opens a palette of a block's bar. Returns false when the block's bar does not show. */ +export function openTool(block: string, tool: string): boolean { + const open = openers.get(block)?.get(tool); + open?.(); + return open !== undefined; +} diff --git a/app/src/features/tables/ui/toolbar/index.ts b/app/src/features/tables/ui/toolbar/index.ts new file mode 100644 index 000000000..967b004c6 --- /dev/null +++ b/app/src/features/tables/ui/toolbar/index.ts @@ -0,0 +1,4 @@ +// The cell format bar of the table toolbar (DESIGN 10.1). Its extra, format target, and commands start from +// register.ts at start-up; the bar itself loads with the first table toolbar. +export { CellFormatBar } from './CellFormatBar'; +export type { CellFormatBarProps } from './CellFormatBar'; diff --git a/app/src/features/tables/ui/toolbar/numberFormats.ts b/app/src/features/tables/ui/toolbar/numberFormats.ts new file mode 100644 index 000000000..b9acff093 --- /dev/null +++ b/app/src/features/tables/ui/toolbar/numberFormats.ts @@ -0,0 +1,204 @@ +// The number format menu and the decimal and thousands buttons (DESIGN 10.1). Pure functions over format codes: +// a code is a string of the number format language (DESIGN 5), so the buttons edit it as text. Only the first +// section decides the direction of a change, and the change is made in every section that shows digits, since the +// rest are the negative and zero cases of the same format. A text section has no digit placeholders and stays. +import type { MessageKey } from '../../../../strings/t'; +import type { Locale } from '../../engine/locale'; +import { regionDateCode } from '../../engine/numfmt'; + +export interface FormatChoice { + id: string; + label: MessageKey; + /** The code the choice writes, or null for General (the table's default format shows through). */ + code(locale: Locale): string | null; +} + +/** The currency sign of a region, by the platform's own table. */ +export function currencySign(locale: Locale): string { + try { + const parts = new Intl.NumberFormat(locale.tag, { + style: 'currency', + currency: locale.currency, + currencyDisplay: 'narrowSymbol', + }).formatToParts(1); + return parts.find((part) => part.type === 'currency')?.value ?? '$'; + } catch { + return '$'; + } +} + +const quoted = (sign: string) => `"${sign.replaceAll('"', '')}"`; + +/** Currency: the sign, thousands separators, two decimals, and negatives in parentheses (ECMA-376 ID 7 form). */ +export const currencyCode = (locale: Locale): string => { + const sign = quoted(currencySign(locale)); + return `${sign}#,##0.00_);(${sign}#,##0.00)`; +}; + +/** Accounting: the sign at the cell's left edge and the number at its right (ECMA-376 ID 44 form). */ +export const accountingCode = (locale: Locale): string => { + const sign = quoted(currencySign(locale)); + return `_(${sign}* #,##0.00_);_(${sign}* \\(#,##0.00\\);_(${sign}* "-"??_);_(@_)`; +}; + +export const FORMAT_CHOICES: readonly FormatChoice[] = [ + { id: 'general', label: 'sheetToolbar.fmtGeneral', code: () => null }, + { id: 'number', label: 'sheetToolbar.fmtNumber', code: () => '0.00' }, + { id: 'currency', label: 'sheetToolbar.fmtCurrency', code: currencyCode }, + { id: 'accounting', label: 'sheetToolbar.fmtAccounting', code: accountingCode }, + { id: 'shortDate', label: 'sheetToolbar.fmtShortDate', code: (locale) => regionDateCode(locale, 'short') }, + { id: 'longDate', label: 'sheetToolbar.fmtLongDate', code: (locale) => regionDateCode(locale, 'long') }, + { id: 'time', label: 'sheetToolbar.fmtTime', code: (locale) => regionDateCode(locale, 'time') }, + { id: 'percent', label: 'sheetToolbar.fmtPercent', code: () => '0.00%' }, + { id: 'fraction', label: 'sheetToolbar.fmtFraction', code: () => '# ?/?' }, + { id: 'scientific', label: 'sheetToolbar.fmtScientific', code: () => '0.00E+00' }, + { id: 'text', label: 'sheetToolbar.fmtText', code: () => '@' }, +]; + +/** The menu choice a code is (General for none), or null for a custom code. The Percent button's `0%` is Percent. */ +export function choiceOf(code: string | undefined, locale: Locale): string | null { + if (isGeneral(code)) return 'general'; + if (code === '0%') return 'percent'; + return FORMAT_CHOICES.find((choice) => choice.code(locale) === code)?.id ?? null; +} + +/** The positions of characters of a code that are outside quotes, escapes, and brackets, with the first section's end. */ +function scan(code: string): { open: boolean[]; end: number } { + const open = new Array(code.length).fill(false); + let quote = false; + let bracket = false; + let end = code.length; + for (let i = 0; i < code.length; i++) { + const c = code[i]; + if (!quote && (c === '\\' || c === '_' || c === '*')) i++; + else if (c === '"') quote = !quote; + else if (!quote && c === '[') bracket = true; + else if (!quote && c === ']') bracket = false; + else if (!quote && !bracket) { + if (c === ';') { + end = i; + break; + } + open[i] = true; + } + } + return { open, end }; +} + +const isDigit = (c: string | undefined) => c !== undefined && /[0#?]/.test(c); +const isGeneral = (code: string | undefined) => code === undefined || code === '' || /^general$/i.test(code); + +/** Excel and Sheets allow at most 30 decimal places. */ +const MAX_DECIMALS = 30; + +/** + * More or fewer digits after the decimal point in the first section. General gains one decimal as `0.0`. A format with + * none gains `.0` after its last digit. Fewer takes the last digit off, and the point with it at none. + */ +function stepSection(text: string, direction: 1 | -1): string { + const { open, end } = scan(text); + // The mantissa of a scientific format ends at its E; digits after the E are the exponent. + let limit = end; + for (let i = 0; i < end; i++) { + if (open[i] && /[eE]/.test(text[i]) && /[+-]/.test(text[i + 1] ?? '')) { + limit = i; + break; + } + } + let dot = -1; + for (let i = 0; i < limit; i++) { + if (open[i] && text[i] === '.') { + dot = i; + break; + } + } + if (dot === -1) { + if (direction === -1) return text; + let last = -1; + for (let i = 0; i < limit; i++) if (open[i] && isDigit(text[i])) last = i; + return last === -1 ? text : `${text.slice(0, last + 1)}.0${text.slice(last + 1)}`; + } + let stop = dot + 1; + while (stop < limit && open[stop] && isDigit(text[stop])) stop++; + const digits = stop - dot - 1; + if (direction === 1) return digits >= MAX_DECIMALS ? text : `${text.slice(0, stop)}0${text.slice(stop)}`; + return digits <= 1 ? `${text.slice(0, dot)}${text.slice(stop)}` : `${text.slice(0, stop - 1)}${text.slice(stop)}`; +} + +/** The sections of a code: the text between semicolons that are outside quotes, escapes, and brackets. */ +function sections(code: string): string[] { + const parts: string[] = []; + let from = 0; + let quote = false; + let bracket = false; + for (let i = 0; i < code.length; i++) { + const c = code[i]; + if (!quote && (c === '\\' || c === '_' || c === '*')) i++; + else if (c === '"') quote = !quote; + else if (!quote && c === '[') bracket = true; + else if (!quote && c === ']') bracket = false; + else if (!quote && !bracket && c === ';') { + parts.push(code.slice(from, i)); + from = i + 1; + } + } + parts.push(code.slice(from)); + return parts; +} + +/** Whether a section shows digits with 0 or # placeholders (a text section or a dash section does not). */ +function showsDigits(section: string): boolean { + const { open, end } = scan(section); + for (let i = 0; i < end; i++) if (open[i] && /[0#]/.test(section[i])) return true; + return false; +} + +/** The decimal places of a code, changed in every section that shows digits. */ +export function stepDecimals(code: string | undefined, direction: 1 | -1): string | null { + if (isGeneral(code)) return direction === 1 ? '0.0' : null; + return sections(code as string) + .map((part) => (showsDigits(part) ? stepSection(part, direction) : part)) + .join(';'); +} + +/** The position of a thousands comma in the first section: between two digit placeholders, outside quotes. */ +function commaAt(text: string): number { + const { open, end } = scan(text); + for (let i = 1; i < end - 1; i++) { + if (open[i] && text[i] === ',' && isDigit(text[i - 1]) && isDigit(text[i + 1])) return i; + } + return -1; +} + +/** Whether the first section groups its integer digits. */ +export function hasThousands(code: string | undefined): boolean { + return !isGeneral(code) && commaAt(code as string) !== -1; +} + +/** Turns the grouping on or off. General becomes `#,##0`; `0.00` becomes `#,##0.00`. */ +function groupSection(text: string, on: boolean): string { + const comma = commaAt(text); + if (!on) return comma === -1 ? text : text.slice(0, comma) + text.slice(comma + 1); + if (comma !== -1) return text; + const { open, end } = scan(text); + let from = -1; + for (let i = 0; i < end; i++) { + if (open[i] && isDigit(text[i])) { + from = i; + break; + } + } + if (from === -1) return text; + let to = from; + while (to < end && open[to] && isDigit(text[to])) to++; + const run = to - from; + return run >= 4 ? `${text.slice(0, to - 3)},${text.slice(to - 3)}` : `${text.slice(0, from)}#,##0${text.slice(to)}`; +} + +/** Turns the grouping on or off: the first section decides, and every section that shows digits follows. */ +export function toggleThousands(code: string | undefined): string { + if (isGeneral(code)) return '#,##0'; + const parts = sections(code as string); + const on = commaAt(parts[0]) === -1; + return parts.map((part) => (showsDigits(part) ? groupSection(part, on) : part)).join(';'); +} diff --git a/app/src/features/tables/ui/toolbar/palette.ts b/app/src/features/tables/ui/toolbar/palette.ts new file mode 100644 index 000000000..e6ba18dec --- /dev/null +++ b/app/src/features/tables/ui/toolbar/palette.ts @@ -0,0 +1,24 @@ +// What the cell format bar tells its color palettes: the color the contrast badge compares with (DESIGN 10.4), and the +// page's paper. Pure helpers, so the bar and its tests share them. +import { themeColor } from '../../../../ui/color/parse'; +import type { Scheme } from '../../../../ui/color/parse'; +import type { CellStyle } from '../../sheet/model'; +import { colorOf } from '../../sheet/render/color'; + +/** The page's paper as `#rrggbb`, read from the page's surface token as the element sees it (a notebook may set it). */ +export function paperOf(element: Element | null | undefined): string | undefined { + if (!element) return undefined; + const value = getComputedStyle(element).getPropertyValue('--color-surface-page').trim(); + return /^#[0-9a-f]{6}$/i.test(value) ? value.toLowerCase() : undefined; +} + +/** + * The color a palette's badge compares with. A fill is read against the cell's text color. A text color is read + * against the cell's fill, or against the paper when the cell has no flat fill. + */ +export function againstFor(tool: 'fill' | 'textColor', style: CellStyle, scheme: Scheme, paper?: string): string { + const page = paper ?? themeColor('background', scheme); + if (tool === 'fill') return colorOf(style.font?.color, scheme) ?? themeColor('text', scheme, { background: page }); + const fill = typeof style.fill === 'string' && style.fill !== 'none' ? colorOf(style.fill, scheme) : undefined; + return fill ?? page; +} diff --git a/app/src/features/tables/ui/toolbar/register.ts b/app/src/features/tables/ui/toolbar/register.ts new file mode 100644 index 000000000..9db72fd5d --- /dev/null +++ b/app/src/features/tables/ui/toolbar/register.ts @@ -0,0 +1,50 @@ +// Starts the cell format bar's part of a table block: the extra that records the block's host, the cell-range +// format target, and the commands. The table toolbar imports this, so it loads with the first table block. +// +// Start-up carries only a thin format target (claims: whole cells and a host) and the command list. The planning +// and writing code (the sheet model, formatTarget.ts) loads when the first table block attaches, which is before a +// cell can be selected, so the thin target hands over to it from then on. +import { registerFormatTarget } from '../../../../editor/commands/formatTarget'; +import type { FormatTarget } from '../../../../editor/commands/formatTarget'; +import type { EditorState } from '@tiptap/pm/state'; +import { tableExtras } from '../../../page'; +import { registerCellCommands } from './commands'; +import { addHost, blockChanged, hostOfState } from './hosts'; + +let started = false; +let loaded: FormatTarget | null = null; + +/** Loads the planning and writing code once, and makes the full target the one the thin target hands over to. */ +function loadTarget(): Promise { + return import('../../sheet/formatTarget').then((module) => { + loaded ??= module.createCellRangeTarget(hostOfState); + }); +} + +/** A cell selection has these anchor cells. The check avoids importing the table plugin at start-up. */ +const isCellSelection = (state: EditorState): boolean => '$anchorCell' in state.selection; + +/** The thin target: it claims what the full one claims, once the full one has loaded. */ +const thinTarget: FormatTarget = { + id: 'tables.cellRange', + claims: (state) => loaded !== null && isCellSelection(state) && loaded.claims(state), + apply: (request, state, dispatch) => loaded?.apply(request, state, dispatch) ?? false, +}; + +export function startCellFormat(): void { + if (started) return; + started = true; + registerFormatTarget(thinTarget); + registerCellCommands(); + tableExtras.register({ + id: 'sheet.cellFormat', + flag: 'tables.sheetWrites', + attach: async (host) => { + await loadTarget(); + const remove = addHost(host); + return { update: blockChanged, destroy: remove }; + }, + }); +} + +startCellFormat(); diff --git a/app/src/features/tables/ui/toolbar/roving.ts b/app/src/features/tables/ui/toolbar/roving.ts new file mode 100644 index 000000000..dc6a30c59 --- /dev/null +++ b/app/src/features/tables/ui/toolbar/roving.ts @@ -0,0 +1,52 @@ +// Arrow keys, Home, and End move focus between the buttons of a toolbar (APG toolbar pattern). A button is any +// element marked data-tool; one has tabIndex 0 and the rest -1, so Tab reaches the bar once and leaves it once. +import type { KeyboardEvent } from 'react'; + +/** The buttons of a bar, in order. */ +export const toolsOf = (bar: HTMLElement): HTMLElement[] => [...bar.querySelectorAll('[data-tool]')]; + +/** Makes `active` the bar's one tab stop. With none given, it keeps the stop it has, or takes the first button. */ +export function rove(bar: HTMLElement, active?: HTMLElement): void { + const buttons = toolsOf(bar); + const stop = active ?? buttons.find((button) => button.tabIndex === 0) ?? buttons[0]; + buttons.forEach((button) => (button.tabIndex = button === stop ? 0 : -1)); +} + +export function moveFocus(event: KeyboardEvent): void { + const bar = event.currentTarget; + const buttons = toolsOf(bar); + const at = buttons.indexOf(document.activeElement as HTMLElement); + if (at === -1) { + // The Size box is the last field before the buttons: Right Arrow at the end of its text goes on into them. + const field = event.target; + if (event.key !== 'ArrowRight' || !(field instanceof HTMLInputElement) || !buttons.length) return; + const inputs = [...bar.querySelectorAll('input')].filter( + (input) => input.compareDocumentPosition(buttons[0]) & Node.DOCUMENT_POSITION_FOLLOWING, + ); + if ( + inputs.at(-1) !== field || + field.selectionStart !== field.value.length || + field.selectionEnd !== field.value.length + ) + return; + event.preventDefault(); + const stop = buttons.find((button) => button.tabIndex === 0) ?? buttons[0]; + rove(bar, stop); + stop.focus(); + return; + } + const next = + event.key === 'ArrowRight' + ? (at + 1) % buttons.length + : event.key === 'ArrowLeft' + ? (at - 1 + buttons.length) % buttons.length + : event.key === 'Home' + ? 0 + : event.key === 'End' + ? buttons.length - 1 + : -1; + if (next === -1) return; + event.preventDefault(); + rove(bar, buttons[next]); + buttons[next].focus(); +} diff --git a/app/src/features/tables/ui/toolbar/titles.ts b/app/src/features/tables/ui/toolbar/titles.ts new file mode 100644 index 000000000..4b09b196f --- /dev/null +++ b/app/src/features/tables/ui/toolbar/titles.ts @@ -0,0 +1,4 @@ +// The title of a cell format command, for the palette and menus. A module of its own so start-up loads no actions. +import type { MessageKey } from '../../../../strings/t'; + +export const commandTitle = (key: string): MessageKey => `sheetToolbar.commands.${key}` as MessageKey; diff --git a/app/src/features/tables/ui/toolbar/toolbar.module.css b/app/src/features/tables/ui/toolbar/toolbar.module.css new file mode 100644 index 000000000..80a44d2ef --- /dev/null +++ b/app/src/features/tables/ui/toolbar/toolbar.module.css @@ -0,0 +1,28 @@ +@layer components { + /* The cell format bar sits under the structure tools in the table toolbar and wraps like them, so at 400 percent + zoom it needs no sideways scrolling. */ + .formatBar { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + padding: var(--space-1); + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-md); + background: var(--color-surface-raised); + box-shadow: var(--elevation-2); + max-inline-size: 100%; + box-sizing: border-box; + } + + .group { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-1); + } + + .group + .group { + padding-inline-start: var(--space-2); + border-inline-start: 1px solid var(--color-border-subtle); + } +} diff --git a/app/src/features/tables/ui/toolbar/toolbar.test.ts b/app/src/features/tables/ui/toolbar/toolbar.test.ts new file mode 100644 index 000000000..dffc907d5 --- /dev/null +++ b/app/src/features/tables/ui/toolbar/toolbar.test.ts @@ -0,0 +1,151 @@ +// The cell format bar's pure parts: number format edits and the borders presets. Expected values are worked by hand +// from the number format language (DESIGN 5) and from the sides each preset sets (DESIGN 10.5). +import { describe, expect, it } from 'vitest'; +import { BORDER_IDS, bordersFor } from './borders'; +import type { BorderId } from './borders'; +import { EN_US } from '../../engine/locale'; +import { accountingCode, choiceOf, currencyCode, hasThousands, stepDecimals, toggleThousands } from './numberFormats'; + +describe('decimal places', () => { + const more: [string | undefined, string | null][] = [ + [undefined, '0.0'], + ['General', '0.0'], + ['0', '0.0'], + ['0.00', '0.000'], + ['#,##0', '#,##0.0'], + ['#,##0.00', '#,##0.000'], + ['0%', '0.0%'], + ['0.00%', '0.000%'], + ['0.00E+00', '0.000E+00'], + ['0E+00', '0.0E+00'], + ['"$"#,##0.00_);("$"#,##0.00)', '"$"#,##0.000_);("$"#,##0.000)'], + // Accounting: the dash section has only ? placeholders and the text section none, so both stay. + [ + '_("$"* #,##0.00_);_("$"* \\(#,##0.00\\);_("$"* "-"??_);_(@_)', + '_("$"* #,##0.000_);_("$"* \\(#,##0.000\\);_("$"* "-"??_);_(@_)', + ], + ['"0.00 m"0', '"0.00 m"0.0'], + ]; + for (const [code, expected] of more) { + it(`adds a decimal to ${code ?? 'nothing'}`, () => expect(stepDecimals(code, 1)).toBe(expected)); + } + const fewer: [string | undefined, string | null][] = [ + [undefined, null], + ['General', null], + ['0.000', '0.00'], + ['0.00', '0.0'], + ['0.0', '0'], + ['0', '0'], + ['#,##0.0', '#,##0'], + ['0.00%', '0.0%'], + ['0.0E+00', '0E+00'], + ['"$"#,##0.00_);("$"#,##0.00)', '"$"#,##0.0_);("$"#,##0.0)'], + ]; + for (const [code, expected] of fewer) { + it(`removes a decimal from ${code ?? 'nothing'}`, () => expect(stepDecimals(code, -1)).toBe(expected)); + } +}); + +describe('thousands separator', () => { + const cases: [string | undefined, string][] = [ + [undefined, '#,##0'], + ['General', '#,##0'], + ['0', '#,##0'], + ['0.00', '#,##0.00'], + ['0000', '0,000'], + ['#,##0', '###0'], + ['#,##0.00', '###0.00'], + // Every section follows the first: off removes the comma of the negative section too, on adds it to both. + ['"$"#,##0.00_);("$"#,##0.00)', '"$"###0.00_);("$"###0.00)'], + ['"$"0.00_);("$"0.00)', '"$"#,##0.00_);("$"#,##0.00)'], + [ + '_("$"* #,##0.00_);_("$"* \\(#,##0.00\\);_("$"* "-"??_);_(@_)', + '_("$"* ###0.00_);_("$"* \\(###0.00\\);_("$"* "-"??_);_(@_)', + ], + ]; + for (const [code, expected] of cases) { + it(`turns ${code ?? 'nothing'} into ${expected}`, () => expect(toggleThousands(code)).toBe(expected)); + } + it('reads the separator only outside quotes', () => { + expect(hasThousands('#,##0')).toBe(true); + expect(hasThousands('"a,b"0')).toBe(false); + expect(hasThousands('0.00')).toBe(false); + }); +}); + +describe('currency codes', () => { + it('writes the sign, grouping, and negatives in parentheses, and the accounting form', () => { + expect(currencyCode(EN_US)).toBe('"$"#,##0.00_);("$"#,##0.00)'); + expect(accountingCode(EN_US)).toBe('_("$"* #,##0.00_);_("$"* \\(#,##0.00\\);_("$"* "-"??_);_(@_)'); + }); +}); + +describe('border presets', () => { + const box = { top: 1, left: 1, bottom: 2, right: 2 }; + const line = { style: 'thin' }; + /** Which sides of which cell a preset sets, as `B2:t` strings. */ + const sets = (id: BorderId, b = box): string[] => { + const out: string[] = []; + for (const step of bordersFor(id, b, line)) { + const [[side, value]] = Object.entries(step.change.border as object); + for (let r = step.box.top; r <= step.box.bottom; r++) + for (let c = step.box.left; c <= step.box.right; c++) + out.push(`${'ABCD'[c]}${r + 1}:${side}${value ? '' : '-'}`); + } + return out.sort(); + }; + it('has sixteen presets', () => expect(BORDER_IDS).toHaveLength(16)); + it('draws the outside of B2:C3 and nothing inside', () => { + expect(sets('outside')).toEqual(['B2:l', 'B2:t', 'B3:b', 'B3:l', 'C2:r', 'C2:t', 'C3:b', 'C3:r']); + }); + it('draws a line shared by two cells on both of them', () => { + expect(sets('insideH')).toEqual(['B2:b', 'B3:t', 'C2:b', 'C3:t']); + expect(sets('insideV')).toEqual(['B2:r', 'B3:r', 'C2:l', 'C3:l']); + }); + it('has no inside lines in a single cell', () => { + expect(sets('insideH', { top: 0, left: 0, bottom: 0, right: 0 })).toEqual([]); + }); + it('draws thick and double lines whatever the menu line style is', () => { + const thick = bordersFor('thickOutside', box, line).map( + (step) => step.change.border as Record, + ); + expect(thick.every((border) => Object.values(border).every((side) => side.style === 'thick'))).toBe(true); + const double = bordersFor('bottomDouble', box, line)[0].change.border as { b: { style: string } }; + expect(double.b.style).toBe('double'); + }); + it('clears every side, diagonals included, for No border', () => { + const cleared = bordersFor('none', box, line) + .map((step) => Object.keys(step.change.border as object)[0]) + .sort(); + expect(cleared).toEqual(['b', 'dd', 'du', 'l', 'r', 't']); + }); + it('draws with the line color when there is one', () => { + const step = bordersFor('top', box, { style: 'medium', color: 'theme:accent1' })[0]; + expect(step.change.border).toEqual({ t: { style: 'medium', color: 'theme:accent1' } }); + }); +}); + +describe('which number format choice a code is', () => { + it('names the choice whose code the cell has', () => { + expect(choiceOf(undefined, EN_US)).toBe('general'); + expect(choiceOf(currencyCode(EN_US), EN_US)).toBe('currency'); + expect(choiceOf(accountingCode(EN_US), EN_US)).toBe('accounting'); + expect(choiceOf('0%', EN_US)).toBe('percent'); + expect(choiceOf('@', EN_US)).toBe('text'); + expect(choiceOf('0.000', EN_US)).toBeNull(); + }); +}); + +describe('decimal places limits and quoted text', () => { + it('stops at 30 decimal places, the limit of Excel and Sheets', () => { + let code: string | null = '0'; + for (let i = 0; i < 100; i++) code = stepDecimals(code ?? undefined, 1); + expect(code).toBe(`0.${'0'.repeat(30)}`); + }); + + it('reads a quoted literal that ends in an underscore as text', () => { + expect(stepDecimals('"x_"0.00', 1)).toBe('"x_"0.000'); + expect(stepDecimals('"x_"0.00', -1)).toBe('"x_"0.0'); + expect(toggleThousands('"x_"0')).toBe('"x_"#,##0'); + }); +}); diff --git a/app/src/state/keymap.test.ts b/app/src/state/keymap.test.ts index aa5f05447..a15b99415 100644 --- a/app/src/state/keymap.test.ts +++ b/app/src/state/keymap.test.ts @@ -1,8 +1,13 @@ -import { afterEach, describe, expect, it } from 'vitest'; +// @vitest-environment jsdom +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { initFlags } from '../app/flags'; +import { commandForKey } from '../commands/dispatcher'; import { keysFor } from '../commands/keymap'; -import { chord, defineCommand } from '../commands/registry'; +import { chord, configureCommands, defineCommand } from '../commands/registry'; import type { CommandDef } from '../commands/types'; import { commands } from '../registries'; +import { createMemoryNotesService } from '../services/notes/memory'; +import { createTestPlatform } from '../test/platform'; import { assignShortcut, resetAllShortcuts, resetShortcut, setShortcut, setShortcutSet } from './keymap'; import { DEFAULT_SETTINGS, getSettings, settingsStore } from './settings'; import { resetStores } from './store'; @@ -63,3 +68,40 @@ describe('changing shortcuts', () => { expect(keysFor('test.theme')).toEqual(['Ctrl+Alt+D']); }); }); + +// The table-grid context (ADR 0063): in a table's Grid mode its commands come before the page's and the editor's, +// Ctrl+K stays the palette, and a chord the person changes moves with the command. These are stand-ins for the +// sheet commands; features/tables/keys/commands.test.ts checks the real ones. +describe('the table-grid context', () => { + beforeAll(() => { + initFlags('dev'); + configureCommands({ platform: createTestPlatform(), notes: createMemoryNotesService({ seed: 'sample' }) }); + }); + + function press(scope: string, key: string, code: string, mods: string) { + document.body.innerHTML = `
`; + const target = document.querySelector('[contenteditable]'); + const has = (name: string) => mods.includes(name); + const event = { key, code, ctrlKey: has('ctrl'), altKey: has('alt'), shiftKey: false, metaKey: false }; + return commandForKey({ ...event, isComposing: false, repeat: false, target })?.id ?? null; + } + + const GRID = 'editor editor.table tableGrid'; + + it('runs Grid mode’s chord before the page’s, and the page’s in Edit mode', () => { + add('pageFill', { keys: [chord('Ctrl+D')], scope: 'page', allowInTextInput: true }); + add('gridFill', { keys: [chord('Ctrl+D')], scope: 'tableGrid', allowInTextInput: true }); + add('palette', { keys: [chord('Ctrl+K')], allowInTextInput: true }); + expect(press(GRID, 'd', 'KeyD', 'ctrl')).toBe('test.gridFill'); + expect(press('editor editor.table tableEdit', 'd', 'KeyD', 'ctrl')).toBe('test.pageFill'); + expect(press(GRID, 'k', 'KeyK', 'ctrl')).toBe('test.palette'); + }); + + it('moves a grid command to the chord the person gives it', async () => { + add('pageFill', { keys: [chord('Ctrl+D')], scope: 'page', allowInTextInput: true }); + add('gridFill', { keys: [chord('Ctrl+D')], scope: 'tableGrid', allowInTextInput: true }); + await setShortcut('test.gridFill', [chord('Ctrl+Alt+D')]); + expect(press(GRID, 'd', 'KeyD', 'ctrl')).toBe('test.pageFill'); + expect(press(GRID, 'd', 'KeyD', 'ctrl alt')).toBe('test.gridFill'); + }); +}); diff --git a/app/src/strings/en/fnArrays.ts b/app/src/strings/en/fnArrays.ts index 42ddd4ebc..9cc18b259 100644 --- a/app/src/strings/en/fnArrays.ts +++ b/app/src/strings/en/fnArrays.ts @@ -1,4 +1,157 @@ -// Descriptions of the dynamic array, matrix, and LAMBDA helper functions. -// Owner: tables:fn-arrays (B11). shared:x-shared registered it in the string index, and the owner fills it in. +// Descriptions of the dynamic array, matrix, and LAMBDA helper functions, one line each, written fresh in American +// English. Owner: tables:fn-arrays (B11). Each function has a description and a line for each argument. `reasons` +// holds the words for an error a function explains, such as an array past the size cap. -export const fnArrays = {} as const; +const arg = { + array: 'The block of values to use.', + array1: 'The first matrix.', + array2: 'The second matrix. It has as many rows as the first has columns.', + arrayFormula: 'A formula. Every formula here already works on arrays, so this gives the formula as it is.', + byArray: 'The row or column of values to sort by, as long as the array.', + byCol: 'TRUE to work on columns instead of rows. FALSE when left out.', + colNum: 'Which column to take. A negative number counts from the end.', + columns: 'How many columns. It is 1 when left out.', + dimension: 'How many rows and columns the identity matrix has.', + exactlyOnce: 'TRUE to keep only the values that appear once. FALSE when left out.', + ifEmpty: 'What to give when nothing passes the test.', + ignore: 'Which items to skip: 0 none, 1 empty cells, 2 errors, 3 both. It is 0 when left out.', + include: 'TRUE or FALSE for each row (or column) of the array: which ones to keep.', + initial: 'The value to start from. Empty when left out.', + integer: 'TRUE for whole numbers only. FALSE when left out.', + lambda: 'A function made with LAMBDA.', + matrix: 'A square block of numbers.', + max: 'The largest value allowed. It is 1 when left out.', + min: 'The smallest value allowed. It is 0 when left out.', + n: 'How many rows to keep. It is 1 when left out.', + padWith: 'The value for the cells that have nothing. It is #N/A when left out.', + range: 'The block of cells to use.', + rowNum: 'Which row to take. A negative number counts from the end.', + rows: 'How many rows. A negative number counts from the end.', + rowsBuilt: 'How many rows. It is 1 when left out.', + scanByColumn: 'TRUE to read down each column. FALSE, the default, reads across each row.', + sortColumn: 'Which column to sort by.', + sortIndex: 'Which column (or row) to sort by. It is 1 when left out.', + sortOrder: '1 for smallest first, -1 for largest first. It is 1 when left out.', + isAscending: 'TRUE for smallest first, FALSE for largest first.', + start: 'The first number. It is 1 when left out.', + step: 'How much each number grows. It is 1 when left out.', + ties: 'How to treat ties: 0 none, 1 keep ties with the last row, 2 drop repeats, 3 keep every copy of the top rows.', + trim: 'Which empty lines to trim: 0 none, 1 leading, 2 trailing, 3 both. It is 3 when left out.', + vector: 'A single row or column of values.', + wrapCount: 'How many values go in each line.', +} as const; + +/** A function's line and one line per argument, by argument name. */ +export interface FnText { + readonly description: string; + readonly params: Readonly>; +} + +// Typed as unknown values on purpose. Function text is looked up by function name at run time, never through t(). +export const fnArrays: Readonly> = { + reasons: { tooMany: 'Too many values' }, + ARRAYFORMULA: { description: 'Gives a formula as it is.', params: { array_formula: arg.arrayFormula } }, + BYCOL: { + description: 'Calls a function on each column of an array and gives one value per column.', + params: { array: arg.array, lambda: arg.lambda }, + }, + BYROW: { + description: 'Calls a function on each row of an array and gives one value per row.', + params: { array: arg.array, lambda: arg.lambda }, + }, + CHOOSECOLS: { + description: 'Takes the chosen columns of an array.', + params: { array: arg.array, col_num: arg.colNum }, + }, + CHOOSEROWS: { description: 'Takes the chosen rows of an array.', params: { array: arg.array, row_num: arg.rowNum } }, + DROP: { + description: 'Removes rows or columns from the start or the end of an array.', + params: { array: arg.array, rows: arg.rows, columns: arg.rows }, + }, + EXPAND: { + description: 'Makes an array larger, filling the new cells.', + params: { array: arg.array, rows: arg.rowsBuilt, columns: arg.columns, pad_with: arg.padWith }, + }, + FILTER: { + description: 'Keeps the rows (or columns) of an array that pass a test.', + params: { array: arg.array, include: arg.include, if_empty: arg.ifEmpty }, + }, + FLATTEN: { description: 'Puts every value of the ranges in one column, row by row.', params: { range: arg.range } }, + HSTACK: { description: 'Joins arrays side by side.', params: { array: arg.array } }, + MAKEARRAY: { + description: 'Builds an array by calling a function with each cell’s row and column number.', + params: { rows: arg.rowsBuilt, columns: arg.columns, lambda: arg.lambda }, + }, + MAP: { + description: 'Calls a function on each value of one or more arrays.', + params: { array: arg.array, lambda: arg.lambda }, + }, + MDETERM: { description: 'Gives the determinant of a square matrix.', params: { array: arg.matrix } }, + MINVERSE: { description: 'Gives the inverse of a square matrix.', params: { array: arg.matrix } }, + MMULT: { description: 'Multiplies a matrix by another.', params: { array1: arg.array1, array2: arg.array2 } }, + MUNIT: { description: 'Gives the identity matrix of a size.', params: { dimension: arg.dimension } }, + RANDARRAY: { + description: 'Gives an array of random numbers.', + params: { rows: arg.rowsBuilt, columns: arg.columns, min: arg.min, max: arg.max, integer: arg.integer }, + }, + REDUCE: { + description: 'Combines the values of an array into one result, a step at a time.', + params: { initial_value: arg.initial, array: arg.array, lambda: arg.lambda }, + }, + SCAN: { + description: 'Combines the values of an array a step at a time and gives every step.', + params: { initial_value: arg.initial, array: arg.array, lambda: arg.lambda }, + }, + SEQUENCE: { + description: 'Gives an array of numbers that grow by a step.', + params: { rows: arg.rowsBuilt, columns: arg.columns, start: arg.start, step: arg.step }, + }, + SORT: { + description: 'Sorts the rows (or columns) of an array.', + params: { array: arg.array, sort_index: arg.sortIndex, sort_order: arg.sortOrder, by_col: arg.byCol }, + }, + SORTBY: { + description: 'Sorts an array by the values of other rows or columns.', + params: { array: arg.array, by_array: arg.byArray, sort_order: arg.sortOrder }, + }, + SORTN: { + description: 'Sorts a range and keeps its first rows.', + params: { + range: arg.range, + n: arg.n, + display_ties_mode: arg.ties, + sort_column: arg.sortColumn, + is_ascending: arg.isAscending, + }, + }, + TAKE: { + description: 'Keeps rows or columns from the start or the end of an array.', + params: { array: arg.array, rows: arg.rows, columns: arg.rows }, + }, + TOCOL: { + description: 'Puts every value of an array in one column.', + params: { array: arg.array, ignore: arg.ignore, scan_by_column: arg.scanByColumn }, + }, + TOROW: { + description: 'Puts every value of an array in one row.', + params: { array: arg.array, ignore: arg.ignore, scan_by_column: arg.scanByColumn }, + }, + TRANSPOSE: { description: 'Swaps the rows and columns of an array.', params: { array: arg.array } }, + TRIMRANGE: { + description: 'Removes the empty rows and columns at the edges of a range.', + params: { range: arg.range, trim_rows: arg.trim, trim_cols: arg.trim }, + }, + UNIQUE: { + description: 'Keeps each distinct row (or column) of an array once.', + params: { array: arg.array, by_col: arg.byCol, exactly_once: arg.exactlyOnce }, + }, + VSTACK: { description: 'Joins arrays one above the other.', params: { array: arg.array } }, + WRAPCOLS: { + description: 'Cuts a row or column of values into columns of a set length.', + params: { vector: arg.vector, wrap_count: arg.wrapCount, pad_with: arg.padWith }, + }, + WRAPROWS: { + description: 'Cuts a row or column of values into rows of a set length.', + params: { vector: arg.vector, wrap_count: arg.wrapCount, pad_with: arg.padWith }, + }, +}; diff --git a/app/src/strings/en/fnLogicInfo.ts b/app/src/strings/en/fnLogicInfo.ts index e2460dd36..25128e707 100644 --- a/app/src/strings/en/fnLogicInfo.ts +++ b/app/src/strings/en/fnLogicInfo.ts @@ -1,4 +1,116 @@ -// Descriptions of the logical and information functions. +// Descriptions of the logical and information functions: one line per function (`about`) and one per argument +// (`args`, by the argument's name in the signature). Written fresh in American English. // Owner: tables:fn-logic-info (B10). shared:x-shared registered it in the string index, and the owner fills it in. -export const fnLogicInfo = {} as const; +const value = 'The value to check.'; + +export const fnLogicInfo = { + AND: { about: 'Gives TRUE when every argument is true.', args: { logical: 'A value, array, or range to test.' } }, + OR: { about: 'Gives TRUE when any argument is true.', args: { logical: 'A value, array, or range to test.' } }, + XOR: { + about: 'Gives TRUE when an odd number of arguments are true.', + args: { logical: 'A value, array, or range to test.' }, + }, + NOT: { about: 'Turns TRUE into FALSE and FALSE into TRUE.', args: { logical: 'The value to flip.' } }, + TRUE: { about: 'Gives the logical value TRUE.', args: {} }, + FALSE: { about: 'Gives the logical value FALSE.', args: {} }, + IF: { + about: 'Gives one result when a test is true and another when it is false.', + args: { + test: 'The condition.', + then: 'The result when true.', + otherwise: 'The result when false. FALSE if left out.', + }, + }, + IFERROR: { + about: 'Gives a fallback when a value is an error.', + args: { value: 'The value to check.', on_error: 'The result when the value is an error.' }, + }, + IFNA: { + about: 'Gives a fallback when a value is #N/A.', + args: { value: 'The value to check.', on_na: 'The result when the value is #N/A.' }, + }, + IFS: { + about: 'Gives the result of the first test that is true. #N/A when none is.', + args: { test: 'A condition.', result: 'The result when that condition is true.' }, + }, + SWITCH: { + about: 'Compares a value with a list of matches and gives the result of the first one that is equal.', + args: { + value: 'The value to compare.', + match: 'A value to compare it with.', + result: 'The result when it matches.', + default: 'The result when nothing matches. #N/A if left out.', + }, + }, + LET: { + about: 'Gives names to values, then calculates with them.', + args: { + name: 'A name to define.', + value: 'The value of that name.', + calculation: 'The formula that uses the names.', + }, + }, + LAMBDA: { + about: 'Makes a function of your own. Call it right away or give it a name.', + args: { parameter: 'A name the function takes as input.', calculation: 'The formula that uses the inputs.' }, + }, + CELL: { + about: 'Gives facts about a cell, such as its address, row, column, type, or format.', + args: { + info_type: + 'What to report: address, col, row, contents, type, format, width, filename. The filename is empty without a page title.', + reference: 'The cell to ask about. This cell if left out.', + }, + }, + ERROR_TYPE: { + about: 'Gives a number for the kind of error, such as 2 for divide by zero and 7 for not available.', + args: { error: 'The error.' }, + }, + INFO: { + about: 'Gives a fact about this page that is safe to share, such as the calculation mode.', + args: { type_text: 'What to report: numfile, origin, recalc, or system.' }, + }, + ISBLANK: { about: 'Gives TRUE for an empty cell.', args: { value } }, + ISNUMBER: { about: 'Gives TRUE for a number. Text that looks like a number is not one.', args: { value } }, + ISTEXT: { about: 'Gives TRUE for text.', args: { value } }, + ISNONTEXT: { about: 'Gives TRUE for anything that is not text, an empty cell included.', args: { value } }, + ISLOGICAL: { about: 'Gives TRUE for TRUE or FALSE.', args: { value } }, + ISERROR: { about: 'Gives TRUE for any error.', args: { value } }, + ISERR: { about: 'Gives TRUE for any error except #N/A.', args: { value } }, + ISNA: { about: 'Gives TRUE for #N/A.', args: { value } }, + ISEVEN: { + about: 'Gives TRUE when a number is even. A fraction is cut off first.', + args: { number: 'The number to check.' }, + }, + ISODD: { + about: 'Gives TRUE when a number is odd. A fraction is cut off first.', + args: { number: 'The number to check.' }, + }, + ISFORMULA: { about: 'Gives TRUE when a cell holds a formula.', args: { reference: 'The cell to check.' } }, + ISOMITTED: { + about: 'In a function made with LAMBDA, gives TRUE when an optional input was left out.', + args: { argument: 'The name of the input.' }, + }, + ISREF: { about: 'Gives TRUE when the value is a reference to cells.', args: { value } }, + ISEMAIL: { about: 'Gives TRUE for text shaped like an email address.', args: { value } }, + ISURL: { about: 'Gives TRUE for text shaped like a web address.', args: { value } }, + ISDATE: { + about: 'Gives TRUE for a date, such as a cell in a date column, or for text that reads as a date.', + args: { value }, + }, + N: { about: 'Turns a value into a number. TRUE is 1, text is 0.', args: { value: 'The value to convert.' } }, + NA: { about: 'Gives the error #N/A, which means a value is not available.', args: {} }, + SHEET: { + about: 'Gives the position of a table on the page, counting from 1.', + args: { value: 'A reference or a table name. This table if left out.' }, + }, + SHEETS: { + about: 'Gives how many tables the page has.', + args: { reference: 'A reference to count the tables in. The whole page if left out.' }, + }, + TYPE: { + about: 'Gives a code for the kind of value: 1 number, 2 text, 4 logical, 16 error, 64 array.', + args: { value }, + }, +} as const; diff --git a/app/src/strings/en/fnLookup.ts b/app/src/strings/en/fnLookup.ts index 13c1f2b9c..cb75e5599 100644 --- a/app/src/strings/en/fnLookup.ts +++ b/app/src/strings/en/fnLookup.ts @@ -1,4 +1,135 @@ -// Descriptions of the lookup and reference functions. -// Owner: tables:fn-lookup (B10). shared:x-shared registered it in the string index, and the owner fills it in. +// Descriptions of the lookup and reference functions, one line per function and one per argument, in our own words. +// Owner: tables:fn-lookup (B10). Typed loosely, as the other family files are, so the message key union stays small. -export const fnLookup = {} as const; +export const fnLookup: Record = { + VLOOKUP: { + summary: 'Finds a value in the first column of a table and gives the value in the same row of another column.', + args: { + lookup_value: 'The value to find in the first column.', + table_array: 'The table to search. Its first column holds the values to find.', + col_index_num: 'Which column to give, counting the first column as 1.', + range_lookup: 'TRUE or left out finds the nearest smaller value in sorted data. FALSE finds an exact match.', + }, + }, + HLOOKUP: { + summary: 'Finds a value in the first row of a table and gives the value in the same column of another row.', + args: { + lookup_value: 'The value to find in the first row.', + table_array: 'The table to search. Its first row holds the values to find.', + row_index_num: 'Which row to give, counting the first row as 1.', + range_lookup: 'TRUE or left out finds the nearest smaller value in sorted data. FALSE finds an exact match.', + }, + }, + LOOKUP: { + summary: 'Finds the nearest smaller value in sorted data and gives the matching value from another row or column.', + args: { + lookup_value: 'The value to find.', + lookup_vector: 'One sorted row or column to search, or a table whose first row or column is searched.', + result_vector: 'Where to give a value from. Left out, the far side of the table opposite the searched one.', + }, + }, + MATCH: { + summary: 'Gives the position of a value in a row or column.', + args: { + lookup_value: 'The value to find. With 0 as the type, * and ? match any text and one character.', + lookup_array: 'One row or column to search.', + match_type: '1 or left out: nearest smaller in ascending data. 0: exact. -1: nearest larger in descending data.', + }, + }, + XLOOKUP: { + summary: 'Finds a value in one row or column and gives what is in the same place in another.', + args: { + lookup_value: 'The value to find.', + lookup_array: 'One row or column to search.', + return_array: 'The rows or columns to give a result from, as long as the searched one.', + if_not_found: 'What to give when nothing matches. Left out, the result is #N/A.', + match_mode: + 'Exact is 0. Exact or the next smaller is -1. Exact or the next larger is 1. Wildcards are 2. A pattern is 3.', + search_mode: '1 first to last, -1 last to first, 2 a fast search of ascending data, -2 of descending data.', + }, + }, + XMATCH: { + summary: 'Gives the position of a value in a row or column, with the same choices as XLOOKUP.', + args: { + lookup_value: 'The value to find.', + lookup_array: 'One row or column to search.', + match_mode: + 'Exact is 0. Exact or the next smaller is -1. Exact or the next larger is 1. Wildcards are 2. A pattern is 3.', + search_mode: '1 first to last, -1 last to first, 2 a fast search of ascending data, -2 of descending data.', + }, + }, + INDEX: { + summary: 'Gives the cell at a row and column of a block, or a whole row or column when one of them is 0.', + args: { + array: 'The block of cells or the array to pick from. A union such as (A1:B2,D1:E2) has several areas.', + row_num: 'The row, counting from 1. 0 gives every row.', + column_num: 'The column, counting from 1. 0 gives every column.', + area_num: 'Which area of a union to use. Left out, the first.', + }, + }, + OFFSET: { + summary: 'Gives a block that starts a number of rows and columns away from a reference. It recalculates often.', + args: { + reference: 'Where to start counting.', + rows: 'How many rows down to move. A negative number moves up.', + cols: 'How many columns right to move. A negative number moves left.', + height: 'How many rows the block has. Left out, as many as the reference.', + width: 'How many columns the block has. Left out, as many as the reference.', + }, + }, + INDIRECT: { + summary: 'Gives the cells that a piece of text names, such as "B2". It recalculates often.', + args: { + ref_text: 'The reference as text.', + a1: 'TRUE or left out reads A1 style. FALSE reads R1C1 style, where R[1]C[-1] counts from this cell.', + }, + }, + CHOOSE: { + summary: 'Gives one value from a list, picked by its number.', + args: { + index_num: 'Which value to give, counting from 1.', + value: 'A value, a reference, or an array to choose from.', + }, + }, + ROW: { + summary: 'Gives the row number of a reference, or of this cell when left out.', + args: { reference: 'The cell or block. A block gives one number per row.' }, + }, + COLUMN: { + summary: 'Gives the column number of a reference, or of this cell when left out.', + args: { reference: 'The cell or block. A block gives one number per column.' }, + }, + ROWS: { + summary: 'Gives how many rows a reference or an array has.', + args: { array: 'The block of cells or the array.' }, + }, + COLUMNS: { + summary: 'Gives how many columns a reference or an array has.', + args: { array: 'The block of cells or the array.' }, + }, + ADDRESS: { + summary: 'Writes a cell reference as text from its row and column numbers.', + args: { + row_num: 'The row number.', + column_num: 'The column number.', + abs_num: '1 or left out: $A$1. 2: A$1. 3: $A1. 4: A1.', + a1: 'TRUE or left out writes A1 style. FALSE writes R1C1 style.', + sheet_text: 'A table name to put in front, such as Sales.', + }, + }, + AREAS: { + summary: 'Gives how many areas a reference has, such as 2 for (A1:B2,D4).', + args: { reference: 'The reference to count.' }, + }, + FORMULATEXT: { + summary: 'Gives the formula in a cell as text. A cell without a formula gives #N/A.', + args: { reference: 'The cell whose formula to show.' }, + }, + HYPERLINK: { + summary: 'Shows a link. The cell opens it only when it is clicked.', + args: { + link_location: 'The address to open: a web page or an email address.', + friendly_name: 'The text to show. Left out, the address itself.', + }, + }, +}; diff --git a/app/src/strings/en/fnStats.ts b/app/src/strings/en/fnStats.ts index fb748ad70..f4d291a11 100644 --- a/app/src/strings/en/fnStats.ts +++ b/app/src/strings/en/fnStats.ts @@ -1,4 +1,301 @@ -// Descriptions of the statistical functions, their compatibility names, and the distributions. -// Owner: tables:fn-stats (B11). shared:x-shared registered it in the string index, and the owner fills it in. +// checks-disable-file ui-voice: function names are spelled in capitals, as formulas write them. +// Descriptions of the statistical functions, their compatibility names, and the distributions, one line each, +// written fresh in American English. Owner: tables:fn-stats (B11). Each function has a description and a line for +// each argument, looked up by the argument's name. -export const fnStats = {} as const; +const arg: Readonly> = { + actual: 'The counts that were observed.', + aggregation: 'How values at one time combine, from 1 (average) to 7 (sum). It is 1 when left out.', + alpha: 'The first shape number, above 0.', + array: 'A block of numbers.', + array1: 'The first block of numbers.', + array2: 'The second block of numbers.', + averageRange: 'The cells to average. The tested cells are averaged when this is left out.', + beta: 'The second shape number, above 0.', + bins: 'The upper edge of each bin.', + chosen: 'How many items each arrangement takes.', + completion: 'Use 1 to fill missing points from their neighbors, or 0 to fill them with zeros.', + confidence: 'The confidence level, between 0 and 1. It is 0.95 when left out.', + const: 'TRUE or left out to fit an intercept; FALSE to force the line through zero.', + criteriaRange: 'The cells to test against the next criterion.', + criterion: 'The test a cell must meet, such as ">2" or "apple".', + cumulative: 'TRUE for the chance of a value up to x; FALSE for the chance or density at x.', + data: 'The values to sort into bins.', + degrees: 'The degrees of freedom, a whole number from 1.', + degrees1: 'The degrees of freedom of the numerator.', + degrees2: 'The degrees of freedom of the denominator.', + expected: 'The counts a hypothesis predicts.', + failures: 'How many failures come before the last success.', + k: 'Which position, or the fraction from 0 to 1, to find.', + knownX: 'The known x values.', + knownY: 'The known y values.', + lambda: 'The rate, above 0.', + lower: 'The lowest value of the range.', + maxRange: 'The cells to find the largest of.', + mean: 'The mean of the distribution.', + minRange: 'The cells to find the smallest of.', + newX: 'The x values to predict y at. The known x values are used when this is left out.', + number: 'The number to rank.', + order: '0 or left out to rank from the largest; any other number to rank from the smallest.', + percent: 'The fraction of values to leave out, split between the two ends.', + population: 'How many items there are in all.', + populationS: 'How many items in all count as a success.', + probRange: 'The chance of each value, adding to 1.', + probability: 'The probability, between 0 and 1.', + quart: 'Which quartile, as a whole number.', + range: 'The cells to test.', + ref: 'The numbers to rank against.', + sample: 'How many items are drawn.', + sampleS: 'How many drawn items are successes.', + sd: 'The standard deviation, above 0.', + seasonality: 'The season length; 1 or left out to find it, 0 for none.', + sigma: 'The standard deviation of the population. The sample standard deviation is used when this is left out.', + significance: 'How many digits to keep. It is 3 when left out.', + size: 'How many values the sample has.', + statistic: 'Which measure to give, from 1 to 8.', + stats: 'TRUE to add the rows of fit statistics; FALSE or left out for the coefficients only.', + successes: 'How many successes.', + successes2: 'The most successes to include. Only the first count is used when this is left out.', + tails: 'Use 1 for one tail or 2 for both tails.', + target: 'The date or number to forecast at.', + timeline: 'The dates or numbers of the known values, with one step between them.', + trials: 'How many trials.', + type: 'Use 1 for paired values, 2 for equal variances, or 3 for unequal variances.', + upper: 'The highest value of the range.', + value: 'A number, a cell, or a block of cells.', + values: 'The known values.', + x: 'The value to evaluate at.', + xRange: 'The values that can occur.', + y: 'The value to transform back.', + z: 'The number of standard deviations from the mean.', +}; + +/** A function's line and one line per argument, by argument name. */ +export interface FnText { + readonly description: string; + readonly params: Readonly>; +} + +/** Each function's description and its arguments, by name. */ +const LINES: Readonly> = { + AVEDEV: ['Gives the average distance of the numbers from their mean.', 'value'], + AVERAGE: ['Gives the mean of the numbers.', 'value'], + AVERAGEA: ['Gives the mean of the values, with text as 0 and TRUE as 1.', 'value'], + AVERAGEIF: ['Gives the mean of the cells whose matching cells meet a test.', 'range criterion averageRange'], + AVERAGEIFS: [ + 'Gives the mean of the cells whose matching cells meet every test.', + 'averageRange criteriaRange criterion', + ], + 'BETA.DIST': ['Gives the beta distribution at x, between optional bounds.', 'x alpha beta cumulative lower upper'], + 'BETA.INV': [ + 'Gives the x at which the cumulative beta distribution reaches a probability.', + 'probability alpha beta lower upper', + ], + 'BINOM.DIST': [ + 'Gives the chance of a number of successes in a set of trials.', + 'successes trials probability cumulative', + ], + 'BINOM.DIST.RANGE': [ + 'Gives the chance that the successes fall within a range.', + 'trials probability successes successes2', + ], + 'BINOM.INV': ['Gives the fewest successes whose cumulative chance reaches alpha.', 'trials probability alpha'], + 'CHISQ.DIST': ['Gives the chi-squared distribution at x.', 'x degrees cumulative'], + 'CHISQ.DIST.RT': ['Gives the chance of a chi-squared value above x.', 'x degrees'], + 'CHISQ.INV': ['Gives the x below which a chi-squared value falls with a probability.', 'probability degrees'], + 'CHISQ.INV.RT': ['Gives the x above which a chi-squared value falls with a probability.', 'probability degrees'], + 'CHISQ.TEST': ['Tests whether observed counts fit expected ones, and gives the chance.', 'actual expected'], + 'CONFIDENCE.NORM': ['Gives half the width of a confidence interval for a mean, by the normal law.', 'alpha sd size'], + 'CONFIDENCE.T': ["Gives half the width of a confidence interval for a mean, by Student's t law.", 'alpha sd size'], + CORREL: ['Gives the correlation of two blocks of numbers, from -1 to 1.', 'array1 array2'], + COUNT: ['Counts the numbers.', 'value'], + COUNTA: ['Counts the cells that are not empty.', 'value'], + COUNTBLANK: ['Counts the empty cells.', 'value'], + COUNTIF: ['Counts the cells that meet a test.', 'range criterion'], + COUNTIFS: ['Counts the rows whose cells meet every test.', 'criteriaRange criterion'], + COUNTUNIQUE: ['Counts the different values, leaving out empty cells.', 'value'], + 'COVARIANCE.P': ['Gives the population covariance of two blocks of numbers.', 'array1 array2'], + 'COVARIANCE.S': ['Gives the sample covariance of two blocks of numbers.', 'array1 array2'], + DEVSQ: ['Adds the squared distances of the numbers from their mean.', 'value'], + 'EXPON.DIST': ['Gives the exponential distribution at x.', 'x lambda cumulative'], + 'F.DIST': ['Gives the F distribution at x.', 'x degrees1 degrees2 cumulative'], + 'F.DIST.RT': ['Gives the chance of an F value above x.', 'x degrees1 degrees2'], + 'F.INV': ['Gives the x below which an F value falls with a probability.', 'probability degrees1 degrees2'], + 'F.INV.RT': ['Gives the x above which an F value falls with a probability.', 'probability degrees1 degrees2'], + 'F.TEST': ['Tests whether two samples have the same variance, and gives the two-tailed chance.', 'array1 array2'], + FISHER: ['Gives the Fisher transform of a correlation.', 'x'], + FISHERINV: ['Turns a Fisher transform back into a correlation.', 'y'], + FORECAST: ['Predicts y at x from a straight line through known points.', 'x knownY knownX'], + 'FORECAST.ETS': [ + 'Forecasts a value by exponential smoothing with trend and season.', + 'target values timeline seasonality completion aggregation', + ], + 'FORECAST.ETS.CONFINT': [ + 'Gives half the width of the confidence interval of an exponential smoothing forecast.', + 'target values timeline confidence seasonality completion aggregation', + ], + 'FORECAST.ETS.SEASONALITY': [ + 'Gives the season length that exponential smoothing finds in a series.', + 'values timeline completion aggregation', + ], + 'FORECAST.ETS.STAT': [ + 'Gives a measure of an exponential smoothing fit, such as its error.', + 'values timeline statistic seasonality completion aggregation', + ], + 'FORECAST.LINEAR': ['Predicts y at x from a straight line through known points.', 'x knownY knownX'], + FREQUENCY: ['Counts how many values fall in each bin, as a column.', 'data bins'], + GAMMA: ['Gives the gamma function of a number.', 'x'], + 'GAMMA.DIST': ['Gives the gamma distribution at x.', 'x alpha beta cumulative'], + 'GAMMA.INV': [ + 'Gives the x at which the cumulative gamma distribution reaches a probability.', + 'probability alpha beta', + ], + GAMMALN: ['Gives the natural logarithm of the gamma function.', 'x'], + 'GAMMALN.PRECISE': ['Gives the natural logarithm of the gamma function.', 'x'], + GAUSS: ['Gives the chance that a standard normal value falls between the mean and z.', 'z'], + GEOMEAN: ['Gives the geometric mean of positive numbers.', 'value'], + GROWTH: ['Predicts values on an exponential curve fitted to known points.', 'knownY knownX newX const'], + HARMEAN: ['Gives the harmonic mean of positive numbers.', 'value'], + 'HYPGEOM.DIST': [ + 'Gives the chance of a number of successes when drawing without putting items back.', + 'sampleS sample populationS population cumulative', + ], + INTERCEPT: ['Gives where the least-squares line crosses the y axis.', 'knownY knownX'], + KURT: ['Gives how heavy the tails of the numbers are, compared with the normal law.', 'value'], + LARGE: ['Gives the k-th largest number.', 'array k'], + LINEST: ['Fits a straight line or plane by least squares and gives its coefficients.', 'knownY knownX const stats'], + LOGEST: ['Fits an exponential curve and gives its factors.', 'knownY knownX const stats'], + 'LOGNORM.DIST': ['Gives the lognormal distribution at x.', 'x mean sd cumulative'], + 'LOGNORM.INV': [ + 'Gives the x at which the cumulative lognormal distribution reaches a probability.', + 'probability mean sd', + ], + MAX: ['Gives the largest number.', 'value'], + MAXA: ['Gives the largest value, with text as 0 and TRUE as 1.', 'value'], + MAXIFS: ['Gives the largest of the cells whose rows meet every test.', 'maxRange criteriaRange criterion'], + MEDIAN: ['Gives the middle number.', 'value'], + MIN: ['Gives the smallest number.', 'value'], + MINA: ['Gives the smallest value, with text as 0 and TRUE as 1.', 'value'], + MINIFS: ['Gives the smallest of the cells whose rows meet every test.', 'minRange criteriaRange criterion'], + 'MODE.MULT': ['Gives every most common number, as a column.', 'value'], + 'MODE.SNGL': ['Gives the most common number.', 'value'], + 'NEGBINOM.DIST': [ + 'Gives the chance of a number of failures before a number of successes.', + 'failures successes probability cumulative', + ], + 'NORM.DIST': ['Gives the normal distribution at x.', 'x mean sd cumulative'], + 'NORM.INV': ['Gives the x at which the cumulative normal distribution reaches a probability.', 'probability mean sd'], + 'NORM.S.DIST': ['Gives the standard normal distribution at z.', 'z cumulative'], + 'NORM.S.INV': [ + 'Gives the z at which the cumulative standard normal distribution reaches a probability.', + 'probability', + ], + PEARSON: ['Gives the Pearson correlation of two blocks of numbers.', 'array1 array2'], + 'PERCENTILE.EXC': ['Gives the value at a fraction of the way through the numbers, with 0 and 1 left out.', 'array k'], + 'PERCENTILE.INC': ['Gives the value at a fraction of the way through the numbers, from 0 to 1.', 'array k'], + 'PERCENTRANK.EXC': ['Gives the share of the numbers below a value, with 0 and 1 left out.', 'array x significance'], + 'PERCENTRANK.INC': ['Gives the share of the numbers below a value, from 0 to 1.', 'array x significance'], + PERMUT: ['Counts the ordered ways to choose items without repeats.', 'number chosen'], + PERMUTATIONA: ['Counts the ordered ways to choose items with repeats.', 'number chosen'], + PHI: ['Gives the standard normal density at x.', 'x'], + 'POISSON.DIST': ['Gives the chance of a number of events by the Poisson law.', 'x mean cumulative'], + PROB: ['Gives the chance that a value falls between two limits.', 'xRange probRange lower upper'], + 'QUARTILE.EXC': ['Gives a quartile of the numbers, with 0 and 1 left out.', 'array quart'], + 'QUARTILE.INC': ['Gives a quartile of the numbers, from the smallest to the largest.', 'array quart'], + 'RANK.AVG': ['Gives the place of a number in a list; ties share the average place.', 'number ref order'], + 'RANK.EQ': ['Gives the place of a number in a list; ties share the top place.', 'number ref order'], + RSQ: ['Gives the square of the correlation of known y and known x.', 'knownY knownX'], + SKEW: ['Gives how lopsided a sample is around its mean.', 'value'], + 'SKEW.P': ['Gives how lopsided a population is around its mean.', 'value'], + SLOPE: ['Gives the slope of the least-squares line.', 'knownY knownX'], + SMALL: ['Gives the k-th smallest number.', 'array k'], + STANDARDIZE: ['Gives how many standard deviations x is from the mean.', 'x mean sd'], + 'STDEV.P': ['Gives the standard deviation of a whole population.', 'value'], + 'STDEV.S': ['Gives the standard deviation of a sample.', 'value'], + STDEVA: ['Gives the sample standard deviation, with text as 0 and TRUE as 1.', 'value'], + STDEVPA: ['Gives the population standard deviation, with text as 0 and TRUE as 1.', 'value'], + STEYX: ['Gives the standard error of the y values predicted by the least-squares line.', 'knownY knownX'], + 'T.DIST': ["Gives Student's t distribution at x.", 'x degrees cumulative'], + 'T.DIST.2T': ['Gives the chance of a t value farther from 0 than x, in both tails.', 'x degrees'], + 'T.DIST.RT': ['Gives the chance of a t value above x.', 'x degrees'], + 'T.INV': ['Gives the t value below which a value falls with a probability.', 'probability degrees'], + 'T.INV.2T': [ + 'Gives the t value beyond which a value falls with a probability, in both tails.', + 'probability degrees', + ], + 'T.TEST': ["Gives the chance from Student's t test that two samples share a mean.", 'array1 array2 tails type'], + TREND: ['Predicts values on a straight line fitted to known points.', 'knownY knownX newX const'], + TRIMMEAN: ['Gives the mean after leaving out a share of the smallest and largest numbers.', 'array percent'], + 'VAR.P': ['Gives the variance of a whole population.', 'value'], + 'VAR.S': ['Gives the variance of a sample.', 'value'], + VARA: ['Gives the sample variance, with text as 0 and TRUE as 1.', 'value'], + VARPA: ['Gives the population variance, with text as 0 and TRUE as 1.', 'value'], + 'WEIBULL.DIST': ['Gives the Weibull distribution at x.', 'x alpha beta cumulative'], + 'Z.TEST': ['Gives the one-tailed chance of a sample mean at least this far above x.', 'array x sigma'], + // Compatibility names: the older spellings that spreadsheets still accept. + BETADIST: ['Gives the cumulative beta distribution. An older name for BETA.DIST.', 'x alpha beta lower upper'], + BETAINV: [ + 'Gives the inverse of the cumulative beta distribution. An older name for BETA.INV.', + 'probability alpha beta lower upper', + ], + BINOMDIST: ['Gives a binomial chance. An older name for BINOM.DIST.', 'successes trials probability cumulative'], + CHIDIST: ['Gives the chance of a chi-squared value above x. An older name for CHISQ.DIST.RT.', 'x degrees'], + CHIINV: ['Gives the x above which a chi-squared value falls. An older name for CHISQ.INV.RT.', 'probability degrees'], + CHITEST: ['Tests observed counts against expected ones. An older name for CHISQ.TEST.', 'actual expected'], + CONFIDENCE: ['Gives half a normal confidence interval. An older name for CONFIDENCE.NORM.', 'alpha sd size'], + COVAR: ['Gives the population covariance. An older name for COVARIANCE.P.', 'array1 array2'], + CRITBINOM: ['Gives the fewest successes that reach alpha. An older name for BINOM.INV.', 'trials probability alpha'], + EXPONDIST: ['Gives the exponential distribution. An older name for EXPON.DIST.', 'x lambda cumulative'], + FDIST: ['Gives the chance of an F value above x. An older name for F.DIST.RT.', 'x degrees1 degrees2'], + FINV: ['Gives the x above which an F value falls. An older name for F.INV.RT.', 'probability degrees1 degrees2'], + FTEST: ['Tests whether two variances match. An older name for F.TEST.', 'array1 array2'], + GAMMADIST: ['Gives the gamma distribution. An older name for GAMMA.DIST.', 'x alpha beta cumulative'], + GAMMAINV: [ + 'Gives the inverse of the cumulative gamma distribution. An older name for GAMMA.INV.', + 'probability alpha beta', + ], + HYPGEOMDIST: [ + 'Gives a hypergeometric chance. An older name for HYPGEOM.DIST.', + 'sampleS sample populationS population', + ], + LOGINV: [ + 'Gives the inverse of the cumulative lognormal distribution. An older name for LOGNORM.INV.', + 'probability mean sd', + ], + LOGNORMDIST: ['Gives the cumulative lognormal distribution. An older name for LOGNORM.DIST.', 'x mean sd'], + MODE: ['Gives the most common number. An older name for MODE.SNGL.', 'value'], + NEGBINOMDIST: [ + 'Gives a negative binomial chance. An older name for NEGBINOM.DIST.', + 'failures successes probability', + ], + NORMDIST: ['Gives the normal distribution. An older name for NORM.DIST.', 'x mean sd cumulative'], + NORMINV: [ + 'Gives the inverse of the cumulative normal distribution. An older name for NORM.INV.', + 'probability mean sd', + ], + NORMSDIST: ['Gives the cumulative standard normal distribution. An older name for NORM.S.DIST.', 'z'], + NORMSINV: ['Gives the inverse of the standard normal distribution. An older name for NORM.S.INV.', 'probability'], + PERCENTILE: ['Gives a percentile from 0 to 1. An older name for PERCENTILE.INC.', 'array k'], + PERCENTRANK: ['Gives the share of numbers below a value. An older name for PERCENTRANK.INC.', 'array x significance'], + POISSON: ['Gives a Poisson chance. An older name for POISSON.DIST.', 'x mean cumulative'], + QUARTILE: ['Gives a quartile. An older name for QUARTILE.INC.', 'array quart'], + RANK: ['Gives the place of a number in a list. An older name for RANK.EQ.', 'number ref order'], + STDEV: ['Gives the sample standard deviation. An older name for STDEV.S.', 'value'], + STDEVP: ['Gives the population standard deviation. An older name for STDEV.P.', 'value'], + TDIST: ["Gives one or both tails of Student's t law beyond x, for x at least 0.", 'x degrees tails'], + TINV: ['Gives the two-tailed inverse of t. An older name for T.INV.2T.', 'probability degrees'], + TTEST: ["Gives the chance from Student's t test. An older name for T.TEST.", 'array1 array2 tails type'], + VAR: ['Gives the sample variance. An older name for VAR.S.', 'value'], + VARP: ['Gives the population variance. An older name for VAR.P.', 'value'], + WEIBULL: ['Gives the Weibull distribution. An older name for WEIBULL.DIST.', 'x alpha beta cumulative'], + ZTEST: ['Gives the one-tailed chance of a z test. An older name for Z.TEST.', 'array x sigma'], +}; + +// Typed as unknown values on purpose, as fnMath is. Function text is looked up by function name at run time, never +// through t(), and a typed key for each of the 149 names makes the message key union too large for the compiler. +export const fnStats: Readonly> = Object.fromEntries( + Object.entries(LINES).map(([name, [description, list]]) => [ + name, + { description, params: Object.fromEntries(list.split(' ').map((p) => [p, arg[p] ?? ''])) } satisfies FnText, + ]), +); diff --git a/app/src/strings/en/pageViews.ts b/app/src/strings/en/pageViews.ts index 447410f7c..b336ba950 100644 --- a/app/src/strings/en/pageViews.ts +++ b/app/src/strings/en/pageViews.ts @@ -166,6 +166,9 @@ export const pageViews = { }, files: { saved: 'Saved {name}.', + tableNumber: 'Table {number}', + staleTables: + 'The formulas in {tables} are text, because their results are out of date. Reopen the page to refresh.', failed: "The file couldn't be saved.", markdownLabel: 'Markdown', htmlLabel: 'Web page', diff --git a/app/src/strings/en/sheetConditional.ts b/app/src/strings/en/sheetConditional.ts index a80fdb3ba..4e164dfba 100644 --- a/app/src/strings/en/sheetConditional.ts +++ b/app/src/strings/en/sheetConditional.ts @@ -1,4 +1,109 @@ // Conditional formatting: rules, color scales, data bars, icon sets, and the rule manager. // Owner: tables:conditional (B11). shared:x-shared registered it in the string index, and the owner fills it in. +// Every interface string is a full sentence here, never joined from pieces (ARCHITECTURE.md section 19). -export const sheetConditional = {} as const; +export const sheetConditional = { + command: { + manage: 'Conditional formatting rules', + manageKeywords: 'conditional format rules color scale data bar icon set highlight', + }, + icon: { + arrowDown: 'Down arrow', + arrowSide: 'Sideways arrow', + arrowUp: 'Up arrow', + arrowDownRight: 'Down and right arrow', + arrowUpRight: 'Up and right arrow', + triangleDown: 'Down triangle', + triangleFlat: 'Flat bar', + triangleUp: 'Up triangle', + lightRed: 'Red light', + lightYellow: 'Yellow light', + lightGreen: 'Green light', + signRed: 'Red diamond', + signYellow: 'Yellow triangle', + signGreen: 'Green circle', + symbolCross: 'Cross', + symbolWarning: 'Exclamation mark', + symbolCheck: 'Check mark', + flagRed: 'Red flag', + flagYellow: 'Yellow flag', + flagGreen: 'Green flag', + star: 'Star rating {level} of {count}', + rating: 'Rating {level} of {count}', + quarter: 'Quarter fill {level} of {count}', + box: 'Box fill {level} of {count}', + }, + manager: { + title: 'Conditional formatting rules', + scopeLabel: 'Show rules for', + scopeSelection: 'Current selection', + scopeTable: 'This table', + scopePage: 'This page', + empty: 'There are no conditional formatting rules here.', + list: 'Rules, in priority order', + moveUp: 'Raise priority', + moveDown: 'Lower priority', + remove: 'Delete rule', + stop: 'Stop if true', + stopHint: 'When this rule matches, rules below it do not apply to that cell.', + appliesTo: 'Applies to {ranges}', + tableName: 'Table {number}', + wholeColumn: 'column {name}', + wholeRow: 'row {number}', + cells: 'cells {from} to {to}', + limit: 'This table already has 256 conditional formatting rules, the most a table can hold.', + problem: 'This formula cannot be read, so the rule does nothing.', + done: 'Done', + newRule: 'New rule', + ruleType: 'Rule type', + }, + type: { + cell: 'Cell value {op} "{a}"', + cellBetween: 'Cell value {op} "{a}" and "{b}"', + text: 'Text {op} "{text}"', + date: 'Dates {period}', + blank: 'Blank cells', + noBlank: 'Cells that are not blank', + error: 'Cells with errors', + noError: 'Cells without errors', + duplicate: 'Duplicate values', + unique: 'Unique values', + top: 'Top {count} items', + topPercent: 'Top {count} percent', + bottom: 'Bottom {count} items', + bottomPercent: 'Bottom {count} percent', + above: 'Above average', + below: 'Below average', + formula: 'Formula {a}', + scale: 'Color scale', + bar: 'Data bar', + icons: 'Icon set {set}', + unknown: 'A rule from a newer version', + }, + op: { + between: 'is between', + notBetween: 'is not between', + equal: 'equals', + notEqual: 'does not equal', + greater: 'is greater than', + less: 'is less than', + greaterOrEqual: 'is at least', + lessOrEqual: 'is at most', + contains: 'contains', + notContains: 'does not contain', + begins: 'begins with', + ends: 'ends with', + }, + period: { + yesterday: 'yesterday', + today: 'today', + tomorrow: 'tomorrow', + last7Days: 'in the last 7 days', + lastWeek: 'last week', + thisWeek: 'this week', + nextWeek: 'next week', + lastMonth: 'last month', + thisMonth: 'this month', + nextMonth: 'next month', + }, +} as const; diff --git a/app/src/strings/en/sheetKeys.ts b/app/src/strings/en/sheetKeys.ts index 712d9affe..1233bc9a5 100644 --- a/app/src/strings/en/sheetKeys.ts +++ b/app/src/strings/en/sheetKeys.ts @@ -1,4 +1,114 @@ // The spreadsheet keys of a table: Grid mode, Edit mode, and their announcements. // Owner: tables:keys (B10). shared:x-shared registered it in the string index, and the owner fills it in. +// Every interface string is a full sentence here, never joined from pieces (ARCHITECTURE.md section 19). -export const sheetKeys = {} as const; +export const sheetKeys = { + commands: { + extendUp: 'Extend the selection up', + extendDown: 'Extend the selection down', + extendLeft: 'Extend the selection left', + extendRight: 'Extend the selection right', + jumpUp: 'Go to the top edge of the data', + jumpDown: 'Go to the bottom edge of the data', + jumpLeft: 'Go to the left edge of the data', + jumpRight: 'Go to the right edge of the data', + extendJumpUp: 'Extend the selection to the top edge of the data', + extendJumpDown: 'Extend the selection to the bottom edge of the data', + extendJumpLeft: 'Extend the selection to the left edge of the data', + extendJumpRight: 'Extend the selection to the right edge of the data', + firstCell: 'Go to the first cell', + lastCell: 'Go to the last used cell', + extendFirstCell: 'Extend the selection to the first cell', + extendLastCell: 'Extend the selection to the last used cell', + extendRowStart: 'Extend the selection to the start of the row', + screenLeft: 'Move one screen left', + screenRight: 'Move one screen right', + moveUp: 'Move up a cell', + fillEntry: 'Fill the selected cells with the active cell', + edit: 'Edit the active cell', + clear: 'Clear the selected cells', + clearAndEdit: 'Clear the active cell and edit it', + selectColumn: 'Select the column', + selectRegion: 'Select the data around the cell, then the table', + fillDown: 'Fill down', + fillRight: 'Fill right', + insertDate: 'Enter today’s date', + insertTime: 'Enter the current time', + copyFormulaAbove: 'Copy the formula from the cell above', + copyValueAbove: 'Copy the value from the cell above', + commitUp: 'Finish the entry and move up', + commitStay: 'Finish the entry and stay in the cell', + lineBreak: 'Start a new line in the cell', + toggleEnterMode: 'Switch between Enter and Edit modes', + documentKeys: 'Use document keys in this table', + }, + keywords: 'spreadsheet keys grid cell edit enter mode excel sheets', + announce: { + cell: '{address}, {header}, {value}', + cellFormula: '{address}, {header}, {value}, formula {formula}', + cellNoHeader: '{address}, {value}', + cellNoHeaderFormula: '{address}, {value}, formula {formula}', + headerCell: 'Column {address} header, {value}', + blank: 'blank', + range: '{from} through {to} selected, {count} cells', + editing: 'Editing {address}.', + enterMode: 'Enter mode. Arrow keys finish the entry and move.', + editMode: 'Edit mode. Arrow keys move in the text.', + canceled: 'Entry canceled.', + filled: 'Filled {count} cells.', + cleared: 'Cleared {count} cells.', + documentKeysOn: 'This table now uses document keys. Enter starts a new line.', + documentKeysOff: 'This table now uses spreadsheet keys. Enter finishes the entry and moves down.', + }, + // How a formula is read aloud: "=B4*2" is "equals B4 times 2". + words: { + equals: 'equals', + plus: 'plus', + minus: 'minus', + times: 'times', + dividedBy: 'divided by', + power: 'to the power of', + join: 'joined with', + percent: 'percent', + through: 'through', + notEqual: 'is not equal to', + lessOrEqual: 'is less than or equal to', + greaterOrEqual: 'is greater than or equal to', + less: 'is less than', + greater: 'is greater than', + }, + fixed: { + title: 'Table keys', + description: 'In a table that uses spreadsheet keys. These keys can’t be changed.', + gridArrows: 'Grid mode: move the active cell', + gridEnter: 'Grid mode: move down or up, and inside a selection, go through its cells', + gridTab: 'Grid mode and Edit mode: move right or left, finishing an entry', + gridHome: 'Grid mode: go to the start of the row', + gridPage: 'Grid mode: move one screen up or down', + gridEscape: + 'Grid mode: select only the active cell, then select the table as an object. Arrows then leave the table.', + gridSpace: 'Grid mode: select the row', + gridType: 'Grid mode: replace the cell with what you type', + typeKey: 'Any letter, digit, or sign', + editEnter: 'Edit mode: finish the entry and move down', + editEscape: 'Edit mode: cancel the entry, put the cell’s text back, and go back to Grid mode', + editArrows: 'Enter mode: finish the entry and move. Edit mode: move in the text, or leave the table at its edge.', + }, + // Shown once, when an older table with numbers or formulas first uses spreadsheet keys. + tip: { + switched: + 'This table holds numbers, so it uses spreadsheet keys. Enter moves down, and typing replaces the cell. ' + + 'To get the old keys back, use the command “Use document keys in this table.”', + useDocument: 'Use document keys', + }, + // Settings, then Tables. + settings: { + title: 'Tables', + newTableKeys: 'New tables use spreadsheet keys', + spreadsheet: 'Spreadsheet keys', + spreadsheetHelp: 'Enter finishes the entry and moves down. Arrows move between cells.', + document: 'Document keys', + documentHelp: 'Enter starts a new line. Arrows move in the text.', + help: 'Each table keeps the keys it was made with. The command “Use document keys in this table” switches one table.', + }, +} as const; diff --git a/app/src/strings/en/sheetToolbar.ts b/app/src/strings/en/sheetToolbar.ts index 4bfaff735..9bf31f5c9 100644 --- a/app/src/strings/en/sheetToolbar.ts +++ b/app/src/strings/en/sheetToolbar.ts @@ -1,4 +1,168 @@ // The table bar: fill, borders, alignment, wrap, merge, number formats, and looks. // Owner: tables:toolbar (B10). shared:x-shared registered it in the string index, and the owner fills it in. -export const sheetToolbar = {} as const; +export const sheetToolbar = { + label: 'Cell format', + more: 'More cell formats', + family: 'Font', + fontSize: 'Font size', + fontSizeUp: 'Increase font size', + fontSizeDown: 'Decrease font size', + bold: 'Bold', + italic: 'Italic', + underline: 'Underline', + strike: 'Strikethrough', + textColor: 'Text color', + fill: 'Fill color', + borders: 'Borders', + alignLeft: 'Align left', + alignCenter: 'Align center', + alignRight: 'Align right', + alignJustify: 'Justify', + alignTop: 'Align top', + alignMiddle: 'Align middle', + alignBottom: 'Align bottom', + wrap: 'Wrap text', + wrapMenu: 'Text wrapping', + wrapWrap: 'Wrap text', + wrapOverflow: 'Overflow into empty cells', + wrapClip: 'Clip text', + rotate: 'Text rotation', + rotateNone: 'Horizontal text', + rotateUp: 'Rotate up 45 degrees', + rotateDown: 'Rotate down 45 degrees', + rotateVertical: 'Vertical text', + rotateStacked: 'Stacked text', + numberFormat: 'Number format', + currency: 'Currency', + percent: 'Percent', + thousands: 'Thousands separator', + moreDecimals: 'More decimals', + fewerDecimals: 'Fewer decimals', + clearFormats: 'Clear formats', + /** The fill and text color popovers. */ + fillPalette: 'Fill colors', + textPalette: 'Text colors', + /** The borders menu. */ + borderAll: 'All borders', + borderOutside: 'Outside borders', + borderThickOutside: 'Thick outside borders', + borderTop: 'Top border', + borderBottom: 'Bottom border', + borderLeft: 'Left border', + borderRight: 'Right border', + borderInsideH: 'Inside horizontal borders', + borderInsideV: 'Inside vertical borders', + borderNone: 'No border', + borderBottomDouble: 'Bottom double border', + borderBottomThick: 'Thick bottom border', + borderTopBottom: 'Top and bottom border', + borderTopThickBottom: 'Top and thick bottom border', + borderDiagonalUp: 'Diagonal up border', + borderDiagonalDown: 'Diagonal down border', + borderStyle: 'Line style', + borderColor: 'Line color', + /** Line styles of the borders menu. */ + styleThin: 'Thin', + styleMedium: 'Medium', + styleThick: 'Thick', + styleHair: 'Hairline', + styleDotted: 'Dotted', + styleDashed: 'Dashed', + styleDouble: 'Double', + /** Number formats of the menu. */ + fmtGeneral: 'General', + fmtNumber: 'Number', + fmtCurrency: 'Currency', + fmtAccounting: 'Accounting', + fmtShortDate: 'Short date', + fmtLongDate: 'Long date', + fmtTime: 'Time', + fmtPercent: 'Percent', + fmtFraction: 'Fraction', + fmtScientific: 'Scientific', + fmtText: 'Text', + fmtCustom: 'Custom', + /** The underline kinds menu. */ + underlineMenu: 'Underline style', + underlineNone: 'No underline', + underlineSingle: 'Single underline', + underlineDouble: 'Double underline', + underlineSingleAccounting: 'Single accounting underline', + underlineDoubleAccounting: 'Double accounting underline', + /** What the command palette and the context menu call the commands. */ + commands: { + fill: 'Cell: Fill color', + textColor: 'Cell: Text color', + borders: 'Cell: Borders', + borderAll: 'Cell: All borders', + borderOutside: 'Cell: Outside borders', + borderNone: 'Cell: No border', + wrap: 'Cell: Wrap text', + alignLeft: 'Cell: Align left', + alignCenter: 'Cell: Align center', + alignRight: 'Cell: Align right', + alignTop: 'Cell: Align top', + alignMiddle: 'Cell: Align middle', + alignBottom: 'Cell: Align bottom', + currency: 'Cell: Currency format', + percent: 'Cell: Percent format', + general: 'Cell: General format', + thousands: 'Cell: Thousands separator', + moreDecimals: 'Cell: More decimals', + fewerDecimals: 'Cell: Fewer decimals', + clearFormats: 'Cell: Clear formats', + conditionalRules: 'Cell: Conditional formatting rules', + borderThickOutside: 'Cell: Thick outside borders', + borderTop: 'Cell: Top border', + borderBottom: 'Cell: Bottom border', + borderLeft: 'Cell: Left border', + borderRight: 'Cell: Right border', + borderInsideH: 'Cell: Inside horizontal borders', + borderInsideV: 'Cell: Inside vertical borders', + borderBottomDouble: 'Cell: Bottom double border', + borderBottomThick: 'Cell: Thick bottom border', + borderTopBottom: 'Cell: Top and bottom border', + borderTopThickBottom: 'Cell: Top and thick bottom border', + borderDiagonalUp: 'Cell: Diagonal up border', + borderDiagonalDown: 'Cell: Diagonal down border', + lineStyleThin: 'Cell: Border line thin', + lineStyleMedium: 'Cell: Border line medium', + lineStyleThick: 'Cell: Border line thick', + lineStyleHair: 'Cell: Border line hairline', + lineStyleDotted: 'Cell: Border line dotted', + lineStyleDashed: 'Cell: Border line dashed', + lineStyleDouble: 'Cell: Border line double', + borderColor: 'Cell: Border line color', + rotateNone: 'Cell: Horizontal text', + rotateUp: 'Cell: Rotate text up', + rotateDown: 'Cell: Rotate text down', + rotateVertical: 'Cell: Vertical text', + rotateStacked: 'Cell: Stacked text', + wrapOverflow: 'Cell: Overflow into empty cells', + wrapClip: 'Cell: Clip text', + alignJustify: 'Cell: Justify', + formatNumber: 'Cell: Number format', + formatAccounting: 'Cell: Accounting format', + formatShortDate: 'Cell: Short date format', + formatLongDate: 'Cell: Long date format', + formatTime: 'Cell: Time format', + formatFraction: 'Cell: Fraction format', + formatScientific: 'Cell: Scientific format', + formatText: 'Cell: Text format', + underlineNone: 'Cell: No underline', + underlineSingle: 'Cell: Single underline', + underlineDouble: 'Cell: Double underline', + underlineSingleAccounting: 'Cell: Single accounting underline', + underlineDoubleAccounting: 'Cell: Double accounting underline', + keywords: 'cell format style color border number decimal align table sheet', + }, + announce: { + fill: 'Fill {color}.', + fillNone: 'No fill.', + format: 'Cell format {format}.', + cleared: 'Formats cleared.', + borders: '{name} applied.', + tooMany: 'This table has too many different formats. Clear some formats first.', + }, +} as const; diff --git a/app/src/strings/fnKey.ts b/app/src/strings/fnKey.ts new file mode 100644 index 000000000..ca856ee0c --- /dev/null +++ b/app/src/strings/fnKey.ts @@ -0,0 +1,7 @@ +// The string key of a spreadsheet function. A dot in a name (ERROR.TYPE, STDEV.S) would split a dotted message key, +// so the fn* description files store it with a low line (ERROR_TYPE). + +/** The key a function's description is stored under in the fn* string files. */ +export function fnKey(name: string): string { + return name.replace(/\./g, '_'); +} diff --git a/app/src/strings/t.ts b/app/src/strings/t.ts index f046b5a8e..5b32bbe6f 100644 --- a/app/src/strings/t.ts +++ b/app/src/strings/t.ts @@ -13,7 +13,13 @@ export type DottedKeys = { [K in keyof T & string]: T[K] extends string ? `${Prefix}${K}` : DottedKeys; }[keyof T & string]; -export type MessageKey = DottedKeys; +/** + * The function description namespaces (fnMath, fnText, ...) are read through `fnKey` in the formula help, never + * through t(). Keeping them out of MessageKey holds the key union under TypeScript's size limit. + */ +type FunctionNamespace = `fn${string}`; + +export type MessageKey = DottedKeys>; /** The message text at a key, as a string literal type. */ export type MessageAt = K extends `${infer Head}.${infer Rest}` diff --git a/crates/interop/src/doc.rs b/crates/interop/src/doc.rs index c0998a9b6..8d6c07f02 100644 --- a/crates/interop/src/doc.rs +++ b/crates/interop/src/doc.rs @@ -270,6 +270,11 @@ pub enum Block { /// The sheet style of each cell, by row and column like `rows`, from the table's `data.sheet`. Empty when /// the table has no sheet styles, and a row may be shorter than its cells. cell_styles: Vec>>, + /// The merged ranges from the table's `data.sheet`. Empty without merges. A merge's anchor holds the content + /// and the other cells it covers are not drawn. + merges: Vec, + /// The column widths in whole page units (1/96 inch), from the table block. Empty when the source has none. + widths: Vec, }, } @@ -280,6 +285,8 @@ impl Block { header, rows, cell_styles: Vec::new(), + merges: Vec::new(), + widths: Vec::new(), } } diff --git a/crates/interop/src/doc/tests.rs b/crates/interop/src/doc/tests.rs index 12afb9d77..333e8ab75 100644 --- a/crates/interop/src/doc/tests.rs +++ b/crates/interop/src/doc/tests.rs @@ -437,6 +437,8 @@ fn a_table_cell_holds_its_sheet_style() { header: true, rows, cell_styles: vec![vec![None, Some(style.clone())]], + merges: Vec::new(), + widths: Vec::new(), }; assert_eq!(styled.cell_style(0, 1), Some(&style)); assert_eq!(styled.cell_style(0, 0), None); diff --git a/crates/interop/src/docx/body.rs b/crates/interop/src/docx/body.rs index f7a50dd5b..7dbcfe2a2 100644 --- a/crates/interop/src/docx/body.rs +++ b/crates/interop/src/docx/body.rs @@ -9,8 +9,6 @@ use crate::doc::data::{Element, Para}; use crate::doc::{push_text, Block, Inline, Item, Marks}; const RULE: &str = ""; -/// The width of the text area of a Letter page, in twentieths of a point. -const TEXT_WIDTH: usize = 9_360; /// How far each list level moves in, in twentieths of a point. const LEVEL_INDENT: u32 = 720; @@ -78,7 +76,13 @@ impl State<'_> { } => self.callout(kind, title, blocks, frame), Block::Code { text, .. } => self.code(text), Block::Break => format!("{RULE}"), - Block::Table { header, rows, .. } => self.table(*header, rows), + Block::Table { + header, + rows, + cell_styles, + merges, + widths, + } => self.table(*header, rows, cell_styles, merges, widths), } } @@ -238,40 +242,6 @@ impl State<'_> { } out } - - fn table(&mut self, header: bool, rows: &[Vec>]) -> String { - let columns = rows.iter().map(Vec::len).max().unwrap_or(1).max(1); - let width = TEXT_WIDTH / columns; - let border = |side: &str| format!(""); - let borders: String = ["top", "left", "bottom", "right", "insideH", "insideV"] - .iter() - .map(|s| border(s)) - .collect(); - let mut out = format!( - "{borders}\ - " - ); - out.push_str(&format!("").repeat(columns)); - out.push_str(""); - for (r, row) in rows.iter().enumerate() { - let head = header && r == 0; - out.push_str(if head { - "" - } else { - "" - }); - for c in 0..columns { - let cell = row.get(c).map_or(&[][..], Vec::as_slice); - let paragraph = format!("{}", self.runs(cell, head)); - out.push_str(&format!( - "{paragraph}" - )); - } - out.push_str(""); - } - out.push_str(""); - out - } } /// The first letter of a paragraph and the rest, for a drop cap. `None` when it starts with no text. diff --git a/crates/interop/src/docx/mod.rs b/crates/interop/src/docx/mod.rs index d8ddb1b8c..d3f67f743 100644 --- a/crates/interop/src/docx/mod.rs +++ b/crates/interop/src/docx/mod.rs @@ -12,6 +12,7 @@ mod rpr; mod runs; mod section; mod styles; +mod table; mod units; pub(crate) mod zip; diff --git a/crates/interop/src/docx/table.rs b/crates/interop/src/docx/table.rs new file mode 100644 index 000000000..831311275 --- /dev/null +++ b/crates/interop/src/docx/table.rs @@ -0,0 +1,259 @@ +//! A table as WordprocessingML: the grid, merged cells (`w:gridSpan` and `w:vMerge`), and the look the table's +//! sheet gives each cell: shading (`w:shd`), borders (`w:tcBorders`), vertical alignment (`w:vAlign`), margins +//! (`w:tcMar`), alignment of the paragraph, and the font of the runs. + +use super::runs::State; +use super::units::{eighths, page_twips, twips}; +use crate::doc::cell_style::{CellBorder, CellStyle, Fill}; +use crate::doc::{Inline, Marks}; +use crate::palette::color_hex; +use crate::sheet::{Merge, MergeIndex, Slot}; + +/// The width of the text area of a Letter page, in twentieths of a point. +pub(super) const TEXT_WIDTH: usize = 9_360; + +/// A color as six hexadecimal digits in capitals. +fn hex(value: &str) -> Option { + color_hex(value).map(|h| h.trim_start_matches('#').to_uppercase()) +} + +/// The `w:val`, `w:sz` in eighths of a point, and the color of a Word border for a border of the sheet. +fn word_border(side: &CellBorder) -> (&'static str, i64) { + match side.style.as_str() { + "none" => ("nil", 0), + "hair" => ("single", 2), + "dotted" => ("dotted", 4), + "dashed" => ("dashed", 4), + "dashDot" => ("dotDash", 4), + "dashDotDot" => ("dotDotDash", 4), + "medium" => ("single", 12), + "mediumDashed" => ("dashed", 12), + "mediumDashDot" => ("dotDash", 12), + "mediumDashDotDot" => ("dotDotDash", 12), + "slantDashDot" => ("dashDotStroked", 12), + "thick" => ("single", 18), + "double" => ("double", 6), + _ => ("single", 4), + } +} + +fn border_xml(tag: &str, side: &Option) -> String { + let Some(side) = side else { return String::new() }; + let (val, size) = word_border(side); + if val == "nil" { + return format!(""); + } + let size = side.width.map_or(size, |w| eighths(w).clamp(2, 96)); + let paint = side.color.as_deref().and_then(hex).unwrap_or_else(|| "auto".to_owned()); + format!("") +} + +/// The shading color of a fill, when it has one. +fn shade(fill: &Fill) -> Option { + match fill { + Fill::Color(c) => hex(c), + Fill::Pattern { back, color, .. } => back.as_deref().and_then(hex).or_else(|| color.as_deref().and_then(hex)), + Fill::Gradient { stops, .. } => stops.first().and_then(|(_, c)| hex(c)), + Fill::None => None, + } +} + +/// The cell's `w:tcPr` children after the width, grid span, and vertical merge, in schema order. +fn cell_props(style: &CellStyle) -> String { + let mut out = String::new(); + let b = &style.border; + let sides = [ + border_xml("top", &b.top), + border_xml("left", &b.left), + border_xml("bottom", &b.bottom), + border_xml("right", &b.right), + border_xml("tl2br", &b.diagonal_down), + border_xml("tr2bl", &b.diagonal_up), + ] + .concat(); + if !sides.is_empty() { + out.push_str(&format!("{sides}")); + } + if let Some(fill) = style.fill.as_ref().and_then(shade) { + out.push_str(&format!("")); + } + if matches!(style.align.wrap.as_deref(), Some("clip" | "overflow")) { + out.push_str(""); + } + let pad = [style.pad.top, style.pad.left, style.pad.bottom, style.pad.right]; + if pad.iter().any(Option::is_some) { + let side = |tag: &str, v: Option, default: f64| { + format!("", twips(v.unwrap_or(default))) + }; + out.push_str(&format!( + "{}{}{}{}", + side("top", style.pad.top, 2.0), + side("left", style.pad.left, 4.0), + side("bottom", style.pad.bottom, 2.0), + side("right", style.pad.right, 4.0) + )); + } + match style.align.vertical.as_deref() { + Some("middle") => out.push_str(""), + Some("bottom") => out.push_str(""), + _ => {} + } + out +} + +/// The paragraph properties of a cell: alignment and indent. +fn para_props(style: &CellStyle) -> String { + let indent = style.align.indent.filter(|n| *n > 0).map_or(String::new(), |n| { + format!("", twips(f64::from(n) * 9.0)) + }); + let jc = match style.align.horizontal.as_deref() { + Some("left") => "", + Some("center") => "", + Some("right") => "", + Some("justify") => "", + _ => "", + }; + if indent.is_empty() && jc.is_empty() { + String::new() + } else { + format!("{indent}{jc}") + } +} + +/// The cell's runs with the font of its style under their own marks (`R-SHT-9`: the marks of the runs win). +fn styled(content: &[Inline], style: Option<&CellStyle>) -> Vec { + let Some(font) = style.map(|s| &s.font) else { + return content.to_vec(); + }; + content + .iter() + .map(|inline| match inline { + Inline::Text { text, marks } => { + let mut m: Marks = marks.clone(); + if font.weight.is_some_and(|w| w >= 600) || font.bold == Some(true) { + m.strong = true; + } + if font.italic == Some(true) { + m.emphasis = true; + } + if font.strike.as_deref().is_some_and(|s| s != "none") { + m.strike = true; + } + if font.underline.as_deref().is_some_and(|u| u != "none") { + m.underline = true; + } + if m.color.is_none() { + m.color.clone_from(&font.color); + } + if m.font.is_none() { + m.font = font + .family + .clone() + .filter(|f| !f.starts_with('+') && !f.starts_with("theme:")); + } + if m.size.is_none() { + m.size = font.size.map(|s| format!("{s}")); + } + Inline::Text { + text: text.clone(), + marks: m, + } + } + other => other.clone(), + }) + .collect() +} + +impl State<'_> { + /// A table. Without styles, merges, and widths it is the plain table it always was. + pub(super) fn table( + &mut self, + header: bool, + rows: &[Vec>], + styles: &[Vec>], + merges: &[Merge], + widths: &[u32], + ) -> String { + let columns = rows.iter().map(Vec::len).max().unwrap_or(1).max(1); + let even = TEXT_WIDTH / columns; + let mut grid: Vec = (0..columns) + .map(|c| { + widths + .get(c) + .map_or(even, |w| usize::try_from(page_twips(f64::from(*w))).unwrap_or(even)) + }) + .collect(); + let total: usize = grid.iter().sum(); + if total > TEXT_WIDTH { + grid = grid.iter().map(|w| w * TEXT_WIDTH / total).collect(); + } + let border = |side: &str| format!(""); + let borders: String = ["top", "left", "bottom", "right", "insideH", "insideV"] + .iter() + .map(|s| border(s)) + .collect(); + let mut out = format!( + "{borders}\ + " + ); + for w in &grid { + out.push_str(&format!("")); + } + out.push_str(""); + let index = MergeIndex::new(merges, rows.len(), columns); + for (r, row) in rows.iter().enumerate() { + let head = header && r == 0; + out.push_str(if head { + "" + } else { + "" + }); + for c in 0..columns { + let slot = index.slot(r, c); + // A cell below an anchor continues its vertical merge; one beside it is inside the grid span. + if let Some(Slot::Covered(m)) = slot { + if c != m.left { + continue; + } + let span = span_of(&m, &grid); + out.push_str(&format!( + "{}", + span.0, span.1 + )); + continue; + } + let anchor = match slot { + Some(Slot::Anchor(m)) => Some(m), + _ => None, + }; + let (width, grid_span) = anchor.map_or((grid[c], String::new()), |m| span_of(&m, &grid)); + let restart = anchor + .filter(|m| m.bottom > m.top) + .map_or("", |_| ""); + let style = styles.get(r).and_then(|row| row.get(c)).and_then(Option::as_ref); + let props = style.map(cell_props).unwrap_or_default(); + let para = style.map(para_props).unwrap_or_default(); + let cell = row.get(c).map_or(&[][..], Vec::as_slice); + let runs = self.runs(&styled(cell, style), head); + out.push_str(&format!( + "{grid_span}{restart}{props}\ + {para}{runs}" + )); + } + out.push_str(""); + } + out.push_str(""); + out + } +} + +/// The width of a merge's columns and its `w:gridSpan`, which is empty for a merge one column wide. +fn span_of(m: &Merge, grid: &[usize]) -> (usize, String) { + let width = grid[m.left..=m.right.min(grid.len() - 1)].iter().sum(); + let span = if m.right > m.left { + format!("", m.right - m.left + 1) + } else { + String::new() + }; + (width, span) +} diff --git a/crates/interop/src/docx/tests.rs b/crates/interop/src/docx/tests.rs index 785ed4e9d..a869aa3d9 100644 --- a/crates/interop/src/docx/tests.rs +++ b/crates/interop/src/docx/tests.rs @@ -7,6 +7,7 @@ mod readback; mod roundtrip; mod schema; mod sections; +mod tables; use std::collections::{BTreeMap, HashMap}; diff --git a/crates/interop/src/docx/tests/tables.rs b/crates/interop/src/docx/tests/tables.rs new file mode 100644 index 000000000..a7ec8507f --- /dev/null +++ b/crates/interop/src/docx/tests/tables.rs @@ -0,0 +1,80 @@ +// checks-disable-file brand-consistency: the test data holds color values to check how they are written +//! Tables with a sheet: fills, borders, vertical alignment, and merges. + +use super::*; +use crate::doc::cell_style::{CellAlign, CellBorder, CellBorders, CellStyle, Fill}; +use crate::doc::Inline; +use crate::sheet::Merge; + +fn cell(text: &str) -> Vec { + vec![Inline::text(text)] +} + +fn styled_document(styles: Vec>>, merges: Vec) -> String { + let rows = vec![ + vec![cell("A"), cell("B"), cell("C")], + vec![cell("d"), cell("e"), cell("")], + vec![cell("g"), cell(""), cell("")], + ]; + let table = Block::Table { + header: true, + rows, + cell_styles: styles, + merges, + widths: Vec::new(), + }; + document(vec![Part::Blocks(vec![table])]) +} + +#[test] +fn a_cell_style_writes_shading_borders_and_vertical_alignment() { + let bottom = CellBorder { + style: "double".to_owned(), + color: Some("#112233".to_owned()), + width: None, + }; + let style = CellStyle { + fill: Some(Fill::Color("#ffeecc".to_owned())), + border: CellBorders { + bottom: Some(bottom), + ..CellBorders::default() + }, + align: CellAlign { + vertical: Some("middle".to_owned()), + ..CellAlign::default() + }, + ..CellStyle::default() + }; + let xml = styled_document(vec![vec![], vec![Some(style)]], Vec::new()); + // ECMA-376 17.4.33 (shd), 17.4.66 (tcBorders), and 17.4.84 (vAlign); a double border has w:val "double". + assert!( + xml.contains(""), + "{xml}" + ); + assert!( + xml.contains(""), + "{xml}" + ); + assert!(xml.contains(""), "{xml}"); +} + +#[test] +fn a_merge_writes_a_grid_span_and_a_vertical_merge() { + // B2:C3: the anchor spans two columns and starts a vertical merge; the cell below continues it. + let merge = Merge { + top: 1, + left: 1, + bottom: 2, + right: 2, + }; + let xml = styled_document(Vec::new(), vec![merge]); + assert!( + xml.contains(""), + "{xml}" + ); + assert!(xml.contains(""), "{xml}"); + // Row 2 has the first cell and the anchor; row 3 has the first cell and the continuation. + let rows: Vec<&str> = xml.split("").skip(1).collect(); + assert_eq!(rows[1].matches("").count(), 2, "{xml}"); + assert_eq!(rows[2].matches("").count(), 2, "{xml}"); +} diff --git a/crates/interop/src/export/convert.rs b/crates/interop/src/export/convert.rs index 756dc7bbb..fe56224bf 100644 --- a/crates/interop/src/export/convert.rs +++ b/crates/interop/src/export/convert.rs @@ -50,6 +50,8 @@ struct Tally { /// Blocks of OpenNote's own extra types (flashcards, study tape, live citations), which a file shows as text. own_with_fallback: usize, unknown: Vec, + /// Tables whose formulas show their text because their cached results are out of date. + stale_tables: Vec, } impl Tally { @@ -87,6 +89,12 @@ impl Tally { report.skipped(what, "This format cannot hold ink. Export to PDF to keep it."); } } + for name in &self.stale_tables { + report.simplified( + format!("table {name}"), + "Its formulas show their text, because its results are out of date. Open the page in OpenNote once to refresh them.", + ); + } let missing = ("file that was not found", "files that were not found"); report.skipped_count(self.missing_assets, missing, "Their names stay in the text."); for name in &self.unknown { @@ -319,17 +327,47 @@ impl Convert<'_> { fn table(&mut self, table: &TableData) -> Block { self.tally.tables += 1; + let sheet = crate::sheet::read(table); let rows = table .rows .iter() - .map(|row| { + .enumerate() + .map(|(r, row)| { let cells = table.columns.iter().map(|column| row.cells.get(&column.id)); cells - .map(|cell| self.cell(cell.map_or("", |c| c.markdown.as_str()))) + .enumerate() + .map(|(c, cell)| { + let result = sheet.as_ref().and_then(|s| s.results[r][c].as_deref()); + match result { + Some(text) => vec![Inline::text(text)], + None => self.cell(cell.map_or("", |c| c.markdown.as_str())), + } + }) .collect() }) .collect(); - Block::table(table.header, rows) + let widths = table + .columns + .iter() + .map(|c| c.width.map_or(0, |w| w.round().clamp(0.0, 100_000.0) as u32)) + .collect::>(); + let widths = if widths.contains(&0) { Vec::new() } else { widths }; + match sheet { + Some(sheet) => { + if sheet.stale { + let name = sheet.name.clone().unwrap_or_else(|| self.tally.tables.to_string()); + self.tally.stale_tables.push(name); + } + Block::Table { + header: table.header, + rows, + cell_styles: sheet.styles, + merges: sheet.merges, + widths, + } + } + None => Block::table(table.header, rows), + } } /// The inlines of a table cell: its paragraphs, joined by breaks. @@ -436,3 +474,6 @@ impl Convert<'_> { fn asset_id(dest: &str) -> Option { AssetId::parse(dest.strip_prefix("asset:")?).ok() } + +#[cfg(test)] +mod sheet_tests; diff --git a/crates/interop/src/export/convert/sheet_tests.rs b/crates/interop/src/export/convert/sheet_tests.rs new file mode 100644 index 000000000..f73681161 --- /dev/null +++ b/crates/interop/src/export/convert/sheet_tests.rs @@ -0,0 +1,138 @@ +//! A page with a table that has a sheet, through the conversion to Word: the file holds the cached result of a +//! formula cell when it is fresh, and the formula text with a report line that names the table when it is stale. +//! The expected values are worked out by hand: `=2*3` is 6. + +use super::*; +use crate::docx::{build, read_parts, Part, Setup, WordInput}; +use crate::source::NoteSource; +use crate::testing::{at, sample_notebook, TestEnv}; +use opennote_core::model::{BlockData, TableCell, TableColumn, TableData, TableRow}; +use opennote_core::{ColumnId, RowId}; +use serde_json::{json, Map}; +use std::collections::HashMap; + +struct Nothing; + +impl Resolver for Nothing { + fn asset(&mut self, _page: &Page, _asset: &Asset) -> Option { + None + } + + fn page(&mut self, _from: PageId, _to: PageId) -> Option { + None + } +} + +fn sheet_table(first_cell: &str, fresh: bool) -> TableData { + let columns: Vec = ["01m3sbcmt0000000000000k001", "01m3sbcmt0000000000000k002"] + .iter() + .map(|id| TableColumn { + id: ColumnId::parse(id).unwrap(), + width: None, + extra: Map::new(), + }) + .collect(); + let row = |id: &str, texts: [&str; 2]| TableRow { + id: RowId::parse(id).unwrap(), + cells: columns + .iter() + .zip(texts) + .map(|(c, t)| { + let cell = TableCell { + markdown: t.to_owned(), + extra: Map::new(), + }; + (c.id, cell) + }) + .collect(), + extra: Map::new(), + }; + let second = "01m3sbcmt0000000000000r002"; + let mut table = TableData { + header: true, + rows: vec![ + row("01m3sbcmt0000000000000r001", ["Item", "Total"]), + row(second, ["x", first_cell]), + ], + columns, + extra: Map::new(), + }; + let of = if fresh { + crate::sheet::fingerprint(&table) + } else { + "fnv1a:0000000000000000".to_owned() + }; + let column = table.columns[1].id.to_string(); + let sheet = json!({ + "values": {second: {column: 6}}, + "valuesOf": of, + "table": {"name": "Totals"} + }); + table.extra.insert("sheet".to_owned(), sheet); + table +} + +/// Converts a page whose only table is `table`, and returns the Word body and the report lines. +fn word_of(table: TableData) -> (String, Vec) { + let world = TestEnv::new(); + let env = world.env(); + let sample = sample_notebook(&env); + let mut page = sample.source.page(sample.photo).expect("the page"); + let block = page + .blocks + .iter() + .find(|b| matches!(b.data, BlockData::Table(_))) + .cloned() + .expect("a table block"); + let mut changed = (*block).clone(); + changed.data = BlockData::Table(table); + page.blocks.replace(std::sync::Arc::new(changed)).expect("replaced"); + let mut report = PageReport::default(); + let document = page_to_document(&page, &mut Nothing, &mut report); + let input = WordInput { + title: "Test".to_owned(), + created: at("2026-01-05T09:30:00Z"), + modified: at("2026-02-10T18:45:00Z"), + parts: vec![Part::Blocks(document.blocks)], + media: HashMap::new(), + setup: Setup::default(), + }; + let parts = read_parts(&build(&input).expect("the file builds")); + let xml = parts + .iter() + .find(|(name, _)| name == "word/document.xml") + .map(|(_, bytes)| String::from_utf8_lossy(bytes).into_owned()) + .expect("a document"); + let lines = report + .entries + .iter() + .map(|e| format!("{} {}", e.what, e.why.clone().unwrap_or_default())) + .collect(); + (xml, lines) +} + +#[test] +fn a_fresh_formula_cell_is_its_cached_result_in_the_word_file() { + let (xml, lines) = word_of(sheet_table("=2*3", true)); + assert!(xml.contains("6"), "{xml}"); + assert!(!xml.contains("=2*3"), "{xml}"); + assert!(!lines.iter().any(|l| l.contains("table Totals")), "{lines:?}"); +} + +#[test] +fn a_stale_formula_cell_keeps_its_text_and_the_report_names_the_table() { + let (xml, lines) = word_of(sheet_table("=2*3", false)); + assert!(xml.contains("=2*3"), "{xml}"); + assert!(!xml.contains("6"), "{xml}"); + assert!(lines.iter().any(|l| l.starts_with("table Totals ")), "{lines:?}"); +} + +#[test] +fn a_bold_formula_cell_is_a_formula_by_its_readable_text() { + let (xml, lines) = word_of(sheet_table("**=2*3**", true)); + assert!(xml.contains("6"), "{xml}"); + assert!(!lines.iter().any(|l| l.contains("table Totals")), "{lines:?}"); + let (stale, lines) = word_of(sheet_table("**=2*3**", false)); + assert!(stale.contains("=2*3"), "{stale}"); + assert!(lines.iter().any(|l| l.starts_with("table Totals ")), "{lines:?}"); +} diff --git a/crates/interop/src/html.rs b/crates/interop/src/html.rs index b7db9450e..510c4979f 100644 --- a/crates/interop/src/html.rs +++ b/crates/interop/src/html.rs @@ -152,7 +152,13 @@ fn block_html(block: &Block) -> String { format!("
{}
\n", escape(text)) } Block::Break => "
\n".to_owned(), - Block::Table { header, rows, .. } => table_html(*header, rows), + Block::Table { + header, + rows, + cell_styles, + merges, + widths, + } => crate::html_table::table_html(*header, rows, cell_styles, merges, widths), } } @@ -203,20 +209,6 @@ fn callout_html(kind: &str, fold: Option, title: &[Inline], blocks: &[Bloc } } -fn table_html(header: bool, rows: &[Vec>]) -> String { - let mut out = String::from("\n"); - for (i, row) in rows.iter().enumerate() { - let tag = if header && i == 0 { "th" } else { "td" }; - out.push_str(""); - for cell in row { - out.push_str(&format!("<{tag}>{}", inlines_html(cell))); - } - out.push_str("\n"); - } - out.push_str("
\n"); - out -} - /// Writes inlines as HTML. Each run carries its own tags, so runs with different marks never overlap. pub fn inlines_html(inlines: &[Inline]) -> String { let mut out = String::new(); diff --git a/crates/interop/src/html_table.rs b/crates/interop/src/html_table.rs new file mode 100644 index 000000000..74c3eb39c --- /dev/null +++ b/crates/interop/src/html_table.rs @@ -0,0 +1,215 @@ +//! A table of the document tree as HTML. Cells carry the look the table's sheet gives them (fill, borders, +//! alignment, padding, and font), merged cells carry `colspan` and `rowspan`, and the cells a merge covers are left +//! out. A table without sheet data is written as it always was. + +use crate::doc::cell_style::{CellBorder, CellFont, CellStyle, Fill}; +use crate::doc::Inline; +use crate::html::{escape, inlines_html}; +use crate::palette::color_hex; +use crate::sheet::{Merge, MergeIndex, Slot}; + +/// A color value as `#rrggbb`, or `None` outside the grammar. +fn color(value: &str) -> Option { + color_hex(value).map(|hex| format!("#{}", hex.trim_start_matches('#').to_lowercase())) +} + +fn number(n: f64) -> String { + let text = format!("{:.2}", (n * 100.0).round() / 100.0); + text.trim_end_matches('0').trim_end_matches('.').to_owned() +} + +/// The CSS width, line, and name of one of the 13 border styles. +fn line(style: &str) -> (f64, &'static str) { + match style { + "dotted" => (1.0, "dotted"), + "dashed" | "dashDot" | "dashDotDot" => (1.0, "dashed"), + "mediumDashed" | "mediumDashDot" | "mediumDashDotDot" | "slantDashDot" => (2.0, "dashed"), + "medium" => (2.0, "solid"), + "thick" => (3.0, "solid"), + "double" => (3.0, "double"), + _ => (1.0, "solid"), + } +} + +fn border(name: &str, side: &Option, css: &mut Vec) { + let Some(side) = side else { return }; + if side.style == "none" { + css.push(format!("border-{name}:none")); + return; + } + let (width, kind) = line(&side.style); + let size = side + .width + .map_or_else(|| format!("{}px", number(width)), |w| format!("{}pt", number(w))); + let paint = side + .color + .as_deref() + .and_then(color) + .unwrap_or_else(|| "currentcolor".to_owned()); + css.push(format!("border-{name}:{size} {kind} {paint}")); +} + +/// The CSS of a cell's font: family, size, weight, slant, lines, color, and script. +fn font_css(font: &CellFont, css: &mut Vec) { + if let Some(family) = font + .family + .as_deref() + .filter(|f| !f.starts_with('+') && !f.starts_with("theme:")) + { + // Only letters, digits, spaces, and a few marks stay, so no escape, function, or quote reaches the style. + let clean: String = family + .chars() + .filter(|c| c.is_alphanumeric() || " ,._-".contains(*c)) + .collect(); + css.push(format!("font-family:{clean}")); + } + if let Some(size) = font.size { + css.push(format!("font-size:{}pt", number(size))); + } + match (font.weight, font.bold) { + (Some(weight), _) => css.push(format!("font-weight:{weight}")), + (None, Some(bold)) => css.push(format!("font-weight:{}", if bold { 700 } else { 400 })), + _ => {} + } + if let Some(italic) = font.italic { + css.push(format!("font-style:{}", if italic { "italic" } else { "normal" })); + } + let mut lines = Vec::new(); + if font.underline.as_deref().is_some_and(|u| u != "none") { + lines.push("underline"); + } + if font.strike.as_deref().is_some_and(|s| s != "none") { + lines.push("line-through"); + } + if !lines.is_empty() { + css.push(format!("text-decoration:{}", lines.join(" "))); + } + if let Some(text) = font.color.as_deref().and_then(color) { + css.push(format!("color:{text}")); + } + match font.script.as_deref() { + Some("super") => css.push("vertical-align:super;font-size:smaller".to_owned()), + Some("sub") => css.push("vertical-align:sub;font-size:smaller".to_owned()), + _ => {} + } +} + +/// The CSS of a cell's resolved style, leaving out what the defaults already give. +pub fn cell_css(style: &CellStyle) -> String { + let mut css = Vec::new(); + font_css(&style.font, &mut css); + match &style.fill { + Some(Fill::Color(value)) => { + if let Some(c) = color(value) { + css.push(format!("background-color:{c}")); + } + } + Some(Fill::Pattern { color: front, back, .. }) => { + if let Some(c) = back + .as_deref() + .and_then(color) + .or_else(|| front.as_deref().and_then(color)) + { + css.push(format!("background-color:{c}")); + } + } + Some(Fill::Gradient { angle, stops }) => { + let stops: Vec = stops + .iter() + .filter_map(|(at, c)| Some(format!("{} {}%", color(c)?, number(at * 100.0)))) + .collect(); + if stops.len() >= 2 { + css.push(format!( + "background-image:linear-gradient({angle}deg,{})", + stops.join(",") + )); + } + } + Some(Fill::None) | None => {} + } + border("top", &style.border.top, &mut css); + border("right", &style.border.right, &mut css); + border("bottom", &style.border.bottom, &mut css); + border("left", &style.border.left, &mut css); + let align = &style.align; + if let Some(h @ ("left" | "center" | "right" | "justify")) = align.horizontal.as_deref() { + css.push(format!("text-align:{h}")); + } + if let Some(v @ ("middle" | "bottom")) = align.vertical.as_deref() { + css.push(format!("vertical-align:{v}")); + } + if matches!(align.wrap.as_deref(), Some("clip" | "overflow")) { + css.push("white-space:nowrap".to_owned()); + } + let pad = ( + style.pad.top.unwrap_or(2.0), + style.pad.right.unwrap_or(4.0), + style.pad.bottom.unwrap_or(2.0), + style.pad.left.unwrap_or(4.0) + f64::from(align.indent.unwrap_or(0)) * 9.0, + ); + if pad != (2.0, 4.0, 2.0, 4.0) { + css.push(format!( + "padding:{}pt {}pt {}pt {}pt", + number(pad.0), + number(pad.1), + number(pad.2), + number(pad.3) + )); + } + css.join(";") +} + +/// The table as HTML. `styles`, `merges`, and `widths` may be empty. +pub fn table_html( + header: bool, + rows: &[Vec>], + styles: &[Vec>], + merges: &[Merge], + widths: &[u32], +) -> String { + let mut out = String::from("\n"); + if !widths.is_empty() { + out.push_str(""); + for width in widths { + out.push_str(&format!("")); + } + out.push_str("\n"); + } + let index = MergeIndex::new(merges, rows.len(), rows.iter().map(Vec::len).max().unwrap_or(0)); + for (r, row) in rows.iter().enumerate() { + let head = header && r == 0; + let tag = if head { "th" } else { "td" }; + out.push_str(""); + for (c, cell) in row.iter().enumerate() { + let slot = index.slot(r, c); + if matches!(slot, Some(Slot::Covered(_))) { + continue; + } + let mut attrs = String::new(); + if let Some(Slot::Anchor(m)) = slot { + if m.right > m.left { + attrs.push_str(&format!(" colspan=\"{}\"", m.right - m.left + 1)); + } + if m.bottom > m.top { + attrs.push_str(&format!(" rowspan=\"{}\"", m.bottom - m.top + 1)); + } + } + let css = styles + .get(r) + .and_then(|row| row.get(c)) + .and_then(Option::as_ref) + .map(cell_css) + .unwrap_or_default(); + if !css.is_empty() { + attrs.push_str(&format!(" style=\"{}\"", escape(&css))); + } + out.push_str(&format!("<{tag}{attrs}>{}", inlines_html(cell))); + } + out.push_str("\n"); + } + out.push_str("
\n"); + out +} + +#[cfg(test)] +mod tests; diff --git a/crates/interop/src/html_table/tests.rs b/crates/interop/src/html_table/tests.rs new file mode 100644 index 000000000..fdfe81a6d --- /dev/null +++ b/crates/interop/src/html_table/tests.rs @@ -0,0 +1,87 @@ +// checks-disable-file brand-consistency: the test data holds color values to check how they are written +use super::*; +use crate::doc::cell_style::{CellAlign, CellBorders, Pad}; + +fn cell(text: &str) -> Vec { + vec![Inline::text(text)] +} + +#[test] +fn a_merge_writes_colspan_and_rowspan_on_its_anchor_and_omits_the_covered_cells() { + // B2:C3 of a 3 by 3 table. + let rows = vec![ + vec![cell("A"), cell("B"), cell("C")], + vec![cell("d"), cell("e"), cell("")], + vec![cell("g"), cell(""), cell("")], + ]; + let merge = Merge { + top: 1, + left: 1, + bottom: 2, + right: 2, + }; + let html = table_html(true, &rows, &[], &[merge], &[]); + assert!( + html.contains("de"), + "{html}" + ); + assert!(html.contains("g"), "{html}"); +} + +#[test] +fn a_style_writes_fill_border_alignment_and_padding() { + let bottom = CellBorder { + style: "double".to_owned(), + color: Some("#112233".to_owned()), + width: None, + }; + let style = CellStyle { + fill: Some(Fill::Color("#ffeecc".to_owned())), + border: CellBorders { + bottom: Some(bottom), + ..CellBorders::default() + }, + align: CellAlign { + vertical: Some("middle".to_owned()), + horizontal: Some("center".to_owned()), + ..CellAlign::default() + }, + pad: Pad { + top: Some(3.0), + right: Some(5.0), + bottom: Some(3.0), + left: Some(5.0), + }, + ..CellStyle::default() + }; + let html = table_html(false, &[vec![cell("x")]], &[vec![Some(style)]], &[], &[]); + for want in [ + "background-color:#ffeecc", + "border-bottom:3px double #112233", + "vertical-align:middle", + "text-align:center", + "padding:3pt 5pt 3pt 5pt", + ] { + assert!(html.contains(want), "{want} in {html}"); + } +} + +#[test] +fn a_table_without_a_sheet_is_written_as_it_was() { + let html = table_html(true, &[vec![cell("h")], vec![cell("b")]], &[], &[], &[]); + assert_eq!(html, "\n\n\n
h
b
\n"); +} + +#[test] +fn a_font_family_keeps_only_names_and_separators() { + use crate::doc::cell_style::{CellFont, CellStyle}; + let style = CellStyle { + font: CellFont { + family: Some(r"Segoe UI, \22 x url(//e.example/a) {};".to_owned()), + ..CellFont::default() + }, + ..CellStyle::default() + }; + // No backslash, parenthesis, slash, brace, quote, semicolon, or angle bracket survives. + assert_eq!(super::cell_css(&style), "font-family:Segoe UI, 22 x urle.examplea b"); +} diff --git a/crates/interop/src/lib.rs b/crates/interop/src/lib.rs index 92ab99054..d6392ec29 100644 --- a/crates/interop/src/lib.rs +++ b/crates/interop/src/lib.rs @@ -20,6 +20,7 @@ pub mod error; pub mod export; pub mod frontmatter; pub mod html; +pub mod html_table; pub mod import; pub mod job; pub mod lock; @@ -28,6 +29,7 @@ pub mod page_builder; pub mod palette; pub mod report; pub mod run; +pub mod sheet; pub mod sink; pub mod source; pub mod sqlite; diff --git a/crates/interop/src/sheet/mod.rs b/crates/interop/src/sheet/mod.rs new file mode 100644 index 000000000..923cb8f95 --- /dev/null +++ b/crates/interop/src/sheet/mod.rs @@ -0,0 +1,95 @@ +//! The reader of a table's `data.sheet` for exports (format spec 6.3, ADR 0067). It resolves each cell's style, +//! finds the active merges, and takes the cached results of formula cells when they are fresh. The page-to-document +//! conversion calls it, so the Word and HTML tables draw the cells as the spreadsheet does. + +mod numfmt; +mod read; + +pub use read::{fingerprint, read, MERGE_LIMIT}; + +use crate::doc::cell_style::CellStyle; + +/// A merged range, as zero-based row and column positions of its corners. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Merge { + /// The first row, which holds the anchor. + pub top: usize, + /// The first column, which holds the anchor. + pub left: usize, + /// The last row. + pub bottom: usize, + /// The last column. + pub right: usize, +} + +impl Merge { + /// Whether the cell at `row` and `column` is inside the merge but is not its anchor. + pub fn covers(&self, row: usize, column: usize) -> bool { + row >= self.top + && row <= self.bottom + && column >= self.left + && column <= self.right + && (row, column) != (self.top, self.left) + } +} + +/// What a merge makes of one cell. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Slot { + /// The cell holds the content of the merge. + Anchor(Merge), + /// The cell is inside the merge but is not its anchor. + Covered(Merge), +} + +/// Where each merge sits, built once per table so a writer asks per cell in constant time. The merges do not +/// overlap, so the index holds at most one entry per cell of the grid. +#[derive(Debug, Default)] +pub struct MergeIndex(std::collections::HashMap<(usize, usize), Slot>); + +impl MergeIndex { + /// Indexes `merges`. A merge outside the grid of `rows` by `columns` is cut to it, and a cell a merge already + /// holds keeps its first merge. + pub fn new(merges: &[Merge], rows: usize, columns: usize) -> Self { + let mut map = std::collections::HashMap::new(); + for m in merges { + if m.top >= rows || m.left >= columns { + continue; + } + for r in m.top..=m.bottom.min(rows - 1) { + for c in m.left..=m.right.min(columns - 1) { + let slot = if (r, c) == (m.top, m.left) { + Slot::Anchor(*m) + } else { + Slot::Covered(*m) + }; + map.entry((r, c)).or_insert(slot); + } + } + } + Self(map) + } + + /// What the merges make of the cell at `row` and `column`, if anything. + pub fn slot(&self, row: usize, column: usize) -> Option { + self.0.get(&(row, column)).copied() + } +} + +/// What the sheet adds to a table. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct TableSheet { + /// The resolved style of each cell, by row and column. A cell no layer styles holds `None`. + pub styles: Vec>>, + /// The merges that apply, in ID order. + pub merges: Vec, + /// The text of a formula or spilled cell's cached result, by row and column. `None` keeps the cell as it is. + pub results: Vec>>, + /// Whether the table has formula cells whose cached results are missing or out of date, so they show their text. + pub stale: bool, + /// The table's name, when the sheet gives one. + pub name: Option, +} + +#[cfg(test)] +mod tests; diff --git a/crates/interop/src/sheet/numfmt.rs b/crates/interop/src/sheet/numfmt.rs new file mode 100644 index 000000000..266e3a020 --- /dev/null +++ b/crates/interop/src/sheet/numfmt.rs @@ -0,0 +1,221 @@ +//! The shown text of a number under a number format code: the subset a note export needs, which is plain digit +//! patterns (`0`, `0.00`, `#,##0.00`, with `%` and literal text) and date and time codes. Any other code returns `None`, +//! and the caller writes the General text. The rules follow the format-code grammar of ECMA-376 Part 1, 18.8.31. + +const MONTHS: [&str; 12] = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", +]; +const DAYS: [&str; 7] = [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", +]; + +/// The text of `value` under `code`, or `None` when the code is outside the supported subset. +pub fn shown(value: f64, code: &str) -> Option { + if !value.is_finite() || code.is_empty() || code.eq_ignore_ascii_case("general") { + return None; + } + let sections: Vec<&str> = code.split(';').collect(); + let (section, magnitude, sign) = if sections.len() >= 3 && value == 0.0 { + (sections[2], 0.0, "") + } else if sections.len() >= 2 && value < 0.0 { + (sections[1], -value, "") + } else { + (sections[0], value.abs(), if value < 0.0 { "-" } else { "" }) + }; + let bare = literal_free(§ion.to_ascii_lowercase()); + if bare.contains('0') || bare.contains('#') { + number(magnitude, section, sign) + } else if bare.chars().any(|c| "ymdhs".contains(c)) { + date(value, section) + } else { + None + } +} + +/// The code without quoted text and escaped characters, so literals do not look like tokens. +fn literal_free(code: &str) -> String { + let mut out = String::new(); + let mut chars = code.chars(); + while let Some(c) = chars.next() { + match c { + '"' => { + for q in chars.by_ref() { + if q == '"' { + break; + } + } + } + '\\' | '_' | '*' => { + chars.next(); + } + _ => out.push(c), + } + } + out +} + +fn number(magnitude: f64, section: &str, sign: &str) -> Option { + let (mut head, mut digits, mut tail) = (String::new(), String::new(), String::new()); + let mut percents = 0; + let mut chars = section.chars(); + while let Some(c) = chars.next() { + // Text before the first digit pattern is the head, text after the last one is the tail. + let after = !digits.is_empty() && !tail.is_empty(); + match c { + '0' | '#' | ',' | '.' if after => return None, + '0' | '#' | ',' | '.' => digits.push(c), + _ => { + let text = match c { + '%' => { + percents += 1; + "%".to_owned() + } + '"' => chars.by_ref().take_while(|q| *q != '"').collect(), + '\\' => chars.next()?.to_string(), + '$' | '-' | '+' | '(' | ')' | ' ' | ':' | '/' => c.to_string(), + _ => return None, + }; + if digits.is_empty() { + head.push_str(&text); + } else { + tail.push_str(&text); + } + } + } + } + let (int_part, frac_part) = digits.split_once('.').unwrap_or((digits.as_str(), "")); + if frac_part.contains([',', '.']) { + return None; + } + let min_int = int_part.matches('0').count(); + let min_frac = frac_part.matches('0').count(); + if frac_part.len() > 15 { + return None; + } + // Half rounds away from zero, as the format-code rules say; the formatter alone would round half to even. + let places = 10f64.powi(frac_part.len() as i32); + let scaled = (magnitude * 100f64.powi(percents) * places).round() / places; + let text = format!("{scaled:.*}", frac_part.len()); + let (whole, frac) = text.split_once('.').unwrap_or((text.as_str(), "")); + let mut frac = frac.to_owned(); + while frac.len() > min_frac && frac.ends_with('0') { + frac.pop(); + } + let mut whole = if whole == "0" && min_int == 0 { + String::new() + } else { + whole.to_owned() + }; + while whole.len() < min_int { + whole.insert(0, '0'); + } + if int_part.contains(',') { + let mut grouped = String::new(); + for (i, ch) in whole.chars().enumerate() { + if i > 0 && (whole.len() - i) % 3 == 0 { + grouped.push(','); + } + grouped.push(ch); + } + whole = grouped; + } + let point = if frac.is_empty() { "" } else { "." }; + let zero = whole.chars().chain(frac.chars()).all(|c| c == '0' || c == ','); + let sign = if zero { "" } else { sign }; + Some(format!("{sign}{head}{whole}{point}{frac}{tail}")) +} + +/// The civil date of a serial number in the 1900 date system, which counts a day for the nonexistent 1900-02-29. +fn civil(serial: i64) -> Option<(i64, i64, i64, usize)> { + if !(1..=2_958_465).contains(&serial) || serial == 60 { + return None; + } + let days = if serial < 60 { serial - 25_568 } else { serial - 25_569 }; + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = doy - (153 * mp + 2) / 5 + 1; + let month = if mp < 10 { mp + 3 } else { mp - 9 }; + let year = yoe + era * 400 + i64::from(month <= 2); + let weekday = (days + 4).rem_euclid(7) as usize; + Some((year, month, day, weekday)) +} + +fn date(value: f64, section: &str) -> Option { + let whole = value.floor(); + let (year, month, day, weekday) = civil(whole as i64)?; + let seconds = ((value - whole) * 86_400.0).round() as i64; + let (hour, minute, second) = (seconds / 3_600 % 24, seconds / 60 % 60, seconds % 60); + let chars: Vec = section.chars().collect(); + let mut out = String::new(); + let mut after_hour = false; + let mut i = 0; + while i < chars.len() { + let c = chars[i].to_ascii_lowercase(); + if "ymdhs".contains(c) { + let mut n = 1; + while i + n < chars.len() && chars[i + n].to_ascii_lowercase() == c { + n += 1; + } + // An `m` right before a colon and seconds is the minute. + let before_seconds = + chars.get(i + n) == Some(&':') && chars.get(i + n + 1).is_some_and(|q| q.eq_ignore_ascii_case(&'s')); + match (c, n) { + ('y', 2) => out.push_str(&format!("{:02}", year % 100)), + ('y', 4) => out.push_str(&format!("{year:04}")), + ('m', 1 | 2) if after_hour || before_seconds => out.push_str(&format!("{minute:0n$}")), + ('m', 1) => out.push_str(&month.to_string()), + ('m', 2) => out.push_str(&format!("{month:02}")), + ('m', 3) => out.push_str(&MONTHS[month as usize - 1][..3]), + ('m', 4) => out.push_str(MONTHS[month as usize - 1]), + ('d', 1) => out.push_str(&day.to_string()), + ('d', 2) => out.push_str(&format!("{day:02}")), + ('d', 3) => out.push_str(&DAYS[weekday][..3]), + ('d', 4) => out.push_str(DAYS[weekday]), + ('h', 1 | 2) => out.push_str(&format!("{hour:0n$}")), + ('s', 1 | 2) => out.push_str(&format!("{second:0n$}")), + _ => return None, + } + after_hour = c == 'h'; + i += n; + continue; + } + match chars[i] { + '"' => { + i += 1; + while i < chars.len() && chars[i] != '"' { + out.push(chars[i]); + i += 1; + } + } + '\\' => { + i += 1; + out.push(*chars.get(i)?); + } + ch if "-/:., ".contains(ch) => out.push(ch), + _ => return None, + } + i += 1; + } + Some(out) +} diff --git a/crates/interop/src/sheet/read.rs b/crates/interop/src/sheet/read.rs new file mode 100644 index 000000000..bb080c6cb --- /dev/null +++ b/crates/interop/src/sheet/read.rs @@ -0,0 +1,400 @@ +//! Reads `data.sheet` of a table block: layers the styles (spec R-SHT-9, without looks and conditional formats), +//! finds merges (R-SHT-12), and checks the value cache against the fingerprint of the cells (R-SHT-17, R-SHT-18). + +use super::{numfmt, Merge, TableSheet}; +use crate::doc::cell_style::{CellAlign, CellBorder, CellBorders, CellFont, CellStyle, Fill, Pad}; +use opennote_core::format::page_json::inline::readable_text; +use opennote_core::model::TableData; +use serde_json::{Map, Value}; +use std::collections::BTreeMap; + +/// The most merges a reader looks at (format spec 6.3). Entries past it are ignored. +pub const MERGE_LIMIT: usize = 10_000; + +type Obj = Map; + +/// Reads the sheet of a table. `None` when the table has no `data.sheet`. +pub fn read(table: &TableData) -> Option { + let sheet = table.extra.get("sheet")?.as_object()?; + let rows = table.rows.len(); + let columns = table.columns.len(); + let fresh = sheet.get("valuesOf").and_then(Value::as_str) == Some(fingerprint(table).as_str()); + let settings = obj(sheet.get("table")); + let mut out = TableSheet { + styles: vec![vec![None; columns]; rows], + results: vec![vec![None; columns]; rows], + name: settings + .and_then(|t| t.get("name")) + .and_then(Value::as_str) + .filter(|n| !n.is_empty()) + .map(str::to_owned), + ..TableSheet::default() + }; + let styles = obj(sheet.get("styles")); + let col_settings = obj(sheet.get("cols")); + let row_settings = obj(sheet.get("rows")); + let cells = obj(sheet.get("cells")); + let values = obj(sheet.get("values")); + let col_ids: Vec = table.columns.iter().map(|c| c.id.to_string()).collect(); + for (r, row) in table.rows.iter().enumerate() { + let row_id = row.id.to_string(); + for (c, column) in table.columns.iter().enumerate() { + let column_id = col_ids[c].as_str(); + let layers = [ + settings.and_then(|t| t.get("style")), + col_settings.and_then(|m| m.get(column_id)).and_then(|s| s.get("style")), + row_settings + .and_then(|m| m.get(row_id.as_str())) + .and_then(|s| s.get("style")), + cells + .and_then(|m| m.get(row_id.as_str())) + .and_then(|m| m.get(column_id)), + ]; + let mut merged = Obj::new(); + let mut any = false; + for id in layers.iter().flatten().filter_map(|v| v.as_str()) { + if let Some(style) = styles.and_then(|s| s.get(id)).and_then(Value::as_object) { + layer_over(&mut merged, style); + any = true; + } + } + if any { + out.styles[r][c] = Some(style_of(&merged)); + } + let fmt = merged + .get("fmt") + .and_then(Value::as_str) + .filter(|f| (1..=255).contains(&f.len())); + let markdown = row.cells.get(&column.id).map_or("", |cell| cell.markdown.as_str()); + let stored = values + .and_then(|v| v.get(row_id.as_str())) + .and_then(|v| v.get(column_id)); + // The readable text decides what a formula is (R-SHT-5), so a bold `=A1*2` is one. + if readable_text(markdown).starts_with('=') { + if fresh { + // An entry a reader cannot use is ignored (R-SHT-17): the cell keeps its formula text. + out.results[r][c] = stored.map_or(Some(String::new()), |v| result_text(v, fmt)); + } else { + out.stale = true; + } + } else if fresh && markdown.is_empty() { + out.results[r][c] = stored.and_then(|v| result_text(v, fmt)); + } + } + } + out.merges = merges(sheet, table); + Some(out) +} + +fn obj(value: Option<&Value>) -> Option<&Obj> { + value?.as_object() +} + +/// Lays `upper` over `lower`: `font`, `align`, `pad`, and `border` merge key by key. Everything else, such as `fill`, +/// replaces. +fn layer_over(lower: &mut Obj, upper: &Obj) { + for (key, value) in upper { + let keyed = matches!(key.as_str(), "font" | "align" | "pad" | "border"); + match (keyed, lower.get_mut(key), value.as_object()) { + (true, Some(Value::Object(below)), Some(above)) => { + for (k, v) in above { + below.insert(k.clone(), v.clone()); + } + } + _ => { + lower.insert(key.clone(), value.clone()); + } + } + } +} + +fn text(value: Option<&Value>) -> Option { + value?.as_str().map(str::to_owned) +} + +fn number(value: Option<&Value>) -> Option { + value?.as_f64().filter(|n| n.is_finite()) +} + +/// A number inside `low..=high`; one outside is dropped, as the sheet schema refuses it. +fn ranged(value: Option<&Value>, low: f64, high: f64) -> Option { + number(value).filter(|n| (low..=high).contains(n)) +} + +fn side(value: Option<&Value>) -> Option { + let side = value?.as_object()?; + Some(CellBorder { + style: text(side.get("style"))?, + color: text(side.get("color")), + width: ranged(side.get("width"), 0.25, 6.0), + }) +} + +fn get<'a>(m: Option<&'a Obj>, k: &str) -> Option<&'a Value> { + m?.get(k) +} + +fn style_of(style: &Obj) -> CellStyle { + let font = obj(style.get("font")); + let align = obj(style.get("align")); + let border = obj(style.get("border")); + let pad = obj(style.get("pad")); + CellStyle { + font: CellFont { + family: text(get(font, "family")), + size: ranged(get(font, "size"), 1.0, 1638.0), + weight: ranged(get(font, "weight"), 100.0, 900.0).map(|w| w as u16), + bold: get(font, "bold").and_then(Value::as_bool), + italic: get(font, "italic").and_then(Value::as_bool), + underline: text(get(font, "underline")), + strike: match get(font, "strike") { + Some(Value::Bool(true)) => Some("single".to_owned()), + Some(Value::Bool(false)) => Some("none".to_owned()), + other => text(other), + }, + color: text(get(font, "color")), + script: text(get(font, "script")), + }, + fill: style.get("fill").and_then(fill_of), + border: CellBorders { + top: side(get(border, "t")), + right: side(get(border, "r")), + bottom: side(get(border, "b")), + left: side(get(border, "l")), + diagonal_up: side(get(border, "du")), + diagonal_down: side(get(border, "dd")), + }, + align: CellAlign { + horizontal: text(get(align, "h")), + vertical: text(get(align, "v")), + wrap: text(get(align, "wrap")), + shrink: get(align, "shrink").and_then(Value::as_bool), + rotate: match get(align, "rotate") { + Some(Value::Number(n)) => n + .as_f64() + .filter(|n| n.fract() == 0.0 && (-90.0..=90.0).contains(n)) + .map(|n| n as i16), + _ => None, + }, + stacked: get(align, "rotate").and_then(Value::as_str) == Some("stacked"), + indent: ranged(get(align, "indent"), 0.0, 15.0) + .filter(|n| n.fract() == 0.0) + .map(|n| n as u8), + dir: text(get(align, "dir")), + }, + pad: Pad { + top: ranged(get(pad, "t"), 0.0, 1584.0), + right: ranged(get(pad, "r"), 0.0, 1584.0), + bottom: ranged(get(pad, "b"), 0.0, 1584.0), + left: ranged(get(pad, "l"), 0.0, 1584.0), + }, + } +} + +fn fill_of(value: &Value) -> Option { + match value { + Value::String(s) if s == "none" => Some(Fill::None), + Value::String(s) => Some(Fill::Color(s.clone())), + Value::Object(o) => { + if let Some(pattern) = o.get("pattern").and_then(Value::as_str) { + return Some(Fill::Pattern { + pattern: pattern.to_owned(), + color: text(o.get("color")), + back: text(o.get("back")), + }); + } + let gradient = o.get("gradient")?.as_object()?; + let stops = gradient + .get("stops")? + .as_array()? + .iter() + .filter_map(|s| Some((number(s.get("at"))?, text(s.get("color"))?))) + .collect(); + Some(Fill::Gradient { + angle: number(gradient.get("angle")).map_or(0, |a| a as u16), + stops, + }) + } + _ => None, + } +} + +/// The active merges (R-SHT-12): in ID order, covering more than one cell, naming rows, columns that exist, and +/// not overlapping a merge with a lower ID. +fn merges(sheet: &Obj, table: &TableData) -> Vec { + let Some(list) = obj(sheet.get("merges")) else { + return Vec::new(); + }; + // One lookup table per axis, so a corner costs a hash and not a scan of the rows. + let row_index: std::collections::HashMap = table + .rows + .iter() + .enumerate() + .map(|(i, r)| (r.id.to_string(), i)) + .collect(); + let col_index: std::collections::HashMap = table + .columns + .iter() + .enumerate() + .map(|(i, c)| (c.id.to_string(), i)) + .collect(); + let row_at = |id: &Value| row_index.get(id.as_str()?).copied(); + let col_at = |id: &Value| col_index.get(id.as_str()?).copied(); + let sorted: BTreeMap<&String, &Value> = list.iter().take(MERGE_LIMIT).collect(); + let mut taken = vec![false; table.rows.len() * table.columns.len()]; + let width = table.columns.len(); + let mut out = Vec::new(); + for value in sorted.values() { + let corner = |key: &str| { + let pair = value.get(key)?.as_array()?; + Some((row_at(pair.first()?)?, col_at(pair.get(1)?)?)) + }; + let (Some(a), Some(b)) = (corner("from"), corner("to")) else { + continue; + }; + let merge = Merge { + top: a.0.min(b.0), + left: a.1.min(b.1), + bottom: a.0.max(b.0), + right: a.1.max(b.1), + }; + if merge.top == merge.bottom && merge.left == merge.right { + continue; + } + // The first taken cell ends the check, so many overlapping merges cost little. + let overlaps = (merge.top..=merge.bottom).any(|r| (merge.left..=merge.right).any(|c| taken[r * width + c])); + if overlaps { + continue; + } + for r in merge.top..=merge.bottom { + taken[r * width + merge.left..=r * width + merge.right].fill(true); + } + out.push(merge); + } + out +} + +/// The text of a cached value, or `None` for an entry a reader ignores: an array, or an object that is not a lone error code. +fn result_text(value: &Value, fmt: Option<&str>) -> Option { + Some(match value { + Value::Null => String::new(), + Value::Array(_) => return None, + Value::Bool(true) => "TRUE".to_owned(), + Value::Bool(false) => "FALSE".to_owned(), + Value::Number(n) => { + let n = n.as_f64().unwrap_or(0.0); + fmt.and_then(|code| numfmt::shown(n, code)) + .unwrap_or_else(|| number_text(n)) + } + Value::String(s) => s.clone(), + Value::Object(o) if o.len() == 1 => o.get("e").and_then(Value::as_str)?.to_owned(), + Value::Object(_) => return None, + }) +} + +/// A number the way JavaScript writes it: no exponent from 1e-6 up to 1e21. +fn number_text(n: f64) -> String { + if n == 0.0 { + return "0".to_owned(); + } + if n.abs() >= 1e-6 && n.abs() < 1e21 { + return format!("{n}"); + } + let text = format!("{n:e}"); + match text.split_once('e') { + Some((m, e)) if !e.starts_with('-') => format!("{m}e+{e}"), + _ => text, + } +} + +/// The fingerprint of a table's cells (R-SHT-18): `fnv1a:` and 16 hexadecimal digits of the FNV-1a 64 hash of the +/// compact canonical JSON of `[header, column IDs, [[row ID, [Markdown per column]] per row], smart]`. +pub fn fingerprint(table: &TableData) -> String { + let columns: Vec = table.columns.iter().map(|c| Value::from(c.id.to_string())).collect(); + let rows: Vec = table + .rows + .iter() + .map(|row| { + let cells = table + .columns + .iter() + .map(|c| Value::from(row.cells.get(&c.id).map_or("", |cell| cell.markdown.as_str()))) + .collect::>(); + Value::Array(vec![Value::from(row.id.to_string()), Value::Array(cells)]) + }) + .collect(); + let smart = table.extra.get("smart").cloned().unwrap_or(Value::Null); + let all = Value::Array(vec![ + Value::Bool(table.header), + Value::Array(columns), + Value::Array(rows), + smart, + ]); + let mut json = String::new(); + canonical(&all, &mut json); + format!("fnv1a:{:016x}", fnv1a64(json.as_bytes())) +} + +/// FNV-1a 64 with the offset basis `cbf29ce484222325` and prime `100000001b3` (R-SHT-6). +fn fnv1a64(bytes: &[u8]) -> u64 { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for byte in bytes { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + hash +} + +fn canonical(value: &Value, out: &mut String) { + match value { + Value::Null => out.push_str("null"), + Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }), + Value::Number(n) => match n.as_i64() { + Some(i) => out.push_str(&i.to_string()), + None => out.push_str(&number_text(n.as_f64().unwrap_or(0.0))), + }, + Value::String(s) => quote(s, out), + Value::Array(items) => { + out.push('['); + for (i, item) in items.iter().enumerate() { + if i > 0 { + out.push(','); + } + canonical(item, out); + } + out.push(']'); + } + Value::Object(map) => { + let sorted: BTreeMap<&String, &Value> = map.iter().collect(); + out.push('{'); + for (i, (key, item)) in sorted.into_iter().enumerate() { + if i > 0 { + out.push(','); + } + quote(key, out); + out.push(':'); + canonical(item, out); + } + out.push('}'); + } + } +} + +/// A string as the canonical form writes it: `"`, `\`, and U+0000 to U+001F are escaped, and the rest is raw UTF-8. +fn quote(text: &str, out: &mut String) { + out.push('"'); + for ch in text.chars() { + match ch { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\u{8}' => out.push_str("\\b"), + '\u{c}' => out.push_str("\\f"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } + } + out.push('"'); +} diff --git a/crates/interop/src/sheet/tests.rs b/crates/interop/src/sheet/tests.rs new file mode 100644 index 000000000..fa11648d5 --- /dev/null +++ b/crates/interop/src/sheet/tests.rs @@ -0,0 +1,368 @@ +// checks-disable-file brand-consistency: the test data holds color values to check how they are written +use super::*; +use opennote_core::model::{TableCell, TableColumn, TableData, TableRow}; +use opennote_core::{ColumnId, RowId}; +use serde_json::{json, Map, Value}; + +use crate::doc::cell_style::Fill; + +const K: [&str; 3] = [ + "01m3sbcmt0000000000000k001", + "01m3sbcmt0000000000000k002", + "01m3sbcmt0000000000000k003", +]; +const R: [&str; 3] = [ + "01m3sbcmt0000000000000r001", + "01m3sbcmt0000000000000r002", + "01m3sbcmt0000000000000r003", +]; + +/// A table of `columns` columns with the given rows of cell Markdown, and `extra` for its keys. +fn table(columns: usize, rows: &[&[&str]], extra: Value) -> TableData { + TableData { + header: true, + columns: K[..columns] + .iter() + .map(|id| TableColumn { + id: ColumnId::parse(id).unwrap(), + width: Some(120.0), + extra: Map::new(), + }) + .collect(), + rows: rows + .iter() + .enumerate() + .map(|(i, cells)| TableRow { + id: RowId::parse(R[i]).unwrap(), + cells: cells + .iter() + .enumerate() + .map(|(c, text)| { + ( + ColumnId::parse(K[c]).unwrap(), + TableCell { + markdown: (*text).to_owned(), + extra: Map::new(), + }, + ) + }) + .collect(), + extra: Map::new(), + }) + .collect(), + extra: extra.as_object().cloned().unwrap_or_default(), + } +} + +#[test] +fn the_fingerprint_matches_the_spec_fixture() { + // The fixture `table-sheet/valuesOf.json` of the format spec gives this fingerprint for this table. + let smart = json!({"smart": {"columns": {K[1]: {"type": "number", "decimals": 2}}}}); + let t = table(2, &[&["Item", "Price"], &["Tea", "2.5"], &["Rice", "4"]], smart); + assert_eq!(fingerprint(&t), "fnv1a:0e8265540bd3b4a9"); +} + +fn formula_table(fresh: bool) -> TableData { + let rows: &[&[&str]] = &[&["A", "B"], &["x", "=2*3"]]; + let mut t = table(2, rows, json!({})); + let of = if fresh { + fingerprint(&t) + } else { + "fnv1a:0000000000000000".to_owned() + }; + let sheet = json!({"values": {R[1]: {K[1]: 6}}, "valuesOf": of}); + t.extra.insert("sheet".to_owned(), sheet); + t +} + +#[test] +fn a_fresh_cache_gives_the_result_of_a_formula_cell() { + let sheet = read(&formula_table(true)).unwrap(); + assert_eq!(sheet.results[1][1].as_deref(), Some("6")); + assert!(!sheet.stale); +} + +#[test] +fn a_stale_cache_keeps_the_formula_text_and_flags_the_table() { + let sheet = read(&formula_table(false)).unwrap(); + assert_eq!(sheet.results[1][1], None); + assert!(sheet.stale); +} + +#[test] +fn a_table_without_a_sheet_has_none() { + assert!(read(&table(1, &[&["a"]], json!({}))).is_none()); +} + +#[test] +fn merges_are_in_id_order_and_the_lower_id_wins_an_overlap() { + let rows: &[&[&str]] = &[&["a", "b", "c"], &["d", "e", "f"], &["g", "h", "i"]]; + let merges = json!({ + "m1": {"from": [R[1], K[1]], "to": [R[2], K[2]]}, + "m2": {"from": [R[2], K[2]], "to": [R[2], K[2]]}, + "m3": {"from": [R[0], K[0]], "to": [R[1], K[1]]}, + "m4": {"from": [R[0], K[0]], "to": [R[0], K[1]]} + }); + let t = table(3, rows, json!({"sheet": {"merges": merges}})); + let found = read(&t).unwrap().merges; + // m1 is B2:C3. m2 covers one cell. m3 overlaps m1 at B2, so m1 wins and m3 is ignored. m4 is A1:B1. + let m1 = Merge { + top: 1, + left: 1, + bottom: 2, + right: 2, + }; + let m4 = Merge { + top: 0, + left: 0, + bottom: 0, + right: 1, + }; + assert_eq!(found, vec![m1, m4]); +} + +#[test] +fn a_cell_style_layers_the_table_column_row_and_cell_styles() { + let sheet = json!({ + "styles": { + "t": {"font": {"size": 11}, "fill": "#eeeeee"}, + "c": {"font": {"bold": true}}, + "x": {"align": {"v": "middle"}, "border": {"b": {"style": "double"}}} + }, + "table": {"style": "t"}, + "cols": {K[0]: {"style": "c"}}, + "cells": {R[1]: {K[0]: "x"}} + }); + let t = table(2, &[&["a", "b"], &["c", "d"]], json!({"sheet": sheet})); + let styles = read(&t).unwrap().styles; + let cell = styles[1][0].as_ref().unwrap(); + assert_eq!(cell.font.size, Some(11.0)); + assert_eq!(cell.font.bold, Some(true)); + assert_eq!(cell.fill, Some(Fill::Color("#eeeeee".to_owned()))); + assert_eq!(cell.align.vertical.as_deref(), Some("middle")); + assert_eq!(cell.border.bottom.as_ref().map(|b| b.style.as_str()), Some("double")); + // The other column has only the table's style. + let other = styles[1][1].as_ref().unwrap(); + assert_eq!(other.font.bold, None); +} + +fn with_values(cell: &str, stored: Value) -> TableData { + let mut t = table(2, &[&["A", "B"], &["x", cell]], json!({})); + let of = fingerprint(&t); + t.extra.insert( + "sheet".to_owned(), + json!({"values": {R[1]: {K[1]: stored}}, "valuesOf": of}), + ); + t +} + +#[test] +fn a_formula_cell_is_found_by_its_readable_text() { + // R-SHT-5: marks do not hide a formula. A bold `=2*3` shows its result when fresh and flags the table when stale. + let sheet = read(&with_values("**=2*3**", json!(6))).unwrap(); + assert_eq!(sheet.results[1][1].as_deref(), Some("6")); + assert!(!sheet.stale); + let mut stale = with_values("**=2*3**", json!(6)); + stale.extra.get_mut("sheet").unwrap()["valuesOf"] = json!("fnv1a:0000000000000000"); + let sheet = read(&stale).unwrap(); + assert_eq!(sheet.results[1][1], None); + assert!(sheet.stale); + // A cell whose readable text does not start with `=` is plain text. + assert_eq!(read(&with_values("a =2*3", json!(6))).unwrap().results[1][1], None); +} + +#[test] +fn an_invalid_cached_entry_is_ignored_and_the_formula_text_stays() { + // R-SHT-17: an array, or an object that is not a lone error code, is not a cached value. + for bad in [ + json!([1, 2]), + json!({"x": 1}), + json!({"e": "#N/A", "x": 1}), + json!({"e": 5}), + ] { + let sheet = read(&with_values("=2*3", bad.clone())).unwrap(); + assert_eq!(sheet.results[1][1], None, "{bad}"); + assert!(!sheet.stale, "{bad}"); + } + assert_eq!( + read(&with_values("=1/0", json!({"e": "#DIV/0!"}))).unwrap().results[1][1].as_deref(), + Some("#DIV/0!") + ); + assert_eq!( + read(&with_values("=A1", json!(null))).unwrap().results[1][1].as_deref(), + Some("") + ); +} + +#[test] +fn only_the_first_ten_thousand_merges_are_read() { + let rows: &[&[&str]] = &[&["a", "b", "c"], &["d", "e", "f"], &["g", "h", "i"]]; + let mut merges = Map::new(); + // IDs sort as text. The first 10,000 are the same whole-table merge, so one is active. The 10,001st would be + // valid, but is past the limit. + for i in 0..MERGE_LIMIT { + merges.insert(format!("a{i:05}"), json!({"from": [R[0], K[0]], "to": [R[2], K[2]]})); + } + merges.insert("b".to_owned(), json!({"from": [R[0], K[0]], "to": [R[0], K[1]]})); + let t = table(3, rows, json!({"sheet": {"merges": Value::Object(merges)}})); + assert_eq!(read(&t).unwrap().merges.len(), 1); + // With room left, a merge that does not overlap is read: drop all but 9,999 of the first group. + let mut few = Map::new(); + for i in 0..MERGE_LIMIT - 1 { + few.insert(format!("a{i:05}"), json!({"from": [R[2], K[0]], "to": [R[2], K[2]]})); + } + few.insert("b".to_owned(), json!({"from": [R[0], K[0]], "to": [R[0], K[1]]})); + few.insert("c".to_owned(), json!({"from": [R[1], K[0]], "to": [R[1], K[1]]})); + let t = table(3, rows, json!({"sheet": {"merges": Value::Object(few)}})); + // Entry 10,000 is `b` and is read. `c` is the 10,001st and is not. + assert_eq!(read(&t).unwrap().merges.len(), 2); +} + +#[test] +fn the_merge_index_names_each_cell_once() { + let m = Merge { + top: 0, + left: 0, + bottom: 1, + right: 1, + }; + let index = MergeIndex::new(&[m], 3, 3); + assert_eq!(index.slot(0, 0), Some(Slot::Anchor(m))); + assert_eq!(index.slot(0, 1), Some(Slot::Covered(m))); + assert_eq!(index.slot(1, 0), Some(Slot::Covered(m))); + assert_eq!(index.slot(2, 2), None); + // A merge past the grid is cut to it, and one that starts outside is left out. + let wide = Merge { + top: 2, + left: 2, + bottom: 9, + right: 9, + }; + let far = Merge { + top: 5, + left: 0, + bottom: 6, + right: 0, + }; + let cut = MergeIndex::new(&[wide, far], 3, 3); + assert_eq!(cut.slot(2, 2), Some(Slot::Anchor(wide))); + assert_eq!(cut.slot(5, 0), None); +} + +/// A table of `rows` by `columns` empty cells with generated IDs. +fn grid(rows: usize, columns: usize, extra: Value) -> TableData { + let col_ids: Vec = (0..columns).map(|i| format!("01m3sbcmt2{i:016}")).collect(); + let row_ids: Vec = (0..rows).map(|i| format!("01m3sbcmt1{i:016}")).collect(); + TableData { + header: true, + columns: col_ids + .iter() + .map(|id| TableColumn { + id: ColumnId::parse(id).unwrap(), + width: None, + extra: Map::new(), + }) + .collect(), + rows: row_ids + .iter() + .map(|id| TableRow { + id: RowId::parse(id).unwrap(), + cells: col_ids + .iter() + .map(|c| { + ( + ColumnId::parse(c).unwrap(), + TableCell { + markdown: String::new(), + extra: Map::new(), + }, + ) + }) + .collect(), + extra: Map::new(), + }) + .collect(), + extra: extra.as_object().cloned().unwrap_or_default(), + } +} + +#[test] +fn ten_thousand_merges_in_a_large_table_read_fast() { + // The limits of the format: 1,000 rows, 100 columns, 10,000 merges. Each merge spans two columns of one row, so + // none overlaps. The check against taken cells must not grow with the table (it took a minute in debug before). + let (rows, columns) = (1_000, 100); + let mut merges = Map::new(); + for i in 0..MERGE_LIMIT { + let (r, c) = (i / 10, (i % 10) * 2); + merges.insert( + format!("m{i:05}"), + json!({"from": [format!("01m3sbcmt1{r:016}"), format!("01m3sbcmt2{c:016}")], + "to": [format!("01m3sbcmt1{r:016}"), format!("01m3sbcmt2{:016}", c + 1)]}), + ); + } + let t = grid(rows, columns, json!({"sheet": {"merges": Value::Object(merges)}})); + let start = std::time::Instant::now(); + let sheet = read(&t).unwrap(); + assert_eq!(sheet.merges.len(), MERGE_LIMIT); + assert!(start.elapsed().as_secs() < 5, "reading took {:?}", start.elapsed()); + let start = std::time::Instant::now(); + let index = MergeIndex::new(&sheet.merges, rows, columns); + assert!(index.slot(999, 19).is_some()); + assert!(start.elapsed().as_secs() < 5); +} + +#[test] +fn a_style_value_outside_the_schema_range_is_dropped() { + // R-SHT ranges: padding 0 to 1584 points, indent 0 to 15, border width 0.25 to 6, rotation -90 to 90, size 1 to 1638. + let style = json!({ + "pad": {"t": 1e300, "r": -50, "b": 10, "l": 1584}, + "align": {"indent": 255, "rotate": 91}, + "border": {"t": {"style": "single", "width": 99}}, + "font": {"size": 0, "weight": 1000} + }); + let t = table( + 1, + &[&["a"], &["b"]], + json!({"sheet": {"styles": {"h": style}, "table": {"style": "h"}}}), + ); + let cell = read(&t).unwrap().styles[0][0].clone().unwrap(); + assert_eq!(cell.pad.top, None); + assert_eq!(cell.pad.right, None); + assert_eq!(cell.pad.bottom, Some(10.0)); + assert_eq!(cell.pad.left, Some(1584.0)); + assert_eq!(cell.align.indent, None); + assert_eq!(cell.align.rotate, None); + assert_eq!(cell.border.top.and_then(|b| b.width), None); + assert_eq!(cell.font.size, None); + assert_eq!(cell.font.weight, None); +} + +fn shown_result(value: Value, fmt: &str) -> Option { + let mut t = table(2, &[&["A", "B"], &["x", "=1/4"]], json!({})); + let of = fingerprint(&t); + t.extra.insert( + "sheet".to_owned(), + json!({"values": {R[1]: {K[1]: value}}, "valuesOf": of, "styles": {"f": {"fmt": fmt}}, "cols": {K[1]: {"style": "f"}}}), + ); + read(&t).unwrap().results[1][1].clone() +} + +#[test] +fn a_result_shows_under_the_number_format_of_its_cell() { + // Hand-worked: 0.25 as a percent is 25%. Serial 45000 is 2023-03-15 (ECMA-376 18.17.4.1, 1900 date system). + assert_eq!(shown_result(json!(0.25), "0%").as_deref(), Some("25%")); + assert_eq!(shown_result(json!(0.256), "0.0%").as_deref(), Some("25.6%")); + assert_eq!(shown_result(json!(1234.5), "#,##0.00").as_deref(), Some("1,234.50")); + assert_eq!(shown_result(json!(7), "0.00").as_deref(), Some("7.00")); + assert_eq!(shown_result(json!(1234.5), "\"$\"#,##0").as_deref(), Some("$1,235")); + assert_eq!(shown_result(json!(45000), "yyyy-mm-dd").as_deref(), Some("2023-03-15")); + assert_eq!( + shown_result(json!(45000.5), "d mmm yyyy h:mm").as_deref(), + Some("15 Mar 2023 12:00") + ); + assert_eq!(shown_result(json!(45000), "dddd").as_deref(), Some("Wednesday")); + // General, an unsupported code, and a value that is not a number keep the plain text. + assert_eq!(shown_result(json!(0.25), "General").as_deref(), Some("0.25")); + assert_eq!(shown_result(json!(0.25), "[Red]0.0E+0").as_deref(), Some("0.25")); + assert_eq!(shown_result(json!("hi"), "0%").as_deref(), Some("hi")); +} diff --git a/crates/updater/tests/swap.rs b/crates/updater/tests/swap.rs index 56b44b563..dd1c124cb 100644 --- a/crates/updater/tests/swap.rs +++ b/crates/updater/tests/swap.rs @@ -10,7 +10,7 @@ mod server; use std::{ fs, - path::{Path, PathBuf}, + path::PathBuf, process::{Child, Command}, sync::OnceLock, thread, @@ -23,24 +23,32 @@ use serde_json::{json, Value}; use server::{Route, Server}; use tempfile::TempDir; -/// The fake app, built by `cargo test` with the other examples, or here when a single test runs. +/// The fake app. Cargo builds it and says where it put it: the test exe's own folder is not the profile folder when +/// cargo keeps build output apart from the target folder, so the path is asked for, not guessed. fn fake_app() -> &'static [u8] { static BYTES: OnceLock> = OnceLock::new(); BYTES.get_or_init(|| { - let test_exe = std::env::current_exe().expect("the test exe"); - let profile = test_exe - .parent() - .and_then(Path::parent) - .expect("the target profile folder"); - let exe = profile.join("examples").join("fake_app.exe"); - if !exe.exists() { - let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()); - let status = Command::new(cargo) - .args(["build", "-p", "opennote-updater", "--example", "fake_app"]) - .status() - .expect("cargo runs"); - assert!(status.success(), "the fake app builds"); - } + let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()); + let output = Command::new(cargo) + .args([ + "build", + "-p", + "opennote-updater", + "--example", + "fake_app", + "--message-format=json", + ]) + .stderr(std::process::Stdio::inherit()) + .output() + .expect("cargo runs"); + assert!(output.status.success(), "the fake app builds"); + let exe = String::from_utf8_lossy(&output.stdout) + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .filter(|message| message["reason"] == "compiler-artifact" && message["target"]["name"] == "fake_app") + .filter_map(|message| message["executable"].as_str().map(PathBuf::from)) + .next_back() + .expect("cargo names the fake app"); fs::read(exe).expect("the fake app") }) } diff --git a/docs/FEATURE-LIST.md b/docs/FEATURE-LIST.md index e90635539..a3d34c65e 100644 --- a/docs/FEATURE-LIST.md +++ b/docs/FEATURE-LIST.md @@ -2,7 +2,7 @@ Every tool, action, and feature in OpenNote, grouped by category, with one sentence each. `npm run docs:features` makes this page. The commands, tool windows, title bar items, settings sections, and setup steps come from what the app registers, so a new one shows up here or fails the tests. The feature sentences come from [FEATURES.md](FEATURES.md), which also says what has been tested by hand and which flag turns each one on. The [help pages](help/README.md) explain how to use them. [docs/screens](screens/README.md) has every picture in light and dark. -The app registers 1040 commands and 48 tool windows. 24 of the windows are subject and engineering kits. The count includes the ink commands, which register when a page first shows its ink, and the commands that register once start-up is done or with the first page: on-device intelligence, the second pass of page commands, and the code block and table commands. The command palette (Ctrl+K) runs every command not marked as left out of it. Some commands need a feature flag, which FEATURES.md names. The keys shown are the defaults of the standard shortcut set. +The app registers 1140 commands and 48 tool windows. 24 of the windows are subject and engineering kits. The count includes the ink commands, which register when a page first shows its ink, and the commands that register once start-up is done or with the first page: on-device intelligence, the second pass of page commands, and the code block and table commands. The command palette (Ctrl+K) runs every command not marked as left out of it. Some commands need a feature flag, which FEATURES.md names. The keys shown are the defaults of the standard shortcut set. ## Contents @@ -66,6 +66,14 @@ The app registers 1040 commands and 48 tool windows. 24 of the windows are subje - **Sheet cell styles in tables.** A table keeps cell styles, merges, rules, names, and cached results in data.sheet. - **Number format codes.** Format codes with sections, colors, and conditions, regional display, and currency, percent, and date column presets, all computed on the device. - **Excel-style formulas.** Formulas follow Excel's rules for text, numbers, errors, and dates, with a registry of functions, names, LET and LAMBDA, dynamic arrays that spill, and references across tables. +- **Logic and information functions.** IFS, SWITCH, XOR, IFNA, the IS functions, CELL, INFO, SHEET, SHEETS, TYPE, and ERROR.TYPE. +- **Lookup and reference functions.** XLOOKUP and XMATCH with every match and search mode, INDEX, MATCH, and the older lookups. +- **Array and matrix functions.** FILTER, SORT, SORTBY, UNIQUE, SEQUENCE, RANDARRAY, TAKE, DROP, the stack and wrap functions, TRIMRANGE, MMULT, MINVERSE, and MDETERM. +- **Statistical functions.** Averages, deviations, ranks, percentiles, the IFS family, distributions with their inverses, tests, regression, and FORECAST.ETS. +- **Cell format bar.** A second row under the table tools sets the font, size, text styles, and colors. +- **Spreadsheet keys in tables.** Grid, Edit, and Enter modes use Excel's keys. +- **Conditional formatting.** Rules color cells by value, text, date, blank, error, top and bottom, average, duplicate, or formula. +- **Table styles in page exports.** Word, HTML, and Markdown exports write each table's last results. - **Code studio foundations.** The contracts, settings, flags, and strings for the code studio: projects as a page block, runs, diagnostics, completion, a replay debugger, tests, and runtime packs. - **Code editor keys.** Tab indents in the code editor, and Ctrl+Page Down and Ctrl+Page Up change tabs, so the tab bar stays reachable. - **Calm shell polish.** Phosphor icons in the command bar, pens back on the bar at 1,280 px, calm pressed and selected states, notebook accent roles, and group dividers. @@ -422,6 +430,106 @@ Open it with the command "Open the code studio". | Table: Eigenvalues of the selection as an equation | Inserts the eigenvalues of the selected matrix as an equation. | | | Table: Solution of Ax = b as an equation | Inserts the solution of Ax = b from the selected cells as an equation. | | | Insert table | Inserts a table. | | +| Cell: Fill color | Opens the fill colors for the selected cells. | | +| Cell: Text color | Opens the text colors for the selected cells. | | +| Cell: Borders | Draws the last border preset again on the selected cells. | | +| Cell: All borders | Draws a border around every selected cell. | | +| Cell: Outside borders | Draws a border around the outside of the selected cells. | | +| Cell: Thick outside borders | Draws the thick outside borders on the selected cells. | | +| Cell: Top border | Draws the top border on the selected cells. | | +| Cell: Bottom border | Draws the bottom border on the selected cells. | | +| Cell: Left border | Draws the left border on the selected cells. | | +| Cell: Right border | Draws the right border on the selected cells. | | +| Cell: Inside horizontal borders | Draws the inside horizontal borders on the selected cells. | | +| Cell: Inside vertical borders | Draws the inside vertical borders on the selected cells. | | +| Cell: No border | Takes every border off the selected cells. | | +| Cell: Bottom double border | Draws the bottom double border on the selected cells. | | +| Cell: Thick bottom border | Draws the thick bottom border on the selected cells. | | +| Cell: Top and bottom border | Draws the top and bottom border on the selected cells. | | +| Cell: Top and thick bottom border | Draws the top and thick bottom border on the selected cells. | | +| Cell: Diagonal up border | Draws the diagonal up border on the selected cells. | | +| Cell: Diagonal down border | Draws the diagonal down border on the selected cells. | | +| Cell: Border line thin | Draws the next borders with a thin line. | | +| Cell: Border line medium | Draws the next borders with a medium line. | | +| Cell: Border line thick | Draws the next borders with a thick line. | | +| Cell: Border line hairline | Draws the next borders with a hairline line. | | +| Cell: Border line dotted | Draws the next borders with a dotted line. | | +| Cell: Border line dashed | Draws the next borders with a dashed line. | | +| Cell: Border line double | Draws the next borders with a double line. | | +| Cell: Border line color | Opens the colors for the line the next borders are drawn with. | | +| Cell: Wrap text | Wraps long text inside the selected cells. | | +| Cell: Overflow into empty cells | Lets long text run into the empty cells next to the selected cells. | | +| Cell: Clip text | Cuts long text off at the edge of the selected cells. | | +| Cell: Horizontal text | Shows the text of the selected cells horizontally. | | +| Cell: Rotate text up | Turns the text of the selected cells up 45 degrees. | | +| Cell: Rotate text down | Turns the text of the selected cells down 45 degrees. | | +| Cell: Vertical text | Turns the text of the selected cells to read bottom to top. | | +| Cell: Stacked text | Stacks the letters of the selected cells one above the other. | | +| Cell: Align left | Aligns the selected cells to the left. | | +| Cell: Align center | Centers the selected cells. | | +| Cell: Align right | Aligns the selected cells to the right. | | +| Cell: Justify | Spreads the text of the selected cells from edge to edge. | | +| Cell: Align top | Aligns the text of the selected cells to the top. | | +| Cell: Align middle | Centers the text of the selected cells between top and bottom. | | +| Cell: Align bottom | Aligns the text of the selected cells to the bottom. | | +| Cell: No underline | Sets the no underline on the selected cells or text. | | +| Cell: Single underline | Sets the single underline on the selected cells or text. | | +| Cell: Double underline | Sets the double underline on the selected cells or text. | | +| Cell: Single accounting underline | Sets the single accounting underline on the selected cells or text. | | +| Cell: Double accounting underline | Sets the double accounting underline on the selected cells or text. | | +| Cell: Currency format | Shows the selected cells as currency. | | +| Cell: Percent format | Shows the selected cells as a percent. | | +| Cell: General format | Takes the number format off the selected cells. | | +| Cell: Number format | Shows the selected cells as plain numbers with two decimals. | | +| Cell: Accounting format | Shows the selected cells with the accounting number format. | | +| Cell: Short date format | Shows the selected cells with the short date number format. | | +| Cell: Long date format | Shows the selected cells with the long date number format. | | +| Cell: Time format | Shows the selected cells with the time number format. | | +| Cell: Fraction format | Shows the selected cells with the fraction number format. | | +| Cell: Scientific format | Shows the selected cells with the scientific number format. | | +| Cell: Text format | Shows the selected cells with the text number format. | | +| Cell: Thousands separator | Turns the thousands separator on or off in the selected cells. | | +| Cell: More decimals | Shows one more decimal place in the selected cells. | | +| Cell: Fewer decimals | Shows one fewer decimal place in the selected cells. | | +| Cell: Clear formats | Takes every format off the selected cells and leaves their text. | | +| Cell: Conditional formatting rules | Opens the conditional formatting rules of the selection, the table, or the page, to reorder, stop, or delete them. | | +| Extend the selection up (not in the palette) | In Grid mode, extends the selected cells one row up. | Shift+Up | +| Extend the selection down (not in the palette) | In Grid mode, extends the selected cells one row down. | Shift+Down | +| Extend the selection left (not in the palette) | In Grid mode, extends the selected cells one column left. | Shift+Left | +| Extend the selection right (not in the palette) | In Grid mode, extends the selected cells one column right. | Shift+Right | +| Go to the top edge of the data (not in the palette) | In Grid mode, goes to the top edge of the data in the column. | Ctrl+Up | +| Go to the bottom edge of the data (not in the palette) | In Grid mode, goes to the bottom edge of the data in the column. | Ctrl+Down | +| Go to the left edge of the data (not in the palette) | In Grid mode, goes to the left edge of the data in the row. | Ctrl+Left | +| Go to the right edge of the data (not in the palette) | In Grid mode, goes to the right edge of the data in the row. | Ctrl+Right | +| Extend the selection to the top edge of the data (not in the palette) | In Grid mode, extends the selected cells to the top edge of the data. | Ctrl+Shift+Up | +| Extend the selection to the bottom edge of the data (not in the palette) | In Grid mode, extends the selected cells to the bottom edge of the data. | Ctrl+Shift+Down | +| Extend the selection to the left edge of the data (not in the palette) | In Grid mode, extends the selected cells to the left edge of the data. | Ctrl+Shift+Left | +| Extend the selection to the right edge of the data (not in the palette) | In Grid mode, extends the selected cells to the right edge of the data. | Ctrl+Shift+Right | +| Go to the first cell (not in the palette) | In Grid mode, goes to the table's first cell. | Ctrl+Home | +| Go to the last used cell (not in the palette) | In Grid mode, goes to the last cell that holds data. | Ctrl+End | +| Extend the selection to the first cell (not in the palette) | In Grid mode, extends the selected cells to the table's first cell. | Ctrl+Shift+Home | +| Extend the selection to the last used cell (not in the palette) | In Grid mode, extends the selected cells to the last cell that holds data. | Ctrl+Shift+End | +| Extend the selection to the start of the row (not in the palette) | In Grid mode, extends the selected cells to the start of the row. | Shift+Home | +| Move one screen left (not in the palette) | In Grid mode, moves one screen of columns left. | Alt+PageUp | +| Move one screen right (not in the palette) | In Grid mode, moves one screen of columns right. | Alt+PageDown | +| Move up a cell (not in the palette) | In Grid mode, moves up a cell, or back through the selected cells. | Shift+Enter | +| Fill the selected cells with the active cell (not in the palette) | In Grid mode, fills every selected cell with the active cell, moving formula references. | Ctrl+Enter | +| Edit the active cell (not in the palette) | In Grid mode, starts editing the active cell with the caret at the end. | F2 | +| Clear the selected cells (not in the palette) | In Grid mode, clears the text of every selected cell. | Delete | +| Clear the active cell and edit it (not in the palette) | In Grid mode, clears the active cell and starts editing it. | Backspace | +| Select the column (not in the palette) | In Grid mode, selects the whole column. | Ctrl+Space | +| Select the data around the cell, then the table (not in the palette) | In Grid mode, selects the data around the active cell, then the whole table. | Ctrl+A | +| Fill down | In Grid mode, copies the top cell of the selection down, moving formula references. | Ctrl+D | +| Fill right | In Grid mode, copies the left cell of the selection right, moving formula references. | Ctrl+R | +| Copy the formula from the cell above (not in the palette) | In Grid mode, copies the formula of the cell above into the active cell. | Ctrl+' | +| Copy the value from the cell above (not in the palette) | In Grid mode, copies the value of the cell above into the active cell. | Ctrl+" | +| Enter today’s date | Enters today's date in the cell, as a date the formulas read. | Ctrl+; | +| Enter the current time | Enters the current time in the cell. | Ctrl+: | +| Finish the entry and move up (not in the palette) | In Edit mode, finishes the cell's entry and moves up. | Shift+Enter | +| Finish the entry and stay in the cell (not in the palette) | In Edit mode, finishes the cell's entry and stays in the cell. | Ctrl+Enter | +| Start a new line in the cell (not in the palette) | In Edit mode, starts a new line in the cell. | Alt+Enter | +| Switch between Enter and Edit modes (not in the palette) | In Edit mode, switches between Enter mode, where arrows move to the next cell, and Edit mode. | F2 | +| Use document keys in this table | Switches a table between spreadsheet keys and document keys, where Enter starts a new line. | | | Add row above | Adds a row above the current one. | | | Add row below | Adds a row below the current one. | Ctrl+Enter | | Move row up | Moves the current row up one place. | Alt+Shift+Up | @@ -2461,6 +2569,7 @@ Open it with the command "Open upcoming". - **Appearance.** Sets the theme, the page color in dark mode, the text and interface size, and reduced motion. - **Language.** Sets the app language and the spelling dialect, with American English as the default. - **Editing.** Turns Markdown shortcuts, the slash menu, and AutoCorrect on or off. +- **Tables.** Chooses whether new tables use spreadsheet keys or document keys. - **Recording.** Sets live captions, transcripts of recordings, audio formats, and the consent reminder. - **Pen and touch.** Sets the writing hand, finger drawing, the steady pen, and the pressure curve. - **My handwriting.** Calibrates the app to your handwriting and the symbols you write by hand. diff --git a/docs/FEATURES.md b/docs/FEATURES.md index ef11de9d7..15e295753 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -487,7 +487,7 @@ These features fill gaps found by comparing OpenNote with other note apps. Each | Feature | What it does | Status | | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Page properties | Fields on a page, such as status, due date, course, author, rating, and a link to another page. Field types are text, number, date, checkbox, choice, and page link. They show in a compact header that can be folded, are saved with the page, and are written at the top of `page.md` so other apps can read them. Search and collections use them, and each field shows its name as well as an icon. | Built, untested by hand. The fields are in the `page.md` front matter and in the front matter of every Markdown export: a page, a section, or a notebook (beta 5; files export since beta 8). | -| Collections | A collection is a saved view of pages chosen by a search, a tag, a section, or property values, such as "open assignments for one class, grouped by due week". It shows as a table, a list, a board, a calendar, or a gallery, and it can sit inside a page as a live block. Editing a property or dragging a card in the view changes the page. Collections update as pages change, and they work offline. A collection can also sit inside a page as a live block, from Insert, Collection or the slash menu, and it updates as notes change. | Built, untested by hand | +| Collections | A collection is a saved view of pages chosen by a search, a tag, a section, or property values, such as "open assignments for one class, grouped by due week". It shows as a table, a list, a board, a calendar, or a gallery, and it can sit inside a page as a live block. Editing a property or dragging a card in the view changes the page. Collections update as pages change, and they work offline. A collection can also sit inside a page as a live block, from Insert, Collection or the slash menu, and it updates as notes change. | Built, untested by hand | | Page embeds | Typing `![[` and choosing a page, a heading, or a paragraph shows it live inside another page. It can be edited in either place, and both update. A small header names the source and opens it. Embeds survive renames and moves, and exports include their text. An embed of locked text shows a lock instead. `![[Page#^id]]` shows the text box that holds one paragraph. Markdown, web page, and PDF exports include the embedded text as a quote under the page title. | Built, untested by hand | | Graph view | Shows pages as dots and links as lines, for a whole notebook or for the pages within 1 to 3 links of the open one. Filters use tags, sections, and properties. A "Connections" list gives the same information for keyboard and screen reader use, and pages with no links appear in a list of their own. | Built, untested by hand | | Canvas of cards | A canvas is an open board of cards: pages, typed notes, images, PDFs, web addresses, and groups, joined by labeled arrows. Opening a card opens its page. Commands align cards in a row, a column, or a grid. A canvas can be imported from and exported to the open JSON Canvas format. | Built, untested by hand | @@ -836,23 +836,31 @@ On in Beta and off in Stable. The [help page](help/subject-tool-kits.md) explain (Beta 9 to 12 work. These lanes are built on the `beta` branch. Several are behind flags that are off in Stable, and some wait for a later lane to wire them in. Help: [Typed notes](help/typed-notes.md), [Tables and charts](help/tables-and-charts.md), [Import and export](help/import-and-export.md), and [Code cells](help/code-cells.md).) -| Feature | What it does | Status | -| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | -| Format version 2 | Notebooks and pages that hold version 2 data are stamped 2, with minWriterVersion, so an older OpenNote refuses a file it cannot keep intact. Unknown keys and invalid values are kept. Pages with nothing new stay version 1. See ADR 0077 and ADR 0078. | Built, untested by hand | -| Run and paragraph formats | Font, size, weight, footnote reference, and foreign marks are read and written as inline tags. Paragraph and list data is stored beside the text by element ID. The CSS for every format draws in the editor and in the static page. The text.v2Controls flag keeps the new controls off in Stable. | Built, untested by hand | -| Editor commands for formats | Set and toggle commands cover every run and paragraph format. Size steps, three clear rules, change case, the format painter (once or locked), repeat, select similar, and speak formatting follow in the command catalog. Keys and buttons wait for the controls lane. Each undoes as one step. | Built, untested by hand | -| Footnotes and endnotes | Insert, number in reading order, restart per document or at each Heading 1, edit in a notes area under the page, and jump between the reference and the note. Restart per printed sheet is not built. | Built, untested by hand | -| Symbols and formatting marks | The Symbol dialog searches by name or code. Alt+X turns hex digits into a character and back. Nonbreaking space, dashes, and soft hyphens have commands. Show formatting marks draws paragraph, space, and tab marks without changing the Markdown. Behind the text.symbols flag. | Built, untested by hand | -| Copy and paste with formatting | Copy writes inline CSS that Word and Google Docs keep. Paste keeps the source formatting from Word, Google Docs, and OneNote, and a Paste Options notice switches between keep source, merge, and plain text. Settings sets a default per source. | Built, untested by hand | -| Bundled fonts and device fonts | Carlito and Caladea stand in for Calibri and Cambria, and load only when a page uses them. The app lists the device's fonts with their metrics, and nothing about them leaves the device. | Built, untested by hand | -| Word import and export | Import reads styles, themes, lists, notes, comments, and tracked changes into version 2 data. Export writes every version 2 format, can embed the bundled fonts, and has a Remove personal information switch. The device font list is not yet sent to the writer. | Built, untested by hand | -| Shared color palette | One palette for text and tables: theme colors with tints and shades, standard colors, recent colors per notebook, Automatic, No color, and More colors. | Built, untested by hand | -| Sheet cell styles in tables | A table keeps cell styles, merges, rules, names, and cached results in data.sheet. Cells draw padding, alignment, wrap, rotation, fonts, fills, borders, row heights, frozen panes, banding, and 21 looks. The drawing layer is not yet wired into the table editor, and merged cells are not drawn. Behind the tables.sheetWrites flag. | Built, untested by hand | -| Number format codes | Format codes with sections, colors, and conditions, regional display, and currency, percent, and date column presets, all computed on the device. | Built, untested by hand | -| Excel-style formulas | Formulas follow Excel's rules for text, numbers, errors, and dates, with a registry of functions, names, LET and LAMBDA, dynamic arrays that spill, and references across tables. Pages above 20,000 cells recalculate in a worker. The list of known differences is in the engine's DIFFERENCES.md. | Built, untested by hand | -| Code studio foundations | The contracts, settings, flags, and strings for the code studio: projects as a page block, runs, diagnostics, completion, a replay debugger, tests, and runtime packs. The code studio window is off until the code.studio flag is on. | Built, untested by hand | -| Code editor keys | Tab indents in the code editor, and Ctrl+Page Down and Ctrl+Page Up change tabs, so the tab bar stays reachable. Ctrl+Tab will accept a suggestion when code lane L01 lands. | Built, untested by hand | -| Calm shell polish | Phosphor icons in the command bar, pens back on the bar at 1,280 px, calm pressed and selected states, notebook accent roles, and group dividers. | Built, untested by hand | +| Feature | What it does | Status | +| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | +| Format version 2 | Notebooks and pages that hold version 2 data are stamped 2, with minWriterVersion, so an older OpenNote refuses a file it cannot keep intact. Unknown keys and invalid values are kept. Pages with nothing new stay version 1. See ADR 0077 and ADR 0078. | Built, untested by hand | +| Run and paragraph formats | Font, size, weight, footnote reference, and foreign marks are read and written as inline tags. Paragraph and list data is stored beside the text by element ID. The CSS for every format draws in the editor and in the static page. The text.v2Controls flag keeps the new controls off in Stable. | Built, untested by hand | +| Editor commands for formats | Set and toggle commands cover every run and paragraph format. Size steps, three clear rules, change case, the format painter (once or locked), repeat, select similar, and speak formatting follow in the command catalog. Keys and buttons wait for the controls lane. Each undoes as one step. | Built, untested by hand | +| Footnotes and endnotes | Insert, number in reading order, restart per document or at each Heading 1, edit in a notes area under the page, and jump between the reference and the note. Restart per printed sheet is not built. | Built, untested by hand | +| Symbols and formatting marks | The Symbol dialog searches by name or code. Alt+X turns hex digits into a character and back. Nonbreaking space, dashes, and soft hyphens have commands. Show formatting marks draws paragraph, space, and tab marks without changing the Markdown. Behind the text.symbols flag. | Built, untested by hand | +| Copy and paste with formatting | Copy writes inline CSS that Word and Google Docs keep. Paste keeps the source formatting from Word, Google Docs, and OneNote, and a Paste Options notice switches between keep source, merge, and plain text. Settings sets a default per source. | Built, untested by hand | +| Bundled fonts and device fonts | Carlito and Caladea stand in for Calibri and Cambria, and load only when a page uses them. The app lists the device's fonts with their metrics, and nothing about them leaves the device. | Built, untested by hand | +| Word import and export | Import reads styles, themes, lists, notes, comments, and tracked changes into version 2 data. Export writes every version 2 format, can embed the bundled fonts, and has a Remove personal information switch. The device font list is not yet sent to the writer. | Built, untested by hand | +| Shared color palette | One palette for text and tables: theme colors with tints and shades, standard colors, recent colors per notebook, Automatic, No color, and More colors. | Built, untested by hand | +| Sheet cell styles in tables | A table keeps cell styles, merges, rules, names, and cached results in data.sheet. Cells draw padding, alignment, wrap, rotation, fonts, fills, borders, row heights, frozen panes, banding, and 21 looks. The drawing layer is not yet wired into the table editor, and merged cells are not drawn. Behind the tables.sheetWrites flag. | Built, untested by hand | +| Number format codes | Format codes with sections, colors, and conditions, regional display, and currency, percent, and date column presets, all computed on the device. | Built, untested by hand | +| Excel-style formulas | Formulas follow Excel's rules for text, numbers, errors, and dates, with a registry of functions, names, LET and LAMBDA, dynamic arrays that spill, and references across tables. Pages above 20,000 cells recalculate in a worker. The list of known differences is in the engine's DIFFERENCES.md. | Built, untested by hand | +| Logic and information functions | IFS, SWITCH, XOR, IFNA, the IS functions, CELL, INFO, SHEET, SHEETS, TYPE, and ERROR.TYPE. CELL("filename") stays empty until the page passes its title to the engine. | Built, untested by hand | +| Lookup and reference functions | XLOOKUP and XMATCH with every match and search mode, INDEX, MATCH, and the older lookups. Also OFFSET, INDIRECT in A1 and R1C1 style, ROW, COLUMN, AREAS, FORMULATEXT, and HYPERLINK. A reference to the calling cell gives #CYCLE!. HYPERLINK shows text; drawing it as a link waits for the sheet display. | Built, untested by hand | +| Array and matrix functions | FILTER, SORT, SORTBY, UNIQUE, SEQUENCE, RANDARRAY, TAKE, DROP, the stack and wrap functions, TRIMRANGE, MMULT, MINVERSE, and MDETERM. Also the LAMBDA helpers MAP, REDUCE, SCAN, BYROW, BYCOL, and MAKEARRAY. Results spill. A result is capped at 1,000,000 values. | Built, untested by hand | +| Statistical functions | Averages, deviations, ranks, percentiles, the IFS family, distributions with their inverses, tests, regression, and FORECAST.ETS. The code loads on first use, so a cell shows #BUSY! for a moment and then recalculates, in the worker too. | Built, untested by hand | +| Cell format bar | A second row under the table tools sets the font, size, text styles, and colors. It also sets 16 border presets with line style and color, alignment, wrap, rotation, and number formats. Each change is one undo step. Behind the tables.sheetWrites flag. | Built, untested by hand | +| Spreadsheet keys in tables | Grid, Edit, and Enter modes use Excel's keys. They cover arrows, Ctrl and Shift jumps, F2, Esc, Enter and Tab cycling, Ctrl+D and Ctrl+R fills, and date and time entry. Screen readers hear each cell. Use document keys in this table switches back, and a setting chooses the keys for new tables. See ADR 0063. Behind the tables.sheetWrites flag. | Built, untested by hand | +| Conditional formatting | Rules color cells by value, text, date, blank, error, top and bottom, average, duplicate, or formula. Color scales, data bars, and icon sets come too. Rules run in priority order with Stop if true. The rule manager opens from the palette and the context menu; the new-rule editor waits for the sheet host. | Built, untested by hand | +| Table styles in page exports | Word, HTML, and Markdown exports write each table's last results. Word and HTML keep fills, borders, alignment, padding, fonts, and merged cells. The export report names a table whose results are out of date. | Built, untested by hand | +| Code studio foundations | The contracts, settings, flags, and strings for the code studio: projects as a page block, runs, diagnostics, completion, a replay debugger, tests, and runtime packs. The code studio window is off until the code.studio flag is on. | Built, untested by hand | +| Code editor keys | Tab indents in the code editor, and Ctrl+Page Down and Ctrl+Page Up change tabs, so the tab bar stays reachable. Ctrl+Tab will accept a suggestion when code lane L01 lands. | Built, untested by hand | +| Calm shell polish | Phosphor icons in the command bar, pens back on the bar at 1,280 px, calm pressed and selected states, notebook accent roles, and group dividers. | Built, untested by hand | ## Appearance and layout diff --git a/docs/adr/0063-spreadsheet-keyboard-model.md b/docs/adr/0063-spreadsheet-keyboard-model.md index 9b3fbe261..7221458e5 100644 --- a/docs/adr/0063-spreadsheet-keyboard-model.md +++ b/docs/adr/0063-spreadsheet-keyboard-model.md @@ -1,6 +1,6 @@ # ADR 0063: Tables use a spreadsheet keyboard model with grid and edit modes -- Status: Proposed +- Status: Accepted - Date: 2026-11-01 ## Context @@ -21,6 +21,18 @@ We will give tables two modes. Grid mode (Excel's Ready mode) has an active cell ## Consequences -- Screen readers get roving focus on the active cell and an announcement per move (DESIGN.md section 17). +- Screen readers hear an announcement per move in place of roving focus on the active cell (DESIGN.md section 17). DOM focus stays on the editor, because a focused cell would break the editor's selection. Roving focus can come later through `aria-activedescendant` on the grid. - The shortcuts page and help gain a table section for both modes. - Changing a chord that collides with an app chord needs a scoped keymap context in `state/keymap.ts`. + +## Implementation notes + +The keys live in `app/src/features/tables/keys/` (tables:keys, B10), behind the flag `tables.sheetWrites`: + +- Grid mode is a cell selection. Its anchor is the active cell; once Enter or Tab moves through a range, the plugin keeps the active cell beside the range. Edit mode is the caret in a cell's text. An entry begun by typing, Backspace, F2, or Ctrl+' remembers the cell's content, so Esc puts it back. An entry begun by typing or Backspace is Enter mode, where arrows finish it and move. +- A ProseMirror plugin, registered first through the table extra (`features/page/tables/extras.ts`), handles the keys. Chords a person may change are commands in the registry, in the scopes `tableGrid`, `tableEdit`, and `editor.table` that `commands/keymap.ts` already nests under the editor. The plugin looks up the person's own chords, so rebinding works. The app's key handler runs the chords that reach commands from a text field (Ctrl, Alt, function keys); the plugin runs the rest. Ctrl+D, Ctrl+R, and Ctrl+Enter refine the table's existing commands, so the registry has no conflicts. +- The navigation keys that `commands/reserved.ts` keeps free (arrows, Enter, Tab, Home, Page Up, Page Down, and Esc) and Shift+Space are fixed. The shortcut list shows them in a Table keys section; the changeable chords are in its Table group. +- Esc on a single cell goes on to the editor, which selects the table as an object, so the keyboard can always leave the table. +- `data.sheet.table.keys` is `spreadsheet` or `document` (spec R-SHT-10). Insert table stores the setting "New tables use spreadsheet keys". A table that stores nothing keeps document keys when it holds only text, and uses spreadsheet keys when a cell holds a number or a formula (PLAN.md decision 4). The table menu's "Use document keys in this table" switches it. +- Screen readers: every cell has `aria-rowindex` and `aria-colindex`. With spreadsheet keys the table has `role="grid"` and each cell `role="gridcell"` or `role="columnheader"`, so in Grid mode the range can have `aria-selected`. The range drops `aria-selected` while focus is outside the editor, as when the table is selected as an object. DOM focus stays on the editor, because a cell inside an editable region cannot take focus without breaking the editor's selection, and the editor's root has no role that allows `aria-activedescendant`. The grid role on the table is the first step toward it. So instead of roving `tabindex`, each move says the address, header, shown value, and formula in words through the polite live region. The active cell has a thick outline in Grid mode and a thin one in Edit mode. +- Ctrl+5 is not bound: Ctrl+1 to Ctrl+9 stay free for tags. Bold, italic, underline, and the strikethrough command already format the text of selected cells. Keys that other lanes own (Ctrl+1, F4, Alt+=, the number format, border, structure, filter, note, function, and paste chords) register in the same scopes. diff --git a/docs/adr/README.md b/docs/adr/README.md index ab27159a6..931527088 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -93,7 +93,7 @@ Accepted records aren't rewritten when plans change. Instead, write a new ADR fo | [0060](0060-own-function-library-and-oracle-fixtures.md) | An in-repository function library tested against reference values | Proposed | 2026-11-01 | | [0061](0061-cached-formula-results.md) | Tables cache formula results in the file | Proposed | 2026-11-01 | | [0062](0062-names-and-cross-table-references.md) | Names, structured references, and references to other tables and pages | Proposed | 2026-11-01 | -| [0063](0063-spreadsheet-keyboard-model.md) | Tables use a spreadsheet keyboard model with grid and edit modes | Proposed | 2026-11-01 | +| [0063](0063-spreadsheet-keyboard-model.md) | Tables use a spreadsheet keyboard model with grid and edit modes | Accepted | 2026-11-01 | | [0064](0064-merged-cells.md) | Merged cells are ranges by corner IDs over a whole grid | Proposed | 2026-11-01 | | [0065](0065-incremental-recalculation.md) | Incremental recalculation, iterative calculation, and the worker | Proposed | 2026-11-01 | | [0066](0066-table-merge-on-sync.md) | Three-way merge of table blocks when two devices edit one page | Proposed | 2026-11-01 | diff --git a/docs/format/README.md b/docs/format/README.md index 85726a9b3..eb9739db6 100644 --- a/docs/format/README.md +++ b/docs/format/README.md @@ -1035,6 +1035,7 @@ A cell of a choice column stores the name of its choice as plain Markdown text, | `pad` | As the style key `pad` | `R-SHT-8` | Default padding of every cell | | `grid` | boolean | `true` | Gridlines where a cell has no border | | `name` | A name (`R-SHT-13`), unique among the page's tables without regard to case | `Table1`, `Table2`, and so on, by the table's place in the page's reading order | The name formulas use for this table | +| `keys` | `spreadsheet` or `document` | `spreadsheet` when a cell holds a number or a formula, else `document` | The table's keys: a spreadsheet's Grid and Edit modes, or a text editor's, where Enter starts a new line in the cell. Writers store the setting "New tables use spreadsheet keys" in each new table. A reader that has no spreadsheet keys ignores it | `R-SHT-11`. A column's settings are `style` (style ID), `hidden` (boolean, default `false`), `level` (outline depth, an integer from 0 to 7, default 0), and `collapsed` (boolean, default `false`). A row's settings add `height` and `exact`. `height` is in page units, from 1 to 10,000, rounded as a geometry value; absent means the row fits its content. `exact` is `true` when the height is exact, and `false` (the default) when it is a minimum. Column widths stay in the `width` of `data.columns`, in page units (`R-NUM-2`). diff --git a/docs/format/tools/schema_sheet.py b/docs/format/tools/schema_sheet.py index 751c77ce4..1e875e242 100644 --- a/docs/format/tools/schema_sheet.py +++ b/docs/format/tools/schema_sheet.py @@ -89,6 +89,7 @@ def is_name(key): "pad": PAD, "grid": BOOL, "name": ok(is_name, "a name (R-SHT-13)"), + "keys": enum("spreadsheet", "document"), } ) COL = obj({"style": STYLE_REF, "hidden": BOOL, "level": integer(0, 7), "collapsed": BOOL}) diff --git a/docs/help/tables-and-charts.md b/docs/help/tables-and-charts.md index 9bb996535..9349979fd 100644 --- a/docs/help/tables-and-charts.md +++ b/docs/help/tables-and-charts.md @@ -22,6 +22,15 @@ A smart table holds typed columns, formulas, and several views of the same rows. Add a calculated column and type a formula, such as `=Price * Qty`. The column fills in for every row and stays up to date as you edit. +Formulas follow Excel's rules. Beyond math, text, and dates, they have these families: + +- Logic and information: IFS, SWITCH, XOR, IFNA, the IS functions, CELL, INFO, TYPE, and ERROR.TYPE. +- Lookup: XLOOKUP, XMATCH, INDEX, MATCH, VLOOKUP, HLOOKUP, OFFSET, INDIRECT, ROW, COLUMN, FORMULATEXT, and HYPERLINK. +- Arrays: FILTER, SORT, SORTBY, UNIQUE, SEQUENCE, TAKE, DROP, VSTACK, HSTACK, MMULT, MINVERSE, and the LAMBDA helpers MAP, REDUCE, SCAN, BYROW, BYCOL, and MAKEARRAY. An array result spills into the cells beside it. +- Statistics: averages, deviations, ranks, percentiles, distributions such as NORM.DIST and T.DIST, regression such as LINEST, and FORECAST.ETS. These load the first time a formula uses one, so the cell shows #BUSY! for a moment. + +The places where OpenNote's answers differ from Excel's are listed in the engine's DIFFERENCES.md. + ## Views A table can show its rows as a board, a calendar, a gallery, or a timeline. The rows do not change. Only the view does. @@ -36,6 +45,8 @@ Every chart has a text summary and a data table, so a screen reader can read it. Export a table as an Excel file (`.xlsx`) or as `.csv`. Both are in the Export dialog. +A page exported to Word, HTML, or Markdown keeps each table's last results. Word and HTML also keep fills, borders, alignment, fonts, and merged cells. When a table's results are out of date, the export report names it. + Smart tables and charts are built. They have been checked by automated tests only. The [feature list](../FEATURES.md) shows their status. ## Choices @@ -63,3 +74,25 @@ A number can have a unit, such as 12.5 kg. When all the numbers in a column shar ## Saved views In the Data menu under a table, choose Saved views, then Save filters and sort as a view. Pick a view later to see the same rows in the same order. Each table keeps its own views. + +## Format cells + +When a table is current, a second row of tools sits under the table tools. It sets the font, size, bold, italic, underline, and strikethrough, the text and fill colors, borders, alignment, wrapping, rotation, and the number format. It also has Currency, Percent, the thousands separator, more and fewer decimals, and Clear formats. + +Select whole cells and a tool formats the cells. Select text inside one cell and the same tool formats only that text. Each change is one undo step. + +The Borders button draws the last border you used. Its arrow opens all 16 borders, the line style, and the line color. In a narrow window, the least used tools move into a More menu. Press Ctrl+K and type "Cell:" to find every format by name. + +## Spreadsheet keys + +A table can use spreadsheet keys. In Grid mode the arrow keys move between cells, Shift extends the selection, and Ctrl jumps to the edge of the data. Type to replace a cell, or press F2 to edit it with the caret at the end. Esc puts the old content back. Enter and Tab move to the next cell, and inside a selection they cycle through it. + +Ctrl+D and Ctrl+R fill down and right, and formula references move with them. Ctrl+; enters today's date and Ctrl+: the time. Alt+Enter starts a new line in a cell. + +Choose Use document keys in this table to get the old keys back, where Enter starts a new line. A setting chooses the keys for new tables. A screen reader hears each cell's position, column name, value, and formula. Keyboard shortcuts lists every key. + +## Conditional formatting + +A rule can color a cell by its value, its text, a date, blanks, errors, top or bottom values, values above or below the average, duplicates, or a formula. Color scales, data bars, and icon sets show a range at a glance. Rules run in priority order, and Stop if true keeps lower rules from applying. + +To manage rules, run Cell: Conditional formatting rules from the command palette (Ctrl+K) or the table's context menu. A window lists the rules of the selection, the table, or the page. You can raise or lower a rule, turn Stop if true on or off, or delete it. Undo takes each change back. Making a new rule from a window is not built yet. diff --git a/docs/tools/featureCommands.ts b/docs/tools/featureCommands.ts index 9cb86e891..63bf99f91 100644 --- a/docs/tools/featureCommands.ts +++ b/docs/tools/featureCommands.ts @@ -2,7 +2,9 @@ // words of its own. A kit tab with an about, intro, lead, or summary string uses that instead (registry.json `about`). // The generator stops when a listed command, section, or step has no sentence, so a new one must get a line here. +import { CELL_COMMAND_TEXT } from './featureCommandsCells.ts'; import { HOME_TAB_TEXT } from './featureCommandsHome.ts'; +import { SHEET_COMMAND_TEXT } from './featureCommandsSheet.ts'; /** Each settings section, by its registry id. */ export const SETTINGS_TEXT: Readonly> = { @@ -10,6 +12,7 @@ export const SETTINGS_TEXT: Readonly> = { appearance: 'Sets the theme, the page color in dark mode, the text and interface size, and reduced motion.', language: 'Sets the app language and the spelling dialect, with American English as the default.', editing: 'Turns Markdown shortcuts, the slash menu, and AutoCorrect on or off.', + tables: 'Chooses whether new tables use spreadsheet keys or document keys.', recording: 'Sets live captions, transcripts of recordings, audio formats, and the consent reminder.', penAndTouch: 'Sets the writing hand, finger drawing, the steady pen, and the pressure curve.', myHandwriting: 'Calibrates the app to your handwriting and the symbols you write by hand.', @@ -39,6 +42,7 @@ export const SETUP_TEXT: Readonly> = { /** Each command with no words of its own, by its registry id. */ export const COMMAND_TEXT: Readonly> = { + ...CELL_COMMAND_TEXT, ...HOME_TAB_TEXT, 'tools.calculator.basic': 'Opens the calculator on its basic tab for everyday arithmetic.', 'tools.calculator.scientific': 'Opens the calculator on its scientific tab, with functions, powers, and angle modes.', @@ -389,6 +393,7 @@ export const COMMAND_TEXT: Readonly> = { 'table.editChoices': 'Edits the list of choices of the column.', 'table.totalSum': 'Adds a total row with the sum of the column.', 'table.totalNone': "Removes the column's total.", + ...SHEET_COMMAND_TEXT, 'insert.chartBar': 'Inserts a bar chart of the table.', 'insert.chartLine': 'Inserts a line chart of the table.', 'insert.chartArea': 'Inserts an area chart of the table.', diff --git a/docs/tools/featureCommandsCells.ts b/docs/tools/featureCommandsCells.ts new file mode 100644 index 000000000..8438f02ba --- /dev/null +++ b/docs/tools/featureCommandsCells.ts @@ -0,0 +1,68 @@ +// One sentence for each cell format command of the table toolbar (featureCommands.ts adds these). + +export const CELL_COMMAND_TEXT: Readonly> = { + 'table.cell.fill': 'Opens the fill colors for the selected cells.', + 'table.cell.textColor': 'Opens the text colors for the selected cells.', + 'table.cell.borders': 'Draws the last border preset again on the selected cells.', + 'table.cell.borderAll': 'Draws a border around every selected cell.', + 'table.cell.borderOutside': 'Draws a border around the outside of the selected cells.', + 'table.cell.borderNone': 'Takes every border off the selected cells.', + 'table.cell.wrap': 'Wraps long text inside the selected cells.', + 'table.cell.alignLeft': 'Aligns the selected cells to the left.', + 'table.cell.alignCenter': 'Centers the selected cells.', + 'table.cell.alignRight': 'Aligns the selected cells to the right.', + 'table.cell.alignTop': 'Aligns the text of the selected cells to the top.', + 'table.cell.alignMiddle': 'Centers the text of the selected cells between top and bottom.', + 'table.cell.alignBottom': 'Aligns the text of the selected cells to the bottom.', + 'table.cell.currency': 'Shows the selected cells as currency.', + 'table.cell.percent': 'Shows the selected cells as a percent.', + 'table.cell.general': 'Takes the number format off the selected cells.', + 'table.cell.thousands': 'Turns the thousands separator on or off in the selected cells.', + 'table.cell.moreDecimals': 'Shows one more decimal place in the selected cells.', + 'table.cell.fewerDecimals': 'Shows one fewer decimal place in the selected cells.', + 'table.cell.borderThickOutside': 'Draws the thick outside borders on the selected cells.', + 'table.cell.borderTop': 'Draws the top border on the selected cells.', + 'table.cell.borderBottom': 'Draws the bottom border on the selected cells.', + 'table.cell.borderLeft': 'Draws the left border on the selected cells.', + 'table.cell.borderRight': 'Draws the right border on the selected cells.', + 'table.cell.borderInsideH': 'Draws the inside horizontal borders on the selected cells.', + 'table.cell.borderInsideV': 'Draws the inside vertical borders on the selected cells.', + 'table.cell.borderBottomDouble': 'Draws the bottom double border on the selected cells.', + 'table.cell.borderBottomThick': 'Draws the thick bottom border on the selected cells.', + 'table.cell.borderTopBottom': 'Draws the top and bottom border on the selected cells.', + 'table.cell.borderTopThickBottom': 'Draws the top and thick bottom border on the selected cells.', + 'table.cell.borderDiagonalUp': 'Draws the diagonal up border on the selected cells.', + 'table.cell.borderDiagonalDown': 'Draws the diagonal down border on the selected cells.', + 'table.cell.lineStyleThin': 'Draws the next borders with a thin line.', + 'table.cell.lineStyleMedium': 'Draws the next borders with a medium line.', + 'table.cell.lineStyleThick': 'Draws the next borders with a thick line.', + 'table.cell.lineStyleHair': 'Draws the next borders with a hairline line.', + 'table.cell.lineStyleDotted': 'Draws the next borders with a dotted line.', + 'table.cell.lineStyleDashed': 'Draws the next borders with a dashed line.', + 'table.cell.lineStyleDouble': 'Draws the next borders with a double line.', + 'table.cell.borderColor': 'Opens the colors for the line the next borders are drawn with.', + 'table.cell.rotateNone': 'Shows the text of the selected cells horizontally.', + 'table.cell.rotateUp': 'Turns the text of the selected cells up 45 degrees.', + 'table.cell.rotateDown': 'Turns the text of the selected cells down 45 degrees.', + 'table.cell.rotateVertical': 'Turns the text of the selected cells to read bottom to top.', + 'table.cell.rotateStacked': 'Stacks the letters of the selected cells one above the other.', + 'table.cell.wrapOverflow': 'Lets long text run into the empty cells next to the selected cells.', + 'table.cell.wrapClip': 'Cuts long text off at the edge of the selected cells.', + 'table.cell.alignJustify': 'Spreads the text of the selected cells from edge to edge.', + 'table.cell.formatNumber': 'Shows the selected cells as plain numbers with two decimals.', + 'table.cell.formatAccounting': 'Shows the selected cells with the accounting number format.', + 'table.cell.formatShortDate': 'Shows the selected cells with the short date number format.', + 'table.cell.formatLongDate': 'Shows the selected cells with the long date number format.', + 'table.cell.formatTime': 'Shows the selected cells with the time number format.', + 'table.cell.formatFraction': 'Shows the selected cells with the fraction number format.', + 'table.cell.formatScientific': 'Shows the selected cells with the scientific number format.', + 'table.cell.formatText': 'Shows the selected cells with the text number format.', + 'table.cell.underlineNone': 'Sets the no underline on the selected cells or text.', + 'table.cell.underlineSingle': 'Sets the single underline on the selected cells or text.', + 'table.cell.underlineDouble': 'Sets the double underline on the selected cells or text.', + 'table.cell.underlineSingleAccounting': 'Sets the single accounting underline on the selected cells or text.', + 'table.cell.underlineDoubleAccounting': 'Sets the double accounting underline on the selected cells or text.', + 'table.cell.clearFormats': 'Takes every format off the selected cells and leaves their text.', + 'table.cell.conditionalRules': + 'Opens the conditional formatting rules of the selection, the table, or the page, to reorder, stop, or delete them.', +}; diff --git a/docs/tools/featureCommandsSheet.ts b/docs/tools/featureCommandsSheet.ts new file mode 100644 index 000000000..1eb297c20 --- /dev/null +++ b/docs/tools/featureCommandsSheet.ts @@ -0,0 +1,42 @@ +// What the spreadsheet keys of a table do, for the feature list (docs/FEATURE-LIST.md). Split from featureCommands.ts. +export const SHEET_COMMAND_TEXT: Readonly> = { + 'table.sheet.extendUp': 'In Grid mode, extends the selected cells one row up.', + 'table.sheet.extendDown': 'In Grid mode, extends the selected cells one row down.', + 'table.sheet.extendLeft': 'In Grid mode, extends the selected cells one column left.', + 'table.sheet.extendRight': 'In Grid mode, extends the selected cells one column right.', + 'table.sheet.jumpUp': 'In Grid mode, goes to the top edge of the data in the column.', + 'table.sheet.jumpDown': 'In Grid mode, goes to the bottom edge of the data in the column.', + 'table.sheet.jumpLeft': 'In Grid mode, goes to the left edge of the data in the row.', + 'table.sheet.jumpRight': 'In Grid mode, goes to the right edge of the data in the row.', + 'table.sheet.extendJumpUp': 'In Grid mode, extends the selected cells to the top edge of the data.', + 'table.sheet.extendJumpDown': 'In Grid mode, extends the selected cells to the bottom edge of the data.', + 'table.sheet.extendJumpLeft': 'In Grid mode, extends the selected cells to the left edge of the data.', + 'table.sheet.extendJumpRight': 'In Grid mode, extends the selected cells to the right edge of the data.', + 'table.sheet.firstCell': "In Grid mode, goes to the table's first cell.", + 'table.sheet.lastCell': 'In Grid mode, goes to the last cell that holds data.', + 'table.sheet.extendFirstCell': "In Grid mode, extends the selected cells to the table's first cell.", + 'table.sheet.extendLastCell': 'In Grid mode, extends the selected cells to the last cell that holds data.', + 'table.sheet.extendRowStart': 'In Grid mode, extends the selected cells to the start of the row.', + 'table.sheet.screenLeft': 'In Grid mode, moves one screen of columns left.', + 'table.sheet.screenRight': 'In Grid mode, moves one screen of columns right.', + 'table.sheet.moveUp': 'In Grid mode, moves up a cell, or back through the selected cells.', + 'table.sheet.fillEntry': 'In Grid mode, fills every selected cell with the active cell, moving formula references.', + 'table.sheet.edit': 'In Grid mode, starts editing the active cell with the caret at the end.', + 'table.sheet.clear': 'In Grid mode, clears the text of every selected cell.', + 'table.sheet.clearAndEdit': 'In Grid mode, clears the active cell and starts editing it.', + 'table.sheet.selectColumn': 'In Grid mode, selects the whole column.', + 'table.sheet.selectRegion': 'In Grid mode, selects the data around the active cell, then the whole table.', + 'table.sheet.fillDown': 'In Grid mode, copies the top cell of the selection down, moving formula references.', + 'table.sheet.fillRight': 'In Grid mode, copies the left cell of the selection right, moving formula references.', + 'table.sheet.copyFormulaAbove': 'In Grid mode, copies the formula of the cell above into the active cell.', + 'table.sheet.copyValueAbove': 'In Grid mode, copies the value of the cell above into the active cell.', + 'table.sheet.insertDate': "Enters today's date in the cell, as a date the formulas read.", + 'table.sheet.insertTime': 'Enters the current time in the cell.', + 'table.sheet.commitUp': "In Edit mode, finishes the cell's entry and moves up.", + 'table.sheet.commitStay': "In Edit mode, finishes the cell's entry and stays in the cell.", + 'table.sheet.lineBreak': 'In Edit mode, starts a new line in the cell.', + 'table.sheet.toggleEnterMode': + 'In Edit mode, switches between Enter mode, where arrows move to the next cell, and Edit mode.', + 'table.sheet.documentKeys': + 'Switches a table between spreadsheet keys and document keys, where Enter starts a new line.', +}; diff --git a/docs/tools/registry.json b/docs/tools/registry.json index 399dcb475..45ddd99de 100644 --- a/docs/tools/registry.json +++ b/docs/tools/registry.json @@ -6442,6 +6442,810 @@ "keys": [], "flag": "sync.e2ee" }, + { + "id": "table.cell.fill", + "title": "Cell: Fill color", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.textColor", + "title": "Cell: Text color", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.borders", + "title": "Cell: Borders", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.borderAll", + "title": "Cell: All borders", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.borderOutside", + "title": "Cell: Outside borders", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.borderThickOutside", + "title": "Cell: Thick outside borders", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.borderTop", + "title": "Cell: Top border", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.borderBottom", + "title": "Cell: Bottom border", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.borderLeft", + "title": "Cell: Left border", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.borderRight", + "title": "Cell: Right border", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.borderInsideH", + "title": "Cell: Inside horizontal borders", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.borderInsideV", + "title": "Cell: Inside vertical borders", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.borderNone", + "title": "Cell: No border", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.borderBottomDouble", + "title": "Cell: Bottom double border", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.borderBottomThick", + "title": "Cell: Thick bottom border", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.borderTopBottom", + "title": "Cell: Top and bottom border", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.borderTopThickBottom", + "title": "Cell: Top and thick bottom border", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.borderDiagonalUp", + "title": "Cell: Diagonal up border", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.borderDiagonalDown", + "title": "Cell: Diagonal down border", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.lineStyleThin", + "title": "Cell: Border line thin", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.lineStyleMedium", + "title": "Cell: Border line medium", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.lineStyleThick", + "title": "Cell: Border line thick", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.lineStyleHair", + "title": "Cell: Border line hairline", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.lineStyleDotted", + "title": "Cell: Border line dotted", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.lineStyleDashed", + "title": "Cell: Border line dashed", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.lineStyleDouble", + "title": "Cell: Border line double", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.borderColor", + "title": "Cell: Border line color", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.wrap", + "title": "Cell: Wrap text", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.wrapOverflow", + "title": "Cell: Overflow into empty cells", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.wrapClip", + "title": "Cell: Clip text", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.rotateNone", + "title": "Cell: Horizontal text", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.rotateUp", + "title": "Cell: Rotate text up", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.rotateDown", + "title": "Cell: Rotate text down", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.rotateVertical", + "title": "Cell: Vertical text", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.rotateStacked", + "title": "Cell: Stacked text", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.alignLeft", + "title": "Cell: Align left", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.alignCenter", + "title": "Cell: Align center", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.alignRight", + "title": "Cell: Align right", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.alignJustify", + "title": "Cell: Justify", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.alignTop", + "title": "Cell: Align top", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.alignMiddle", + "title": "Cell: Align middle", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.alignBottom", + "title": "Cell: Align bottom", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.underlineNone", + "title": "Cell: No underline", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.underlineSingle", + "title": "Cell: Single underline", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.underlineDouble", + "title": "Cell: Double underline", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.underlineSingleAccounting", + "title": "Cell: Single accounting underline", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.underlineDoubleAccounting", + "title": "Cell: Double accounting underline", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.currency", + "title": "Cell: Currency format", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.percent", + "title": "Cell: Percent format", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.general", + "title": "Cell: General format", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.formatNumber", + "title": "Cell: Number format", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.formatAccounting", + "title": "Cell: Accounting format", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.formatShortDate", + "title": "Cell: Short date format", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.formatLongDate", + "title": "Cell: Long date format", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.formatTime", + "title": "Cell: Time format", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.formatFraction", + "title": "Cell: Fraction format", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.formatScientific", + "title": "Cell: Scientific format", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.formatText", + "title": "Cell: Text format", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.thousands", + "title": "Cell: Thousands separator", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.moreDecimals", + "title": "Cell: More decimals", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.fewerDecimals", + "title": "Cell: Fewer decimals", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.clearFormats", + "title": "Cell: Clear formats", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.cell.conditionalRules", + "title": "Cell: Conditional formatting rules", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, + { + "id": "table.sheet.extendUp", + "title": "Extend the selection up", + "category": "table", + "keys": [ + "Shift+Up" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.extendDown", + "title": "Extend the selection down", + "category": "table", + "keys": [ + "Shift+Down" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.extendLeft", + "title": "Extend the selection left", + "category": "table", + "keys": [ + "Shift+Left" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.extendRight", + "title": "Extend the selection right", + "category": "table", + "keys": [ + "Shift+Right" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.jumpUp", + "title": "Go to the top edge of the data", + "category": "table", + "keys": [ + "Ctrl+Up" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.jumpDown", + "title": "Go to the bottom edge of the data", + "category": "table", + "keys": [ + "Ctrl+Down" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.jumpLeft", + "title": "Go to the left edge of the data", + "category": "table", + "keys": [ + "Ctrl+Left" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.jumpRight", + "title": "Go to the right edge of the data", + "category": "table", + "keys": [ + "Ctrl+Right" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.extendJumpUp", + "title": "Extend the selection to the top edge of the data", + "category": "table", + "keys": [ + "Ctrl+Shift+Up" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.extendJumpDown", + "title": "Extend the selection to the bottom edge of the data", + "category": "table", + "keys": [ + "Ctrl+Shift+Down" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.extendJumpLeft", + "title": "Extend the selection to the left edge of the data", + "category": "table", + "keys": [ + "Ctrl+Shift+Left" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.extendJumpRight", + "title": "Extend the selection to the right edge of the data", + "category": "table", + "keys": [ + "Ctrl+Shift+Right" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.firstCell", + "title": "Go to the first cell", + "category": "table", + "keys": [ + "Ctrl+Home" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.lastCell", + "title": "Go to the last used cell", + "category": "table", + "keys": [ + "Ctrl+End" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.extendFirstCell", + "title": "Extend the selection to the first cell", + "category": "table", + "keys": [ + "Ctrl+Shift+Home" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.extendLastCell", + "title": "Extend the selection to the last used cell", + "category": "table", + "keys": [ + "Ctrl+Shift+End" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.extendRowStart", + "title": "Extend the selection to the start of the row", + "category": "table", + "keys": [ + "Shift+Home" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.screenLeft", + "title": "Move one screen left", + "category": "table", + "keys": [ + "Alt+PageUp" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.screenRight", + "title": "Move one screen right", + "category": "table", + "keys": [ + "Alt+PageDown" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.moveUp", + "title": "Move up a cell", + "category": "table", + "keys": [ + "Shift+Enter" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.fillEntry", + "title": "Fill the selected cells with the active cell", + "category": "table", + "keys": [ + "Ctrl+Enter" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.edit", + "title": "Edit the active cell", + "category": "table", + "keys": [ + "F2" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.clear", + "title": "Clear the selected cells", + "category": "table", + "keys": [ + "Delete" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.clearAndEdit", + "title": "Clear the active cell and edit it", + "category": "table", + "keys": [ + "Backspace" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.selectColumn", + "title": "Select the column", + "category": "table", + "keys": [ + "Ctrl+Space" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.selectRegion", + "title": "Select the data around the cell, then the table", + "category": "table", + "keys": [ + "Ctrl+A" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.fillDown", + "title": "Fill down", + "category": "table", + "keys": [ + "Ctrl+D" + ], + "flag": "tables.sheetWrites" + }, + { + "id": "table.sheet.fillRight", + "title": "Fill right", + "category": "table", + "keys": [ + "Ctrl+R" + ], + "flag": "tables.sheetWrites" + }, + { + "id": "table.sheet.copyFormulaAbove", + "title": "Copy the formula from the cell above", + "category": "table", + "keys": [ + "Ctrl+'" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.copyValueAbove", + "title": "Copy the value from the cell above", + "category": "table", + "keys": [ + "Ctrl+\"" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.insertDate", + "title": "Enter today’s date", + "category": "table", + "keys": [ + "Ctrl+;" + ], + "flag": "tables.sheetWrites" + }, + { + "id": "table.sheet.insertTime", + "title": "Enter the current time", + "category": "table", + "keys": [ + "Ctrl+:" + ], + "flag": "tables.sheetWrites" + }, + { + "id": "table.sheet.commitUp", + "title": "Finish the entry and move up", + "category": "table", + "keys": [ + "Shift+Enter" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.commitStay", + "title": "Finish the entry and stay in the cell", + "category": "table", + "keys": [ + "Ctrl+Enter" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.lineBreak", + "title": "Start a new line in the cell", + "category": "table", + "keys": [ + "Alt+Enter" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.toggleEnterMode", + "title": "Switch between Enter and Edit modes", + "category": "table", + "keys": [ + "F2" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.documentKeys", + "title": "Use document keys in this table", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, { "id": "theme.toggle", "title": "Toggle dark mode", @@ -8053,6 +8857,10 @@ "id": "editing", "title": "Editing" }, + { + "id": "tables", + "title": "Tables" + }, { "id": "recording", "title": "Recording" diff --git a/tests/ui/audit/known.json b/tests/ui/audit/known.json index f38609415..38cc5c9ec 100644 --- a/tests/ui/audit/known.json +++ b/tests/ui/audit/known.json @@ -3,10 +3,11 @@ "block | box-overlap | span.mathInline > div.holderInline > button.toggle x p > strong": "The Edit/Simplify/Solve holder opens as a popover laid over the text after the equation, by design; it is closed again by Escape or a click away.", "block | target-small | div.trace > label.slider > input": "The slider is the browser's own range input, 16 px tall with a 573 px wide hit line; its thumb is the target and it sits within the 24 px minimum spacing.", "block | target-small | li.task > span.taskBox > input": "A native checkbox drawn at the size of the text; making it 24 px would push the item's text and the ruled-paper alignment (a larger hit area needs a custom control).", - "block | text-overlap | div.holderInline > div[role=group].actions > button x th > p": "The Edit/Simplify/Solve holder opens as a popover laid over the text after the equation, by design; it is closed again by Escape or a click away.", - "block | text-overlap | span.mathInline > div.holderInline > button.toggle x th > p": "The Edit/Simplify/Solve holder opens as a popover laid over the text after the equation, by design; it is closed again by Escape or a click away.", - "block | text-overlap | td > p > strong x div.holderInline > button.toggle": "The Edit/Simplify/Solve holder opens as a popover laid over the text after the equation, by design; it is closed again by Escape or a click away.", - "block | text-overlap | tr > th > p x div.holderInline > button.toggle": "The Edit/Simplify/Solve holder opens as a popover laid over the text after the equation, by design; it is closed again by Escape or a click away.", + "block | text-overlap | div.holderInline > div[role=group].actions > button x th[role=columnheader] > p": "The Edit/Simplify/Solve holder opens as a popover laid over the text after the equation, by design; it is closed again by Escape or a click away.", + "block | text-overlap | span.mathInline > div.holderInline > button.toggle x th[role=columnheader] > p": "The Edit/Simplify/Solve holder opens as a popover laid over the text after the equation, by design; it is closed again by Escape or a click away.", + "block | text-overlap | td[role=gridcell] > p > strong x div.holderInline > button.toggle": "The Edit/Simplify/Solve holder opens as a popover laid over the text after the equation, by design; it is closed again by Escape or a click away.", + "block | text-overlap | tr > th[role=columnheader] > p x div.holderInline > button.toggle": "The Edit/Simplify/Solve holder opens as a popover laid over the text after the equation, by design; it is closed again by Escape or a click away.", + "block | text-overlap | tr > th[role=columnheader].editing > p x div.holderInline > button.toggle": "The Edit/Simplify/Solve holder opens as a popover laid over the text after the equation, by design; it is closed again by Escape or a click away.", "command | box-overlap | div.panel > div[role=group].filters > button[role=switch].labeled x div[role=group].filters > button[role=switch].labeled": "A command in the walk now opens a second filter panel whose switches sit in one row; their focus outlines touch at the narrowest width and the labels are not covered. The palette entry above has the same cause.", "command | target-small | div.panel > div[role=group].filters > button[role=switch].labeled": "A compact filter switch with its label inside the hit area; touch density raises it to 44 px. The palette entry for the same switch has the same cause.", "command | dead-space | details > label.field > textarea.mono (nested)": "The box is a text area, a table region, or a typeset equation that grows with its content, so it is larger than its content until the person types more; the room is its own.", @@ -269,9 +270,11 @@ "block | box-overlap | div.root > div[role=toolbar].bar > button.button x div.holderInline > button.toggle", "block | box-overlap | div[role=group].block > div > button.tape x div > p.muted", "block | box-overlap | div[role=group].block > div > button.tape x th > p", + "block | box-overlap | span.group > div.box > input[role=combobox].field x div.band > p.changed", "block | box-overlap | span.mathInline > div.holderInline > button.toggle x div[role=toolbar].bar > button.button", "block | document-scroll | ", "block | target-gap | div.root > div[role=toolbar].bar > button.button x div.holderInline > button.toggle", + "block | target-gap | span.group > div.box > input[role=combobox].field x div.box > input[role=combobox].field", "block | text-overlap | div > button.tape > span x th > p", "block | text-overlap | div[role=group].block > div > p.muted x button.tape > span", "block | text-overlap | td > p > strong x div[role=group].actions > button", @@ -281,6 +284,8 @@ "command | document-scroll | ", "command | offscreen | div.content > div.scroller > div[role=listbox].grid", "command | offscreen | div[data-scope=palette].panel > div.body > div[role=listbox].results", + "command | offscreen | span > span.wrapper > button[data-tool].iconButton", + "command | offscreen | span.group > span.wrapper > button[data-tool].iconButton", "command | target-gap | div.body > div[role=listbox].results > div[role=option].option x div[role=listbox].results > div[role=option].option", "command | target-gap | div.content > div.header > button.button x div.header > button.button", "command | target-gap | div.content > div.periods > button.button x div.periods > button.button", diff --git a/tests/ui/behavior/sheet-conditional.spec.ts b/tests/ui/behavior/sheet-conditional.spec.ts new file mode 100644 index 000000000..f67e2ce84 --- /dev/null +++ b/tests/ui/behavior/sheet-conditional.spec.ts @@ -0,0 +1,82 @@ +// checks-disable-file brand-consistency: cell colors here are document data of the file format, not interface styling + +// Conditional formats drawn in a browser (DESIGN 13.5): a color scale fills cells, a data bar draws a gradient of the +// right width, an icon set draws its glyph before the value with its text for screen readers, and icon only hides +// the value. The table is the HTML the render decorations give (harness.ts), with the real stylesheet. + +import type { Page } from '@playwright/test'; +import { installConditional } from '../../../app/src/features/tables/sheet/conditional'; +import { harnessCss, tableHtml } from '../../../app/src/features/tables/sheet/render/harness'; +import type { Harness } from '../../../app/src/features/tables/sheet/render/harness'; +import { resolveColor } from '../../../app/src/ui/color/parse'; +import { expect, test } from '../fixtures'; + +installConditional(); + +const ROW = (n: number) => `01m3sbcmt0000000000000r${String(n).padStart(3, '0')}`; +const COL = '01m3sbcmt0000000000000k001'; + +function harness(values: number[], rule: object): Harness { + return { + sheet: { conditional: { c1: { ranges: [{ col: COL }], order: 1, ...rule } } }, + table: { + header: false, + columns: [{ id: COL }], + rows: values.map((v, i) => ({ id: ROW(i + 1), cells: { [COL]: { markdown: String(v) } } })), + }, + widths: [120], + }; +} + +async function show(page: Page, h: Harness): Promise { + const head = ``; + const body = `
${tableHtml('t', h, 'light')}
`; + await page.setContent(`${head}${body}`); +} + +const rgb = (hex: string) => { + const n = parseInt(hex.slice(1), 16); + return `rgb(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255})`; +}; + +test('a color scale fills the cells from its end colors', async ({ page }) => { + const stops = [ + { at: 'min', color: 'brick' }, + { at: 'max', color: 'fern' }, + ]; + await show(page, harness([1, 5, 10], { type: 'scale', stops })); + const fill = (row: number) => + page.locator(`tr:nth-child(${row}) td`).evaluate((el) => getComputedStyle(el).backgroundColor); + expect(await fill(1)).toBe(rgb(resolveColor('brick', 'light') as string)); + expect(await fill(3)).toBe(rgb(resolveColor('fern', 'light') as string)); + expect(await fill(2)).not.toBe(await fill(1)); +}); + +test('a data bar is a gradient that stops at the value share of the cell', async ({ page }) => { + const bar = { type: 'bar', color: 'indigo', min: { at: 'num', value: '0' }, max: { at: 'num', value: '100' } }; + await show(page, harness([25, 100], bar)); + const image = (row: number) => + page.locator(`tr:nth-child(${row}) td`).evaluate((el) => getComputedStyle(el).backgroundImage); + expect(await image(1)).toContain('linear-gradient'); + // The bar ends at 25 percent of the cell; a full bar ends at 100 percent. + expect(await image(1)).toContain('rgba(0, 0, 0, 0) 25%'); + expect(await image(2)).not.toContain('rgba(0, 0, 0, 0) 25%'); +}); + +test('an icon set draws its glyph before the value, with text for screen readers', async ({ page }) => { + await show(page, harness([1, 50, 99], { type: 'icons', set: '3Arrows' })); + const glyph = (row: number) => + page.locator(`tr:nth-child(${row}) td > p`).evaluate((el) => getComputedStyle(el, '::before').content); + expect(await glyph(1)).toContain('▼'); + expect(await glyph(3)).toContain('▲'); + await expect(page.locator('tr:nth-child(1) td')).toHaveAttribute('data-icon-text', 'Down arrow'); + await expect(page.locator('tr:nth-child(2) td')).toHaveAttribute('data-icon-text', 'Sideways arrow'); + await expect(page.locator('tr:nth-child(3) td')).toHaveAttribute('data-icon-text', 'Up arrow'); +}); + +test('icon only hides the value from the eye and keeps it in the page', async ({ page }) => { + await show(page, harness([1, 99], { type: 'icons', set: '3Arrows', iconOnly: true })); + const p = page.locator('tr:nth-child(1) td > p'); + expect(await p.evaluate((el) => getComputedStyle(el).color)).toBe('rgba(0, 0, 0, 0)'); + await expect(p).toHaveText('1'); +}); diff --git a/tests/ui/behavior/sheet-keys.spec.ts b/tests/ui/behavior/sheet-keys.spec.ts new file mode 100644 index 000000000..318505058 --- /dev/null +++ b/tests/ui/behavior/sheet-keys.spec.ts @@ -0,0 +1,112 @@ +// Spreadsheet keys in a table (ADR 0063). Grid mode moves an active cell. Typing replaces a cell, and Esc puts it +// back. Enter finishes and moves down. Ctrl+D fills with references moved, and Ctrl+; enters today's date. A screen +// reader hears each cell, and Ctrl+K stays the palette. A table set to document keys keeps today's Enter. + +import type { Locator, Page } from '@playwright/test'; +import { expect, test } from '../fixtures'; +import { fillSales, openSmart, setCell } from './smart'; + +const cell = (page: Page, row: number, column: number): Locator => + page.locator('table tr').nth(row).locator('td, th').nth(column); + +/** A cell's paragraph. A click on it is a click into the text; a click on the cell's padding selects the cell. */ +const inText = (page: Page, row: number, column: number): Locator => cell(page, row, column).locator('p').first(); + +/** + * Grid mode on a cell: a click into its text, then Esc. A formula's result arrives after the click on a slow runner + * and redraws the cell, which drops the focus the click gave. The click and Esc repeat until the cell is selected. + */ +async function gridAt(page: Page, row: number, column: number): Promise { + await expect(async () => { + await inText(page, row, column).click(); + await page.keyboard.press('Escape'); + await expect(cell(page, row, column)).toHaveAttribute('aria-selected', 'true', { timeout: 2000 }); + }).toPass({ timeout: 20_000 }); +} + +const selected = (page: Page) => page.locator('table [aria-selected="true"]'); +/** The polite live region that says `text`. The announcer keeps two, and fills them in turn. */ +const said = (page: Page, text: string) => page.locator('[aria-live="polite"]').filter({ hasText: text }); + +test.beforeEach(async ({ page }) => { + await openSmart(page); + // The keys load with the table's editor; every cell has its row number once they are in. Start-up defers that + // import, so a loaded machine can take longer than the default 5 seconds. + await expect(cell(page, 0, 0)).toHaveAttribute('aria-rowindex', '1', { timeout: 20_000 }); + await fillSales(page); +}); + +test('jumps to the edges of the data and selects to the first cell', async ({ page }) => { + await gridAt(page, 1, 0); + await page.keyboard.press('Control+ArrowDown'); + await expect(selected(page)).toHaveCount(1); + await expect(cell(page, 3, 0)).toHaveAttribute('aria-selected', 'true'); + await page.keyboard.press('Control+ArrowRight'); + await expect(cell(page, 3, 2)).toHaveAttribute('aria-selected', 'true'); + await page.keyboard.press('Control+Shift+Home'); + await expect(selected(page)).toHaveCount(12); +}); + +test('types over a cell, puts it back with Esc, and finishes an F2 entry with Enter', async ({ page }) => { + await gridAt(page, 1, 1); + await page.keyboard.type('7'); + await expect(cell(page, 1, 1)).toHaveText('7'); + await page.keyboard.press('Escape'); + await expect(cell(page, 1, 1)).toHaveText('120'); + await page.keyboard.press('F2'); + await page.keyboard.type('5'); + await page.keyboard.press('Enter'); + await expect(cell(page, 1, 1)).toHaveText('1205'); + await expect(cell(page, 2, 1)).toHaveAttribute('aria-selected', 'true'); +}); + +test('starts a new line with Alt+Enter, and with Enter once the table uses document keys', async ({ page }) => { + const breaks = (at: Locator) => at.locator('br:not(.ProseMirror-trailingBreak)'); + await inText(page, 1, 0).click(); + await page.keyboard.press('End'); + await page.keyboard.press('Alt+Enter'); + await expect(breaks(cell(page, 1, 0))).toHaveCount(1); + // The smart table's View list and the Home tab's font boxes are comboboxes too, so wait for the palette's own field. + await page.keyboard.press('Control+k'); + await expect(page.getByPlaceholder('Type a command or a page name')).toBeFocused(); + await page.keyboard.type('Use document keys'); + await page.getByRole('option', { name: 'Use document keys in this table' }).first().click(); + await inText(page, 3, 0).click(); + await page.keyboard.press('End'); + await page.keyboard.press('Enter'); + await expect(breaks(cell(page, 3, 0))).toHaveCount(1); + await expect(selected(page)).toHaveCount(0); +}); + +test('fills a formula down with Ctrl+D and enters today’s date with Ctrl+;', async ({ page }) => { + await gridAt(page, 2, 2); + await page.keyboard.press('Shift+ArrowDown'); + await page.keyboard.press('Control+d'); + await expect(cell(page, 3, 2)).toContainText('=B3*0.6'); + await gridAt(page, 3, 1); + await page.keyboard.press('Control+;'); + await page.keyboard.press('Enter'); + const today = await page.evaluate(() => { + const now = new Date(); + return `${now.getMonth() + 1}/${now.getDate()}/${now.getFullYear()}`; + }); + await expect(cell(page, 3, 1)).toHaveText(today); +}); + +test('tells a screen reader the address, header, value, and formula', async ({ page }) => { + await setCell(page, 0, 2, 'Total'); + await setCell(page, 3, 1, '5'); + await setCell(page, 3, 2, '=B3*2'); + await gridAt(page, 2, 2); + // The result is drawn once the formula's cell is left, and the announcement says the result. + await expect(cell(page, 3, 2)).toHaveAttribute('data-shown', '10', { timeout: 20_000 }); + await page.keyboard.press('ArrowDown'); + await expect(said(page, 'C3, Total, 10, formula equals B3 times 2')).toHaveCount(1); +}); + +test('keeps Ctrl+K as the command palette in Grid mode', async ({ page }) => { + await gridAt(page, 1, 0); + await page.keyboard.press('Shift+ArrowRight'); + await page.keyboard.press('Control+k'); + await expect(page.getByPlaceholder('Type a command or a page name')).toBeFocused(); +}); diff --git a/tests/ui/behavior/sheet-toolbar.spec.ts b/tests/ui/behavior/sheet-toolbar.spec.ts new file mode 100644 index 000000000..ae49a54d5 --- /dev/null +++ b/tests/ui/behavior/sheet-toolbar.spec.ts @@ -0,0 +1,96 @@ +// The cell format bar (DESIGN 10.1) in the real page: a fill on a range of cells, Bold on cells, and on text, the +// borders menu, the palette's names and axe, and the bar at a narrow width (400 percent zoom of a 1,280 pixel window). + +import type { Page } from '@playwright/test'; +import { expectNoAxeViolations } from '../axe'; +import { expect, test } from '../fixtures'; +import { openSmart } from './smart'; + +const cell = (page: Page, row: number, column: number) => + page.locator('table tr').nth(row).locator('td, th').nth(column); + +/** Selects whole cells: a click in the first, a Shift-click in the last. */ +async function selectCells(page: Page, from: [number, number], to: [number, number]): Promise { + await cell(page, ...from).click(); + await cell(page, ...to).click({ modifiers: ['Shift'] }); + await expect(page.getByRole('toolbar', { name: 'Cell format' })).toBeVisible(); +} + +const bar = (page: Page) => page.getByRole('toolbar', { name: 'Cell format' }); + +async function openFill(page: Page): Promise { + await bar(page).getByRole('button', { name: 'Fill color' }).click(); + await expect(page.getByRole('dialog', { name: 'Fill colors' })).toBeVisible(); +} + +test('fills B2:C3 with one choice and Undo takes it back', async ({ page }) => { + await openSmart(page); + await selectCells(page, [1, 1], [2, 2]); + await openFill(page); + await page.getByRole('button', { name: 'Amber', exact: true }).click(); + await expect(page.getByRole('dialog', { name: 'Fill colors' })).toBeHidden(); + await openFill(page); + await expect(page.getByRole('button', { name: 'Amber', exact: true })).toHaveAttribute('aria-pressed', 'true'); + await page.keyboard.press('Escape'); + await page.keyboard.press('Control+z'); + await openFill(page); + await expect(page.getByRole('button', { name: 'Amber', exact: true })).toHaveAttribute('aria-pressed', 'false'); +}); + +test('Bold on cells formats the cells, and on selected text formats the text only', async ({ page }) => { + await openSmart(page); + await selectCells(page, [1, 1], [1, 2]); + const boldButton = bar(page).getByRole('button', { name: 'Bold' }); + await boldButton.click(); + await expect(boldButton).toHaveAttribute('aria-pressed', 'true'); + await expect(cell(page, 1, 1).locator('strong')).toHaveCount(0); + // Text inside one cell: a double click selects a word, and Bold marks only that text. + await cell(page, 3, 0).dblclick(); + await bar(page).getByRole('button', { name: 'Bold' }).click(); + await expect(cell(page, 3, 0).locator('strong')).toHaveCount(1); + // The text mark wrote no cell style: the whole cell shows Bold off, and the other cells keep their Bold. + await selectCells(page, [3, 0], [3, 0]); + await expect(bar(page).getByRole('button', { name: 'Bold' })).toHaveAttribute('aria-pressed', 'false'); + await selectCells(page, [1, 1], [1, 2]); + await expect(bar(page).getByRole('button', { name: 'Bold' })).toHaveAttribute('aria-pressed', 'true'); +}); + +test('the borders menu lists sixteen presets and applies one', async ({ page }) => { + await openSmart(page); + await selectCells(page, [1, 1], [2, 2]); + await bar(page).getByRole('button', { name: 'Borders' }).last().click(); + const menu = page.getByRole('menu', { name: 'Borders' }); + await expect(menu.getByRole('menuitem')).toHaveCount(18); + await menu.getByRole('menuitem', { name: 'Outside borders', exact: true }).click(); + await expect(page.getByText('Outside borders applied.')).toBeAttached(); +}); + +test('every swatch has a name and the open palette has no axe violations', async ({ page }) => { + await openSmart(page); + await selectCells(page, [1, 1], [2, 2]); + await openFill(page); + const palette = page.getByRole('dialog', { name: 'Fill colors' }); + await expect(palette.getByRole('button', { name: 'Indigo, lighter 40%' })).toBeVisible(); + const unnamed = await palette + .locator('button') + .evaluateAll( + (buttons) => buttons.filter((b) => !(b.getAttribute('aria-label') ?? b.textContent ?? '').trim()).length, + ); + expect(unnamed).toBe(0); + await expectNoAxeViolations(page, { include: '[role="dialog"]' }); +}); + +test('at 400 percent zoom the toolbar wraps without sideways scrolling', async ({ page }) => { + await page.setViewportSize({ width: 360, height: 800 }); + await openSmart(page); + await selectCells(page, [1, 1], [2, 2]); + const sideways = await page.evaluate(() => { + const tools = document.querySelector('[role="toolbar"][aria-label="Cell format"]'); + return { + page: document.documentElement.scrollWidth - document.documentElement.clientWidth, + bar: tools ? tools.scrollWidth - tools.clientWidth : -1, + }; + }); + expect(sideways.bar).toBeLessThanOrEqual(0); + expect(sideways.page).toBeLessThanOrEqual(0); +}); diff --git a/tests/ui/behavior/table-analyze.spec.ts b/tests/ui/behavior/table-analyze.spec.ts index 5c283ec86..259c540b3 100644 --- a/tests/ui/behavior/table-analyze.spec.ts +++ b/tests/ui/behavior/table-analyze.spec.ts @@ -25,7 +25,8 @@ test('adds summary statistics of the selection as a table', async ({ page }) => await fillSales(page); await selectSales(page); await analyze(page, 'Statistics', 'Summary statistics'); - await expect(page.getByRole('cell', { name: 'Median' })).toBeVisible(); + // A new table uses spreadsheet keys, so its cells are grid cells (ADR 0063). + await expect(page.getByRole('gridcell', { name: 'Median' })).toBeVisible(); }); test('adds a box plot chart', async ({ page }) => { diff --git a/tests/ui/fixtures.ts b/tests/ui/fixtures.ts index 76b0a7b5b..7093030a6 100644 --- a/tests/ui/fixtures.ts +++ b/tests/ui/fixtures.ts @@ -24,6 +24,12 @@ export const test = base.extend({ (options) => { window.__OPENNOTE_DEV__ = { fixture: options.fixture, pseudo: options.pseudo }; if (options.boot) window.__OPENNOTE_BOOT__ = { ...options.boot, bootVersion: 1 }; + // The one-time tip about spreadsheet keys would queue ahead of the toasts the tests wait for. + try { + localStorage.setItem('opennote.sheetKeys.tipShown', '1'); + } catch { + // A blocked store only means the tip may show. + } }, { fixture, pseudo: pseudoLocale, boot }, ); diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/connectors-settings-expanded-dark.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/connectors-settings-expanded-dark.png index 547401031..8d2b93298 100644 Binary files a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/connectors-settings-expanded-dark.png and b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/connectors-settings-expanded-dark.png differ diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/connectors-settings-expanded-light.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/connectors-settings-expanded-light.png index 85a1f900f..eb213d30d 100644 Binary files a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/connectors-settings-expanded-light.png and b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/connectors-settings-expanded-light.png differ diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/connectors-settings-wide-dark.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/connectors-settings-wide-dark.png index cffb4616c..2ce78997a 100644 Binary files a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/connectors-settings-wide-dark.png and b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/connectors-settings-wide-dark.png differ diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/connectors-settings-wide-light.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/connectors-settings-wide-light.png index 1cd74eb1e..9e30441c4 100644 Binary files a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/connectors-settings-wide-light.png and b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/connectors-settings-wide-light.png differ diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/crashReports-consent-expanded-dark.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/crashReports-consent-expanded-dark.png index a2bd5f93c..dab75b066 100644 Binary files a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/crashReports-consent-expanded-dark.png and b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/crashReports-consent-expanded-dark.png differ diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/crashReports-consent-expanded-light.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/crashReports-consent-expanded-light.png index 6e8a83671..9e5c84718 100644 Binary files a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/crashReports-consent-expanded-light.png and b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/crashReports-consent-expanded-light.png differ diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/crashReports-consent-wide-dark.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/crashReports-consent-wide-dark.png index 0c9b10a08..eed7f0b7d 100644 Binary files a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/crashReports-consent-wide-dark.png and b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/crashReports-consent-wide-dark.png differ diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/crashReports-consent-wide-light.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/crashReports-consent-wide-light.png index c0fec07c9..72e78fb66 100644 Binary files a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/crashReports-consent-wide-light.png and b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/crashReports-consent-wide-light.png differ diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/crashReports-review-expanded-dark.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/crashReports-review-expanded-dark.png index 07939c730..8f001edbb 100644 Binary files a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/crashReports-review-expanded-dark.png and b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/crashReports-review-expanded-dark.png differ diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/crashReports-review-expanded-light.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/crashReports-review-expanded-light.png index 1957bd46c..d121a8552 100644 Binary files a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/crashReports-review-expanded-light.png and b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/crashReports-review-expanded-light.png differ diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/crashReports-review-wide-dark.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/crashReports-review-wide-dark.png index f34e97ac4..5fe78be05 100644 Binary files a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/crashReports-review-wide-dark.png and b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/crashReports-review-wide-dark.png differ diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/crashReports-review-wide-light.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/crashReports-review-wide-light.png index ca89b7869..665eed872 100644 Binary files a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/crashReports-review-wide-light.png and b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/crashReports-review-wide-light.png differ diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/help-selfCheck-expanded-dark.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/help-selfCheck-expanded-dark.png index f6caddb87..02adabfc4 100644 Binary files a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/help-selfCheck-expanded-dark.png and b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/help-selfCheck-expanded-dark.png differ diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/help-selfCheck-expanded-light.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/help-selfCheck-expanded-light.png index ffb389d86..c3ada0447 100644 Binary files a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/help-selfCheck-expanded-light.png and b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/help-selfCheck-expanded-light.png differ diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/help-selfCheck-wide-dark.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/help-selfCheck-wide-dark.png index 39406fc8c..bc7ea0921 100644 Binary files a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/help-selfCheck-wide-dark.png and b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/help-selfCheck-wide-dark.png differ diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/help-selfCheck-wide-light.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/help-selfCheck-wide-light.png index c8e6d17fc..a5a81a2c7 100644 Binary files a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/help-selfCheck-wide-light.png and b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/help-selfCheck-wide-light.png differ diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/intel-settings-expanded-dark.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/intel-settings-expanded-dark.png index 0c578df27..8ce8a6d40 100644 Binary files a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/intel-settings-expanded-dark.png and b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/intel-settings-expanded-dark.png differ diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/intel-settings-expanded-light.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/intel-settings-expanded-light.png index fd0a61c91..3f2af0253 100644 Binary files a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/intel-settings-expanded-light.png and b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/intel-settings-expanded-light.png differ diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/intel-settings-wide-dark.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/intel-settings-wide-dark.png index dfe467a6a..6b2a08ee1 100644 Binary files a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/intel-settings-wide-dark.png and b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/intel-settings-wide-dark.png differ diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/intel-settings-wide-light.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/intel-settings-wide-light.png index 0ae3e2743..193928d51 100644 Binary files a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/intel-settings-wide-light.png and b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/intel-settings-wide-light.png differ diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/page-sampler-editing-wide-light.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/page-sampler-editing-wide-light.png index a9ccc9e08..b5b2a8ca1 100644 Binary files a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/page-sampler-editing-wide-light.png and b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/page-sampler-editing-wide-light.png differ diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/privacy-offline-expanded-dark.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/privacy-offline-expanded-dark.png index 97cd901d3..8e4e25a8e 100644 Binary files a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/privacy-offline-expanded-dark.png and b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/privacy-offline-expanded-dark.png differ diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/privacy-offline-expanded-light.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/privacy-offline-expanded-light.png index 7e373d0c3..db08ee74a 100644 Binary files a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/privacy-offline-expanded-light.png and b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/privacy-offline-expanded-light.png differ diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/privacy-offline-wide-dark.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/privacy-offline-wide-dark.png index 7d8e122e3..96a9438db 100644 Binary files a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/privacy-offline-wide-dark.png and b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/privacy-offline-wide-dark.png differ diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/privacy-offline-wide-light.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/privacy-offline-wide-light.png index 78adbd53f..1621564f0 100644 Binary files a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/privacy-offline-wide-light.png and b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/privacy-offline-wide-light.png differ diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/privacy-panel-expanded-dark.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/privacy-panel-expanded-dark.png index e1cbbda90..e4d69ce57 100644 Binary files a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/privacy-panel-expanded-dark.png and b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/privacy-panel-expanded-dark.png differ diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/privacy-panel-expanded-light.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/privacy-panel-expanded-light.png index 215760b7d..6b7eaf5e1 100644 Binary files a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/privacy-panel-expanded-light.png and b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/privacy-panel-expanded-light.png differ diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/privacy-panel-wide-dark.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/privacy-panel-wide-dark.png index 49b8f6239..40763845e 100644 Binary files a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/privacy-panel-wide-dark.png and b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/privacy-panel-wide-dark.png differ diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/privacy-panel-wide-light.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/privacy-panel-wide-light.png index 1620948c0..f04b93dd0 100644 Binary files a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/privacy-panel-wide-light.png and b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/privacy-panel-wide-light.png differ diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/recording-settings-expanded-dark.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/recording-settings-expanded-dark.png index 9dff8200a..a846c4e58 100644 Binary files a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/recording-settings-expanded-dark.png and b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/recording-settings-expanded-dark.png differ diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/recording-settings-expanded-light.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/recording-settings-expanded-light.png index f4d34094a..b4262b298 100644 Binary files a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/recording-settings-expanded-light.png and b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/recording-settings-expanded-light.png differ diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/recording-settings-wide-dark.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/recording-settings-wide-dark.png index 39f5efe7d..853ab184e 100644 Binary files a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/recording-settings-wide-dark.png and b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/recording-settings-wide-dark.png differ diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/recording-settings-wide-light.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/recording-settings-wide-light.png index b76257577..6445bb452 100644 Binary files a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/recording-settings-wide-light.png and b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/recording-settings-wide-light.png differ diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-about-expanded-dark.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-about-expanded-dark.png index 021c074cd..a6aee3e8e 100644 Binary files a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-about-expanded-dark.png and b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-about-expanded-dark.png differ diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-about-expanded-light.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-about-expanded-light.png index c42990edc..da9a5ca7c 100644 Binary files a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-about-expanded-light.png and b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-about-expanded-light.png differ diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-about-wide-dark.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-about-wide-dark.png index 466e05834..cfc25cdd4 100644 Binary files a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-about-wide-dark.png and b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-about-wide-dark.png differ diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-about-wide-light.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-about-wide-light.png index 07cd695fc..6019e6c72 100644 Binary files a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-about-wide-light.png and b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-about-wide-light.png differ diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-appearance-expanded-dark.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-appearance-expanded-dark.png index 426be39c1..d4356a19f 100644 Binary files a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-appearance-expanded-dark.png and b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-appearance-expanded-dark.png differ diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-appearance-expanded-light.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-appearance-expanded-light.png index 71df59c0c..c5c1e37c9 100644 Binary files a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-appearance-expanded-light.png and b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-appearance-expanded-light.png differ diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-appearance-wide-dark.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-appearance-wide-dark.png index 1b3e82d43..22f475710 100644 Binary files a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-appearance-wide-dark.png and b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-appearance-wide-dark.png differ diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-appearance-wide-light.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-appearance-wide-light.png index 8da6b6cdf..01558e731 100644 Binary files a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-appearance-wide-light.png and b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-appearance-wide-light.png differ diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-general-expanded-dark.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-general-expanded-dark.png index 273cd1fa9..62d2fb5c4 100644 Binary files a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-general-expanded-dark.png and b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-general-expanded-dark.png differ diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-general-expanded-light.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-general-expanded-light.png index fd990b35a..9773a7737 100644 Binary files a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-general-expanded-light.png and b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-general-expanded-light.png differ diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-general-wide-dark.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-general-wide-dark.png index 583edd486..e59184ee2 100644 Binary files a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-general-wide-dark.png and b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-general-wide-dark.png differ diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-general-wide-light.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-general-wide-light.png index 4dde685fa..8c18f7951 100644 Binary files a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-general-wide-light.png and b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-general-wide-light.png differ diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-shortcuts-expanded-dark.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-shortcuts-expanded-dark.png index 462206218..4c50650c3 100644 Binary files a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-shortcuts-expanded-dark.png and b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-shortcuts-expanded-dark.png differ diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-shortcuts-expanded-light.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-shortcuts-expanded-light.png index 946a587b6..100b3b270 100644 Binary files a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-shortcuts-expanded-light.png and b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-shortcuts-expanded-light.png differ diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-shortcuts-wide-dark.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-shortcuts-wide-dark.png index c13082709..893b75257 100644 Binary files a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-shortcuts-wide-dark.png and b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-shortcuts-wide-dark.png differ diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-shortcuts-wide-light.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-shortcuts-wide-light.png index 1d93ce97c..dadf2c2f9 100644 Binary files a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-shortcuts-wide-light.png and b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/settings-shortcuts-wide-light.png differ diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/storage-settings-expanded-dark.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/storage-settings-expanded-dark.png index cc59f53c4..58e25f4c6 100644 Binary files a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/storage-settings-expanded-dark.png and b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/storage-settings-expanded-dark.png differ diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/storage-settings-expanded-light.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/storage-settings-expanded-light.png index 2b1158f7a..05673c9ec 100644 Binary files a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/storage-settings-expanded-light.png and b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/storage-settings-expanded-light.png differ diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/storage-settings-wide-dark.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/storage-settings-wide-dark.png index 932edc73c..588c6f468 100644 Binary files a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/storage-settings-wide-dark.png and b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/storage-settings-wide-dark.png differ diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/storage-settings-wide-light.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/storage-settings-wide-light.png index 33e7d293a..eea26b176 100644 Binary files a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/storage-settings-wide-light.png and b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/storage-settings-wide-light.png differ diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/windows-settings-compact-dark.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/windows-settings-compact-dark.png index 8c0a56ca5..eb2103eea 100644 Binary files a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/windows-settings-compact-dark.png and b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/windows-settings-compact-dark.png differ diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/windows-settings-expanded-dark.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/windows-settings-expanded-dark.png index 36009ace1..16f2ad850 100644 Binary files a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/windows-settings-expanded-dark.png and b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/windows-settings-expanded-dark.png differ diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/windows-settings-expanded-light.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/windows-settings-expanded-light.png index 838f738ec..18623e99b 100644 Binary files a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/windows-settings-expanded-light.png and b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/windows-settings-expanded-light.png differ diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/windows-settings-wide-dark.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/windows-settings-wide-dark.png index c20acac0d..2f06c9333 100644 Binary files a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/windows-settings-wide-dark.png and b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/windows-settings-wide-dark.png differ diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/windows-settings-wide-light.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/windows-settings-wide-light.png index 0011f536b..e02f750eb 100644 Binary files a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/windows-settings-wide-light.png and b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/windows-settings-wide-light.png differ diff --git a/tests/ui/visual/__screenshots__/win32/polish.visual.spec.ts/forced-ribbon-light.png b/tests/ui/visual/__screenshots__/win32/polish.visual.spec.ts/forced-ribbon-light.png index b0f1b0d9b..e6ba99db9 100644 Binary files a/tests/ui/visual/__screenshots__/win32/polish.visual.spec.ts/forced-ribbon-light.png and b/tests/ui/visual/__screenshots__/win32/polish.visual.spec.ts/forced-ribbon-light.png differ