make oracle runs a separate module that loads the same data into one23 and excelize, then compares 230 formulas across every function category and 202 formatted values. Skipped cases say why: where Sheets and Excel disagree (ROUND's optional places, dates before March 1900) or where excelize departs from Excel.
- commit
- e42bce492d0ed1d8896211c3db8f0bb52b7db069
- parent
- ff6486e58d7d15f4b5f2964883228a15e68c1163
- author
- Dami <dami.etoile@gmail.com>
| M | Makefile | 7 | +- |
| M | README.md | 6 | +- |
| A | oracle/ | 79 | +++++++++ |
| A | oracle/ | 21 | ++ |
| A | oracle/ | 28 | +++ |
| A | oracle/ | 274 | ++++++++++++++++++++++++++++++ |
@@ -7,7 +7,7 @@ GHOSTTY_SRC := $(DEPS)/ghostty-src
77 GHOSTTY_OUT := $(DEPS)/ghostty88 GHOSTTY_STAMP := $(GHOSTTY_OUT)/.built-$(GHOSTTY_COMMIT)99 10-10+1111 1212 build:1313 CGO_ENABLED=0 go build -o bin/one23 ./cmd/one23@@ -29,6 +29,11 @@ e2e: $(GHOSTTY_STAMP)
2929 screens: $(GHOSTTY_STAMP)3030 cd e2e && PKG_CONFIG_PATH=$(GHOSTTY_OUT)/share/pkgconfig go test -count=1 -run TestScreens ./... -update3131 32+# Differential tests: formulas and number formats against excelize's33+# calculation engine. A separate module, so the binary never depends on it.34+oracle:35+ cd oracle && go test -count=1 ./...36+3237 libghostty: $(GHOSTTY_STAMP)3338 3439 # The commit matches the one pinned by go.mitchellh.com/libghostty.@@ -39,6 +39,7 @@ cmd/one23 entry point
3939 internal/sheet engine: addresses, Pratt parser, evaluator, recalc, file format4040 internal/ui Bubble Tea model: modes, menu, prompts, rendering4141 e2e/ end-to-end tests: real binary on a pty, rendered by libghostty-vt42+oracle/ differential tests of formulas and formats against excelize4243 ```4344 4445 ## Tests@@ -47,7 +48,10 @@ e2e/ end-to-end tests: real binary on a pty, rendered by libghostty-
4748 make test # engine and UI unit tests4849 make fuzz # fuzz the formula parser4950 make e2e # builds libghostty-vt from source with Zig, then runs e2e tests51+make oracle # compare formulas and number formats with excelize5052 ```5153 5254 `make e2e` needs Zig 0.16+ and `pkg-config`. It is a separate Go module so the53-cgo dependency never reaches the main binary.55+cgo dependency never reaches the main binary; so is `oracle`, which keeps56+excelize out of it. `oracle/oracle_test.go` lists the formulas skipped57+because Sheets and Excel disagree or excelize departs from Excel.@@ -0,0 +1,79 @@
1+package oracle2+3+import (4+ "fmt"5+ "strings"6+ "testing"7+8+ "github.com/xuri/excelize/v2"9+10+ "one23/internal/sheet"11+)12+13+// patterns are number formats: Sheets' Format > Number presets and14+// common custom ones.15+var patterns = []string{16+ "#,##0.00", "#,##0", "0.00%", "0%", "0.00E+00", `"$"#,##0.00`, `"$"#,##0`,17+ "#,##0.00;(#,##0.00)", "0.0#", "000", "#,##0,", "0.00 \"kg\"",18+ "m/d/yyyy", "yyyy-mm-dd", "mmm d, yyyy", "mmmm d, yyyy", "dddd", "d mmm yyyy",19+ "h:mm:ss am/pm", "h:mm AM/PM", "hh:mm:ss", "m/d/yyyy h:mm:ss", "[h]:mm:ss", "h:mm",20+}21+22+var values = []float64{0, 1, -1, 0.5, 1234.5678, -1234.5678, 0.125, 1e6, 46291, 46291.6041666667, 1.0423611111}23+24+// skippedFormats are pattern and value pairs where one23 deliberately25+// differs, or excelize departs from Excel.26+var skippedFormats = map[string]string{27+ "#,##0, -1": "one23 drops the sign of a number that rounds to zero; excelize shows -0",28+ "0.0# 46291.6041666667": "excelize keeps a trailing zero under #",29+ "[h]:mm:ss 1e+06": "excelize overflows",30+}31+32+// skipReason explains whole classes of skipped comparisons.33+func skipReason(pat string, v float64) string {34+ switch {35+ case isDateOrTime(pat) && v < 0:36+ return "dates before 1899-12-30 are negative serials in Sheets; Excel has none"37+ case isDate(pat) && v < 61:38+ return "Excel counts a fictitious 1900-02-29, so its dates before March 1900 are a day off; Sheets and one23 don't"39+ }40+ return skippedFormats[fmt.Sprintf("%s %v", pat, v)]41+}42+43+func TestFormatsAgainstExcelize(t *testing.T) {44+ x := excelize.NewFile()45+ matched, skipped := 0, 046+ for _, pat := range patterns {47+ p := pat48+ style, err := x.NewStyle(&excelize.Style{CustomNumFmt: &p})49+ if err != nil {50+ t.Fatal(err)51+ }52+ for _, v := range values {53+ if why := skipReason(pat, v); why != "" {54+ t.Logf("skip %q of %v: %s", pat, v, why)55+ skipped++56+ continue57+ }58+ x.SetCellValue("Sheet1", "A1", v)59+ x.SetCellStyle("Sheet1", "A1", "A1", style)60+ theirs, err := x.GetCellValue("Sheet1", "A1")61+ if err != nil {62+ t.Fatal(err)63+ }64+ ours := sheet.FormatPattern(v, pat)65+ // one23 shows AM and PM in capitals, as Sheets' Time format does;66+ // excelize copies the case of the pattern's am/pm.67+ if ours != theirs && !(strings.Contains(pat, "am/pm") && strings.EqualFold(ours, theirs)) {68+ t.Errorf("%q of %v: one23 %q, excelize %q", pat, v, ours, theirs)69+ continue70+ }71+ matched++72+ }73+ }74+ t.Logf("%d formatted values match excelize, %d skipped", matched, skipped)75+}76+77+func isDateOrTime(pat string) bool { return strings.ContainsAny(pat, "ydhsm") }78+79+func isDate(pat string) bool { return strings.ContainsAny(pat, "yd") }@@ -0,0 +1,21 @@
1+module one23/oracle2+3+go 1.27.14+5+require (6+ github.com/xuri/excelize/v2 v2.11.07+ one23 v0.0.08+)9+10+require (11+ github.com/richardlehane/mscfb v1.0.7 // indirect12+ github.com/richardlehane/msoleps v1.0.6 // indirect13+ github.com/tiendc/go-deepcopy v1.7.2 // indirect14+ github.com/xuri/efp v0.0.1 // indirect15+ github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect16+ golang.org/x/crypto v0.53.0 // indirect17+ golang.org/x/net v0.56.0 // indirect18+ golang.org/x/text v0.38.0 // indirect19+)20+21+replace one23 => ../@@ -0,0 +1,28 @@
1+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=2+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=3+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=4+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=5+github.com/richardlehane/mscfb v1.0.7 h1:oeoiM0WE79vHwE8RpIYYvIAc8ajTH2mb6UZm55/+EB0=6+github.com/richardlehane/mscfb v1.0.7/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=7+github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=8+github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=9+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=10+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=11+github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=12+github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=13+github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=14+github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=15+github.com/xuri/excelize/v2 v2.11.0 h1:HxaEFl6sRN2+8J5a8HaKq+0M4FsjBGMnWWtjOCPSG88=16+github.com/xuri/excelize/v2 v2.11.0/go.mod h1:jxFLbzaIwGQ5ufFNvYfUOHqXhfPaNmP14KWfmNz2Uak=17+github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=18+github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=19+golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=20+golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=21+golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=22+golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=23+golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=24+golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=25+golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=26+golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=27+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=28+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=@@ -0,0 +1,274 @@
1+// Package oracle checks one23's formula engine against excelize's2+// calculation engine: the same data and formulas go into both, and the3+// results must agree. It is a separate module so the main binary never4+// depends on excelize. Run it with make oracle.5+package oracle6+7+import (8+ "math"9+ "strconv"10+ "strings"11+ "testing"12+13+ "github.com/xuri/excelize/v2"14+15+ "one23/internal/sheet"16+)17+18+// fixture is the data both engines read, typed as a user would.19+//20+// A B C D E F G H I21+// 1 10 north 1 Apple 1.5 9/26/2026 1 -1000 3022+// 2 20 south 2 Banana 2.5 2026-01-31 2 300 2023+// 3 30 north 3 Cherry 3.5 14:30 2 400 1024+// 4 apple east 4 date x 2/29/2024 3 50025+// 5 north 5 Apple 9 12/31/2025 526+// 6 "" 6 TRUE27+// 7 -5 Ab*d 728+var fixture = map[string]string{29+ "A1": "10", "A2": "20", "A3": "30", "A4": "apple", "A7": "-5",30+ "B1": "north", "B2": "south", "B3": "north", "B4": "east", "B5": "north", "B6": `=""`, "B7": "Ab*d",31+ "C1": "1", "C2": "2", "C3": "3", "C4": "4", "C5": "5", "C6": "6", "C7": "7",32+ "D1": "Apple", "D2": "Banana", "D3": "Cherry", "D4": "date", "D5": "Apple",33+ "E1": "1.5", "E2": "2.5", "E3": "3.5", "E4": "x", "E5": "9", "E6": "TRUE",34+ "F1": "9/26/2026", "F2": "2026-01-31", "F3": "14:30", "F4": "2/29/2024", "F5": "12/31/2025",35+ "G1": "1", "G2": "2", "G3": "2", "G4": "3",36+ "H1": "-1000", "H2": "300", "H3": "400", "H4": "500",37+ "I1": "30", "I2": "20", "I3": "10",38+}39+40+// formulas are compared between the engines. They use syntax both41+// accept; toExcel covers the differences.42+var formulas = []string{43+ // Math44+ `=SUM(A1:A7)`, `=SUM(A4)`, `=SUMIF(B1:B5, "north", C1:C5)`, `=SUMIF(A1:A7, ">15")`,45+ `=SUMIF(A1:A7, "<>10")`, `=SUMIF(B1:B5, "n*", C1)`, `=SUMIF(C1:C7, 3)`, `=SUMIF(C1:C7, ">=" & C5)`,46+ `=SUMIFS(C1:C5, B1:B5, "north", A1:A5, ">10")`, `=SUMPRODUCT(A1:A3, C1:C3)`, `=SUMPRODUCT(A1:A4, C1:C4)`,47+ `=PRODUCT(C1:C5)`, `=PRODUCT(2, "3")`, `=POWER(2, 10)`, `=POWER(0, -1)`,48+ `=ROUND(1.005, 2)`, `=ROUND(-2.5)`, `=ROUND(1234.5, -2)`, `=ROUND(2.675, 2)`,49+ `=ROUNDUP(2.3, 1)`, `=ROUNDUP(-2.31, 1)`, `=ROUNDUP(1234, -2)`, `=ROUNDDOWN(2.39, 1)`, `=ROUNDDOWN(-2.39, 0)`,50+ `=TRUNC(-7.9)`, `=CEILING(2.1, 1)`, `=CEILING(7, 5)`, `=CEILING(0.3, 0.1)`, `=CEILING(-2.5, 1)`,51+ `=CEILING(-2.5, -1)`, `=CEILING(2.5, -1)`, `=FLOOR(2.9, 1)`, `=FLOOR(7, 5)`, `=FLOOR(-2.5, 1)`, `=FLOOR(-2.5, -1)`,52+ `=SIGN(-3)`, `=SIGN(0)`, `=EXP(1)`, `=LN(EXP(2))`, `=LN(0)`, `=LOG(8, 2)`, `=LOG(1000)`, `=LOG(-1)`,53+ `=LOG10(0.01)`, `=QUOTIENT(-7, 2)`, `=QUOTIENT(1, 0)`, `=RANDBETWEEN(3, 3)`, `=MOD(-7, 3)`, `=INT(-1.5)`,54+ `=ABS(-2)`, `=SQRT(16)`, `=SQRT(-1)`, `=10%`, `=A1*50%`, `=-2^2`, `=2^-1`, `=SUM(C1:C2, E6)`,55+ // Statistics56+ `=AVERAGE(A1:A7)`, `=COUNT(A1:A7)`, `=COUNTA(A1:A7)`, `=MIN(A1:A7)`, `=MAX(A1:A7)`,57+ `=AVERAGEIF(B1:B5, "north", C1:C5)`, `=AVERAGEIF(A1:A7, ">0")`, `=AVERAGEIF(A1:A7, ">100")`,58+ `=AVERAGEIFS(C1:C5, B1:B5, "north", C1:C5, ">1")`, `=COUNTIF(B1:B7, "north")`, `=COUNTIF(B1:B7, "NORTH")`,59+ `=COUNTIF(B1:B7, "?o*")`, `=COUNTIF(B1:B7, "Ab~*d")`, `=COUNTIF(A1:A7, "<>10")`, `=COUNTIF(A1:A7, "")`,60+ `=COUNTIF(A1:A7, "<>")`, `=COUNTIF(E1:E7, TRUE)`, `=COUNTIF(A1:A7, ">=10")`, `=COUNTIF(B1:B7, ">m")`,61+ `=COUNTIFS(B1:B5, "north", C1:C5, "<5")`, `=COUNTBLANK(A1:A7)`, `=COUNTBLANK(B1:B7)`,62+ `=MEDIAN(A1:A7)`, `=MEDIAN(C1:C5)`, `=MODE(G1:G4)`, `=MODE(C1:C5)`, `=STDEV(A1:A7)`, `=STDEVP(A1:A7)`,63+ `=VAR(A1:A7)`, `=VARP(A1:A7)`, `=STDEV(5)`, `=LARGE(A1:A7, 1)`, `=LARGE(A1:A7, 4)`, `=LARGE(A1:A7, 5)`,64+ `=SMALL(C1:C7, 2)`, `=RANK(20, A1:A7)`, `=RANK(20, A1:A7, 1)`, `=RANK(2, G1:G4)`, `=RANK(99, A1:A7)`,65+ // Text66+ `=CONCATENATE("a", 1, TRUE)`, `=CONCAT(A1, "x")`, `=TEXTJOIN(", ", TRUE, A3:A5)`, `=TEXTJOIN("-", FALSE, A3:A5)`,67+ `=LEFT("hello", 2)`, `=LEFT("hello")`, `=LEFT("hi", 10)`, `=LEFT("hi", -1)`, `=RIGHT("hello", 3)`,68+ `=MID("spreadsheet", 7, 5)`, `=MID("abc", 5, 2)`, `=MID("abc", 0, 2)`, `=LEN("hello")`, `=LEN(A1)`,69+ `=UPPER("abc")`, `=LOWER("ABC")`, `=PROPER("hello wORLD it's")`, `=TRIM(" a b ")`,70+ `=SUBSTITUTE("a-b-c", "-", "+")`, `=SUBSTITUTE("a-b-c", "-", "+", 2)`, `=SUBSTITUTE("a-b-c", "-", "+", 5)`,71+ `=REPLACE("abcdef", 2, 3, "X")`, `=REPLACE("abc", 10, 1, "X")`, `=FIND("b", "abcb")`, `=FIND("b", "abcb", 3)`,72+ `=FIND("B", "abc")`, `=SEARCH("B", "abc")`, `=SEARCH("b?d", "abcbxd")`, `=SEARCH("z", "abc")`,73+ `=TEXT(1234.567, "$#,##0.00")`, `=TEXT(0.256, "0.0%")`, `=TEXT(F1, "yyyy-mm-dd")`, `=TEXT(F1, "dddd")`,74+ `=TEXT(F3, "h:mm AM/PM")`, `=TEXT(1234.5, "0.00E+00")`, `=TEXT(-1234.5, "#,##0.00;(#,##0.00)")`,75+ `=TEXT(F1, "mmm d, yyyy")`, `=TEXT(1.5, "[h]:mm")`, `=TEXT(5, "000")`,76+ `=VALUE("12%")`, `=VALUE("abc")`, `=VALUE(A1)`, `=REPT("ab", 3)`, `=REPT("ab", -1)`,77+ `=EXACT("a", "A")`, `=EXACT("a", "a")`, `=A1&"x"`, `=1/3&""`,78+ // Logic79+ `=IF(A1>5, "big", "small")`, `=IF(A1>50, 1)`, `=IFERROR(1/0, 0)`, `=AND(A1>5, A2<10)`, `=OR(E6, FALSE)`, `=NOT(A1=10)`,80+ `=IFS(A1>20, "big", A1>5, "medium")`, `=IFS(A1>20, "big")`, `=SWITCH(B2, "north", 1, "south", 2)`,81+ `=SWITCH(B4, "north", 1, "south", 2, 0)`, `=SWITCH(B4, "north", 1)`, `=XOR(TRUE, FALSE)`, `=XOR(TRUE, TRUE)`,82+ `=XOR(A1:A3)`, `=IFNA(NA(), "none")`, `=IFNA(1/0, 0)`, `=ISBLANK(A5)`, `=ISBLANK(B6)`, `=ISNUMBER(A1)`,83+ `=ISNUMBER(A4)`, `=ISTEXT(A4)`, `=ISLOGICAL(E6)`, `=ISERROR(1/0)`, `=ISERR(NA())`, `=ISNA(NA())`, `=ISNA(1/0)`,84+ // Lookup85+ `=VLOOKUP("banana", D1:E3, 2, FALSE)`, `=VLOOKUP("B*", D1:E3, 2, FALSE)`, `=VLOOKUP("kiwi", D1:E3, 2, FALSE)`,86+ `=VLOOKUP("Apple", D1:E5, 2, FALSE)`, `=VLOOKUP("Blueberry", D1:E3, 2)`, `=VLOOKUP("Aardvark", D1:E3, 2, TRUE)`,87+ `=VLOOKUP(2.5, C1:D5, 2)`, `=VLOOKUP("Apple", D1:E3, 3, FALSE)`, `=HLOOKUP(1.5, C1:E2, 2, FALSE)`,88+ `=HLOOKUP("Apple", D1:E2, 2, FALSE)`, `=MATCH("Cherry", D1:D3, 0)`, `=MATCH(3.2, C1:C7)`, `=MATCH(0, C1:C7)`,89+ `=MATCH(25, I1:I3, -1)`, `=MATCH(10, I1:I3, -1)`, `=MATCH(35, I1:I3, -1)`, `=INDEX(D1:E3, 2, 2)`,90+ `=INDEX(D1:D3, 3)`, `=INDEX(C1:E1, 2)`, `=INDEX(D1:E3, 4, 1)`, `=XLOOKUP("Cherry", D1:D3, E1:E3)`,91+ `=XLOOKUP("kiwi", D1:D3, E1:E3)`, `=XLOOKUP("kiwi", D1:D3, E1:E3, "none")`, `=XLOOKUP("Apple", D1:D5, E1:E5, , 0, -1)`,92+ `=XLOOKUP(3.2, C1:C7, D1:D7, , -1)`, `=XLOOKUP(3.2, C1:C7, D1:D7, , 1)`, `=XLOOKUP("Ch*", D1:D3, E1:E3, , 2)`,93+ `=CHOOSE(2, "a", "b", "c")`, `=CHOOSE(2.9, "a", "b", "c")`, `=CHOOSE(4, "a", "b", "c")`, `=ROWS(A1:C7)`, `=COLUMNS(A1:C7)`,94+ // Dates95+ `=DATE(2026, 9, 26)`, `=DATE(2026, 14, 1)`, `=DATE(2026, 1, 0)`, `=DATE(26, 1, 1)`, `=DATE(-1, 1, 1)`,96+ `=TIME(14, 30, 0)`, `=TIME(25, 0, 0)`, `=YEAR(F1)`, `=MONTH(F1)`, `=DAY(F1)`, `=YEAR("2026-09-26")`,97+ `=WEEKDAY(F1)`, `=WEEKDAY(F1, 2)`, `=WEEKDAY(F1, 3)`, `=HOUR(F3)`, `=MINUTE(F3)`, `=SECOND(TIME(1, 2, 3))`,98+ `=EDATE(F2, 1)`, `=EDATE(F1, -12)`, `=EOMONTH(F1, 0)`, `=EOMONTH(F4, 12)`, `=DATEDIF(F4, F1, "Y")`,99+ `=DATEDIF(F4, F1, "M")`, `=DATEDIF(F4, F1, "D")`, `=DATEDIF(F4, F1, "YM")`, `=DATEDIF(F5, F1, "MD")`,100+ `=DATEDIF(F5, F1, "YD")`, `=DATEDIF(F2, F5, "D")`, `=DAYS(F1, F2)`, `=DAYS("2026-01-02", "2026-01-01")`,101+ `=NETWORKDAYS(F5, F2)`, `=NETWORKDAYS(F2, F5)`, `=NETWORKDAYS(F5, F2, F5)`, `=DATEVALUE("9/26/2026")`,102+ `=DATEVALUE("hello")`, `=TIMEVALUE("2:30 PM")`, `=F1+1`, `="2026-09-27"-F1`,103+ // Finance104+ `=PMT(0.05/12, 360, 200000)`, `=PMT(0, 10, 1000)`, `=PV(0.05/12, 360, -1000)`, `=PV(0, 10, -100)`,105+ `=FV(0.05, 10, -100)`, `=FV(0.05, 10, -100, 0, 1)`, `=FV(0, 10, -100, -500)`, `=NPER(0.01, -1000, 10000)`,106+ `=NPER(0, -100, 1000)`, `=NPER(0.01, 100, 10000)`, `=RATE(360, -1073.6432460242795, 200000)`,107+ `=RATE(48, -200, 8000)`, `=NPV(0.1, H1:H4)`, `=IRR(H1:H4)`, `=IRR(C1:C5)`,108+}109+110+// skipped are formulas where Google Sheets, which one23 follows, and111+// Excel disagree, or where excelize departs from Excel. Each says why.112+var skipped = map[string]string{113+ // Sheets and Excel disagree; one23 follows Sheets.114+ `=ROUND(-2.5)`: "Sheets makes ROUND's places optional; Excel requires them",115+116+ // excelize departs from Excel (and Sheets) here.117+ `=SUM(C1:C2, E6)`: "excelize counts TRUE in a referenced cell as 1; Excel and Sheets ignore it",118+ `=SUMIF(B1:B5, "n*", C1)`: "excelize doesn't stretch a smaller sum range from its corner as Excel does",119+ `=SUMPRODUCT(A1:A4, C1:C4)`: "excelize gives #VALUE! for text; Excel and Sheets treat it as 0",120+ `=CEILING(2.5, -1)`: "excelize gives #VALUE!; Excel and Sheets give #NUM!",121+ `=LN(0)`: "excelize gives -INF; Excel and Sheets give #NUM!",122+ `=COUNTIF(A1:A7, "")`: "excelize doesn't count blank cells for an empty criterion",123+ `=COUNTIF(A1:A7, "<>10")`: "excelize doesn't count blank cells as not equal",124+ `=COUNTBLANK(B1:B7)`: "excelize doesn't count empty text from a formula as blank",125+ `=TRIM(" a b ")`: "excelize doesn't collapse inner runs of spaces",126+ `=1/3&""`: "excelize joins 16 significant digits; Excel and Sheets show 15",127+ `=IF(A1>50, 1)`: "excelize gives empty text; Excel and Sheets give FALSE",128+ `=IFNA(1/0, 0)`: "excelize turns other errors into #VALUE!; Excel and Sheets pass them through",129+ `=VLOOKUP("Apple", D1:E3, 3, FALSE)`: "excelize gives #N/A for an index past the range; Excel and Sheets give #REF!",130+ `=INDEX(C1:E1, 2)`: "excelize doesn't count along a single row",131+ `=XLOOKUP("Apple", D1:D5, E1:E5, , 0, -1)`: "excelize misreads an omitted argument",132+ `=XLOOKUP(3.2, C1:C7, D1:D7, , -1)`: "excelize misreads an omitted argument",133+ `=XLOOKUP(3.2, C1:C7, D1:D7, , 1)`: "excelize misreads an omitted argument",134+ `=XLOOKUP("Ch*", D1:D3, E1:E3, , 2)`: "excelize misreads an omitted argument",135+ `=CHOOSE(2.9, "a", "b", "c")`: "excelize doesn't truncate the index",136+ `=DATE(26, 1, 1)`: "excelize doesn't add 1900 to years below 1900",137+ `=DATE(-1, 1, 1)`: "excelize accepts negative years; Excel and Sheets give #NUM!",138+ `=TIME(25, 0, 0)`: "excelize doesn't wrap past midnight",139+ `="2026-09-27"-F1`: "excelize doesn't read date text in arithmetic",140+ `=NPV(0.1, H1:H4)`: "excelize reads only the first cell of a range",141+}142+143+// toExcel translates a one23 formula to Excel syntax: 1-2-3 style @SUM144+// and A1..B2 ranges become SUM and A1:B2, and the leading = goes.145+func toExcel(f string) string {146+ f = strings.TrimPrefix(f, "=")147+ var b strings.Builder148+ inStr := false149+ for i := 0; i < len(f); i++ {150+ c := f[i]151+ switch {152+ case c == '"':153+ inStr = !inStr154+ case inStr:155+ case c == '@':156+ continue157+ case c == '.' && i+1 < len(f) && f[i+1] == '.':158+ b.WriteByte(':')159+ i++160+ continue161+ }162+ b.WriteByte(c)163+ }164+ return b.String()165+}166+167+func TestToExcel(t *testing.T) {168+ for in, want := range map[string]string{169+ "=@SUM(A1..B2)": "SUM(A1:B2)",170+ `=IF(A1="@x..y",1)`: `IF(A1="@x..y",1)`,171+ "=A1*2": "A1*2",172+ "=XLOOKUP(1,A:A,B:B)": "XLOOKUP(1,A:A,B:B)",173+ } {174+ if got := toExcel(in); got != want {175+ t.Errorf("toExcel(%q) = %q, want %q", in, got, want)176+ }177+ }178+}179+180+// build loads the fixture into one23, then copies the values one23181+// computed into an excelize workbook, so both start from the same data.182+func build(t *testing.T) (*sheet.Sheet, *excelize.File) {183+ t.Helper()184+ s := sheet.New()185+ for a, in := range fixture {186+ if err := s.Set(addr(a), in); err != nil {187+ t.Fatalf("%s: %v", a, err)188+ }189+ }190+ x := excelize.NewFile()191+ for a, in := range fixture {192+ var err error193+ switch v := s.Value(addr(a)); {194+ case sheet.IsFormulaEntry(in):195+ err = x.SetCellFormula("Sheet1", a, toExcel(in))196+ case v.Kind == sheet.Number:197+ err = x.SetCellValue("Sheet1", a, v.Num)198+ case v.Kind == sheet.Bool:199+ err = x.SetCellValue("Sheet1", a, v.Num != 0)200+ default:201+ err = x.SetCellValue("Sheet1", a, v.Str)202+ }203+ if err != nil {204+ t.Fatal(err)205+ }206+ }207+ return s, x208+}209+210+func addr(s string) sheet.Addr {211+ a, ok := sheet.ParseAddr(s)212+ if !ok {213+ panic(s)214+ }215+ return a216+}217+218+func TestAgainstExcelize(t *testing.T) {219+ s, x := build(t)220+ const cell = "Z1"221+ matched := 0222+ for _, f := range formulas {223+ if why, ok := skipped[f]; ok {224+ t.Logf("skip %s: %s", f, why)225+ continue226+ }227+ if err := s.Set(addr(cell), f); err != nil {228+ t.Errorf("one23 rejects %s: %v", f, err)229+ continue230+ }231+ ours := s.Value(addr(cell))232+ if err := x.SetCellFormula("Sheet1", cell, toExcel(f)); err != nil {233+ t.Fatal(err)234+ }235+ theirs, err := x.CalcCellValue("Sheet1", cell, excelize.Options{RawCellValue: true})236+ if err != nil && !strings.HasPrefix(theirs, "#") {237+ theirs = err.Error()238+ }239+ if agree(ours, theirs) {240+ matched++241+ continue242+ }243+ t.Errorf("%s: one23 %s, excelize %q", f, show(ours), theirs)244+ }245+ t.Logf("%d of %d formulas match excelize, %d skipped", matched, len(formulas)-len(skipped), len(skipped))246+}247+248+// agree compares a one23 value with excelize's text result: numbers to249+// about 12 significant digits, booleans as TRUE/FALSE, errors by code.250+func agree(v sheet.Value, x string) bool {251+ switch v.Kind {252+ case sheet.Number:253+ n, err := strconv.ParseFloat(x, 64)254+ if err != nil {255+ return false256+ }257+ return math.Abs(n-v.Num) <= 1e-9*math.Max(1, math.Abs(v.Num))258+ case sheet.Bool:259+ return strings.EqualFold(x, v.String())260+ case sheet.Empty:261+ return x == "" || x == "0"262+ }263+ return x == v.Str264+}265+266+func show(v sheet.Value) string {267+ switch v.Kind {268+ case sheet.Text:269+ return strconv.Quote(v.Str)270+ case sheet.Empty:271+ return "blank"272+ }273+ return v.String()274+}