diff --git a/test/formula_test.dart b/test/formula_test.dart new file mode 100644 index 0000000..40435d5 --- /dev/null +++ b/test/formula_test.dart @@ -0,0 +1,202 @@ +import 'package:cellar/cellar.dart'; +import 'package:test/test.dart'; + +double numberOf(CellValue value) => (value as NumberValue).value; + +CellValue evalFormula(String formula) { + final sheet = Sheet(); + sheet.setValue('Z100', formula); + return sheet.valueOf('Z100'); +} + +void main() { + group('arithmetic and precedence', () { + test('multiplication binds tighter than addition', () { + expect(numberOf(evalFormula('=1+2*3')), 7); + }); + + test('parentheses override precedence', () { + expect(numberOf(evalFormula('=(1+2)*3')), 9); + }); + + test('exponent is right associative', () { + expect(numberOf(evalFormula('=2^3^2')), 512); + }); + + test('unary minus binds looser than exponent', () { + expect(numberOf(evalFormula('=-2^2')), -4); + }); + + test('division yields a fraction', () { + expect(numberOf(evalFormula('=10/4')), 2.5); + }); + + test('percent operator is modulo', () { + expect(numberOf(evalFormula('=10%3')), 1); + }); + + test('comparison has the lowest precedence', () { + expect(evalFormula('=1+1=2'), const BooleanValue(true)); + }); + + test('inequality returns a boolean', () { + expect(evalFormula('=5>=5'), const BooleanValue(true)); + expect(evalFormula('=2<>2'), const BooleanValue(false)); + }); + + test('string concatenation with ampersand', () { + expect(evalFormula('="a"&"b"&"c"'), const TextValue('abc')); + }); + }); + + group('built-in functions', () { + late Sheet sheet; + + setUp(() { + sheet = Sheet(); + sheet.setValue('A1', '1'); + sheet.setValue('A2', '2'); + sheet.setValue('A3', '3'); + }); + + test('SUM over a range', () { + sheet.setValue('B1', '=SUM(A1:A3)'); + expect(numberOf(sheet.valueOf('B1')), 6); + }); + + test('SUM mixes range and scalar arguments', () { + sheet.setValue('B1', '=SUM(A1:A3, 10, 4)'); + expect(numberOf(sheet.valueOf('B1')), 20); + }); + + test('AVERAGE and AVG alias', () { + sheet.setValue('B1', '=AVERAGE(A1:A3)'); + sheet.setValue('B2', '=AVG(A1:A3)'); + expect(numberOf(sheet.valueOf('B1')), 2); + expect(numberOf(sheet.valueOf('B2')), 2); + }); + + test('MIN and MAX', () { + sheet.setValue('B1', '=MIN(A1:A3)'); + sheet.setValue('B2', '=MAX(A1:A3)'); + expect(numberOf(sheet.valueOf('B1')), 1); + expect(numberOf(sheet.valueOf('B2')), 3); + }); + + test('COUNT counts only numbers while COUNTA counts non-blank', () { + sheet.setValue('C1', '10'); + sheet.setValue('C2', 'hello'); + sheet.setValue('C4', 'TRUE'); + sheet.setValue('D1', '=COUNT(C1:C4)'); + sheet.setValue('D2', '=COUNTA(C1:C4)'); + expect(numberOf(sheet.valueOf('D1')), 1); + expect(numberOf(sheet.valueOf('D2')), 3); + }); + + test('IF chooses a branch', () { + sheet.setValue('B1', '=IF(A3>A1,"big","small")'); + expect(sheet.valueOf('B1'), const TextValue('big')); + }); + + test('nested functions', () { + sheet.setValue('B1', '=IF(SUM(A1:A3)=6, MAX(A1:A3)*2, 0)'); + expect(numberOf(sheet.valueOf('B1')), 6); + }); + + test('math helpers', () { + expect(numberOf(evalFormula('=ABS(-4.5)')), 4.5); + expect(numberOf(evalFormula('=ROUND(3.14159, 2)')), 3.14); + expect(numberOf(evalFormula('=FLOOR(7.8)')), 7); + expect(numberOf(evalFormula('=CEIL(7.2)')), 8); + expect(numberOf(evalFormula('=MOD(10, 3)')), 1); + expect(numberOf(evalFormula('=POWER(2, 10)')), 1024); + expect(numberOf(evalFormula('=SQRT(144)')), 12); + expect(numberOf(evalFormula('=INT(9.99)')), 9); + }); + + test('text helpers', () { + expect(evalFormula('=CONCAT("foo","bar")'), const TextValue('foobar')); + expect(numberOf(evalFormula('=LEN("hello")')), 5); + expect(evalFormula('=LEFT("spreadsheet", 6)'), const TextValue('spread')); + expect(evalFormula('=RIGHT("spreadsheet", 5)'), const TextValue('sheet')); + expect(evalFormula('=MID("spreadsheet", 7, 5)'), const TextValue('sheet')); + expect(evalFormula('=UPPER("cellar")'), const TextValue('CELLAR')); + expect(evalFormula('=LOWER("CELLAR")'), const TextValue('cellar')); + expect(evalFormula('=TRIM(" a b ")'), const TextValue('a b')); + }); + + test('logical helpers', () { + expect(evalFormula('=AND(TRUE, 1=1, 2>1)'), const BooleanValue(true)); + expect(evalFormula('=OR(FALSE, 1=2, 3>1)'), const BooleanValue(true)); + expect(evalFormula('=NOT(1=1)'), const BooleanValue(false)); + }); + + test('INDEX into a range', () { + sheet.setValue('B1', '=INDEX(A1:A3, 2)'); + expect(numberOf(sheet.valueOf('B1')), 2); + }); + + test('INDEX out of range returns #REF!', () { + sheet.setValue('B1', '=INDEX(A1:A3, 9)'); + expect(sheet.valueOf('B1'), const ErrorValue(ErrorKind.ref)); + }); + + test('IFERROR replaces an error', () { + expect(numberOf(evalFormula('=IFERROR(1/0, 42)')), 42); + expect(numberOf(evalFormula('=IFERROR(5, 42)')), 5); + }); + + test('ISERROR detects errors', () { + expect(evalFormula('=ISERROR(1/0)'), const BooleanValue(true)); + expect(evalFormula('=ISERROR(1+1)'), const BooleanValue(false)); + }); + }); + + group('errors', () { + test('division by zero', () { + expect(evalFormula('=1/0'), const ErrorValue(ErrorKind.divZero)); + }); + + test('reference error literal propagates through arithmetic', () { + expect(evalFormula('=#REF!'), const ErrorValue(ErrorKind.ref)); + expect(evalFormula('=#REF!+1'), const ErrorValue(ErrorKind.ref)); + }); + + test('unknown function name', () { + expect(evalFormula('=NOPE(1)'), const ErrorValue(ErrorKind.name)); + }); + + test('bare unknown name', () { + expect(evalFormula('=SOMENAME'), const ErrorValue(ErrorKind.name)); + }); + + test('square root of a negative is #NUM!', () { + expect(evalFormula('=SQRT(-1)'), const ErrorValue(ErrorKind.numError)); + }); + + test('error propagates from a referenced cell', () { + final sheet = Sheet(); + sheet.setValue('A1', '=1/0'); + sheet.setValue('B1', '=A1+5'); + expect(sheet.valueOf('B1'), const ErrorValue(ErrorKind.divZero)); + }); + }); + + group('address helpers', () { + test('column label round trips', () { + expect(columnLabel(0), 'A'); + expect(columnLabel(25), 'Z'); + expect(columnLabel(26), 'AA'); + expect(columnLabel(27), 'AB'); + expect(columnLabel(701), 'ZZ'); + expect(columnLabel(702), 'AAA'); + }); + + test('parse A1 style references', () { + expect(CellAddress.parse('A1'), const CellAddress(0, 0)); + expect(CellAddress.parse('B2'), const CellAddress(1, 1)); + expect(CellAddress.parse('AA10').col, 26); + expect(CellAddress.parse('AA10').row, 9); + }); + }); +}