Skip to content

Commit 3e366a3

Browse files
authored
Merge pull request #128 from walkframe/fix/percentage
Fix percentage string handling and cache strip results in formula engine
2 parents ee01103 + 0817e95 commit 3e366a3

40 files changed

Lines changed: 833 additions & 628 deletions

e2e/formula.spec.ts

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,14 @@ test('simple calculation', async ({ page }) => {
388388
await go(page, 'formula-simple--simple-calculation');
389389
const largeEditor = page.locator('.gs-formula-bar textarea');
390390

391+
// Verify A1 formula bar shows raw formula (formulaEnabled: false active)
392+
await page.locator("[data-address='A1']").click();
393+
expect(await largeEditor.inputValue()).toBe('=100 + 5');
394+
395+
// Verify that A-column cell containing an address reference is NOT converted to an internal ID
396+
// A2 has value "=B1 - 60" with formulaEnabled:false — it must render the literal string, not e.g. "=#3- 60"
397+
expect(await page.locator('[data-address="A2"] .gs-cell-rendered').textContent()).toBe('=B1 - 60');
398+
391399
// Column A: formulaEnabled: false — formula is displayed as raw text, not evaluated
392400
// Column B: formula is evaluated normally
393401

@@ -451,17 +459,13 @@ test('simple calculation', async ({ page }) => {
451459
expect(await page.locator('[data-address="B34"] .gs-cell-rendered').textContent()).toBe('123');
452460
expect(await page.locator('[data-address="B35"] .gs-cell-rendered').textContent()).toBe('0123');
453461

454-
// Verify that none of the B-column results are #VALUE!
455-
for (let row = 1; row <= 35; row++) {
456-
const text = await page.locator(`[data-address="B${row}"] .gs-cell-rendered`).textContent();
457-
expect(text).not.toBe('#VALUE!');
458-
}
459-
460-
// Verify A1 formula bar shows raw formula (formulaEnabled: false active)
461-
await page.locator("[data-address='A1']").click();
462-
expect(await largeEditor.inputValue()).toBe('=100 + 5');
463-
464-
// Verify that A-column cell containing an address reference is NOT converted to an internal ID
465-
// A2 has value "=B1 - 60" with formulaEnabled:false — it must render the literal string, not e.g. "=#3- 60"
466-
expect(await page.locator('[data-address="A2"] .gs-cell-rendered').textContent()).toBe('=B1 - 60');
462+
// Percentage handling — scroll into view first (virtualized rows may not be rendered)
463+
await page.locator('[data-address="A36"]').scrollIntoViewIfNeeded();
464+
expect(await page.locator('[data-address="B37"] .gs-cell-rendered').textContent()).toBe('5.05');
465+
expect(await page.locator('[data-address="B38"] .gs-cell-rendered').textContent()).toBe('10');
466+
expect(await page.locator('[data-address="B39"] .gs-cell-rendered').textContent()).toBe('0.5');
467+
expect(await page.locator('[data-address="B40"] .gs-cell-rendered').textContent()).toBe('1.5');
468+
expect(await page.locator('[data-address="B41"] .gs-cell-rendered').textContent()).toBe('50%');
469+
expect(await page.locator('[data-address="B42"] .gs-cell-rendered').textContent()).toBe('5.5');
470+
expect(await page.locator('[data-address="B43"] .gs-cell-rendered').textContent()).toBe('23.43');
467471
});

gridsheet.png

-3.91 KB
Loading

packages/core/src/formula/evaluator.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,7 @@ export class Token {
326326
entity: any;
327327
precedence: number;
328328
closed: boolean;
329+
raw?: string;
329330
private at?: Id;
330331

331332
constructor(type: TokenType, entity: any, precedence = 0, at?: Id, closed = true) {
@@ -351,6 +352,9 @@ export class Token {
351352
if (typeof this.entity === 'boolean') {
352353
return this.entity ? 'TRUE' : 'FALSE';
353354
}
355+
if (this.raw != null) {
356+
return this.raw;
357+
}
354358
}
355359
return this.entity as string;
356360
}
@@ -524,7 +528,7 @@ export class Lexer {
524528
switch (t.type) {
525529
case 'VALUE':
526530
if (typeof t.entity === 'number' || typeof t.entity === 'boolean') {
527-
return t.entity;
531+
return t.raw ?? t.entity;
528532
}
529533
return t.closed ? `"${t.entity}"` : `"${t.entity}`;
530534
case 'ID':
@@ -653,7 +657,9 @@ export class Lexer {
653657
break;
654658
}
655659
if (buf.match(/^[+-]?(\d*[.])?\d+$/)) {
656-
this.tokens.push(new Token('VALUE', parseFloat(buf), 0, this.at));
660+
const tok = new Token('VALUE', parseFloat(buf), 0, this.at);
661+
tok.raw = buf;
662+
this.tokens.push(tok);
657663
} else {
658664
const bool = BOOLS[buf.toLowerCase()];
659665
if (bool != null) {

packages/core/src/formula/functions/__base.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
asyncCacheMiss,
2020
} from './__async';
2121
import { Time } from '../../lib/time';
22+
import { isNumeric, isPercentage } from './__utils';
2223

2324
export type FunctionCategory =
2425
| 'math'
@@ -87,7 +88,7 @@ const matchesType = (
8788
case 'any':
8889
return true;
8990
case 'number':
90-
if (typeof value === 'number') {
91+
if (isNumeric(value)) {
9192
return true;
9293
}
9394
break;
@@ -315,6 +316,9 @@ export class BaseFunction {
315316
value = stripMatrix(value, this.at);
316317
}
317318
}
319+
if (isPercentage(value)) {
320+
value = parseFloat(value.slice(0, -1)) / 100;
321+
}
318322
if (!matchesType(value, def)) {
319323
throw new FormulaError(
320324
'#VALUE!',

packages/core/src/formula/functions/__utils.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,26 @@ export type EnsureBooleanOptions = {
5555
ignore?: boolean;
5656
};
5757

58+
export const isPercentage = (value: any): value is string => {
59+
if (typeof value !== 'string' || !value.endsWith('%')) {
60+
return false;
61+
}
62+
return !isNaN(parseFloat(value.slice(0, -1)));
63+
};
64+
65+
export const isNumeric = (value: any): boolean => {
66+
if (typeof value === 'number') {
67+
return true;
68+
}
69+
if (isPercentage(value)) {
70+
return true;
71+
}
72+
if (typeof value === 'string') {
73+
return !isNaN(parseFloat(value)) && isFinite(Number(value));
74+
}
75+
return false;
76+
};
77+
5878
export const ensureNumber = (value: any, options?: EnsureNumberOptions): number => {
5979
const { alternative, ignore } = options || {};
6080
if (Pending.is(value)) {
@@ -78,11 +98,8 @@ export const ensureNumber = (value: any, options?: EnsureNumberOptions): number
7898
return value.days;
7999
}
80100

81-
if (typeof value === 'string' && value.endsWith('%')) {
82-
const num = parseFloat(value.slice(0, -1));
83-
if (!isNaN(num)) {
84-
return num / 100;
85-
}
101+
if (isPercentage(value)) {
102+
return parseFloat(value.slice(0, -1)) / 100;
86103
}
87104

88105
const num = parseFloat(value as string);

packages/core/src/formula/functions/average.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { FormulaError } from '../formula-error';
22
import { BaseFunction, FunctionCategory, FunctionArgumentDefinition, isMatrix } from './__base';
3-
import { ensureNumber, eachMatrix } from './__utils';
3+
import { ensureNumber, isNumeric, eachMatrix } from './__utils';
44

55
const description = `Returns the average of a series of numbers or cells.`;
66

@@ -26,8 +26,8 @@ export class AverageFunction extends BaseFunction {
2626
eachMatrix(
2727
val,
2828
(v) => {
29-
if (typeof v === 'number') {
30-
sum += v;
29+
if (isNumeric(v)) {
30+
sum += ensureNumber(v);
3131
count++;
3232
}
3333
},

packages/core/src/formula/functions/count.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { BaseFunction, FunctionCategory, FunctionArgumentDefinition, isMatrix } from './__base';
2-
import { ensureNumber, eachMatrix } from './__utils';
2+
import { ensureNumber, isNumeric, eachMatrix } from './__utils';
33

44
const description = `Returns the count of a series of numbers or cells.`;
55

@@ -24,7 +24,7 @@ export class CountFunction extends BaseFunction {
2424
eachMatrix(
2525
val,
2626
(v) => {
27-
if (typeof v === 'number') {
27+
if (isNumeric(v)) {
2828
count++;
2929
}
3030
},

packages/core/src/formula/functions/max.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { BaseFunction, FunctionCategory, FunctionArgumentDefinition, isMatrix } from './__base';
2-
import { ensureNumber, eachMatrix } from './__utils';
2+
import { ensureNumber, isNumeric, eachMatrix } from './__utils';
33

44
const description = `Returns the max in a series of numbers or cells.`;
55

@@ -25,9 +25,10 @@ export class MaxFunction extends BaseFunction {
2525
eachMatrix(
2626
val,
2727
(v) => {
28-
if (typeof v === 'number') {
29-
if (v > max) {
30-
max = v;
28+
if (isNumeric(v)) {
29+
const num = ensureNumber(v);
30+
if (num > max) {
31+
max = num;
3132
}
3233
hasValues = true;
3334
}

packages/core/src/formula/functions/min.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { BaseFunction, FunctionCategory, FunctionArgumentDefinition, isMatrix } from './__base';
2-
import { ensureNumber, eachMatrix } from './__utils';
2+
import { ensureNumber, isNumeric, eachMatrix } from './__utils';
33

44
const description = `Returns the min in a series of numbers or cells.`;
55

@@ -25,9 +25,10 @@ export class MinFunction extends BaseFunction {
2525
eachMatrix(
2626
val,
2727
(v) => {
28-
if (typeof v === 'number') {
29-
if (v < min) {
30-
min = v;
28+
if (isNumeric(v)) {
29+
const num = ensureNumber(v);
30+
if (num < min) {
31+
min = num;
3132
}
3233
hasValues = true;
3334
}

packages/core/src/formula/solver.ts

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -67,21 +67,15 @@ export const solveFormula = ({ value, sheet, point, raise = true, resolution = '
6767
}
6868
}
6969

70-
if (resolution === 'RESOLVED' && solved instanceof Sheet) {
71-
// Unwrap to scalar (top-left cell of the sheet's area).
72-
// NOTE: We intentionally call solveSheet directly here instead of stripSheet,
73-
// to avoid a three-way cycle: solveFormula → stripSheet → solveSheet → solveFormula.
74-
// The mutual recursion between solveFormula and solveSheet is unavoidable by design,
75-
// but routing through stripSheet would make the call graph harder to follow and reason about.
76-
solved = solveSheet({ sheet: solved, raise, at })[0]?.[0];
77-
}
78-
// 'EVALUATED' resolution: keep Sheet objects intact (range formulas return Sheet, not scalar).
79-
8070
if (Spilling.is(solved)) {
8171
solved = sheet.spill(point, solved.matrix);
8272
} else {
8373
sheet.finishSolvedCache(point, solved);
8474
}
75+
76+
if (resolution === 'RESOLVED' && solved instanceof Sheet) {
77+
solved = stripSheet({ value: solved, raise, at });
78+
}
8579
if (Pending.is(solved)) {
8680
sheet.finishSolvedCache(point, solved);
8781
}
@@ -140,8 +134,8 @@ export type StripSheetProps = {
140134
};
141135

142136
export const stripSheet = ({ value, at, raise = true }: StripSheetProps): any => {
143-
while (value instanceof Sheet) {
144-
value = solveSheet({ sheet: value, raise, at })[0]?.[0];
137+
if (value instanceof Sheet) {
138+
return value.strip({ raise, at });
145139
}
146140
return value;
147141
};

0 commit comments

Comments
 (0)