UNPKG

@borgar/fx

Version:

Utilities for working with Excel formulas

235 lines (206 loc) 8.42 kB
import { describe, test, expect } from 'vitest'; import { tokenize } from './tokenize.ts'; import { addTokenMeta } from './addTokenMeta.ts'; import { fixFormulaRanges, fixFormulaRangesXlsx, fixTokenRanges, fixTokenRangesXlsx } from './fixRanges.ts'; import { FUNCTION, FX_PREFIX, OPERATOR, REF_RANGE, REF_STRUCT, REF_TERNARY } from './constants.ts'; function isFixed (expr: string, expected: string, options: any = {}) { const result = options.xlsx ? fixFormulaRangesXlsx(expr, options) : fixFormulaRanges(expr, options); expect(result).toBe(expected); } describe('fixRanges basics', () => { test('throws on non-string inputs', () => { expect(() => fixTokenRanges(123 as any)).toThrow(); expect(() => fixTokenRanges(null as any)).toThrow(); expect(() => fixFormulaRanges(123 as any)).toThrow(); expect(() => fixFormulaRanges(null as any)).toThrow(); expect(() => fixTokenRangesXlsx(123 as any)).toThrow(); expect(() => fixTokenRangesXlsx(null as any)).toThrow(); expect(() => fixFormulaRangesXlsx(123 as any)).toThrow(); expect(() => fixFormulaRangesXlsx(null as any)).toThrow(); }); test('emits new array instance and preserves meta', () => { const fx = '=SUM([wb]Sheet1!B2:A1)'; const tokens = addTokenMeta(tokenize(fx, { mergeRefs: true })); const fixedTokens = fixTokenRanges(tokens); expect(tokens).not.toBe(fixedTokens); expect(tokens[3]).not.toBe(fixedTokens[3]); expect(tokens[3]).toEqual({ type: REF_RANGE, value: '[wb]Sheet1!B2:A1', index: 3, depth: 1, groupId: 'fxg1' }); expect(fixedTokens[3]).toEqual({ type: REF_RANGE, value: '[wb]Sheet1!A1:B2', index: 3, depth: 1, groupId: 'fxg1' }); }); test('updates token source location information', () => { const tokensWithRanges = tokenize( '=SUM(B2:A,table[[#This Row],[Foo]])', { withLocation: true, mergeRefs: true, allowTernary: true } ); expect(fixTokenRanges(tokensWithRanges, { addBounds: true })).toEqual([ { type: FX_PREFIX, value: '=', loc: [ 0, 1 ] }, { type: FUNCTION, value: 'SUM', loc: [ 1, 4 ] }, { type: OPERATOR, value: '(', loc: [ 4, 5 ] }, { type: REF_TERNARY, value: 'A2:B1048576', loc: [ 5, 16 ] }, { type: OPERATOR, value: ',', loc: [ 16, 17 ] }, { type: REF_STRUCT, value: 'table[@Foo]', loc: [ 17, 28 ] }, { type: OPERATOR, value: ')', loc: [ 28, 29 ] } ]); }); }); describe('fixRanges prefixes', () => { test('Quotes prefixes as needed', () => { isFixed('=Sch1!B2', "='Sch1'!B2"); isFixed('=[Foo]Ab12x!B2', '=[Foo]Ab12x!B2'); isFixed('=[Foo]Ab12!B2', "='[Foo]Ab12'!B2"); isFixed('=ABC123!B2', "='ABC123'!B2"); isFixed('=abc123!B2', "='abc123'!B2"); isFixed('=C!B2', "='C'!B2"); isFixed('=R!B2', "='R'!B2"); isFixed('=RC!B2', "='RC'!B2"); isFixed('=CR!B2', '=CR!B2'); }); }); describe('fixRanges A1', () => { const opt = { allowTernary: true }; test('doesn\'t mess with things that it doesn\'t have to', () => { isFixed('=A1', '=A1', opt); isFixed('=ZZ123', '=ZZ123', opt); isFixed('=A1:B2', '=A1:B2', opt); isFixed('=B3:OFFSET(A1,10,10)', '=B3:OFFSET(A1,10,10)', opt); isFixed('=A:B', '=A:B', opt); isFixed('=C:C', '=C:C', opt); isFixed('=3:6', '=3:6', opt); isFixed('=3:3', '=3:3', opt); }); test('handles redundancy', () => { isFixed('=A1:$A$1', '=A1:$A$1', opt); isFixed('=A1:A1', '=A1', opt); }); test('converts lowercase to uppercase', () => { isFixed('=a1', '=A1', opt); isFixed('=zz123', '=ZZ123', opt); isFixed('=a1:b2', '=A1:B2', opt); }); test('fixes flipped rectangles', () => { isFixed('=B2:A1', '=A1:B2', opt); isFixed('=$B$2:$A$1', '=$A$1:$B$2', opt); }); test('fixes flipped beams', () => { isFixed('=C:A', '=A:C', opt); isFixed('=$D:B', '=B:$D', opt); isFixed('=10:1', '=1:10', opt); isFixed('=$5:3', '=3:$5', opt); }); test('fixes flipped partials - bottom', () => { isFixed('=A:A1', '=A1:A', opt); isFixed('=A:A$1', '=A$1:A', opt); }); test('fixes flipped partials - right', () => { isFixed('=1:A1', '=A1:1', opt); // $1:$A1 is rather counter intuitive case: // This range is parsed as { left=null, top=$1, right=$A, bottom=1 } but, // because left is null, right and left are flipped around, making this // end up as { left=$A, top=$1, right=null, bottom=1 } which serializes // as $A$1:1 isFixed('=$1:$A1', '=$A$1:1', opt); }); }); describe('fixRanges A1 addBounds', () => { const opt = { allowTernary: true, addBounds: true }; test('handles functions and existing bounds', () => { isFixed('=B3:OFFSET(A1,10,10)', '=B3:OFFSET(A1,10,10)', opt); isFixed('=A:A', '=A:A', opt); isFixed('=A:A1', '=A:A', opt); isFixed('=A:A$1', '=A:A', opt); isFixed('=A:$A$1', '=A:$A', opt); isFixed('=A.:A', '=A.:A', opt); }); test('adds bounds to partials - bottom', () => { isFixed('=A1:A', '=A:A', opt); isFixed('=A1:Z', '=A:Z', opt); isFixed('=A:A1', '=A:A', opt); isFixed('=$A1:A', '=$A:A', opt); isFixed('=A$1:A', '=A:A', opt); isFixed('=A1:$A', '=A:$A', opt); isFixed('=A2:A', '=A2:A1048576', opt); isFixed('=B2:B', '=B2:B1048576', opt); isFixed('=A:A2', '=A2:A1048576', opt); isFixed('=B:B2', '=B2:B1048576', opt); isFixed('=B.:.B2', '=B2.:.B1048576', opt); }); test('adds bounds to flipped partials - bottom', () => { isFixed('=A1:1', '=1:1', opt); isFixed('=A1:4', '=1:4', opt); isFixed('=1:A1', '=1:1', opt); isFixed('=$A1:1', '=1:1', opt); isFixed('=A$1:1', '=$1:1', opt); isFixed('=A1:$1', '=1:$1', opt); isFixed('=B1:1', '=B1:XFD1', opt); isFixed('=1:B1', '=B1:XFD1', opt); isFixed('=B2:20', '=B2:XFD20', opt); isFixed('=2:B20', '=B2:XFD20', opt); isFixed('=2:.B20', '=B2:.XFD20', opt); }); }); describe('fixRanges structured references', () => { test('fixes this row references', () => { isFixed('=Table1[[#This Row],[Foo]]', '=Table1[@Foo]'); isFixed('=[[#This Row],[s:s]]', '=[@[s:s]]'); }); test('fixes column ranges', () => { isFixed('=Table1[[#Totals],col name:Foo]', '=Table1[[#Totals],[col name]:[Foo]]'); }); test('fixes special identifiers order', () => { isFixed('[[#data],[#headers]]', '[[#Headers],[#Data]]'); isFixed('[[#headers],[#data]]', '[[#Headers],[#Data]]'); isFixed('[[#totals],[#data]]', '[[#Data],[#Totals]]'); isFixed('[ [#totals], [#data] ]', '[[#Data],[#Totals]]'); isFixed('[[#data],[#totals]]', '[[#Data],[#Totals]]'); }); test('fixes column references', () => { isFixed('[[#all],foo:bar]', '[[#All],[foo]:[bar]]'); isFixed('[[#all],[#all],[#all],[#all],[ColumnName]]', '[[#All],[ColumnName]]'); isFixed('[@[foo]:bar]', '[@[foo]:[bar]]'); isFixed('[@foo bar]', '[@[foo bar]]'); }); test('preserves whitespace in column headers', () => { // Care must be taken with spaces in column headers. // Excel considers refs only valid if they match the column name // but the parser does not know the names, so it must preserve // leading/trailing whitespace. isFixed('[ @[foo bar] ]', '[@[foo bar]]'); isFixed('[ @[ foo bar ] ]', '[@[ foo bar ]]'); isFixed('[ @foo bar ]', '[@[foo bar ]]'); isFixed('[@ foo bar]', '[@[ foo bar]]'); isFixed('[ @ foo bar ]', '[@[ foo bar ]]'); }); }); describe('fixRanges works with xlsx mode', () => { test('should not mess with invalid ranges in normal mode', () => { isFixed("='[Workbook]'!Table[Column]", "='[Workbook]'!Table[Column]"); isFixed('=[Workbook]!Table[Column]', '=[Workbook]!Table[Column]'); isFixed("='[Foo]'!A1", "='[Foo]'!A1"); isFixed('=[Foo]!A1', '=[Foo]!A1'); }); test('should fix things in xlsx mode', () => { const opts = { xlsx: true }; isFixed("='[Workbook]'!Table[Column]", '=[Workbook]!Table[Column]', opts); isFixed('=[Workbook]!Table[Column]', '=[Workbook]!Table[Column]', opts); isFixed('=[Lorem Ipsum]!Table[Column]', "='[Lorem Ipsum]'!Table[Column]", opts); isFixed("='[Foo]'!A1", '=[Foo]!A1', opts); isFixed('=[Foo]Bar!A1', '=[Foo]Bar!A1', opts); isFixed('=[Foo Bar]Baz!A1', "='[Foo Bar]Baz'!A1", opts); isFixed('=[Foo]!A1', '=[Foo]!A1', opts); isFixed('=[Lorem Ipsum]!A1', "='[Lorem Ipsum]'!A1", opts); }); });