Number formatting: add coverage (rounding, errors, blanks, dates, unsupported fallback) and fix dead-code + literal-section sign bugs found in self-review
5 files changed,
+210
−27
internal/sheets/format/dates.go+6 −8internal/sheets/format/format.go+19 −12internal/sheets/format/format_test.go+1 −1internal/sheets/format/rounding_test.go+160 −0internal/sheets/format/section.go+24 −6
Intent
diff --git a/internal/sheets/format/dates.go b/internal/sheets/format/dates.go| index 94a4976..514ce41 100644 |
| --- a/internal/sheets/format/dates.go |
| +++ b/internal/sheets/format/dates.go |
| @@ -9,14 +9,12 @@ import ( |
| // excelEpoch mirrors calc/fn_date.go's constant of the same name and |
| // value: day zero in spreadsheet date-serial numbering (Dec 30, 1899). |
| -// format cannot reach calc's unexported var (and wouldn't want the |
| -// coupling if it could — this package's only allowed import from calc |
| -// is Value), so the value is restated here; it must stay in lockstep |
| -// with calc's for a date serial to render as the calendar date that |
| -// produced it. See calc/fn_date.go's comment for why this particular |
| -// epoch (one day off from the "real" Dec 31, 1899) is the right one: |
| -// it reproduces Excel's date-serial numbering using ordinary calendar |
| -// arithmetic, no fake-leap-day special case required. |
| +// format cannot reach calc's unexported var (its only allowed import |
| +// from calc is Value), so the value is restated here; it must stay in |
| +// lockstep with calc's, or a date serial stops rendering as the |
| +// calendar date that produced it. See calc/fn_date.go for why this |
| +// epoch (one day off "real" Dec 31, 1899) reproduces Excel's numbering |
| +// with ordinary calendar arithmetic, no fake-leap-day case needed. |
| var excelEpoch = time.Date(1899, time.December, 30, 0, 0, 0, 0, time.UTC) |
| type dateTokKind int |
diff --git a/internal/sheets/format/format.go b/internal/sheets/format/format.go| index 3d24dfb..aa30868 100644 |
| --- a/internal/sheets/format/format.go |
| +++ b/internal/sheets/format/format.go |
| @@ -103,9 +103,9 @@ func displayNumber(num float64, numFmt string) string { |
| code = "General" |
| } |
| sections := splitSections(code) |
| - sec, negate, abs := chooseNumberSection(sections, num) |
| + sec, negate := chooseNumberSection(sections, num) |
| val := num |
| - if abs && val < 0 { |
| + if val < 0 { |
| val = -val |
| } |
| out, ok := renderSection(sec, val) |
| @@ -123,27 +123,34 @@ func displayNumber(num float64, numFmt string) string { |
| } |
| // chooseNumberSection picks which section applies to num, and whether an |
| -// automatic "-" belongs in front of it. See the package doc for the |
| -// section-count rules this implements. |
| -func chooseNumberSection(sections []string, num float64) (section string, negate, useAbs bool) { |
| +// automatic "-" belongs in front of it. Every branch renders against |
| +// abs(num) — see the package doc's section-count rules: a single-section |
| +// format gets an automatic sign over the absolute value, while two or |
| +// more sections supply their own negative styling (over the absolute |
| +// value too) with no automatic sign, exactly as Excel does. The |
| +// automatic sign only applies when that single section actually renders |
| +// the number (numeric or date placeholders) — a literal-only section |
| +// like "n/a" is a fixed label, not a number with its sign stripped, so |
| +// it gets no automatic "-" either. |
| +func chooseNumberSection(sections []string, num float64) (section string, negate bool) { |
| switch len(sections) { |
| case 0: |
| - return "General", num < 0, true |
| + return "General", num < 0 |
| case 1: |
| - return sections[0], num < 0, true |
| + return sections[0], num < 0 && rendersNumber(sections[0]) |
| case 2: |
| if num < 0 { |
| - return sections[1], false, true |
| + return sections[1], false |
| } |
| - return sections[0], false, true |
| + return sections[0], false |
| default: // 3 or more (a 4th section, if present, is for text only) |
| switch { |
| case num < 0: |
| - return sections[1], false, true |
| + return sections[1], false |
| case num == 0: |
| - return sections[2], false, true |
| + return sections[2], false |
| default: |
| - return sections[0], false, true |
| + return sections[0], false |
| } |
| } |
| } |
diff --git a/internal/sheets/format/format_test.go b/internal/sheets/format/format_test.go| index 1869be5..4a04524 100644 |
| --- a/internal/sheets/format/format_test.go |
| +++ b/internal/sheets/format/format_test.go |
| @@ -6,7 +6,7 @@ import ( |
| "amadan.net/oficina/sheets/internal/sheets/calc" |
| ) |
| -func numValue(n float64) calc.Value { return calc.Value{Kind: calc.KindNum, Num: n} } |
| +func numValue(n float64) calc.Value { return calc.Value{Kind: calc.KindNum, Num: n} } |
| func errValue(e calc.ErrKind) calc.Value { return calc.Value{Kind: calc.KindErr, Err: e} } |
| func TestDisplay(t *testing.T) { |
diff --git a/internal/sheets/format/rounding_test.go b/internal/sheets/format/rounding_test.go| new file mode 100644 |
| index 0000000..995f22b |
| --- /dev/null |
| +++ b/internal/sheets/format/rounding_test.go |
| @@ -0,0 +1,160 @@ |
| +package format |
| + |
| +import ( |
| + "testing" |
| + "time" |
| + |
| + "amadan.net/oficina/sheets/internal/sheets/calc" |
| +) |
| + |
| +// strconv.FormatFloat/fmt round half-to-even ("banker's rounding"): |
| +// 0.005 at "0.00" comes out "0.00" in Go, but Excel (and every other |
| +// spreadsheet) rounds half-away-from-zero and shows "0.01". This test |
| +// pins that Display follows Excel, not Go's default — see roundDecimal |
| +// in numeric.go, which works from a decimal-string representation |
| +// specifically to make this the natural outcome rather than a special |
| +// case bolted on top of float rounding. |
| +func TestDisplayRoundsHalfAwayFromZero(t *testing.T) { |
| + for _, tc := range []struct { |
| + num float64 |
| + want string |
| + }{ |
| + {0.005, "0.01"}, |
| + {0.015, "0.02"}, |
| + } { |
| + if got := Display(numValue(tc.num), "0.00"); got != tc.want { |
| + t.Errorf("Display(%v, %q) = %q, want %q", tc.num, "0.00", got, tc.want) |
| + } |
| + } |
| +} |
| + |
| +// Every ErrKind must display as its Excel text under any number format, |
| +// not just #DIV/0! under one format — the single case the brief pins. |
| +func TestDisplayEveryErrorIgnoresNumberFormat(t *testing.T) { |
| + kinds := []calc.ErrKind{ |
| + calc.ErrDiv0, calc.ErrValue, calc.ErrRef, calc.ErrName, |
| + calc.ErrNum, calc.ErrNA, calc.ErrCircular, |
| + } |
| + for _, k := range kinds { |
| + want := k.String() |
| + for _, numFmt := range []string{"General", "0.00", "#,##0.00", "0%", "yyyy-mm-dd"} { |
| + if got := Display(errValue(k), numFmt); got != want { |
| + t.Errorf("Display(%v, %q) = %q, want %q", k, numFmt, got, want) |
| + } |
| + } |
| + } |
| +} |
| + |
| +// KindEmpty is the zero value of ValueKind, so a Value{} — an untouched |
| +// cell — must render as "" under any format, never as the number "0". |
| +func TestDisplayBlankIsEmptyString(t *testing.T) { |
| + for _, numFmt := range []string{"General", "0.00", "#,##0.00", "0%", `"€"#,##0.00`, "yyyy-mm-dd"} { |
| + if got := Display(calc.Value{}, numFmt); got != "" { |
| + t.Errorf("Display(blank, %q) = %q, want \"\"", numFmt, got) |
| + } |
| + } |
| +} |
| + |
| +func TestDisplayBoolIgnoresNumberFormat(t *testing.T) { |
| + trueV := calc.Value{Kind: calc.KindBool, Bool: true} |
| + falseV := calc.Value{Kind: calc.KindBool, Bool: false} |
| + if got := Display(trueV, "0.00"); got != "TRUE" { |
| + t.Errorf("Display(TRUE, %q) = %q, want TRUE", "0.00", got) |
| + } |
| + if got := Display(falseV, "0.00"); got != "FALSE" { |
| + t.Errorf("Display(FALSE, %q) = %q, want FALSE", "0.00", got) |
| + } |
| +} |
| + |
| +// A text value is not reformatted by a numeric format code (matching |
| +// Excel: a number format only applies to a text section, the 4th of |
| +// up to four ';'-separated sections, absent here). |
| +func TestDisplayTextPassesThroughNumericFormat(t *testing.T) { |
| + v := calc.Value{Kind: calc.KindStr, Str: "hello"} |
| + if got := Display(v, "0.00"); got != "hello" { |
| + t.Errorf("Display(%q, %q) = %q, want %q", "hello", "0.00", got, "hello") |
| + } |
| +} |
| + |
| +func TestDisplayTextSection(t *testing.T) { |
| + v := calc.Value{Kind: calc.KindStr, Str: "n/a"} |
| + if got := Display(v, `0;0;0;"got: "@`); got != "got: n/a" { |
| + t.Errorf("Display with text section = %q, want %q", got, "got: n/a") |
| + } |
| +} |
| + |
| +// A negative number under a single-section format gets an automatic |
| +// "-" over the absolute value's rendering. |
| +func TestDisplayNegativeSingleSectionAutoSign(t *testing.T) { |
| + if got := Display(numValue(-1234.5), "#,##0.00"); got != "-1,234.50" { |
| + t.Fatalf("Display(-1234.5, %q) = %q, want %q", "#,##0.00", got, "-1,234.50") |
| + } |
| +} |
| + |
| +// A two-or-more-section format supplies its own negative styling with |
| +// NO automatic sign — matching Excel, where "0.00;(0.00)" is how you |
| +// spell accounting-style parenthesized negatives. |
| +func TestDisplayNegativeSectionNoAutoSign(t *testing.T) { |
| + if got := Display(numValue(-5), "0.00;(0.00)"); got != "(5.00)" { |
| + t.Fatalf("Display(-5, %q) = %q, want %q", "0.00;(0.00)", got, "(5.00)") |
| + } |
| +} |
| + |
| +// A section with no digit or date placeholders is a fixed literal — |
| +// punctuation in it (not a bare letter) is not an unsupported construct, |
| +// it just displays the same text regardless of value. |
| +func TestDisplayLiteralOnlySection(t *testing.T) { |
| + if got := Display(numValue(5), `"-"`); got != "-" { |
| + t.Errorf(`Display(5, "-") = %q, want "-"`, got) |
| + } |
| + if got := Display(numValue(-99), `"n/a"`); got != "n/a" { |
| + t.Errorf(`Display(-99, "n/a") = %q, want "n/a"`, got) |
| + } |
| +} |
| + |
| +// An unsupported construct (here '_' — Excel's space-padding fill |
| +// character) falls the WHOLE value back to General rather than |
| +// mangling the output partially. |
| +func TestDisplayUnsupportedFallsBackToGeneral(t *testing.T) { |
| + for _, numFmt := range []string{"0.00_)", "q", "0.00E+00", "# ?/?"} { |
| + got := Display(numValue(1234.5678), numFmt) |
| + want := Display(numValue(1234.5678), "General") |
| + if got != want { |
| + t.Errorf("Display(1234.5678, %q) = %q, want General's %q", numFmt, got, want) |
| + } |
| + } |
| +} |
| + |
| +// serialFor mirrors calc's dateSerial (fn_date.go) and this package's |
| +// excelEpoch (dates.go) independently, so a date test here pins the |
| +// same convention rather than assuming it. |
| +func serialFor(y int, mo time.Month, d, h, mi, s int) float64 { |
| + epoch := time.Date(1899, time.December, 30, 0, 0, 0, 0, time.UTC) |
| + tm := time.Date(y, mo, d, h, mi, s, 0, time.UTC) |
| + return tm.Sub(epoch).Hours() / 24 |
| +} |
| + |
| +func TestDisplayDates(t *testing.T) { |
| + for _, tc := range []struct { |
| + name string |
| + serial float64 |
| + numFmt string |
| + want string |
| + }{ |
| + {"iso date", serialFor(2024, time.March, 5, 0, 0, 0), "yyyy-mm-dd", "2024-03-05"}, |
| + {"slash date", serialFor(2024, time.March, 5, 0, 0, 0), "dd/mm/yyyy", "05/03/2024"}, |
| + {"abbreviated month", serialFor(2024, time.March, 5, 0, 0, 0), "mmm d, yyyy", "Mar 5, 2024"}, |
| + {"full month and weekday", serialFor(2024, time.March, 5, 0, 0, 0), "dddd, mmmm d, yyyy", |
| + time.Date(2024, time.March, 5, 0, 0, 0, 0, time.UTC).Weekday().String() + ", March 5, 2024"}, |
| + {"24h time", serialFor(2024, time.March, 5, 14, 30, 9), "hh:mm:ss", "14:30:09"}, |
| + {"12h time PM", serialFor(2024, time.March, 5, 13, 5, 9), "hh:mm:ss AM/PM", "01:05:09 PM"}, |
| + {"12h time midnight", serialFor(2024, time.March, 5, 0, 0, 0), "hh:mm:ss AM/PM", "12:00:00 AM"}, |
| + {"date and minutes not month", serialFor(2024, time.March, 5, 14, 30, 0), "yyyy-mm-dd hh:mm", "2024-03-05 14:30"}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + if got := Display(numValue(tc.serial), tc.numFmt); got != tc.want { |
| + t.Errorf("Display(%s, %q) = %q, want %q", tc.name, tc.numFmt, got, tc.want) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/sheets/format/section.go b/internal/sheets/format/section.go| index 31bad7b..6e41e1e 100644 |
| --- a/internal/sheets/format/section.go |
| +++ b/internal/sheets/format/section.go |
| @@ -62,6 +62,17 @@ func walkBareRunes(section string, fn func(byte) bool) { |
| } |
| } |
| +// rendersNumber reports whether section actually renders the number |
| +// (via "General", numeric, or date placeholders) rather than being a |
| +// fixed literal label — used to decide whether a single-section format |
| +// gets an automatic "-" for a negative value. |
| +func rendersNumber(section string) bool { |
| + if strings.EqualFold(strings.TrimSpace(section), "General") { |
| + return true |
| + } |
| + return detectMode(section) != modeLiteral |
| +} |
| + |
| // renderSection renders val (already sign-adjusted by the caller) |
| // through one format section. ok is false when the section uses a |
| // construct this package does not support, telling the caller to fall |
| @@ -80,15 +91,22 @@ func renderSection(section string, val float64) (string, bool) { |
| } |
| } |
| -// renderLiteral renders a section with no placeholders at all: the same |
| -// text regardless of val (e.g. a format section used purely to hide a |
| -// value, or to show a fixed label). |
| +// renderLiteral renders a section with no digit or date placeholders at |
| +// all: the same text regardless of val (e.g. a section used purely to |
| +// hide a value, or to show a fixed label like "-" or "n/a"). A bare |
| +// ASCII letter is still rejected — Excel reserves unquoted letters for |
| +// format tokens, so an unrecognized one (e.g. a stray "q") is treated |
| +// as an unsupported construct rather than assumed to be a literal word. |
| func renderLiteral(section string) (string, bool) { |
| var sb strings.Builder |
| ok := true |
| - walkLiteralAndPlaceholders(section, func(lit string) { sb.WriteString(lit) }, func(byte) bool { |
| - ok = false |
| - return true |
| + walkLiteralAndPlaceholders(section, func(lit string) { sb.WriteString(lit) }, func(c byte) bool { |
| + if isASCIILetter(c) || strings.IndexByte("?@_*", c) >= 0 { |
| + ok = false |
| + return true |
| + } |
| + sb.WriteByte(c) |
| + return false |
| }) |
| return sb.String(), ok |
| } |