diff --git a/.kiro/specs/calculator/design.md b/.kiro/specs/calculator/design.md index 018a968..c7663cb 100644 --- a/.kiro/specs/calculator/design.md +++ b/.kiro/specs/calculator/design.md @@ -67,7 +67,7 @@ build.zig (workspace root) | `programmer.zig` | Integer operations, base conversion, bit manipulation | | `struct_layout.zig` | Struct DSL parser, layout computation, ABI profiles | | `units.zig` | Unit conversion tables and resolver | -| `financial.zig` | CAGR, TVM, compound interest | +| `financial.zig` | CAGR, TVM, compound interest, amortization | | `types.zig` | Shared types (Value, Error, etc.) | | `engine.zig` | Public API surface (Zig-native) | | `c_api.zig` | `extern "C"` wrappers for JNI/FFI consumers | @@ -190,7 +190,7 @@ pub const Environment = struct { }; ``` -Standard mode evaluates to `f64`. Programmer mode evaluates to `Integer` (exact, truncated to bit width). Financial functions return `f64` with optional step-by-step breakdown. +Standard mode evaluates to `Number`, the exact/inexact union of section 2.7. Programmer mode evaluates to `Integer` (exact, truncated to bit width). Financial functions compute in `f64` and enter the expression language as inexact `Number` values (section 5.6). ### 2.6 Number Display Formatting @@ -201,9 +201,18 @@ The engine provides raw values; frontends apply display formatting. The engine i | Context | Display | Clipboard | |---------|---------|-----------| | Standard result | `4,294,967,295` | `4294967295` | +| Fractional result | `231,677.04` | `231677.04` | | Financial result | `$1,234,567.89` | `1234567.89` | | Large decimals | `4,294,967,295` (never scientific unless > 15 digits of precision) | `4294967295` | +**Grouping applies to the integer part regardless of a fractional part.** Commas +group the digits left of the decimal point and never appear to the right of it, +so `231677.04` displays as `231,677.04` rather than ungrouped. Grouping only +whole numbers would make the same magnitude read two different ways depending on +whether cents happened to be present, which showed up immediately in financial +output. Because FR-1.8 accepts commas as input digit separators, the grouped +display remains valid input. Text already in scientific notation is left alone. + **Scientific notation threshold**: Only use scientific notation when the number exceeds what can be meaningfully displayed in decimal (> 15 significant digits, or absolute value > 10^15 or < 10^-15). Otherwise, always show full decimal with commas. This is a deliberate departure from calculators that jump to scientific notation at 10+ digits. #### Programmer Mode Formatting @@ -863,7 +872,40 @@ answers. CAGR = (end_value / start_value) ^ (1 / periods) - 1 ``` -### 5.2 TVM (Time Value of Money) +### 5.2 Compound Interest + +``` +FV = PV * (1 + rate/100/m)^(m * years) +``` + +Solvable for any of its four variables, all in closed form: + +``` +PV = FV / (1 + rate/100/m)^(m*years) +rate = m * ((FV/PV)^(1/(m*years)) - 1) * 100 +years = ln(FV/PV) / (m * ln(1 + rate/100/m)) +``` + +`m` (compounds per year) is deliberately NOT solvable. As `m` rises, FV climbs +from the annual case toward the continuous limit `PV * e^(rate*years/100)`, so a +solution exists only inside that band: for 10,000 at 6% over 10 years, only for +future values between about 17,908 and 18,221. Nearly every real input has no +answer, and a fractional answer ("compound 7.3 times a year") would be +meaningless, so it stays an input with a prefilled default of 1. + +**Nominal versus effective.** `compoundRate` returns a NOMINAL annual rate: at +`m = 12` it is the APR a lender quotes, not what the money earned. +`effectiveAnnualRate` converts (18% nominal monthly is 19.56% effective), and the +TUI shows both whenever `m > 1`. This only matters when solving, because a rate +that was typed in is one the user already understands; a rate the calculator +produces is one they are about to compare against something else. + +Solving for the rate at `m = 1` is exactly CAGR. Both entry points are kept: CAGR +is a named thing people look for, and it takes periods rather than years so it +works for quarters or months without a frequency argument. A test asserts the two +agree, so they cannot drift. + +### 5.3 TVM (Time Value of Money) The five TVM variables: N (periods), I/Y (interest rate per period), PV (present value), PMT (payment), FV (future value). @@ -878,6 +920,87 @@ Solving for the unknown variable: - **N**: logarithmic solution - **I/Y**: Newton-Raphson iterative solver (no closed form) +Sign convention is the cash-flow one used by financial calculators: money +received is positive, money paid out is negative. A borrower taking a 200,000 +loan has `pv = 200000` and a negative `pmt`. The solver never flips signs on the +caller's behalf, because a silent flip is harder to debug than a wrong-looking +answer. + +The rate solver takes its derivative by central difference rather than +analytically: the annuity-due form of the derivative is unwieldy and the +numerical version is not the accuracy bottleneck. Two details make it reliable: + +1. **Scale-relative acceptance.** The residual of the TVM equation is + proportional to the size of the cash flows, so it is compared against + `tolerance * max(|pv|, |fv|, |pmt| * n, 1)`. With an absolute tolerance a + 200,000 mortgage cannot converge at all, because floating-point noise in the + residual exceeds any fixed epsilon. +2. **Multiple seeds.** The residual can be flat or badly sloped near a poor + initial guess, so the solver retries from a small set of starting rates + (5%, 1%, 10%, 0.5%, 25%, -5%, 50%). A single seed fails on well-posed inputs. + +### 5.4 Amortization + +An amortization schedule is generated period by period from the balance rather +than from a closed form, because that is the only way the rounding matches what a +lender's table shows. Each period: + +``` +interest = round(balance * r) +principal = payment - interest +balance = balance - principal +``` + +Rows are read from the borrower's side, so every figure is positive: `payment` is +an amount paid, `balance` an amount owed. Mixing in the TVM sign convention only +makes a table harder to read. + +Three behaviors are deliberate: + +- **The final period settles the balance exactly.** Cent rounding leaves a + residue over hundreds of periods, so the last payment differs slightly from the + rest. This is what real schedules do, and it is why the total of the principal + column equals the loan amount to the cent. +- **An overlarge payment retires the loan early**, and the schedule simply ends + short of `periods`. +- **An underfunded term ends in a balloon.** A payment that covers the interest + but not the full term leaves a large final payment, which is shown rather than + smoothed away. A payment that does not cover even the first period's interest + never amortizes at all, so it is a domain error rather than an infinite loop. + +Single rows (`amortizationEntry`) and totals (`amortizationTotals`) walk the same +cursor as the full schedule, so a row fetched on its own can never disagree with +the same row inside a table. Only the full schedule allocates. + +### 5.5 Reachability: expression functions, not just a mode + +Financial calculations are exposed as ordinary expression functions in standard +mode, mirroring how unit conversion is reachable from a bare expression. That +keeps them composable (`cagr(10000, 25000, 5) * 100`) and usable from the CLI +without a subcommand per formula. See FR-5.7 for the function list. + +This required the evaluator's function dispatch to grow beyond the 0/1/2-argument +cases it previously handled; financial functions take three or four arguments. An +unknown name at those arities still reports `UnknownFunction`, and a wrong arity +for a known financial function does too, rather than filling in defaults. + +The one exception is the amortization table itself: a table is not a value, so it +lives behind the `tally amort` subcommand (FR-6.11). The per-period figures are +still reachable as functions (`amort_interest(...)` and friends). + +### 5.6 Precision: f64 plus explicit rounding + +This module is f64, not the exact `Number` tier used elsewhere (design 2.7). +Every formula here needs a non-integer power or a logarithm - CAGR raises to +`1/n`, the period solver takes `ln`, the rate solver iterates - and those escape +the rationals by definition, so there is nothing for the exact tier to preserve. + +What money needs is not exactness but controlled rounding, which is a separate +concern: `roundToScale(value, decimals, mode)` and `roundToCents` are applied at +the point a figure becomes an amount. The default is half-even (banker's +rounding); half-up is available but is not the default because it biases totals +upward across many roundings. + --- ## 6. C API (for Android/FFI) @@ -951,8 +1074,7 @@ Design notes: tally [OPTIONS] tally struct [OPTIONS] tally convert -tally cagr -tally tvm [--n N] [--rate R] [--pv PV] [--pmt PMT] [--fv FV] +tally amort [payment] [--monthly] [--summary] [--exact] Options: -p, --programmer Programmer mode @@ -1022,6 +1144,28 @@ $ tally "5 kg + 3 lb" ## 8. TUI Layout +### 8.0.0 How the TUI is tested + +Terminal code is usually left uncovered on the grounds that it needs a terminal. +Most of it does not. Two harnesses cover nearly all of it: + +- **Rendered frames.** `src/tui/test_render.zig` draws a real frame through the + real widget draw path into a real surface, then reads the cells back as rows of + text. Assertions are made against what a user would see (`"= PMT -1,199.10"`, + `"rows 1-7 of 360"`), plus a structural check that every row is exactly the + requested width, since the drawing helpers clip silently rather than erroring + when a column is miscomputed. Views are exercised at 20x6 through 200x60 to + catch layouts that only work at one size. +- **Real event handlers.** `vxfw.EventContext` is constructible in a test, so key + presses and mouse events go through `handleKey` and `handleMouse` rather than + through a reimplementation of them. This is what catches a binding that was + never routed, or one mode swallowing another mode's keys. + +What is left uncovered is the terminal boundary itself: the event loop, `vaxis` +initialisation, and `main`. Coverage is reported per source tree (engine, CLI, +TUI) with disjoint include paths, so a file is never accounted for twice and a +query tool never has to guess which report answers for it. + ### 8.0 Mouse Input Architecture The TUI is mouse-driven as well as keyboard-driven (FR-7.11). Rather than each @@ -1036,6 +1180,7 @@ pub const Action = union(enum) { focus_input, close_help, toggle_float, cycle_width, toggle_endian, toggle_signedness, toggle_float_format, conv_category: UnitCategory, conv_from: usize, conv_to: usize, conv_swap, + fin_form: Form, fin_field: usize, // financial chips and rows }; pub const RegionSet = struct { @@ -1061,7 +1206,11 @@ Key properties: from cell to bit index is written once. `registerDigitRegions` mirrors `drawFieldWithCursor`'s digit walk for the same reason. - **Left press only.** Release, motion, and drag are ignored so one physical - click produces exactly one action. + click produces exactly one action. The wheel is the one exception: in financial + mode `wheel_up`/`wheel_down` scroll the amortization schedule, handled before + the press filter because a wheel event is not a press. The wheel does not go + through the region table at all, since it acts on the view rather than on a + specific cell. - **A fixed-size buffer, not an allocation.** Drawing happens on every keystroke and must not fail; an exhausted budget silently drops extra regions (a click does nothing) instead of erroring. 512 comfortably covers the worst case, the @@ -1300,6 +1449,136 @@ Interaction: --- +### 8.5 Financial Mode + +``` +Tally [Standard] [Programmer] [Financial] [Convert] + + Calculation: + CAGR Compound TVM Amortization + + > Principal 200,000 + Rate per period 0.5 % + Periods 360 + Payment (derived) + + Payment 1,199.10 | total interest 231,677.04 | total paid 431,677.04 + + Period Payment Interest Principal Balance + 1 1,199.10 1,000.00 199.10 199,800.90 + 2 1,199.10 999.00 200.10 199,600.80 + 3 1,199.10 998.00 201.10 199,399.70 + ... + rows 1-7 of 360 (PgUp/PgDn) + ------------------------------------------------------------------ + > + Up/Dn:field | L/R:calc | Enter:eval | Space:END/BGN | Ctrl-U:clear +``` + +Four forms behind one set of chips: CAGR, Compound, TVM, Amortization. The TVM +form adds a sixth row, an END/BGN toggle for annuity due. + +**Solving by omission.** TVM and compound interest solve for whichever field is +left blank, the way a financial calculator does and the way the engine's own +`TvmParams` is shaped. Blank is therefore a meaningful state rather than an error, +and a blank solve-for field renders as `[solve]` instead of looking like missing +input. When the form is not yet answerable it says what it is waiting for ("clear +one field to solve for it"), so an incomplete form is never a silent no-op. +Amortization treats a blank payment differently: it means "derive it", since a +schedule always has a payment. + +**Defaults are prefilled, not placeheld.** Compounds/year starts at `1` rather +than showing an empty field that means "annual". That keeps "blank means solve for +this" true for every field the user sees, instead of blank meaning a default in one +place and a solve slot in another. The prefill is baked into the comptime initial +state rather than applied at draw time, because a draw-time default cannot tell +"never touched" from "deliberately cleared". Clearing a non-solvable field has +to report that it needs a value, not try to solve for it. + +**A solved rate says which kind it is.** Above annual compounding the compound +form reports `= rate 18.00% nominal (19.56% effective)`. See design 5.2 for why: +the nominal figure alone invites a wrong comparison against a quoted APY, and this +is the only place the calculator produces a rate rather than consuming one. + +**Two ways to enter a number, deliberately.** Typing in the form zone goes +straight into the focused field, which is what makes quick entry work. The shared +input line at the bottom evaluates an expression and drops the result into the +focused field, so a value can also be computed from outside the form. + +**Fields hold expressions, and group as you type.** `12 * 30` is valid input for a +period count; it displays verbatim in orange with an `(Enter)` prompt, and Enter +evaluates it in place. A plain number is grouped for display as it is typed +(`200` then `0` shows `2,000`), while the buffer keeps the ungrouped text so it +still parses and still round-trips through Enter. Grouping the buffer instead +would mean stripping separators on every read and would make cursor arithmetic +depend on where the commas happen to fall. A typed comma is absorbed for the same +reason. + +A field holding an unevaluated expression is reported as such rather than being +treated as empty, since a blank field means "solve for this" in two of the four +forms and silently conflating the two would be a wrong answer rather than a +cosmetic problem. + +**Rates say they are rates.** A percent field renders a trailing `%` after the +value and absorbs a typed one, so `6%` and `6` both mean six percent. The label +carries that information too, but not once the eye has moved to the value column, +which is where a bare `6` is ambiguous between a rate and a multiplier. + +**Focus styling.** The focused field is a dark selection fill (Molokai's own +selection colour) with the value in its normal foreground, and a single yellow +block cursor. The earlier version reversed bright cyan into the background, which +read as a 1980s terminal and fought every other colour on screen; a neutral fill +keeps the number legible and leaves the cursor as the only bright cell on the row. +The row marker and focused label are yellow, matching the mode's own tab colour +rather than introducing a fourth accent. + +**Results are live, not history.** Every keystroke recomputes, so there is +nothing to submit and nothing is appended to history. That is why financial mode +is the one mode whose input line does not produce a history entry. + +**The schedule is the reason this mode exists.** Everything else here is +available as an expression function (FR-5.7), but a 360-row table is not a value. +It scrolls with PgUp/PgDn from either zone and with the mouse wheel, and shows +`rows X-Y of N` whenever it is showing a window rather than the whole thing. The +scroll offset is clamped at draw time rather than at scroll time, so a resize or a +changed period count cannot leave a stale offset showing an empty table. + +**Formula breakdown.** Under the result, the relationship being solved is shown, +plus a substituted line for the forms where it fits (`= (25000 / 10000)^(1/5) - +1`). TVM and amortization show the general form only; substituting five variables +into the TVM identity produces a line too long to be useful. + +Interaction, consistent with the other modes: + +- Backtick toggles the input zone and the form zone. +- Up/Down move between fields, wrapping. Left/Right switch calculation, so every + form is reachable from the keyboard alone (FR-7.7). +- Enter evaluates the focused field in place. +- Space toggles END/BGN on the payments row; Ctrl-U (or Delete) empties a field, + which is also how a TVM variable is marked as the one to solve for. +- Clicking a chip selects a calculation; clicking a row focuses that field. + Switching calculation resets the focused field and the scroll offset, but each + form keeps its own entries, so moving between them does not discard typing. + +### 8.6 Help Overlay + +The overlay is a static line list (headers, bindings, free text) rendered through +one windowed loop, not a sequence of sections that each gate themselves on the +remaining height. The old shape had a specific failure: every section ended with +`if (row < height - 6)`, so as sections were added the later ones silently +disappeared on a 24-row terminal with no indication anything was missing. That was +found by a test asserting the Convert section was present, which failed once the +Financial section was added above it. + +As a line list the content can be scrolled (Up/Down, PgUp/PgDn, wheel) and its +length is knowable without drawing, which is what lets the App clamp its own +scroll offset. Any non-scrolling key returns, preserving the "press a key to get +out" behaviour. The footer shows `lines X-Y of N` only when something is off +screen, so a tall terminal is not cluttered with a position indicator it does not +need. + +--- + ## 9. Android UI Design The Android app uses a fundamentally different interaction model from the TUI. Where the TUI is keyboard-driven with a prompt, Android is **touch-first with purpose-built input surfaces** for each mode. No text-cursor-in-a-prompt paradigm - instead, tappable buttons, interactive grids, and form fields. diff --git a/.kiro/specs/calculator/requirements.md b/.kiro/specs/calculator/requirements.md index d3e6718..0d9bb52 100644 --- a/.kiro/specs/calculator/requirements.md +++ b/.kiro/specs/calculator/requirements.md @@ -94,9 +94,14 @@ A calculator application with three frontends (CLI, TUI, Android) sharing a comm - **FR-5.1**: Compute CAGR given start value, end value, and number of periods. - **FR-5.2**: Compute compound interest (future value given principal, rate, periods, compounding frequency). +- **FR-5.2.1**: The compound-interest relationship is solvable for any of its four variables: present value, future value, nominal annual rate, or years. The compounding frequency is an input rather than a variable, because a solution for it only exists in the narrow band between annual and continuous compounding and a fractional answer would be meaningless. +- **FR-5.2.2**: A solved rate is a NOMINAL annual rate. When the compounding frequency exceeds 1 the effective annual rate (APY) must be reported alongside it, since a nominal rate compared against a quoted effective rate is a wrong comparison. At annual compounding the two coincide and only one figure is shown. - **FR-5.3**: Compute present value (discount future cash flow). - **FR-5.4**: Support TVM (Time Value of Money) solver - given any 4 of: N, I/Y, PV, PMT, FV, solve for the 5th. - **FR-5.5**: Display intermediate calculation steps/formula used (transparency). +- **FR-5.6**: Produce an amortization schedule for a loan: given principal, rate per period, and number of periods (with an optional payment override), report each period's payment, interest, principal, and remaining balance, plus totals. Figures round to whole cents by default, with the final period absorbing the rounding residue so the balance reaches exactly zero. A payment larger than the level payment retires the loan early; a payment that covers the interest but not the full term ends in a balloon payment; a payment that does not cover the first period's interest is an error rather than an infinite schedule. +- **FR-5.7**: Financial calculations must be reachable from a standard-mode expression as functions, not only from a dedicated mode, the same way unit conversion is reachable from a bare expression. Results compose with ordinary arithmetic, so `cagr(10000, 25000, 5) * 100` is a valid expression. The functions are: `cagr(start, end, periods)`, `fv(pv, rate, years[, per_year])`, `pv(fv, rate, years[, per_year])`, `compound_rate(pv, fv, years[, per_year])`, `compound_years(pv, fv, rate[, per_year])`, `apy(nominal_rate, per_year)`, `tvm_pmt`/`tvm_fv`/`tvm_pv`/`tvm_n`/`tvm_rate` (each taking the other four variables), `amort_payment(principal, rate, periods)`, `amort_interest`/`amort_principal`/`amort_balance` (`..., period`), and `amort_total_interest`/`amort_total_paid`. Anything the financial forms can solve must also be reachable this way, so the two surfaces do not drift apart. +- **FR-5.8**: Financial results are in the inexact tier (FR-4.22's exactness model does not apply): every formula needs a non-integer power or a logarithm, so there is no exact rational to preserve. Money is handled by explicit rounding at the point a figure becomes an amount, banker's rounding (half-even) by default so repeated rounding does not bias totals upward. ### FR-6: CLI Frontend @@ -110,15 +115,21 @@ A calculator application with three frontends (CLI, TUI, Android) sharing a comm - **FR-6.8**: Subcommand `tally convert ` for unit conversion. - **FR-6.9**: Output format flags: `--json` for machine-readable output, plain text default. - **FR-6.10**: Exit code 0 on success, non-zero on parse/evaluation errors with stderr message. +- **FR-6.11**: Subcommand `tally amort [payment]` prints an amortization schedule as a table. Flags: `--monthly` (the rate given is an annual nominal rate charged over monthly periods, so rate/12 applies per period), `--summary` (totals only, no per-period rows), `--exact` (skip cent rounding). A table is the one financial output that cannot be an expression function, which is why it is a subcommand. ### FR-7: TUI Frontend -- **FR-7.1**: Full-screen terminal UI with mode tabs (Standard, Programmer, Financial, Convert). +- **FR-7.1**: Full-screen terminal UI with mode tabs (Standard, Programmer, Financial, Convert). Tab moves to the next mode and Shift-Tab to the previous one, so a mis-hit costs one keystroke rather than a full lap. - **FR-7.2**: Standard mode: expression input line, result display, scrollable history. - **FR-7.3**: Programmer mode: bit grid (navigable with arrow keys, toggle with Space/Enter), simultaneous base displays, expression input. - **FR-7.4**: Programmer mode struct sub-view: field list editor, live-updating memory map visualization. -- **FR-7.5**: Financial mode: form-style input for parameters, result display with formula breakdown. +- **FR-7.5**: Financial mode: form-style input for parameters, result display with formula breakdown, and a scrollable amortization schedule. Four calculations behind one selector: CAGR, compound interest, TVM, and amortization. TVM and compound interest solve for whichever field is left blank; an unanswerable form reports what it is waiting for rather than doing nothing. Results are live, so financial mode records nothing in history. +- **FR-7.5.1**: Form fields group digits as they are typed: entering `200` then `0` displays `2,000`. The stored text stays ungrouped so it still parses, and a typed comma is accepted and discarded rather than corrupting the value. +- **FR-7.5.2**: Form fields accept expressions, not only numbers. `12 * 30` in a period count is valid input, shown verbatim while it is an expression and evaluated in place by Enter. A field holding an unevaluated expression is reported as such, so it is never mistaken for a blank field the form is waiting to solve for. A field that does not evaluate keeps its text rather than being silently cleared. +- **FR-7.5.3**: Rate fields display a trailing `%` after the value, and accept a typed `%` as a no-op, so `6%` and `6` mean the same thing. The label alone is not sufficient affordance once the eye is on the value column. +- **FR-7.5.4**: Where a default is near-universal, the form prefills it as a visible value rather than showing an empty field with a placeholder. Compounds/year starts at `1`. This keeps "blank means solve for this" true for every field the user sees, instead of blank meaning a default in one place and a solve slot in another. Clearing a prefilled non-solvable field asks for a value rather than for an answer. - **FR-7.6**: Convert mode: select category, input value, select from/to units, live result. +- **FR-7.6.1**: The help overlay scrolls (Up/Down, PgUp/PgDn, or the mouse wheel) and shows its position when content is off screen. Any other key returns. Sections are a single line list rather than individually height-gated draw calls, which is what previously caused later sections to be dropped silently on a normal-sized terminal. - **FR-7.7**: Every action must be reachable from the keyboard alone; the TUI is fully usable without a mouse. - **FR-7.8**: Quick-switch keys for base display in programmer mode (e.g., `d`=dec, `h`=hex, `o`=oct, `b`=bin to highlight primary). - **FR-7.9**: Support terminal resize gracefully. @@ -140,8 +151,9 @@ still required (FR-7.7): the mouse never becomes the only way to do something. - **FR-7.11.8**: In convert mode, clicking a category chip selects that category (resetting the unit pair to that category's defaults); clicking a unit in the From or To column selects it. - **FR-7.11.9**: Clicking the input line returns keyboard focus to the expression prompt. - **FR-7.11.10**: Clicking anywhere dismisses the help overlay, matching its "any key dismisses" keyboard behavior. -- **FR-7.11.11**: Only a left button press acts. Release, motion, and drag events are ignored so a single click cannot fire an action twice. +- **FR-7.11.11**: Only a left button press acts. Release, motion, and drag events are ignored so a single click cannot fire an action twice. The wheel is the one exception: in financial mode it scrolls the amortization schedule (FR-7.11.13). - **FR-7.11.12**: Clickable regions are rebuilt every frame from what was actually drawn, so hit targets can never drift out of sync with the display. +- **FR-7.11.13**: In financial mode, clicking a calculation chip selects that calculation and clicking a field row focuses it for editing; the mouse wheel scrolls the amortization schedule. In the help overlay the wheel scrolls and a click returns. ### FR-8: Android Frontend @@ -192,7 +204,8 @@ still required (FR-7.7): the mouse never becomes the only way to do something. - Engine must be testable in isolation (no I/O dependencies). - Struct layout results must be verifiable against `pahole` or compiler output for reference structs. - Engine test coverage target: >= 80% line coverage. The engine is pure computation with no I/O, so this is achievable and expected. -- Frontend coverage: best-effort via integration and smoke tests (TUI key-sequence tests, Android instrumented tests). No hard coverage target on frontend code. +- Frontend coverage is measured, not waived. The CLI and TUI each get their own instrumented coverage report alongside the engine's, with include paths kept disjoint so no file is accounted for twice. The TUI is testable further than "smoke tests" suggests: views are drawn into a surface and the cells read back, and key and mouse handling goes through the real handlers, so layout and binding regressions are caught rather than eyeballed. Target is the same >= 80% as the engine. +- What genuinely cannot be covered is the terminal itself: the event loop, `vaxis` setup, and the process entry point. Those are the only frontend paths exempt. - All public engine API functions must have corresponding unit tests covering happy path, error cases, and edge cases. ### NFR-6: Build & Distribution diff --git a/.kiro/specs/calculator/tasks.md b/.kiro/specs/calculator/tasks.md index 478e09a..60b9d1d 100644 --- a/.kiro/specs/calculator/tasks.md +++ b/.kiro/specs/calculator/tasks.md @@ -313,16 +313,36 @@ binary approximation before `convertUnits` did its own multiply and divide. - This affects the byte-map visualization labels, not the layout computation itself - Verify: unit test showing a u32 value's bytes in both endiannesses -### Task 2.5: Implement financial module -- Create `engine/src/financial.zig` -- Implement CAGR: `(end/start)^(1/n) - 1` -- Implement compound interest (future value): `PV * (1 + r/m)^(m*t)` -- Implement present value: `FV / (1 + r/m)^(m*t)` -- Implement TVM solver: - - Closed-form for FV, PV, PMT, N - - Newton-Raphson for I/Y (rate) with convergence check, max 1000 iterations, tolerance 1e-10 -- Return step-by-step formula string alongside numeric result -- Verify: unit tests against known financial calculation results (textbook examples) +### Task 2.5: Implement financial module [DONE] +- Created `engine/src/financial.zig` +- CAGR: `(end/start)^(1/n) - 1` +- Compound interest (future value): `PV * (1 + r/m)^(m*t)`; present value as its inverse +- TVM solver over `TvmParams`/`TvmSolution`: exactly one of the five variables is + null and gets solved for + - Closed-form for FV, PV, PMT; logarithmic for N + - Newton for I/Y, max 1000 iterations, tolerance 1e-10 +- `TvmVariable.label()` gives the calculator names (N, I/Y, PV, PMT, FV) +- Amortization (FR-5.6): `amortizationPayment`, `amortizationEntry`, + `amortizationSchedule`, `amortizationTotals`, sharing one cursor so a single row + cannot disagree with the same row in a table. Final period settles the balance + exactly; overlarge payments end early; underfunded terms balloon +- Money rounding: `roundToScale(value, decimals, mode)` and `roundToCents`, + half-even by default +- Reachable from standard-mode expressions (FR-5.7): `evalFunction` in + `evaluator.zig` gained 3- and 4-argument dispatch, and `evalFinancialFn` maps + the names `cagr`, `fv`, `pv`, `tvm_pmt`/`tvm_fv`/`tvm_pv`/`tvm_n`/`tvm_rate`, + `amort_payment`, `amort_interest`/`_principal`/`_balance`, + `amort_total_interest`/`_total_paid` +- CLI `tally amort [payment]` with `--monthly`, + `--summary`, `--exact` (FR-6.11), since a table is not an expression value +- DESIGN CHANGE from the original spec: f64 rather than the exact `Number` tier. + Every formula needs a non-integer power or a logarithm, so nothing exact + survives; money is handled by explicit rounding instead (design.md 5.6) +- DEFERRED from the original spec: the step-by-step formula string. The + expression-function route made the formula visible in the input itself, and the + breakdown belongs with the TUI financial form (Task 5.6) +- 60 financial tests plus 12 evaluator tests and 10 CLI tests; financial.zig is + 100% covered apart from an unreachable test-sweep guard ### Task 2.6: Implement unit conversion engine [DONE] - Created `engine/src/units.zig` @@ -446,6 +466,25 @@ Remaining subcommands deferred until their engine modules exist. - Verify: bare conversions, expression values, inch ambiguity, error paths, and unchanged behavior for ordinary expressions all confirmed by running the binary +### Task 4.5: Financial functions in expressions + amort subcommand [DONE] +- Financial math is reachable from any expression rather than only a mode + (FR-5.7), the same way conversion is: `tally 'cagr(10000, 25000, 5) * 100'` +- SUPERSEDES the originally planned `tally cagr` and `tally tvm` subcommands + (FR-6.6, FR-6.7). A subcommand per formula buys nothing once the functions + compose inside expressions, and flag-per-variable parsing is more to type than + `tvm_pmt(360, 0.5, 200000, 0)` +- `tally amort` (aliased `amortize`) is the exception, because a schedule is a + table rather than a value: `--monthly`, `--summary`, `--exact` flags, money + formatted with thousands separators and two decimals +- Domain errors from loan terms get a message that names the likely cause instead + of a bare "domain error" +- Help text lists the financial functions and both subcommands +- 10 CLI tests (arg parsing, flags, money formatting, table shape, error path) +- Verify: ran `tally amort 200000 6 360 --monthly` and confirmed the first row + (1,199.10 / 1,000.00 / 199.10 / 199,800.90), the balloon and early-payoff + variants, the zero-rate case, and a total interest of 231,677.04 against an + independent calculation + --- ## Phase 5: TUI Frontend @@ -561,13 +600,104 @@ Remaining subcommands deferred until their engine modules exist. - Transition: from programmer mode, press a key (e.g., `S`) to enter struct sub-view, Esc to return - Verify: can enter struct definition, see layout update, toggle ABI/endian -### Task 5.6: Implement financial mode TUI view -- Create `tui/src/views/financial.zig` -- Form-style interface: labeled input fields for each financial function -- Sub-mode selector: CAGR, Compound Interest, TVM -- For TVM: 5 input fields, leave one empty to solve -- Result display with formula breakdown -- Verify: can input values, compute results, see formula steps +### Task 5.6: Implement financial mode TUI view [DONE] +- Created `src/tui/financial.zig`; financial is now a top-level mode and Tab + cycles Standard -> Programmer -> Financial -> Convert +- Four forms behind one chip row: CAGR, Compound, TVM, Amortization, with a sixth + END/BGN row on TVM for annuity due +- TVM and compound solve for whichever field is left blank, matching the engine's + `TvmParams` convention; a blank solve-for field renders `[solve]` and an + unanswerable form says what it is waiting for instead of doing nothing +- Amortization shows payment and totals plus a scrollable schedule (FR-7.5): + PgUp/PgDn from either zone and the mouse wheel. The scroll offset is clamped at + draw time, so a resize or a changed period count cannot leave a stale offset + showing an empty table +- Two entry routes: digits typed in the form zone go into the focused field, and + the shared input line evaluates an expression into it (`30 * 12` -> 360), which + keeps the full expression language available inside a field +- Results are live, so financial mode is the one mode that appends nothing to + history +- Formula breakdown under the result: the general relationship always, plus a + substituted line for CAGR and compound where it fits on one row +- State and solving live in `financial.zig` as a pure `State` struct, so the + behavior is testable without a terminal; `App` embeds one and forwards keys +- DEFERRED from the original spec: nothing. The step-by-step breakdown deferred + from Task 2.5 is included here +- 43 form-logic tests, 12 rendered-frame tests (a real surface is drawn and the + cells read back, which is what catches a layout that writes nothing or runs off + the bottom), and 11 wiring tests through the actual key and mouse handlers +- Verify: rendered frames inspected for all four forms at 80x24, plus narrow (34 + columns) and short (10 rows) terminals; confirmed the first mortgage row reads + 1,199.10 / 1,000.00 / 199.10 / 199,800.90 and totals 231,677.04 + +### Task 5.6.1: Fix AST ownership leaks found while testing 5.6 [DONE] +- The financial-mode wiring tests run on `testing.allocator` rather than an arena, + which exposed two pre-existing leaks the whole suite had been hiding: + - `evalStringInfo` and `evalProgrammerString` never freed the parsed tree at + all, on any path. Every evaluated expression leaked its whole AST. Invisible + until now because the CLI and every existing test pass in an arena + - the parser leaked partial trees on every error path (a parser test comment + even said an arena was needed "so error paths don't leak") +- Fixed by giving the parser proper cleanup (`errdefer freeExpr` at each site + where a child is parsed before a step that can fail, and explicit frees on the + trailing-token and unmatched-paren paths) and having both eval entry points + release the tree they own. `freeExpr` is now public, since callers need it +- This mattered most in the TUI, which is the only long-running caller: every + expression and every typo grew the arena for the life of the session +- 4 regression tests on `testing.allocator`: 16 malformed inputs, programmer-mode + malformed inputs, successful parses freed exactly once, and 200 repeated + evaluations + +### Task 5.6.2: Financial mode UX refinements and frontend coverage [DONE] +- Shift-Tab walks back through the modes (FR-7.1); `nextMode`/`prevMode` are one + ordering used by both directions, tested as inverses +- Form fields group digits as they are typed (FR-7.5.1): `200` then `0` shows + `2,000`. Display-only, reusing the engine formatter's grouping helpers (now + public) rather than a third copy of the logic; the buffer stays ungrouped so it + still parses, and a typed comma is absorbed +- Form fields accept expressions (FR-7.5.2): `12 * 30` shows verbatim with an + `(Enter)` prompt and Enter evaluates it in place. An unevaluated field is + reported as such rather than treated as a blank, which in TVM and compound means + "solve for this" +- Rate fields show a trailing `%` and absorb a typed one (FR-7.5.3) +- Focus styling changed from reversed cyan to a dark selection fill with a yellow + block cursor; the mode's own yellow is reused for the row marker and label + instead of introducing another accent +- Help overlay rewritten as a scrollable line list (FR-7.6.1). The previous + per-section height gating silently dropped the Convert section once Financial + was added above it, which a render test caught +- Coverage now measured for the CLI and TUI, not just the engine (NFR-5): three + reports with disjoint include paths. `Coverage.addModule` takes include paths + instead of hardcoding `engine/src`, resolved to absolute paths because the + substring form matched any home directory containing `/src/` +- TUI coverage went from unmeasured (the view modules had literally zero + instrumented lines) to 98.91%, via the render harness in + `src/tui/test_render.zig` and event tests through the real handlers +- Verify: 811 tests, engine 99.30%, CLI 95.74%, TUI 98.91%; frames inspected for + every mode at sizes from 20x6 to 200x60 + +### Task 5.6.3: Solve compound interest for rate and years [DONE] +- `financial.compoundRate` and `financial.compoundPeriods`: the compound + relationship solved for its remaining two variables, both closed form, no solver +- `financial.effectiveAnnualRate` (APY), because a solved rate is NOMINAL and + reporting it alone invites comparison against a quoted effective rate. The form + shows both whenever compounds/year > 1; at annual compounding they coincide and + only one figure appears +- Compounds/year is deliberately not solvable (FR-5.2.1): a solution exists only + between annual and continuous compounding, and a fractional frequency is + meaningless. It is prefilled with `1` instead (FR-7.5.4), which is what keeps + "blank means solve" true for every field the user sees. The prefill is baked into + the comptime initial state, since a draw-time default could not distinguish + "never touched" from "deliberately cleared" +- Expression parity (FR-5.7): `compound_rate`, `compound_years`, and + `apy(nominal, per_year)` in 3- and 4-argument forms, so the form cannot solve + something the expression language cannot +- Solving for rate at annual compounding IS CAGR; both entry points are kept (CAGR + takes periods, not years) and a test asserts they agree so they cannot drift +- 19 engine tests including a four-way round trip through all four variables, 9 + form tests, 3 evaluator tests, 3 rendered-frame tests +- Verify: `tally 'compound_rate(10000, 25000, 5)'` gives 20.11244339814313, equal + to `cagr(10000, 25000, 5) * 100`; `apy(18, 12)` gives 19.5618 ### Task 5.7: Implement unit conversion TUI view [DONE] - Created `src/tui/convert.zig`; convert is now a third top-level mode and Tab diff --git a/build.zig b/build.zig index 632f900..b10a31f 100644 --- a/build.zig +++ b/build.zig @@ -102,14 +102,46 @@ pub fn build(b: *std.Build) void { test_step.dependOn(&run_tui_tests.step); // -- Coverage step (uses kcov, Linux x86_64/aarch64 only) -- + // + // One report per source tree, with disjoint include patterns so no file is + // accounted for twice. The CLI test binary also compiles tui.zig (main.zig + // imports it) but never runs its tests, so the CLI report is narrowed to + // main.zig; without that narrowing the TUI would appear near-uncovered in one + // report and covered in another. { var cov = Coverage.init(b); - const cov_mod = b.createModule(.{ + + const engine_cov = b.createModule(.{ .root_source_file = b.path("engine/src/engine.zig"), .target = target, .optimize = optimize, }); - _ = cov.addModule(cov_mod, "tally-engine"); + _ = cov.addModule(engine_cov, "tally-engine", &.{"engine/src"}); + + const cli_cov = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + .imports = &.{ + .{ .name = "engine", .module = engine_mod }, + .{ .name = "vaxis", .module = vaxis_dep.module("vaxis") }, + }, + }); + _ = cov.addModule(cli_cov, "tally-cli", &.{"src/main.zig"}); + + const tui_cov = b.createModule(.{ + .root_source_file = b.path("src/tui.zig"), + .target = target, + .optimize = optimize, + .imports = &.{ + .{ .name = "engine", .module = engine_mod }, + .{ .name = "vaxis", .module = vaxis_dep.module("vaxis") }, + }, + }); + // tui.zig plus its view modules. main.zig is deliberately excluded even + // though it is not compiled into this binary, so the two app reports stay + // disjoint by construction rather than by accident. + _ = cov.addModule(tui_cov, "tally-tui", &.{ "src/tui.zig", "src/tui" }); } // -- Run step -- diff --git a/build/Coverage.zig b/build/Coverage.zig index cebd734..56a31a0 100644 --- a/build/Coverage.zig +++ b/build/Coverage.zig @@ -70,13 +70,33 @@ pub fn init(b: *Build) Coverage { /// then reads the coverage JSON and prints a summary (with per-file /// breakdown if --verbose). Fails if below -Dcoverage-threshold. /// +/// `include_paths` are paths relative to the build root deciding which sources +/// this report accounts for; each may be a directory or a single file. Keep them +/// disjoint across modules: a file appearing in two reports means a query tool has +/// to guess which one answers for it, and the wrong guess reports covered lines as +/// uncovered. +/// +/// These are resolved to absolute paths rather than passed as substrings, because +/// a substring like "/src/" also matches any user whose home directory contains +/// one (which is how this was first written, and it silently pulled in the whole +/// dependency tree). +/// /// Returns the test executable so the caller can add any extra linking steps. -pub fn addModule(self: *Coverage, root_module: *Build.Module, name: []const u8) *Build.Step.Compile { +pub fn addModule( + self: *Coverage, + root_module: *Build.Module, + name: []const u8, + include_paths: []const []const u8, +) *Build.Step.Compile { const b = self.b; const run_coverage = b.addSystemCommand(&.{self.kcov_path}); - const include_path = b.pathJoin(&.{ b.build_root.path.?, "engine", "src" }); - run_coverage.addArgs(&.{ "--include-path", include_path }); + var joined = std.ArrayList(u8).empty; + for (include_paths, 0..) |path, i| { + if (i != 0) joined.append(b.allocator, ',') catch @panic("OOM"); + joined.appendSlice(b.allocator, b.pathJoin(&.{ b.build_root.path.?, path })) catch @panic("OOM"); + } + run_coverage.addArgs(&.{ "--include-path", joined.items }); const css_file = b.pathJoin(&.{ b.build_root.path.?, "build", "bcov.css" }); run_coverage.addArg(b.fmt("--configure=css-file={s}", .{css_file})); run_coverage.addArg(self.coverage_dir); diff --git a/engine/src/engine.zig b/engine/src/engine.zig index fe4fd67..7b28e77 100644 --- a/engine/src/engine.zig +++ b/engine/src/engine.zig @@ -13,6 +13,7 @@ pub const programmer = @import("programmer.zig"); 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"); // Exact numeric model (design.md 2.7). Not yet wired into the evaluator; see // Task 2.0b. Exported here so its tests run as part of `zig build test`. pub const rational = @import("rational.zig"); @@ -43,6 +44,19 @@ pub const ConvertResult = units.ConvertResult; pub const convert = units.convert; pub const findUnit = units.findUnit; +// Financial +pub const TvmVariable = financial.TvmVariable; +pub const TvmParams = financial.TvmParams; +pub const TvmSolution = financial.TvmSolution; +pub const solveTvm = financial.solveTvm; +pub const cagr = financial.cagr; +pub const compoundFutureValue = financial.compoundFutureValue; +pub const compoundPresentValue = financial.compoundPresentValue; +pub const compoundRate = financial.compoundRate; +pub const compoundPeriods = financial.compoundPeriods; +pub const effectiveAnnualRate = financial.effectiveAnnualRate; +pub const roundToCents = financial.roundToCents; + // Exact numeric model pub const Rational = rational.Rational; pub const Number = number.Number; diff --git a/engine/src/evaluator.zig b/engine/src/evaluator.zig index f2d22f4..42c95e9 100644 --- a/engine/src/evaluator.zig +++ b/engine/src/evaluator.zig @@ -19,6 +19,7 @@ const parser_mod = @import("parser.zig"); const Parser = parser_mod.Parser; const number_mod = @import("number.zig"); const Number = number_mod.Number; +const financial = @import("financial.zig"); /// Evaluation environment holding variables, history, and config. /// @@ -322,6 +323,11 @@ fn evalFunction(env: *Environment, scratch: Allocator, name: []const u8, args: [ const x = a.toFloat(scratch); const y = b.toFloat(scratch); if (std.mem.eql(u8, name, "atan2")) return Number.fromFloat(math.atan2(x, y)); + // apy(nominal_rate, compounds_per_year): the effective annual rate, so a + // nominal rate can be compared against one. + if (std.mem.eql(u8, name, "apy")) { + return Number.fromFloat(try financial.effectiveAnnualRate(x, y)); + } if (std.mem.eql(u8, name, "log")) { // log(value, base) if (y <= 0 or y == 1 or x <= 0) return CalcError.DomainError; @@ -329,6 +335,22 @@ fn evalFunction(env: *Environment, scratch: Allocator, name: []const u8, args: [ } } + // Financial functions take three or four arguments. + // + // These are inexact by construction: every financial formula needs a + // non-integer power or a logarithm, so the exact tier has nothing to + // preserve (see the header of financial.zig). + if (args.len == 3 or args.len == 4) { + var values: [4]f64 = undefined; + for (args, 0..) |arg, i| { + const value = try evalExact(env, scratch, arg); + values[i] = value.toFloat(scratch); + } + if (try evalFinancialFn(name, values[0..args.len])) |result| { + return Number.fromFloat(result); + } + } + // Zero-argument functions if (args.len == 0) { if (std.mem.eql(u8, name, "rand")) { @@ -340,6 +362,114 @@ fn evalFunction(env: *Environment, scratch: Allocator, name: []const u8, args: [ return CalcError.UnknownFunction; } +/// A whole period count or 1-based period index, validated. +fn periodCount(value: f64) CalcError!usize { + if (!math.isFinite(value)) return CalcError.DomainError; + if (@floor(value) != value) return CalcError.DomainError; + if (value < 1 or value > @as(f64, @floatFromInt(financial.max_schedule_periods))) { + return CalcError.DomainError; + } + return @intFromFloat(value); +} + +/// Financial functions callable from a standard-mode expression. +/// +/// Exposed as functions rather than only as a separate mode so that they compose +/// with the rest of the language: `cagr(10000, 25000, 5) * 100` and +/// `amort_interest(200000, 0.5, 360, 1) + 50` both work, the same way unit +/// conversion is reachable from a bare expression. +/// +/// Returns null when `name` is not a financial function, so the caller can carry +/// on to report an unknown function. +fn evalFinancialFn(name: []const u8, a: []const f64) CalcError!?f64 { + if (a.len == 3) { + // cagr(start, end, periods) -> growth rate as a fraction. + if (std.mem.eql(u8, name, "cagr")) return try financial.cagr(a[0], a[1], a[2]); + // fv(pv, annual_rate_percent, years) compounded annually. + if (std.mem.eql(u8, name, "fv")) return try financial.compoundFutureValue(a[0], a[1], a[2], 1); + // pv(fv, annual_rate_percent, years) compounded annually. + if (std.mem.eql(u8, name, "pv")) return try financial.compoundPresentValue(a[0], a[1], a[2], 1); + // The same relationship solved for its other two variables. The rate is + // NOMINAL; use apy() to convert. + if (std.mem.eql(u8, name, "compound_rate")) return try financial.compoundRate(a[0], a[1], a[2], 1); + if (std.mem.eql(u8, name, "compound_years")) return try financial.compoundPeriods(a[0], a[1], a[2], 1); + + // amort_payment(principal, rate_per_period_percent, periods) + if (std.mem.eql(u8, name, "amort_payment")) { + return try financial.amortizationPayment(.{ + .principal = a[0], + .rate = a[1], + .periods = try periodCount(a[2]), + }); + } + if (std.mem.eql(u8, name, "amort_total_interest")) { + const totals = try financial.amortizationTotals(.{ + .principal = a[0], + .rate = a[1], + .periods = try periodCount(a[2]), + }); + return totals.interest; + } + if (std.mem.eql(u8, name, "amort_total_paid")) { + const totals = try financial.amortizationTotals(.{ + .principal = a[0], + .rate = a[1], + .periods = try periodCount(a[2]), + }); + return totals.paid; + } + return null; + } + + // fv(pv, rate, years, compounds_per_year) and its inverse. + if (std.mem.eql(u8, name, "fv")) return try financial.compoundFutureValue(a[0], a[1], a[2], a[3]); + if (std.mem.eql(u8, name, "pv")) return try financial.compoundPresentValue(a[0], a[1], a[2], a[3]); + // compound_rate(pv, fv, years, compounds_per_year) -> nominal annual rate. + if (std.mem.eql(u8, name, "compound_rate")) return try financial.compoundRate(a[0], a[1], a[2], a[3]); + // compound_years(pv, fv, rate, compounds_per_year) + if (std.mem.eql(u8, name, "compound_years")) return try financial.compoundPeriods(a[0], a[1], a[2], a[3]); + + // TVM: each function names the variable it solves for, and takes the other + // four in the calculator's N, I/Y, PV, PMT, FV order. + if (std.mem.eql(u8, name, "tvm_fv")) { + const s = try financial.solveTvm(.{ .periods = a[0], .rate = a[1], .present_value = a[2], .payment = a[3] }); + return s.value; + } + if (std.mem.eql(u8, name, "tvm_pv")) { + const s = try financial.solveTvm(.{ .periods = a[0], .rate = a[1], .payment = a[2], .future_value = a[3] }); + return s.value; + } + if (std.mem.eql(u8, name, "tvm_pmt")) { + const s = try financial.solveTvm(.{ .periods = a[0], .rate = a[1], .present_value = a[2], .future_value = a[3] }); + return s.value; + } + if (std.mem.eql(u8, name, "tvm_n")) { + const s = try financial.solveTvm(.{ .rate = a[0], .present_value = a[1], .payment = a[2], .future_value = a[3] }); + return s.value; + } + if (std.mem.eql(u8, name, "tvm_rate")) { + const s = try financial.solveTvm(.{ .periods = a[0], .present_value = a[1], .payment = a[2], .future_value = a[3] }); + return s.value; + } + + // Amortization rows: (principal, rate_per_period_percent, periods, period) + const is_interest = std.mem.eql(u8, name, "amort_interest"); + const is_principal = std.mem.eql(u8, name, "amort_principal"); + const is_balance = std.mem.eql(u8, name, "amort_balance"); + if (is_interest or is_principal or is_balance) { + const entry = try financial.amortizationEntry(.{ + .principal = a[0], + .rate = a[1], + .periods = try periodCount(a[2]), + }, try periodCount(a[3])); + if (is_interest) return entry.interest; + if (is_principal) return entry.principal; + return entry.balance; + } + + return null; +} + /// Evaluate a single-argument built-in function that has no exact form. fn evalSingleArgFn(name: []const u8, x: f64) ?f64 { if (std.mem.eql(u8, name, "sin")) return @sin(x); @@ -384,6 +514,12 @@ pub fn evalString(env: *Environment, allocator: Allocator, source: []const u8) C pub fn evalStringInfo(env: *Environment, allocator: Allocator, source: []const u8) CalcError!EvalInfo { var p = Parser.init(allocator, source, env.mode); const expr = try p.parse(); + // The parser hands over ownership. Nothing in the result borrows from the + // tree (literal text points into `source`, and the value is cloned out of the + // scratch arena), so it can be released as soon as evaluation is done. + // Without this every evaluated expression leaked its whole AST, which only + // went unnoticed because the CLI hands in an arena. + defer parser_mod.freeExpr(allocator, expr); // Intermediates live in a scratch arena; only the final value is copied out // into the caller's allocator. @@ -1076,3 +1212,209 @@ test "Number API: a variable name outliving its source text stays valid" { const result = try evalString(&env, a, "myvar + 1"); try testing.expectEqual(@as(f64, 43.0), result.toFloat(a)); } + +// -- Financial functions in standard mode -- +// +// The point of these tests is reachability and composition: the financial math +// itself is covered in financial.zig. What matters here is that a plain +// expression can call them, with the argument counts the parser now allows. + +test "financial: cagr is callable from a standard expression" { + try testing.expectApproxEqAbs(@as(f64, 0.2011244), try testEval("cagr(10000, 25000, 5)"), 1e-7); +} + +test "financial: a financial result composes with ordinary arithmetic" { + // The fraction-to-percent conversion users will reach for immediately. + try testing.expectApproxEqAbs(@as(f64, 20.11244), try testEval("cagr(10000, 25000, 5) * 100"), 1e-5); +} + +test "financial: arguments may themselves be expressions" { + try testing.expectApproxEqAbs( + @as(f64, 0.2011244), + try testEval("cagr(10000, 5 * 5000, 60 / 12)"), + 1e-7, + ); +} + +test "financial: compound interest with and without a frequency argument" { + // Three arguments compounds annually. + try testing.expectApproxEqAbs(@as(f64, 1628.894627), try testEval("fv(1000, 5, 10)"), 1e-6); + // The fourth argument is the compounding frequency; monthly beats annual. + try testing.expectApproxEqAbs(@as(f64, 1647.009498), try testEval("fv(1000, 5, 10, 12)"), 1e-6); + try testing.expect(try testEval("fv(1000, 5, 10, 12)") > try testEval("fv(1000, 5, 10)")); +} + +test "financial: present value inverts future value" { + const future = try testEval("fv(1000, 5, 10, 4)"); + var buffer: [64]u8 = undefined; + const source = try std.fmt.bufPrint(&buffer, "pv({d}, 5, 10, 4)", .{future}); + try testing.expectApproxEqAbs(@as(f64, 1000.0), try testEval(source), 1e-6); +} + +test "financial: compound interest solves for its rate and its time" { + // The same relationship as fv(), read backwards. + try testing.expectApproxEqAbs( + @as(f64, 5.0), + try testEval("compound_rate(1000, 1628.894627, 10)"), + 1e-6, + ); + try testing.expectApproxEqAbs( + @as(f64, 10.0), + try testEval("compound_years(1000, 1628.894627, 5)"), + 1e-6, + ); + + // With a compounding frequency the rate is nominal, so it is lower. + const monthly = try testEval("compound_rate(1000, 2000, 10, 12)"); + const annual = try testEval("compound_rate(1000, 2000, 10)"); + try testing.expect(monthly < annual); + try testing.expectApproxEqAbs(@as(f64, 6.95152928), monthly, 1e-8); + + // At annual compounding, solving for the rate is CAGR. + try testing.expectApproxEqAbs( + try testEval("cagr(10000, 25000, 5) * 100"), + try testEval("compound_rate(10000, 25000, 5)"), + 1e-9, + ); +} + +test "financial: apy converts a nominal rate to an effective one" { + try testing.expectApproxEqAbs(@as(f64, 19.5618), try testEval("apy(18, 12)"), 1e-4); + // Annual compounding is its own effective rate. + try testing.expectApproxEqAbs(@as(f64, 5.0), try testEval("apy(5, 1)"), 1e-12); + // And it composes, which is the point of exposing it as a function. + try testing.expectApproxEqAbs( + @as(f64, 19.5618), + try testEval("apy(compound_rate(1000, fv(1000, 18, 5, 12), 5, 12), 12)"), + 1e-4, + ); + try testing.expectError(CalcError.DomainError, testEval("apy(5, 0)")); +} + +test "financial: the tvm solvers are reachable as four-argument functions" { + // 200,000 at 0.5% a period over 360 periods: the classic mortgage payment. + try testing.expectApproxEqAbs( + @as(f64, -1199.10105), + try testEval("tvm_pmt(360, 0.5, 200000, 0)"), + 1e-5, + ); + try testing.expectApproxEqAbs(@as(f64, 7.1773462), try testEval("tvm_rate(10, -1000, 0, 2000)"), 1e-6); + try testing.expectApproxEqAbs(@as(f64, 10.244768), try testEval("tvm_n(7, -1000, 0, 2000)"), 1e-6); + try testing.expectApproxEqAbs(@as(f64, 1257.789254), try testEval("tvm_fv(10, 5, 0, -100)"), 1e-6); + try testing.expectApproxEqAbs(@as(f64, -1016.698584), try testEval("tvm_pv(10, 7, 0, 2000)"), 1e-6); +} + +test "financial: amortization rows are reachable from an expression" { + try testing.expectEqual(@as(f64, 1199.10), try testEval("amort_payment(200000, 0.5, 360)")); + // Month one of a 6% loan on 200,000 is exactly 1000 of interest. + try testing.expectEqual(@as(f64, 1000.0), try testEval("amort_interest(200000, 0.5, 360, 1)")); + try testing.expectApproxEqAbs(@as(f64, 199.10), try testEval("amort_principal(200000, 0.5, 360, 1)"), 1e-9); + try testing.expectApproxEqAbs(@as(f64, 199800.90), try testEval("amort_balance(200000, 0.5, 360, 1)"), 1e-9); + try testing.expectEqual(@as(f64, 0.0), try testEval("amort_balance(200000, 0.5, 360, 360)")); +} + +test "financial: amortization totals" { + const interest = try testEval("amort_total_interest(200000, 0.5, 360)"); + const paid = try testEval("amort_total_paid(200000, 0.5, 360)"); + try testing.expect(interest > 231000 and interest < 232000); + try testing.expectApproxEqAbs(paid - interest, 200000, 0.05); +} + +test "financial: results are inexact, so they do not claim exactness" { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer _ = arena.deinit(); + const alloc = arena.allocator(); + var env = Environment.init(alloc, .standard); + defer env.deinit(); + + const result = try evalString(&env, alloc, "cagr(1000, 2000, 10)"); + try testing.expect(!result.isExact()); +} + +test "financial: bad arguments are domain errors, not wrong answers" { + // Zero periods. + try testing.expectError(CalcError.DomainError, testEval("cagr(1000, 2000, 0)")); + // A fractional period count cannot index an amortization schedule. + try testing.expectError(CalcError.DomainError, testEval("amort_interest(200000, 0.5, 360.5, 1)")); + // Period past the end of the schedule. + try testing.expectError(CalcError.DomainError, testEval("amort_balance(200000, 0.5, 360, 361)")); + // Payments that never retire the loan. + try testing.expectError(CalcError.DomainError, testEval("amort_payment(0, 0.5, 360)")); + // Period counts outside the schedule bounds. + try testing.expectError(CalcError.DomainError, testEval("amort_payment(200000, 0.5, 0)")); + try testing.expectError(CalcError.DomainError, testEval("amort_payment(200000, 0.5, 20000)")); + try testing.expectError(CalcError.DomainError, testEval("amort_payment(200000, 0.5, 10^400)")); +} + +test "financial: wrong argument counts are unknown functions, not silent defaults" { + try testing.expectError(CalcError.UnknownFunction, testEval("cagr(10000, 25000)")); + try testing.expectError(CalcError.UnknownFunction, testEval("tvm_pmt(360, 0.5, 200000)")); + try testing.expectError(CalcError.UnknownFunction, testEval("amort_payment(200000, 0.5, 360, 1)")); + // A three or four argument call to something that is not a function at all. + try testing.expectError(CalcError.UnknownFunction, testEval("nope(1, 2, 3)")); + try testing.expectError(CalcError.UnknownFunction, testEval("nope(1, 2, 3, 4)")); +} + +// -- The AST is not the caller's problem -- +// +// These use testing.allocator directly rather than testEval's arena, because an +// arena hides exactly the bug they guard against: evalStringInfo used to leak the +// whole parsed tree on every call, which an arena silently absorbs. + +test "no leak: a successful evaluation releases the parsed tree" { + var env = Environment.init(testing.allocator, .standard); + defer env.deinit(); + + const sources = [_][]const u8{ + "1 + 2 * 3", + "-(4 + 5)", + "max(1, min(2, 3))", + "sqrt(2) + factorial(10)", + "X = 7 * 6", + "X + 1", + "cagr(10000, 25000, 5) * 100", + "amort_interest(200000, 0.5, 360, 1)", + "0xFF and 0x0F", + }; + for (sources) |source| { + var value = try evalString(&env, testing.allocator, source); + value.deinit(); + } +} + +test "no leak: a failed evaluation releases the parsed tree" { + var env = Environment.init(testing.allocator, .standard); + defer env.deinit(); + + const sources = [_][]const u8{ + "2 +", + "(1 + 2", + "max(1, 2", + "1 / 0", + "unknownfn(1)", + "undefined_variable + 1", + "sqrt(-1)", + "cagr(1000, 2000, 0)", + }; + for (sources) |source| { + if (evalString(&env, testing.allocator, source)) |value| { + var owned = value; + owned.deinit(); + std.debug.print("expected an error for \"{s}\"\n", .{source}); + return error.TestUnexpectedResult; + } else |_| {} + } +} + +test "no leak: repeated evaluation does not accumulate" { + // A long interactive session is the case that made this visible: the TUI + // evaluates on every Enter and never frees anything itself. + var env = Environment.init(testing.allocator, .standard); + defer env.deinit(); + + var i: usize = 0; + while (i < 200) : (i += 1) { + var value = try evalString(&env, testing.allocator, "Ans + 1"); + value.deinit(); + } +} diff --git a/engine/src/financial.zig b/engine/src/financial.zig new file mode 100644 index 0000000..7d9b6eb --- /dev/null +++ b/engine/src/financial.zig @@ -0,0 +1,1370 @@ +//! Financial calculations for Tally. +//! +//! CAGR, compound interest, present value, and a five-variable TVM solver. +//! +//! ## Why this module is f64 rather than exact +//! +//! Every formula here needs a non-integer power or a logarithm: CAGR raises to +//! `1/n`, the period solver takes `ln`, and the rate solver iterates. Those +//! escape the rationals by definition (see design.md 2.7.4), so results are +//! inexact and there is nothing for the exact tier to preserve. +//! +//! What money actually needs is not exactness but *controlled rounding*, which +//! is a separate concern handled by `roundToScale` at the point a figure is +//! committed or displayed. +//! +//! ## Sign convention +//! +//! TVM uses the cash-flow convention shared by financial calculators: money +//! received is positive, money paid out is negative. A borrower taking a +//! 200,000 loan has `pv = 200000` and a negative `pmt`. The five variables +//! satisfy +//! +//! pv * (1+r)^n + pmt * annuityFactor(r, n) + fv = 0 +//! +//! Getting this backwards is the most common source of sign confusion, so the +//! solver never silently flips signs for the caller. + +const std = @import("std"); +const math = std.math; +const types = @import("types.zig"); +const CalcError = types.CalcError; + +/// Iteration cap for the rate solver. +pub const max_iterations: usize = 1000; +/// Convergence tolerance for the rate solver, on the residual of the TVM +/// equation. +pub const tolerance: f64 = 1e-10; + +// -- Compound growth -- + +/// Compound annual growth rate, as a decimal fraction (0.2011 means 20.11%). +/// +/// cagr = (end / start)^(1/periods) - 1 +pub fn cagr(start_value: f64, end_value: f64, periods: f64) CalcError!f64 { + if (periods <= 0) return CalcError.DomainError; + // A zero or negative starting value has no meaningful growth rate, and a + // negative ending value would need a complex root. + if (start_value <= 0 or end_value < 0) return CalcError.DomainError; + return math.pow(f64, end_value / start_value, 1.0 / periods) - 1.0; +} + +/// Future value under compound interest. +/// +/// fv = pv * (1 + rate/m)^(m * years) +/// +/// `annual_rate` is a percentage (5 means 5%). `compounds_per_year` is the +/// compounding frequency; use 1 for annual, 12 for monthly. +pub fn compoundFutureValue( + present_value: f64, + annual_rate: f64, + years: f64, + compounds_per_year: f64, +) CalcError!f64 { + if (compounds_per_year <= 0) return CalcError.DomainError; + if (years < 0) return CalcError.DomainError; + const periodic = annual_rate / 100.0 / compounds_per_year; + if (periodic <= -1.0) return CalcError.DomainError; + return present_value * math.pow(f64, 1.0 + periodic, compounds_per_year * years); +} + +/// Present value of a future amount under compound interest: the inverse of +/// `compoundFutureValue`. +pub fn compoundPresentValue( + future_value: f64, + annual_rate: f64, + years: f64, + compounds_per_year: f64, +) CalcError!f64 { + if (compounds_per_year <= 0) return CalcError.DomainError; + if (years < 0) return CalcError.DomainError; + const periodic = annual_rate / 100.0 / compounds_per_year; + if (periodic <= -1.0) return CalcError.DomainError; + const factor = math.pow(f64, 1.0 + periodic, compounds_per_year * years); + if (factor == 0) return CalcError.DivisionByZero; + return future_value / factor; +} + +/// The NOMINAL annual rate, as a percentage, that grows `present_value` into +/// `future_value` over `years` with `compounds_per_year` compounding periods. +/// +/// rate = m * ((fv/pv)^(1/(m*t)) - 1) * 100 +/// +/// Nominal, not effective: at m = 12 this is the APR a lender would quote, which +/// is lower than what the money actually earns. Use `effectiveAnnualRate` to +/// convert. The distinction only bites when solving, because everywhere else the +/// rate is an input the caller already understands. +/// +/// A negative result is a legitimate answer (the value shrank), so it is returned +/// rather than rejected. +pub fn compoundRate( + present_value: f64, + future_value: f64, + years: f64, + compounds_per_year: f64, +) CalcError!f64 { + if (compounds_per_year <= 0) return CalcError.DomainError; + // With no time elapsed, any rate satisfies pv == fv and none satisfies + // pv != fv, so there is no answer to give. + if (years <= 0) return CalcError.DomainError; + if (present_value == 0) return CalcError.DomainError; + + const ratio = future_value / present_value; + // A sign change has no real root: no rate turns 1000 into -500. + if (!(ratio > 0)) return CalcError.DomainError; + + const periods = compounds_per_year * years; + const periodic = math.pow(f64, ratio, 1.0 / periods) - 1.0; + const rate = periodic * compounds_per_year * 100.0; + if (!math.isFinite(rate)) return CalcError.DomainError; + return rate; +} + +/// Years needed to grow `present_value` into `future_value` at a nominal annual +/// rate. +/// +/// years = ln(fv/pv) / (m * ln(1 + rate/100/m)) +pub fn compoundPeriods( + present_value: f64, + future_value: f64, + annual_rate: f64, + compounds_per_year: f64, +) CalcError!f64 { + if (compounds_per_year <= 0) return CalcError.DomainError; + if (present_value == 0) return CalcError.DomainError; + + const ratio = future_value / present_value; + if (!(ratio > 0)) return CalcError.DomainError; + // Already there, whatever the rate. + if (ratio == 1) return 0; + + const periodic = annual_rate / 100.0 / compounds_per_year; + if (periodic <= -1.0) return CalcError.DomainError; + // A zero rate never moves the balance, so no amount of time reaches a + // different future value. + if (periodic == 0) return CalcError.DomainError; + + const years = @log(ratio) / @log(1.0 + periodic) / compounds_per_year; + if (!math.isFinite(years)) return CalcError.DomainError; + return years; +} + +/// Effective annual rate (APY) for a nominal rate compounded `compounds_per_year` +/// times a year, both as percentages. +/// +/// effective = ((1 + nominal/100/m)^m - 1) * 100 +/// +/// 18% compounded monthly is 19.56% effective. Reporting a solved nominal rate +/// without this is how rate comparisons go wrong. +pub fn effectiveAnnualRate(annual_rate: f64, compounds_per_year: f64) CalcError!f64 { + if (compounds_per_year <= 0) return CalcError.DomainError; + const periodic = annual_rate / 100.0 / compounds_per_year; + if (periodic <= -1.0) return CalcError.DomainError; + const grown = math.pow(f64, 1.0 + periodic, compounds_per_year); + if (!math.isFinite(grown)) return CalcError.DomainError; + return (grown - 1.0) * 100.0; +} + +// -- Time value of money -- + +pub const TvmVariable = enum { + periods, + rate, + present_value, + payment, + future_value, + + pub fn label(self: TvmVariable) []const u8 { + return switch (self) { + .periods => "N", + .rate => "I/Y", + .present_value => "PV", + .payment => "PMT", + .future_value => "FV", + }; + } +}; + +/// The five TVM variables. Exactly one must be null: that is the one solved for. +pub const TvmParams = struct { + /// Number of periods. + periods: ?f64 = null, + /// Interest rate per period, as a percentage (0.5 means 0.5% per period). + rate: ?f64 = null, + present_value: ?f64 = null, + payment: ?f64 = null, + future_value: ?f64 = null, + /// True when payments occur at the START of each period (annuity due, "BGN" + /// on a financial calculator). Default is end of period (ordinary annuity). + due: bool = false, +}; + +pub const TvmSolution = struct { + variable: TvmVariable, + value: f64, + /// Iterations used by the rate solver; null for the closed-form cases. + iterations: ?usize = null, +}; + +/// (1+r)^n, the growth factor over `n` periods. +fn growth(rate: f64, periods: f64) f64 { + return math.pow(f64, 1.0 + rate, periods); +} + +/// The annuity factor multiplying PMT. +/// +/// At rate zero the usual `((1+r)^n - 1) / r` is 0/0; its limit is simply `n`, +/// which is also the intuitive answer (n equal payments, no interest). +fn annuityFactor(rate: f64, periods: f64, due: bool) f64 { + if (rate == 0) return periods; + const base = (growth(rate, periods) - 1.0) / rate; + return if (due) base * (1.0 + rate) else base; +} + +/// Residual of the TVM equation. Zero when the five variables are consistent. +fn tvmResidual(rate: f64, periods: f64, pv: f64, pmt: f64, fv: f64, due: bool) f64 { + return pv * growth(rate, periods) + pmt * annuityFactor(rate, periods, due) + fv; +} + +/// Solve for whichever variable is null. +pub fn solveTvm(params: TvmParams) CalcError!TvmSolution { + // Exactly one unknown. + var unknowns: usize = 0; + var which: TvmVariable = .future_value; + if (params.periods == null) { + unknowns += 1; + which = .periods; + } + if (params.rate == null) { + unknowns += 1; + which = .rate; + } + if (params.present_value == null) { + unknowns += 1; + which = .present_value; + } + if (params.payment == null) { + unknowns += 1; + which = .payment; + } + if (params.future_value == null) { + unknowns += 1; + which = .future_value; + } + if (unknowns != 1) return CalcError.InsufficientParameters; + + return switch (which) { + .future_value => .{ .variable = which, .value = try solveFutureValue(params) }, + .present_value => .{ .variable = which, .value = try solvePresentValue(params) }, + .payment => .{ .variable = which, .value = try solvePayment(params) }, + .periods => .{ .variable = which, .value = try solvePeriods(params) }, + .rate => try solveRate(params), + }; +} + +fn solveFutureValue(p: TvmParams) CalcError!f64 { + const r = p.rate.? / 100.0; + const n = p.periods.?; + if (r <= -1.0) return CalcError.DomainError; + return -(p.present_value.? * growth(r, n) + p.payment.? * annuityFactor(r, n, p.due)); +} + +fn solvePresentValue(p: TvmParams) CalcError!f64 { + const r = p.rate.? / 100.0; + const n = p.periods.?; + if (r <= -1.0) return CalcError.DomainError; + const g = growth(r, n); + if (g == 0) return CalcError.DivisionByZero; + return -(p.future_value.? + p.payment.? * annuityFactor(r, n, p.due)) / g; +} + +fn solvePayment(p: TvmParams) CalcError!f64 { + const r = p.rate.? / 100.0; + const n = p.periods.?; + if (r <= -1.0) return CalcError.DomainError; + const af = annuityFactor(r, n, p.due); + if (af == 0) return CalcError.DivisionByZero; + return -(p.present_value.? * growth(r, n) + p.future_value.?) / af; +} + +fn solvePeriods(p: TvmParams) CalcError!f64 { + const r = p.rate.? / 100.0; + const pv = p.present_value.?; + const pmt = p.payment.?; + const fv = p.future_value.?; + if (r <= -1.0) return CalcError.DomainError; + + // With no interest the equation is linear: pv + pmt*n + fv = 0. + if (r == 0) { + if (pmt == 0) return CalcError.InsufficientParameters; + return -(pv + fv) / pmt; + } + + // pv*g + pmt*d*(g-1)/r + fv = 0, with d = (1+r) for annuity due. + // Let a = pmt*d/r. Then g*(pv + a) = a - fv. + const d: f64 = if (p.due) 1.0 + r else 1.0; + const a = pmt * d / r; + const denominator = pv + a; + if (denominator == 0) return CalcError.DivisionByZero; + + const g = (a - fv) / denominator; + // A non-positive growth factor has no real logarithm: the cash flows cannot + // reach the requested future value at this rate. + if (g <= 0) return CalcError.DomainError; + const base = 1.0 + r; + if (base <= 0) return CalcError.DomainError; + return @log(g) / @log(base); +} + +/// Solve for the periodic rate with Newton's method. +/// +/// There is no closed form, and the analytic derivative of the annuity-due +/// variant is unwieldy, so the derivative is taken by central difference. That +/// makes this a quasi-Newton iteration in the strict sense; convergence is +/// verified rather than assumed. +/// +/// Convergence is judged on the STEP SIZE in the rate, with the residual checked +/// only relative to the magnitude of the cash flows. An absolute residual +/// tolerance does not work here: for a 200,000 mortgage the residual is scaled by +/// the principal, so floating-point noise alone exceeds any fixed epsilon and a +/// perfectly good root looks like a failure. +/// +/// Several starting points are tried because the residual can be flat or have a +/// bad slope near a poor initial guess, and a single seed makes the solver fail +/// on otherwise well-posed inputs. +fn solveRate(p: TvmParams) CalcError!TvmSolution { + const n = p.periods.?; + const pv = p.present_value.?; + const pmt = p.payment.?; + const fv = p.future_value.?; + if (n <= 0) return CalcError.DomainError; + + // A sign change in the cash flows is necessary for a solution to exist. + if (pv == 0 and pmt == 0 and fv == 0) return CalcError.InsufficientParameters; + + // Residuals are proportional to the size of the cash flows, so the + // acceptance threshold has to be too. + const scale = @max(@max(@abs(pv), @abs(fv)), @max(@abs(pmt) * n, 1.0)); + const residual_limit = tolerance * scale; + + const seeds = [_]f64{ 0.05, 0.01, 0.1, 0.005, 0.25, -0.05, 0.5 }; + var total_iterations: usize = 0; + + for (seeds) |seed| { + var r = seed; + var i: usize = 0; + while (i < max_iterations) : (i += 1) { + total_iterations += 1; + const f = tvmResidual(r, n, pv, pmt, fv, p.due); + if (@abs(f) <= residual_limit) { + return .{ .variable = .rate, .value = r * 100.0, .iterations = total_iterations }; + } + + // Central difference, scaled to the magnitude of r so the step stays + // meaningful for both tiny and large rates. + const h = @max(1e-9, @abs(r) * 1e-6); + const f_hi = tvmResidual(r + h, n, pv, pmt, fv, p.due); + const f_lo = tvmResidual(r - h, n, pv, pmt, fv, p.due); + const slope = (f_hi - f_lo) / (2.0 * h); + if (slope == 0 or !math.isFinite(slope)) break; + + var next = r - f / slope; + if (!math.isFinite(next)) break; + // Keep the iterate in the region where (1+r)^n is defined. + if (next <= -1.0) next = (r - 1.0) / 2.0; + + // The step has stopped moving: this is the root to the precision the + // arithmetic allows, provided the residual is small for its scale. + if (@abs(next - r) <= 1e-14 * @max(1.0, @abs(r))) { + const residual = tvmResidual(next, n, pv, pmt, fv, p.due); + if (@abs(residual) <= residual_limit) { + return .{ .variable = .rate, .value = next * 100.0, .iterations = total_iterations }; + } + break; + } + r = next; + } + } + return CalcError.ConvergenceFailure; +} + +// -- Money rounding -- + +pub const RoundingMode = enum { + /// Halves go to the nearest even digit. The default for money because it + /// does not bias totals upward the way half-up does across many roundings. + half_even, + /// Halves go away from zero. What most people mean by "round". + half_up, +}; + +/// Round to a fixed number of decimal places. +/// +/// This is the "money boundary" from design.md 2.7.3: rather than introducing a +/// decimal numeric type, financial figures are computed in binary floating point +/// and rounded explicitly at the point they become an amount. +pub fn roundToScale(value: f64, decimals: u8, mode: RoundingMode) f64 { + if (!math.isFinite(value)) return value; + if (decimals > 17) return value; + + const scale = math.pow(f64, 10.0, @floatFromInt(decimals)); + const scaled = value * scale; + if (!math.isFinite(scaled)) return value; + + const rounded = switch (mode) { + .half_up => @round(scaled), + .half_even => blk: { + const floor = @floor(scaled); + const diff = scaled - floor; + if (diff > 0.5) break :blk floor + 1.0; + if (diff < 0.5) break :blk floor; + // Exactly halfway: pick the even neighbour. + break :blk if (@mod(floor, 2.0) == 0.0) floor else floor + 1.0; + }, + }; + return rounded / scale; +} + +/// Round to whole cents, the common case. +pub fn roundToCents(value: f64) f64 { + return roundToScale(value, 2, .half_even); +} + +// -- Amortization -- + +/// Upper bound on schedule length. 12,000 monthly periods is a thousand years, +/// so anything past this is a typo rather than a loan, and the cap keeps a bad +/// input from asking for an enormous allocation. +pub const max_schedule_periods: usize = 12_000; + +/// One row of an amortization schedule. +/// +/// Unlike TVM, the figures here are all positive: a schedule is read from the +/// borrower's side, where `payment` is an amount paid and `balance` an amount +/// still owed. Mixing the TVM sign convention into a table only makes it harder +/// to read. +pub const AmortizationEntry = struct { + /// 1-based period number. + period: usize, + payment: f64, + interest: f64, + principal: f64, + /// Balance remaining AFTER this payment. + balance: f64, +}; + +pub const AmortizationParams = struct { + /// Loan amount, as a positive number. + principal: f64, + /// Interest rate per period, as a percentage (0.5 means 0.5% per period). + rate: f64, + /// Number of payments. + periods: usize, + /// Level payment per period, as a positive amount. When null it is derived + /// from the loan terms with the TVM solver. + payment: ?f64 = null, + /// Round every figure to whole cents, the way a lender's schedule does. + /// The last period absorbs whatever residue the rounding leaves, which is + /// why the final payment often differs by a cent or two. + round_cents: bool = true, +}; + +pub const AmortizationTotals = struct { + /// Periods actually generated. Less than `periods` when a payment larger + /// than the level payment retires the loan early. + periods: usize, + paid: f64, + interest: f64, + principal: f64, +}; + +/// The level payment implied by a loan, as a positive amount. +pub fn amortizationPayment(p: AmortizationParams) CalcError!f64 { + if (p.principal <= 0) return CalcError.DomainError; + if (p.periods == 0 or p.periods > max_schedule_periods) return CalcError.DomainError; + // A negative rate would mean the balance shrinks on its own, which is not + // something an amortization table describes. + if (p.rate < 0) return CalcError.DomainError; + + if (p.payment) |given| { + if (given <= 0) return CalcError.DomainError; + return if (p.round_cents) roundToCents(given) else given; + } + + const solution = try solveTvm(.{ + .periods = @floatFromInt(p.periods), + .rate = p.rate, + .present_value = p.principal, + .future_value = 0, + }); + // solveTvm returns the payment as a cash outflow; a schedule wants the + // magnitude. + const amount = -solution.value; + if (!math.isFinite(amount) or amount <= 0) return CalcError.DomainError; + return if (p.round_cents) roundToCents(amount) else amount; +} + +/// Walks a schedule one period at a time. +/// +/// Both the single-row and whole-table entry points go through this, so a row +/// fetched on its own can never disagree with the same row inside a full +/// schedule. +const AmortizationCursor = struct { + params: AmortizationParams, + payment: f64, + rate: f64, + balance: f64, + period: usize = 0, + + fn init(p: AmortizationParams) CalcError!AmortizationCursor { + const payment = try amortizationPayment(p); + const rate = p.rate / 100.0; + const balance = if (p.round_cents) roundToCents(p.principal) else p.principal; + + // A payment that does not even cover the first period's interest never + // reduces the balance: the loan grows forever, and there is no schedule + // to print. + const first_interest = balance * rate; + if (payment <= first_interest) return CalcError.DomainError; + + return .{ .params = p, .payment = payment, .rate = rate, .balance = balance }; + } + + fn scale(self: AmortizationCursor, value: f64) f64 { + return if (self.params.round_cents) roundToCents(value) else value; + } + + fn next(self: *AmortizationCursor) ?AmortizationEntry { + if (self.period >= self.params.periods or self.balance <= 0) return null; + self.period += 1; + + const interest = self.scale(self.balance * self.rate); + var principal = self.payment - interest; + var payment = self.payment; + + // The final period, or any period whose scheduled principal would + // overshoot, settles the balance exactly instead. On the last period of + // an underfunded loan this is a balloon payment rather than a rounding + // adjustment, which is the honest thing to show. + if (self.period == self.params.periods or principal >= self.balance) { + principal = self.balance; + payment = self.scale(self.balance + interest); + } + + self.balance = self.scale(self.balance - principal); + return .{ + .period = self.period, + .payment = payment, + .interest = interest, + .principal = principal, + .balance = self.balance, + }; + } +}; + +/// A single period of a schedule, without building the whole table. +pub fn amortizationEntry(p: AmortizationParams, period: usize) CalcError!AmortizationEntry { + if (period == 0) return CalcError.DomainError; + var cursor = try AmortizationCursor.init(p); + while (cursor.next()) |entry| { + if (entry.period == period) return entry; + } + // The loan was retired before this period, so the period does not exist. + return CalcError.DomainError; +} + +/// The full schedule. Caller owns the returned slice. +pub fn amortizationSchedule( + allocator: std.mem.Allocator, + p: AmortizationParams, +) CalcError![]AmortizationEntry { + var cursor = try AmortizationCursor.init(p); + var rows: std.ArrayList(AmortizationEntry) = .empty; + errdefer rows.deinit(allocator); + while (cursor.next()) |entry| { + try rows.append(allocator, entry); + } + return rows.toOwnedSlice(allocator); +} + +/// Schedule totals, computed without allocating a table. +pub fn amortizationTotals(p: AmortizationParams) CalcError!AmortizationTotals { + var cursor = try AmortizationCursor.init(p); + var totals: AmortizationTotals = .{ .periods = 0, .paid = 0, .interest = 0, .principal = 0 }; + while (cursor.next()) |entry| { + totals.periods = entry.period; + totals.paid += entry.payment; + totals.interest += entry.interest; + totals.principal += entry.principal; + } + if (p.round_cents) { + totals.paid = roundToCents(totals.paid); + totals.interest = roundToCents(totals.interest); + totals.principal = roundToCents(totals.principal); + } + return totals; +} + +// -- Tests -- + +const testing = std.testing; + +// Textbook and well-known reference values throughout. The mortgage figure in +// particular (200,000 at 6% over 30 years giving 1199.10 a month) is a standard +// check that any TVM implementation should reproduce. + +test "cagr: textbook 10000 to 25000 over 5 years" { + const result = try cagr(10000, 25000, 5); + try testing.expectApproxEqAbs(@as(f64, 0.2011244), result, 1e-7); +} + +test "cagr: doubling in 10 years" { + const result = try cagr(1000, 2000, 10); + // 2^(1/10) - 1 + try testing.expectApproxEqAbs(@as(f64, 0.0717734625), result, 1e-9); +} + +test "cagr: no change is zero growth" { + try testing.expectApproxEqAbs(@as(f64, 0.0), try cagr(500, 500, 3), 1e-15); +} + +test "cagr: a decline is negative" { + const result = try cagr(1000, 500, 5); + try testing.expect(result < 0); + // (0.5)^(1/5) - 1 + try testing.expectApproxEqAbs(@as(f64, -0.1294494), result, 1e-7); +} + +test "cagr: single period is the simple return" { + try testing.expectApproxEqAbs(@as(f64, 0.25), try cagr(100, 125, 1), 1e-12); +} + +test "cagr: domain errors" { + try testing.expectError(CalcError.DomainError, cagr(1000, 2000, 0)); + try testing.expectError(CalcError.DomainError, cagr(1000, 2000, -5)); + try testing.expectError(CalcError.DomainError, cagr(0, 2000, 5)); + try testing.expectError(CalcError.DomainError, cagr(-1000, 2000, 5)); + try testing.expectError(CalcError.DomainError, cagr(1000, -1, 5)); +} + +test "compound interest: annual compounding" { + // 1000 at 5% for 10 years, compounded annually + const fv = try compoundFutureValue(1000, 5, 10, 1); + try testing.expectApproxEqAbs(@as(f64, 1628.894627), fv, 1e-6); +} + +test "compound interest: monthly compounding beats annual" { + const monthly = try compoundFutureValue(1000, 5, 10, 12); + const annual = try compoundFutureValue(1000, 5, 10, 1); + try testing.expectApproxEqAbs(@as(f64, 1647.009498), monthly, 1e-6); + try testing.expect(monthly > annual); +} + +test "compound interest: daily compounding" { + const fv = try compoundFutureValue(1000, 5, 10, 365); + try testing.expectApproxEqAbs(@as(f64, 1648.6648), fv, 1e-4); +} + +test "compound interest: zero years is the principal" { + try testing.expectApproxEqAbs(@as(f64, 1000.0), try compoundFutureValue(1000, 5, 0, 12), 1e-12); +} + +test "compound interest: zero rate is the principal" { + try testing.expectApproxEqAbs(@as(f64, 1000.0), try compoundFutureValue(1000, 0, 10, 12), 1e-12); +} + +test "compound interest: present value inverts future value" { + const fv = try compoundFutureValue(1000, 7, 15, 4); + const pv = try compoundPresentValue(fv, 7, 15, 4); + try testing.expectApproxEqAbs(@as(f64, 1000.0), pv, 1e-9); +} + +test "compound interest: present value textbook figure" { + // What is 10000 in 5 years worth today at 8% compounded annually? + const pv = try compoundPresentValue(10000, 8, 5, 1); + try testing.expectApproxEqAbs(@as(f64, 6805.83), pv, 0.01); +} + +test "compound interest: domain errors" { + try testing.expectError(CalcError.DomainError, compoundFutureValue(1000, 5, 10, 0)); + try testing.expectError(CalcError.DomainError, compoundFutureValue(1000, 5, -1, 12)); + try testing.expectError(CalcError.DomainError, compoundPresentValue(1000, 5, 10, 0)); + // A rate of -100% per period wipes the base out entirely. + try testing.expectError(CalcError.DomainError, compoundFutureValue(1000, -1200, 10, 12)); +} + +test "tvm: solve payment for a classic 30-year mortgage" { + // 200,000 borrowed at 6% a year over 360 monthly periods. + const solution = try solveTvm(.{ + .periods = 360, + .rate = 0.5, // 6% / 12 + .present_value = 200000, + .future_value = 0, + }); + try testing.expectEqual(TvmVariable.payment, solution.variable); + // Payment is negative: money leaving the borrower. + // 200000 * 0.005 / (1 - 1.005^-360) + try testing.expectApproxEqAbs(@as(f64, -1199.10105030), solution.value, 1e-6); +} + +test "tvm: solve future value of a savings plan" { + // 100 deposited at the end of each period for 10 periods at 5%. + const solution = try solveTvm(.{ + .periods = 10, + .rate = 5, + .present_value = 0, + .payment = -100, + }); + try testing.expectEqual(TvmVariable.future_value, solution.variable); + try testing.expectApproxEqAbs(@as(f64, 1257.789254), solution.value, 1e-6); +} + +test "tvm: solve present value" { + const solution = try solveTvm(.{ + .periods = 10, + .rate = 7, + .payment = 0, + .future_value = 2000, + }); + try testing.expectEqual(TvmVariable.present_value, solution.variable); + // 2000 discounted 10 periods at 7%: -2000 / 1.07^10 + try testing.expectApproxEqAbs(@as(f64, -1016.69858427), solution.value, 1e-6); +} + +test "tvm: solve periods to double at 7 percent" { + const solution = try solveTvm(.{ + .rate = 7, + .present_value = -1000, + .payment = 0, + .future_value = 2000, + }); + try testing.expectEqual(TvmVariable.periods, solution.variable); + // ln(2) / ln(1.07) + try testing.expectApproxEqAbs(@as(f64, 10.244768), solution.value, 1e-6); +} + +test "tvm: solve rate to double in 10 periods" { + const solution = try solveTvm(.{ + .periods = 10, + .present_value = -1000, + .payment = 0, + .future_value = 2000, + }); + try testing.expectEqual(TvmVariable.rate, solution.variable); + // 2^(1/10) - 1, as a percentage + try testing.expectApproxEqAbs(@as(f64, 7.17734625), solution.value, 1e-6); + try testing.expect(solution.iterations != null); +} + +test "tvm: solve rate for a mortgage payment" { + // Recover the 0.5% periodic rate from the payment it produces. + const solution = try solveTvm(.{ + .periods = 360, + .present_value = 200000, + .payment = -1199.101083, + .future_value = 0, + }); + try testing.expectApproxEqAbs(@as(f64, 0.5), solution.value, 1e-6); +} + +test "tvm: solve rate with both a payment and a future value" { + const solution = try solveTvm(.{ + .periods = 20, + .present_value = -5000, + .payment = -100, + .future_value = 12000, + }); + // Verify by substituting back into the equation rather than hardcoding a + // figure: the residual is the definition of a correct answer. + const residual = tvmResidual(solution.value / 100.0, 20, -5000, -100, 12000, false); + try testing.expectApproxEqAbs(@as(f64, 0.0), residual, 1e-6); +} + +test "tvm: every solved variable reproduces the others" { + // Round-trip: solve each variable from the other four and confirm the + // original value comes back. + const n: f64 = 120; + const rate: f64 = 0.75; + const pv: f64 = 50000; + const pmt: f64 = -600; + + const fv_solution = try solveTvm(.{ .periods = n, .rate = rate, .present_value = pv, .payment = pmt }); + const fv = fv_solution.value; + + const back_pv = try solveTvm(.{ .periods = n, .rate = rate, .payment = pmt, .future_value = fv }); + try testing.expectApproxEqAbs(pv, back_pv.value, 1e-6); + + const back_pmt = try solveTvm(.{ .periods = n, .rate = rate, .present_value = pv, .future_value = fv }); + try testing.expectApproxEqAbs(pmt, back_pmt.value, 1e-6); + + const back_n = try solveTvm(.{ .rate = rate, .present_value = pv, .payment = pmt, .future_value = fv }); + try testing.expectApproxEqAbs(n, back_n.value, 1e-6); + + const back_rate = try solveTvm(.{ .periods = n, .present_value = pv, .payment = pmt, .future_value = fv }); + try testing.expectApproxEqAbs(rate, back_rate.value, 1e-6); +} + +test "tvm: zero rate is handled as the linear case" { + // No interest: 10 payments of 100 exactly repay 1000. + const solution = try solveTvm(.{ + .periods = 10, + .rate = 0, + .present_value = 1000, + .future_value = 0, + }); + try testing.expectApproxEqAbs(@as(f64, -100.0), solution.value, 1e-12); + + const periods = try solveTvm(.{ + .rate = 0, + .present_value = 1000, + .payment = -100, + .future_value = 0, + }); + try testing.expectApproxEqAbs(@as(f64, 10.0), periods.value, 1e-12); +} + +test "tvm: annuity due pays less than an ordinary annuity" { + // Paying at the start of each period means every payment earns interest for + // one extra period, so a smaller payment settles the same loan. + const ordinary = try solveTvm(.{ + .periods = 360, + .rate = 0.5, + .present_value = 200000, + .future_value = 0, + .due = false, + }); + const due = try solveTvm(.{ + .periods = 360, + .rate = 0.5, + .present_value = 200000, + .future_value = 0, + .due = true, + }); + try testing.expect(@abs(due.value) < @abs(ordinary.value)); + // Exactly a factor of (1 + r) smaller. + try testing.expectApproxEqAbs(ordinary.value / 1.005, due.value, 1e-6); +} + +test "tvm: annuity due round-trips too" { + const solution = try solveTvm(.{ + .periods = 24, + .rate = 1, + .present_value = 10000, + .due = true, + .future_value = 0, + }); + const residual = tvmResidual(0.01, 24, 10000, solution.value, 0, true); + try testing.expectApproxEqAbs(@as(f64, 0.0), residual, 1e-6); +} + +test "tvm: requires exactly one unknown" { + // All five supplied. + try testing.expectError(CalcError.InsufficientParameters, solveTvm(.{ + .periods = 10, + .rate = 5, + .present_value = 100, + .payment = -10, + .future_value = 0, + })); + // Two unknowns. + try testing.expectError(CalcError.InsufficientParameters, solveTvm(.{ + .periods = 10, + .rate = 5, + .present_value = 100, + })); + // Nothing supplied at all. + try testing.expectError(CalcError.InsufficientParameters, solveTvm(.{})); +} + +test "tvm: unsolvable cash flows report convergence failure, not a wrong answer" { + // All cash flows the same sign: no rate can balance the equation. + const result = solveTvm(.{ + .periods = 10, + .present_value = 1000, + .payment = 100, + .future_value = 5000, + }); + try testing.expectError(CalcError.ConvergenceFailure, result); +} + +test "tvm: rate solver rejects degenerate input" { + try testing.expectError(CalcError.InsufficientParameters, solveTvm(.{ + .periods = 10, + .present_value = 0, + .payment = 0, + .future_value = 0, + })); + try testing.expectError(CalcError.DomainError, solveTvm(.{ + .periods = 0, + .present_value = -100, + .payment = 0, + .future_value = 200, + })); +} + +test "tvm: unreachable future value has no real period count" { + // Paying nothing can never grow 1000 into 5000. + try testing.expectError(CalcError.DomainError, solveTvm(.{ + .rate = 5, + .present_value = 1000, + .payment = 0, + .future_value = 5000, + })); +} + +test "tvm: periods with zero rate and zero payment is unsolvable" { + try testing.expectError(CalcError.InsufficientParameters, solveTvm(.{ + .rate = 0, + .present_value = 1000, + .payment = 0, + .future_value = -1000, + })); +} + +test "roundToScale: half-even avoids the upward bias of half-up" { + // The classic pair: both are exactly halfway, and half-even splits them. + try testing.expectEqual(@as(f64, 0.02), roundToScale(0.025, 2, .half_even)); + try testing.expectEqual(@as(f64, 0.04), roundToScale(0.035, 2, .half_even)); + // Half-up sends both the same direction. + try testing.expectEqual(@as(f64, 0.03), roundToScale(0.025, 2, .half_up)); + try testing.expectEqual(@as(f64, 0.04), roundToScale(0.035, 2, .half_up)); +} + +test "roundToScale: ordinary cases" { + try testing.expectApproxEqAbs(@as(f64, 1.23), roundToScale(1.2345, 2, .half_even), 1e-12); + try testing.expectApproxEqAbs(@as(f64, 1.24), roundToScale(1.2355, 2, .half_even), 1e-12); + try testing.expectApproxEqAbs(@as(f64, 1.0), roundToScale(1.4, 0, .half_even), 1e-12); + try testing.expectApproxEqAbs(@as(f64, 2.0), roundToScale(1.6, 0, .half_even), 1e-12); +} + +test "roundToScale: negatives round symmetrically for half-up" { + try testing.expectApproxEqAbs(@as(f64, -1.24), roundToScale(-1.235, 2, .half_up), 1e-12); + try testing.expectApproxEqAbs(@as(f64, -1.23), roundToScale(-1.2345, 2, .half_up), 1e-12); +} + +test "roundToScale: non-finite values pass through" { + try testing.expect(math.isNan(roundToScale(math.nan(f64), 2, .half_even))); + try testing.expect(math.isPositiveInf(roundToScale(math.inf(f64), 2, .half_even))); +} + +test "roundToScale: absurd scales are left alone rather than overflowing" { + const huge = 1.5e300; + try testing.expectEqual(huge, roundToScale(huge, 2, .half_even)); + try testing.expectEqual(@as(f64, 1.2345), roundToScale(1.2345, 18, .half_even)); +} + +test "roundToCents: mortgage payment to whole cents" { + const solution = try solveTvm(.{ + .periods = 360, + .rate = 0.5, + .present_value = 200000, + .future_value = 0, + }); + try testing.expectApproxEqAbs(@as(f64, -1199.1), roundToCents(solution.value), 1e-12); +} + +test "TvmVariable labels match calculator conventions" { + try testing.expectEqualStrings("N", TvmVariable.periods.label()); + try testing.expectEqualStrings("I/Y", TvmVariable.rate.label()); + try testing.expectEqualStrings("PV", TvmVariable.present_value.label()); + try testing.expectEqualStrings("PMT", TvmVariable.payment.label()); + try testing.expectEqualStrings("FV", TvmVariable.future_value.label()); +} + +test "annuityFactor: rate zero is the period count, not a division by zero" { + try testing.expectEqual(@as(f64, 10.0), annuityFactor(0, 10, false)); + try testing.expectEqual(@as(f64, 10.0), annuityFactor(0, 10, true)); +} + +test "annuityFactor: due is an extra period of interest" { + const ordinary = annuityFactor(0.05, 10, false); + const due = annuityFactor(0.05, 10, true); + try testing.expectApproxEqAbs(ordinary * 1.05, due, 1e-12); +} + +// -- Amortization tests -- + +test "amortization: payment matches the TVM solution rounded to cents" { + const payment = try amortizationPayment(.{ .principal = 200000, .rate = 0.5, .periods = 360 }); + try testing.expectEqual(@as(f64, 1199.10), payment); +} + +test "amortization: an explicit payment is used as given" { + const payment = try amortizationPayment(.{ + .principal = 200000, + .rate = 0.5, + .periods = 360, + .payment = 1500, + }); + try testing.expectEqual(@as(f64, 1500.0), payment); +} + +test "amortization: first row of a classic 30-year mortgage" { + const entry = try amortizationEntry(.{ .principal = 200000, .rate = 0.5, .periods = 360 }, 1); + try testing.expectEqual(@as(usize, 1), entry.period); + // 200,000 * 0.5% = exactly 1000 of interest, so the rest reduces principal. + try testing.expectEqual(@as(f64, 1000.0), entry.interest); + try testing.expectEqual(@as(f64, 1199.10), entry.payment); + try testing.expectApproxEqAbs(@as(f64, 199.10), entry.principal, 1e-9); + try testing.expectApproxEqAbs(@as(f64, 199800.90), entry.balance, 1e-9); +} + +test "amortization: interest falls and principal rises over the life of the loan" { + const params: AmortizationParams = .{ .principal = 200000, .rate = 0.5, .periods = 360 }; + const first = try amortizationEntry(params, 1); + const middle = try amortizationEntry(params, 180); + const last = try amortizationEntry(params, 360); + + try testing.expect(first.interest > middle.interest); + try testing.expect(middle.interest > last.interest); + try testing.expect(first.principal < middle.principal); + try testing.expect(middle.principal < last.principal); +} + +test "amortization: the schedule ends at a zero balance" { + const rows = try amortizationSchedule(testing.allocator, .{ + .principal = 200000, + .rate = 0.5, + .periods = 360, + }); + defer testing.allocator.free(rows); + + try testing.expectEqual(@as(usize, 360), rows.len); + try testing.expectEqual(@as(f64, 0.0), rows[rows.len - 1].balance); +} + +test "amortization: principal repaid sums to the loan amount" { + const rows = try amortizationSchedule(testing.allocator, .{ + .principal = 200000, + .rate = 0.5, + .periods = 360, + }); + defer testing.allocator.free(rows); + + var sum: f64 = 0; + for (rows) |row| sum += row.principal; + // Cent rounding is absorbed by the final payment, so this is exact to the + // cent rather than merely close. + try testing.expectApproxEqAbs(@as(f64, 200000.0), sum, 0.005); +} + +test "amortization: every row is payment = interest + principal" { + const rows = try amortizationSchedule(testing.allocator, .{ + .principal = 25000, + .rate = 0.375, + .periods = 60, + }); + defer testing.allocator.free(rows); + + for (rows) |row| { + try testing.expectApproxEqAbs(row.payment, row.interest + row.principal, 1e-9); + } +} + +test "amortization: the balance never increases" { + const rows = try amortizationSchedule(testing.allocator, .{ + .principal = 15000, + .rate = 1.0, + .periods = 48, + }); + defer testing.allocator.free(rows); + + var previous: f64 = 15000; + for (rows) |row| { + try testing.expect(row.balance <= previous); + previous = row.balance; + } +} + +test "amortization: totals agree with the generated schedule" { + const params: AmortizationParams = .{ .principal = 200000, .rate = 0.5, .periods = 360 }; + const rows = try amortizationSchedule(testing.allocator, params); + defer testing.allocator.free(rows); + + var paid: f64 = 0; + var interest: f64 = 0; + for (rows) |row| { + paid += row.payment; + interest += row.interest; + } + + const totals = try amortizationTotals(params); + try testing.expectEqual(@as(usize, 360), totals.periods); + try testing.expectApproxEqAbs(roundToCents(paid), totals.paid, 0.005); + try testing.expectApproxEqAbs(roundToCents(interest), totals.interest, 0.005); + // A 30-year 6% mortgage costs more in interest than the house. + try testing.expect(totals.interest > 231000 and totals.interest < 232000); + try testing.expectApproxEqAbs(totals.paid, totals.interest + totals.principal, 0.02); +} + +test "amortization: zero rate splits the principal evenly" { + const rows = try amortizationSchedule(testing.allocator, .{ + .principal = 1200, + .rate = 0, + .periods = 12, + }); + defer testing.allocator.free(rows); + + try testing.expectEqual(@as(usize, 12), rows.len); + for (rows) |row| { + try testing.expectEqual(@as(f64, 0.0), row.interest); + try testing.expectEqual(@as(f64, 100.0), row.payment); + } + try testing.expectEqual(@as(f64, 0.0), rows[11].balance); +} + +test "amortization: a larger payment retires the loan early" { + const params: AmortizationParams = .{ + .principal = 200000, + .rate = 0.5, + .periods = 360, + .payment = 2000, + }; + const rows = try amortizationSchedule(testing.allocator, params); + defer testing.allocator.free(rows); + + try testing.expect(rows.len < 360); + try testing.expectEqual(@as(f64, 0.0), rows[rows.len - 1].balance); + // The last payment is the one that clears the balance, so it is no larger + // than a full payment. + try testing.expect(rows[rows.len - 1].payment <= 2000); + + const totals = try amortizationTotals(params); + try testing.expectEqual(rows.len, totals.periods); + // Paying faster costs less interest than the scheduled payment would. + const scheduled = try amortizationTotals(.{ .principal = 200000, .rate = 0.5, .periods = 360 }); + try testing.expect(totals.interest < scheduled.interest); +} + +test "amortization: an underfunded term ends in a balloon payment" { + // 200,000 at 6% needs 1199.10 a month. 1100 covers the interest but does not + // retire the loan in 360 periods, so the last period carries the remainder. + const params: AmortizationParams = .{ + .principal = 200000, + .rate = 0.5, + .periods = 360, + .payment = 1100, + }; + const rows = try amortizationSchedule(testing.allocator, params); + defer testing.allocator.free(rows); + + try testing.expectEqual(@as(usize, 360), rows.len); + const last = rows[rows.len - 1]; + try testing.expectEqual(@as(f64, 0.0), last.balance); + // Roughly 100,000 still owed at the end, paid in one lump. + try testing.expect(last.payment > 90000 and last.payment < 110000); +} + +test "amortization: a payment below the first interest charge is rejected" { + // 1000 of interest in month one, so 500 never touches principal. + try testing.expectError(CalcError.DomainError, amortizationPayment(.{ + .principal = 200000, + .rate = 0.5, + .periods = 360, + .payment = -1, + })); + try testing.expectError(CalcError.DomainError, amortizationEntry(.{ + .principal = 200000, + .rate = 0.5, + .periods = 360, + .payment = 500, + }, 1)); +} + +test "amortization: rejects nonsense loan terms" { + try testing.expectError(CalcError.DomainError, amortizationPayment(.{ + .principal = 0, + .rate = 0.5, + .periods = 12, + })); + try testing.expectError(CalcError.DomainError, amortizationPayment(.{ + .principal = 1000, + .rate = 0.5, + .periods = 0, + })); + try testing.expectError(CalcError.DomainError, amortizationPayment(.{ + .principal = 1000, + .rate = -1, + .periods = 12, + })); + try testing.expectError(CalcError.DomainError, amortizationPayment(.{ + .principal = 1000, + .rate = 0.5, + .periods = max_schedule_periods + 1, + })); +} + +test "amortization: periods outside the schedule are an error" { + const params: AmortizationParams = .{ .principal = 1200, .rate = 0, .periods = 12 }; + try testing.expectError(CalcError.DomainError, amortizationEntry(params, 0)); + try testing.expectError(CalcError.DomainError, amortizationEntry(params, 13)); +} + +test "amortization: unrounded mode keeps full precision" { + const params: AmortizationParams = .{ + .principal = 200000, + .rate = 0.5, + .periods = 360, + .round_cents = false, + }; + const payment = try amortizationPayment(params); + try testing.expectApproxEqAbs(@as(f64, 1199.10105030), payment, 1e-6); + + const rows = try amortizationSchedule(testing.allocator, params); + defer testing.allocator.free(rows); + // With the exact payment there is no residue for the last period to absorb, + // so every payment is identical. + try testing.expectApproxEqAbs(rows[0].payment, rows[rows.len - 1].payment, 1e-6); +} + +test "amortization: schedule allocation failure frees the partial table" { + // Sweeps the failure point across every allocation the schedule makes, which + // is what exercises the errdefer that releases a half-built table. + var fail_index: usize = 0; + while (fail_index < 64) : (fail_index += 1) { + var failing = std.testing.FailingAllocator.init(testing.allocator, .{ .fail_index = fail_index }); + const allocator = failing.allocator(); + if (amortizationSchedule(allocator, .{ .principal = 1200, .rate = 1, .periods = 12 })) |rows| { + allocator.free(rows); + return; + } else |err| { + try testing.expectEqual(CalcError.OutOfMemory, err); + } + } + return error.AllocationSweepNeverCompleted; +} + +// -- Solving compound interest for rate and time -- + +test "compoundRate: inverts compoundFutureValue" { + // 1000 grows to 1628.894627 at 5% over 10 years, annually. + const rate = try compoundRate(1000, 1628.894627, 10, 1); + try testing.expectApproxEqAbs(@as(f64, 5.0), rate, 1e-6); + + // And with monthly compounding, where the answer is the nominal rate. + const monthly_fv = try compoundFutureValue(1000, 5, 10, 12); + const monthly_rate = try compoundRate(1000, monthly_fv, 10, 12); + try testing.expectApproxEqAbs(@as(f64, 5.0), monthly_rate, 1e-9); +} + +test "compoundRate: at annual compounding it agrees with CAGR" { + // Same question, two entry points: they must not disagree. + const rate = try compoundRate(10000, 25000, 5, 1); + const growth_rate = try cagr(10000, 25000, 5); + try testing.expectApproxEqAbs(growth_rate * 100.0, rate, 1e-12); + try testing.expectApproxEqAbs(@as(f64, 20.11244), rate, 1e-5); +} + +test "compoundRate: doubling money" { + // 2^(1/10) - 1 as a percentage. + try testing.expectApproxEqAbs(@as(f64, 7.17734625), try compoundRate(1000, 2000, 10, 1), 1e-8); + // Compounded monthly the nominal rate needed is lower: 12*(2^(1/120) - 1). + const monthly = try compoundRate(1000, 2000, 10, 12); + try testing.expect(monthly < 7.17734625); + try testing.expectApproxEqAbs(@as(f64, 6.95152928), monthly, 1e-8); +} + +test "compoundRate: a loss is a negative rate, not an error" { + const rate = try compoundRate(1000, 500, 5, 1); + try testing.expect(rate < 0); + try testing.expectApproxEqAbs(@as(f64, -12.94494), rate, 1e-5); +} + +test "compoundRate: unchanged value is a zero rate" { + try testing.expectApproxEqAbs(@as(f64, 0), try compoundRate(1000, 1000, 5, 4), 1e-12); +} + +test "compoundRate: domain errors" { + // No time elapsed: nothing to solve. + try testing.expectError(CalcError.DomainError, compoundRate(1000, 2000, 0, 1)); + try testing.expectError(CalcError.DomainError, compoundRate(1000, 2000, -5, 1)); + // No compounding frequency. + try testing.expectError(CalcError.DomainError, compoundRate(1000, 2000, 10, 0)); + try testing.expectError(CalcError.DomainError, compoundRate(1000, 2000, 10, -12)); + // Nothing to grow from. + try testing.expectError(CalcError.DomainError, compoundRate(0, 2000, 10, 1)); + // A sign change has no real root. + try testing.expectError(CalcError.DomainError, compoundRate(1000, -500, 10, 1)); + try testing.expectError(CalcError.DomainError, compoundRate(-1000, 500, 10, 1)); + // Reaching exactly zero would need a rate of -100%, which is a limit. + try testing.expectError(CalcError.DomainError, compoundRate(1000, 0, 10, 1)); +} + +test "compoundPeriods: inverts compoundFutureValue" { + const years = try compoundPeriods(1000, 1628.894627, 5, 1); + try testing.expectApproxEqAbs(@as(f64, 10.0), years, 1e-6); + + const monthly_fv = try compoundFutureValue(1000, 5, 10, 12); + const monthly_years = try compoundPeriods(1000, monthly_fv, 5, 12); + try testing.expectApproxEqAbs(@as(f64, 10.0), monthly_years, 1e-9); +} + +test "compoundPeriods: doubling at 7 percent takes about ten years" { + // ln(2)/ln(1.07), the same figure the TVM period solver produces. + const years = try compoundPeriods(1000, 2000, 7, 1); + try testing.expectApproxEqAbs(@as(f64, 10.244768), years, 1e-6); +} + +test "compoundPeriods: more frequent compounding gets there sooner" { + const annual = try compoundPeriods(1000, 2000, 7, 1); + const monthly = try compoundPeriods(1000, 2000, 7, 12); + try testing.expect(monthly < annual); +} + +test "compoundPeriods: a shrinking balance takes time to fall" { + const years = try compoundPeriods(1000, 500, -10, 1); + try testing.expect(years > 0); + // ln(0.5)/ln(0.9) + try testing.expectApproxEqAbs(@as(f64, 6.5788), years, 1e-4); +} + +test "compoundPeriods: already there takes no time at all" { + try testing.expectEqual(@as(f64, 0), try compoundPeriods(1000, 1000, 5, 1)); + // Even at a zero rate, since no growth is needed. + try testing.expectEqual(@as(f64, 0), try compoundPeriods(1000, 1000, 0, 1)); +} + +test "compoundPeriods: domain errors" { + // A zero rate never reaches a different value. + try testing.expectError(CalcError.DomainError, compoundPeriods(1000, 2000, 0, 1)); + // -100% or worse is not a rate. + try testing.expectError(CalcError.DomainError, compoundPeriods(1000, 2000, -100, 1)); + try testing.expectError(CalcError.DomainError, compoundPeriods(1000, 2000, -150, 1)); + try testing.expectError(CalcError.DomainError, compoundPeriods(1000, 2000, 5, 0)); + try testing.expectError(CalcError.DomainError, compoundPeriods(0, 2000, 5, 1)); + try testing.expectError(CalcError.DomainError, compoundPeriods(1000, -2000, 5, 1)); +} + +test "effectiveAnnualRate: monthly compounding beats its nominal rate" { + try testing.expectApproxEqAbs(@as(f64, 19.5618), try effectiveAnnualRate(18, 12), 1e-4); + try testing.expectApproxEqAbs(@as(f64, 5.11619), try effectiveAnnualRate(5, 12), 1e-5); +} + +test "effectiveAnnualRate: annual compounding is its own effective rate" { + try testing.expectApproxEqAbs(@as(f64, 5.0), try effectiveAnnualRate(5, 1), 1e-12); + try testing.expectApproxEqAbs(@as(f64, 0.0), try effectiveAnnualRate(0, 12), 1e-12); +} + +test "effectiveAnnualRate: a negative nominal rate stays negative" { + const effective = try effectiveAnnualRate(-10, 12); + try testing.expect(effective < 0); + try testing.expect(effective > -10); +} + +test "effectiveAnnualRate: domain errors" { + try testing.expectError(CalcError.DomainError, effectiveAnnualRate(5, 0)); + try testing.expectError(CalcError.DomainError, effectiveAnnualRate(-100, 1)); +} + +test "compound interest: the four variables round-trip through each other" { + // One consistent set, solved for each variable in turn. + const pv: f64 = 5000; + const rate: f64 = 6.5; + const years: f64 = 8; + const per_year: f64 = 4; + + const fv = try compoundFutureValue(pv, rate, years, per_year); + try testing.expectApproxEqAbs(pv, try compoundPresentValue(fv, rate, years, per_year), 1e-9); + 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); +} diff --git a/engine/src/formatter.zig b/engine/src/formatter.zig index 956d049..ab0e5b0 100644 --- a/engine/src/formatter.zig +++ b/engine/src/formatter.zig @@ -5,7 +5,9 @@ //! - `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") +//! - 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: underscore per 16-bit word (e.g. "0xFFFF_FFFF") //! - Binary: space per nibble (e.g. "1111 1111") //! - Octal: underscore per 3-digit group (e.g. "0o37_777_777_777") @@ -55,9 +57,21 @@ pub fn formatFloat(buf: []u8, value: f64) FormattedValue { return .{ .display = buf[0..raw_len], .raw = buf[0..raw_len] }; } - // Regular float formatting + // 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; - return .{ .display = buf[0..raw_len], .raw = buf[0..raw_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). @@ -225,32 +239,52 @@ pub const NumberDisplay = struct { /// 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; +} + +/// Bytes `writeGroupedDecimal` will produce for `text`. Equal to `text.len` when +/// there is nothing to group, which callers use to skip the copy entirely. +/// +/// Public because the TUI groups partially typed input, where reformatting +/// through an f64 would discard what the user typed (trailing zeros, a lone +/// decimal point). +pub fn groupedDecimalLen(text: []const u8) usize { + // Text already in scientific notation has no long integer part to group, and + // inserting commas around an exponent would only corrupt it. + if (std.mem.indexOfAny(u8, text, "eE") != null) return text.len; + const int_digits = integerDigitCount(text); + if (int_digits <= 3) return text.len; + return text.len + (int_digits - 1) / 3; +} + +/// Copy `text` into `dest` with commas grouping the integer part. `dest` must be +/// at least `groupedDecimalLen(text)` bytes and must not overlap `text`. +pub fn writeGroupedDecimal(dest: []u8, text: []const u8) usize { var start: usize = 0; if (text.len > 0 and (text[0] == '-' or text[0] == '+')) start = 1; const dot = std.mem.indexOfScalar(u8, text, '.') orelse text.len; const int_digits = dot - start; - // Nothing to group. - if (int_digits <= 3) return allocator.dupe(u8, text); - - const separators = (int_digits - 1) / 3; - var out = try allocator.alloc(u8, text.len + separators); - - var w: usize = 0; - @memcpy(out[0..start], text[0..start]); - w = start; + @memcpy(dest[0..start], text[0..start]); + var w: usize = start; var i: usize = 0; while (i < int_digits) : (i += 1) { if (i > 0 and (int_digits - i) % 3 == 0) { - out[w] = ','; + dest[w] = ','; w += 1; } - out[w] = text[start + i]; + dest[w] = text[start + i]; w += 1; } - @memcpy(out[w..], text[dot..]); - return out; + const tail = text[dot..]; + @memcpy(dest[w..][0..tail.len], tail); + return w + tail.len; } /// Format an integer for programmer mode hex display. @@ -1050,3 +1084,124 @@ test "integerDigitCount ignores sign and fraction" { try testing.expectEqual(@as(usize, 3), integerDigitCount("123.456")); try testing.expectEqual(@as(usize, 1), 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); +} diff --git a/engine/src/parser.zig b/engine/src/parser.zig index 7d69d3b..00b52c6 100644 --- a/engine/src/parser.zig +++ b/engine/src/parser.zig @@ -65,9 +65,17 @@ pub const Parser = struct { } /// Parse a complete expression. Returns error if parsing fails. + /// + /// On success the caller owns the tree and must release it with `freeExpr`. + /// On failure nothing is returned and nothing is left allocated: every error + /// path below frees what it built. Without that, a single typo in an + /// interactive session leaks the partial tree, which is exactly what the TUI + /// does on every keystroke-completed expression. pub fn parse(self: *Parser) CalcError!*Expr { const expr = try self.parseExpr(.none); if (self.current.kind != .eof) { + // Trailing tokens: the tree parsed so far is unreachable. + freeExpr(self.allocator, expr); self.had_error = true; self.error_pos = self.current.start; return CalcError.UnexpectedToken; @@ -78,6 +86,9 @@ pub const Parser = struct { /// Parse an expression with the given minimum precedence. fn parseExpr(self: *Parser, min_prec: Prec) CalcError!*Expr { var left = try self.parsePrefix(); + // Each successful parseInfix returns a node that has adopted `left`, so + // this errdefer always covers the whole tree built so far. + errdefer freeExpr(self.allocator, left); while (true) { const prec = self.infixPrecedence(self.current.kind); @@ -119,6 +130,7 @@ pub const Parser = struct { // "not" prefix keyword = bitwise NOT if (std.mem.eql(u8, name, "not")) { const operand = try self.parseExpr(.unary); + errdefer freeExpr(self.allocator, operand); return self.makeNode(.{ .unary = .{ .op = .bitwise_not, .operand = operand, @@ -129,6 +141,7 @@ pub const Parser = struct { if (self.current.kind == .equals) { self.advance(); const value = try self.parseExpr(.none); + errdefer freeExpr(self.allocator, value); return self.makeNode(.{ .assignment = .{ .name = name, .value = value, @@ -140,15 +153,23 @@ pub const Parser = struct { self.advance(); // consume ( var args = std.ArrayList(*Expr).empty; defer args.deinit(self.allocator); + // Arguments parsed before the failure still own their trees. + errdefer for (args.items) |arg| freeExpr(self.allocator, arg); if (self.current.kind != .right_paren) { const first_arg = try self.parseExpr(.none); - args.append(self.allocator, first_arg) catch return CalcError.OutOfMemory; + args.append(self.allocator, first_arg) catch { + freeExpr(self.allocator, first_arg); + return CalcError.OutOfMemory; + }; while (self.current.kind == .comma) { self.advance(); // consume , const arg = try self.parseExpr(.none); - args.append(self.allocator, arg) catch return CalcError.OutOfMemory; + args.append(self.allocator, arg) catch { + freeExpr(self.allocator, arg); + return CalcError.OutOfMemory; + }; } } @@ -161,6 +182,7 @@ pub const Parser = struct { const args_slice = self.allocator.dupe(*Expr, args.items) catch return CalcError.OutOfMemory; + errdefer self.allocator.free(args_slice); return self.makeNode(.{ .call = .{ .name = name, @@ -177,6 +199,7 @@ pub const Parser = struct { self.advance(); // consume ( const inner = try self.parseExpr(.none); if (self.current.kind != .right_paren) { + freeExpr(self.allocator, inner); self.had_error = true; self.error_pos = self.current.start; return CalcError.UnmatchedParen; @@ -187,6 +210,7 @@ pub const Parser = struct { .minus => { self.advance(); const operand = try self.parseExpr(.unary); + errdefer freeExpr(self.allocator, operand); return self.makeNode(.{ .unary = .{ .op = .negate, .operand = operand, @@ -195,6 +219,7 @@ pub const Parser = struct { .tilde => { self.advance(); const operand = try self.parseExpr(.unary); + errdefer freeExpr(self.allocator, operand); return self.makeNode(.{ .unary = .{ .op = .bitwise_not, .operand = operand, @@ -223,6 +248,9 @@ pub const Parser = struct { if (keywordBinaryOp(name)) |op| { self.advance(); const right = try self.parseExpr(prec); + // `left` belongs to the caller's errdefer until makeNode adopts + // it, so only the right operand is released here. + errdefer freeExpr(self.allocator, right); return self.makeNode(.{ .binary = .{ .op = op, .left = left, @@ -245,6 +273,7 @@ pub const Parser = struct { prec; const right = try self.parseExpr(next_prec); + errdefer freeExpr(self.allocator, right); return self.makeNode(.{ .binary = .{ .op = op, .left = left, @@ -322,13 +351,12 @@ pub const Parser = struct { const testing = std.testing; -// Use an arena for tests so error paths don't leak +// Error-path tests used to need an arena because a failed parse leaked its +// partial tree. They no longer do (the parser cleans up after itself), but the +// arena helper is kept for the tests already written against it. var test_arena_instance = std.heap.ArenaAllocator.init(std.heap.page_allocator); fn testParse(source: []const u8, mode: Mode) !*Expr { - // Reset arena between test calls isn't needed since each test is independent - // and we use testing.allocator for successful parses (with manual free), - // but for error cases we need an arena. var parser = Parser.init(testing.allocator, source, mode); return parser.parse(); } @@ -339,7 +367,12 @@ fn testParseArena(source: []const u8, mode: Mode) CalcError!*Expr { return p.parse(); } -fn freeExpr(allocator: Allocator, expr: *Expr) void { +/// Release a parsed tree. +/// +/// The parser hands ownership of the tree to the caller, so every successful +/// `parse` needs a matching `freeExpr`. Error paths inside the parser clean up +/// after themselves, so a failed parse leaves nothing to free. +pub fn freeExpr(allocator: Allocator, expr: *Expr) void { switch (expr.*) { .number => {}, .string_literal => {}, @@ -578,3 +611,107 @@ test "parse error: identifier in infix position (not a keyword op)" { const result = testParseArena("5 foo", .standard); try testing.expectError(CalcError.UnexpectedToken, result); } + +// -- Ownership on the error paths -- +// +// These run on testing.allocator rather than an arena, so a partial tree left +// behind by a failed parse fails the test. Before the parser cleaned up after +// itself, every one of these inputs leaked, which mattered in the TUI: a typo +// at the prompt leaked the tree parsed up to that point. + +test "a failed parse leaves nothing allocated" { + const bad = [_][]const u8{ + "2 +", // missing right operand + "1 2", // trailing token after a complete expression + "(1 + 2", // unmatched paren + "(1 + (2 * 3)", // unmatched outer paren, nested tree built + "max(1, 2", // unterminated argument list + "max(1, 2,", // trailing comma then end + "sin(", // call with nothing in it + "-", // unary with no operand + "~", // bitwise not with no operand + "not", // keyword not with no operand + "x =", // assignment with no value + "y = 1 +", // assignment whose value fails to parse + "*", // operator in prefix position + "", // empty input + "1 + 2 3", // trailing token after a binary expression + "-(1 + ", // nested failure under a unary + }; + for (bad) |source| { + var parser = Parser.init(testing.allocator, source, .standard); + if (parser.parse()) |expr| { + freeExpr(testing.allocator, expr); + std.debug.print("expected a parse error for \"{s}\"\n", .{source}); + return error.TestUnexpectedResult; + } else |_| {} + } +} + +test "a failed parse in programmer mode also leaves nothing allocated" { + const bad = [_][]const u8{ "0xFF and", "1 rol", "not", "0b1010 xor (1", "1 << " }; + for (bad) |source| { + var parser = Parser.init(testing.allocator, source, .programmer); + if (parser.parse()) |expr| { + freeExpr(testing.allocator, expr); + std.debug.print("expected a parse error for \"{s}\"\n", .{source}); + return error.TestUnexpectedResult; + } else |_| {} + } +} + +test "a successful parse hands over exactly one tree to free" { + // The mirror of the above: freeing once must be enough and must not + // double-free any shared node. + const good = [_][]const u8{ + "1 + 2 * 3", + "-(4 + 5)", + "max(1, min(2, 3))", + "x = 2 ^ 3 ^ 4", + "not 0xFF", + "sqrt(2) + factorial(5)", + "cagr(10000, 25000, 5)", + "tvm_pmt(360, 0.5, 200000, 0)", + }; + for (good) |source| { + var parser = Parser.init(testing.allocator, source, .standard); + const expr = try parser.parse(); + freeExpr(testing.allocator, expr); + } +} + +test "an allocation failure mid-parse frees whatever was built" { + // The cleanup added for the error paths above is mostly reachable only when + // an allocation fails partway through, so it is swept rather than assumed. + // The wrapped testing.allocator reports any node the parser abandons. + const sources = [_][]const u8{ + "1 + 2 * 3", + "-(1 + 2)", + "~5", + "not 7", + "max(1, 2, 3)", + "min(max(1, 2), 3)", + "x = 1 + 2", + "1 rol 2", + "(((1)))", + "cagr(10000, 25000, 5)", + }; + for (sources) |source| { + var fail_index: usize = 0; + while (fail_index < 64) : (fail_index += 1) { + var failing = std.testing.FailingAllocator.init(testing.allocator, .{ .fail_index = fail_index }); + const allocator = failing.allocator(); + var parser = Parser.init(allocator, source, .standard); + if (parser.parse()) |expr| { + // Past the last allocation this input makes, so nothing is left + // to fail; the tree itself must still be well formed. + freeExpr(allocator, expr); + break; + } else |err| { + try testing.expectEqual(CalcError.OutOfMemory, err); + } + } else { + return error.AllocationSweepNeverCompleted; + } + } +} diff --git a/engine/src/programmer.zig b/engine/src/programmer.zig index 96cc02c..ac2b744 100644 --- a/engine/src/programmer.zig +++ b/engine/src/programmer.zig @@ -163,6 +163,9 @@ fn evalBinaryOp(config: ProgrammerConfig, op: BinaryOp, left: u128, right: u128) pub fn evalProgrammerString(allocator: Allocator, source: []const u8, config: ProgrammerConfig) CalcError!Integer { var p = Parser.init(allocator, source, .programmer); const expr = try p.parse(); + // Same ownership rule as evalStringInfo: the tree is ours to release, and the + // returned Integer does not borrow from it. + defer parser_mod.freeExpr(allocator, expr); return evalProgrammer(config, expr); } @@ -448,3 +451,18 @@ test "prog: arithmetic shift right amount >= width positive value" { const result = try testProgWith("0x40 >> 20", .{ .bit_width = .bits8 }); try testing.expectEqual(@as(u128, 0), result.unsignedValue()); } + +test "no leak: evalProgrammerString releases the parsed tree" { + // testing.allocator rather than an arena, so a retained AST fails the test. + const config: ProgrammerConfig = .{}; + const good = [_][]const u8{ "0xFF and 0x0F", "1 << 8", "not 0", "0b1010 xor 0b0101", "5 rol 2" }; + for (good) |source| { + _ = try evalProgrammerString(std.testing.allocator, source, config); + } + + const bad = [_][]const u8{ "0xFF and", "1 <<", "(1 | 2" }; + for (bad) |source| { + _ = evalProgrammerString(std.testing.allocator, source, config) catch continue; + return error.TestUnexpectedResult; + } +} diff --git a/src/main.zig b/src/main.zig index eac9408..476006b 100644 --- a/src/main.zig +++ b/src/main.zig @@ -21,6 +21,11 @@ pub const ParsedArgs = union(enum) { from: []const u8, to: []const u8, }, + amortization: struct { + params: engine.financial.AmortizationParams, + /// Print only the summary block, not the per-period rows. + summary_only: bool, + }, output: struct { text: []const u8, is_error: bool, @@ -38,6 +43,12 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const []const u8) ParsedA return parseConvertArgs(args[1..]); } + // "amort" subcommand: the one financial output that is a table rather than a + // single number, so it cannot be an expression function. + if (args.len > 0 and (std.mem.eql(u8, args[0], "amort") or std.mem.eql(u8, args[0], "amortize"))) { + return parseAmortArgs(args[1..]); + } + for (args) |arg| { if (std.mem.eql(u8, arg, "-p") or std.mem.eql(u8, arg, "--programmer")) { mode = .programmer; @@ -405,6 +416,212 @@ const convert_usage = \\ ; +const amort_usage = + \\usage: tally amort [payment] [flags] + \\ + \\flags: + \\ --monthly The rate given is an annual nominal rate and the periods are + \\ months, so rate/12 is charged each period + \\ --summary Print only the totals, not every period + \\ --exact Do not round each figure to whole cents + \\ + \\examples: + \\ tally amort 200000 0.5 360 200,000 at 0.5% a month + \\ tally amort 200000 6 360 --monthly the same loan, 6% a year + \\ tally amort 200000 6 360 1500 --monthly --summary + \\ +; + +/// Parse the arguments following the `amort` subcommand. +/// +/// Positional form only for the loan terms: ` ` with +/// an optional payment. A payment larger than the scheduled one retires the loan +/// early; a smaller one ends in a balloon payment. +fn parseAmortArgs(args: []const []const u8) ParsedArgs { + var positional: [4][]const u8 = undefined; + var count: usize = 0; + var summary_only = false; + var monthly = false; + var round_cents = true; + + for (args) |arg| { + if (std.mem.eql(u8, arg, "--summary")) { + summary_only = true; + } else if (std.mem.eql(u8, arg, "--monthly")) { + monthly = true; + } else if (std.mem.eql(u8, arg, "--exact")) { + round_cents = false; + } else if (std.mem.startsWith(u8, arg, "--")) { + return .{ .output = .{ .text = amort_usage, .is_error = true } }; + } else { + if (count >= positional.len) { + return .{ .output = .{ .text = amort_usage, .is_error = true } }; + } + positional[count] = arg; + count += 1; + } + } + + if (count < 3) return .{ .output = .{ .text = amort_usage, .is_error = true } }; + + const principal = std.fmt.parseFloat(f64, positional[0]) catch { + return .{ .output = .{ .text = "error: invalid number\n", .is_error = true } }; + }; + const rate_input = std.fmt.parseFloat(f64, positional[1]) catch { + return .{ .output = .{ .text = "error: invalid number\n", .is_error = true } }; + }; + const periods_input = std.fmt.parseFloat(f64, positional[2]) catch { + return .{ .output = .{ .text = "error: invalid number\n", .is_error = true } }; + }; + const payment: ?f64 = if (count == 4) + std.fmt.parseFloat(f64, positional[3]) catch { + return .{ .output = .{ .text = "error: invalid number\n", .is_error = true } }; + } + else + null; + + const max_periods: f64 = @floatFromInt(engine.financial.max_schedule_periods); + if (!(periods_input >= 1) or periods_input > max_periods or @floor(periods_input) != periods_input) { + return .{ .output = .{ + .text = "error: periods must be a whole number of at least 1\n", + .is_error = true, + } }; + } + + return .{ .amortization = .{ + .params = .{ + .principal = principal, + .rate = if (monthly) rate_input / 12.0 else rate_input, + .periods = @intFromFloat(periods_input), + .payment = payment, + .round_cents = round_cents, + }, + .summary_only = summary_only, + } }; +} + +/// Render an amount with thousands separators and two decimal places. +fn formatMoney(buf: []u8, value: f64) []const u8 { + var digits: [64]u8 = undefined; + const text = std.fmt.bufPrint(&digits, "{d:.2}", .{@abs(value)}) catch return "?"; + // "{d:.2}" always emits ".dd", so the integer part is everything before the + // last three characters. + if (text.len < 4) return "?"; + const whole = text[0 .. text.len - 3]; + const fraction = text[text.len - 3 ..]; + + var written: usize = 0; + if (value < 0) { + if (buf.len == 0) return "?"; + buf[0] = '-'; + written = 1; + } + for (whole, 0..) |digit, i| { + const remaining = whole.len - i; + if (i != 0 and remaining % 3 == 0) { + if (written == buf.len) return "?"; + buf[written] = ','; + written += 1; + } + if (written == buf.len) return "?"; + buf[written] = digit; + written += 1; + } + if (written + fraction.len > buf.len) return "?"; + @memcpy(buf[written..][0..fraction.len], fraction); + return buf[0 .. written + fraction.len]; +} + +/// Render an amortization schedule as a table. The result is allocated because a +/// 360-period schedule does not fit the fixed buffers the other outputs use. +pub fn formatAmortization( + allocator: std.mem.Allocator, + params: engine.financial.AmortizationParams, + summary_only: bool, +) CliResult { + const payment = engine.financial.amortizationPayment(params) catch |err| { + return .{ .output = amortErrorMessage(err), .is_error = true }; + }; + const rows = engine.financial.amortizationSchedule(allocator, params) catch |err| { + return .{ .output = amortErrorMessage(err), .is_error = true }; + }; + defer allocator.free(rows); + + var out = std.ArrayList(u8).empty; + errdefer out.deinit(allocator); + var line: [160]u8 = undefined; + var money: [48]u8 = undefined; + + const header = std.fmt.bufPrint(&line, "{s} at {d}% per period over {d} periods\n", .{ + formatMoney(&money, params.principal), + 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), + }) catch return .{ .output = "error: buffer overflow\n", .is_error = true }; + out.appendSlice(allocator, payment_line) catch return oomResult(); + + if (!summary_only) { + out.appendSlice( + allocator, + "Period Payment Interest Principal Balance\n", + ) catch return oomResult(); + for (rows) |row| { + var pay_buf: [48]u8 = undefined; + var int_buf: [48]u8 = undefined; + var prin_buf: [48]u8 = undefined; + var bal_buf: [48]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), + formatMoney(&int_buf, row.interest), + formatMoney(&prin_buf, row.principal), + formatMoney(&bal_buf, row.balance), + }) catch return .{ .output = "error: buffer overflow\n", .is_error = true }; + out.appendSlice(allocator, row_text) catch return oomResult(); + } + out.appendSlice(allocator, "\n") catch return oomResult(); + } + + const totals = engine.financial.amortizationTotals(params) catch |err| { + return .{ .output = amortErrorMessage(err), .is_error = true }; + }; + var paid_buf: [48]u8 = undefined; + var interest_buf: [48]u8 = undefined; + var principal_buf: [48]u8 = undefined; + const summary = std.fmt.bufPrint( + &line, + "Periods paid {d}\nTotal paid {s}\nTotal interest {s}\nPrincipal {s}", + .{ + totals.periods, + formatMoney(&paid_buf, totals.paid), + formatMoney(&interest_buf, totals.interest), + formatMoney(&principal_buf, totals.principal), + }, + ) catch return .{ .output = "error: buffer overflow\n", .is_error = true }; + out.appendSlice(allocator, summary) catch return oomResult(); + + const text = out.toOwnedSlice(allocator) catch return oomResult(); + return .{ .output = text, .is_error = false }; +} + +fn oomResult() CliResult { + return .{ .output = "error: out of memory\n", .is_error = true }; +} + +fn amortErrorMessage(err: engine.CalcError) []const u8 { + return switch (err) { + engine.CalcError.DomainError => + // The realistic causes are all one of these, and a bare "domain error" + // would leave the user guessing which. + "error: check the loan terms: principal and periods must be positive, the rate cannot be negative, and the payment must at least cover the first period's interest\n", + else => errorMessage(err), + }; +} + const help_text = \\tally - a cross-platform calculator \\ @@ -422,8 +639,20 @@ const help_text = \\ tally 32F in C \\ tally '2*3 kg to lb' The value may be an expression \\ - \\A 'convert' subcommand is also accepted for explicitness: - \\ tally convert 100 km to mi + \\Financial functions work in any expression: + \\ tally 'cagr(10000, 25000, 5) * 100' Growth rate as a percent + \\ tally 'fv(1000, 5, 10)' Future value, 5% for 10 years + \\ tally 'fv(1000, 5, 10, 12)' ...compounded monthly + \\ tally 'pv(2000, 7, 10)' Present value + \\ tally 'compound_rate(10000, 25000, 5)' Solve the rate (nominal) + \\ tally 'apy(18, 12)' Nominal rate to effective + \\ tally 'tvm_pmt(360, 0.5, 200000, 0)' Solve a payment (N, I/Y, PV, FV) + \\ tally 'tvm_rate(10, -1000, 0, 2000)' Solve a rate (N, PV, PMT, FV) + \\ tally 'amort_interest(200000, 0.5, 360, 1)' + \\ + \\Subcommands: + \\ tally convert 100 km to mi Explicit unit conversion + \\ tally amort 200000 6 360 --monthly Amortization schedule \\ \\Run with no arguments to start the interactive TUI. \\ @@ -474,6 +703,13 @@ pub fn main(init: std.process.Init) u8 { if (!result.is_error) write(io, std.Io.File.stdout(), "\n"); return if (result.is_error) @as(u8, 1) else 0; }, + .amortization => |amort| { + const result = formatAmortization(allocator, amort.params, amort.summary_only); + const file = if (result.is_error) std.Io.File.stderr() else std.Io.File.stdout(); + write(io, file, result.output); + if (!result.is_error) write(io, std.Io.File.stdout(), "\n"); + return if (result.is_error) @as(u8, 1) else 0; + }, } } @@ -932,3 +1168,137 @@ test "formatConversion: exact conversion prints exactly" { try testing.expect(!mps.is_error); try testing.expectEqualStrings("3.6 km/h = 1 m/s", mps.output); } + +// -- Amortization CLI -- + +test "parseArgs: amort positional form" { + const parsed = parseArgs(testing.allocator, &.{ "amort", "200000", "0.5", "360" }); + switch (parsed) { + .amortization => |a| { + try testing.expectEqual(@as(f64, 200000), a.params.principal); + try testing.expectEqual(@as(f64, 0.5), a.params.rate); + try testing.expectEqual(@as(usize, 360), a.params.periods); + try testing.expectEqual(@as(?f64, null), a.params.payment); + try testing.expect(a.params.round_cents); + try testing.expect(!a.summary_only); + }, + else => return error.TestUnexpectedResult, + } +} + +test "parseArgs: amortize is accepted as a longer spelling" { + const parsed = parseArgs(testing.allocator, &.{ "amortize", "1000", "1", "12" }); + try testing.expect(parsed == .amortization); +} + +test "parseArgs: amort --monthly divides an annual rate by twelve" { + const parsed = parseArgs(testing.allocator, &.{ "amort", "200000", "6", "360", "--monthly" }); + switch (parsed) { + .amortization => |a| try testing.expectApproxEqAbs(@as(f64, 0.5), a.params.rate, 1e-12), + else => return error.TestUnexpectedResult, + } +} + +test "parseArgs: amort flags and an explicit payment" { + const parsed = parseArgs( + testing.allocator, + &.{ "amort", "200000", "0.5", "360", "1500", "--summary", "--exact" }, + ); + switch (parsed) { + .amortization => |a| { + try testing.expectEqual(@as(?f64, 1500), a.params.payment); + try testing.expect(a.summary_only); + try testing.expect(!a.params.round_cents); + }, + else => return error.TestUnexpectedResult, + } +} + +test "parseArgs: amort rejects incomplete or malformed terms" { + // Too few terms. + const short = parseArgs(testing.allocator, &.{ "amort", "200000", "0.5" }); + try testing.expect(short == .output and short.output.is_error); + // Too many. + const long = parseArgs(testing.allocator, &.{ "amort", "1", "2", "3", "4", "5" }); + try testing.expect(long == .output and long.output.is_error); + // Unknown flag. + const bad_flag = parseArgs(testing.allocator, &.{ "amort", "1", "2", "3", "--nope" }); + try testing.expect(bad_flag == .output and bad_flag.output.is_error); + // Non-numeric term. + const bad_number = parseArgs(testing.allocator, &.{ "amort", "abc", "0.5", "360" }); + try testing.expect(bad_number == .output and bad_number.output.is_error); + // A fractional period count has no meaning in a schedule. + const fractional = parseArgs(testing.allocator, &.{ "amort", "200000", "0.5", "360.5" }); + try testing.expect(fractional == .output and fractional.output.is_error); + try testing.expect(std.mem.indexOf(u8, fractional.output.text, "whole number") != null); + // Zero periods. + const zero = parseArgs(testing.allocator, &.{ "amort", "200000", "0.5", "0" }); + try testing.expect(zero == .output and zero.output.is_error); +} + +test "formatMoney: grouping and sign" { + 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: a buffer too small reports rather than truncating silently" { + var tiny: [4]u8 = undefined; + try testing.expectEqualStrings("?", formatMoney(&tiny, 1234567.89)); +} + +test "formatAmortization: table has a row per period and correct first row" { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer _ = arena.deinit(); + + const result = formatAmortization(arena.allocator(), .{ + .principal = 200000, + .rate = 0.5, + .periods = 360, + }, false); + try testing.expect(!result.is_error); + + // Header, payment, blank, column head, 360 rows, blank, 4 summary lines. + var lines: usize = 0; + var it = std.mem.splitScalar(u8, result.output, '\n'); + while (it.next()) |_| lines += 1; + try testing.expectEqual(@as(usize, 369), lines); + + try testing.expect(std.mem.indexOf(u8, result.output, "Payment 1,199.10 per period") != null); + try testing.expect(std.mem.indexOf(u8, result.output, "1,000.00") != null); + try testing.expect(std.mem.indexOf(u8, result.output, "199,800.90") != null); + try testing.expect(std.mem.indexOf(u8, result.output, "Total interest 231,677.04") != null); +} + +test "formatAmortization: summary only omits the rows" { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer _ = arena.deinit(); + + const result = formatAmortization(arena.allocator(), .{ + .principal = 200000, + .rate = 0.5, + .periods = 360, + }, true); + try testing.expect(!result.is_error); + try testing.expect(std.mem.indexOf(u8, result.output, "Period Payment") == null); + try testing.expect(std.mem.indexOf(u8, result.output, "Periods paid 360") != null); +} + +test "formatAmortization: bad terms explain themselves" { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer _ = arena.deinit(); + + // A payment that never covers the interest. + const result = formatAmortization(arena.allocator(), .{ + .principal = 200000, + .rate = 0.5, + .periods = 360, + .payment = 500, + }, false); + try testing.expect(result.is_error); + try testing.expect(std.mem.indexOf(u8, result.output, "loan terms") != null); +} diff --git a/src/tui.zig b/src/tui.zig index 272a1b5..5470971 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -13,12 +13,14 @@ const draw = @import("tui/draw.zig"); const programmer_view = @import("tui/programmer.zig"); const float_view = @import("tui/float_view.zig"); const convert_view = @import("tui/convert.zig"); +const financial_view = @import("tui/financial.zig"); const help_view = @import("tui/help.zig"); +const test_render = @import("tui/test_render.zig"); const C = draw.C; const Allocator = std.mem.Allocator; -const Mode = enum { standard, programmer, convert }; +const Mode = enum { standard, programmer, financial, convert }; /// Which column of the convert view has keyboard focus. pub const ConvZone = enum { category, from, to }; @@ -66,6 +68,10 @@ pub const Action = union(enum) { conv_to: usize, /// Swap source and target units. conv_swap, + /// Select a financial calculation form. + fin_form: financial_view.Form, + /// Focus a field in the financial form. + fin_field: usize, }; /// Upper bound on clickable regions in a single frame. The worst case is the @@ -118,6 +124,9 @@ pub const App = struct { input: vxfw.TextField, history: std.ArrayList(HistoryEntry), show_help: bool, + /// First help line shown. The overlay is taller than a normal terminal, so it + /// scrolls rather than dropping its later sections. + help_scroll: usize, history_browse_idx: ?usize, saved_input: ?[]const u8, mode: Mode, @@ -138,6 +147,11 @@ pub const App = struct { /// the conversion, matching the CLI. conv_value: engine.Number, conv_zone: ConvZone, + /// Financial mode state (forms, focused field, schedule scroll). + fin: financial_view.State, + /// Terminal height from the last draw, so key handling can compute a page + /// without guessing at the layout. + last_height: u16, // Mouse hit regions, rebuilt every frame during drawing regions: RegionSet, @@ -209,6 +223,7 @@ pub const App = struct { .input = text_field, .history = .empty, .show_help = false, + .help_scroll = 0, .history_browse_idx = null, .saved_input = null, .mode = .standard, @@ -224,6 +239,8 @@ pub const App = struct { .conv_to_idx = default_to, .conv_value = engine.Number.fromFloat(1), .conv_zone = .from, + .fin = .{}, + .last_height = 24, .regions = .empty, }; } @@ -268,21 +285,79 @@ pub const App = struct { return self.regions.at(row, col); } - fn handleMouse(self: *App, ctx: *vxfw.EventContext, mouse: vaxis.Mouse) !void { - // Only act on a left press. Release/motion/drag would double-fire. - if (mouse.type != .press or mouse.button != .left) return; - if (mouse.row < 0 or mouse.col < 0) return; - const row: u16 = @intCast(mouse.row); - const col: u16 = @intCast(mouse.col); + /// Move the help overlay by a line delta, clamped to its content. `visible` + /// comes from the last drawn height so a page is a real page. + fn scrollHelpBy(self: *App, delta: i32) void { + const total = help_view.lineCount(); + const visible = help_view.visibleLines(self.last_height); + const max_scroll = if (total > visible) total - visible else 0; + const next = @as(i64, @intCast(self.help_scroll)) + delta; + if (next < 0) { + self.help_scroll = 0; + } else if (@as(usize, @intCast(next)) > max_scroll) { + self.help_scroll = max_scroll; + } else { + self.help_scroll = @intCast(next); + } + } - // The help overlay swallows clicks, matching its "any key dismisses" - // keyboard behavior. + /// Handle a scrolling key while the help overlay is open. Returns true when + /// the key was a scroll rather than a dismiss. + fn scrollHelp(self: *App, key: vaxis.Key) bool { + const page: i32 = @intCast(@max(1, help_view.visibleLines(self.last_height))); + if (key.matches(vaxis.Key.down, .{})) { + self.scrollHelpBy(1); + return true; + } + if (key.matches(vaxis.Key.up, .{})) { + self.scrollHelpBy(-1); + return true; + } + if (key.matches(vaxis.Key.page_down, .{})) { + self.scrollHelpBy(page); + return true; + } + if (key.matches(vaxis.Key.page_up, .{})) { + self.scrollHelpBy(-page); + return true; + } + return false; + } + + fn handleMouse(self: *App, ctx: *vxfw.EventContext, mouse: vaxis.Mouse) !void { + if (mouse.row < 0 or mouse.col < 0) return; + + // The help overlay takes the wheel to scroll and swallows clicks to + // dismiss, matching its keyboard behavior. if (self.show_help) { + if (mouse.button == .wheel_up or mouse.button == .wheel_down) { + self.scrollHelpBy(if (mouse.button == .wheel_up) -3 else 3); + ctx.redraw = true; + return; + } + if (mouse.type != .press or mouse.button != .left) return; self.show_help = false; + self.help_scroll = 0; ctx.redraw = true; return; } + // The wheel scrolls the amortization schedule. Handled before the + // press filter below, since a wheel event is not a left press. + if (self.mode == .financial and (mouse.button == .wheel_up or mouse.button == .wheel_down)) { + const delta: i32 = if (mouse.button == .wheel_up) -3 else 3; + // The visible row count is a drawing concern, so scroll optimistically + // and let the draw pass clamp against the real schedule length. + self.fin.scrollBy(delta, 0, std.math.maxInt(u32)); + ctx.redraw = true; + return; + } + + // Only act on a left press. Release/motion/drag would double-fire. + if (mouse.type != .press or mouse.button != .left) return; + const row: u16 = @intCast(mouse.row); + const col: u16 = @intCast(mouse.col); + const action = self.regionAt(row, col) orelse return; try self.applyAction(ctx, action); } @@ -333,6 +408,14 @@ pub const App = struct { self.value_zone_active = true; }, .conv_swap => self.swapConvUnits(), + .fin_form => |form| { + self.fin.setForm(form); + self.value_zone_active = true; + }, + .fin_field => |index| { + self.fin.focusField(index); + self.value_zone_active = true; + }, } ctx.redraw = true; } @@ -464,7 +547,15 @@ pub const App = struct { } if (self.show_help) { + // Scrolling keys navigate the overlay; anything else dismisses it, so + // the "press a key to get out" behavior survives while the sections + // past the first screen become reachable. + if (self.scrollHelp(key)) { + ctx.redraw = true; + return; + } self.show_help = false; + self.help_scroll = 0; ctx.redraw = true; return; } @@ -475,24 +566,49 @@ pub const App = struct { return; } - // Tab: cycle Standard -> Programmer -> Convert + // Tab: cycle Standard -> Programmer -> Financial -> Convert. + // Shift-Tab goes back, so a mis-hit does not mean cycling all the way + // around. if (key.matches(vaxis.Key.tab, .{})) { - self.setMode(switch (self.mode) { - .standard => .programmer, - .programmer => .convert, - .convert => .standard, - }); + self.setMode(nextMode(self.mode)); + ctx.redraw = true; + return; + } + if (key.matches(vaxis.Key.tab, .{ .shift = true })) { + self.setMode(prevMode(self.mode)); ctx.redraw = true; return; } // Backtick: toggle between input zone and value/selection zone - if ((self.mode == .programmer or self.mode == .convert) and key.matches('`', .{})) { + if ((self.mode == .programmer or self.mode == .convert or self.mode == .financial) and + key.matches('`', .{})) + { self.value_zone_active = !self.value_zone_active; ctx.redraw = true; return; } + // Financial mode: the schedule scrolls from either zone, so PgUp/PgDn + // work while editing fields too. + if (self.mode == .financial) { + if (key.matches(vaxis.Key.page_up, .{})) { + self.fin.scrollBy(-10, 0, std.math.maxInt(u32)); + ctx.redraw = true; + return; + } + if (key.matches(vaxis.Key.page_down, .{})) { + self.fin.scrollBy(10, 0, std.math.maxInt(u32)); + ctx.redraw = true; + return; + } + if (self.value_zone_active) { + self.handleFinancialZoneKey(key); + ctx.redraw = true; + return; + } + } + // Convert mode keys if (self.mode == .convert) { // Ctrl-S swaps the two units from either zone. @@ -609,6 +725,68 @@ pub const App = struct { ctx.redraw = true; } + /// Financial form editing: arrows move between fields, printable characters + /// edit the focused one. Kept separate from `handleValueZoneKey`, which is + /// specific to the programmer view's bit and base fields. + fn handleFinancialZoneKey(self: *App, key: vaxis.Key) void { + if (key.matches(vaxis.Key.down, .{})) { + self.fin.nextField(); + return; + } + if (key.matches(vaxis.Key.up, .{})) { + self.fin.prevField(); + return; + } + // Left/Right cycle the calculation, so every form is reachable without + // the mouse (FR-7.7). + if (key.matches(vaxis.Key.right, .{})) { + const forms = std.enums.values(financial_view.Form); + const next = (@intFromEnum(self.fin.form) + 1) % forms.len; + self.fin.setForm(@enumFromInt(next)); + return; + } + if (key.matches(vaxis.Key.left, .{})) { + const forms = std.enums.values(financial_view.Form); + const current = @intFromEnum(self.fin.form); + const prev = if (current == 0) forms.len - 1 else current - 1; + self.fin.setForm(@enumFromInt(prev)); + return; + } + if (key.matches(vaxis.Key.backspace, .{})) { + self.fin.backspace(); + return; + } + // Enter evaluates the field in place: type "12 * 30" in a period count and + // it becomes 360. Fields hold expressions, so this is the commit step. + if (key.matches(vaxis.Key.enter, .{})) { + self.commitFinancialField(); + return; + } + // Ctrl-U empties a field, which is also how a TVM variable is marked as + // the one to solve for. + if (key.matches('u', .{ .ctrl = true }) or key.matches(vaxis.Key.delete, .{})) { + self.fin.clearFocused(); + return; + } + const cp = key.codepoint; + if (cp >= 0x20 and cp < 0x7F) { + _ = self.fin.typeChar(@intCast(cp)); + } + } + + /// Evaluate the focused financial field in place, replacing an expression with + /// its value. A field that does not evaluate is left exactly as typed, so a + /// half-finished expression is never silently discarded. + fn commitFinancialField(self: *App) void { + if (self.fin.focusedIsToggle()) return; + const field = self.fin.focused(); + if (field.isEmpty()) return; + + var value = engine.evalString(&self.env, self.allocator, field.text()) catch return; + defer value.deinit(); + self.fin.setFocusedValue(value.toFloat(self.allocator)); + } + fn handleValueZoneKey(self: *App, key: vaxis.Key) void { // Up/Down: move between fields if (key.matches(vaxis.Key.up, .{})) { @@ -820,6 +998,8 @@ pub const App = struct { } } else if (self.mode == .convert) { try self.submitConvert(expr_text); + } else if (self.mode == .financial) { + try self.submitFinancial(expr_text); } else { try self.submitStandard(expr_text); } @@ -963,6 +1143,25 @@ pub const App = struct { try self.history.append(self.allocator, .{ .expr = expr_text, .result = result, .is_error = false }); } + /// In financial mode the input line fills the focused field, evaluating + /// whatever was typed first. That is how "1200*12" becomes a periods entry + /// without leaving the form, and it keeps the full expression language + /// available inside a form field. + fn submitFinancial(self: *App, expr_text: []const u8) !void { + defer self.allocator.free(expr_text); + + var value = engine.evalString(&self.env, self.allocator, expr_text) catch { + // Nothing is recorded on a bad expression: the form is live, so the + // field simply keeps its previous contents. + return; + }; + defer value.deinit(); + + self.fin.setFocusedValue(value.toFloat(self.allocator)); + // Move focus into the form so the next field is a keystroke away. + self.value_zone_active = true; + } + fn submitProgrammer(self: *App, expr_text: []const u8) !void { const is_error, const display_text = if (engine.evalProgrammerString(self.allocator, expr_text, self.prog_config)) |int| blk: { self.prog_value = int.unsignedValue(); @@ -1010,12 +1209,13 @@ pub const App = struct { const height = ctx.max.height orelse 24; var surface = try vxfw.Surface.init(ctx.arena, self.widget(), .{ .width = width, .height = height }); + self.last_height = height; // Hit regions describe the frame being drawn, so start fresh. self.clearRegions(); if (self.show_help) { - help_view.drawHelp(&surface, width, height); + help_view.drawHelp(&surface, width, height, self.help_scroll); return surface; } @@ -1027,6 +1227,7 @@ pub const App = struct { const tabs = [_]struct { mode: Mode, text: []const u8, color: vaxis.Cell.Color }{ .{ .mode = .standard, .text = " Standard ", .color = C.green }, .{ .mode = .programmer, .text = " Programmer ", .color = C.orange }, + .{ .mode = .financial, .text = " Financial ", .color = C.yellow }, .{ .mode = .convert, .text = " Convert ", .color = C.purple }, }; var total_tab_width: u16 = 0; @@ -1052,6 +1253,7 @@ pub const App = struct { } }, .convert => convert_view.drawConvertMode(self, &surface, width, height), + .financial => financial_view.drawFinancialMode(self, &surface, width, height), .standard => self.drawStandardMode(&surface, width, height), } @@ -1095,6 +1297,25 @@ pub const App = struct { } }; +/// Mode order for Tab and Shift-Tab, matching the tab bar left to right. +pub fn nextMode(mode: Mode) Mode { + return switch (mode) { + .standard => .programmer, + .programmer => .financial, + .financial => .convert, + .convert => .standard, + }; +} + +pub fn prevMode(mode: Mode) Mode { + return switch (mode) { + .standard => .convert, + .programmer => .standard, + .financial => .programmer, + .convert => .financial, + }; +} + /// Move an index by delta within [0, len), wrapping at both ends. pub fn wrapIndex(current: usize, delta: i32, len: usize) usize { if (len == 0) return 0; @@ -1308,3 +1529,1398 @@ test "defaultUnitIndices: length defaults to meters" { const table = engine.units.unitsIn(.length); try testing.expectEqualStrings("m", table[defaults.from].name); } + +// -- Financial mode wiring -- +// +// financial.zig tests the form logic in isolation. These go through the actual +// key and mouse handlers, which is what catches a binding that was never routed +// or one mode swallowing another's keys. + +fn testApp() App { + // SAFETY: `io` is only used by the event loop and command handling, neither + // of which these tests reach. + return App.init(testing.allocator, undefined); +} + +fn testCtx() vxfw.EventContext { + // SAFETY: same as above; no test here issues a command that would use `io`. + return .{ .io = undefined, .alloc = testing.allocator, .cmds = .empty }; +} + +fn press(app: *App, ctx: *vxfw.EventContext, key: vaxis.Key) !void { + try app.handleKey(ctx, key); +} + +test "tab cycles through all four modes" { + var app = testApp(); + defer app.deinit(); + var ctx = testCtx(); + defer ctx.cmds.deinit(testing.allocator); + + const tab: vaxis.Key = .{ .codepoint = vaxis.Key.tab }; + try testing.expectEqual(Mode.standard, app.mode); + try press(&app, &ctx, tab); + try testing.expectEqual(Mode.programmer, app.mode); + try press(&app, &ctx, tab); + try testing.expectEqual(Mode.financial, app.mode); + try press(&app, &ctx, tab); + try testing.expectEqual(Mode.convert, app.mode); + try press(&app, &ctx, tab); + try testing.expectEqual(Mode.standard, app.mode); +} + +test "financial mode: backtick toggles the form zone and digits reach the field" { + var app = testApp(); + defer app.deinit(); + var ctx = testCtx(); + defer ctx.cmds.deinit(testing.allocator); + + app.setMode(.financial); + try testing.expect(!app.value_zone_active); + try press(&app, &ctx, .{ .codepoint = '`' }); + try testing.expect(app.value_zone_active); + + try press(&app, &ctx, .{ .codepoint = '2' }); + try press(&app, &ctx, .{ .codepoint = '5' }); + try press(&app, &ctx, .{ .codepoint = '0' }); + try testing.expectEqualStrings("250", app.fin.focused().text()); + + // Backspace and Ctrl-U both edit, and neither leaks into the text field. + try press(&app, &ctx, .{ .codepoint = vaxis.Key.backspace }); + try testing.expectEqualStrings("25", app.fin.focused().text()); + try press(&app, &ctx, .{ .codepoint = 'u', .mods = .{ .ctrl = true } }); + try testing.expect(app.fin.focused().isEmpty()); +} + +test "financial mode: up and down move between fields" { + var app = testApp(); + defer app.deinit(); + var ctx = testCtx(); + defer ctx.cmds.deinit(testing.allocator); + + app.setMode(.financial); + app.value_zone_active = true; + try press(&app, &ctx, .{ .codepoint = vaxis.Key.down }); + try testing.expectEqual(@as(usize, 1), app.fin.field); + try press(&app, &ctx, .{ .codepoint = vaxis.Key.up }); + try testing.expectEqual(@as(usize, 0), app.fin.field); + // Wraps rather than sticking at the top. + try press(&app, &ctx, .{ .codepoint = vaxis.Key.up }); + try testing.expectEqual(@as(usize, 2), app.fin.field); +} + +test "financial mode: left and right switch calculation without the mouse" { + var app = testApp(); + defer app.deinit(); + var ctx = testCtx(); + defer ctx.cmds.deinit(testing.allocator); + + app.setMode(.financial); + app.value_zone_active = true; + try testing.expectEqual(financial_view.Form.cagr, app.fin.form); + try press(&app, &ctx, .{ .codepoint = vaxis.Key.right }); + try testing.expectEqual(financial_view.Form.compound, app.fin.form); + try press(&app, &ctx, .{ .codepoint = vaxis.Key.left }); + try testing.expectEqual(financial_view.Form.cagr, app.fin.form); + // Wraps backwards to the last calculation. + try press(&app, &ctx, .{ .codepoint = vaxis.Key.left }); + try testing.expectEqual(financial_view.Form.amortization, app.fin.form); +} + +test "financial mode: the digits that edit a field do not switch modes" { + var app = testApp(); + defer app.deinit(); + var ctx = testCtx(); + defer ctx.cmds.deinit(testing.allocator); + + app.setMode(.financial); + app.value_zone_active = true; + // '?' opens help only from the input zone; in the form zone it is ignored + // rather than stealing a keystroke mid-entry. + try press(&app, &ctx, .{ .codepoint = '?' }); + try testing.expect(!app.show_help); + try testing.expect(app.fin.focused().isEmpty()); +} + +test "financial mode: page keys scroll the schedule from either zone" { + var app = testApp(); + defer app.deinit(); + var ctx = testCtx(); + defer ctx.cmds.deinit(testing.allocator); + + app.setMode(.financial); + app.fin.setForm(.amortization); + try press(&app, &ctx, .{ .codepoint = vaxis.Key.page_down }); + try testing.expectEqual(@as(usize, 10), app.fin.scroll); + + app.value_zone_active = true; + try press(&app, &ctx, .{ .codepoint = vaxis.Key.page_down }); + try testing.expectEqual(@as(usize, 20), app.fin.scroll); + try press(&app, &ctx, .{ .codepoint = vaxis.Key.page_up }); + try testing.expectEqual(@as(usize, 10), app.fin.scroll); +} + +test "financial mode: clicking a calculation or a field focuses it" { + var app = testApp(); + defer app.deinit(); + var ctx = testCtx(); + defer ctx.cmds.deinit(testing.allocator); + + app.setMode(.financial); + try app.applyAction(&ctx, .{ .fin_form = .tvm }); + try testing.expectEqual(financial_view.Form.tvm, app.fin.form); + try testing.expect(app.value_zone_active); + + try app.applyAction(&ctx, .{ .fin_field = 3 }); + try testing.expectEqual(@as(usize, 3), app.fin.field); + + // An out-of-range index is ignored rather than moving focus off the form. + try app.applyAction(&ctx, .{ .fin_field = 99 }); + try testing.expectEqual(@as(usize, 3), app.fin.field); +} + +test "financial mode: the wheel scrolls the schedule" { + var app = testApp(); + defer app.deinit(); + var ctx = testCtx(); + defer ctx.cmds.deinit(testing.allocator); + + app.setMode(.financial); + app.fin.setForm(.amortization); + try app.handleMouse(&ctx, .{ + .col = 10, + .row = 14, + .button = .wheel_down, + .mods = .{}, + .type = .press, + }); + try testing.expectEqual(@as(usize, 3), app.fin.scroll); + try app.handleMouse(&ctx, .{ + .col = 10, + .row = 14, + .button = .wheel_up, + .mods = .{}, + .type = .press, + }); + try testing.expectEqual(@as(usize, 0), app.fin.scroll); +} + +test "financial mode: the input line evaluates an expression into the field" { + var app = testApp(); + defer app.deinit(); + var ctx = testCtx(); + defer ctx.cmds.deinit(testing.allocator); + + app.setMode(.financial); + app.fin.setForm(.amortization); + app.fin.focusField(2); // Periods + try app.input.insertSliceAtCursor("30 * 12"); + try press(&app, &ctx, .{ .codepoint = vaxis.Key.enter }); + + try testing.expectEqualStrings("360", app.fin.fieldAt(2).text()); + // The form takes focus so the next field is one keystroke away, and the + // input line is left empty. + try testing.expect(app.value_zone_active); + try testing.expectEqual(@as(usize, 0), app.input.buf.realLength()); + // A form result is live, so nothing is appended to history. + try testing.expectEqual(@as(usize, 0), app.history.items.len); +} + +test "financial mode: a bad expression leaves the field untouched" { + var app = testApp(); + defer app.deinit(); + var ctx = testCtx(); + defer ctx.cmds.deinit(testing.allocator); + + app.setMode(.financial); + app.fin.fieldAt(0).set("100"); + try app.input.insertSliceAtCursor("2 +"); + try press(&app, &ctx, .{ .codepoint = vaxis.Key.enter }); + try testing.expectEqualStrings("100", app.fin.fieldAt(0).text()); +} + +test "other modes still get their own keys after financial mode was added" { + var app = testApp(); + defer app.deinit(); + var ctx = testCtx(); + defer ctx.cmds.deinit(testing.allocator); + + // Convert mode's Ctrl-S swap must not be shadowed by the financial branch. + app.setMode(.convert); + const before = app.convUnits(); + try press(&app, &ctx, .{ .codepoint = 's', .mods = .{ .ctrl = true } }); + const after = app.convUnits(); + try testing.expectEqualStrings(before.from.name, after.to.name); + + // Programmer mode's bit-width cycle still works. + app.setMode(.programmer); + const width_before = app.prog_config.bit_width; + try press(&app, &ctx, .{ .codepoint = 'w', .mods = .{ .ctrl = true } }); + try testing.expect(app.prog_config.bit_width != width_before); +} + +test "shift-tab walks back through the modes" { + var app = testApp(); + defer app.deinit(); + var ctx = testCtx(); + defer ctx.cmds.deinit(testing.allocator); + + const back_tab: vaxis.Key = .{ .codepoint = vaxis.Key.tab, .mods = .{ .shift = true } }; + try testing.expectEqual(Mode.standard, app.mode); + try press(&app, &ctx, back_tab); + try testing.expectEqual(Mode.convert, app.mode); + try press(&app, &ctx, back_tab); + try testing.expectEqual(Mode.financial, app.mode); + try press(&app, &ctx, back_tab); + try testing.expectEqual(Mode.programmer, app.mode); + try press(&app, &ctx, back_tab); + try testing.expectEqual(Mode.standard, app.mode); +} + +test "nextMode and prevMode are inverses in both directions" { + for ([_]Mode{ .standard, .programmer, .financial, .convert }) |mode| { + try testing.expectEqual(mode, prevMode(nextMode(mode))); + try testing.expectEqual(mode, nextMode(prevMode(mode))); + } +} + +test "plain tab is not shift-tab" { + var app = testApp(); + defer app.deinit(); + var ctx = testCtx(); + defer ctx.cmds.deinit(testing.allocator); + + // Forward once, back once, and we are where we started. + try press(&app, &ctx, .{ .codepoint = vaxis.Key.tab }); + try press(&app, &ctx, .{ .codepoint = vaxis.Key.tab, .mods = .{ .shift = true } }); + try testing.expectEqual(Mode.standard, app.mode); +} + +test "financial mode: Enter evaluates the focused field in place" { + var app = testApp(); + defer app.deinit(); + var ctx = testCtx(); + defer ctx.cmds.deinit(testing.allocator); + + app.setMode(.financial); + app.fin.setForm(.amortization); + app.value_zone_active = true; + app.fin.focusField(2); // Periods + for ("12 * 30") |char| _ = app.fin.typeChar(char); + try testing.expect(app.fin.focused().isExpression()); + + try press(&app, &ctx, .{ .codepoint = vaxis.Key.enter }); + try testing.expectEqualStrings("360", app.fin.focused().text()); + try testing.expect(!app.fin.focused().isExpression()); +} + +test "financial mode: Enter on a field that does not evaluate keeps the text" { + var app = testApp(); + defer app.deinit(); + var ctx = testCtx(); + defer ctx.cmds.deinit(testing.allocator); + + app.setMode(.financial); + app.value_zone_active = true; + for ("12 *") |char| _ = app.fin.typeChar(char); + try press(&app, &ctx, .{ .codepoint = vaxis.Key.enter }); + try testing.expectEqualStrings("12 *", app.fin.focused().text()); +} + +test "financial mode: Enter on a toggle or empty field does nothing" { + var app = testApp(); + defer app.deinit(); + var ctx = testCtx(); + defer ctx.cmds.deinit(testing.allocator); + + app.setMode(.financial); + app.fin.setForm(.tvm); + app.value_zone_active = true; + app.fin.focusField(5); // the END/BGN row + try press(&app, &ctx, .{ .codepoint = vaxis.Key.enter }); + try testing.expect(!app.fin.due); + try testing.expect(app.fin.fieldAt(5).isEmpty()); + + app.fin.focusField(0); + try press(&app, &ctx, .{ .codepoint = vaxis.Key.enter }); + try testing.expect(app.fin.focused().isEmpty()); +} + +test "financial mode: an expression typed into a field survives field changes" { + var app = testApp(); + defer app.deinit(); + var ctx = testCtx(); + defer ctx.cmds.deinit(testing.allocator); + + app.setMode(.financial); + app.value_zone_active = true; + for ("2 * 5000") |char| _ = app.fin.typeChar(char); + try press(&app, &ctx, .{ .codepoint = vaxis.Key.down }); + try press(&app, &ctx, .{ .codepoint = vaxis.Key.up }); + try testing.expectEqualStrings("2 * 5000", app.fin.focused().text()); + try press(&app, &ctx, .{ .codepoint = vaxis.Key.enter }); + try testing.expectEqualStrings("10000", app.fin.focused().text()); +} + +// -- Rendered frames for every mode -- +// +// Drawing code was previously untested: the view modules had zero instrumented +// coverage, so a layout regression would only show up by eye. These draw real +// frames through the actual widget draw path and read the cells back. They also +// assert the frame stays rectangular, since the drawing helpers clip silently +// rather than erroring when a column is miscomputed. + +fn renderApp(arena: std.mem.Allocator, app: *App, width: u16, height: u16) ![][]u8 { + return test_render.frame(arena, app, width, height); +} + +test "render: the tab bar shows every mode and marks the active one" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var app = testApp(); + defer app.deinit(); + + for ([_]Mode{ .standard, .programmer, .financial, .convert }) |mode| { + app.setMode(mode); + const rows = try renderApp(arena, &app, 100, 24); + try testing.expect(test_render.wellFormed(rows, 100)); + try testing.expect(test_render.contains(rows, "Tally")); + try testing.expect(test_render.contains(rows, "Standard")); + try testing.expect(test_render.contains(rows, "Programmer")); + try testing.expect(test_render.contains(rows, "Financial")); + try testing.expect(test_render.contains(rows, "Convert")); + } +} + +test "render: standard mode shows history, results and details" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var app = testApp(); + defer app.deinit(); + var ctx = testCtx(); + defer ctx.cmds.deinit(testing.allocator); + + try app.input.insertSliceAtCursor("2 + 3 * 4"); + try press(&app, &ctx, .{ .codepoint = vaxis.Key.enter }); + // A non-decimal literal adds the hex/oct/bin detail lines. + try app.input.insertSliceAtCursor("0xFF + 1"); + try press(&app, &ctx, .{ .codepoint = vaxis.Key.enter }); + // And an error entry, which renders in the error style. + try app.input.insertSliceAtCursor("1 / 0"); + try press(&app, &ctx, .{ .codepoint = vaxis.Key.enter }); + + const rows = try renderApp(arena, &app, 80, 24); + try testing.expect(test_render.wellFormed(rows, 80)); + try testing.expect(test_render.contains(rows, "2 + 3 * 4")); + try testing.expect(test_render.contains(rows, "= 14")); + try testing.expect(test_render.contains(rows, "= 256")); + try testing.expect(test_render.contains(rows, "hex:")); + try testing.expect(test_render.contains(rows, "oct:")); + try testing.expect(test_render.contains(rows, "bin:")); + try testing.expect(test_render.contains(rows, "division by zero")); + try testing.expect(test_render.contains(rows, "?:help")); +} + +test "render: standard mode with more history than fits keeps the newest" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var app = testApp(); + defer app.deinit(); + var ctx = testCtx(); + defer ctx.cmds.deinit(testing.allocator); + + var i: usize = 1; + while (i <= 30) : (i += 1) { + var buf: [32]u8 = undefined; + const expr = try std.fmt.bufPrint(&buf, "{d} * 1000", .{i}); + try app.input.insertSliceAtCursor(expr); + try press(&app, &ctx, .{ .codepoint = vaxis.Key.enter }); + } + + const rows = try renderApp(arena, &app, 80, 24); + // Newest entry visible, oldest scrolled off. + try testing.expect(test_render.contains(rows, "30 * 1000")); + try testing.expect(!test_render.contains(rows, "1 * 1000")); +} + +test "render: programmer mode draws the bit grid and all base rows" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var app = testApp(); + defer app.deinit(); + app.setMode(.programmer); + app.prog_value = 0xDEADBEEF; + + const rows = try renderApp(arena, &app, 100, 30); + try testing.expect(test_render.wellFormed(rows, 100)); + try testing.expect(test_render.contains(rows, "Bits: 64")); + try testing.expect(test_render.contains(rows, "Signed: yes")); + try testing.expect(test_render.contains(rows, "DEC(s):")); + try testing.expect(test_render.contains(rows, "DEC(u):")); + try testing.expect(test_render.contains(rows, "HEX:")); + try testing.expect(test_render.contains(rows, "OCT:")); + try testing.expect(test_render.contains(rows, "BIN:")); + try testing.expect(test_render.contains(rows, "[float: Ctrl-F]")); + // 0xDEADBEEF in the decimal row and the hex row. + try testing.expect(test_render.contains(rows, "3,735,928,559") or + test_render.contains(rows, "3735928559")); + try testing.expect(test_render.contains(rows, "DE AD BE EF")); +} + +test "render: programmer mode warns when the value exceeds the display width" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var app = testApp(); + defer app.deinit(); + app.setMode(.programmer); + app.prog_value = 0xDEADBEEF; + app.prog_config.bit_width = .bits8; + + const rows = try renderApp(arena, &app, 100, 30); + try testing.expect(test_render.contains(rows, "value exceeds 8 bits")); +} + +test "render: programmer mode is well formed at every width and setting" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var app = testApp(); + defer app.deinit(); + app.setMode(.programmer); + app.prog_value = 0xFEDCBA9876543210; + + for ([_]engine.BitWidth{ .bits8, .bits16, .bits32, .bits64, .bits128 }) |width| { + app.prog_config.bit_width = width; + for ([_]engine.types.Endianness{ .little, .big }) |endian| { + app.prog_config.display_endian = endian; + for ([_]engine.types.Signedness{ .signed, .unsigned }) |signedness| { + app.prog_config.signedness = signedness; + const rows = try renderApp(arena, &app, 100, 40); + try testing.expect(test_render.wellFormed(rows, 100)); + var buf: [24]u8 = undefined; + const expected = try std.fmt.bufPrint(&buf, "Bits: {d}", .{width.bits()}); + try testing.expect(test_render.contains(rows, expected)); + } + } + } +} + +test "render: the float view decodes a known bit pattern" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var app = testApp(); + defer app.deinit(); + app.setMode(.programmer); + app.float_view_active = true; + app.float_format = .f32; + app.prog_config.bit_width = .bits32; + app.prog_value = @as(u32, @bitCast(@as(f32, 1.0))); + + const rows = try renderApp(arena, &app, 100, 30); + try testing.expect(test_render.wellFormed(rows, 100)); + try testing.expect(test_render.contains(rows, "sign")); + try testing.expect(test_render.contains(rows, "exponent")); + try testing.expect(test_render.contains(rows, "significand")); + try testing.expect(test_render.contains(rows, "Value:")); + try testing.expect(test_render.contains(rows, "Class:")); + try testing.expect(test_render.contains(rows, "Formula:")); + try testing.expect(test_render.contains(rows, "ULP:")); + // 1.0 is a normal number whose value renders as exactly 1. + try testing.expect(test_render.contains(rows, "normal")); +} + +test "render: the float view handles every classification" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var app = testApp(); + defer app.deinit(); + app.setMode(.programmer); + app.float_view_active = true; + + const cases = [_]struct { format: engine.FloatFormat, bits: u128 }{ + .{ .format = .f32, .bits = 0 }, // zero + .{ .format = .f32, .bits = 1 }, // denormal (smallest) + .{ .format = .f32, .bits = 0x7F800000 }, // infinity + .{ .format = .f32, .bits = 0x7FC00000 }, // NaN + .{ .format = .f64, .bits = @as(u64, @bitCast(@as(f64, -3.14))) }, + .{ .format = .f64, .bits = @as(u64, @bitCast(@as(f64, 0))) }, + }; + for (cases) |case| { + app.float_format = case.format; + app.prog_config.bit_width = if (case.format == .f32) .bits32 else .bits64; + app.prog_value = case.bits; + const rows = try renderApp(arena, &app, 100, 34); + try testing.expect(test_render.wellFormed(rows, 100)); + try testing.expect(test_render.contains(rows, "Class:")); + } +} + +test "render: convert mode draws chips, both unit columns and a factor" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var app = testApp(); + defer app.deinit(); + app.setMode(.convert); + + const rows = try renderApp(arena, &app, 100, 34); + try testing.expect(test_render.wellFormed(rows, 100)); + try testing.expect(test_render.contains(rows, "Category:")); + try testing.expect(test_render.contains(rows, "Length")); + try testing.expect(test_render.contains(rows, "From")); + try testing.expect(test_render.contains(rows, "To")); + // Default pair is the base unit against a distinct second unit. + try testing.expect(test_render.contains(rows, "1 m =")); + try testing.expect(test_render.contains(rows, "Ctrl-S:swap")); +} + +test "render: convert mode says when a conversion is affine" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var app = testApp(); + defer app.deinit(); + app.setMode(.convert); + var ctx = testCtxNoCmds(); + try app.applyAction(&ctx, .{ .conv_category = .temperature }); + + const rows = try renderApp(arena, &app, 100, 34); + // No single factor describes C to F, so it must not print one. + try testing.expect(test_render.contains(rows, "affine conversion")); +} + +test "render: convert mode is well formed for every category" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var app = testApp(); + defer app.deinit(); + app.setMode(.convert); + + for (std.enums.values(engine.UnitCategory)) |category| { + var ctx = testCtxNoCmds(); + try app.applyAction(&ctx, .{ .conv_category = category }); + const rows = try renderApp(arena, &app, 100, 34); + try testing.expect(test_render.wellFormed(rows, 100)); + try testing.expect(test_render.contains(rows, category.label())); + } +} + +test "render: convert mode reports a unit list too long for the terminal" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var app = testApp(); + defer app.deinit(); + app.setMode(.convert); + + // Length has 14 units, which cannot fit in a 16-row terminal. + const rows = try renderApp(arena, &app, 100, 16); + try testing.expect(test_render.contains(rows, "more (resize to see all)")); +} + +test "render: the help overlay lists every mode's bindings across its pages" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var app = testApp(); + defer app.deinit(); + app.show_help = true; + + // The content is longer than any normal terminal, so walk it a page at a time + // and require that every section appears somewhere. + const wanted = [_][]const u8{ + "Tally - Help", + "Keybindings", + "Shift-Tab", + "Mouse", + "Programmer Mode", + "Financial Mode", + "Convert Mode", + "Functions", + "Financial Functions", + "Operators", + "Units and Conversion", + }; + var found: [wanted.len]bool = @splat(false); + + var page: usize = 0; + while (page < 12) : (page += 1) { + const rows = try renderApp(arena, &app, 80, 24); + try testing.expect(test_render.wellFormed(rows, 80)); + for (wanted, 0..) |needle, i| { + if (test_render.contains(rows, needle)) found[i] = true; + } + app.scrollHelpBy(10); + } + + for (wanted, found) |needle, ok| { + if (!ok) { + std.debug.print("help never showed \"{s}\"\n", .{needle}); + return error.TestUnexpectedResult; + } + } +} + +test "render: the help overlay shows its scroll position when content is off screen" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var app = testApp(); + defer app.deinit(); + app.show_help = true; + + const short = try renderApp(arena, &app, 80, 24); + try testing.expect(test_render.contains(short, "lines 1-")); + try testing.expect(test_render.contains(short, "scroll")); + + // Tall enough for everything: the position indicator is not needed. + const tall = try renderApp(arena, &app, 80, 90); + try testing.expect(test_render.contains(tall, "Press any key to return")); + try testing.expect(!test_render.contains(tall, "lines 1-")); +} + +test "render: the help overlay survives a terminal too short for any content" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var app = testApp(); + defer app.deinit(); + app.show_help = true; + + const rows = try renderApp(arena, &app, 80, 4); + try testing.expect(test_render.wellFormed(rows, 80)); + try testing.expect(test_render.contains(rows, "Tally - Help")); +} + +test "render: every mode survives a tiny terminal" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var app = testApp(); + defer app.deinit(); + + for ([_]Mode{ .standard, .programmer, .financial, .convert }) |mode| { + app.setMode(mode); + for ([_][2]u16{ .{ 20, 6 }, .{ 40, 10 }, .{ 60, 15 }, .{ 200, 60 } }) |size| { + const rows = try renderApp(arena, &app, size[0], size[1]); + try testing.expect(test_render.wellFormed(rows, size[0])); + } + } +} + +/// An EventContext for calls that cannot issue a command, so nothing needs to be +/// freed afterwards. +fn testCtxNoCmds() vxfw.EventContext { + return testCtx(); +} + +test "help overlay: arrows scroll, other keys dismiss" { + var app = testApp(); + defer app.deinit(); + var ctx = testCtx(); + defer ctx.cmds.deinit(testing.allocator); + + try press(&app, &ctx, .{ .codepoint = '?' }); + try testing.expect(app.show_help); + + // Scrolling keys navigate rather than closing. + try press(&app, &ctx, .{ .codepoint = vaxis.Key.down }); + try testing.expect(app.show_help); + try testing.expectEqual(@as(usize, 1), app.help_scroll); + try press(&app, &ctx, .{ .codepoint = vaxis.Key.page_down }); + try testing.expect(app.show_help); + try testing.expect(app.help_scroll > 1); + try press(&app, &ctx, .{ .codepoint = vaxis.Key.up }); + try testing.expect(app.show_help); + + // Anything else closes it and resets the position. + try press(&app, &ctx, .{ .codepoint = 'q' }); + try testing.expect(!app.show_help); + try testing.expectEqual(@as(usize, 0), app.help_scroll); +} + +test "help overlay: scrolling is clamped to the content" { + var app = testApp(); + defer app.deinit(); + + app.show_help = true; + app.scrollHelpBy(-5); + try testing.expectEqual(@as(usize, 0), app.help_scroll); + + app.scrollHelpBy(10_000); + const visible = help_view.visibleLines(app.last_height); + try testing.expectEqual(help_view.lineCount() - visible, app.help_scroll); + + // The last line is always reachable, and never scrolled past. + try testing.expect(app.help_scroll + visible == help_view.lineCount()); +} + +test "help overlay: the wheel scrolls it and a click dismisses it" { + var app = testApp(); + defer app.deinit(); + var ctx = testCtx(); + defer ctx.cmds.deinit(testing.allocator); + + app.show_help = true; + try app.handleMouse(&ctx, .{ .col = 10, .row = 5, .button = .wheel_down, .mods = .{}, .type = .press }); + try testing.expect(app.show_help); + try testing.expectEqual(@as(usize, 3), app.help_scroll); + + try app.handleMouse(&ctx, .{ .col = 10, .row = 5, .button = .wheel_up, .mods = .{}, .type = .press }); + try testing.expectEqual(@as(usize, 0), app.help_scroll); + + try app.handleMouse(&ctx, .{ .col = 10, .row = 5, .button = .left, .mods = .{}, .type = .press }); + try testing.expect(!app.show_help); +} + +test "help overlay: a click does not fall through to the view underneath" { + var app = testApp(); + defer app.deinit(); + var ctx = testCtx(); + defer ctx.cmds.deinit(testing.allocator); + + // Draw a frame so the tab-bar regions exist, then open help and click one. + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + _ = try renderApp(arena_state.allocator(), &app, 80, 24); + app.show_help = true; + + try app.handleMouse(&ctx, .{ .col = 72, .row = 0, .button = .left, .mods = .{}, .type = .press }); + try testing.expect(!app.show_help); + // Still in standard mode: the click closed help instead of switching mode. + try testing.expectEqual(Mode.standard, app.mode); +} + +// -- Interaction paths -- +// +// Event handling was the other half of the untested TUI: the key and mouse +// routing for programmer and convert mode had no coverage, so a binding could be +// broken by a refactor without any test noticing. + +/// Send an event through the widget interface, the way the runtime does. +fn dispatch(app: *App, ctx: *vxfw.EventContext, event: vxfw.Event) !void { + const widget = app.widget(); + try widget.eventHandler.?(widget.userdata, ctx, event); +} + +test "events arrive through the widget interface" { + var app = testApp(); + defer app.deinit(); + var ctx = testCtx(); + defer ctx.cmds.deinit(testing.allocator); + + try dispatch(&app, &ctx, .init); + try testing.expect(ctx.redraw); + + try dispatch(&app, &ctx, .{ .key_press = .{ .codepoint = vaxis.Key.tab } }); + try testing.expectEqual(Mode.programmer, app.mode); + + try dispatch(&app, &ctx, .{ .mouse = .{ + .col = 0, + .row = 0, + .button = .left, + .mods = .{}, + .type = .press, + } }); + + // An event kind the app does not handle is ignored rather than crashing. + try dispatch(&app, &ctx, .focus_in); +} + +test "ProgField next and prev walk the full cycle" { + const fields = [_]App.ProgField{ .bits, .dec_signed, .dec_unsigned, .hex, .oct, .bin, .expression }; + for (fields) |field| { + try testing.expectEqual(field, field.next().prev()); + try testing.expectEqual(field, field.prev().next()); + try testing.expect(field.label().len > 0); + } + // A full lap returns to the start. + var field: App.ProgField = .bits; + for (fields) |_| field = field.next(); + try testing.expectEqual(App.ProgField.bits, field); +} + +test "clicking a tab switches mode through the region table" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + + var app = testApp(); + defer app.deinit(); + var ctx = testCtx(); + defer ctx.cmds.deinit(testing.allocator); + + // Draw first so the tab regions exist, then click each tab where it was drawn. + _ = try renderApp(arena_state.allocator(), &app, 100, 24); + for ([_]Mode{ .convert, .financial, .programmer, .standard }) |mode| { + const region = for (app.regions.items[0..app.regions.count]) |r| { + switch (r.action) { + .mode => |m| if (m == mode) break r, + else => {}, + } + } else return error.NoRegionForMode; + + try app.handleMouse(&ctx, .{ + .col = @intCast(region.col), + .row = @intCast(region.row), + .button = .left, + .mods = .{}, + .type = .press, + }); + try testing.expectEqual(mode, app.mode); + } +} + +test "clicking empty space does nothing" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + + var app = testApp(); + defer app.deinit(); + var ctx = testCtx(); + defer ctx.cmds.deinit(testing.allocator); + + _ = try renderApp(arena_state.allocator(), &app, 100, 24); + const before = app.mode; + try app.handleMouse(&ctx, .{ .col = 3, .row = 1, .button = .left, .mods = .{}, .type = .press }); + try testing.expectEqual(before, app.mode); + + // Non-press events are ignored so one click cannot fire twice. + try app.handleMouse(&ctx, .{ .col = 0, .row = 0, .button = .left, .mods = .{}, .type = .release }); + try app.handleMouse(&ctx, .{ .col = 0, .row = 0, .button = .middle, .mods = .{}, .type = .press }); + try testing.expectEqual(before, app.mode); +} + +test "programmer mode: every clickable control acts" { + var app = testApp(); + defer app.deinit(); + var ctx = testCtx(); + defer ctx.cmds.deinit(testing.allocator); + app.setMode(.programmer); + + // Focus a field, with and without a bit position. + try app.applyAction(&ctx, .{ .prog_field = .{ .field = .hex, .bit = 8 } }); + try testing.expectEqual(App.ProgField.hex, app.prog_field); + try testing.expectEqual(@as(u7, 8), app.bit_cursor); + try app.applyAction(&ctx, .{ .prog_field = .{ .field = .oct, .bit = null } }); + try testing.expectEqual(App.ProgField.oct, app.prog_field); + // An out-of-width bit is ignored rather than moving the cursor off the value. + app.prog_config.bit_width = .bits8; + try app.applyAction(&ctx, .{ .prog_field = .{ .field = .bits, .bit = 100 } }); + try testing.expect(app.bit_cursor < 8); + + // Toggling bits. + app.prog_config.bit_width = .bits32; + app.prog_value = 0; + try app.applyAction(&ctx, .{ .toggle_bit = 3 }); + try testing.expectEqual(@as(u128, 8), app.prog_value); + try app.applyAction(&ctx, .{ .toggle_bit = 3 }); + try testing.expectEqual(@as(u128, 0), app.prog_value); + try app.applyAction(&ctx, .{ .toggle_bit = 99 }); // out of width: no-op + try testing.expectEqual(@as(u128, 0), app.prog_value); + + // Settings. + const width_before = app.prog_config.bit_width; + try app.applyAction(&ctx, .cycle_width); + try testing.expect(app.prog_config.bit_width != width_before); + const endian_before = app.prog_config.display_endian; + try app.applyAction(&ctx, .toggle_endian); + try testing.expect(app.prog_config.display_endian != endian_before); + const signed_before = app.prog_config.signedness; + try app.applyAction(&ctx, .toggle_signedness); + try testing.expect(app.prog_config.signedness != signed_before); + + // Float overlay and its format toggle. + try app.applyAction(&ctx, .toggle_float); + try testing.expect(app.float_view_active); + const format_before = app.float_format; + try app.applyAction(&ctx, .toggle_float_format); + try testing.expect(app.float_format != format_before); + try app.applyAction(&ctx, .toggle_float); + try testing.expect(!app.float_view_active); + + // Focus and help. + try app.applyAction(&ctx, .focus_input); + try testing.expect(!app.value_zone_active); + app.show_help = true; + try app.applyAction(&ctx, .close_help); + try testing.expect(!app.show_help); +} + +test "programmer mode: bit width cycles through every size and keeps the cursor in range" { + var app = testApp(); + defer app.deinit(); + var ctx = testCtx(); + defer ctx.cmds.deinit(testing.allocator); + app.setMode(.programmer); + app.value_zone_active = true; + app.bit_cursor = 100; + app.prog_config.bit_width = .bits8; + + var seen: usize = 0; + while (seen < 6) : (seen += 1) { + try press(&app, &ctx, .{ .codepoint = 'w', .mods = .{ .ctrl = true } }); + try testing.expect(app.bit_cursor < app.prog_config.bit_width.bits()); + } + // Six steps through five widths lands one past the start: 8, 16, 32, 64, 128, 8, 16. + try testing.expectEqual(engine.BitWidth.bits16, app.prog_config.bit_width); +} + +test "programmer mode: typing edits the focused field in its own base" { + var app = testApp(); + defer app.deinit(); + var ctx = testCtx(); + defer ctx.cmds.deinit(testing.allocator); + app.setMode(.programmer); + app.value_zone_active = true; + app.prog_config.bit_width = .bits32; + + // Hex nibble entry. + app.prog_field = .hex; + app.bit_cursor = 28; + app.prog_value = 0; + for ("dead") |char| try press(&app, &ctx, .{ .codepoint = char }); + try testing.expectEqual(@as(u128, 0xDEAD0000), app.prog_value); + + // Octal digit entry. + app.prog_field = .oct; + app.bit_cursor = 6; + app.prog_value = 0; + try press(&app, &ctx, .{ .codepoint = '7' }); + try testing.expectEqual(@as(u128, 0o700), app.prog_value); + + // Binary digits. + app.prog_field = .bin; + app.bit_cursor = 3; + app.prog_value = 0; + try press(&app, &ctx, .{ .codepoint = '1' }); + try press(&app, &ctx, .{ .codepoint = '1' }); + try testing.expectEqual(@as(u128, 0b1100), app.prog_value); + try press(&app, &ctx, .{ .codepoint = '0' }); + + // Decimal digits accumulate. + app.prog_field = .dec_unsigned; + app.prog_value = 0; + for ("123") |char| try press(&app, &ctx, .{ .codepoint = char }); + try testing.expectEqual(@as(u128, 123), app.prog_value); + + // The expression row ignores typed digits. + app.prog_field = .expression; + const before = app.prog_value; + try press(&app, &ctx, .{ .codepoint = '9' }); + try testing.expectEqual(before, app.prog_value); +} + +test "programmer mode: arrows and space navigate the bit grid" { + var app = testApp(); + defer app.deinit(); + var ctx = testCtx(); + defer ctx.cmds.deinit(testing.allocator); + app.setMode(.programmer); + app.value_zone_active = true; + app.prog_config.bit_width = .bits64; + app.prog_field = .bits; + app.bit_cursor = 0; + + // Up moves a row of bits at a time inside the grid, then to the previous field. + try press(&app, &ctx, .{ .codepoint = vaxis.Key.up }); + try testing.expectEqual(@as(u7, 32), app.bit_cursor); + try press(&app, &ctx, .{ .codepoint = vaxis.Key.down }); + try testing.expectEqual(@as(u7, 0), app.bit_cursor); + try press(&app, &ctx, .{ .codepoint = vaxis.Key.up }); + try press(&app, &ctx, .{ .codepoint = vaxis.Key.up }); + try testing.expectEqual(App.ProgField.expression, app.prog_field); + + // Left/Right step by the field's digit size. + app.prog_field = .hex; + app.bit_cursor = 0; + try press(&app, &ctx, .{ .codepoint = vaxis.Key.left }); + try testing.expectEqual(@as(u7, 4), app.bit_cursor); + try press(&app, &ctx, .{ .codepoint = vaxis.Key.right }); + try testing.expectEqual(@as(u7, 0), app.bit_cursor); + // At the edges the cursor stays put. + try press(&app, &ctx, .{ .codepoint = vaxis.Key.right }); + try testing.expectEqual(@as(u7, 0), app.bit_cursor); + + // Space flips the bit under the cursor, but only in the grid. + app.prog_field = .bits; + app.prog_value = 0; + app.bit_cursor = 5; + try press(&app, &ctx, .{ .codepoint = ' ' }); + try testing.expectEqual(@as(u128, 32), app.prog_value); + app.prog_field = .hex; + try press(&app, &ctx, .{ .codepoint = ' ' }); + try testing.expectEqual(@as(u128, 32), app.prog_value); + + // Down from the last field wraps to the first. + app.prog_field = .expression; + try press(&app, &ctx, .{ .codepoint = vaxis.Key.down }); + try testing.expectEqual(App.ProgField.bits, app.prog_field); +} + +test "programmer mode: an expression submitted from the prompt sets the value" { + var app = testApp(); + defer app.deinit(); + var ctx = testCtx(); + defer ctx.cmds.deinit(testing.allocator); + app.setMode(.programmer); + + try app.input.insertSliceAtCursor("0xFF and 0x0F"); + try press(&app, &ctx, .{ .codepoint = vaxis.Key.enter }); + try testing.expectEqual(@as(u128, 0x0F), app.prog_value); + try testing.expectEqual(@as(usize, 1), app.history.items.len); + try testing.expect(!app.history.items[0].is_error); + + // A bad expression records an error and leaves the value alone. + try app.input.insertSliceAtCursor("0xFF and"); + try press(&app, &ctx, .{ .codepoint = vaxis.Key.enter }); + try testing.expectEqual(@as(u128, 0x0F), app.prog_value); + try testing.expect(app.history.items[1].is_error); +} + +test "float view: typing a decimal stores the nearest bit pattern" { + var app = testApp(); + defer app.deinit(); + var ctx = testCtx(); + defer ctx.cmds.deinit(testing.allocator); + app.setMode(.programmer); + app.float_view_active = true; + app.float_format = .f64; + app.prog_config.bit_width = .bits64; + + try app.input.insertSliceAtCursor("3.14"); + try press(&app, &ctx, .{ .codepoint = vaxis.Key.enter }); + try testing.expectEqual(@as(u128, @as(u64, @bitCast(@as(f64, 3.14)))), app.prog_value); + try testing.expect(app.history.items.len == 1); + + // Input that is not a float literal falls through to the integer engine. + try app.input.insertSliceAtCursor("1 << 3"); + try press(&app, &ctx, .{ .codepoint = vaxis.Key.enter }); + try testing.expectEqual(@as(u128, 8), app.prog_value); + + // In the float overlay Ctrl-W swaps format instead of cycling width. + try press(&app, &ctx, .{ .codepoint = 'w', .mods = .{ .ctrl = true } }); + try testing.expectEqual(engine.FloatFormat.f32, app.float_format); + try testing.expectEqual(engine.BitWidth.bits32, app.prog_config.bit_width); + + // Ctrl-F leaves the overlay. + try press(&app, &ctx, .{ .codepoint = 'f', .mods = .{ .ctrl = true } }); + try testing.expect(!app.float_view_active); +} + +test "switching into programmer mode picks a representation that fits the answer" { + var app = testApp(); + defer app.deinit(); + var ctx = testCtx(); + defer ctx.cmds.deinit(testing.allocator); + + // An integer answer uses the integer views. + try app.input.insertSliceAtCursor("6 * 7"); + try press(&app, &ctx, .{ .codepoint = vaxis.Key.enter }); + app.setMode(.programmer); + try testing.expect(!app.float_view_active); + try testing.expectEqual(@as(u128, 42), app.prog_value); + + // A negative integer arrives as its two's complement. + app.setMode(.standard); + try app.input.insertSliceAtCursor("0 - 42"); + try press(&app, &ctx, .{ .codepoint = vaxis.Key.enter }); + app.setMode(.programmer); + try testing.expect(!app.float_view_active); + try testing.expectEqual(@as(u128, @as(u64, @bitCast(@as(i64, -42)))), app.prog_value & 0xFFFF_FFFF_FFFF_FFFF); + + // A fraction cannot be shown in the integer views, so the float view opens. + app.setMode(.standard); + try app.input.insertSliceAtCursor("1 / 3"); + try press(&app, &ctx, .{ .codepoint = vaxis.Key.enter }); + app.setMode(.programmer); + try testing.expect(app.float_view_active); + try testing.expectEqual(engine.FloatFormat.f64, app.float_format); +} + +test "convert mode: selection keys move between columns and wrap within them" { + var app = testApp(); + defer app.deinit(); + var ctx = testCtx(); + defer ctx.cmds.deinit(testing.allocator); + app.setMode(.convert); + app.value_zone_active = true; + + // Left and right cycle category -> from -> to. + app.conv_zone = .from; + try press(&app, &ctx, .{ .codepoint = vaxis.Key.left }); + try testing.expectEqual(ConvZone.category, app.conv_zone); + try press(&app, &ctx, .{ .codepoint = vaxis.Key.left }); + try testing.expectEqual(ConvZone.to, app.conv_zone); + try press(&app, &ctx, .{ .codepoint = vaxis.Key.right }); + try testing.expectEqual(ConvZone.category, app.conv_zone); + try press(&app, &ctx, .{ .codepoint = vaxis.Key.right }); + try testing.expectEqual(ConvZone.from, app.conv_zone); + try press(&app, &ctx, .{ .codepoint = vaxis.Key.right }); + try testing.expectEqual(ConvZone.to, app.conv_zone); + + // Up/Down move the selection in the focused column. + app.conv_zone = .from; + const from_before = app.conv_from_idx; + try press(&app, &ctx, .{ .codepoint = vaxis.Key.down }); + try testing.expect(app.conv_from_idx != from_before); + try press(&app, &ctx, .{ .codepoint = vaxis.Key.up }); + try testing.expectEqual(from_before, app.conv_from_idx); + + app.conv_zone = .to; + const to_before = app.conv_to_idx; + try press(&app, &ctx, .{ .codepoint = vaxis.Key.down }); + try testing.expect(app.conv_to_idx != to_before); + + // In the category column, Up/Down change category and reset the unit pair. + app.conv_zone = .category; + const category_before = app.conv_category; + try press(&app, &ctx, .{ .codepoint = vaxis.Key.down }); + try testing.expect(app.conv_category != category_before); + try press(&app, &ctx, .{ .codepoint = vaxis.Key.up }); + try testing.expectEqual(category_before, app.conv_category); +} + +test "convert mode: swap works from either zone and clicks select units" { + var app = testApp(); + defer app.deinit(); + var ctx = testCtx(); + defer ctx.cmds.deinit(testing.allocator); + app.setMode(.convert); + + const before = app.convUnits(); + try press(&app, &ctx, .{ .codepoint = 's', .mods = .{ .ctrl = true } }); + const after = app.convUnits(); + try testing.expectEqualStrings(before.from.name, after.to.name); + try testing.expectEqualStrings(before.to.name, after.from.name); + + try app.applyAction(&ctx, .{ .conv_from = 2 }); + try testing.expectEqual(@as(usize, 2), app.conv_from_idx); + try testing.expectEqual(ConvZone.from, app.conv_zone); + try app.applyAction(&ctx, .{ .conv_to = 5 }); + try testing.expectEqual(@as(usize, 5), app.conv_to_idx); + try testing.expectEqual(ConvZone.to, app.conv_zone); + try app.applyAction(&ctx, .conv_swap); + try testing.expectEqual(@as(usize, 5), app.conv_from_idx); + + // Selecting a category resets the pair to that category's defaults. + try app.applyAction(&ctx, .{ .conv_category = .mass }); + const defaults = defaultUnitIndices(.mass); + try testing.expectEqual(defaults.from, app.conv_from_idx); + try testing.expectEqual(defaults.to, app.conv_to_idx); +} + +test "convert mode: the input line sets the value, an expression or a plain number" { + var app = testApp(); + defer app.deinit(); + var ctx = testCtx(); + defer ctx.cmds.deinit(testing.allocator); + app.setMode(.convert); + try app.applyAction(&ctx, .{ .conv_category = .length }); + + try app.input.insertSliceAtCursor("100"); + try press(&app, &ctx, .{ .codepoint = vaxis.Key.enter }); + try testing.expectEqual(@as(usize, 1), app.history.items.len); + try testing.expect(!app.history.items[0].is_error); + + try app.input.insertSliceAtCursor("2 * 3.5"); + try press(&app, &ctx, .{ .codepoint = vaxis.Key.enter }); + try testing.expect(!app.history.items[1].is_error); + + // A bad value records an error and leaves the previous value in place. + try app.input.insertSliceAtCursor("2 +"); + try press(&app, &ctx, .{ .codepoint = vaxis.Key.enter }); + try testing.expect(app.history.items[2].is_error); +} + +test "standard mode: a bare conversion and its failure modes are recorded" { + var app = testApp(); + defer app.deinit(); + var ctx = testCtx(); + defer ctx.cmds.deinit(testing.allocator); + + try app.input.insertSliceAtCursor("100 km to mi"); + try press(&app, &ctx, .{ .codepoint = vaxis.Key.enter }); + try testing.expect(!app.history.items[0].is_error); + try testing.expect(std.mem.indexOf(u8, app.history.items[0].result, "mi") != null); + + // Incompatible units. + try app.input.insertSliceAtCursor("100 km to kg"); + try press(&app, &ctx, .{ .codepoint = vaxis.Key.enter }); + try testing.expect(app.history.items[1].is_error); + + // A value that does not evaluate. + try app.input.insertSliceAtCursor("2 + to mi"); + try press(&app, &ctx, .{ .codepoint = vaxis.Key.enter }); + try testing.expect(app.history.items[2].is_error); +} + +test "history browsing walks back and restores what was being typed" { + var app = testApp(); + defer app.deinit(); + var ctx = testCtx(); + defer ctx.cmds.deinit(testing.allocator); + + for ([_][]const u8{ "1 + 1", "2 + 2", "3 + 3" }) |expr| { + try app.input.insertSliceAtCursor(expr); + try press(&app, &ctx, .{ .codepoint = vaxis.Key.enter }); + } + + // A partially typed expression is preserved while browsing. + try app.input.insertSliceAtCursor("9 * "); + try press(&app, &ctx, .{ .codepoint = vaxis.Key.up }); + try testing.expectEqual(@as(?usize, 0), app.history_browse_idx); + try press(&app, &ctx, .{ .codepoint = vaxis.Key.up }); + try testing.expectEqual(@as(?usize, 1), app.history_browse_idx); + try press(&app, &ctx, .{ .codepoint = vaxis.Key.down }); + try testing.expectEqual(@as(?usize, 0), app.history_browse_idx); + try press(&app, &ctx, .{ .codepoint = vaxis.Key.down }); + try testing.expectEqual(@as(?usize, null), app.history_browse_idx); + try testing.expectEqual(@as(usize, 4), app.input.buf.realLength()); + + // Down with nothing to restore is a no-op, and Up past the oldest entry stops. + try press(&app, &ctx, .{ .codepoint = vaxis.Key.down }); + var i: usize = 0; + while (i < 10) : (i += 1) try press(&app, &ctx, .{ .codepoint = vaxis.Key.up }); + try testing.expectEqual(@as(?usize, 2), app.history_browse_idx); +} + +test "history browsing with no history does nothing" { + var app = testApp(); + defer app.deinit(); + var ctx = testCtx(); + defer ctx.cmds.deinit(testing.allocator); + + try press(&app, &ctx, .{ .codepoint = vaxis.Key.up }); + try testing.expectEqual(@as(?usize, null), app.history_browse_idx); +} + +test "Ctrl-L clears history and any saved input" { + var app = testApp(); + defer app.deinit(); + var ctx = testCtx(); + defer ctx.cmds.deinit(testing.allocator); + + try app.input.insertSliceAtCursor("0xFF + 1"); + try press(&app, &ctx, .{ .codepoint = vaxis.Key.enter }); + try app.input.insertSliceAtCursor("in progress"); + try press(&app, &ctx, .{ .codepoint = vaxis.Key.up }); + try testing.expect(app.saved_input != null); + + try press(&app, &ctx, .{ .codepoint = 'l', .mods = .{ .ctrl = true } }); + try testing.expectEqual(@as(usize, 0), app.history.items.len); + try testing.expectEqual(@as(?usize, null), app.history_browse_idx); + try testing.expect(app.saved_input == null); +} + +test "an empty prompt submits nothing" { + var app = testApp(); + defer app.deinit(); + var ctx = testCtx(); + defer ctx.cmds.deinit(testing.allocator); + + try press(&app, &ctx, .{ .codepoint = vaxis.Key.enter }); + try testing.expectEqual(@as(usize, 0), app.history.items.len); +} + +test "typed characters reach the prompt and Ctrl-C quits" { + var app = testApp(); + defer app.deinit(); + var ctx = testCtx(); + defer ctx.cmds.deinit(testing.allocator); + + for ("42") |char| try press(&app, &ctx, .{ .codepoint = char, .text = "x" }); + try testing.expect(app.input.buf.realLength() > 0); + + try press(&app, &ctx, .{ .codepoint = 'c', .mods = .{ .ctrl = true } }); + try testing.expect(ctx.quit); +} + +test "errorStr covers the errors the TUI can surface" { + const errors = [_]engine.CalcError{ + engine.CalcError.DivisionByZero, + engine.CalcError.UnknownFunction, + engine.CalcError.UnknownVariable, + engine.CalcError.UnmatchedParen, + engine.CalcError.UnexpectedToken, + engine.CalcError.UnexpectedEnd, + engine.CalcError.InvalidNumber, + engine.CalcError.DomainError, + engine.CalcError.Overflow, + engine.CalcError.UnknownUnit, + engine.CalcError.IncompatibleUnits, + engine.CalcError.ConvergenceFailure, + }; + for (errors) |err| { + const text = errorStr(err); + try testing.expect(std.mem.startsWith(u8, text, "error: ")); + } +} + +test "render: programmer mode draws the cursor in whichever field is focused" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var app = testApp(); + defer app.deinit(); + app.setMode(.programmer); + app.value_zone_active = true; + app.prog_value = 0x0FEDCBA987654321; + + // Each base row highlights differently and draws a digit cursor, so every + // field has to be drawn focused at least once. + const fields = [_]App.ProgField{ .bits, .dec_signed, .dec_unsigned, .hex, .oct, .bin, .expression }; + for (fields) |field| { + app.prog_field = field; + for ([_]engine.BitWidth{ .bits8, .bits64, .bits128 }) |width| { + app.prog_config.bit_width = width; + app.bit_cursor = @intCast(@min(5, width.bits() - 1)); + const rows = try renderApp(arena, &app, 110, 40); + try testing.expect(test_render.wellFormed(rows, 110)); + // The config line is always present, whichever field has focus. + try testing.expect(test_render.contains(rows, "Bits:")); + } + } +} + +test "render: convert mode draws the focused column and category" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var app = testApp(); + defer app.deinit(); + app.setMode(.convert); + app.value_zone_active = true; + + for ([_]ConvZone{ .category, .from, .to }) |zone| { + app.conv_zone = zone; + const rows = try renderApp(arena, &app, 100, 34); + try testing.expect(test_render.wellFormed(rows, 100)); + try testing.expect(test_render.contains(rows, "Category:")); + } +} diff --git a/src/tui/draw.zig b/src/tui/draw.zig index bf4fd27..7f5fb9f 100644 --- a/src/tui/draw.zig +++ b/src/tui/draw.zig @@ -15,6 +15,12 @@ pub const C = struct { pub const yellow: vaxis.Cell.Color = .{ .rgb = .{ 0xE6, 0xDB, 0x74 } }; pub const purple: vaxis.Cell.Color = .{ .rgb = .{ 0xAE, 0x81, 0xFF } }; pub const orange: vaxis.Cell.Color = .{ .rgb = .{ 0xFD, 0x97, 0x1F } }; + /// Selection background, from Molokai's own selection colour. Used where a + /// field is being edited: reversing a bright accent colour into the + /// background reads as a 1980s terminal and fights the surrounding text, while + /// a dark neutral fill keeps the value legible in its normal colour and lets + /// the cursor be the only bright thing on the row. + pub const sel: vaxis.Cell.Color = .{ .rgb = .{ 0x49, 0x48, 0x3E } }; }; /// Static single-byte grapheme strings with static lifetime. diff --git a/src/tui/financial.zig b/src/tui/financial.zig new file mode 100644 index 0000000..6ec0241 --- /dev/null +++ b/src/tui/financial.zig @@ -0,0 +1,1702 @@ +//! Financial mode for the TUI (FR-7.5). +//! +//! A form per calculation: CAGR, compound interest, TVM, and amortization. The +//! engine already exposes all of this as expression functions (FR-5.7), so this +//! view exists for the case where you want the variables laid out and labeled +//! rather than remembering an argument order, and for the one output that is not +//! a single value: the amortization schedule. +//! +//! ## Solving by omission +//! +//! TVM and compound interest solve for whichever field is left blank, matching a +//! financial calculator and the engine's own `TvmParams` convention. That is why +//! blank is a meaningful state here rather than an error: the form tells you what +//! it is waiting for instead of demanding every box be filled. +//! +//! ## State lives here, not in App +//! +//! `State` owns the form buffers and all the editing and solving logic, so the +//! behavior is testable without constructing a terminal app. `App` embeds one and +//! forwards key presses to it. Everything below the drawing section is pure. + +const std = @import("std"); +const vaxis = @import("vaxis"); +const vxfw = vaxis.vxfw; +const engine = @import("engine"); +const draw = @import("draw.zig"); +const tui = @import("../tui.zig"); +const test_render = @import("test_render.zig"); +const C = draw.C; + +const financial = engine.financial; +const formatter = engine.formatter; + +/// Which calculation the form is showing. +pub const Form = enum { + cagr, + compound, + tvm, + amortization, + + pub fn label(self: Form) []const u8 { + return switch (self) { + .cagr => "CAGR", + .compound => "Compound", + .tvm => "TVM", + .amortization => "Amortization", + }; + } + + /// The relationship being solved, shown under the result so the numbers are + /// never just asserted. + pub fn formula(self: Form) []const u8 { + return switch (self) { + .cagr => "CAGR = (end / start)^(1/n) - 1", + .compound => "FV = PV * (1 + rate/m)^(m * years)", + .tvm => "PV*(1+r)^N + PMT*[((1+r)^N - 1)/r] + FV = 0, r = I/Y / 100", + .amortization => "interest = balance * r, principal = payment - interest", + }; + } + + pub fn fields(self: Form) []const FieldSpec { + return switch (self) { + .cagr => &cagr_fields, + .compound => &compound_fields, + .tvm => &tvm_fields, + .amortization => &amortization_fields, + }; + } +}; + +pub const form_count = std.enums.values(Form).len; + +/// One row of the form. +pub const FieldSpec = struct { + label: []const u8, + /// What to show when the field is empty. For a field that can be solved for + /// this reads "[solve]"; for an optional input it says what the default is. + blank: []const u8 = "", + /// A toggle is flipped with Space instead of typed into. + toggle: bool = false, + /// A rate. The value renders with a trailing `%`, and a typed `%` is accepted + /// and ignored, so "6%" and "6" mean the same thing. The label alone was doing + /// this work before, which left the entered number ambiguous as soon as the + /// eye moved to the value column. + percent: bool = false, +}; + +const cagr_fields = [_]FieldSpec{ + .{ .label = "Start value" }, + .{ .label = "End value" }, + .{ .label = "Periods" }, +}; + +const compound_fields = [_]FieldSpec{ + .{ .label = "Present value", .blank = "[solve]" }, + .{ .label = "Future value", .blank = "[solve]" }, + .{ .label = "Annual rate", .blank = "[solve]", .percent = true }, + .{ .label = "Years", .blank = "[solve]" }, + // Not solvable, and prefilled with 1 so the default is visible rather than + // implied by an empty field. Solving for a compounding frequency only has an + // answer in a narrow band between annual and continuous compounding, and a + // fractional answer ("compound 7.3 times a year") would be meaningless, so + // this stays an input. Clearing it asks the form for a value rather than for + // an answer. + .{ .label = "Compounds/year" }, +}; + +/// Index of the compounding-frequency row, which is prefilled. +const compound_per_year_index: usize = 4; + +const tvm_fields = [_]FieldSpec{ + .{ .label = "N (periods)", .blank = "[solve]" }, + .{ .label = "I/Y (per period)", .blank = "[solve]", .percent = true }, + .{ .label = "PV", .blank = "[solve]" }, + .{ .label = "PMT", .blank = "[solve]" }, + .{ .label = "FV", .blank = "[solve]" }, + .{ .label = "Payments at", .toggle = true }, +}; + +const amortization_fields = [_]FieldSpec{ + .{ .label = "Principal" }, + .{ .label = "Rate per period", .percent = true }, + .{ .label = "Periods" }, + .{ .label = "Payment", .blank = "(derived)" }, +}; + +/// Longest field list, so the buffers can be a fixed array. +pub const max_fields = 6; + +/// Characters in a single field. Enough for an amount with grouping or a short +/// expression; entry stops rather than scrolling, so the value is always fully +/// visible. +pub const field_capacity = 32; + +/// One editable field. +/// +/// The buffer holds exactly what was typed, ungrouped, so it round-trips through +/// `parseFloat` and through `Enter` evaluation. Grouping is a display concern +/// only: `writeDisplay` inserts the commas. Storing the grouped form instead would +/// mean stripping separators on every read, and would make cursor arithmetic +/// depend on where the commas happen to fall. +pub const Field = struct { + // Zero-filled rather than undefined: only `buf[0..len]` is ever read, so the + // rest is dead space, and zeroing 32 bytes costs nothing while removing the + // chance of a stale-byte bug in a struct that gets copied around. + buf: [field_capacity]u8 = @splat(0), + len: usize = 0, + + pub fn text(self: *const Field) []const u8 { + return self.buf[0..self.len]; + } + + pub fn isEmpty(self: *const Field) bool { + return self.len == 0; + } + + pub fn clear(self: *Field) void { + self.len = 0; + } + + fn push(self: *Field, char: u8) void { + if (self.len >= self.buf.len) return; + self.buf[self.len] = char; + self.len += 1; + } + + fn pop(self: *Field) void { + if (self.len > 0) self.len -= 1; + } + + /// Replace the contents with `text_value`, truncating at capacity. + pub fn set(self: *Field, text_value: []const u8) void { + const n = @min(text_value.len, self.buf.len); + @memcpy(self.buf[0..n], text_value[0..n]); + self.len = n; + } + + /// The parsed value, or null when the field is empty, holds an expression, or + /// does not parse. A half-typed "1e" parses as null rather than as a wrong + /// number. + pub fn value(self: *const Field) ?f64 { + if (self.len == 0) return null; + const parsed = std.fmt.parseFloat(f64, self.text()) catch return null; + if (!std.math.isFinite(parsed)) return null; + return parsed; + } + + /// True when the contents are something other than a plain number, i.e. an + /// expression waiting to be evaluated with Enter. + pub fn isExpression(self: *const Field) bool { + return self.len != 0 and self.value() == null; + } + + /// Write the display form into `dest` and return it: a plain number gets + /// thousands separators, an expression is shown exactly as typed (there is + /// nothing meaningful to group in "12 * 30"). + pub fn writeDisplay(self: *const Field, dest: []u8) []const u8 { + const raw = self.text(); + if (self.isExpression()) return raw[0..@min(raw.len, dest.len)]; + const needed = formatter.groupedDecimalLen(raw); + if (needed == raw.len or needed > dest.len) return raw[0..@min(raw.len, dest.len)]; + return dest[0..formatter.writeGroupedDecimal(dest, raw)]; + } +}; + +/// What the form currently evaluates to. +pub const Outcome = union(enum) { + /// The form is not yet answerable; the text says what it needs. + hint: []const u8, + err: engine.CalcError, + value: Value, + schedule: Schedule, + + pub const Value = struct { + label: []const u8, + number: f64, + suffix: []const u8 = "", + /// Iterations used by the rate solver, when one ran. + iterations: ?usize = null, + /// The effective annual rate, when `number` is a nominal one and the two + /// differ. A solved nominal rate shown on its own invites a wrong + /// comparison against a quoted effective rate. + effective: ?f64 = null, + }; + + pub const Schedule = struct { + params: financial.AmortizationParams, + payment: f64, + totals: financial.AmortizationTotals, + }; +}; + +/// Starting contents of every form's fields. Computed at comptime so `State{}` is +/// already seeded: a default applied at draw time could not tell "never touched" +/// from "deliberately cleared". +const initial_values: [form_count][max_fields]Field = blk: { + var values: [form_count][max_fields]Field = @splat(@splat(.{})); + values[@intFromEnum(Form.compound)][compound_per_year_index].set("1"); + break :blk values; +}; + +/// Financial mode state: the form buffers plus the cursor and scroll position. +pub const State = struct { + form: Form = .cagr, + /// Focused field index within the current form. + field: usize = 0, + /// Per-form buffers, so switching forms does not discard what was typed. + /// Seeded with the defaults that are near-universal, so a default is a visible + /// value rather than an empty field the user has to know the meaning of. + values: [form_count][max_fields]Field = initial_values, + /// Payments at the start of each period (annuity due) rather than the end. + due: bool = false, + /// First schedule row shown, for the amortization table. + scroll: usize = 0, + + pub fn specs(self: *const State) []const FieldSpec { + return self.form.fields(); + } + + pub fn fieldCount(self: *const State) usize { + return self.specs().len; + } + + pub fn fieldAt(self: *State, index: usize) *Field { + return &self.values[@intFromEnum(self.form)][index]; + } + + pub fn fieldAtConst(self: *const State, index: usize) *const Field { + return &self.values[@intFromEnum(self.form)][index]; + } + + pub fn focused(self: *State) *Field { + return self.fieldAt(self.field); + } + + pub fn setForm(self: *State, form: Form) void { + self.form = form; + self.field = 0; + self.scroll = 0; + } + + pub fn focusField(self: *State, index: usize) void { + if (index < self.fieldCount()) self.field = index; + } + + pub fn nextField(self: *State) void { + self.field = (self.field + 1) % self.fieldCount(); + } + + pub fn prevField(self: *State) void { + self.field = if (self.field == 0) self.fieldCount() - 1 else self.field - 1; + } + + /// True when the focused row is the annuity-due toggle rather than a number. + pub fn focusedIsToggle(self: *const State) bool { + const list = self.specs(); + return self.field < list.len and list[self.field].toggle; + } + + /// Accept a typed character into the focused field. + /// + /// Fields take expressions, not just numbers: `12 * 30` in a period count is + /// more honest than making the user do the multiplication, and the expression + /// language is already there. So anything printable is accepted except the + /// separators the field adds itself. + /// + /// Two characters are absorbed rather than stored: a comma, because grouping + /// is applied on display and a typed one would break `parseFloat`, and a `%` + /// on a rate field, because the field already means percent. Typing "6%" in a + /// rate field therefore does what it looks like. + /// + /// Returns true when the key was consumed. + pub fn typeChar(self: *State, char: u8) bool { + if (self.focusedIsToggle()) { + if (char == ' ') { + self.due = !self.due; + return true; + } + return false; + } + if (char == ',') return true; + if (char == '%' and self.specs()[self.field].percent) return true; + // A whitelist rather than "anything printable": it has to cover the + // expression language (identifiers for `sqrt`, operators, parens) while + // still leaving `?` free to mean help and backtick free to switch zones. + const accepted = std.ascii.isAlphanumeric(char) or char == ' ' or + std.mem.indexOfScalar(u8, "+-*/^%().<>&|~=_", char) != null; + if (!accepted) return false; + self.focused().push(char); + return true; + } + + pub fn backspace(self: *State) void { + if (self.focusedIsToggle()) return; + self.focused().pop(); + } + + pub fn clearFocused(self: *State) void { + if (self.focusedIsToggle()) return; + self.focused().clear(); + } + + /// Put a computed value into the focused field, as plain digits so it can be + /// re-parsed. Used by the input line, which evaluates an expression and + /// assigns it here. + pub fn setFocusedValue(self: *State, number: f64) void { + if (self.focusedIsToggle()) { + self.due = !self.due; + return; + } + var buf: [field_capacity]u8 = undefined; + const text = std.fmt.bufPrint(&buf, "{d}", .{number}) catch return; + self.focused().set(text); + } + + /// Scroll the amortization schedule, clamped to the row count. + pub fn scrollBy(self: *State, delta: i32, visible: usize, total: usize) void { + const max_scroll = if (total > visible) total - visible else 0; + const current: i64 = @intCast(self.scroll); + const next = current + delta; + if (next < 0) { + self.scroll = 0; + } else if (@as(usize, @intCast(next)) > max_scroll) { + self.scroll = max_scroll; + } else { + self.scroll = @intCast(next); + } + } + + /// Count of empty fields among `indices`, used by the solve-by-omission + /// forms. + fn blankCount(self: *const State, indices: []const usize) usize { + var count: usize = 0; + for (indices) |i| { + if (self.fieldAtConst(i).isEmpty()) count += 1; + } + return count; + } + + /// The first field holding an unevaluated expression, if any. + pub fn pendingExpression(self: *const State) ?usize { + for (self.specs(), 0..) |spec, i| { + if (spec.toggle) continue; + if (self.fieldAtConst(i).isExpression()) return i; + } + return null; + } + + /// What the form currently computes to. Pure: no allocation, no drawing. + pub fn outcome(self: *const State) Outcome { + // An unevaluated expression is reported rather than treated as a blank, + // which would otherwise make a typed "12 * 30" look like a field the form + // is waiting to solve for. + if (self.pendingExpression()) |_| return .{ .hint = "press Enter to evaluate this field" }; + return switch (self.form) { + .cagr => self.cagrOutcome(), + .compound => self.compoundOutcome(), + .tvm => self.tvmOutcome(), + .amortization => self.amortizationOutcome(), + }; + } + + fn cagrOutcome(self: *const State) Outcome { + const start = self.fieldAtConst(0).value() orelse return .{ .hint = "enter a start value" }; + const end = self.fieldAtConst(1).value() orelse return .{ .hint = "enter an end value" }; + const periods = self.fieldAtConst(2).value() orelse return .{ .hint = "enter a period count" }; + const rate = financial.cagr(start, end, periods) catch |err| return .{ .err = err }; + // Shown as a percentage: a growth rate is what a user came here to read, + // and 20.11% is the form they expect it in. + return .{ .value = .{ .label = "CAGR", .number = rate * 100.0, .suffix = "%" } }; + } + + fn compoundOutcome(self: *const State) Outcome { + // The compounding frequency is an input, not a variable, so it is excluded + // from the solve-by-omission rule and simply has to be present. + const per_year = self.fieldAtConst(compound_per_year_index).value() orelse + return .{ .hint = "enter a compounding frequency (1 = annual, 12 = monthly)" }; + + const blanks = self.blankCount(&.{ 0, 1, 2, 3 }); + if (blanks == 0) return .{ .hint = "clear one field to solve for it" }; + if (blanks > 1) return .{ .hint = "fill in all but one of PV, FV, rate and years" }; + + if (self.fieldAtConst(0).isEmpty()) { + const fv = self.fieldAtConst(1).value().?; + const rate = self.fieldAtConst(2).value().?; + const years = self.fieldAtConst(3).value().?; + const pv = financial.compoundPresentValue(fv, rate, years, per_year) catch |err| return .{ .err = err }; + return .{ .value = .{ .label = "PV", .number = pv } }; + } + if (self.fieldAtConst(1).isEmpty()) { + const pv = self.fieldAtConst(0).value().?; + const rate = self.fieldAtConst(2).value().?; + const years = self.fieldAtConst(3).value().?; + const fv = financial.compoundFutureValue(pv, rate, years, per_year) catch |err| return .{ .err = err }; + return .{ .value = .{ .label = "FV", .number = fv } }; + } + if (self.fieldAtConst(2).isEmpty()) { + const pv = self.fieldAtConst(0).value().?; + const fv = self.fieldAtConst(1).value().?; + const years = self.fieldAtConst(3).value().?; + const rate = financial.compoundRate(pv, fv, years, per_year) catch |err| return .{ .err = err }; + // Above annual compounding the solved figure is a nominal rate, which + // is not what the money earned; both are shown so the two cannot be + // confused. + const effective: ?f64 = if (per_year > 1) + financial.effectiveAnnualRate(rate, per_year) catch null + else + null; + return .{ .value = .{ + .label = "rate", + .number = rate, + .suffix = "%", + .effective = effective, + } }; + } + + const pv = self.fieldAtConst(0).value().?; + const fv = self.fieldAtConst(1).value().?; + const rate = self.fieldAtConst(2).value().?; + const years = financial.compoundPeriods(pv, fv, rate, per_year) catch |err| return .{ .err = err }; + return .{ .value = .{ .label = "years", .number = years } }; + } + + fn tvmOutcome(self: *const State) Outcome { + const blanks = self.blankCount(&.{ 0, 1, 2, 3, 4 }); + if (blanks == 0) return .{ .hint = "clear one field to solve for it" }; + if (blanks > 1) return .{ .hint = "fill in all but one field" }; + + const solution = financial.solveTvm(.{ + .periods = self.fieldAtConst(0).value(), + .rate = self.fieldAtConst(1).value(), + .present_value = self.fieldAtConst(2).value(), + .payment = self.fieldAtConst(3).value(), + .future_value = self.fieldAtConst(4).value(), + .due = self.due, + }) catch |err| return .{ .err = err }; + + return .{ .value = .{ + .label = solution.variable.label(), + .number = solution.value, + .suffix = if (solution.variable == .rate) "%" else "", + .iterations = solution.iterations, + } }; + } + + fn amortizationOutcome(self: *const State) Outcome { + const principal = self.fieldAtConst(0).value() orelse return .{ .hint = "enter a principal" }; + const rate = self.fieldAtConst(1).value() orelse return .{ .hint = "enter a rate per period" }; + const periods_input = self.fieldAtConst(2).value() orelse return .{ .hint = "enter a number of periods" }; + const max_periods: f64 = @floatFromInt(financial.max_schedule_periods); + if (!(periods_input >= 1) or periods_input > max_periods or @floor(periods_input) != periods_input) { + return .{ .hint = "periods must be a whole number of at least 1" }; + } + + const params: financial.AmortizationParams = .{ + .principal = principal, + .rate = rate, + .periods = @intFromFloat(periods_input), + .payment = self.fieldAtConst(3).value(), + }; + const payment = financial.amortizationPayment(params) catch |err| return .{ .err = err }; + const totals = financial.amortizationTotals(params) catch |err| return .{ .err = err }; + return .{ .schedule = .{ .params = params, .payment = payment, .totals = totals } }; + } +}; + +// -- Drawing -- + +const label_col: u16 = 4; +const value_col: u16 = 24; +/// Width of the editable slot, wide enough for a grouped amount plus a "%". +const value_width: u16 = 26; + +pub fn drawFinancialMode(app: *tui.App, surface: *vxfw.Surface, width: u16, height: u16) void { + const state = &app.fin; + const zone_active = app.value_zone_active; + + // -- Calculation chips -- + draw.writeStr(surface, 2, 2, "Calculation:", .{ .fg = C.muted }); + var row: u16 = 3; + var col: u16 = label_col; + for (std.enums.values(Form)) |form| { + const label = form.label(); + const chip_len: u16 = @intCast(label.len + 2); + if (col + chip_len >= width -| 2) { + row += 1; + col = label_col; + } + const selected = form == state.form; + const style: vaxis.Style = if (selected) + .{ .fg = C.bg, .bg = C.yellow, .bold = true } + else + .{ .fg = C.muted }; + draw.writeChar(surface, row, col, ' ', style); + draw.writeStr(surface, row, col + 1, label, style); + draw.writeChar(surface, row, col + 1 + @as(u16, @intCast(label.len)), ' ', style); + app.addRegion(row, col, chip_len, .{ .fin_form = form }); + col += chip_len + 1; + } + + // -- Fields -- + row += 2; + for (state.specs(), 0..) |spec, i| { + if (row >= height -| 4) break; + const focused = zone_active and i == state.field; + const label_style: vaxis.Style = if (focused) + .{ .fg = C.yellow, .bold = true } + else + .{ .fg = C.muted }; + draw.writeChar(surface, row, 2, if (focused) '>' else ' ', .{ .fg = C.yellow, .bold = true }); + draw.writeStr(surface, row, label_col, spec.label, label_style); + + drawFieldValue(state, surface, row, i, spec, focused); + app.addRegion(row, 2, value_col + value_width, .{ .fin_field = i }); + row += 1; + } + + // -- Result -- + row += 1; + row = drawOutcome(app, state, surface, row, width, height); + + // -- Amortization schedule -- + if (state.form == .amortization) { + if (state.outcome() == .schedule) { + drawSchedule(app, state, surface, row, width, height); + } + } + + // -- Separator, input line, status -- + draw.fillRow(surface, height -| 3, '-', .{ .fg = C.dim }); + app.drawInput(surface, height -| 2); + app.addRegion(height -| 2, 0, width, .focus_input); + + draw.fillRow(surface, height -| 1, ' ', .{ .fg = C.muted, .bg = C.bg }); + const status = if (zone_active) + "Up/Dn:field | L/R:calc | Enter:eval | Space:END/BGN | Ctrl-U:clear | `:input" + else + "Expr + Enter fills the field | `:fields | PgUp/PgDn:scroll | Tab:mode | ?:help"; + draw.writeStr(surface, height -| 1, 1, status, .{ .fg = C.muted, .bg = C.bg }); +} + +/// Draw one field's value, or its blank placeholder. +/// +/// A focused field gets a dark selection fill across the whole value slot rather +/// than a reversed accent colour, so the number keeps its normal foreground and +/// the block cursor is the only bright cell on the row. +fn drawFieldValue( + state: *State, + surface: *vxfw.Surface, + row: u16, + index: usize, + spec: FieldSpec, + focused: bool, +) void { + if (spec.toggle) { + const text = if (state.due) "BGN (start of period)" else "END (end of period)"; + if (focused) fillField(surface, row); + draw.writeStr(surface, row, value_col, text, if (focused) + .{ .fg = C.fg, .bg = C.sel, .bold = true } + else + .{ .fg = C.fg }); + return; + } + + const field = state.fieldAt(index); + if (focused) fillField(surface, row); + // vaxis has no "no background" colour, so the unfocused case uses the app + // background explicitly rather than an optional. + const field_bg: vaxis.Cell.Color = if (focused) C.sel else C.bg; + + if (field.isEmpty()) { + // A blank solve-for field is the answer slot, so it reads as active + // rather than as missing input. + const style: vaxis.Style = if (focused) + .{ .fg = C.yellow, .bg = C.sel, .bold = true } + else + .{ .fg = C.dim }; + draw.writeStr(surface, row, value_col, spec.blank, style); + if (focused) drawCursor(surface, row, value_col + @as(u16, @intCast(spec.blank.len)) + 1); + return; + } + + // Grouped for a plain number ("2,000"), verbatim for an expression, which has + // nothing meaningful to group. + var display_buf: [field_capacity * 2]u8 = undefined; + const shown = field.writeDisplay(&display_buf); + const is_expression = field.isExpression(); + const style: vaxis.Style = .{ + .fg = if (is_expression) C.orange else C.fg, + .bg = field_bg, + .bold = focused, + }; + draw.writeStr(surface, row, value_col, shown, style); + + var end = value_col + @as(u16, @intCast(shown.len)); + // The percent sign is part of the display, not the stored value, so a rate + // field cannot be misread as a raw multiplier. + if (spec.percent and !is_expression) { + draw.writeStr(surface, row, end, " %", .{ .fg = C.muted, .bg = field_bg }); + end += 2; + } + if (is_expression) { + draw.writeStr(surface, row, end + 1, "(Enter)", .{ .fg = C.dim }); + } + if (focused) drawCursor(surface, row, value_col + @as(u16, @intCast(shown.len))); +} + +/// Fill the value slot of a row with the selection background. +fn fillField(surface: *vxfw.Surface, row: u16) void { + var col: u16 = value_col; + while (col < value_col + value_width) : (col += 1) { + draw.writeChar(surface, row, col, ' ', .{ .bg = C.sel }); + } +} + +/// The typing position: a single bright cell, the only one on the row. +fn drawCursor(surface: *vxfw.Surface, row: u16, col: u16) void { + draw.writeChar(surface, row, col, ' ', .{ .fg = C.bg, .bg = C.yellow }); +} + +/// Draw the result block. Returns the next free row. +fn drawOutcome( + app: *tui.App, + state: *State, + surface: *vxfw.Surface, + start_row: u16, + width: u16, + height: u16, +) u16 { + _ = width; + var row = start_row; + if (row >= height -| 4) return row; + + switch (state.outcome()) { + .hint => |text| { + draw.writeStr(surface, row, 2, text, .{ .fg = C.muted }); + row += 1; + }, + .err => |err| { + draw.writeStr(surface, row, 2, errorText(err), .{ .fg = C.pink }); + row += 1; + }, + .value => |value| { + var buf: [160]u8 = undefined; + const text = formatValueLine(&buf, value) orelse "?"; + draw.writeStr(surface, row, 2, text, .{ .fg = C.green, .bold = true }); + row += 1; + if (row < height -| 4) { + draw.writeStr(surface, row, 4, state.form.formula(), .{ .fg = C.muted }); + row += 1; + } + // A substituted line, where it fits on one row, so the arithmetic is + // visible rather than implied. + if (row < height -| 4) { + var sub_buf: [160]u8 = undefined; + if (substitutedFormula(state, &sub_buf)) |text2| { + draw.writeStr(surface, row, 4, text2, .{ .fg = C.dim }); + row += 1; + } + } + }, + .schedule => |schedule| { + var buf: [192]u8 = undefined; + var payment_buf: [40]u8 = undefined; + var interest_buf: [40]u8 = undefined; + var paid_buf: [40]u8 = undefined; + const text = std.fmt.bufPrint(&buf, "Payment {s} | total interest {s} | total paid {s}", .{ + money(&payment_buf, schedule.payment), + money(&interest_buf, schedule.totals.interest), + money(&paid_buf, schedule.totals.paid), + }) catch "?"; + draw.writeStr(surface, row, 2, text, .{ .fg = C.green, .bold = true }); + row += 1; + if (schedule.totals.periods != schedule.params.periods and row < height -| 4) { + var early_buf: [96]u8 = undefined; + const early = std.fmt.bufPrint(&early_buf, "paid off after {d} of {d} periods", .{ + schedule.totals.periods, schedule.params.periods, + }) catch "?"; + draw.writeStr(surface, row, 4, early, .{ .fg = C.muted }); + row += 1; + } + }, + } + _ = app; + return row + 1; +} + +/// Draw the scrollable amortization table. +fn drawSchedule( + app: *tui.App, + state: *State, + surface: *vxfw.Surface, + start_row: u16, + width: u16, + height: u16, +) void { + _ = width; + var row = start_row; + const list_end = height -| 4; + if (row + 1 >= list_end) return; + + const outcome = state.outcome(); + const schedule_info = switch (outcome) { + .schedule => |s| s, + else => return, + }; + + const rows = financial.amortizationSchedule(app.allocator, schedule_info.params) catch return; + defer app.allocator.free(rows); + + draw.writeStr(surface, row, label_col, "Period Payment Interest Principal Balance", .{ .fg = C.muted }); + row += 1; + + const visible: usize = if (list_end > row) list_end - row else 0; + if (visible == 0) return; + + // Clamping here rather than at scroll time keeps the view correct when the + // terminal is resized or the period count changes under a stale offset. + const max_scroll = if (rows.len > visible) rows.len - visible else 0; + if (state.scroll > max_scroll) state.scroll = max_scroll; + + const first = state.scroll; + const last = @min(rows.len, first + visible); + for (rows[first..last]) |entry| { + var line_buf: [160]u8 = undefined; + var pay_buf: [40]u8 = undefined; + var int_buf: [40]u8 = undefined; + var prin_buf: [40]u8 = undefined; + var bal_buf: [40]u8 = undefined; + const line = std.fmt.bufPrint(&line_buf, "{d: >6} {s: >11} {s: >11} {s: >11} {s: >11}", .{ + entry.period, + money(&pay_buf, entry.payment), + money(&int_buf, entry.interest), + money(&prin_buf, entry.principal), + money(&bal_buf, entry.balance), + }) catch continue; + draw.writeStr(surface, row, label_col, line, .{ .fg = C.fg }); + row += 1; + } + + // Position indicator, so a partial view never looks like the whole schedule. + if (rows.len > visible) { + var pos_buf: [64]u8 = undefined; + const pos = std.fmt.bufPrint(&pos_buf, "rows {d}-{d} of {d} (PgUp/PgDn)", .{ + first + 1, last, rows.len, + }) catch ""; + draw.writeStr(surface, height -| 4, label_col, pos, .{ .fg = C.dim }); + } +} + +/// "CAGR = 20.11 %" style result line. +fn formatValueLine(buf: []u8, value: Outcome.Value) ?[]const u8 { + var number_buf: [48]u8 = undefined; + const number = money(&number_buf, value.number); + if (value.effective) |effective| { + var effective_buf: [48]u8 = undefined; + return std.fmt.bufPrint(buf, "= {s} {s}{s} nominal ({s}% effective)", .{ + value.label, number, value.suffix, money(&effective_buf, effective), + }) catch null; + } + if (value.iterations) |iterations| { + return std.fmt.bufPrint(buf, "= {s} {s}{s} ({d} iterations)", .{ + value.label, number, value.suffix, iterations, + }) catch null; + } + return std.fmt.bufPrint(buf, "= {s} {s}{s}", .{ value.label, number, value.suffix }) catch null; +} + +/// The formula with the entered values substituted, for the forms where it fits +/// on one line. Returns null when there is nothing useful to show. +fn substitutedFormula(state: *const State, buf: []u8) ?[]const u8 { + return switch (state.form) { + .cagr => blk: { + const start = state.fieldAtConst(0).text(); + const end = state.fieldAtConst(1).text(); + const periods = state.fieldAtConst(2).text(); + break :blk std.fmt.bufPrint(buf, "= ({s} / {s})^(1/{s}) - 1", .{ end, start, periods }) catch null; + }, + .compound => blk: { + // Only the future-value case is short enough to read on one line. + if (!state.fieldAtConst(1).isEmpty()) break :blk null; + if (state.fieldAtConst(0).isEmpty()) break :blk null; + const pv = state.fieldAtConst(0).text(); + const rate = state.fieldAtConst(2).text(); + const years = state.fieldAtConst(3).text(); + const per_year = state.fieldAtConst(compound_per_year_index).text(); + if (rate.len == 0 or years.len == 0 or per_year.len == 0) break :blk null; + break :blk std.fmt.bufPrint(buf, "= {s} * (1 + {s}/100/{s})^({s} * {s})", .{ + pv, rate, per_year, per_year, years, + }) catch null; + }, + .tvm, .amortization => null, + }; +} + +/// Format an amount with grouping and two decimals, matching the CLI table. +fn money(buf: []u8, value: f64) []const u8 { + var digits: [64]u8 = undefined; + const text = std.fmt.bufPrint(&digits, "{d:.2}", .{@abs(value)}) catch return "?"; + if (text.len < 4) return "?"; + const whole = text[0 .. text.len - 3]; + const fraction = text[text.len - 3 ..]; + + var written: usize = 0; + if (value < 0) { + if (buf.len == 0) return "?"; + buf[0] = '-'; + written = 1; + } + for (whole, 0..) |digit, i| { + const remaining = whole.len - i; + if (i != 0 and remaining % 3 == 0) { + if (written == buf.len) return "?"; + buf[written] = ','; + written += 1; + } + if (written == buf.len) return "?"; + buf[written] = digit; + written += 1; + } + if (written + fraction.len > buf.len) return "?"; + @memcpy(buf[written..][0..fraction.len], fraction); + return buf[0 .. written + fraction.len]; +} + +/// Error text tuned to this view: the generic "domain error" is useless in a +/// form, where the cause is always one of a few bad entries. +pub fn errorText(err: engine.CalcError) []const u8 { + return switch (err) { + engine.CalcError.DomainError => "check the entries: values must be positive and a payment must cover the interest", + engine.CalcError.ConvergenceFailure => "no rate solves these cash flows", + engine.CalcError.InsufficientParameters => "these values do not determine an answer", + engine.CalcError.DivisionByZero => "division by zero", + engine.CalcError.Overflow => "overflow", + else => "cannot compute with these values", + }; +} + +// -- Tests -- + +const testing = std.testing; + +fn stateWith(form: Form, entries: []const []const u8) State { + var state: State = .{}; + state.setForm(form); + for (entries, 0..) |text, i| { + state.fieldAt(i).set(text); + } + return state; +} + +test "Field: typing, backspace, and parsing" { + var state: State = .{}; + try testing.expect(state.typeChar('1')); + try testing.expect(state.typeChar('2')); + try testing.expect(state.typeChar('.')); + try testing.expect(state.typeChar('5')); + try testing.expectEqualStrings("12.5", state.focused().text()); + try testing.expectEqual(@as(?f64, 12.5), state.focused().value()); + + state.backspace(); + try testing.expectEqualStrings("12.", state.focused().text()); + // A half-typed number parses as nothing rather than as a wrong value. + try testing.expectEqual(@as(?f64, 12.0), state.focused().value()); + + state.clearFocused(); + try testing.expect(state.focused().isEmpty()); + try testing.expectEqual(@as(?f64, null), state.focused().value()); +} + +test "Field: expression characters are accepted, junk is not" { + var state: State = .{}; + // Fields take expressions, so operators and identifiers are legitimate. + for ("12 * 30") |char| try testing.expect(state.typeChar(char)); + try testing.expectEqualStrings("12 * 30", state.focused().text()); + try testing.expect(state.focused().isExpression()); + try testing.expectEqual(@as(?f64, null), state.focused().value()); + + state.clearFocused(); + for ("sqrt(144)") |char| try testing.expect(state.typeChar(char)); + try testing.expectEqualStrings("sqrt(144)", state.focused().text()); + + // `?` stays free to mean help, and a backtick stays free to switch zones. + state.clearFocused(); + try testing.expect(!state.typeChar('?')); + try testing.expect(!state.typeChar('`')); + try testing.expect(state.focused().isEmpty()); +} + +test "Field: a typed comma is absorbed, since grouping is applied on display" { + var state: State = .{}; + for ("2,000") |char| try testing.expect(state.typeChar(char)); + // The buffer stays plain so parseFloat still works. + try testing.expectEqualStrings("2000", state.focused().text()); + try testing.expectEqual(@as(?f64, 2000), state.focused().value()); +} + +test "Field: a typed percent is absorbed on a rate field only" { + var state: State = .{}; + state.setForm(.amortization); + state.focusField(1); // Rate per period + for ("6%") |char| try testing.expect(state.typeChar(char)); + try testing.expectEqualStrings("6", state.focused().text()); + + // On a non-rate field `%` is the modulo operator and belongs in the text. + state.focusField(0); + for ("7 % 2") |char| try testing.expect(state.typeChar(char)); + try testing.expectEqualStrings("7 % 2", state.focused().text()); +} + +test "Field: exponent and sign notation still parse" { + var state: State = .{}; + for ("-1e3") |char| try testing.expect(state.typeChar(char)); + try testing.expectEqual(@as(?f64, -1000), state.focused().value()); +} + +test "Field: display groups a plain number and leaves an expression alone" { + var buf: [64]u8 = undefined; + var field: Field = .{}; + + field.set("200"); + try testing.expectEqualStrings("200", field.writeDisplay(&buf)); + // The case from the report: typing a fourth digit starts grouping. + field.set("2000"); + try testing.expectEqualStrings("2,000", field.writeDisplay(&buf)); + field.set("200000"); + try testing.expectEqualStrings("200,000", field.writeDisplay(&buf)); + field.set("1234567.891"); + try testing.expectEqualStrings("1,234,567.891", field.writeDisplay(&buf)); + field.set("-9876543.21"); + try testing.expectEqualStrings("-9,876,543.21", field.writeDisplay(&buf)); + + // Mid-entry states must not be mangled. + field.set("1000."); + try testing.expectEqualStrings("1,000.", field.writeDisplay(&buf)); + field.set("-"); + try testing.expectEqualStrings("-", field.writeDisplay(&buf)); + + // Expressions and exponent notation are shown verbatim. + field.set("12 * 30"); + try testing.expectEqualStrings("12 * 30", field.writeDisplay(&buf)); + field.set("1e6"); + try testing.expectEqualStrings("1e6", field.writeDisplay(&buf)); +} + +test "Field: display falls back to the raw text when the buffer is too small" { + var tiny: [4]u8 = undefined; + var field: Field = .{}; + field.set("200000"); + // No room for the grouped form, so the ungrouped digits are shown rather + // than a truncated, wrong-looking number. + try testing.expectEqualStrings("2000", field.writeDisplay(&tiny)); +} + +test "State: an unevaluated expression is reported, not treated as a blank" { + var state = stateWith(.amortization, &.{ "200000", "0.5", "12 * 30" }); + try testing.expectEqual(@as(?usize, 2), state.pendingExpression()); + const outcome = state.outcome(); + try testing.expect(outcome == .hint); + try testing.expect(std.mem.indexOf(u8, outcome.hint, "Enter") != null); + + // Once evaluated, the form answers. + state.fieldAt(2).set("360"); + try testing.expectEqual(@as(?usize, null), state.pendingExpression()); + try testing.expect(state.outcome() == .schedule); +} + +test "State: a pending expression in a TVM field does not look like a solve slot" { + var state = stateWith(.tvm, &.{ "10 * 36", "0.5", "200000", "", "0" }); + try testing.expect(state.outcome() == .hint); + state.fieldAt(0).set("360"); + try testing.expectEqualStrings("PMT", state.outcome().value.label); +} + +test "Field: entry stops at capacity instead of overflowing" { + var state: State = .{}; + var i: usize = 0; + while (i < field_capacity + 10) : (i += 1) _ = state.typeChar('9'); + try testing.expectEqual(field_capacity, state.focused().len); +} + +test "State: field navigation wraps within the current form" { + var state: State = .{}; + state.setForm(.tvm); + try testing.expectEqual(@as(usize, 6), state.fieldCount()); + state.prevField(); + try testing.expectEqual(@as(usize, 5), state.field); + state.nextField(); + try testing.expectEqual(@as(usize, 0), state.field); +} + +test "State: each form keeps its own entries" { + var state: State = .{}; + state.setForm(.cagr); + state.fieldAt(0).set("10000"); + state.setForm(.tvm); + state.fieldAt(0).set("360"); + // Switching back must not have clobbered the CAGR entry. + state.setForm(.cagr); + try testing.expectEqualStrings("10000", state.fieldAt(0).text()); + state.setForm(.tvm); + try testing.expectEqualStrings("360", state.fieldAt(0).text()); +} + +test "State: switching forms resets the cursor and scroll" { + var state: State = .{}; + state.setForm(.amortization); + state.field = 3; + state.scroll = 40; + state.setForm(.cagr); + try testing.expectEqual(@as(usize, 0), state.field); + try testing.expectEqual(@as(usize, 0), state.scroll); +} + +test "State: the due row toggles with Space instead of typing" { + var state: State = .{}; + state.setForm(.tvm); + state.focusField(5); + try testing.expect(state.focusedIsToggle()); + try testing.expect(!state.due); + try testing.expect(state.typeChar(' ')); + try testing.expect(state.due); + // Digits do nothing on a toggle row, and do not corrupt its buffer. + try testing.expect(!state.typeChar('7')); + try testing.expect(state.fieldAt(5).isEmpty()); + state.backspace(); + try testing.expect(state.due); +} + +test "State: setFocusedValue writes a re-parseable number" { + var state: State = .{}; + state.setFocusedValue(14400); + try testing.expectEqualStrings("14400", state.focused().text()); + try testing.expectEqual(@as(?f64, 14400), state.focused().value()); + // No grouping, because the field is parsed back with parseFloat. + try testing.expect(std.mem.indexOfScalar(u8, state.focused().text(), ',') == null); +} + +test "cagr form: solves and reports as a percentage" { + var state = stateWith(.cagr, &.{ "10000", "25000", "5" }); + const outcome = state.outcome(); + try testing.expectEqualStrings("CAGR", outcome.value.label); + try testing.expectApproxEqAbs(@as(f64, 20.11244), outcome.value.number, 1e-5); + try testing.expectEqualStrings("%", outcome.value.suffix); +} + +test "cagr form: says what it is waiting for" { + var state: State = .{}; + try testing.expect(state.outcome() == .hint); + state.fieldAt(0).set("100"); + try testing.expect(state.outcome() == .hint); + state.fieldAt(1).set("200"); + try testing.expect(state.outcome() == .hint); + state.fieldAt(2).set("3"); + try testing.expect(state.outcome() == .value); +} + +test "cagr form: a bad entry is an error, not a wrong answer" { + var state = stateWith(.cagr, &.{ "0", "25000", "5" }); + try testing.expectEqual(engine.CalcError.DomainError, state.outcome().err); +} + +test "compound form: solves whichever of the four variables is blank" { + // 1000 at 5% for 10 years, annually. + var fv_case = stateWith(.compound, &.{ "1000", "", "5", "10" }); + const fv = fv_case.outcome(); + try testing.expectEqualStrings("FV", fv.value.label); + try testing.expectApproxEqAbs(@as(f64, 1628.894627), fv.value.number, 1e-6); + + // The same relationship in reverse. + var pv_case = stateWith(.compound, &.{ "", "1628.894627", "5", "10" }); + const pv = pv_case.outcome(); + try testing.expectEqualStrings("PV", pv.value.label); + try testing.expectApproxEqAbs(@as(f64, 1000), pv.value.number, 1e-5); + + // Solving for the rate. + var rate_case = stateWith(.compound, &.{ "1000", "1628.894627", "", "10" }); + const rate = rate_case.outcome(); + try testing.expectEqualStrings("rate", rate.value.label); + try testing.expectEqualStrings("%", rate.value.suffix); + try testing.expectApproxEqAbs(@as(f64, 5.0), rate.value.number, 1e-6); + + // And for the time it takes. + var years_case = stateWith(.compound, &.{ "1000", "1628.894627", "5", "" }); + const years = years_case.outcome(); + try testing.expectEqualStrings("years", years.value.label); + try testing.expectApproxEqAbs(@as(f64, 10.0), years.value.number, 1e-6); +} + +test "compound form: a solved rate above annual compounding reports both forms" { + // Nominal 18% compounded monthly is 19.56% effective. + const fv = try engine.financial.compoundFutureValue(1000, 18, 5, 12); + var buf: [32]u8 = undefined; + const fv_text = try std.fmt.bufPrint(&buf, "{d}", .{fv}); + + var state = stateWith(.compound, &.{ "1000", fv_text, "", "5", "12" }); + const outcome = state.outcome(); + try testing.expectApproxEqAbs(@as(f64, 18.0), outcome.value.number, 1e-9); + try testing.expect(outcome.value.effective != null); + try testing.expectApproxEqAbs(@as(f64, 19.5618), outcome.value.effective.?, 1e-4); + + // At annual compounding the two are the same figure, so only one is shown. + var annual = stateWith(.compound, &.{ "1000", "2000", "", "10", "1" }); + try testing.expect(annual.outcome().value.effective == null); +} + +test "compound form: solving for the rate agrees with the CAGR form" { + var compound = stateWith(.compound, &.{ "10000", "25000", "", "5", "1" }); + var growth = stateWith(.cagr, &.{ "10000", "25000", "5" }); + // CAGR reports a percentage too, so these must be the same number. + try testing.expectApproxEqAbs( + growth.outcome().value.number, + compound.outcome().value.number, + 1e-9, + ); +} + +test "compound form: the compounding frequency is prefilled with 1" { + var state: State = .{}; + state.setForm(.compound); + try testing.expectEqualStrings("1", state.fieldAt(compound_per_year_index).text()); + // So a fresh form only needs the four real variables, three of them filled. + state.fieldAt(0).set("1000"); + state.fieldAt(2).set("5"); + state.fieldAt(3).set("10"); + try testing.expectEqualStrings("FV", state.outcome().value.label); +} + +test "compound form: clearing the frequency asks for a value, not an answer" { + var state = stateWith(.compound, &.{ "1000", "", "5", "10" }); + state.fieldAt(compound_per_year_index).clear(); + const outcome = state.outcome(); + try testing.expect(outcome == .hint); + try testing.expect(std.mem.indexOf(u8, outcome.hint, "compounding frequency") != null); +} + +test "compound form: monthly compounding beats annual" { + var annual = stateWith(.compound, &.{ "1000", "", "5", "10", "1" }); + var monthly = stateWith(.compound, &.{ "1000", "", "5", "10", "12" }); + try testing.expect(monthly.outcome().value.number > annual.outcome().value.number); +} + +test "compound form: needs exactly one of the four variables blank" { + var none = stateWith(.compound, &.{ "1000", "2000", "5", "10" }); + const full = none.outcome(); + try testing.expect(full == .hint); + try testing.expect(std.mem.indexOf(u8, full.hint, "clear one") != null); + + var two = stateWith(.compound, &.{ "1000", "", "", "10" }); + const partial = two.outcome(); + try testing.expect(partial == .hint); + try testing.expect(std.mem.indexOf(u8, partial.hint, "all but one") != null); +} + +test "compound form: unsolvable inputs are errors, not wrong answers" { + // No rate turns 1000 into -500. + var sign_change = stateWith(.compound, &.{ "1000", "-500", "", "10" }); + try testing.expect(sign_change.outcome() == .err); + // A zero rate never reaches a different value. + var flat = stateWith(.compound, &.{ "1000", "2000", "0", "" }); + try testing.expect(flat.outcome() == .err); + // No time in which to earn a rate. + var instant = stateWith(.compound, &.{ "1000", "2000", "", "0" }); + try testing.expect(instant.outcome() == .err); +} + +test "tvm form: solves the blank variable" { + // The classic mortgage payment, with PMT left blank. + var state = stateWith(.tvm, &.{ "360", "0.5", "200000", "", "0" }); + const outcome = state.outcome(); + try testing.expectEqualStrings("PMT", outcome.value.label); + try testing.expectApproxEqAbs(@as(f64, -1199.10105), outcome.value.number, 1e-5); +} + +test "tvm form: every variable is solvable from the other four" { + // A self-consistent set: 1000 grows to 2000 over 10.244768 periods at 7%, + // so blanking any one field must recover exactly that field's value. + const cases = [_]struct { blank: usize, label: []const u8, expected: f64 }{ + .{ .blank = 0, .label = "N", .expected = 10.244768 }, + .{ .blank = 1, .label = "I/Y", .expected = 7 }, + .{ .blank = 2, .label = "PV", .expected = -1000 }, + .{ .blank = 3, .label = "PMT", .expected = 0 }, + .{ .blank = 4, .label = "FV", .expected = 2000 }, + }; + for (cases) |case| { + var state = stateWith(.tvm, &.{ "10.244768351058712", "7", "-1000", "0", "2000" }); + state.fieldAt(case.blank).clear(); + const outcome = state.outcome(); + try testing.expectEqualStrings(case.label, outcome.value.label); + try testing.expectApproxEqAbs(case.expected, outcome.value.number, 1e-4); + } +} + +test "tvm form: the rate solution reports its iteration count" { + var state = stateWith(.tvm, &.{ "10", "", "-1000", "0", "2000" }); + const outcome = state.outcome(); + try testing.expectEqualStrings("I/Y", outcome.value.label); + try testing.expectEqualStrings("%", outcome.value.suffix); + try testing.expect(outcome.value.iterations != null); + try testing.expect(outcome.value.iterations.? > 0); +} + +test "tvm form: zero or several blanks are hints, not errors" { + var full = stateWith(.tvm, &.{ "360", "0.5", "200000", "-1199.1", "0" }); + try testing.expect(full.outcome() == .hint); + var two_blank = stateWith(.tvm, &.{ "360", "0.5", "", "", "0" }); + try testing.expect(two_blank.outcome() == .hint); +} + +test "tvm form: the due toggle changes the answer" { + var ordinary = stateWith(.tvm, &.{ "360", "0.5", "200000", "", "0" }); + const end_payment = ordinary.outcome().value.number; + ordinary.due = true; + const begin_payment = ordinary.outcome().value.number; + // Paying at the start of each period costs less per payment. + try testing.expect(@abs(begin_payment) < @abs(end_payment)); +} + +test "tvm form: unsolvable cash flows surface as an error" { + // No rate makes these consistent. + var state = stateWith(.tvm, &.{ "10", "", "1000", "100", "5000" }); + try testing.expect(state.outcome() == .err); +} + +test "amortization form: payment and totals for a 30-year mortgage" { + var state = stateWith(.amortization, &.{ "200000", "0.5", "360" }); + const outcome = state.outcome(); + try testing.expectEqual(@as(f64, 1199.10), outcome.schedule.payment); + try testing.expectEqual(@as(usize, 360), outcome.schedule.totals.periods); + try testing.expect(outcome.schedule.totals.interest > 231000); + try testing.expect(outcome.schedule.totals.interest < 232000); +} + +test "amortization form: an explicit payment shortens the schedule" { + var state = stateWith(.amortization, &.{ "200000", "0.5", "360", "2000" }); + const outcome = state.outcome(); + try testing.expectEqual(@as(f64, 2000), outcome.schedule.payment); + try testing.expect(outcome.schedule.totals.periods < 360); +} + +test "amortization form: rejects a fractional or absurd period count" { + var fractional = stateWith(.amortization, &.{ "200000", "0.5", "360.5" }); + try testing.expect(fractional.outcome() == .hint); + var zero = stateWith(.amortization, &.{ "200000", "0.5", "0" }); + try testing.expect(zero.outcome() == .hint); + var absurd = stateWith(.amortization, &.{ "200000", "0.5", "99999" }); + try testing.expect(absurd.outcome() == .hint); +} + +test "amortization form: a payment that never amortizes is an error" { + var state = stateWith(.amortization, &.{ "200000", "0.5", "360", "500" }); + try testing.expect(state.outcome() == .err); +} + +test "scrollBy: clamps at both ends" { + var state: State = .{}; + state.scrollBy(10, 20, 100); + try testing.expectEqual(@as(usize, 10), state.scroll); + // Cannot scroll past the last full page. + state.scrollBy(1000, 20, 100); + try testing.expectEqual(@as(usize, 80), state.scroll); + state.scrollBy(-1000, 20, 100); + try testing.expectEqual(@as(usize, 0), state.scroll); +} + +test "scrollBy: nothing to scroll when everything fits" { + var state: State = .{}; + state.scrollBy(5, 50, 12); + try testing.expectEqual(@as(usize, 0), state.scroll); +} + +test "money: grouping and two decimals" { + var buf: [48]u8 = undefined; + try testing.expectEqualStrings("1,199.10", money(&buf, 1199.101)); + try testing.expectEqualStrings("231,677.04", money(&buf, 231677.04)); + try testing.expectEqualStrings("-1,199.10", money(&buf, -1199.10)); + try testing.expectEqualStrings("0.00", money(&buf, 0)); +} + +test "money: reports rather than truncating when the buffer is too small" { + var tiny: [3]u8 = undefined; + try testing.expectEqualStrings("?", money(&tiny, 1234567.89)); +} + +test "formatValueLine: with and without an iteration count" { + var buf: [160]u8 = undefined; + const plain = formatValueLine(&buf, .{ .label = "FV", .number = 1628.894627 }).?; + try testing.expectEqualStrings("= FV 1,628.89", plain); + + var buf2: [160]u8 = undefined; + const iterated = formatValueLine(&buf2, .{ + .label = "I/Y", + .number = 7.1773, + .suffix = "%", + .iterations = 6, + }).?; + try testing.expect(std.mem.indexOf(u8, iterated, "7.18%") != null); + try testing.expect(std.mem.indexOf(u8, iterated, "6 iterations") != null); +} + +test "substitutedFormula: shows the arithmetic for cagr and compound" { + var state = stateWith(.cagr, &.{ "10000", "25000", "5" }); + var buf: [160]u8 = undefined; + try testing.expectEqualStrings("= (25000 / 10000)^(1/5) - 1", substitutedFormula(&state, &buf).?); + + var compound = stateWith(.compound, &.{ "1000", "", "5", "10" }); + var buf2: [160]u8 = undefined; + const text = substitutedFormula(&compound, &buf2).?; + try testing.expect(std.mem.indexOf(u8, text, "1000 * (1 + 5/100/1)") != null); + + // Nothing sensible to substitute for these. + var tvm = stateWith(.tvm, &.{"360"}); + var buf3: [160]u8 = undefined; + try testing.expect(substitutedFormula(&tvm, &buf3) == null); +} + +test "every form has a label, a formula, and at most max_fields fields" { + for (std.enums.values(Form)) |form| { + try testing.expect(form.label().len > 0); + try testing.expect(form.formula().len > 0); + try testing.expect(form.fields().len > 0); + try testing.expect(form.fields().len <= max_fields); + } +} + +test "errorText: every financial error has its own wording" { + const errors = [_]engine.CalcError{ + engine.CalcError.DomainError, + engine.CalcError.ConvergenceFailure, + engine.CalcError.InsufficientParameters, + engine.CalcError.DivisionByZero, + engine.CalcError.Overflow, + engine.CalcError.UnknownUnit, + }; + for (errors) |err| try testing.expect(errorText(err).len > 0); + try testing.expect(!std.mem.eql( + u8, + errorText(engine.CalcError.DomainError), + errorText(engine.CalcError.ConvergenceFailure), + )); +} + +// -- Rendered frame -- +// +// The state tests above cover the arithmetic; these draw an actual frame into a +// surface and read the cells back, which is the only way to catch a layout that +// silently writes nothing, overlaps itself, or runs off the bottom of the +// terminal. + +/// Render one financial-mode frame and return it as a list of text rows. +/// Caller owns the returned rows. +fn renderFrame( + allocator: std.mem.Allocator, + arena: std.mem.Allocator, + state: State, + width: u16, + height: u16, + zone_active: bool, +) ![][]u8 { + // SAFETY: `io` is only touched by the event loop and command dispatch, and + // drawing reaches neither. + var app = tui.App.init(allocator, undefined); + defer app.deinit(); + app.mode = .financial; + app.fin = state; + app.value_zone_active = zone_active; + return test_render.frame(arena, &app, width, height); +} + +fn frameContains(rows: [][]u8, needle: []const u8) bool { + return test_render.contains(rows, needle); +} + +test "render: the cagr form shows its labels, result and formula" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const state = stateWith(.cagr, &.{ "10000", "25000", "5" }); + const rows = try renderFrame(testing.allocator, arena, state, 80, 24, false); + + try testing.expect(frameContains(rows, "Calculation:")); + try testing.expect(frameContains(rows, "CAGR")); + try testing.expect(frameContains(rows, "Start value")); + try testing.expect(frameContains(rows, "10000")); + try testing.expect(frameContains(rows, "= CAGR 20.11%")); + try testing.expect(frameContains(rows, "(end / start)")); + try testing.expect(frameContains(rows, "= (25000 / 10000)^(1/5) - 1")); +} + +test "render: an incomplete form shows a hint instead of a number" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const rows = try renderFrame(testing.allocator, arena, .{}, 80, 24, false); + try testing.expect(frameContains(rows, "enter a start value")); + try testing.expect(!frameContains(rows, "= CAGR")); +} + +test "render: the tvm form marks the blank variable as the one being solved" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const state = stateWith(.tvm, &.{ "360", "0.5", "200000", "", "0" }); + const rows = try renderFrame(testing.allocator, arena, state, 80, 24, false); + + try testing.expect(frameContains(rows, "N (periods)")); + try testing.expect(frameContains(rows, "[solve]")); + try testing.expect(frameContains(rows, "END (end of period)")); + try testing.expect(frameContains(rows, "= PMT -1,199.10")); +} + +test "render: the amortization schedule shows a header, rows and a position" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const state = stateWith(.amortization, &.{ "200000", "0.5", "360" }); + const rows = try renderFrame(testing.allocator, arena, state, 80, 24, false); + + try testing.expect(frameContains(rows, "Payment 1,199.10")); + try testing.expect(frameContains(rows, "total interest 231,677.04")); + try testing.expect(frameContains(rows, "Period Payment")); + // First period of this loan: exactly 1000 of interest. + try testing.expect(frameContains(rows, "1,199.10 1,000.00")); + try testing.expect(frameContains(rows, "199,800.90")); + // A 360-row schedule cannot fit in 24 rows, so it must say so. + try testing.expect(frameContains(rows, "of 360")); + try testing.expect(frameContains(rows, "PgUp/PgDn")); +} + +test "render: scrolling the schedule moves the visible rows" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var state = stateWith(.amortization, &.{ "200000", "0.5", "360" }); + const top = try renderFrame(testing.allocator, arena, state, 80, 24, false); + try testing.expect(frameContains(top, "199,800.90")); + + state.scroll = 300; + const scrolled = try renderFrame(testing.allocator, arena, state, 80, 24, false); + try testing.expect(!frameContains(scrolled, "199,800.90")); + try testing.expect(frameContains(scrolled, "rows 301-")); +} + +test "render: a stale scroll offset is clamped rather than showing an empty table" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // Offset far past the end, as if the period count had just been reduced. + var state = stateWith(.amortization, &.{ "1200", "0", "12" }); + state.scroll = 5000; + const rows = try renderFrame(testing.allocator, arena, state, 80, 24, false); + // 1200 over 12 interest-free periods is 100.00 a period, and rows are still + // drawn rather than the offset leaving an empty table. + try testing.expect(frameContains(rows, "100.00")); +} + +test "render: the focused field is highlighted only in the form zone" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var state = stateWith(.cagr, &.{ "10000", "25000", "5" }); + state.field = 1; + + const unfocused = try renderFrame(testing.allocator, arena, state, 80, 24, false); + try testing.expect(!frameContains(unfocused, "> End value")); + + const focused = try renderFrame(testing.allocator, arena, state, 80, 24, true); + try testing.expect(frameContains(focused, "> End value")); +} + +test "render: the status line reflects which zone has focus" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const input_zone = try renderFrame(testing.allocator, arena, .{}, 80, 24, false); + try testing.expect(frameContains(input_zone, "Expr + Enter fills the field")); + + const form_zone = try renderFrame(testing.allocator, arena, .{}, 80, 24, true); + try testing.expect(frameContains(form_zone, "Up/Dn:field")); +} + +test "render: a short terminal drops rows instead of writing out of bounds" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // Ten rows cannot hold the chips, six TVM fields, a result and an input line. + const state = stateWith(.tvm, &.{ "360", "0.5", "200000", "", "0" }); + const rows = try renderFrame(testing.allocator, arena, state, 80, 10, false); + try testing.expectEqual(@as(usize, 10), rows.len); + // The input line is anchored to the bottom, so it survives the squeeze. + try testing.expect(frameContains(rows, ">")); +} + +test "render: a narrow terminal wraps the calculation chips" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const rows = try renderFrame(testing.allocator, arena, .{}, 34, 24, false); + // All four chips still appear, on more than one row. + try testing.expect(frameContains(rows, "CAGR")); + try testing.expect(frameContains(rows, "Amortization")); +} + +test "render: every form draws without panicking, empty or filled" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + for (std.enums.values(Form)) |form| { + var empty: State = .{}; + empty.setForm(form); + _ = try renderFrame(testing.allocator, arena, empty, 80, 24, true); + + // Every field filled with the same plausible number, which is nonsense + // for some forms: the point is that no combination crashes or writes + // outside the surface. + var filled: State = .{}; + filled.setForm(form); + for (0..filled.fieldCount()) |i| filled.fieldAt(i).set("12"); + _ = try renderFrame(testing.allocator, arena, filled, 80, 24, true); + } +} + +test "render: typed digits are grouped in place" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var state = stateWith(.amortization, &.{"200"}); + const before = try renderFrame(testing.allocator, arena, state, 80, 24, true); + try testing.expect(frameContains(before, "200")); + try testing.expect(!frameContains(before, "2,000")); + + // Typing one more zero turns 200 into 2,000 on screen. + _ = state.typeChar('0'); + const after = try renderFrame(testing.allocator, arena, state, 80, 24, true); + try testing.expect(frameContains(after, "2,000")); + + state.fieldAt(0).set("200000"); + const large = try renderFrame(testing.allocator, arena, state, 80, 24, true); + try testing.expect(frameContains(large, "200,000")); +} + +test "render: rate fields carry a percent sign" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const state = stateWith(.amortization, &.{ "200000", "0.5", "360" }); + const rows = try renderFrame(testing.allocator, arena, state, 80, 24, false); + try testing.expect(frameContains(rows, "0.5 %")); + // The stored value has no percent sign in it. + try testing.expectEqualStrings("0.5", state.fieldAtConst(1).text()); + // A non-rate field gets no suffix. + try testing.expect(!frameContains(rows, "200,000 %")); +} + +test "render: an expression shows verbatim with an Enter prompt" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const state = stateWith(.amortization, &.{ "200000", "0.5", "12 * 30" }); + const rows = try renderFrame(testing.allocator, arena, state, 80, 24, true); + try testing.expect(frameContains(rows, "12 * 30")); + try testing.expect(frameContains(rows, "(Enter)")); + try testing.expect(frameContains(rows, "press Enter to evaluate")); + // No schedule while a field is unevaluated. + try testing.expect(!frameContains(rows, "Period Payment")); +} + +test "render: the status line advertises evaluation" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const rows = try renderFrame(testing.allocator, arena, .{}, 80, 24, true); + try testing.expect(frameContains(rows, "Enter:eval")); + // The whole hint has to fit an 80-column terminal, or the last binding is + // silently clipped. + try testing.expect(frameContains(rows, "`:input")); +} + +test "render: the compound form shows the prefilled frequency and four solve slots" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var state: State = .{}; + state.setForm(.compound); + const rows = try renderFrame(testing.allocator, arena, state, 80, 24, true); + + try testing.expect(frameContains(rows, "Present value")); + try testing.expect(frameContains(rows, "Annual rate")); + try testing.expect(frameContains(rows, "Compounds/year")); + // The default is a visible value, not an empty field. + try testing.expect(frameContains(rows, "[solve]")); + try testing.expect(frameContains(rows, "clear one field") or + frameContains(rows, "all but one")); +} + +test "render: a solved nominal rate shows its effective equivalent" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const fv = try engine.financial.compoundFutureValue(1000, 18, 5, 12); + var buf: [32]u8 = undefined; + const fv_text = try std.fmt.bufPrint(&buf, "{d}", .{fv}); + + const state = stateWith(.compound, &.{ "1000", fv_text, "", "5", "12" }); + const rows = try renderFrame(testing.allocator, arena, state, 80, 24, false); + try testing.expect(frameContains(rows, "18.00% nominal")); + try testing.expect(frameContains(rows, "19.56% effective")); +} + +test "render: solving for years reads as a duration" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const state = stateWith(.compound, &.{ "1000", "2000", "7", "" }); + const rows = try renderFrame(testing.allocator, arena, state, 80, 24, false); + try testing.expect(frameContains(rows, "= years 10.24")); +} + +test "render: an unsolvable form shows the error, not a number" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // No rate turns 1000 into -500. + const state = stateWith(.compound, &.{ "1000", "-500", "", "10" }); + const rows = try renderFrame(testing.allocator, arena, state, 80, 24, false); + try testing.expect(frameContains(rows, "check the entries")); + try testing.expect(!frameContains(rows, "= rate")); +} + +test "render: the payments toggle shows its state and highlights when focused" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var state = stateWith(.tvm, &.{ "360", "0.5", "200000", "", "0" }); + state.focusField(5); + const ordinary = try renderFrame(testing.allocator, arena, state, 80, 24, true); + try testing.expect(frameContains(ordinary, "END (end of period)")); + + state.due = true; + const due = try renderFrame(testing.allocator, arena, state, 80, 24, true); + try testing.expect(frameContains(due, "BGN (start of period)")); +} diff --git a/src/tui/help.zig b/src/tui/help.zig index 501106f..4f8a175 100644 --- a/src/tui/help.zig +++ b/src/tui/help.zig @@ -1,112 +1,208 @@ //! Help overlay for the TUI. +//! +//! The content is a static line list rather than a sequence of hand-gated draw +//! calls. That change came from a real failure: each section used to guard itself +//! with `if (row < height - 6)`, so as sections were added the later ones were +//! silently dropped on a normal-sized terminal, with no indication anything was +//! missing. A line list can be windowed and scrolled, and its length is knowable +//! without drawing. +const std = @import("std"); const vaxis = @import("vaxis"); const vxfw = vaxis.vxfw; const draw = @import("draw.zig"); const C = draw.C; -pub fn drawHelp(surface: *vxfw.Surface, width: u16, height: u16) void { +/// One rendered line of help. +const Line = union(enum) { + /// Section heading. + header: []const u8, + /// A binding: name and what it does. + key: struct { name: []const u8, desc: []const u8 }, + /// Free text, indented under a heading. + text: []const u8, + blank, +}; + +const lines = [_]Line{ + .{ .header = "Keybindings" }, + .{ .key = .{ .name = "Enter", .desc = "Evaluate expression" } }, + .{ .key = .{ .name = "Tab", .desc = "Next mode (Standard/Programmer/Financial/Convert)" } }, + .{ .key = .{ .name = "Shift-Tab", .desc = "Previous mode" } }, + .{ .key = .{ .name = "Ctrl-C/D", .desc = "Quit" } }, + .{ .key = .{ .name = "Ctrl-L", .desc = "Clear history" } }, + .{ .key = .{ .name = "Up/Down", .desc = "Browse history" } }, + .{ .key = .{ .name = "?", .desc = "Toggle this help" } }, + .blank, + + .{ .header = "Mouse" }, + .{ .key = .{ .name = "Tabs", .desc = "Click a tab to switch mode" } }, + .{ .key = .{ .name = "Bit grid", .desc = "Click a bit to flip it" } }, + .{ .key = .{ .name = "HEX/OCT", .desc = "Click a digit to put the cursor on it" } }, + .{ .key = .{ .name = "BIN", .desc = "Click a digit to flip that bit" } }, + .{ .key = .{ .name = "DEC rows", .desc = "Click to focus the field" } }, + .{ .key = .{ .name = "Bits/Signed", .desc = "Click the label to toggle it" } }, + .{ .key = .{ .name = "Convert", .desc = "Click a category or unit to select it" } }, + .{ .key = .{ .name = "Financial", .desc = "Click a calculation or a field" } }, + .{ .key = .{ .name = "Wheel", .desc = "Scroll the schedule, or this help" } }, + .{ .key = .{ .name = "Input line", .desc = "Click to return focus to the prompt" } }, + .blank, + + .{ .header = "Programmer Mode" }, + .{ .key = .{ .name = "`", .desc = "Toggle input / value zone" } }, + .{ .key = .{ .name = "Ctrl-W", .desc = "Cycle bit width (8/16/32/64/128)" } }, + .{ .key = .{ .name = "Ctrl-E", .desc = "Toggle endianness (BE / LE)" } }, + .{ .key = .{ .name = "Ctrl-F", .desc = "IEEE 754 float view (Ctrl-W: f32/f64)" } }, + .{ .key = .{ .name = "Space", .desc = "Toggle bit (in grid)" } }, + .{ .key = .{ .name = "Arrows", .desc = "Navigate fields and bits (value zone)" } }, + .blank, + + .{ .header = "Financial Mode" }, + .{ .key = .{ .name = "`", .desc = "Toggle input / form zone" } }, + .{ .key = .{ .name = "Up/Down", .desc = "Move between fields" } }, + .{ .key = .{ .name = "Left/Right", .desc = "Switch calculation" } }, + .{ .key = .{ .name = "type", .desc = "Numbers or expressions: 12 * 30" } }, + .{ .key = .{ .name = "Enter", .desc = "Evaluate the focused field in place" } }, + .{ .key = .{ .name = "Space", .desc = "Toggle END / BGN on the payments row" } }, + .{ .key = .{ .name = "Ctrl-U", .desc = "Clear the field (marks it to solve for)" } }, + .{ .key = .{ .name = "PgUp/PgDn", .desc = "Scroll the amortization schedule" } }, + .blank, + + .{ .header = "Convert Mode" }, + .{ .key = .{ .name = "`", .desc = "Toggle input / selection zone" } }, + .{ .key = .{ .name = "Arrows", .desc = "Left/Right: column, Up/Down: select" } }, + .{ .key = .{ .name = "Ctrl-S", .desc = "Swap from and to units" } }, + .{ .key = .{ .name = "Enter", .desc = "Set the value to convert" } }, + .blank, + + .{ .header = "Functions" }, + .{ .text = "sin cos tan asin acos atan log ln sqrt abs" }, + .{ .text = "ceil floor round factorial max min exp" }, + .blank, + + .{ .header = "Financial Functions" }, + .{ .text = "cagr(start, end, periods) growth rate as a fraction" }, + .{ .text = "fv(pv, rate%, years [, per year]) future value" }, + .{ .text = "pv(fv, rate%, years [, per year]) present value" }, + .{ .text = "compound_rate(pv, fv, years [, m]) nominal annual rate" }, + .{ .text = "compound_years(pv, fv, rate [, m]) time to get there" }, + .{ .text = "apy(nominal_rate, per_year) effective annual rate" }, + .{ .text = "tvm_pmt(n, rate%, pv, fv) solve a payment" }, + .{ .text = "tvm_fv / tvm_pv / tvm_n / tvm_rate solve the other variables" }, + .{ .text = "amort_payment(principal, rate%, n) level payment" }, + .{ .text = "amort_interest / _principal / _balance (.., n, period)" }, + .{ .text = "amort_total_interest / _total_paid (principal, rate%, n)" }, + .blank, + + .{ .header = "Operators" }, + .{ .text = "Standard: + - * / % ^ (power)" }, + .{ .text = "Programmer: & | ~ << >> >>> ^/** (pow) and or xor not rol ror" }, + .blank, + + .{ .header = "Units and Conversion" }, + .{ .text = "100 km to mi, 32F in C, 2*3 kg to lb - works in any expression" }, +}; + +/// Total help lines, so the caller can clamp its scroll offset without drawing. +pub fn lineCount() usize { + return lines.len; +} + +/// Lines visible at a given terminal height: everything between the title and the +/// footer. +pub fn visibleLines(height: u16) usize { + const first = content_start; + const last = height -| 2; + return if (last > first) last - first else 0; +} + +const content_start: u16 = 3; +const key_col: u16 = 4; +const desc_col: u16 = 18; + +/// Draw the overlay, starting at line `scroll`. Out-of-range offsets are clamped +/// here rather than trusted, so a resize cannot leave the view blank. +pub fn drawHelp(surface: *vxfw.Surface, width: u16, height: u16, scroll: usize) void { for (0..height) |r| { draw.fillRow(surface, @intCast(r), ' ', .{}); } - var row: u16 = 1; - draw.writeStr(surface, row, 2, "Tally - Help", .{ .fg = C.cyan, .bold = true }); - row += 2; + draw.writeStr(surface, 1, 2, "Tally - Help", .{ .fg = C.cyan, .bold = true }); - draw.writeStr(surface, row, 2, "Keybindings", .{ .fg = C.purple, .bold = true }); - row += 1; - const keys = [_][2][]const u8{ - .{ "Enter", "Evaluate expression" }, - .{ "Tab", "Cycle mode (Standard/Programmer/Convert)" }, - .{ "Ctrl-C/D", "Quit" }, - .{ "Ctrl-L", "Clear history" }, - .{ "Up/Down", "Browse history" }, - .{ "?", "Toggle this help" }, - }; - for (keys) |kv| { - draw.writeStr(surface, row, 4, kv[0], .{ .fg = C.yellow }); - draw.writeStr(surface, row, 18, kv[1], .{ .fg = C.fg }); - row += 1; - } - row += 1; + const visible = visibleLines(height); + if (visible == 0) return; + const max_scroll = if (lines.len > visible) lines.len - visible else 0; + const first = @min(scroll, max_scroll); + const last = @min(lines.len, first + visible); - draw.writeStr(surface, row, 2, "Mouse", .{ .fg = C.purple, .bold = true }); - row += 1; - const mouse_keys = [_][2][]const u8{ - .{ "Tabs", "Click a tab to switch mode" }, - .{ "Bit grid", "Click a bit to flip it" }, - .{ "HEX/OCT", "Click a digit to put the cursor on it" }, - .{ "BIN", "Click a digit to flip that bit" }, - .{ "DEC rows", "Click to focus the field" }, - .{ "Bits/Signed/Endian", "Click the label to toggle it" }, - .{ "Convert", "Click a category or unit to select it" }, - .{ "Input line", "Click to return focus to the prompt" }, - }; - for (mouse_keys) |kv| { - if (row >= height -| 4) break; - draw.writeStr(surface, row, 4, kv[0], .{ .fg = C.yellow }); - draw.writeStr(surface, row, 24, kv[1], .{ .fg = C.fg }); - row += 1; - } - row += 1; - - draw.writeStr(surface, row, 2, "Programmer Mode", .{ .fg = C.purple, .bold = true }); - row += 1; - const prog_keys = [_][2][]const u8{ - .{ "`", "Toggle input / value zone" }, - .{ "Ctrl-W", "Cycle bit width (8/16/32/64/128)" }, - .{ "Ctrl-E", "Toggle endianness (BE / LE)" }, - .{ "Ctrl-F", "IEEE 754 float view (Ctrl-W: f32/f64)" }, - .{ "Space", "Toggle bit (in grid)" }, - .{ "Arrows", "Navigate fields and bits (value zone)" }, - }; - for (prog_keys) |kv| { - if (row >= height -| 4) break; - draw.writeStr(surface, row, 4, kv[0], .{ .fg = C.yellow }); - draw.writeStr(surface, row, 18, kv[1], .{ .fg = C.fg }); - row += 1; - } - row += 1; - - if (row < height -| 6) { - draw.writeStr(surface, row, 2, "Convert Mode", .{ .fg = C.purple, .bold = true }); - row += 1; - const conv_keys = [_][2][]const u8{ - .{ "`", "Toggle input / selection zone" }, - .{ "Arrows", "Left/Right: column, Up/Down: select" }, - .{ "Ctrl-S", "Swap from and to units" }, - .{ "Enter", "Set the value to convert" }, - }; - for (conv_keys) |kv| { - if (row >= height -| 4) break; - draw.writeStr(surface, row, 4, kv[0], .{ .fg = C.yellow }); - draw.writeStr(surface, row, 18, kv[1], .{ .fg = C.fg }); - row += 1; + var row: u16 = content_start; + for (lines[first..last]) |line| { + switch (line) { + .header => |text| draw.writeStr(surface, row, 2, text, .{ .fg = C.purple, .bold = true }), + .key => |kv| { + draw.writeStr(surface, row, key_col, kv.name, .{ .fg = C.yellow }); + draw.writeStr(surface, row, desc_col, kv.desc, .{ .fg = C.fg }); + }, + .text => |text| draw.writeStr(surface, row, key_col, text, .{ .fg = C.fg }), + .blank => {}, } row += 1; } - if (row < height -| 6) { - draw.writeStr(surface, row, 2, "Functions", .{ .fg = C.purple, .bold = true }); - row += 1; - draw.writeStr(surface, row, 4, "sin cos tan asin acos atan log ln sqrt abs", .{ .fg = C.fg }); - row += 1; - draw.writeStr(surface, row, 4, "ceil floor round factorial max min exp", .{ .fg = C.fg }); - row += 1; - } - - if (row < height -| 4) { - row += 1; - draw.writeStr(surface, row, 2, "Operators", .{ .fg = C.purple, .bold = true }); - row += 1; - draw.writeStr(surface, row, 4, "Standard: + - * / % ^ (power)", .{ .fg = C.fg }); - row += 1; - draw.writeStr(surface, row, 4, "Programmer: & | ~ << >> >>> ^/** (pow) and or xor not rol ror", .{ .fg = C.fg }); - row += 1; - } - + // Footer: the position indicator only appears when something is off screen, + // so a full view is not cluttered with it. draw.fillRow(surface, height -| 1, ' ', .{ .fg = C.muted }); - draw.writeStr(surface, height -| 1, 1, "Press any key to return", .{ .fg = C.muted }); + if (lines.len > visible) { + var buf: [96]u8 = undefined; + const status = std.fmt.bufPrint(&buf, "lines {d}-{d} of {d} | Up/Down or wheel: scroll | any other key: return", .{ + first + 1, last, lines.len, + }) catch "Up/Down: scroll | any other key: return"; + draw.writeStr(surface, height -| 1, 1, status, .{ .fg = C.muted }); + } else { + draw.writeStr(surface, height -| 1, 1, "Press any key to return", .{ .fg = C.muted }); + } _ = width; } + +// -- Tests -- + +const testing = std.testing; + +test "every help line has content" { + for (lines) |line| { + switch (line) { + .header => |text| try testing.expect(text.len > 0), + .key => |kv| { + try testing.expect(kv.name.len > 0); + try testing.expect(kv.desc.len > 0); + // Descriptions start at a fixed column, so a long name would + // overwrite its own description. + try testing.expect(kv.name.len < desc_col - key_col); + }, + .text => |text| try testing.expect(text.len > 0), + .blank => {}, + } + } +} + +test "help lines fit an 80-column terminal" { + for (lines) |line| { + const used: usize = switch (line) { + .header => |text| 2 + text.len, + .key => |kv| desc_col + kv.desc.len, + .text => |text| key_col + text.len, + .blank => 0, + }; + try testing.expect(used <= 80); + } +} + +test "visibleLines shrinks with the terminal and never underflows" { + try testing.expect(visibleLines(40) > visibleLines(24)); + try testing.expectEqual(@as(usize, 0), visibleLines(4)); + try testing.expectEqual(@as(usize, 0), visibleLines(0)); +} + +test "the help content is longer than a standard terminal, which is why it scrolls" { + try testing.expect(lineCount() > visibleLines(24)); +} diff --git a/src/tui/test_render.zig b/src/tui/test_render.zig new file mode 100644 index 0000000..4aa00a7 --- /dev/null +++ b/src/tui/test_render.zig @@ -0,0 +1,76 @@ +//! Test-only frame rendering for the TUI. +//! +//! Draws a real frame into a real surface and reads the cells back as text, which +//! is the only way to test a terminal view without a terminal. It catches the +//! failures that matter in drawing code: a layout that writes nothing, one that +//! overlaps itself, and one that runs off the bottom or the right edge. +//! +//! Nothing here is used outside tests. It lives in its own file so both the view +//! modules and tui.zig can share one harness rather than each growing a copy. + +const std = @import("std"); +const vaxis = @import("vaxis"); +const vxfw = vaxis.vxfw; +const tui = @import("../tui.zig"); + +/// Draw one full frame of `app` (title bar, tabs, and whichever mode is active) +/// and return it as `height` rows of `width` characters. Rows are allocated from +/// `arena`. +pub fn frame(arena: std.mem.Allocator, app: *tui.App, width: u16, height: u16) ![][]u8 { + const widget = app.widget(); + const ctx: vxfw.DrawContext = .{ + .arena = arena, + .min = .{ .width = 0, .height = 0 }, + .max = .{ .width = width, .height = height }, + .cell_size = .{ .width = 8, .height = 16 }, + }; + const surface = try widget.drawFn(widget.userdata, ctx); + return flatten(arena, surface, width, height); +} + +/// Read a surface back as text. Multi-byte graphemes become a single space: the +/// TUI is ASCII by design, so anything wider is a bug this does not need to model. +pub fn flatten( + arena: std.mem.Allocator, + surface: vxfw.Surface, + width: u16, + height: u16, +) ![][]u8 { + const rows = try arena.alloc([]u8, height); + for (0..height) |r| { + const line = try arena.alloc(u8, width); + for (0..width) |c| { + const index = r * @as(usize, width) + c; + const grapheme = if (index < surface.buffer.len) surface.buffer[index].char.grapheme else " "; + line[c] = if (grapheme.len == 1) grapheme[0] else ' '; + } + rows[r] = line; + } + return rows; +} + +/// True when any row contains `needle`. +pub fn contains(rows: [][]u8, needle: []const u8) bool { + for (rows) |row| { + if (std.mem.indexOf(u8, row, needle) != null) return true; + } + return false; +} + +/// The row index containing `needle`, or null. +pub fn rowOf(rows: [][]u8, needle: []const u8) ?usize { + for (rows, 0..) |row, i| { + if (std.mem.indexOf(u8, row, needle) != null) return i; + } + return null; +} + +/// True when every row is exactly `width` wide and nothing was written past it. +/// A view that miscomputes a column would otherwise fail silently, since the +/// drawing helpers clip rather than error. +pub fn wellFormed(rows: [][]u8, width: u16) bool { + for (rows) |row| { + if (row.len != width) return false; + } + return true; +}