cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

Functions follow Sheets where the Sheets corpus found them apart

Dami committed 441dcbc
Comparisons and criteria look at 15 significant digits. MID, CHOOSE
and INDEX out of range are #NUM!, SEQUENCE(0) too, and ROWS and
COLUMNS give an error they're handed. UPPER maps case fully, TEXT keeps
TRUE, IFERROR with one argument is blank, conditions read "TRUE" and
"", COUNT skips errors and COUNTA counts them, COUNTIF matches text
numbers, INDEX with a 0 passes the whole row or column through, odd
roots of negatives are real, SUMPRODUCT counts TRUE, and LET's unused
errors don't matter. The oracle skips the three Excel error codes that
now differ, and the decimal demo compares a residue with 0, which binary
still misses.
commit
441dcbc9dff48f7dbccb3c676de100049c367bf8
parent
5f4d97f9438ac4614d5d1048be03af7d96c66498
author
Dami <dami.etoile@gmail.com>
kind
fix risk score 2.6 of 3, Jev's estimate
27 files changed, +319 -88
Mdemos/decimal.tape4+-
Mdocs/formulas/README.md21++++++++-
Mdocs/formulas/arrays.md2+
Mdocs/formulas/decimal.md4+-
Mdocs/reference/functions.md20++++----
Me2e/settings_test.go4+-
Minternal/functions/args.go25++++++++----
Minternal/functions/dynamic.go5++-
Minternal/functions/eval.go6+--
Minternal/functions/everyday.go64++++++++++++++++++++---------
Minternal/functions/lambda.go10++---
Minternal/functions/lift.go6++-
Minternal/functions/logic.go29++++++++++++-
Minternal/functions/lookup.go23++++++++--
Minternal/functions/masked.go4+-
Minternal/functions/math.go20++++++++-
Minternal/functions/text.go30++++++++++++--
Minternal/sheet/decimal_test.go9++--
Minternal/sheet/functions_array_test.go2+-
Minternal/sheet/functions_lookup_test.go7++-
Minternal/sheet/functions_math_test.go7+++-
Ainternal/sheet/functions_sheets_test.go67++++++++++++++++++++++++++++++
Minternal/sheet/functions_text_test.go2+-
Minternal/sheet/workbook_test.go2+-
Minternal/ui/settings_test.go2+-
Minternal/value/value.go27++++++++++++
Moracle/oracle_test.go5++-
M demos/decimal.tape +2 -2
M docs/formulas/README.md +18 -3
@@ -36,11 +36,26 @@ caret on the problem and says what's wrong on the context line, e.g.
3636 tighter than `^` as in Sheets and Excel, so `=-2^2` is 4. 1-2-3's `#AND#`,3737 `#OR#` and `#NOT#` still work; `AND()`, `OR()` and `NOT()` are the Sheets way.3838 39+Numbers are binary floating point, as in Sheets and Excel, and follow40+Sheets where that shows:41+42+- Comparisons, and criteria such as `COUNTIF`'s, look at the 1543+ significant digits a cell shows, so `=0.1+0.2=0.3` is TRUE. A44+ difference itself keeps every digit: `=0.1+0.2-0.3` is 5.551E-1745+ ([decimal arithmetic](decimal.md) makes it 0).46+- A number written in a formula keeps 15 significant digits:47+ `=123456789012345678` is 123456789012345000.48+- A negative number has odd roots: `=(-8)^(1/3)` and `=POWER(-8, 1/3)`49+ are -2; other fractional powers of it are `#NUM!`.50+3951 ## Values and errors4052 4153 Text in arithmetic is `#VALUE!` unless it reads as a number (`="3"*2` is 6);42-blanks count as 0 or empty text. Aggregates such as `SUM` over a range skip43-text. Errors use Sheets' codes:54+blanks count as 0 or empty text. A condition (`IF`, `IFS`, `AND`, `OR`,55+`NOT`) also reads the text `TRUE` or `FALSE`, and empty text as FALSE, so56+`=IF("TRUE", 1, 2)` is 1. Aggregates such as `SUM` over a range skip57+text; `COUNT` skips errors too and `COUNTA` counts them, where the others58+give the first error in the range. Errors use Sheets' codes:4459 4560 | Error | Meaning |4661 |---|---|
@@ -49,7 +64,7 @@ text. Errors use Sheets' codes:
4964 | `#NAME?` | an unknown name |5065 | `#REF!` | a reference to deleted cells, or a circular reference: every cell of the cycle, even one whose formula catches errors (`IFERROR`) or skips them (`COUNTIF`), as in Sheets |5166 | `#N/A` | not available, e.g. a lookup found nothing |52-| `#NUM!` | a number out of range, e.g. `SQRT(-1)` |67+| `#NUM!` | a number out of range, e.g. `SQRT(-1)`, or a position outside what `CHOOSE`, `INDEX` or `MID` reads from, as in Sheets (Excel says `#VALUE!` or `#REF!`) |5368 5469 Error cells are red with a curly underline, and the context line explains the5570 active cell's error and where it came from, e.g.
M docs/formulas/arrays.md +2 -0
M docs/formulas/decimal.md +3 -1
M docs/reference/functions.md +10 -10
@@ -20,15 +20,15 @@ Aliases: `AVG` for `AVERAGE`.
2020 | `ABS(value)` | Absolute value |2121 | `AND(logical1, [logical2, ...])` | TRUE if all arguments are true |2222 | `AVERAGE(value1, [value2, ...])` | Average of numbers, ignoring text |23-| `COUNT(value1, [value2, ...])` | Count of numeric values |24-| `COUNTA(value1, [value2, ...])` | Count of non-empty values |23+| `COUNT(value1, [value2, ...])` | Count of numeric values, skipping errors |24+| `COUNTA(value1, [value2, ...])` | Count of non-empty values, errors included |2525 | `FALSE()` | The logical value FALSE |2626 | `IF(condition, value_if_true, [value_if_false])` | Choose a value by a condition |27-| `IFERROR(value, [value_if_error])` | A fallback when a value is an error |27+| `IFERROR(value, [value_if_error])` | A fallback when a value is an error; blank without one |2828 | `INT(value)` | Round down to the nearest integer |2929 | `MAX(value1, [value2, ...])` | Largest number |3030 | `MIN(value1, [value2, ...])` | Smallest number |31-| `MOD(dividend, divisor)` | Remainder, with the sign of the divisor |31+| `MOD(dividend, divisor)` | Remainder, with the sign of the divisor: MOD(0.3, 0.1) is 0.1 as in Excel, where Sheets gives -5.55E-17 |3232 | `NA()` | The #N/A error |3333 | `NOT(logical)` | The opposite of a logical value |3434 | `OR(logical1, [logical2, ...])` | TRUE if any argument is true |
@@ -48,7 +48,7 @@ Aliases: `AVG` for `AVERAGE`.
4848 | `LN(value)` | Natural logarithm |4949 | `LOG(value, [base])` | Logarithm, base 10 by default |5050 | `LOG10(value)` | Base-10 logarithm |51-| `POWER(base, exponent)` | A number raised to a power |51+| `POWER(base, exponent)` | A number raised to a power, odd roots of negatives included |5252 | `PRODUCT(factor1, [factor2, ...])` | Product of numbers |5353 | `QUOTIENT(dividend, divisor)` | Integer part of a division |5454 | `RAND()` | A random number from 0 up to 1, new on every change (recalculates on every change) |
@@ -58,7 +58,7 @@ Aliases: `AVG` for `AVERAGE`.
5858 | `SIGN(value)` | 1, 0 or -1 by the sign of a number |5959 | `SUMIF(range, criterion, [sum_range])` | Sum of the cells that meet a condition |6060 | `SUMIFS(sum_range, criteria_range1, criterion1, [criteria_range2, criterion2, ...])` | Sum of the cells that meet every condition |61-| `SUMPRODUCT(array1, [array2, ...])` | Sum of the products of matching entries |61+| `SUMPRODUCT(array1, [array2, ...])` | Sum of the products of matching entries, TRUE counting as 1 |6262 | `TRUNC(value, [places])` | Drop decimals past a number of places |6363 6464 ## Statistics
@@ -105,7 +105,7 @@ Aliases: `AVG` for `AVERAGE`.
105105 | `EXACT(string1, string2)` | TRUE if two texts are identical, case included |106106 | `FIND(search_for, text_to_search, [starting_at])` | Position of text, case-sensitive |107107 | `LEFT(string, [number_of_characters])` | The first characters of text |108-| `LEN(text)` | Number of characters in text |108+| `LEN(text)` | Number of characters in text: an emoji is one, where Sheets counts two |109109 | `LOWER(text)` | Text in lower case |110110 | `MID(string, starting_at, extract_length)` | Characters from the middle of text |111111 | `PROPER(text)` | Text with each word capitalized |
@@ -117,7 +117,7 @@ Aliases: `AVG` for `AVERAGE`.
117117 | `TEXT(number, format)` | A number as text in a format, e.g. "$#,##0.00" or "yyyy-mm-dd" |118118 | `TEXTJOIN(delimiter, ignore_empty, text1, [text2, ...])` | Join text with a delimiter |119119 | `TRIM(text)` | Text without leading, trailing and repeated spaces |120-| `UPPER(text)` | Text in upper case |120+| `UPPER(text)` | Text in upper case: ß is SS |121121 | `VALUE(text)` | Text as a number; dates and times too |122122 123123 ## Split and regular expressions
@@ -136,7 +136,7 @@ Aliases: `AVG` for `AVERAGE`.
136136 | `CHOOSE(index, choice1, [choice2, ...])` | The choice at a position |137137 | `COLUMNS(range)` | Number of columns in a range |138138 | `HLOOKUP(search_key, range, index, [is_sorted])` | Find a key in the first row and return a value from its column |139-| `INDEX(reference, [row], [column])` | The value at a row and column of a range |139+| `INDEX(reference, [row], [column])` | The value at a row and column of a range; row or column 0 for a whole column or row |140140 | `MATCH(search_key, range, [search_type])` | Position of a key in a row or column |141141 | `ROWS(range)` | Number of rows in a range |142142 | `VLOOKUP(search_key, range, index, [is_sorted])` | Find a key in the first column and return a value from its row |
@@ -164,7 +164,7 @@ Aliases: `AVG` for `AVERAGE`.
164164 | `BYCOL(array_or_range, LAMBDA)` | Each column of an array passed to a LAMBDA, one value per column |165165 | `BYROW(array_or_range, LAMBDA)` | Each row of an array passed to a LAMBDA, one value per row |166166 | `LAMBDA([name, ...], formula_expression)` | A function of names, called with values: LAMBDA(x, x*2)(3) |167-| `LET(name1, value_expression1, [name2, value_expression2, ...], formula_expression)` | Name values for use in a formula |167+| `LET(name1, value_expression1, [name2, value_expression2, ...], formula_expression)` | Name values for use in a formula; an error counts only where its name is used |168168 | `MAKEARRAY(rows, columns, LAMBDA)` | An array of a size, each entry a LAMBDA of its row and column |169169 | `MAP(array1, [array2, ...], LAMBDA)` | Each entry of arrays passed to a LAMBDA |170170 | `REDUCE(initial_value, array_or_range, LAMBDA)` | An array folded into one value by a LAMBDA of the total so far and each entry |
M e2e/settings_test.go +2 -2
M internal/functions/args.go +17 -8
M internal/functions/dynamic.go +4 -1
M internal/functions/eval.go +2 -4
M internal/functions/everyday.go +44 -20
@@ -119,10 +119,10 @@ func init() {
119119 }120120 return num(s.sum / float64(s.nums))121121 }), format: inherit},122- &FuncDef{Name: "COUNT", Args: "value1, [value2, ...]", Desc: "Count of numeric values", Min: 1, Max: -1,123- eval: aggregate(func(s Agg) Value { return num(float64(s.nums)) })},124- &FuncDef{Name: "COUNTA", Args: "value1, [value2, ...]", Desc: "Count of non-empty values", Min: 1, Max: -1,125- eval: aggregate(func(s Agg) Value { return num(float64(s.count)) })},122+ &FuncDef{Name: "COUNT", Args: "value1, [value2, ...]", Desc: "Count of numeric values, skipping errors", Min: 1, Max: -1,123+ eval: counter(func(s Agg) Value { return num(float64(s.nums)) })},124+ &FuncDef{Name: "COUNTA", Args: "value1, [value2, ...]", Desc: "Count of non-empty values, errors included", Min: 1, Max: -1,125+ eval: counter(func(s Agg) Value { return num(float64(s.count + s.errs)) })},126126 &FuncDef{Name: "MIN", Args: "value1, [value2, ...]", Desc: "Smallest number", Min: 1, Max: -1,127127 eval: aggregate(func(s Agg) Value {128128 if s.nums == 0 {
@@ -142,7 +142,7 @@ func init() {
142142 &FuncDef{Name: "SQRT", Args: "value", Desc: "Square root", Min: 1, Max: 1, eval: math1(math.Sqrt)},143143 &FuncDef{Name: "ROUND", Args: "value, [places]", Desc: "Round to a number of decimal places, halves away from zero", Min: 1, Max: 2,144144 eval: rounder(numfmt.HalfUp), format: inheritFrom(0)},145- &FuncDef{Name: "MOD", Args: "dividend, divisor", Desc: "Remainder, with the sign of the divisor", Min: 2, Max: 2,145+ &FuncDef{Name: "MOD", Args: "dividend, divisor", Desc: "Remainder, with the sign of the divisor: MOD(0.3, 0.1) is 0.1 as in Excel, where Sheets gives -5.55E-17", Min: 2, Max: 2,146146 eval: numeric(func(x []float64) Value {147147 if x[1] == 0 {148148 return value.ErrDiv0
@@ -155,15 +155,11 @@ func init() {
155155 &FuncDef{Name: "NA", Desc: "The #N/A error", Max: 0, eval: constant(value.ErrNA)},156156 &FuncDef{Name: "IF", Args: "condition, value_if_true, [value_if_false]", Desc: "Choose a value by a condition", Min: 2, Max: 3, arrays: liftPass,157157 eval: func(args []Node, get lookup) Value {158- c := eval1(args[0], get)159- if c.Kind == value.Error {160- return c161- }162- f, err := toNum(c)158+ t, err := truth(eval1(args[0], get))163159 if err != nil {164160 return *err165161 }166- if f != 0 {162+ if t {167163 return eval(args[1], get)168164 }169165 if len(args) < 3 {
@@ -171,14 +167,14 @@ func init() {
171167 }172168 return eval(args[2], get)173169 }, format: func(args []Node, infer func(Node) Format) Format { return inherit(args[1:], infer) }},174- &FuncDef{Name: "IFERROR", Args: "value, [value_if_error]", Desc: "A fallback when a value is an error", Min: 1, Max: 2, arrays: liftPass,170+ &FuncDef{Name: "IFERROR", Args: "value, [value_if_error]", Desc: "A fallback when a value is an error; blank without one", Min: 1, Max: 2, arrays: liftPass,175171 eval: func(args []Node, get lookup) Value {176172 v := eval(args[0], get)177173 if v.Kind != value.Error {178174 return v179175 }180176 if len(args) < 2 {181- return Value{Kind: value.Text}177+ return Value{} // blank, as in Sheets182178 }183179 return eval(args[1], get)184180 }},
@@ -187,7 +183,13 @@ func init() {
187183 &FuncDef{Name: "OR", Args: "logical1, [logical2, ...]", Desc: "TRUE if any argument is true", Min: 1, Max: -1,188184 eval: logical(func(t, _ int) bool { return t > 0 })},189185 &FuncDef{Name: "NOT", Args: "logical", Desc: "The opposite of a logical value", Min: 1, Max: 1,190- eval: numeric(func(x []float64) Value { return boolean(x[0] == 0) })},186+ eval: func(args []Node, get lookup) Value {187+ t, err := truth(eval(args[0], get))188+ if err != nil {189+ return *err190+ }191+ return boolean(!t)192+ }},191193 )192194 }193195
@@ -198,9 +200,13 @@ func init() {
198200 type Agg struct {199201 sum float64200202 prod float64201- count int // non-empty values203+ count int // non-empty values other than errors202204 nums int203205 min, max float64206+ // counting is set for COUNT and COUNTA, which count errors (errs)207+ // where the others stop at the first.208+ counting bool209+ errs int204210 }205211 206212 // each calls fn for every value in args: every cell of a range that
@@ -264,10 +270,15 @@ func eachEntry(a *Array, fn func(v Value, direct bool) *Value) *Value {
264270 }265271 266272 // Add counts v into the aggregate, with SUM's rules: blanks are skipped,267-// errors returned, and text counts only when given directly.273+// errors returned (counted, when counting), and text counts only when274+// given directly.268275 func (s *Agg) Add(v Value, direct bool) *Value {269276 switch v.Kind {270277 case value.Error:278+ if s.counting {279+ s.errs++280+ return nil281+ }271282 return errOf(v)272283 case value.Empty:273284 return nil
@@ -298,8 +309,21 @@ func NewAgg() Agg { return Agg{prod: 1, min: math.Inf(1), max: math.Inf(-1)} }
298309 // read each cell once; other ranges the engine adds up itself299310 // (Book.Fold), as it reads them.300311 func aggregate(done func(Agg) Value) func([]Node, lookup) Value {312+ return aggregateFrom(NewAgg(), done)313+}314+315+// counter builds COUNT and COUNTA, which read past errors, as Sheets316+// does: COUNT skips them and COUNTA counts them. Their ranges are read317+// directly, as the shared aggregates stop at an error.318+func counter(done func(Agg) Value) func([]Node, lookup) Value {319+ s := NewAgg()320+ s.counting = true321+ return aggregateFrom(s, done)322+}323+324+func aggregateFrom(start Agg, done func(Agg) Value) func([]Node, lookup) Value {301325 return func(args []Node, get lookup) Value {302- s := NewAgg()326+ s := start303327 for _, arg := range args {304328 if rn, ok := get.refOf(arg).(formula.Range); ok {305329 var e *Value
@@ -319,7 +343,7 @@ func aggregate(done func(Agg) Value) func([]Node, lookup) Value {
319343 // rangeAgg adds the range rn to s: from the running aggregates when s is320344 // empty and the range is shared, and otherwise as the engine reads it.321345 func rangeAgg(rn formula.Range, s Agg, get lookup) (Agg, *Value) {322- if s.count == 0 {346+ if s.count == 0 && !s.counting {323347 if a, e, ok := get.book.RangeAgg(rn.Sheet, rn.Rect); ok {324348 return a, e325349 }
@@ -337,12 +361,12 @@ func logical(test func(trues, n int) bool) func([]Node, lookup) Value {
337361 case v.Kind == value.Empty, v.Kind == value.Text && !direct:338362 return nil339363 }340- f, err := toNum(v)364+ t, err := truth(v)341365 if err != nil {342366 return err343367 }344368 n++345- if f != 0 {369+ if t {346370 trues++347371 }348372 return nil
M internal/functions/lambda.go +4 -6
M internal/functions/lift.go +5 -1
M internal/functions/logic.go +26 -3
M internal/functions/lookup.go +18 -5
@@ -14,16 +14,16 @@ func init() {
1414 eval: func(args []Node, get lookup) Value { return tableLookup(args, get, false) }},1515 &FuncDef{Name: "MATCH", Args: "search_key, range, [search_type]", Desc: "Position of a key in a row or column", Min: 2, Max: 3,1616 eval: match},17- &FuncDef{Name: "INDEX", Args: "reference, [row], [column]", Desc: "The value at a row and column of a range", Min: 1, Max: 3,17+ &FuncDef{Name: "INDEX", Args: "reference, [row], [column]", Desc: "The value at a row and column of a range; row or column 0 for a whole column or row", Min: 1, Max: 3, arrays: liftPass,1818 eval: index, format: inheritFrom(0)},1919 &FuncDef{Name: "XLOOKUP", Args: "search_key, lookup_range, result_range, [missing_value], [match_mode], [search_mode]", Desc: "Find a key and return the matching entry of another range", Min: 3, Max: 6,2020 eval: xlookup, format: inheritFrom(2)},2121 &FuncDef{Name: "CHOOSE", Args: "index, choice1, [choice2, ...]", Desc: "The choice at a position", Min: 2, Max: -1, arrays: liftPass,2222 eval: choose},2323 &FuncDef{Name: "ROWS", Args: "range", Desc: "Number of rows in a range", Min: 1, Max: 1,24- eval: func(args []Node, get lookup) Value { return num(float64(matrixArg(args[0], get).rows)) }},24+ eval: func(args []Node, get lookup) Value { return measure(args[0], get, true) }},2525 &FuncDef{Name: "COLUMNS", Args: "range", Desc: "Number of columns in a range", Min: 1, Max: 1,26- eval: func(args []Node, get lookup) Value { return num(float64(matrixArg(args[0], get).cols)) }},26+ eval: func(args []Node, get lookup) Value { return measure(args[0], get, false) }},2727 )2828 }2929
@@ -83,7 +83,7 @@ func index(args []Node, get lookup) Value {
8383 }8484 switch {8585 case row > m.rows || col > m.cols:86- return value.ErrRef86+ return value.ErrNum // as Sheets; Excel says #REF!8787 case row == 0 || col == 0: // a whole column, row or both: an array8888 return get.arrayValue(m.slice(row, col))8989 }
@@ -113,13 +113,26 @@ func (m matrix) slice(row, col int) *Array {
113113 return a114114 }115115 116+// measure is ROWS, or COLUMNS when rows is false. An error in place of117+// a range or array is the answer: ROWS(SEQUENCE(0)) is #NUM!.118+func measure(n Node, get lookup, rows bool) Value {119+ m := matrixArg(n, get)120+ switch {121+ case !m.ref && m.rows == 1 && m.cols == 1 && m.blank.Kind == value.Error:122+ return m.blank123+ case rows:124+ return num(float64(m.rows))125+ }126+ return num(float64(m.cols))127+}128+116129 func choose(args []Node, get lookup) Value {117130 i, err := intArg(args, 0, 0, get)118131 if err != nil {119132 return *err120133 }121134 if i < 1 || i >= len(args) {122- return value.ErrValue135+ return value.ErrNum // as Sheets; Excel says #VALUE!123136 }124137 return eval(args[i], get)125138 }
M internal/functions/masked.go +2 -2
M internal/functions/math.go +17 -3
@@ -14,7 +14,7 @@ func init() {
1414 eval: sumIf, format: inheritFrom(2)},1515 &FuncDef{Name: "SUMIFS", Args: "sum_range, criteria_range1, criterion1, [criteria_range2, criterion2, ...]", Desc: "Sum of the cells that meet every condition", Min: 3, Max: -1, step: 2,1616 eval: sumIfs, format: inheritFrom(0)},17- &FuncDef{Name: "SUMPRODUCT", Args: "array1, [array2, ...]", Desc: "Sum of the products of matching entries", Min: 1, Max: -1,17+ &FuncDef{Name: "SUMPRODUCT", Args: "array1, [array2, ...]", Desc: "Sum of the products of matching entries, TRUE counting as 1", Min: 1, Max: -1,1818 eval: sumProduct},1919 &FuncDef{Name: "PRODUCT", Args: "factor1, [factor2, ...]", Desc: "Product of numbers", Min: 1, Max: -1,2020 eval: aggregate(func(s Agg) Value {
@@ -23,7 +23,7 @@ func init() {
2323 }2424 return num(s.prod)2525 })},26- &FuncDef{Name: "POWER", Args: "base, exponent", Desc: "A number raised to a power", Min: 2, Max: 2,26+ &FuncDef{Name: "POWER", Args: "base, exponent", Desc: "A number raised to a power, odd roots of negatives included", Min: 2, Max: 2,2727 eval: numeric(func(x []float64) Value { return powerOf(x[0], x[1]) })},2828 &FuncDef{Name: "ROUNDUP", Args: "value, [places]", Desc: "Round away from zero", Min: 1, Max: 2,2929 eval: rounder(numfmt.Up), format: inheritFrom(0)},
@@ -102,7 +102,21 @@ func powerOf(b, e float64) Value {
102102 if b == 0 && e < 0 {103103 return value.ErrDiv0104104 }105- return num(math.Pow(b, e))105+ return num(power(b, e))106+}107+108+// power is b raised to e, as POWER and ^ compute it. A negative base109+// has a real odd root, as in Sheets: (-8)^(1/3) is -2, where math.Pow110+// gives NaN (#NUM!). Other fractional powers of a negative stay NaN.111+func power(b, e float64) float64 {112+ if b >= 0 || e == math.Trunc(e) {113+ return math.Pow(b, e)114+ }115+ n := math.Round(1 / e) // e is 1/n, or near it: 1/3 isn't exact116+ if math.Abs(1/e-n) > 1e-9*math.Abs(n) || math.Mod(n, 2) == 0 {117+ return math.NaN()118+ }119+ return -math.Pow(-b, e)106120 }107121 108122 // toMultiple builds CEILING and FLOOR with Sheets semantics: the factor
M internal/functions/text.go +26 -4
@@ -3,6 +3,10 @@ package functions
33 import (44 "strings"55 "unicode"6+ "unicode/utf8"7+8+ "golang.org/x/text/cases"9+ "golang.org/x/text/language"610 711 "github.com/FelineStateMachine/012/internal/numfmt"812 "github.com/FelineStateMachine/012/internal/value"
@@ -25,10 +29,10 @@ func init() {
2529 eval: textSlice(func(r []rune, n int) string { return string(r[len(r)-min(n, len(r)):]) })},2630 &FuncDef{Name: "MID", Args: "string, starting_at, extract_length", Desc: "Characters from the middle of text", Min: 3, Max: 3,2731 eval: mid},28- &FuncDef{Name: "LEN", Args: "text", Desc: "Number of characters in text", Min: 1, Max: 1,32+ &FuncDef{Name: "LEN", Args: "text", Desc: "Number of characters in text: an emoji is one, where Sheets counts two", Min: 1, Max: 1,2933 eval: textFn(func(s string) Value { return num(float64(runeLen(s))) })},30- &FuncDef{Name: "UPPER", Args: "text", Desc: "Text in upper case", Min: 1, Max: 1,31- eval: textFn(func(s string) Value { return str(strings.ToUpper(s)) })},34+ &FuncDef{Name: "UPPER", Args: "text", Desc: "Text in upper case: ß is SS", Min: 1, Max: 1,35+ eval: textFn(func(s string) Value { return str(upper(s)) })},3236 &FuncDef{Name: "LOWER", Args: "text", Desc: "Text in lower case", Min: 1, Max: 1,3337 eval: textFn(func(s string) Value { return str(strings.ToLower(s)) })},3438 &FuncDef{Name: "PROPER", Args: "text", Desc: "Text with each word capitalized", Min: 1, Max: 1,
@@ -113,7 +117,10 @@ func mid(args []Node, get lookup) Value {
113117 if err != nil {114118 return *err115119 }116- if start < 1 || n < 0 {120+ switch {121+ case start < 1:122+ return value.ErrNum // as Sheets; Excel says #VALUE!123+ case n < 0:117124 return value.ErrValue118125 }119126 r := []rune(s)
@@ -123,6 +130,18 @@ func mid(args []Node, get lookup) Value {
123130 return str(string(r[start-1 : min(start-1+n, len(r))]))124131 }125132 133+// upper is UPPER's full case mapping, which Sheets uses: "straße" is134+// "STRASSE", where mapping each letter alone would keep the ß. A Caser135+// keeps state, so each call makes its own, and ASCII needs none.136+func upper(s string) string {137+ for i := 0; i < len(s); i++ {138+ if s[i] >= utf8.RuneSelf {139+ return cases.Upper(language.Und).String(s)140+ }141+ }142+ return strings.ToUpper(s)143+}144+126145 func trimSpaces(s string) Value {127146 return str(strings.Join(strings.FieldsFunc(s, func(r rune) bool { return r == ' ' }), " "))128147 }
@@ -162,6 +181,9 @@ func textFormat(args []Node, get lookup) Value {
162181 if err != nil {163182 return *err164183 }184+ if v.Kind == value.Bool {185+ return str(v.String()) // TRUE stays TRUE whatever the format, as in Sheets186+ }165187 if v.Kind == value.Text {166188 n, _, ok := value.ParseValue(v.Str)167189 if !ok {
M internal/sheet/decimal_test.go +5 -4
@@ -36,12 +36,13 @@ func TestDecimalArithmetic(t *testing.T) {
3636 binary, decim string3737 }{3838 {"sum of two", "=0.1+0.2", nil, "0.30000000000000004", "0.3"},39- {"comparison", "=0.1+0.2=0.3", nil, "FALSE", "TRUE"},39+ {"comparison", "=0.1+0.2=0.3", nil, "TRUE", "TRUE"}, // at the 15 digits shown40+ {"comparison with zero", "=0.1+0.2-0.3=0", nil, "FALSE", "TRUE"},4041 {"residue", "=1.1-1-0.1", nil, "8.326672684688674E-17", "0"},4142 {"product", "=0.07*3", nil, "0.21000000000000002", "0.21"},4243 {"cents to whole", "=INT(A1*100)", []string{"$4.35"}, "434", "435"},4344 {"currency sum", "=SUM(A1:A10)", tenCents, "0.9999999999999999", "1"},44- {"currency sum equals", "=SUM(A1:A10)=1", tenCents, "FALSE", "TRUE"},45+ {"currency sum equals", "=SUM(A1:A10)-1=0", tenCents, "FALSE", "TRUE"},4546 {"average", "=AVERAGE(0.1, 0.2)", nil, "0.15000000000000002", "0.15"},4647 {"division", "=0.3/0.1", nil, "2.9999999999999996", "3"},4748 {"postfix percent", "=1.1%", nil, "0.011000000000000001", "0.011"},
@@ -77,7 +78,7 @@ func TestDecimalArithmetic(t *testing.T) {
7778 {"product of text", `=PRODUCT("a")`, nil, "#VALUE!", "#VALUE!"},7879 {"huge product falls back", "=PRODUCT(1E308, 10)", nil, "#NUM!", "#NUM!"},7980 {"sumif", `=SUMIF(A1:A10, ">0")`, tenCents, "0.9999999999999999", "1"},80- {"sumif equals", `=SUMIF(A1:A10, ">0")=1`, tenCents, "FALSE", "TRUE"},81+ {"sumif equals", `=SUMIF(A1:A10, ">0")-1=0`, tenCents, "FALSE", "TRUE"},8182 {"sumif with sum range", `=SUMIF(A1:A3, "<>x", A1:A3)`, []string{"0.1", "x", "0.2"}, "0.30000000000000004", "0.3"},8283 {"sumif error", `=SUMIF(A1:A2, ">0")`, []string{"1", "=1/0"}, "1", "1"},8384 {"sumifs", `=SUMIFS(A1:A10, A1:A10, "0.1")`, tenCents, "0.9999999999999999", "1"},
@@ -110,7 +111,7 @@ func TestDecimalArithmetic(t *testing.T) {
110111 // Turning the setting on and off recalculates, is one undo step, and111112 // undo and redo recalculate too.112113 func TestDecimalUndo(t *testing.T) {113- s := decimalSheet(t, false, "=0.1+0.2=0.3")114+ s := decimalSheet(t, false, "=0.1+0.2-0.3=0")114115 s.ClearHistory()115116 check := func(when string, want bool) {116117 t.Helper()
M internal/sheet/functions_array_test.go +1 -1
M internal/sheet/functions_lookup_test.go +4 -3
M internal/sheet/functions_math_test.go +6 -1
A internal/sheet/functions_sheets_test.go +67 -0
@@ -0,0 +1,67 @@
1+package sheet2+3+import "testing"4+5+// Where Sheets and Excel differ, 012 follows Sheets (the Sheets corpus in6+// internal/fileio checks these against Sheets itself).7+func TestSheetsSemantics(t *testing.T) {8+ s := fixture(t)9+ for a, in := range map[string]string{"J1": "'3", "J2": "=1/0", "J3": "FALSE", "J4": "=NA()"} {10+ if err := s.Set(at(a), in); err != nil {11+ t.Fatal(err)12+ }13+ }14+ checkFormulas(t, s, []fnCase{15+ // Text.16+ {`=UPPER("straße")`, txt("STRASSE")},17+ {`=UPPER("dž")`, txt("DŽ")},18+ {`=TEXT(TRUE, "0")`, txt("TRUE")},19+ {`=TEXT(FALSE, "0.00")`, txt("FALSE")},20+ {`=TEXT(0.75, "# ?/?")`, txt("3/4")},21+ {`=TEXT(2.5, "# ?/?")`, txt("2 1/2")},22+ // Numbers compare at the 15 digits a cell shows.23+ {"=0.1+0.2=0.3", boolean(true)},24+ {"=(1-0.9)=0.1", boolean(true)},25+ {"=SUM(0.1, 0.2, 0.3)=0.6", boolean(true)},26+ {"=0.1+0.2<0.3", boolean(false)},27+ {"=0.1+0.2>=0.3", boolean(true)},28+ {"=0.1+0.2-0.3=0", boolean(false)},29+ {"=1.00000000000001=1", boolean(false)},30+ {`=COUNTIF(E1:E3, 0.1+0.2+1.2)`, num(1)},31+ {"=123456789012345678", num(123456789012345000)},32+ {"=123456789012345678=123456789012345000", boolean(true)},33+ {"=ROUND(1.23456789012345, 14)=1.23456789012346", boolean(true)}, // the half added carries34+ {"=ROUND(2.675, 2)", num(2.68)},35+ {"=ROUND(-2.5)", num(-3)},36+ // Errors and conditions.37+ {"=IFERROR(NA())", Value{}},38+ {`=ISBLANK(IFERROR(NA()))`, boolean(true)},39+ {`=IF("TRUE", 1, 2)`, num(1)},40+ {`=IF("false", 1, 2)`, num(2)},41+ {`=IF("", 1, 2)`, num(2)},42+ {`=IF("abc", 1, 2)`, ErrValue},43+ {`=NOT("")`, boolean(true)},44+ {`=NOT("TRUE")`, boolean(false)},45+ {`=AND("TRUE", 1)`, boolean(true)},46+ {"=COUNT(J1:J4)", num(0)},47+ {"=COUNTA(J1:J4)", num(4)},48+ {"=COUNT(1/0, 2)", num(1)},49+ {"=SUM(J1:J4)", ErrDiv0},50+ {"=COUNTIF(J1:J4, 3)", num(1)},51+ {`=COUNTIF(J1:J4, "<>3")`, num(3)},52+ {`=COUNTIF(J1:J4, ">2")`, num(0)},53+ // Positions out of range, and whole rows and columns.54+ {`=CHOOSE(0, "a")`, ErrNum},55+ {"=INDEX(A1:A3, 5)", ErrNum},56+ {"=ROWS(INDEX(A1:C3, 0, 1))", num(3)},57+ {"=COLUMNS(INDEX(A1:C3, 2, 0))", num(3)},58+ {"=SUM(INDEX(C1:E3, 0, 1))", num(6)},59+ {"=ROWS(SEQUENCE(0))", ErrNum},60+ {"=ROWS(1/0)", ErrDiv0},61+ // Arrays and names.62+ {"=SUMPRODUCT(C1:C3>1)", num(2)},63+ {"=SUMPRODUCT(C1:C3>1, C1:C3)", num(5)},64+ {"=LET(x, 1/0, 5)", num(5)},65+ {"=LET(x, 1/0, x+1)", ErrDiv0},66+ })67+}
M internal/sheet/functions_text_test.go +1 -1
M internal/sheet/workbook_test.go +1 -1
M internal/ui/settings_test.go +1 -1
M internal/value/value.go +27 -0
M oracle/oracle_test.go +4 -1