human review: remove formatter.zig. grouping.zig reviewed
This commit is contained in:
parent
7853d8aad4
commit
5e46d9e706
18 changed files with 1027 additions and 1184 deletions
|
|
@ -64,17 +64,16 @@ build.zig (workspace root)
|
|||
| `grouping.zig` | The thousands rule, shared by both display paths |
|
||||
| `Integer.zig` | A fixed-width integer (file-as-struct): `raw`, `width`, `signedness`, the interpretations, and the notations it renders in |
|
||||
| `Rational.zig` | Exact rationals over big integers (file-as-struct) |
|
||||
| `number.zig` | The exact/inexact numeric model (section 2.7). Lowercase: `Number` is a tagged union, which a file-as-struct cannot express |
|
||||
| `number.zig` | The exact/inexact numeric model (section 2.7), including how a value renders itself at a budget the caller supplies. Lowercase: `Number` is a tagged union, which a file-as-struct cannot express |
|
||||
| `tokenizer.zig` | Lexer, and `Base` for literals |
|
||||
| `ast.zig` | AST node definitions |
|
||||
| `parser.zig` | Pratt parser -> AST |
|
||||
| `evaluator.zig` | Walk AST, produce results |
|
||||
| `bitwise.zig` | The fixed-width operators (`& \| xor ~ << >> >>> rol ror`), shared by both modes |
|
||||
| `programmer.zig` | Programmer-mode evaluation, its `Config`, wrapping arithmetic |
|
||||
| `formatter.zig` | Display and clipboard strings for floats and exact `Number`s, plus money and the display width |
|
||||
| `float_interp.zig` | IEEE 754 bit-level interpretation |
|
||||
| `units.zig` | Unit conversion tables and resolver |
|
||||
| `financial.zig` | CAGR, TVM, compound interest, amortization |
|
||||
| `financial.zig` | CAGR, TVM, compound interest, amortization, and money display (cents are a property of money, not of a screen) |
|
||||
| `message.zig` | Gone: the wording lives in `engine.zig` beside the `Error` union it words |
|
||||
| `engine.zig` | Public API surface (Zig-native): the module re-exports, the `Error` union, and `phrase` |
|
||||
| `c_api.zig` | `extern "C"` wrappers for JNI/FFI consumers |
|
||||
|
|
@ -466,7 +465,7 @@ exact value.
|
|||
bit width is the entire point of that mode; exact arithmetic would break it.
|
||||
- **The IEEE 754 float view** stays binary `f32`/`f64`. It exists to show binary
|
||||
encodings.
|
||||
- **The formatter's f64 paths** remain, since the inexact tier still needs them.
|
||||
- **The f64 rendering paths** remain, since the inexact tier still needs them.
|
||||
|
||||
#### 2.7.8 Fallback precision: why f64 and not f128
|
||||
|
||||
|
|
@ -651,7 +650,7 @@ Three existing items must change because we are invalidating their premise:
|
|||
1. `tokenizer.zig` test `"parseNumber huge decimal falls back to float"` -
|
||||
`99999999999999999999999999` becomes exactly representable, so the test still
|
||||
passes but its intent is now false. Rewrite it.
|
||||
2. `formatter.zig` `is_integer`, gated on `< 2^53` - that guard is the display
|
||||
2. `Number.renderInexact`'s 2^53 bound (then `formatter.zig`'s `is_integer`) - that guard is the display
|
||||
half of the `9007199254740993` bug.
|
||||
3. `evaluator.zig` `factorial`'s `x > 170` rejection, which is also the source of
|
||||
the misleading `unknown function` error.
|
||||
|
|
|
|||
|
|
@ -229,7 +229,7 @@ still required (FR-7.7): the mouse never becomes the only way to do something.
|
|||
- Decimal numbers must use comma grouping for display (e.g., `4,294,967,295`).
|
||||
- **Inexact (f64) values**: scientific notation only when absolute value > 10^15 / < 10^-15. Never jump to scientific notation for values that fit in a readable decimal. This bound is not a readability preference: 10^15 is where f64 stops distinguishing consecutive integers (2^53 ~ 9.007 x 10^15), so printing a plain integer past it would assert precision the value does not have.
|
||||
- (An earlier draft also triggered scientific notation past 15 significant digits. That clause was never implemented and was wrong: read literally it renders `0.9999999999999998` as `9.999999999999998e-1`, which is worse.)
|
||||
- **Exact values**: the 10^15 bound above must NOT apply. It exists because of f64's precision cliff, and an exact value has no such cliff, so applying it would contradict NFR-9.1 - `9007199254740993` is ~9.007 x 10^15 and would render as `9.007199254740993e15`, which is precisely the bug NFR-9.1 forbids. Exact values instead have a readability cap on integer digits (`formatter.max_display_integer_digits`), above which the display abbreviates to scientific notation while the clipboard/`raw` form retains every digit.
|
||||
- **Exact values**: the 10^15 bound above must NOT apply. It exists because of f64's precision cliff, and an exact value has no such cliff, so applying it would contradict NFR-9.1 - `9007199254740993` is ~9.007 x 10^15 and would render as `9.007199254740993e15`, which is precisely the bug NFR-9.1 forbids. Exact values instead have a readability cap on integer digits, above which the display abbreviates to scientific notation while the clipboard form retains every digit. The cap is not the engine's to choose: it is a field of `Number.FormatOptions`, which every frontend supplies (`max_integer_digits`, with `null` meaning never abbreviate, which is what the clipboard form asks for).
|
||||
- The cap must exceed the values the exact tier exists to serve: `9007199254740993` (16 digits) and `2^128` (39 digits). It exists at all because without any cap, `factorial(171)` renders 310 digits and `1.5e300 * 10` renders 301: accurate but unreadable.
|
||||
- **PROVISIONALLY 40 digits, pending review.** Chosen as the smallest round number above `2^128`. Not derived from any measured preference.
|
||||
- Programmer mode hex values display with space-separated bytes (e.g., `FF FF FF FF`).
|
||||
|
|
|
|||
|
|
@ -813,11 +813,12 @@ instead of "overflow". Two tests updated to match.
|
|||
`display`/`raw` pair that lived at two offsets inside it. That is not how Zig formats
|
||||
values, and the buffer had no bounds check: too small a buffer walked off the end.
|
||||
|
||||
- A value now produces a view: `int.as(.hex, .{ .endian = .little })` returns a `View`
|
||||
with a `format` method, so callers print it (`w.print("hex: {f}\n", .{...})`). The
|
||||
notation is an enum and the decoration is `Options{ separators, prefix, endian }`,
|
||||
with `Options.plain` as the no-separators, prefixed form.
|
||||
- `View.render(buf)` exists for callers that need a slice, such as the TUI, which
|
||||
- A value now produces a formatter: `int.fmt(.hex, .{ .endian = .little })` returns a
|
||||
`Format` with a `format` method, so callers print it (`w.print("hex: {f}\n", .{...})`).
|
||||
`Integer` also has a primary `format`, so `{f}` on the value itself renders decimal.
|
||||
The notation is an enum and the decoration is `FormatOptions{ separators, prefix,
|
||||
endian }`, with `FormatOptions.plain` as the no-separators, prefixed form.
|
||||
- `Format.render(buf)` exists for callers that need a slice, such as the TUI, which
|
||||
measures and positions text before drawing. It is `Writer.fixed` underneath, so a
|
||||
short buffer is `error.WriteFailed`.
|
||||
- `std.Io.Writer.printInt` replaced the hand-written digit loops for the prefixed
|
||||
|
|
@ -832,11 +833,64 @@ values, and the buffer had no bounds check: too small a buffer walked off the en
|
|||
Both frontends got shorter: the CLI prints five rows in one `print` call with no
|
||||
per-base buffers, and the programmer view asks the value for each row.
|
||||
|
||||
NOT DONE, and it will be obvious when you read them: `formatter.zig`'s remaining
|
||||
functions (`formatFloat`, `formatCompactFloat`, `formatNumber`, `formatAmount`,
|
||||
`formatMoney`) still take buffers and return `FormattedValue`/`NumberDisplay` pairs.
|
||||
That conversion reaches every display call site in both frontends, including the
|
||||
financial forms and the amortization table, so it is deliberately a separate pass.
|
||||
### Task 5.17: Delete formatter.zig; a value renders itself at the caller's budget
|
||||
|
||||
The other half of 5.16, and the answer to "why does `formatter.zig` exist at all".
|
||||
It held four unrelated jobs, organised by verb rather than by type, and five display
|
||||
decisions the engine had no business making: `exact_fraction_digits` (20),
|
||||
`max_display_integer_digits` (40), `scientific_significant_digits` (17), and two
|
||||
anonymous `1e15`/`1e-15` literals inside `formatFloat`. Android would have inherited
|
||||
a budget chosen for an 80-column terminal, or reimplemented the rules in Kotlin.
|
||||
|
||||
Each job went to the type that owns it:
|
||||
|
||||
- **`Number` renders itself.** `render(allocator, FormatOptions) Error!Rendered`,
|
||||
where `FormatOptions{ fraction_digits, max_integer_digits, significant_digits,
|
||||
separators }` has NO defaults, so a frontend cannot forget to decide. `main.zig`
|
||||
and `tui.zig` each declare one; `null` for `max_integer_digits` means never
|
||||
abbreviate, which is the clipboard form (requirements.md line 237). The old
|
||||
`NumberDisplay{ display, raw, exact }` triple is gone: display and clipboard are
|
||||
two option sets, so nothing allocates a clipboard string on every keystroke for a
|
||||
yank command that does not exist yet.
|
||||
- **`exact` became `truncated`**, and now describes the text alone: rounded away at
|
||||
`fraction_digits`, or abbreviated. Whether the VALUE is exact is `n == .exact`,
|
||||
and the two are independent. The old flag tried to mean both, which is why an
|
||||
exact 1e50 reported `exact = false` while its own `raw` field held every digit.
|
||||
A frontend marking a result approximate composes the two facts itself.
|
||||
- **`financial.zig` got money.** `money(value)` returns a `Money` with `format` and
|
||||
`render(buf)`. Two decimals and grouping are properties of money, not of a
|
||||
screen, so there is no budget to pass. The dead `formatAmount` went away with it,
|
||||
and the null-on-failure contract became `error.WriteFailed`, so the nine CLI
|
||||
`orelse return unformattableResult()` sites became `catch return`.
|
||||
- **`Integer.zig` got `displayWidthFor`**, the one thing in the file that formatted
|
||||
nothing: it returns a `BitWidth`, which is `Integer`'s own type.
|
||||
|
||||
Two rules stayed in the engine because they are facts about values rather than
|
||||
preferences about screens: the 2^53 bound, past which an f64's fixed rendering would
|
||||
invent digits (so an inexact value goes scientific there no matter what the caller's
|
||||
budget says), and that a value needing more fractional digits than the budget allows
|
||||
abbreviates rather than being silently shortened. `fraction_digits` means the same
|
||||
thing to both tiers: an exact expansion is rounded there, and a float, whose shortest
|
||||
round-trip form is never rounded, goes scientific when its digits do not fit. That
|
||||
one rule replaced five thresholds, including `formatCompactFloat`'s separate
|
||||
`1e-4`/`1e16` pair.
|
||||
|
||||
The float arm renders onto a stack buffer sized by `std.fmt.float.bufferSize(.decimal,
|
||||
f64)` (347 bytes) and allocates once, so a caller can hand it a
|
||||
`FixedBufferAllocator` sized for the text it asked for. `src/tui/float_view.zig` does
|
||||
exactly that: an 80-byte stack buffer, no heap in the draw path, "?" if it does not
|
||||
fit.
|
||||
|
||||
Behaviour changed in two bands, both deliberate, both making the tiers agree:
|
||||
|
||||
- An inexact value in [1e15, 2^53) now prints in full instead of scientific. Those
|
||||
digits are real; the old `> 1e15` test was a display preference sitting in front
|
||||
of the representability bound that actually matters.
|
||||
- An inexact value whose shortest form fits the fractional budget now prints fixed
|
||||
instead of scientific, which is what the exact tier already did for the same
|
||||
magnitude: a 1e-16 result no longer reads differently depending on which tier
|
||||
produced it. One that does not fit (`pi * 1e-17` needs 33 digits) still
|
||||
abbreviates, as before.
|
||||
|
||||
Also found while checking callers: nothing consumes the `raw` form of an integer
|
||||
rendering, and there is no clipboard code in the TUI at all. The prefixed form is kept
|
||||
|
|
@ -1134,6 +1188,22 @@ STILL OPEN, in the order I would take them:
|
|||
12. `engine/src/c_api.zig` has no tests and appears in no coverage report, because
|
||||
no test target builds the shared library. Deferred until Phase 6 gives it a
|
||||
caller; recorded here so it is a known gap rather than an oversight.
|
||||
13. NFR-9.9 ends with "the exact fraction available", and no frontend ever asks for
|
||||
it. `Rational.toFractionString` works and is tested, but its only callers are
|
||||
tests: nothing in the TUI or the CLI renders `1/3` as a fraction. Wiring it
|
||||
needs a display decision that does not exist yet (integers and inexact values
|
||||
have no useful fraction form, so the frontend has to choose when to show the
|
||||
line at all). Recorded rather than left as unused layers of forwarding code.
|
||||
14. Nothing consumes `Rendered.truncated` yet, and no frontend has a yank command,
|
||||
so the two halves of NFR-9.9's "marked approximate, with the exact fraction
|
||||
available" and requirements.md line 237's clipboard form are engine-side only.
|
||||
`Number.render` reports the fact and takes clipboard options; the TUI has yet
|
||||
to draw an indicator or copy anything.
|
||||
15. `c_api.zig` renders nothing today, so Phase 6 has to decide how a
|
||||
`Number.FormatOptions` crosses the FFI boundary. It must be parameters the
|
||||
caller passes, not a default the C layer invents, or Android inherits a budget
|
||||
chosen for an 80-column terminal (Task 5.17). Task 6.2 covers the bridge; this
|
||||
is the display half of it.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -268,6 +268,30 @@ pub const BitWidth = enum(u8) {
|
|||
}
|
||||
};
|
||||
|
||||
/// The width to print the hex, octal and binary rows at, for a standard-mode value
|
||||
/// that has no configured width (FR-1.9).
|
||||
///
|
||||
/// The narrowest of the standard widths that holds `value`, so a small number does
|
||||
/// not come out padded to 64 bits: 1 prints as `01`, and 256 steps up to `01 00`.
|
||||
/// It rounds up to a width a reader recognises rather than to a bit count, which is
|
||||
/// what makes the hex row read as whole bytes.
|
||||
///
|
||||
/// Unsigned only: it counts significant bits of the pattern, so a negative value's
|
||||
/// two's complement form would always report the full width. Callers reach this
|
||||
/// only for non-negative integers, which is also what FR-1.9 promises. Zero has no
|
||||
/// significant bits and prints at the narrowest width.
|
||||
///
|
||||
/// Programmer mode does not use this; there the width is the user's setting.
|
||||
pub fn displayWidthFor(value: u128) BitWidth {
|
||||
return switch (128 - @clz(value)) {
|
||||
0...8 => .bits8,
|
||||
9...16 => .bits16,
|
||||
17...32 => .bits32,
|
||||
33...64 => .bits64,
|
||||
else => .bits128,
|
||||
};
|
||||
}
|
||||
|
||||
/// Whether the top bit of a pattern is a sign.
|
||||
pub const Signedness = enum {
|
||||
signed,
|
||||
|
|
@ -614,3 +638,34 @@ test "the default format is the number, read by its own signedness" {
|
|||
try pair.print("{f} and {f}", .{ at(5, .bits8), at(0xFE, .bits8) });
|
||||
try testing.expectEqualStrings("5 and -2", pair.buffered());
|
||||
}
|
||||
|
||||
// -- displayWidthFor tests --
|
||||
//
|
||||
// Moved here with the function, from `formatter.zig`, where it was the one thing
|
||||
// in the file that formatted nothing: it returns a `BitWidth`, which is this
|
||||
// file's type.
|
||||
|
||||
test "displayWidthFor: the narrowest standard width that holds the value" {
|
||||
try testing.expectEqual(BitWidth.bits8, displayWidthFor(0));
|
||||
try testing.expectEqual(BitWidth.bits8, displayWidthFor(255));
|
||||
try testing.expectEqual(BitWidth.bits16, displayWidthFor(256));
|
||||
try testing.expectEqual(BitWidth.bits16, displayWidthFor(65535));
|
||||
try testing.expectEqual(BitWidth.bits32, displayWidthFor(65536));
|
||||
try testing.expectEqual(BitWidth.bits64, displayWidthFor(0x1_0000_0000));
|
||||
try testing.expectEqual(BitWidth.bits128, displayWidthFor(0x1_0000_0000_0000_0000));
|
||||
try testing.expectEqual(BitWidth.bits128, displayWidthFor(std.math.maxInt(u128)));
|
||||
}
|
||||
|
||||
test "displayWidthFor: the chosen width holds the value and sizes the rows" {
|
||||
// What the width is for: the hex row of a small number is one byte, not eight.
|
||||
var buf: [256]u8 = undefined;
|
||||
for ([_]u128{ 0, 1, 255, 256, 0xFFFF, 0x1_0000, std.math.maxInt(u64), std.math.maxInt(u128) }) |value| {
|
||||
const width = displayWidthFor(value);
|
||||
try testing.expectEqual(value, value & width.mask());
|
||||
const int: Integer = .{ .raw = value, .width = width, .signedness = .unsigned };
|
||||
const hex = try int.fmt(.hex, .{}).render(&buf);
|
||||
// Two hex digits per byte, plus one space between bytes.
|
||||
const bytes: usize = width.bits() / 8;
|
||||
try testing.expectEqual(bytes * 3 - 1, hex.len);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2082,7 +2082,7 @@ test "toScientificString: a single significant digit rounds correctly" {
|
|||
|
||||
test "toScientificString: agrees with f64 for values f64 can hold" {
|
||||
// The file-level `alloc` is testing.allocator.
|
||||
// Cross-check against the float formatter's own rendering of the same value,
|
||||
// Cross-check against std.fmt's own rendering of the same value,
|
||||
// so the mantissa and exponent are not just self-consistent.
|
||||
const values = [_]struct { num: i64, den: i64 }{
|
||||
.{ .num = 1, .den = 8 },
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ pub const evaluator = @import("evaluator.zig");
|
|||
pub const bitwise = @import("bitwise.zig");
|
||||
pub const programmer = @import("programmer.zig");
|
||||
// Domains and display.
|
||||
pub const formatter = @import("formatter.zig");
|
||||
pub const float_interp = @import("float_interp.zig");
|
||||
pub const units = @import("units.zig");
|
||||
pub const financial = @import("financial.zig");
|
||||
|
|
|
|||
|
|
@ -1217,8 +1217,8 @@ fn expectExactDecimal(expected: []const u8, source: []const u8) !void {
|
|||
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
|
||||
defer _ = arena.deinit();
|
||||
const result = try testEvalNumber(&arena, source);
|
||||
try testing.expect(result.isExact());
|
||||
const shown = try result.toDecimalString(arena.allocator(), 20);
|
||||
try testing.expect(result == .exact);
|
||||
const shown = try result.exact.toDecimalString(arena.allocator(), 20);
|
||||
try testing.expectEqualStrings(expected, shown.text);
|
||||
}
|
||||
|
||||
|
|
@ -1240,14 +1240,14 @@ test "Number API: one third is retained exactly, not as a decimal" {
|
|||
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
|
||||
defer _ = arena.deinit();
|
||||
const result = try testEvalNumber(&arena, "1/3");
|
||||
try testing.expect(result.isExact());
|
||||
try testing.expect(result == .exact);
|
||||
|
||||
// The exact form is a fraction, which no float could express.
|
||||
const frac = (try result.toFractionString(arena.allocator())).?;
|
||||
const frac = try result.exact.toFractionString(arena.allocator());
|
||||
try testing.expectEqualStrings("1/3", frac);
|
||||
|
||||
// And its decimal rendering is correctly reported as approximate.
|
||||
const shown = try result.toDecimalString(arena.allocator(), 10);
|
||||
const shown = try result.exact.toDecimalString(arena.allocator(), 10);
|
||||
try testing.expect(!shown.exact);
|
||||
}
|
||||
|
||||
|
|
@ -1255,9 +1255,9 @@ test "Number API: factorial is exact past the old 170 limit" {
|
|||
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
|
||||
defer _ = arena.deinit();
|
||||
const result = try testEvalNumber(&arena, "factorial(171)");
|
||||
try testing.expect(result.isExact());
|
||||
try testing.expect(result == .exact);
|
||||
|
||||
const shown = try result.toDecimalString(arena.allocator(), 0);
|
||||
const shown = try result.exact.toDecimalString(arena.allocator(), 0);
|
||||
// 171! has 310 digits; f64 could only report infinity.
|
||||
try testing.expectEqual(@as(usize, 310), shown.text.len);
|
||||
try testing.expect(shown.exact);
|
||||
|
|
@ -1268,24 +1268,24 @@ test "Number API: transcendentals are reported as inexact" {
|
|||
defer _ = arena.deinit();
|
||||
|
||||
const s = try testEvalNumber(&arena, "sin(1)");
|
||||
try testing.expect(!s.isExact());
|
||||
try testing.expect(s != .exact);
|
||||
|
||||
const p = try testEvalNumber(&arena, "pi");
|
||||
try testing.expect(!p.isExact());
|
||||
try testing.expect(p != .exact);
|
||||
|
||||
const r = try testEvalNumber(&arena, "sqrt(2)");
|
||||
try testing.expect(!r.isExact());
|
||||
try testing.expect(r != .exact);
|
||||
|
||||
// But a perfect square stays exact.
|
||||
const q = try testEvalNumber(&arena, "sqrt(144)");
|
||||
try testing.expect(q.isExact());
|
||||
try testing.expect(q == .exact);
|
||||
}
|
||||
|
||||
test "Number API: inexactness is contagious across an expression" {
|
||||
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
|
||||
defer _ = arena.deinit();
|
||||
const result = try testEvalNumber(&arena, "0.1 + 0.2 + sin(0)");
|
||||
try testing.expect(!result.isExact());
|
||||
try testing.expect(result != .exact);
|
||||
}
|
||||
|
||||
test "Number API: variables keep the exactness of their expression" {
|
||||
|
|
@ -1298,11 +1298,11 @@ test "Number API: variables keep the exactness of their expression" {
|
|||
defer env.deinit();
|
||||
|
||||
const assigned = try evalString(&env, a, "X = 0.1");
|
||||
try testing.expect(assigned.isExact());
|
||||
try testing.expect(assigned == .exact);
|
||||
|
||||
const sum = try evalString(&env, a, "X + 0.2");
|
||||
try testing.expect(sum.isExact());
|
||||
const shown = try sum.toDecimalString(a, 20);
|
||||
try testing.expect(sum == .exact);
|
||||
const shown = try sum.exact.toDecimalString(a, 20);
|
||||
try testing.expectEqualStrings("0.3", shown.text);
|
||||
try testing.expect(shown.exact);
|
||||
}
|
||||
|
|
@ -1316,8 +1316,8 @@ test "Number API: Ans keeps exactness between evaluations" {
|
|||
|
||||
_ = try evalString(&env, a, "1/3");
|
||||
const doubled = try evalString(&env, a, "Ans * 3");
|
||||
try testing.expect(doubled.isExact());
|
||||
const shown = try doubled.toDecimalString(a, 20);
|
||||
try testing.expect(doubled == .exact);
|
||||
const shown = try doubled.exact.toDecimalString(a, 20);
|
||||
try testing.expectEqualStrings("1", shown.text);
|
||||
}
|
||||
|
||||
|
|
@ -1473,7 +1473,7 @@ test "financial: results are inexact, so they do not claim exactness" {
|
|||
defer env.deinit();
|
||||
|
||||
const result = try evalString(&env, alloc, "cagr(1000, 2000, 10)");
|
||||
try testing.expect(!result.isExact());
|
||||
try testing.expect(result != .exact);
|
||||
}
|
||||
|
||||
test "financial: bad arguments are domain errors, not wrong answers" {
|
||||
|
|
@ -1610,10 +1610,15 @@ test "a grouped literal past 2^53 is still exact" {
|
|||
defer env.deinit();
|
||||
var value = try evalString(&env, testing.allocator, "9,007,199,254,740,993");
|
||||
defer value.deinit();
|
||||
try testing.expect(value.isExact());
|
||||
const shown = try @import("formatter.zig").formatNumber(testing.allocator, value);
|
||||
try testing.expect(value == .exact);
|
||||
const shown = try value.render(testing.allocator, .{
|
||||
.fraction_digits = 20,
|
||||
.max_integer_digits = null,
|
||||
.significant_digits = 17,
|
||||
.separators = false,
|
||||
});
|
||||
defer shown.deinit(testing.allocator);
|
||||
try testing.expectEqualStrings("9007199254740993", shown.raw);
|
||||
try testing.expectEqualStrings("9007199254740993", shown.text);
|
||||
}
|
||||
|
||||
// -- Operands that do not fit a machine word --
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@
|
|||
|
||||
const std = @import("std");
|
||||
const math = std.math;
|
||||
const grouping = @import("grouping.zig");
|
||||
/// What the financial calculations can fail with.
|
||||
///
|
||||
/// `InsufficientParameters` and `ConvergenceFailure` are theirs alone: no other part
|
||||
|
|
@ -462,6 +463,49 @@ pub fn roundToCents(value: f64) f64 {
|
|||
return roundToScale(value, 2, .half_even);
|
||||
}
|
||||
|
||||
// -- Money display --
|
||||
//
|
||||
// Two decimal places and a grouped integer part is a property of money, not of a
|
||||
// screen, so unlike `Number.FormatOptions` there is no budget to pass: cents are
|
||||
// cents on every frontend. This used to live in `formatter.zig`, and before that
|
||||
// character for character in `src/main.zig` and `src/tui/financial.zig`, both of
|
||||
// which also reimplemented the thousands grouping.
|
||||
|
||||
/// Widest fixed-point rendering an f64 has (about 310 integer digits), plus
|
||||
/// separators and cents.
|
||||
const money_text_max = 512;
|
||||
|
||||
/// An amount ready to print: grouped integer part, exactly two decimal places.
|
||||
pub const Money = struct {
|
||||
value: f64,
|
||||
|
||||
pub fn format(self: Money, w: *std.Io.Writer) std.Io.Writer.Error!void {
|
||||
// Grouping asserts numeric text, and "inf" is not. A non-finite amount
|
||||
// prints as itself rather than as a placeholder that reads like data.
|
||||
if (!math.isFinite(self.value)) {
|
||||
return w.print("{d}", .{self.value});
|
||||
}
|
||||
var plain: [money_text_max]u8 = undefined;
|
||||
const text = std.fmt.bufPrint(&plain, "{d:.2}", .{self.value}) catch
|
||||
return error.WriteFailed;
|
||||
return grouping.print(w, text);
|
||||
}
|
||||
|
||||
/// For a caller that must measure or pad the text, such as a table column.
|
||||
/// `error.WriteFailed` when `buf` is too small: the amount is reported as a
|
||||
/// failure rather than truncated or replaced with "?".
|
||||
pub fn render(self: Money, buf: []u8) std.Io.Writer.Error![]const u8 {
|
||||
var w = std.Io.Writer.fixed(buf);
|
||||
try self.format(&w);
|
||||
return w.buffered();
|
||||
}
|
||||
};
|
||||
|
||||
/// An amount at two decimal places, the money case.
|
||||
pub fn money(value: f64) Money {
|
||||
return .{ .value = value };
|
||||
}
|
||||
|
||||
// -- Amortization --
|
||||
|
||||
/// Upper bound on schedule length. 12,000 monthly periods is a thousand years,
|
||||
|
|
@ -1403,3 +1447,84 @@ test "compound interest: the four variables round-trip through each other" {
|
|||
try testing.expectApproxEqAbs(rate, try compoundRate(pv, fv, years, per_year), 1e-9);
|
||||
try testing.expectApproxEqAbs(years, try compoundPeriods(pv, fv, rate, per_year), 1e-9);
|
||||
}
|
||||
|
||||
// -- Money display tests --
|
||||
//
|
||||
// These moved here with `Money`, from `formatter.zig`. The old version returned
|
||||
// `?[]const u8` and both frontends had copies returning the string "?", so
|
||||
// `tally amort 1e40 0.5 3` printed a table of question marks and exited 0.
|
||||
|
||||
fn expectMoney(expected: []const u8, value: f64) !void {
|
||||
var buf: [money_text_max]u8 = undefined;
|
||||
try testing.expectEqualStrings(expected, try money(value).render(&buf));
|
||||
}
|
||||
|
||||
test "money: grouping, sign and two decimals" {
|
||||
try expectMoney("0.00", 0);
|
||||
try expectMoney("199.10", 199.1);
|
||||
try expectMoney("1,199.10", 1199.1);
|
||||
try expectMoney("200,000.00", 200000);
|
||||
try expectMoney("231,677.04", 231677.04);
|
||||
try expectMoney("1,234,567.89", 1234567.89);
|
||||
try expectMoney("-1,199.10", -1199.1);
|
||||
try expectMoney("-0.01", -0.01);
|
||||
}
|
||||
|
||||
test "money: rounds to the cent" {
|
||||
try expectMoney("1,199.10", 1199.101050305518);
|
||||
try expectMoney("2.00", 1.995);
|
||||
}
|
||||
|
||||
test "money: a buffer too small is a failure, not a placeholder" {
|
||||
var tiny: [4]u8 = undefined;
|
||||
try testing.expectError(error.WriteFailed, money(1234567.89).render(&tiny));
|
||||
}
|
||||
|
||||
test "money: non-finite amounts print as themselves" {
|
||||
// Not reachable from the solvers, which reject the inputs that would produce
|
||||
// one, but a formatter that silently emitted "?" here would be worse than one
|
||||
// that says "inf".
|
||||
try expectMoney("inf", math.inf(f64));
|
||||
try expectMoney("-inf", -math.inf(f64));
|
||||
try expectMoney("nan", math.nan(f64));
|
||||
}
|
||||
|
||||
test "money: very large amounts render or fail cleanly, never partially" {
|
||||
// 1e40 needs 41 integer digits, 13 separators and cents: 57 bytes.
|
||||
var buf: [64]u8 = undefined;
|
||||
const forty = try money(1e40).render(&buf);
|
||||
try testing.expectEqual(@as(usize, 57), forty.len);
|
||||
try testing.expect(std.mem.startsWith(u8, forty, "10,000,000,000"));
|
||||
try testing.expect(std.mem.endsWith(u8, forty, ".00"));
|
||||
|
||||
// 1e300 needs 404 bytes, so the same buffer must refuse rather than truncate.
|
||||
try testing.expectError(error.WriteFailed, money(1e300).render(&buf));
|
||||
var wide: [money_text_max]u8 = undefined;
|
||||
const huge = try money(1e300).render(&wide);
|
||||
try testing.expect(std.mem.endsWith(u8, huge, ".00"));
|
||||
}
|
||||
|
||||
test "money: prints through a writer without a caller buffer" {
|
||||
var out: [64]u8 = undefined;
|
||||
var w = std.Io.Writer.fixed(&out);
|
||||
try w.print("Payment {f} per period", .{money(1199.1)});
|
||||
try testing.expectEqualStrings("Payment 1,199.10 per period", w.buffered());
|
||||
}
|
||||
|
||||
test "money: groups the same way an ordinary result does" {
|
||||
// The point of collapsing the copies: an amount and a plain result group
|
||||
// identically, differing only in the fixed decimal places.
|
||||
var money_buf: [64]u8 = undefined;
|
||||
const as_money = try money(231677).render(&money_buf);
|
||||
var value = @import("number.zig").Number.fromFloat(231677);
|
||||
const as_value = try value.render(testing.allocator, .{
|
||||
.fraction_digits = 20,
|
||||
.max_integer_digits = 40,
|
||||
.significant_digits = 17,
|
||||
.separators = true,
|
||||
});
|
||||
defer as_value.deinit(testing.allocator);
|
||||
try testing.expectEqualStrings("231,677.00", as_money);
|
||||
try testing.expectEqualStrings("231,677", as_value.text);
|
||||
try testing.expect(std.mem.startsWith(u8, as_money, as_value.text));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,941 +0,0 @@
|
|||
//! Number display formatter for Tally.
|
||||
//!
|
||||
//! Produces two string representations for every value:
|
||||
//! - `display`: human-readable with separators (commas, underscores, spaces)
|
||||
//! - `raw`: clipboard-friendly without separators (but with base prefix)
|
||||
//!
|
||||
//! Formatting rules per the spec:
|
||||
//! - Decimal: comma-separated groups of 3 (e.g. "4,294,967,295"). The integer
|
||||
//! part is grouped whether or not there is a fractional part, so "231,677.04"
|
||||
//! and "231,677" read consistently. A fractional part is never grouped.
|
||||
//! - Hex value view: space per byte (e.g. "FF FF FF FF"); the prefix appears only
|
||||
//! in the clipboard form ("0xFFFFFFFF")
|
||||
//! - Binary: space per nibble (e.g. "1111 1111")
|
||||
//! - Octal: space per 3-digit group, zero-padded to the bit width
|
||||
//! - Scientific notation when |value| is above 10^15 or below 10^-15, and for an
|
||||
//! exact value whose integer part exceeds `max_display_integer_digits` or whose
|
||||
//! magnitude is below the fractional budget
|
||||
|
||||
const std = @import("std");
|
||||
const Integer = @import("Integer.zig");
|
||||
const BitWidth = Integer.BitWidth;
|
||||
const grouping = @import("grouping.zig");
|
||||
const Number = @import("number.zig").Number;
|
||||
|
||||
/// A formatted value with both display and clipboard representations.
|
||||
pub const FormattedValue = struct {
|
||||
display: []const u8,
|
||||
raw: []const u8,
|
||||
};
|
||||
|
||||
/// Length of the grouped form of `text`, and the writer for it. Private: the TUI
|
||||
/// groups partially typed input through `grouping` directly, and these are only the
|
||||
/// buffer-shaped conveniences this file's own buffer-based functions need.
|
||||
const groupedDecimalLen = grouping.lengthOf;
|
||||
|
||||
/// Group `text` into `dest`, which must hold `groupedDecimalLen(text)` bytes.
|
||||
fn writeGroupedDecimal(dest: []u8, text: []const u8) usize {
|
||||
var w = std.Io.Writer.fixed(dest);
|
||||
grouping.print(&w, text) catch @panic("grouping buffer too small; ask groupedDecimalLen first");
|
||||
return w.end;
|
||||
}
|
||||
|
||||
/// Format a floating-point value for display.
|
||||
/// Uses comma grouping for integers, avoids scientific notation unless necessary.
|
||||
///
|
||||
/// The 2^53 bound is NOT a display preference: past it an f64 no longer
|
||||
/// distinguishes consecutive integers, so printing one as an exact-looking integer
|
||||
/// would assert precision the value does not have. Exact values are not subject to
|
||||
/// this and go through `formatNumber`, which prints them in full.
|
||||
///
|
||||
/// In practice the `< 1e15` test on the next line is the stricter of the two, so
|
||||
/// the 2^53 bound never decides an outcome on its own. It is kept because the two
|
||||
/// bounds mean different things: one is about representability, the other about how
|
||||
/// many digits are worth showing, and a change to the display threshold should not
|
||||
/// silently remove the representability check.
|
||||
pub fn formatFloat(buf: []u8, value: f64) FormattedValue {
|
||||
const is_integer = value == @trunc(value) and @abs(value) < 9007199254740992.0; // 2^53
|
||||
|
||||
if (is_integer and @abs(value) < 1e15) {
|
||||
// An integral value: plain digits, then the same digits grouped.
|
||||
const int_val: i128 = @intFromFloat(value);
|
||||
var raw_writer = std.Io.Writer.fixed(buf);
|
||||
raw_writer.printInt(int_val, 10, .lower, .{}) catch
|
||||
return .{ .display = "ERR", .raw = "ERR" };
|
||||
const raw = raw_writer.buffered();
|
||||
|
||||
var display_writer = std.Io.Writer.fixed(buf[raw.len..]);
|
||||
grouping.print(&display_writer, raw) catch
|
||||
return .{ .display = raw, .raw = raw };
|
||||
|
||||
return .{ .display = display_writer.buffered(), .raw = raw };
|
||||
}
|
||||
|
||||
// Check if we should use scientific notation
|
||||
const abs_val = @abs(value);
|
||||
if (abs_val != 0 and (abs_val > 1e15 or abs_val < 1e-15)) {
|
||||
// Scientific notation
|
||||
const raw_len = (std.fmt.bufPrint(buf, "{e}", .{value}) catch return .{ .display = "ERR", .raw = "ERR" }).len;
|
||||
return .{ .display = buf[0..raw_len], .raw = buf[0..raw_len] };
|
||||
}
|
||||
|
||||
// Regular float formatting. The integer part gets the same comma grouping an
|
||||
// integer or an exact value gets, so `231677.04` does not read differently
|
||||
// from `231677`.
|
||||
const raw_len = (std.fmt.bufPrint(buf, "{d}", .{value}) catch return .{ .display = "ERR", .raw = "ERR" }).len;
|
||||
const raw = buf[0..raw_len];
|
||||
|
||||
const display_len = groupedDecimalLen(raw);
|
||||
// Nothing to group, or no room for a second copy: display is the raw text.
|
||||
if (display_len == raw_len or raw_len + display_len > buf.len) {
|
||||
return .{ .display = raw, .raw = raw };
|
||||
}
|
||||
// Writes into the region after `raw`, so source and destination never
|
||||
// overlap.
|
||||
const written = writeGroupedDecimal(buf[raw_len..], raw);
|
||||
return .{ .display = buf[raw_len..][0..written], .raw = raw };
|
||||
}
|
||||
|
||||
/// Format a float for compact single-line display (used by the float view).
|
||||
/// Uses shortest round-trip fixed-point for normal magnitudes, and scientific
|
||||
/// notation for very small or very large magnitudes so rows stay readable
|
||||
/// (e.g. a subnormal ULP prints as "1.4e-45" instead of 45 decimal digits).
|
||||
/// Non-finite values render as "inf", "-inf", or "nan".
|
||||
pub fn formatCompactFloat(buf: []u8, value: f64) []const u8 {
|
||||
if (std.math.isNan(value)) return "nan";
|
||||
if (std.math.isPositiveInf(value)) return "inf";
|
||||
if (std.math.isNegativeInf(value)) return "-inf";
|
||||
|
||||
const abs = @abs(value);
|
||||
const use_scientific = abs != 0 and (abs < 1e-4 or abs >= 1e16);
|
||||
if (use_scientific) {
|
||||
return std.fmt.bufPrint(buf, "{e}", .{value}) catch return "ERR";
|
||||
}
|
||||
return std.fmt.bufPrint(buf, "{d}", .{value}) catch return "ERR";
|
||||
}
|
||||
|
||||
/// Format a `Number` for display, preserving exactness where it exists.
|
||||
///
|
||||
/// The rules, per NFR-9.9:
|
||||
/// - An exact **integer** prints in full with comma grouping, at ANY magnitude.
|
||||
/// It deliberately does not switch to scientific notation: printing
|
||||
/// `9007199254740993` correctly is the entire point of the exact tier, and
|
||||
/// abbreviating it would throw the result away at the last step.
|
||||
/// - An exact value with a **terminating** decimal expansion prints exactly.
|
||||
/// - An exact value with a **repeating** expansion is rounded to
|
||||
/// `exact_fraction_digits` and reported as approximate.
|
||||
/// - An **inexact** value uses the float rules (`formatFloat`) and is always
|
||||
/// reported as approximate, because rounding already happened.
|
||||
///
|
||||
/// Caller owns `display` and `raw`.
|
||||
pub fn formatNumber(allocator: std.mem.Allocator, value: Number) !NumberDisplay {
|
||||
switch (value) {
|
||||
.inexact => |f| {
|
||||
var buf: [512]u8 = undefined;
|
||||
const formatted = formatFloat(&buf, f);
|
||||
const display = try allocator.dupe(u8, formatted.display);
|
||||
errdefer allocator.free(display);
|
||||
const raw = try allocator.dupe(u8, formatted.raw);
|
||||
return .{ .display = display, .raw = raw, .exact = false };
|
||||
},
|
||||
.exact => |r| {
|
||||
const rendered = try r.toDecimalString(allocator, exact_fraction_digits);
|
||||
errdefer allocator.free(rendered.text);
|
||||
|
||||
// Very long values are abbreviated for display only. The `raw`
|
||||
// (clipboard) form always keeps every digit, so the exact value is
|
||||
// never actually lost, just not shown inline.
|
||||
//
|
||||
// Both this and the small-magnitude case below go through
|
||||
// `toScientificString`. There used to be a second renderer here that
|
||||
// worked on the already-rendered text; two implementations of the same
|
||||
// notation is one more than needed, and the rational-based one handles
|
||||
// both ends of the range.
|
||||
if (grouping.integerDigitCount(rendered.text) > max_display_integer_digits) {
|
||||
const display = try r.toScientificString(allocator, scientific_significant_digits);
|
||||
return .{ .display = display, .raw = rendered.text, .exact = false };
|
||||
}
|
||||
|
||||
// The other end of the same problem: a value smaller than the
|
||||
// fractional budget renders as all zeros, which destroys it in the
|
||||
// clipboard as well as on screen (2^-70 printed as 0.00000...).
|
||||
if (!isZeroText(rendered.text)) {
|
||||
// Group the integer part for readability; the raw form stays plain.
|
||||
const display = try groupDecimalText(allocator, rendered.text);
|
||||
return .{ .display = display, .raw = rendered.text, .exact = rendered.exact };
|
||||
}
|
||||
if (r.isZero()) {
|
||||
const display = try allocator.dupe(u8, rendered.text);
|
||||
return .{ .display = display, .raw = rendered.text, .exact = true };
|
||||
}
|
||||
allocator.free(rendered.text);
|
||||
const scientific = try r.toScientificString(allocator, scientific_significant_digits);
|
||||
errdefer allocator.free(scientific);
|
||||
const raw = try allocator.dupe(u8, scientific);
|
||||
// Rounded to 17 significant digits, so not exact even though the value
|
||||
// is: the tag describes the text, not the value behind it.
|
||||
return .{ .display = scientific, .raw = raw, .exact = false };
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// True when decimal text carries no significant digit, i.e. it is some spelling
|
||||
/// of zero ("0", "0.00", "-0.000").
|
||||
fn isZeroText(text: []const u8) bool {
|
||||
for (text) |ch| {
|
||||
if (ch >= '1' and ch <= '9') return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Fractional digits produced for an exact value whose decimal expansion does
|
||||
/// not terminate (1/3, 1/7). Exact arithmetic can justify more digits than f64,
|
||||
/// so this is above f64's ~17 significant digits.
|
||||
pub const exact_fraction_digits: usize = 20;
|
||||
|
||||
/// Integer digits shown in full before display switches to scientific notation.
|
||||
///
|
||||
/// The exact tier exists so values like `9007199254740993` (16 digits) and
|
||||
/// `2^128` (39 digits) print correctly, so the cap must be comfortably above
|
||||
/// those. It exists at all because without it `factorial(171)` renders 310
|
||||
/// digits and `1.5e300 * 10` renders 301, which is accurate but unreadable.
|
||||
pub const max_display_integer_digits: usize = 40;
|
||||
|
||||
/// Significant digits kept when abbreviating to scientific notation.
|
||||
const scientific_significant_digits: usize = 17;
|
||||
|
||||
pub const NumberDisplay = struct {
|
||||
/// Human-readable form, with comma grouping.
|
||||
display: []const u8,
|
||||
/// Clipboard form: no separators.
|
||||
raw: []const u8,
|
||||
/// False when the text is a rounded approximation of the true value.
|
||||
exact: bool,
|
||||
|
||||
pub fn deinit(self: NumberDisplay, allocator: std.mem.Allocator) void {
|
||||
allocator.free(self.display);
|
||||
allocator.free(self.raw);
|
||||
}
|
||||
};
|
||||
|
||||
/// Insert comma separators into the integer part of decimal text, leaving any
|
||||
/// sign and fractional part alone.
|
||||
fn groupDecimalText(allocator: std.mem.Allocator, text: []const u8) ![]u8 {
|
||||
const len = groupedDecimalLen(text);
|
||||
if (len == text.len) return allocator.dupe(u8, text);
|
||||
const out = try allocator.alloc(u8, len);
|
||||
const written = writeGroupedDecimal(out, text);
|
||||
std.debug.assert(written == len);
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Format a value as an amount: grouped integer part, exactly `decimals` places.
|
||||
///
|
||||
/// The single implementation of this. It existed three times before: character for
|
||||
/// character in `src/main.zig` and `src/tui/financial.zig`, both of which also
|
||||
/// reimplemented the comma grouping that lives a few lines below here.
|
||||
///
|
||||
/// Returns null rather than a placeholder when the result does not fit `buf`. The
|
||||
/// copies returned the string "?", so `tally amort 1e40 0.5 3` printed a full table
|
||||
/// of question marks and exited 0. A caller that cannot format a number should say
|
||||
/// so, not render one.
|
||||
pub fn formatAmount(buf: []u8, value: f64, decimals: u8) ?[]const u8 {
|
||||
if (!std.math.isFinite(value)) return null;
|
||||
|
||||
// Enough for f64's widest fixed-point rendering (about 310 integer digits)
|
||||
// plus separators and a fractional part.
|
||||
var plain: [400]u8 = undefined;
|
||||
const text = switch (decimals) {
|
||||
0 => std.fmt.bufPrint(&plain, "{d:.0}", .{value}),
|
||||
1 => std.fmt.bufPrint(&plain, "{d:.1}", .{value}),
|
||||
2 => std.fmt.bufPrint(&plain, "{d:.2}", .{value}),
|
||||
else => std.fmt.bufPrint(&plain, "{d:.6}", .{value}),
|
||||
} catch return null;
|
||||
|
||||
const needed = groupedDecimalLen(text);
|
||||
if (needed > buf.len) return null;
|
||||
if (needed == text.len) {
|
||||
@memcpy(buf[0..text.len], text);
|
||||
return buf[0..text.len];
|
||||
}
|
||||
return buf[0..writeGroupedDecimal(buf, text)];
|
||||
}
|
||||
|
||||
/// `formatAmount` at two decimal places, the money case.
|
||||
pub fn formatMoney(buf: []u8, value: f64) ?[]const u8 {
|
||||
return formatAmount(buf, value, 2);
|
||||
}
|
||||
|
||||
/// The width to print the hex, octal and binary rows at, for a standard-mode value
|
||||
/// that has no configured width (FR-1.9).
|
||||
///
|
||||
/// The narrowest of the standard widths that holds `value`, so a small number does
|
||||
/// not come out padded to 64 bits: 1 prints as `01`, and 256 steps up to `01 00`.
|
||||
/// It rounds up to a width a reader recognises rather than to a bit count, which is
|
||||
/// what makes the hex row read as whole bytes.
|
||||
///
|
||||
/// Unsigned only: it counts significant bits of the pattern, so a negative value's
|
||||
/// two's complement form would always report the full width. Callers reach this
|
||||
/// only for non-negative integers, which is also what FR-1.9 promises. Zero has no
|
||||
/// significant bits and prints at the narrowest width.
|
||||
///
|
||||
/// Programmer mode does not use this; there the width is the user's setting.
|
||||
pub fn displayWidthFor(value: u128) BitWidth {
|
||||
return switch (128 - @clz(value)) {
|
||||
0...8 => .bits8,
|
||||
9...16 => .bits16,
|
||||
17...32 => .bits32,
|
||||
33...64 => .bits64,
|
||||
else => .bits128,
|
||||
};
|
||||
}
|
||||
|
||||
// -- Tests --
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
test "formatFloat: a buffer too small degrades instead of overrunning" {
|
||||
// Both halves are written through a fixed writer, so a short buffer is an error
|
||||
// the function handles rather than a walk off the end.
|
||||
var no_room: [4]u8 = undefined;
|
||||
const failed = formatFloat(&no_room, 1234567);
|
||||
try testing.expectEqualStrings("ERR", failed.display);
|
||||
try testing.expectEqualStrings("ERR", failed.raw);
|
||||
|
||||
// Room for the digits but not for a second, grouped copy: display falls back to
|
||||
// the ungrouped text rather than being truncated.
|
||||
var tight: [9]u8 = undefined;
|
||||
const partial = formatFloat(&tight, 1234567);
|
||||
try testing.expectEqualStrings("1234567", partial.raw);
|
||||
try testing.expectEqualStrings("1234567", partial.display);
|
||||
}
|
||||
|
||||
test "formatFloat: integer value" {
|
||||
var buf: [256]u8 = undefined;
|
||||
const result = formatFloat(&buf, 42.0);
|
||||
try testing.expectEqualStrings("42", result.display);
|
||||
try testing.expectEqualStrings("42", result.raw);
|
||||
}
|
||||
|
||||
test "formatFloat: large integer with commas" {
|
||||
var buf: [256]u8 = undefined;
|
||||
const result = formatFloat(&buf, 4294967295.0);
|
||||
try testing.expectEqualStrings("4,294,967,295", result.display);
|
||||
try testing.expectEqualStrings("4294967295", result.raw);
|
||||
}
|
||||
|
||||
test "formatFloat: negative integer" {
|
||||
var buf: [256]u8 = undefined;
|
||||
const result = formatFloat(&buf, -1234.0);
|
||||
try testing.expectEqualStrings("-1,234", result.display);
|
||||
try testing.expectEqualStrings("-1234", result.raw);
|
||||
}
|
||||
|
||||
test "formatFloat: million" {
|
||||
var buf: [256]u8 = undefined;
|
||||
const result = formatFloat(&buf, 1000000.0);
|
||||
try testing.expectEqualStrings("1,000,000", result.display);
|
||||
try testing.expectEqualStrings("1000000", result.raw);
|
||||
}
|
||||
|
||||
test "formatFloat: zero" {
|
||||
var buf: [256]u8 = undefined;
|
||||
const result = formatFloat(&buf, 0.0);
|
||||
try testing.expectEqualStrings("0", result.display);
|
||||
try testing.expectEqualStrings("0", result.raw);
|
||||
}
|
||||
|
||||
test "displayWidthFor: the narrowest standard width that holds the value" {
|
||||
const BW = Integer.BitWidth;
|
||||
try testing.expectEqual(BW.bits8, displayWidthFor(0));
|
||||
try testing.expectEqual(BW.bits8, displayWidthFor(255));
|
||||
try testing.expectEqual(BW.bits16, displayWidthFor(256));
|
||||
try testing.expectEqual(BW.bits16, displayWidthFor(65535));
|
||||
try testing.expectEqual(BW.bits32, displayWidthFor(65536));
|
||||
try testing.expectEqual(BW.bits64, displayWidthFor(0x1_0000_0000));
|
||||
try testing.expectEqual(BW.bits128, displayWidthFor(0x1_0000_0000_0000_0000));
|
||||
try testing.expectEqual(BW.bits128, displayWidthFor(std.math.maxInt(u128)));
|
||||
}
|
||||
|
||||
test "displayWidthFor: the chosen width holds the value and sizes the rows" {
|
||||
// What the width is for: the hex row of a small number is one byte, not eight.
|
||||
var buf: [256]u8 = undefined;
|
||||
for ([_]u128{ 0, 1, 255, 256, 0xFFFF, 0x1_0000, std.math.maxInt(u64), std.math.maxInt(u128) }) |value| {
|
||||
const bw = displayWidthFor(value);
|
||||
try testing.expectEqual(value, value & bw.mask());
|
||||
const int: Integer = .{ .raw = value, .width = bw, .signedness = .unsigned };
|
||||
const hex = try int.fmt(.hex, .{}).render(&buf);
|
||||
// Two hex digits per byte, plus one space between bytes.
|
||||
const bytes: usize = bw.bits() / 8;
|
||||
try testing.expectEqual(bytes * 3 - 1, hex.len);
|
||||
}
|
||||
}
|
||||
|
||||
test "formatFloat: very large number uses scientific notation" {
|
||||
var buf: [256]u8 = undefined;
|
||||
const result = formatFloat(&buf, 1.5e16);
|
||||
// Should use scientific notation for values > 1e15
|
||||
try testing.expect(std.mem.indexOf(u8, result.display, "e") != null or
|
||||
std.mem.indexOf(u8, result.display, "E") != null);
|
||||
}
|
||||
|
||||
test "formatFloat: very small number uses scientific notation" {
|
||||
var buf: [256]u8 = undefined;
|
||||
const result = formatFloat(&buf, 1.5e-16);
|
||||
try testing.expect(std.mem.indexOf(u8, result.display, "e") != null or
|
||||
std.mem.indexOf(u8, result.display, "E") != null);
|
||||
}
|
||||
|
||||
test "formatFloat: regular float" {
|
||||
var buf: [256]u8 = undefined;
|
||||
const result = formatFloat(&buf, 3.14159);
|
||||
try testing.expect(std.mem.indexOf(u8, result.display, "3.14") != null);
|
||||
}
|
||||
|
||||
fn hasChar(s: []const u8, c: u8) bool {
|
||||
return std.mem.indexOfScalar(u8, s, c) != null;
|
||||
}
|
||||
|
||||
test "formatCompactFloat: normal magnitudes use fixed-point" {
|
||||
var buf: [64]u8 = undefined;
|
||||
try testing.expect(!hasChar(formatCompactFloat(&buf, 1.0), 'e'));
|
||||
try testing.expect(!hasChar(formatCompactFloat(&buf, 3.14), 'e'));
|
||||
try testing.expect(!hasChar(formatCompactFloat(&buf, 0.5), 'e'));
|
||||
try testing.expect(!hasChar(formatCompactFloat(&buf, -2.0), 'e'));
|
||||
// Just above the small-magnitude threshold stays fixed-point
|
||||
try testing.expect(!hasChar(formatCompactFloat(&buf, 0.001), 'e'));
|
||||
}
|
||||
|
||||
test "formatCompactFloat: 3.14 renders exactly" {
|
||||
var buf: [64]u8 = undefined;
|
||||
try testing.expectEqualStrings("3.14", formatCompactFloat(&buf, 3.14));
|
||||
}
|
||||
|
||||
test "formatCompactFloat: preserves round-trip precision for normal magnitude" {
|
||||
var buf: [64]u8 = undefined;
|
||||
// 0.1 stored as f32 then widened: normal magnitude, so fixed-point and full
|
||||
const v: f64 = @floatCast(@as(f32, 0.1));
|
||||
const s = formatCompactFloat(&buf, v);
|
||||
try testing.expect(!hasChar(s, 'e'));
|
||||
try testing.expect(std.mem.startsWith(u8, s, "0.100000001"));
|
||||
}
|
||||
|
||||
test "formatCompactFloat: very small magnitudes use scientific" {
|
||||
var buf: [64]u8 = undefined;
|
||||
// smallest f32 subnormal ~ 1.4e-45
|
||||
try testing.expect(hasChar(formatCompactFloat(&buf, std.math.ldexp(@as(f64, 1.0), -149)), 'e'));
|
||||
// f32 ULP of 1.0 = 2^-23 ~ 1.19e-7
|
||||
try testing.expect(hasChar(formatCompactFloat(&buf, std.math.ldexp(@as(f64, 1.0), -23)), 'e'));
|
||||
// f64 ULP of 1.0 = 2^-52 ~ 2.2e-16
|
||||
try testing.expect(hasChar(formatCompactFloat(&buf, std.math.ldexp(@as(f64, 1.0), -52)), 'e'));
|
||||
}
|
||||
|
||||
test "formatCompactFloat: very large magnitudes use scientific" {
|
||||
var buf: [64]u8 = undefined;
|
||||
try testing.expect(hasChar(formatCompactFloat(&buf, 1e20), 'e'));
|
||||
try testing.expect(hasChar(formatCompactFloat(&buf, -1e18), 'e'));
|
||||
}
|
||||
|
||||
test "formatCompactFloat: zero is fixed-point, not scientific" {
|
||||
var buf: [64]u8 = undefined;
|
||||
try testing.expectEqualStrings("0", formatCompactFloat(&buf, 0.0));
|
||||
}
|
||||
|
||||
test "formatCompactFloat: non-finite values" {
|
||||
var buf: [64]u8 = undefined;
|
||||
try testing.expectEqualStrings("inf", formatCompactFloat(&buf, std.math.inf(f64)));
|
||||
try testing.expectEqualStrings("-inf", formatCompactFloat(&buf, -std.math.inf(f64)));
|
||||
try testing.expectEqualStrings("nan", formatCompactFloat(&buf, std.math.nan(f64)));
|
||||
}
|
||||
|
||||
// -- formatNumber (exact display) --
|
||||
|
||||
fn expectNumberDisplay(expected_display: []const u8, expected_exact: bool, value: Number) !void {
|
||||
const shown = try formatNumber(testing.allocator, value);
|
||||
defer shown.deinit(testing.allocator);
|
||||
try testing.expectEqualStrings(expected_display, shown.display);
|
||||
try testing.expectEqual(expected_exact, shown.exact);
|
||||
}
|
||||
|
||||
test "formatNumber: exact small integer" {
|
||||
var n = try Number.fromInt(testing.allocator, 42);
|
||||
defer n.deinit();
|
||||
try expectNumberDisplay("42", true, n);
|
||||
}
|
||||
|
||||
test "formatNumber: exact integer gets comma grouping" {
|
||||
var n = try Number.fromInt(testing.allocator, 4294967295);
|
||||
defer n.deinit();
|
||||
try expectNumberDisplay("4,294,967,295", true, n);
|
||||
|
||||
const shown = try formatNumber(testing.allocator, n);
|
||||
defer shown.deinit(testing.allocator);
|
||||
// The clipboard form keeps no separators.
|
||||
try testing.expectEqualStrings("4294967295", shown.raw);
|
||||
}
|
||||
|
||||
test "formatNumber: the integer f64 cannot represent survives intact" {
|
||||
// The whole point of the exact tier: this must NOT become
|
||||
// 9.007199254740992e15.
|
||||
var n = try Number.parse(testing.allocator, "9007199254740993");
|
||||
defer n.deinit();
|
||||
try expectNumberDisplay("9,007,199,254,740,993", true, n);
|
||||
}
|
||||
|
||||
test "formatNumber: huge exact integers print in full, never scientific" {
|
||||
var n = try Number.parse(testing.allocator, "123456789012345678901234567890");
|
||||
defer n.deinit();
|
||||
try expectNumberDisplay("123,456,789,012,345,678,901,234,567,890", true, n);
|
||||
}
|
||||
|
||||
test "formatNumber: negative exact integer" {
|
||||
var n = try Number.fromInt(testing.allocator, -1234567);
|
||||
defer n.deinit();
|
||||
try expectNumberDisplay("-1,234,567", true, n);
|
||||
}
|
||||
|
||||
test "formatNumber: exact terminating fraction" {
|
||||
var n = try Number.parse(testing.allocator, "0.125");
|
||||
defer n.deinit();
|
||||
try expectNumberDisplay("0.125", true, n);
|
||||
}
|
||||
|
||||
test "formatNumber: exact terminating fraction with a grouped integer part" {
|
||||
var n = try Number.parse(testing.allocator, "1234567.25");
|
||||
defer n.deinit();
|
||||
try expectNumberDisplay("1,234,567.25", true, n);
|
||||
}
|
||||
|
||||
test "formatNumber: 0.1 + 0.2 renders as 0.3 exactly" {
|
||||
const alloc = testing.allocator;
|
||||
var a = try Number.parse(alloc, "0.1");
|
||||
defer a.deinit();
|
||||
var b = try Number.parse(alloc, "0.2");
|
||||
defer b.deinit();
|
||||
var sum = try Number.add(alloc, a, b);
|
||||
defer sum.deinit();
|
||||
try expectNumberDisplay("0.3", true, sum);
|
||||
}
|
||||
|
||||
test "formatNumber: repeating expansion is rounded and flagged approximate" {
|
||||
const alloc = testing.allocator;
|
||||
var one = try Number.fromInt(alloc, 1);
|
||||
defer one.deinit();
|
||||
var three = try Number.fromInt(alloc, 3);
|
||||
defer three.deinit();
|
||||
var third = try Number.div(alloc, one, three);
|
||||
defer third.deinit();
|
||||
|
||||
const shown = try formatNumber(alloc, third);
|
||||
defer shown.deinit(alloc);
|
||||
try testing.expect(!shown.exact);
|
||||
try testing.expect(std.mem.startsWith(u8, shown.display, "0.3333333333"));
|
||||
try testing.expectEqual(exact_fraction_digits + 2, shown.display.len); // "0." + digits
|
||||
}
|
||||
|
||||
test "formatNumber: inexact values are always flagged approximate" {
|
||||
var n = Number.fromFloat(0.5);
|
||||
defer n.deinit();
|
||||
try expectNumberDisplay("0.5", false, n);
|
||||
|
||||
var whole = Number.fromFloat(42.0);
|
||||
defer whole.deinit();
|
||||
try expectNumberDisplay("42", false, whole);
|
||||
}
|
||||
|
||||
test "formatNumber: negative zero and zero" {
|
||||
var z = try Number.fromInt(testing.allocator, 0);
|
||||
defer z.deinit();
|
||||
try expectNumberDisplay("0", true, z);
|
||||
}
|
||||
|
||||
test "groupDecimalText: boundaries around the grouping threshold" {
|
||||
const alloc = testing.allocator;
|
||||
const cases = [_][2][]const u8{
|
||||
.{ "1", "1" },
|
||||
.{ "12", "12" },
|
||||
.{ "123", "123" },
|
||||
.{ "1234", "1,234" },
|
||||
.{ "12345", "12,345" },
|
||||
.{ "123456", "123,456" },
|
||||
.{ "1234567", "1,234,567" },
|
||||
.{ "-1234567", "-1,234,567" },
|
||||
.{ "1234.5678", "1,234.5678" },
|
||||
.{ "-1234.5", "-1,234.5" },
|
||||
.{ "0.123456789", "0.123456789" },
|
||||
};
|
||||
for (cases) |c| {
|
||||
const got = try groupDecimalText(alloc, c[0]);
|
||||
defer alloc.free(got);
|
||||
try testing.expectEqualStrings(c[1], got);
|
||||
}
|
||||
}
|
||||
|
||||
// -- Display cap for very long exact values (NFR-7) --
|
||||
|
||||
test "formatNumber: exact integers at the cap still print in full" {
|
||||
// 2^128 is 39 digits, inside the cap, and is a value the exact tier exists
|
||||
// to serve.
|
||||
var n = try Number.parse(testing.allocator, "340282366920938463463374607431768211456");
|
||||
defer n.deinit();
|
||||
const shown = try formatNumber(testing.allocator, n);
|
||||
defer shown.deinit(testing.allocator);
|
||||
try testing.expect(shown.exact);
|
||||
try testing.expectEqualStrings("340,282,366,920,938,463,463,374,607,431,768,211,456", shown.display);
|
||||
}
|
||||
|
||||
test "formatNumber: past the cap the display abbreviates but raw stays exact" {
|
||||
const alloc = testing.allocator;
|
||||
// 1e50: 51 digits, past the cap.
|
||||
var n = try Number.parse(alloc, "1e50");
|
||||
defer n.deinit();
|
||||
const shown = try formatNumber(alloc, n);
|
||||
defer shown.deinit(alloc);
|
||||
|
||||
try testing.expectEqualStrings("1e50", shown.display);
|
||||
// The exact value is never lost, just not shown inline.
|
||||
try testing.expectEqual(@as(usize, 51), shown.raw.len);
|
||||
try testing.expectEqualStrings("1", shown.raw[0..1]);
|
||||
// The abbreviated text is not the full value, so it is flagged.
|
||||
try testing.expect(!shown.exact);
|
||||
}
|
||||
|
||||
test "formatNumber: abbreviation rounds the mantissa" {
|
||||
const alloc = testing.allocator;
|
||||
// 41 nines: rounds up and carries all the way into a new power of ten.
|
||||
var n = try Number.parse(alloc, "99999999999999999999999999999999999999999");
|
||||
defer n.deinit();
|
||||
const shown = try formatNumber(alloc, n);
|
||||
defer shown.deinit(alloc);
|
||||
try testing.expectEqualStrings("1e41", shown.display);
|
||||
}
|
||||
|
||||
test "formatNumber: abbreviation rounds a middle digit without carrying" {
|
||||
const alloc = testing.allocator;
|
||||
// 41 digits whose 18th is 8, so the 17th significant digit rounds 7 -> 8
|
||||
// with no carry propagation.
|
||||
var n = try Number.parse(alloc, "12345678901234567800000000000000000000000");
|
||||
defer n.deinit();
|
||||
const shown = try formatNumber(alloc, n);
|
||||
defer shown.deinit(alloc);
|
||||
try testing.expectEqualStrings("1.2345678901234568e40", shown.display);
|
||||
}
|
||||
|
||||
test "formatNumber: abbreviation rounds down when the next digit is below five" {
|
||||
const alloc = testing.allocator;
|
||||
var n = try Number.parse(alloc, "12345678901234567400000000000000000000000");
|
||||
defer n.deinit();
|
||||
const shown = try formatNumber(alloc, n);
|
||||
defer shown.deinit(alloc);
|
||||
try testing.expectEqualStrings("1.2345678901234567e40", shown.display);
|
||||
}
|
||||
|
||||
test "formatNumber: negative values past the cap keep their sign" {
|
||||
const alloc = testing.allocator;
|
||||
var n = try Number.parse(alloc, "-1.5e60");
|
||||
defer n.deinit();
|
||||
const shown = try formatNumber(alloc, n);
|
||||
defer shown.deinit(alloc);
|
||||
try testing.expectEqualStrings("-1.5e60", shown.display);
|
||||
}
|
||||
|
||||
test "formatNumber: 9007199254740993 is above NFR-7's f64 bound but must print in full" {
|
||||
// This is the case where NFR-7's 10^15 scientific-notation bound would be
|
||||
// actively wrong: the value exceeds it, but abbreviating would reintroduce
|
||||
// the exact bug NFR-9.1 forbids.
|
||||
const alloc = testing.allocator;
|
||||
var n = try Number.parse(alloc, "9007199254740993");
|
||||
defer n.deinit();
|
||||
const shown = try formatNumber(alloc, n);
|
||||
defer shown.deinit(alloc);
|
||||
try testing.expect(shown.exact);
|
||||
try testing.expectEqualStrings("9,007,199,254,740,993", shown.display);
|
||||
try testing.expectEqualStrings("9007199254740993", shown.raw);
|
||||
}
|
||||
|
||||
test "abbreviated huge values go through the same renderer as tiny ones" {
|
||||
// This case used to have its own text-based renderer. Both ends of the range
|
||||
// now use Rational.toScientificString, so this checks the shared path from the
|
||||
// formatter's side: mantissa trimming, sign, and the exponent.
|
||||
const alloc = testing.allocator;
|
||||
const cases = [_][2][]const u8{
|
||||
// 41 digits, one past max_display_integer_digits.
|
||||
.{ "10000000000000000000000000000000000000000", "1e40" },
|
||||
.{ "12000000000000000000000000000000000000000", "1.2e40" },
|
||||
.{ "-25000000000000000000000000000000000000000", "-2.5e40" },
|
||||
};
|
||||
for (cases) |c| {
|
||||
var value = try Number.parse(alloc, c[0]);
|
||||
defer value.deinit();
|
||||
const shown = try formatNumber(alloc, value);
|
||||
defer shown.deinit(alloc);
|
||||
try testing.expectEqualStrings(c[1], shown.display);
|
||||
// The clipboard form still carries every digit.
|
||||
try testing.expectEqualStrings(c[0], shown.raw);
|
||||
try testing.expect(!shown.exact);
|
||||
}
|
||||
}
|
||||
|
||||
test "integerDigitCount ignores sign and fraction" {
|
||||
try testing.expectEqual(@as(usize, 3), grouping.integerDigitCount("123"));
|
||||
try testing.expectEqual(@as(usize, 3), grouping.integerDigitCount("-123"));
|
||||
try testing.expectEqual(@as(usize, 3), grouping.integerDigitCount("123.456"));
|
||||
try testing.expectEqual(@as(usize, 1), grouping.integerDigitCount("0.5"));
|
||||
}
|
||||
|
||||
// -- Grouping of values with a fractional part --
|
||||
//
|
||||
// Previously only whole numbers were grouped, so a financial result read as
|
||||
// `231677.04` while the same magnitude as an integer read as `231,677`. The
|
||||
// spec (design.md 2.6) asks for full decimal with commas, and FR-1.8 makes the
|
||||
// grouped form valid input again, so the integer part is grouped either way.
|
||||
|
||||
test "formatFloat: fractional value groups its integer part" {
|
||||
var buf: [256]u8 = undefined;
|
||||
const result = formatFloat(&buf, 231677.04);
|
||||
try testing.expectEqualStrings("231,677.04", result.display);
|
||||
try testing.expectEqualStrings("231677.04", result.raw);
|
||||
}
|
||||
|
||||
test "formatFloat: raw form never carries separators" {
|
||||
var buf: [256]u8 = undefined;
|
||||
const result = formatFloat(&buf, 1234567.891);
|
||||
try testing.expectEqualStrings("1,234,567.891", result.display);
|
||||
try testing.expectEqualStrings("1234567.891", result.raw);
|
||||
try testing.expect(std.mem.indexOfScalar(u8, result.raw, ',') == null);
|
||||
}
|
||||
|
||||
test "formatFloat: negative fractional value" {
|
||||
var buf: [256]u8 = undefined;
|
||||
const result = formatFloat(&buf, -9876543.21);
|
||||
try testing.expectEqualStrings("-9,876,543.21", result.display);
|
||||
try testing.expectEqualStrings("-9876543.21", result.raw);
|
||||
}
|
||||
|
||||
test "formatFloat: fewer than four integer digits is left alone" {
|
||||
var buf: [256]u8 = undefined;
|
||||
// Display and raw are the same slice in this case, which is intentional:
|
||||
// there is nothing to group, so there is no reason to copy.
|
||||
const small = formatFloat(&buf, 123.456);
|
||||
try testing.expectEqualStrings("123.456", small.display);
|
||||
try testing.expectEqualStrings("123.456", small.raw);
|
||||
|
||||
const sub_one = formatFloat(&buf, 0.5);
|
||||
try testing.expectEqualStrings("0.5", sub_one.display);
|
||||
|
||||
const boundary = formatFloat(&buf, 999.99);
|
||||
try testing.expectEqualStrings("999.99", boundary.display);
|
||||
}
|
||||
|
||||
test "formatFloat: grouping starts at four integer digits" {
|
||||
var buf: [256]u8 = undefined;
|
||||
const result = formatFloat(&buf, 1000.25);
|
||||
try testing.expectEqualStrings("1,000.25", result.display);
|
||||
}
|
||||
|
||||
test "formatFloat: scientific notation is not grouped" {
|
||||
var buf: [256]u8 = undefined;
|
||||
// Above 1e15 the float path switches to scientific, where commas would only
|
||||
// corrupt the exponent.
|
||||
const big = formatFloat(&buf, 1.234e20);
|
||||
try testing.expect(std.mem.indexOfScalar(u8, big.display, ',') == null);
|
||||
try testing.expect(std.mem.indexOfAny(u8, big.display, "eE") != null);
|
||||
|
||||
const tiny = formatFloat(&buf, 1.5e-20);
|
||||
try testing.expect(std.mem.indexOfScalar(u8, tiny.display, ',') == null);
|
||||
}
|
||||
|
||||
test "formatFloat: a buffer too small for both forms falls back to the raw text" {
|
||||
// Just enough for "1234567.891" but not for a grouped second copy.
|
||||
var buf: [12]u8 = undefined;
|
||||
const result = formatFloat(&buf, 1234567.891);
|
||||
try testing.expectEqualStrings("1234567.891", result.raw);
|
||||
try testing.expectEqualStrings("1234567.891", result.display);
|
||||
}
|
||||
|
||||
test "formatNumber: an inexact fractional result is grouped too" {
|
||||
var value = Number.fromFloat(231677.04);
|
||||
defer value.deinit();
|
||||
const shown = try formatNumber(testing.allocator, value);
|
||||
defer shown.deinit(testing.allocator);
|
||||
try testing.expectEqualStrings("231,677.04", shown.display);
|
||||
try testing.expectEqualStrings("231677.04", shown.raw);
|
||||
try testing.expect(!shown.exact);
|
||||
}
|
||||
|
||||
test "formatNumber: an exact fractional result was already grouped and still is" {
|
||||
var value = try Number.parse(testing.allocator, "1234567.891");
|
||||
defer value.deinit();
|
||||
const shown = try formatNumber(testing.allocator, value);
|
||||
defer shown.deinit(testing.allocator);
|
||||
try testing.expectEqualStrings("1,234,567.891", shown.display);
|
||||
try testing.expectEqualStrings("1234567.891", shown.raw);
|
||||
try testing.expect(shown.exact);
|
||||
}
|
||||
|
||||
test "groupedDecimalLen: agrees with what writeGroupedDecimal writes" {
|
||||
const cases = [_][]const u8{
|
||||
"0",
|
||||
"999",
|
||||
"1000",
|
||||
"-1234",
|
||||
"1234567.891",
|
||||
"-9876543.21",
|
||||
"0.5",
|
||||
"1000000000000",
|
||||
"1.5e+20",
|
||||
};
|
||||
var buf: [64]u8 = undefined;
|
||||
for (cases) |text| {
|
||||
const len = groupedDecimalLen(text);
|
||||
if (len == text.len) continue;
|
||||
try testing.expectEqual(len, writeGroupedDecimal(&buf, text));
|
||||
}
|
||||
}
|
||||
|
||||
test "grouped display re-parses to the same value" {
|
||||
// FR-1.8 accepts commas as digit separators, so the display form is valid
|
||||
// input. This is what makes grouping safe to apply to results.
|
||||
var buf: [256]u8 = undefined;
|
||||
const result = formatFloat(&buf, 1234567.891);
|
||||
var reparsed = try Number.parse(testing.allocator, "1234567.891");
|
||||
defer reparsed.deinit();
|
||||
try testing.expectEqualStrings("1,234,567.891", result.display);
|
||||
try testing.expectApproxEqAbs(@as(f64, 1234567.891), reparsed.toFloat(testing.allocator), 1e-9);
|
||||
}
|
||||
|
||||
// -- Exact values too small for the fractional budget --
|
||||
//
|
||||
// The exact path renders 20 fractional digits, so anything below 1e-20 came out
|
||||
// as "0.00000000000000000000" in `display` AND in `raw`. That destroyed the value
|
||||
// at the last step, in the one tier whose entire purpose is not doing that, and it
|
||||
// made the exact tier display strictly worse than the inexact one.
|
||||
|
||||
test "formatNumber: an exact value below the fractional budget uses scientific notation" {
|
||||
// 2^-70, exactly representable, equal to 8.470329472543003e-22.
|
||||
var value = try Number.parse(testing.allocator, "1");
|
||||
defer value.deinit();
|
||||
var divisor = try Number.parse(testing.allocator, "1180591620717411303424");
|
||||
defer divisor.deinit();
|
||||
var tiny = try Number.div(testing.allocator, value, divisor);
|
||||
defer tiny.deinit();
|
||||
|
||||
const shown = try formatNumber(testing.allocator, tiny);
|
||||
defer shown.deinit(testing.allocator);
|
||||
try testing.expectEqualStrings("8.4703294725430034e-22", shown.display);
|
||||
// The clipboard form must not be zero either.
|
||||
try testing.expectEqualStrings("8.4703294725430034e-22", shown.raw);
|
||||
// Rounded to 17 significant digits, so the text is not exact even though the
|
||||
// value is.
|
||||
try testing.expect(!shown.exact);
|
||||
}
|
||||
|
||||
test "formatNumber: a small exact value keeps its sign" {
|
||||
var numerator = try Number.parse(testing.allocator, "-1");
|
||||
defer numerator.deinit();
|
||||
var divisor = try Number.parse(testing.allocator, "1180591620717411303424");
|
||||
defer divisor.deinit();
|
||||
var tiny = try Number.div(testing.allocator, numerator, divisor);
|
||||
defer tiny.deinit();
|
||||
|
||||
const shown = try formatNumber(testing.allocator, tiny);
|
||||
defer shown.deinit(testing.allocator);
|
||||
try testing.expect(shown.display[0] == '-');
|
||||
try testing.expectEqualStrings("-8.4703294725430034e-22", shown.display);
|
||||
}
|
||||
|
||||
test "formatNumber: exact zero is still zero, not scientific" {
|
||||
var zero = try Number.parse(testing.allocator, "0");
|
||||
defer zero.deinit();
|
||||
const shown = try formatNumber(testing.allocator, zero);
|
||||
defer shown.deinit(testing.allocator);
|
||||
try testing.expectEqualStrings("0", shown.display);
|
||||
try testing.expectEqualStrings("0", shown.raw);
|
||||
try testing.expect(shown.exact);
|
||||
}
|
||||
|
||||
test "formatNumber: values just inside the budget still print fixed point" {
|
||||
// 1e-20 is the last magnitude the 20-digit budget can show.
|
||||
var value = try Number.parse(testing.allocator, "0.00000000000000000001");
|
||||
defer value.deinit();
|
||||
const shown = try formatNumber(testing.allocator, value);
|
||||
defer shown.deinit(testing.allocator);
|
||||
try testing.expectEqualStrings("0.00000000000000000001", shown.display);
|
||||
try testing.expect(shown.exact);
|
||||
}
|
||||
|
||||
test "isZeroText: recognises every spelling of zero" {
|
||||
try testing.expect(isZeroText("0"));
|
||||
try testing.expect(isZeroText("0.00"));
|
||||
try testing.expect(isZeroText("-0.00000000000000000000"));
|
||||
try testing.expect(!isZeroText("0.00000000000000000001"));
|
||||
try testing.expect(!isZeroText("10.00"));
|
||||
try testing.expect(!isZeroText("-0.5"));
|
||||
}
|
||||
//
|
||||
// This logic existed three times: character for character in src/main.zig and
|
||||
// src/tui/financial.zig, each reimplementing the comma grouping that already lived
|
||||
// in this file. Both copies also returned the string "?" when the buffer was too
|
||||
// small, so `tally amort 1e40 0.5 3` printed a table of question marks and exited 0.
|
||||
|
||||
test "formatMoney: grouping, sign and two decimals" {
|
||||
var buf: [64]u8 = undefined;
|
||||
try testing.expectEqualStrings("0.00", formatMoney(&buf, 0).?);
|
||||
try testing.expectEqualStrings("199.10", formatMoney(&buf, 199.1).?);
|
||||
try testing.expectEqualStrings("1,199.10", formatMoney(&buf, 1199.1).?);
|
||||
try testing.expectEqualStrings("200,000.00", formatMoney(&buf, 200000).?);
|
||||
try testing.expectEqualStrings("231,677.04", formatMoney(&buf, 231677.04).?);
|
||||
try testing.expectEqualStrings("1,234,567.89", formatMoney(&buf, 1234567.89).?);
|
||||
try testing.expectEqualStrings("-1,199.10", formatMoney(&buf, -1199.1).?);
|
||||
try testing.expectEqualStrings("-0.01", formatMoney(&buf, -0.01).?);
|
||||
}
|
||||
|
||||
test "formatMoney: rounds to the cent" {
|
||||
var buf: [64]u8 = undefined;
|
||||
try testing.expectEqualStrings("1,199.10", formatMoney(&buf, 1199.101050305518).?);
|
||||
try testing.expectEqualStrings("2.00", formatMoney(&buf, 1.995).?);
|
||||
}
|
||||
|
||||
test "formatMoney: reports failure instead of a placeholder" {
|
||||
var tiny: [4]u8 = undefined;
|
||||
try testing.expect(formatMoney(&tiny, 1234567.89) == null);
|
||||
// Non-finite values have no amount rendering at all.
|
||||
var buf: [64]u8 = undefined;
|
||||
try testing.expect(formatMoney(&buf, std.math.inf(f64)) == null);
|
||||
try testing.expect(formatMoney(&buf, std.math.nan(f64)) == null);
|
||||
}
|
||||
|
||||
test "formatMoney: very large amounts render or fail cleanly, never partially" {
|
||||
var buf: [64]u8 = undefined;
|
||||
// 1e40 needs 41 integer digits, 13 separators and cents: 57 bytes, so it fits.
|
||||
const forty = formatMoney(&buf, 1e40).?;
|
||||
try testing.expectEqual(@as(usize, 57), forty.len);
|
||||
try testing.expect(std.mem.startsWith(u8, forty, "10,000,000,000"));
|
||||
try testing.expect(std.mem.endsWith(u8, forty, ".00"));
|
||||
|
||||
// 1e300 needs 404 bytes, so the same buffer must refuse rather than truncate.
|
||||
try testing.expect(formatMoney(&buf, 1e300) == null);
|
||||
var wide: [512]u8 = undefined;
|
||||
const huge = formatMoney(&wide, 1e300).?;
|
||||
try testing.expect(std.mem.endsWith(u8, huge, ".00"));
|
||||
}
|
||||
|
||||
test "formatAmount: other decimal counts" {
|
||||
var buf: [64]u8 = undefined;
|
||||
try testing.expectEqualStrings("1,000", formatAmount(&buf, 1000.4, 0).?);
|
||||
try testing.expectEqualStrings("1,000.4", formatAmount(&buf, 1000.44, 1).?);
|
||||
try testing.expectEqualStrings("1,000.44", formatAmount(&buf, 1000.44, 2).?);
|
||||
}
|
||||
|
||||
test "formatMoney: agrees with the grouping used for ordinary results" {
|
||||
// The whole point of collapsing these: an amount and a plain result group the
|
||||
// same way.
|
||||
var money_buf: [64]u8 = undefined;
|
||||
var value_buf: [256]u8 = undefined;
|
||||
const as_money = formatMoney(&money_buf, 231677).?;
|
||||
const as_value = formatFloat(&value_buf, 231677).display;
|
||||
try testing.expectEqualStrings("231,677.00", as_money);
|
||||
try testing.expectEqualStrings("231,677", as_value);
|
||||
// Same separators, differing only in the fixed decimal places.
|
||||
try testing.expect(std.mem.startsWith(u8, as_money, as_value));
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
//! The thousands rule, in one place.
|
||||
//!
|
||||
//! Both display paths group digits: `Integer` groups the decimal readings of a
|
||||
//! fixed-width value, and `formatter` groups floats and exact `Number`s. Those two
|
||||
//! fixed-width value, and `Number` groups floats and exact rationals. Those two
|
||||
//! each worked out where a sign ended and where the fraction began, and disagreed, so
|
||||
//! `-1234.56` grouped differently from `1234.56`. This is the shared answer.
|
||||
//!
|
||||
|
|
@ -77,7 +77,7 @@ pub fn lengthOf(text: []const u8) usize {
|
|||
return text.len + (parts.int_digits - 1) / 3;
|
||||
}
|
||||
|
||||
/// Digits before the decimal point, ignoring any sign. The formatter uses this to
|
||||
/// Digits before the decimal point, ignoring any sign. `Number.render` uses this to
|
||||
/// decide when an exact value is too wide to show in full.
|
||||
pub fn integerDigitCount(text: []const u8) usize {
|
||||
std.debug.assert(isNumericText(text));
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -655,9 +655,9 @@ fn convertExactInner(
|
|||
to: UnitDef,
|
||||
) number_mod.Error!Number {
|
||||
// base = value * from.factor + from.offset
|
||||
var from_factor = Number.fromRational(try Rational.parse(allocator, from.factor_text.?));
|
||||
var from_factor = Number.fromRational(allocator, try Rational.parse(allocator, from.factor_text.?));
|
||||
defer from_factor.deinit();
|
||||
var from_offset = Number.fromRational(try Rational.parse(allocator, from.offset_text.?));
|
||||
var from_offset = Number.fromRational(allocator, try Rational.parse(allocator, from.offset_text.?));
|
||||
defer from_offset.deinit();
|
||||
|
||||
var scaled = try Number.mul(allocator, value, from_factor);
|
||||
|
|
@ -666,9 +666,9 @@ fn convertExactInner(
|
|||
defer base.deinit();
|
||||
|
||||
// result = (base - to.offset) / to.factor
|
||||
var to_factor = Number.fromRational(try Rational.parse(allocator, to.factor_text.?));
|
||||
var to_factor = Number.fromRational(allocator, try Rational.parse(allocator, to.factor_text.?));
|
||||
defer to_factor.deinit();
|
||||
var to_offset = Number.fromRational(try Rational.parse(allocator, to.offset_text.?));
|
||||
var to_offset = Number.fromRational(allocator, try Rational.parse(allocator, to.offset_text.?));
|
||||
defer to_offset.deinit();
|
||||
|
||||
var shifted = try Number.sub(allocator, base, to_offset);
|
||||
|
|
@ -1263,8 +1263,8 @@ fn expectExactConversion(expected: []const u8, value_text: []const u8, from_name
|
|||
var result = try convertExactUnits(alloc, value, from, to);
|
||||
defer result.deinit();
|
||||
|
||||
try testing.expect(result.isExact());
|
||||
const shown = try result.toDecimalString(alloc, 30);
|
||||
try testing.expect(result == .exact);
|
||||
const shown = try result.exact.toDecimalString(alloc, 30);
|
||||
defer alloc.free(shown.text);
|
||||
try testing.expectEqualStrings(expected, shown.text);
|
||||
try testing.expect(shown.exact);
|
||||
|
|
@ -1339,7 +1339,7 @@ test "exact: pi-based angle units fall back to inexact" {
|
|||
defer result.deinit();
|
||||
|
||||
// Degrees are defined via pi, so no exact rational answer exists.
|
||||
try testing.expect(!result.isExact());
|
||||
try testing.expect(result != .exact);
|
||||
try testing.expectApproxEqAbs(std.math.pi, result.toFloat(alloc), 1e-15);
|
||||
}
|
||||
|
||||
|
|
@ -1355,7 +1355,7 @@ test "exact: an inexact input stays inexact even between exact units" {
|
|||
defer value.deinit();
|
||||
var result = try convertExactUnits(alloc, value, findUnit("in").?, findUnit("ft").?);
|
||||
defer result.deinit();
|
||||
try testing.expect(!result.isExact());
|
||||
try testing.expect(result != .exact);
|
||||
}
|
||||
|
||||
test "exact: same unit is an exact identity" {
|
||||
|
|
@ -1380,12 +1380,12 @@ test "exact: non-terminating conversions are exact values with rounded display"
|
|||
defer result.deinit();
|
||||
|
||||
// The VALUE is exact even though its decimal expansion does not terminate.
|
||||
try testing.expect(result.isExact());
|
||||
const frac = (try result.toFractionString(alloc)).?;
|
||||
try testing.expect(result == .exact);
|
||||
const frac = try result.exact.toFractionString(alloc);
|
||||
defer alloc.free(frac);
|
||||
try testing.expectEqualStrings("781250/12573", frac);
|
||||
|
||||
const shown = try result.toDecimalString(alloc, 9);
|
||||
const shown = try result.exact.toDecimalString(alloc, 9);
|
||||
defer alloc.free(shown.text);
|
||||
try testing.expect(!shown.exact);
|
||||
try testing.expectEqualStrings("62.137119224", shown.text);
|
||||
|
|
@ -1411,7 +1411,7 @@ test "exact: every unit pair within a category round-trips EXACTLY" {
|
|||
var back = try convertExactUnits(alloc, forward, b, a);
|
||||
defer back.deinit();
|
||||
|
||||
try testing.expect(back.isExact());
|
||||
try testing.expect(back == .exact);
|
||||
if (!try Number.eql(alloc, value, back)) {
|
||||
std.debug.print("round trip {s} -> {s} -> {s} was not exact\n", .{ a.name, b.name, a.name });
|
||||
return error.InexactRoundTrip;
|
||||
|
|
|
|||
91
src/main.zig
91
src/main.zig
|
|
@ -15,6 +15,19 @@ pub const CliResult = struct {
|
|||
/// because `-p` is the only mode flag; the TUI's four tabs are its own enum.
|
||||
pub const Mode = enum { standard, programmer };
|
||||
|
||||
/// How this frontend renders a number.
|
||||
///
|
||||
/// The engine has no default and no digit constants of its own: a terminal, a
|
||||
/// clipboard and an Android screen want different budgets, so each frontend states
|
||||
/// its own (NFR-9.9, "display precision is a separate decision from compute
|
||||
/// precision"). These are the values the engine used to hold.
|
||||
const display_format: engine.Number.FormatOptions = .{
|
||||
.fraction_digits = 20,
|
||||
.max_integer_digits = 40,
|
||||
.significant_digits = 17,
|
||||
.separators = true,
|
||||
};
|
||||
|
||||
/// Parse CLI args and determine the expression and mode.
|
||||
/// Returns the joined expression and mode, or an error/help output.
|
||||
pub const ParsedArgs = union(enum) {
|
||||
|
|
@ -393,17 +406,17 @@ fn formatConversionUnits(
|
|||
};
|
||||
defer converted.deinit();
|
||||
|
||||
const shown_in = engine.formatter.formatNumber(allocator, value) catch {
|
||||
const shown_in = value.render(allocator, display_format) catch {
|
||||
return .{ .output = "error: out of memory\n", .is_error = true };
|
||||
};
|
||||
defer shown_in.deinit(allocator);
|
||||
const shown_out = engine.formatter.formatNumber(allocator, converted) catch {
|
||||
const shown_out = converted.render(allocator, display_format) catch {
|
||||
return .{ .output = "error: out of memory\n", .is_error = true };
|
||||
};
|
||||
defer shown_out.deinit(allocator);
|
||||
|
||||
const output = std.fmt.bufPrint(buf, "{s} {s} = {s} {s}", .{
|
||||
shown_in.display, from.name, shown_out.display, to.name,
|
||||
shown_in.text, from.name, shown_out.text, to.name,
|
||||
}) catch {
|
||||
return .{ .output = "error: buffer overflow\n", .is_error = true };
|
||||
};
|
||||
|
|
@ -456,21 +469,24 @@ pub fn evaluateWith(
|
|||
// Enrich with multi-base view when the expression used non-decimal
|
||||
// literals and the result is a non-negative integer.
|
||||
if (info.has_nondecimal_literal and isDisplayableInt(info.value.toFloat(allocator))) {
|
||||
var base_buf: [4096]u8 = undefined;
|
||||
const decimal = engine.formatter.formatFloat(&base_buf, info.value.toFloat(allocator));
|
||||
return formatStandardMultiBase(buf, decimal.display, info.value.toFloat(allocator));
|
||||
const decimal = info.value.render(allocator, display_format) catch {
|
||||
return .{ .output = "error: out of memory\n", .is_error = true };
|
||||
};
|
||||
defer decimal.deinit(allocator);
|
||||
return formatStandardMultiBase(buf, decimal.text, info.value.toFloat(allocator));
|
||||
}
|
||||
|
||||
const shown = engine.formatter.formatNumber(allocator, info.value) catch {
|
||||
const shown = info.value.render(allocator, display_format) catch {
|
||||
return .{ .output = "error: out of memory\n", .is_error = true };
|
||||
};
|
||||
defer shown.deinit(allocator);
|
||||
// Copy into the caller's buffer so the result does not depend on the
|
||||
// allocator outliving this call.
|
||||
if (shown.display.len > buf.len) {
|
||||
if (shown.text.len > buf.len) {
|
||||
return .{ .output = "error: result too long to display\n", .is_error = true };
|
||||
}
|
||||
@memcpy(buf[0..shown.display.len], shown.display);
|
||||
return .{ .output = buf[0..shown.display.len], .is_error = false };
|
||||
@memcpy(buf[0..shown.text.len], shown.text);
|
||||
return .{ .output = buf[0..shown.text.len], .is_error = false };
|
||||
}
|
||||
|
||||
/// True if the f64 is a non-negative integer within u128 range.
|
||||
|
|
@ -484,19 +500,16 @@ fn formatStandardMultiBase(buf: []u8, dec_display: []const u8, value: f64) CliRe
|
|||
const int_val: u128 = @intFromFloat(value);
|
||||
const int: engine.Integer = .{
|
||||
.raw = int_val,
|
||||
.width = engine.formatter.displayWidthFor(int_val),
|
||||
.width = engine.Integer.displayWidthFor(int_val),
|
||||
.signedness = .unsigned,
|
||||
};
|
||||
|
||||
// dec_display points into buf, so copy it out before we overwrite buf.
|
||||
var dec_copy: [128]u8 = undefined;
|
||||
const dec_len = @min(dec_display.len, dec_copy.len);
|
||||
@memcpy(dec_copy[0..dec_len], dec_display[0..dec_len]);
|
||||
|
||||
// The rows print themselves, so nothing here needs a buffer per base.
|
||||
// `dec_display` is the caller's allocated text rather than a slice of `buf`,
|
||||
// so writing into `buf` cannot clobber it and it needs no copy first.
|
||||
var w = std.Io.Writer.fixed(buf);
|
||||
w.print("{s}\n hex: {f}\n oct: {f}\n bin: {f}", .{
|
||||
dec_copy[0..dec_len],
|
||||
dec_display,
|
||||
int.fmt(.hex, .{}),
|
||||
int.fmt(.octal, .{}),
|
||||
int.fmt(.binary, .{}),
|
||||
|
|
@ -681,17 +694,17 @@ pub fn formatAmortization(
|
|||
var out = std.ArrayList(u8).empty;
|
||||
errdefer out.deinit(allocator);
|
||||
var line: [256]u8 = undefined;
|
||||
var money: [64]u8 = undefined;
|
||||
var money_buf: [64]u8 = undefined;
|
||||
|
||||
const header = std.fmt.bufPrint(&line, "{s} at {d}% per period over {d} periods\n", .{
|
||||
formatMoney(&money, params.principal) orelse return unformattableResult(),
|
||||
money(params.principal).render(&money_buf) catch return unformattableResult(),
|
||||
params.rate,
|
||||
params.periods,
|
||||
}) catch return .{ .output = "error: buffer overflow\n", .is_error = true };
|
||||
out.appendSlice(allocator, header) catch return oomResult();
|
||||
|
||||
const payment_line = std.fmt.bufPrint(&line, "Payment {s} per period\n\n", .{
|
||||
formatMoney(&money, payment) orelse return unformattableResult(),
|
||||
money(payment).render(&money_buf) catch return unformattableResult(),
|
||||
}) catch return .{ .output = "error: buffer overflow\n", .is_error = true };
|
||||
out.appendSlice(allocator, payment_line) catch return oomResult();
|
||||
|
||||
|
|
@ -707,10 +720,10 @@ pub fn formatAmortization(
|
|||
var bal_buf: [64]u8 = undefined;
|
||||
const row_text = std.fmt.bufPrint(&line, "{d: >6} {s: >12} {s: >12} {s: >12} {s: >12}\n", .{
|
||||
row.period,
|
||||
formatMoney(&pay_buf, row.payment) orelse return unformattableResult(),
|
||||
formatMoney(&int_buf, row.interest) orelse return unformattableResult(),
|
||||
formatMoney(&prin_buf, row.principal) orelse return unformattableResult(),
|
||||
formatMoney(&bal_buf, row.balance) orelse return unformattableResult(),
|
||||
money(row.payment).render(&pay_buf) catch return unformattableResult(),
|
||||
money(row.interest).render(&int_buf) catch return unformattableResult(),
|
||||
money(row.principal).render(&prin_buf) catch return unformattableResult(),
|
||||
money(row.balance).render(&bal_buf) catch return unformattableResult(),
|
||||
}) catch return .{ .output = "error: buffer overflow\n", .is_error = true };
|
||||
out.appendSlice(allocator, row_text) catch return oomResult();
|
||||
}
|
||||
|
|
@ -728,9 +741,9 @@ pub fn formatAmortization(
|
|||
"Periods paid {d}\nTotal paid {s}\nTotal interest {s}\nPrincipal {s}",
|
||||
.{
|
||||
totals.periods,
|
||||
formatMoney(&paid_buf, totals.paid) orelse return unformattableResult(),
|
||||
formatMoney(&interest_buf, totals.interest) orelse return unformattableResult(),
|
||||
formatMoney(&principal_buf, totals.principal) orelse return unformattableResult(),
|
||||
money(totals.paid).render(&paid_buf) catch return unformattableResult(),
|
||||
money(totals.interest).render(&interest_buf) catch return unformattableResult(),
|
||||
money(totals.principal).render(&principal_buf) catch return unformattableResult(),
|
||||
},
|
||||
) catch return .{ .output = "error: buffer overflow\n", .is_error = true };
|
||||
out.appendSlice(allocator, summary) catch return oomResult();
|
||||
|
|
@ -739,9 +752,10 @@ pub fn formatAmortization(
|
|||
return .{ .output = text, .is_error = false };
|
||||
}
|
||||
|
||||
/// The engine formatter, so the CLI, the TUI and the engine all group amounts the
|
||||
/// same way.
|
||||
const formatMoney = engine.formatter.formatMoney;
|
||||
/// The engine's amount formatter, so the CLI, the TUI and the engine all group
|
||||
/// amounts the same way. Two decimals and grouping are properties of money rather
|
||||
/// than of a screen, so unlike `display_format` there is no budget to pass.
|
||||
const money = engine.financial.money;
|
||||
|
||||
/// An amount too large to render. Reported rather than printed as a placeholder:
|
||||
/// the previous local formatter returned "?" for these, so a table of question
|
||||
|
|
@ -1536,23 +1550,6 @@ test "parseArgs: amort rejects incomplete or malformed terms" {
|
|||
try testing.expect(zero == .output and zero.output.is_error);
|
||||
}
|
||||
|
||||
test "formatMoney: the CLI uses the engine formatter, not its own copy" {
|
||||
// This lived in main.zig character for character alongside a second copy in
|
||||
// src/tui/financial.zig. These cases now exercise engine.formatter.formatMoney.
|
||||
var buf: [48]u8 = undefined;
|
||||
try testing.expectEqualStrings("0.00", formatMoney(&buf, 0).?);
|
||||
try testing.expectEqualStrings("199.10", formatMoney(&buf, 199.1).?);
|
||||
try testing.expectEqualStrings("1,199.10", formatMoney(&buf, 1199.1).?);
|
||||
try testing.expectEqualStrings("200,000.00", formatMoney(&buf, 200000).?);
|
||||
try testing.expectEqualStrings("1,234,567.89", formatMoney(&buf, 1234567.89).?);
|
||||
try testing.expectEqualStrings("-1,199.10", formatMoney(&buf, -1199.1).?);
|
||||
}
|
||||
|
||||
test "formatMoney: an amount that does not fit is reported, not rendered" {
|
||||
var tiny: [4]u8 = undefined;
|
||||
try testing.expect(formatMoney(&tiny, 1234567.89) == null);
|
||||
}
|
||||
|
||||
test "formatAmortization: an unrenderable amount is an error, not a table of marks" {
|
||||
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
|
||||
defer _ = arena.deinit();
|
||||
|
|
|
|||
43
src/tui.zig
43
src/tui.zig
|
|
@ -22,6 +22,33 @@ const Allocator = std.mem.Allocator;
|
|||
|
||||
const Mode = enum { standard, programmer, financial, convert };
|
||||
|
||||
/// How the TUI renders a number.
|
||||
///
|
||||
/// The engine holds no digit budget of its own, so this is the terminal's answer to
|
||||
/// NFR-9.9: 20 fractional digits, and full digits up to 40 of them before a value
|
||||
/// abbreviates to scientific notation. The views share it, and a value copied to
|
||||
/// the clipboard will ask for `clipboard_format` instead.
|
||||
pub const display_format: engine.Number.FormatOptions = .{
|
||||
.fraction_digits = 20,
|
||||
.max_integer_digits = 40,
|
||||
.significant_digits = 17,
|
||||
.separators = true,
|
||||
};
|
||||
|
||||
/// A single line with no separators, for a value shown beside other text rather
|
||||
/// than as the result: the float view's rows and the convert view's factor.
|
||||
///
|
||||
/// 17 fractional digits is every digit an f64 actually has, so a value whose
|
||||
/// shortest form fits shows in full (an f32-rounded 0.1 reads
|
||||
/// "0.100000001490116"), and one that does not, such as a subnormal ULP, goes
|
||||
/// scientific instead of spending the row on 45 digits.
|
||||
pub const compact_format: engine.Number.FormatOptions = .{
|
||||
.fraction_digits = 17,
|
||||
.max_integer_digits = 16,
|
||||
.significant_digits = 17,
|
||||
.separators = false,
|
||||
};
|
||||
|
||||
/// Which column of the convert view has keyboard focus.
|
||||
pub const ConvZone = enum { category, from, to };
|
||||
|
||||
|
|
@ -1034,9 +1061,9 @@ pub const App = struct {
|
|||
// Exact results render in full, so an exact integer past f64's 2^53
|
||||
// limit reaches the user intact instead of collapsing to scientific
|
||||
// notation.
|
||||
const shown = try engine.formatter.formatNumber(self.allocator, info.value);
|
||||
const shown = try info.value.render(self.allocator, display_format);
|
||||
defer shown.deinit(self.allocator);
|
||||
const result_copy = try self.allocator.dupe(u8, shown.display);
|
||||
const result_copy = try self.allocator.dupe(u8, shown.text);
|
||||
|
||||
var details: ?[3][]const u8 = null;
|
||||
const as_float = info.value.toFloat(self.allocator);
|
||||
|
|
@ -1047,7 +1074,7 @@ pub const App = struct {
|
|||
const int_val: u128 = @intFromFloat(as_float);
|
||||
const int: engine.Integer = .{
|
||||
.raw = int_val,
|
||||
.width = engine.formatter.displayWidthFor(int_val),
|
||||
.width = engine.Integer.displayWidthFor(int_val),
|
||||
.signedness = .unsigned,
|
||||
};
|
||||
details = .{
|
||||
|
|
@ -1106,13 +1133,13 @@ pub const App = struct {
|
|||
self.conv_value.deinit();
|
||||
self.conv_value = value;
|
||||
|
||||
const shown_in = try engine.formatter.formatNumber(self.allocator, self.conv_value);
|
||||
const shown_in = try self.conv_value.render(self.allocator, display_format);
|
||||
defer shown_in.deinit(self.allocator);
|
||||
const shown_out = try engine.formatter.formatNumber(self.allocator, converted);
|
||||
const shown_out = try converted.render(self.allocator, display_format);
|
||||
defer shown_out.deinit(self.allocator);
|
||||
|
||||
const result = try std.fmt.allocPrint(self.allocator, "{s} {s} = {s} {s}", .{
|
||||
shown_in.display, pair.from.name, shown_out.display, pair.to.name,
|
||||
shown_in.text, pair.from.name, shown_out.text, pair.to.name,
|
||||
});
|
||||
try self.history.append(self.allocator, .{ .expr = expr_text, .result = result, .is_error = false });
|
||||
}
|
||||
|
|
@ -1133,9 +1160,9 @@ pub const App = struct {
|
|||
};
|
||||
defer converted.deinit();
|
||||
|
||||
const shown = try engine.formatter.formatNumber(self.allocator, converted);
|
||||
const shown = try converted.render(self.allocator, display_format);
|
||||
defer shown.deinit(self.allocator);
|
||||
const result = try std.fmt.allocPrint(self.allocator, "{s} {s}", .{ shown.display, request.to.name });
|
||||
const result = try std.fmt.allocPrint(self.allocator, "{s} {s}", .{ shown.text, request.to.name });
|
||||
try self.history.append(self.allocator, .{ .expr = expr_text, .result = result, .is_error = false });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -62,29 +62,29 @@ pub fn drawConvertMode(app: *tui.App, surface: *vxfw.Surface, width: u16, height
|
|||
app.conv_value.clone() catch return;
|
||||
defer converted.deinit();
|
||||
|
||||
const shown_in = engine.formatter.formatNumber(app.allocator, app.conv_value) catch return;
|
||||
const shown_in = app.conv_value.render(app.allocator, tui.display_format) catch return;
|
||||
defer shown_in.deinit(app.allocator);
|
||||
const shown_out = engine.formatter.formatNumber(app.allocator, converted) catch return;
|
||||
const shown_out = converted.render(app.allocator, tui.display_format) catch return;
|
||||
defer shown_out.deinit(app.allocator);
|
||||
|
||||
var line_buf: [192]u8 = undefined;
|
||||
const input_line = std.fmt.bufPrint(&line_buf, "{s} {s}", .{ shown_in.display, pair.from.name }) catch "?";
|
||||
const input_line = std.fmt.bufPrint(&line_buf, "{s} {s}", .{ shown_in.text, pair.from.name }) catch "?";
|
||||
draw.writeStr(surface, row, 2, input_line, .{ .fg = C.fg });
|
||||
row += 1;
|
||||
|
||||
var result_buf: [192]u8 = undefined;
|
||||
const result_line = std.fmt.bufPrint(&result_buf, "= {s} {s}", .{ shown_out.display, pair.to.name }) catch "?";
|
||||
const result_line = std.fmt.bufPrint(&result_buf, "= {s} {s}", .{ shown_out.text, pair.to.name }) catch "?";
|
||||
draw.writeStr(surface, row, 2, result_line, .{ .fg = C.green, .bold = true });
|
||||
row += 1;
|
||||
|
||||
// -- Conversion factor (linear conversions only) --
|
||||
if (pair.from.isLinear() and pair.to.isLinear()) {
|
||||
const factor = pair.from.to_base_factor / pair.to.to_base_factor;
|
||||
var factor_val_buf: [64]u8 = undefined;
|
||||
const factor_str = engine.formatter.formatCompactFloat(&factor_val_buf, factor);
|
||||
const factor_shown = engine.Number.fromFloat(factor).render(app.allocator, tui.compact_format) catch return;
|
||||
defer factor_shown.deinit(app.allocator);
|
||||
var factor_buf: [160]u8 = undefined;
|
||||
const factor_line = std.fmt.bufPrint(&factor_buf, "1 {s} = {s} {s}", .{
|
||||
pair.from.name, factor_str, pair.to.name,
|
||||
pair.from.name, factor_shown.text, pair.to.name,
|
||||
}) catch "?";
|
||||
draw.writeStr(surface, row, 2, factor_line, .{ .fg = C.muted });
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ const test_render = @import("test_render.zig");
|
|||
const C = draw.C;
|
||||
|
||||
const financial = engine.financial;
|
||||
const formatter = engine.formatter;
|
||||
const grouping = engine.grouping;
|
||||
|
||||
/// Which calculation the form is showing.
|
||||
|
|
@ -869,14 +868,14 @@ fn substitutedFormula(state: *const State, buf: []u8) ?[]const u8 {
|
|||
};
|
||||
}
|
||||
|
||||
/// Format an amount with grouping and two decimals, via the engine formatter so
|
||||
/// the CLI table and this view cannot diverge.
|
||||
/// Format an amount with grouping and two decimals, via the engine so the CLI
|
||||
/// table and this view cannot diverge.
|
||||
///
|
||||
/// Drawing cannot fail, so a value too large to render becomes "(too large)"
|
||||
/// rather than being dropped. The CLI reports it as an error instead, because a
|
||||
/// command can exit non-zero and a frame cannot.
|
||||
fn money(buf: []u8, value: f64) []const u8 {
|
||||
return formatter.formatMoney(buf, value) orelse "(too large)";
|
||||
return engine.financial.money(value).render(buf) catch "(too large)";
|
||||
}
|
||||
|
||||
/// Error text for this view.
|
||||
|
|
|
|||
|
|
@ -14,6 +14,19 @@ const C = draw.C;
|
|||
|
||||
const fi = engine.float_interp;
|
||||
|
||||
/// Render a float for a single row.
|
||||
///
|
||||
/// Drawing cannot fail and should not need the heap, so this hands
|
||||
/// `Number.render` a fixed buffer over the caller's stack: a value too long for
|
||||
/// the row becomes "?" rather than a partial line. The budget is the TUI's, not
|
||||
/// the engine's.
|
||||
fn compact(buf: []u8, value: f64) []const u8 {
|
||||
var fba: std.heap.FixedBufferAllocator = .init(buf);
|
||||
const shown = engine.Number.fromFloat(value).render(fba.allocator(), tui.compact_format) catch
|
||||
return "?";
|
||||
return shown.text;
|
||||
}
|
||||
|
||||
/// Which IEEE 754 field a given bit belongs to.
|
||||
const Field = enum { sign, exponent, significand };
|
||||
|
||||
|
|
@ -76,7 +89,7 @@ pub fn drawFloatView(app: *tui.App, surface: *vxfw.Surface, width: u16, height:
|
|||
|
||||
// Value
|
||||
var val_buf: [64]u8 = undefined;
|
||||
const val_str = engine.formatter.formatCompactFloat(&val_buf, info.value);
|
||||
const val_str = compact(&val_buf, info.value);
|
||||
draw.writeStr(surface, row, 2, "Value:", .{ .fg = C.cyan });
|
||||
draw.writeStr(surface, row, 12, val_str, .{ .fg = C.fg, .bold = true });
|
||||
row += 1;
|
||||
|
|
@ -111,7 +124,7 @@ pub fn drawFloatView(app: *tui.App, surface: *vxfw.Surface, width: u16, height:
|
|||
const ulp_str: []const u8 = if (std.math.isNan(info.ulp))
|
||||
"n/a"
|
||||
else
|
||||
engine.formatter.formatCompactFloat(&ulp_buf, info.ulp);
|
||||
compact(&ulp_buf, info.ulp);
|
||||
draw.writeStr(surface, row, 12, ulp_str, .{ .fg = C.fg });
|
||||
row += 1;
|
||||
row += 1;
|
||||
|
|
|
|||
|
|
@ -208,7 +208,7 @@ fn registerConfigRegions(app: *tui.App, row: u16, col: u16, text: []const u8) vo
|
|||
/// is exactly one bit). Otherwise a click just moves the cursor to that digit,
|
||||
/// since "toggling" a multi-bit nibble or octal digit has no single meaning.
|
||||
///
|
||||
/// `text` is a formatter `display` string, which is digits and spaces only. This
|
||||
/// `text` is a grouped display string, which is digits and spaces only. This
|
||||
/// used to begin by skipping a `0x`/`0o`/`0b` prefix; only the `raw` strings carry
|
||||
/// one, so that branch never ran.
|
||||
fn registerDigitRegions(
|
||||
|
|
@ -250,7 +250,7 @@ fn registerDigitRegions(
|
|||
/// Draw a field's display string with a cursor highlighting the digit at bit_cursor position.
|
||||
/// `bits_per_digit` is 4 for hex, 3 for oct, 1 for bin.
|
||||
///
|
||||
/// `text` is a formatter `display` string: digits and spaces, never a `0x`/`0o`/`0b`
|
||||
/// `text` is a grouped display string: digits and spaces, never a `0x`/`0o`/`0b`
|
||||
/// prefix. This used to skip a prefix and draw it unhighlighted, which was dead
|
||||
/// code in both this function and `registerDigitRegions`.
|
||||
fn drawFieldWithCursor(surface: *vxfw.Surface, row: u16, col: u16, text: []const u8, bit_cursor: u7, bits_per_digit: u8, total_bits: u8, color: vaxis.Cell.Color) void {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue