diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6426ddb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + typecheck: + name: Typecheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + # `nuxi typecheck` prepares Nuxt types itself, then runs vue-tsc. + # Fails the job on any type error (non-zero exit). + - name: Typecheck + run: npx nuxi typecheck diff --git a/.kiro/specs/fix-typecheck-errors/design.md b/.kiro/specs/fix-typecheck-errors/design.md new file mode 100644 index 0000000..9f77133 --- /dev/null +++ b/.kiro/specs/fix-typecheck-errors/design.md @@ -0,0 +1,202 @@ +# Design — Fix pre-existing typecheck errors + +> Traceability: implements **GitHub Issue #10** ("chore: fix pre-existing +> typecheck errors"). Relates to #4. + +## Problem + +`npx nuxi typecheck` is **not clean** on `main`. Re-baselined on +`chore/fix-typecheck-errors` (branched from `main` at `e590105`, i.e. *after* +PR #12 merged) it reports **78 errors** — identical count and breakdown to the +issue, because the test files PR #12 added already follow the `!`-on-index +convention and contribute zero. + +Current breakdown (re-measured, not copied from the issue): + +| Count | Code(s) | File | +|------:|---------|------| +| 38 | TS2532 / TS18048 | `utils/toRegisterRows.test.ts` | +| 13 | TS2532 | `utils/toTransactionInput.test.ts` | +| 12 | TS2532 | `utils/toTransactionInput.property.test.ts` | +| 7 | TS18048 | `utils/roundTrip.property.test.ts` | +| 6 | TS18048 | `utils/toRegisterRows.property.test.ts` | +| 1 | TS2532 | `server/api/__tests__/migration.test.ts` | +| 1 | **TS2322** | `pages/accounts/[...path].vue` | + +By error code: **40× TS2532**, **37× TS18048** (both `noUncheckedIndexedAccess`), +**1× TS2322** (a real type mismatch). + +CI does not currently gate on typecheck, so these have accumulated silently. + +## Two distinct problems + +This is **not** one homogeneous fix. There are two root causes: + +### A. 77 test-file errors — `noUncheckedIndexedAccess` ergonomics (benign) + +With `noUncheckedIndexedAccess`, any array index or destructure yields +`T | undefined`. The tests index results they *know* are populated (they +construct the input) but never tell the compiler. Three shapes appear: + +1. **Array index** — `rows[0].runningBalance` → `rows[0]` is `Row | undefined`. +2. **Destructure** — `const [row] = toRegisterRows(...)` → `row` is + `Row | undefined`. +3. **Mock-call tuple** — `mockAppendTransaction.mock.calls[0][0]` → both + `[0]` indexes are possibly-undefined (`migration.test.ts:113`). + +These are genuinely safe accesses in test context, so the fix is to assert +non-null at the indexing site. This is **the same pattern the codebase already +adopted** for newly written tests (per the issue), so we are converging on an +existing convention, not inventing one. + +### B. 1 page-component error — a **real latent runtime bug** (TS2322) + +`pages/accounts/[...path].vue:106` — `@delete="deleteTx"`: + +``` +Type '(row: { transactionIndex: number; }) => Promise' + is not assignable to type '(index: number) => any'. +``` + +`AccountRegister` declares `defineEmits<{ edit: [index: number]; delete: +[index: number] }>()` and emits **a plain number** +(`emit('delete', row.original.transactionIndex)`). But the page handler is +typed/written to receive an **object**: + +```ts +async function deleteTx(row: { transactionIndex: number }) { + deleting.value = row.transactionIndex // number.transactionIndex → undefined + await $fetch('/api/transactions', { + method: 'DELETE', + query: { index: row.transactionIndex }, // index: undefined ← bug + }) +} +``` + +At runtime the handler receives a number, so `row.transactionIndex` is +`undefined` and the delete request sends `index: undefined`. **The typecheck +error is surfacing a genuine defect**, exactly as the issue anticipated +("may be a real type gap, not just test ergonomics"). This one is fixed by +correcting the handler, not by silencing the index. + +## Proposed solution + +### A. Test files — non-null assertion at the indexing site + +Prefer `!` exactly where the issue recommends ("`!` on indexed access where the +index is provably in range"): + +```ts +// array index +expect(rows[0]!.runningBalance).toBe(-5) + +// destructure +const [row] = toRegisterRows(txs, 'assets:checking') +expect(row!.inflow).toBeNull() + +// mock-call tuple +const txInput = mockAppendTransaction.mock.calls[0]![0] +``` + +Rationale for `!` over the alternatives: + +- **`!` (chosen)** — minimal diff, matches the convention already in newer + tests, keeps each assertion on one line, reads as "this is provably present." +- **Guard (`if (!row) throw`)** — heavier; only worth it where a destructure is + reused across many lines. We will use a single hoisted `const row = rows[i]!` + where a row is dereferenced repeatedly in one test, to avoid `!`-noise on + every line (judgment call per block, favoring readability). +- **Rewriting helpers to return non-optional types** — out of scope and wrong: + the helpers are correctly typed; the gap is purely at test call sites. + +No production `utils/` source changes — only the `.test.ts` / `.property.test.ts` +call sites. + +### B. Page component — fix the handler signature and body + +Align `deleteTx` with the emitted `number`, fixing the latent bug: + +```ts +async function deleteTx(transactionIndex: number) { + if (!confirm('Delete this transaction? This cannot be undone.')) return + deleting.value = transactionIndex + try { + await $fetch('/api/transactions', { + method: 'DELETE', + query: { index: transactionIndex }, + }) + ... +``` + +While here, tighten `editTx(_row: any)` → `editTx(_index: number)` to match the +`edit: [index: number]` emit and remove the `any` (coding-standards: no `any`). +This is in-scope cleanup of the same emit contract, not scope creep. + +This change is behavior-affecting (it repairs delete). **Regression guard +(decided 2026-06-14): the typecheck gate itself.** The suite has no +component-mount harness and `deleteTx` is not exported, so a runtime test would +require new devDeps (`@nuxt/test-utils` + happy-dom) and brittle Nuxt UI +stubbing — disproportionate for a one-line client fix. Instead, the now +CI-gated `nuxi typecheck` (R4) structurally forbids the regression: reverting +`deleteTx` to a non-`number` param reintroduces the exact `TS2322` and fails CI. +See R3.4. + +### CI gate (final step) + +Once typecheck is clean, the issue asks to "make `npx nuxi typecheck` a required +gate (CI) so this can't regress." There is currently **no CI workflow in the +repo at all** (tests aren't gated either). + +**Decision (confirmed): add a typecheck-only CI workflow here.** Create +`.github/workflows/ci.yml` that, on push/PR to `main`, runs `npm ci` then +`npx nuxi typecheck`. Deliberately **no test job** in this ticket: the test +suite has hledger-on-PATH-gated tests whose CI wiring overlaps with #11, so a +test job is left to a follow-up. A typecheck-only job is self-contained (no +hledger needed) and directly fulfills the issue's "can't regress" requirement. + +Notes for the workflow: +- This will be the repo's first GitHub Actions file — keep it minimal and + conventional (checkout → setup-node with npm cache → `npm ci` → typecheck). +- `nuxi typecheck` needs the Nuxt types prepared; it runs `nuxi prepare` + implicitly, but the workflow should not assume a committed `.nuxt/`. +- Pin `actions/checkout` and `actions/setup-node` to major version tags. + +## Files touched + +| File | Change | Errors fixed | +|------|--------|-------------:| +| `utils/toRegisterRows.test.ts` | `!` on indexed/destructured rows | 38 | +| `utils/toTransactionInput.test.ts` | `!` on indexed access | 13 | +| `utils/toTransactionInput.property.test.ts` | `!` on indexed access | 12 | +| `utils/roundTrip.property.test.ts` | `!` on destructured rows | 7 | +| `utils/toRegisterRows.property.test.ts` | `!` on indexed/destructured rows | 6 | +| `server/api/__tests__/migration.test.ts` | `!` on mock-call tuple | 1 | +| `pages/accounts/[...path].vue` | fix `deleteTx`/`editTx` signatures (bug fix) | 1 | +| `.github/workflows/ci.yml` *(new)* | typecheck-only CI gate (npm ci → nuxi typecheck) | — | + +No config files touched (`tsconfig*`, `nuxt.config`, `vitest.config` are +off-limits per coding-standards — the goal is a genuinely clean typecheck, not a +silenced one). + +## Edge cases / risks + +- **Behavior-preserving for tests:** `!` is a compile-time assertion only — zero + runtime change; the test suite must still pass identically afterward. +- **The page fix changes runtime behavior** (delete now sends a real index). + Must be verified by a test and ideally a manual delete in the running app. +- **Regression visibility:** without a CI gate, a clean typecheck can silently + re-break. Mitigated by the (deferred) CI recommendation; at minimum the final + task re-asserts a clean run. +- **`any` in tests** is permitted by coding-standards (mocking), so existing + `e: any` / mock `any` usage in these files is left as-is unless it causes an + error. + +## Alternatives considered + +- **Relax `noUncheckedIndexedAccess` or exclude tests from typecheck** — + rejected outright; coding-standards forbid fixing via tooling config, and the + issue explicitly calls this out. +- **Wrap every test access in `if (!x) throw`** — rejected as default; too noisy + for the 77 provably-safe sites. Used sparingly only where a value is reused. +- **Silence the page error with `as any` on the handler** — rejected; it would + hide the real delete bug instead of fixing it. diff --git a/.kiro/specs/fix-typecheck-errors/requirements.md b/.kiro/specs/fix-typecheck-errors/requirements.md new file mode 100644 index 0000000..b946afc --- /dev/null +++ b/.kiro/specs/fix-typecheck-errors/requirements.md @@ -0,0 +1,86 @@ +# Requirements — Fix pre-existing typecheck errors + +> Traceability: **GitHub Issue #10**. Builds on the approved +> [design.md](./design.md). + +## User stories & acceptance criteria + +### R1 — Clean typecheck +**As a** contributor, **I want** `npx nuxi typecheck` to pass with zero errors, +**so that** type regressions are visible and the check can gate CI. + +- **R1.1** — WHEN a developer runs `npx nuxi typecheck` on this branch, THE + SYSTEM SHALL exit `0` with **no `error TS` lines** (currently 78). +- **R1.2** — THE fix SHALL NOT modify any tooling/compiler config + (`tsconfig*.json`, `nuxt.config.ts`, `vitest.config.*`, etc.); in particular + `noUncheckedIndexedAccess` SHALL remain enabled and no test files SHALL be + excluded from typecheck. + +### R2 — Test call-site fixes (the 77 `noUncheckedIndexedAccess` errors) +**As a** contributor, **I want** the test indexing errors resolved without +weakening the tests, **so that** the suite keeps asserting the same behavior. + +- **R2.1** — WHEN an array index or destructured element that is provably + in range is dereferenced in a test, THE SYSTEM SHALL use a non-null assertion + (`!`) at the indexing site (e.g. `rows[0]!.x`, `const [row] = …; row!.x`, + `mock.calls[0]![0]`). +- **R2.2** — WHERE a single value is dereferenced repeatedly within one test + block, THE fix MAY hoist it to one `const x = arr[i]!` rather than repeating + `!` on every line, when that reads more clearly. +- **R2.3** — THE fix SHALL NOT change any production `utils/` source; only the + `.test.ts` / `.property.test.ts` call sites and `migration.test.ts`. +- **R2.4** — Each affected test SHALL retain its original assertions (no test + deleted, skipped, or weakened to dodge the error). + +### R3 — Page component delete bug (the 1 `TS2322` error) +**As a** user, **I want** deleting a transaction from the account register to +send the correct index, **so that** the right transaction is deleted. + +- **R3.1** — THE `deleteTx` handler in `pages/accounts/[...path].vue` SHALL + accept a `number` (matching `AccountRegister`'s `delete: [index: number]` + emit) and use it directly as the delete index. +- **R3.2** — WHEN a user confirms deletion of a transaction, THE SYSTEM SHALL + issue `DELETE /api/transactions` with `query.index` set to the emitted + numeric transaction index (never `undefined`). +- **R3.3** — THE `editTx` handler SHALL be typed `(_index: number)` to match the + `edit` emit, removing the `any` parameter. +- **R3.4** — The handler/emit contract SHALL be protected from regression by the + **typecheck gate** (R4): if `deleteTx` ever reverts to a non-`number` param it + reintroduces the same `TS2322`, failing CI. _Decision (2026-06-14):_ a runtime + test is NOT added — the suite has no component-mount harness, `deleteTx` is not + exported, and adding one (`@nuxt/test-utils` + happy-dom + Nuxt UI stubbing) + is disproportionate for a client-only fix the type gate already guards. See + design "CI gate" / Edge cases. + +### R4 — CI gate +**As a** maintainer, **I want** typecheck enforced in CI, **so that** a clean +typecheck cannot silently regress. + +- **R4.1** — THE repo SHALL contain `.github/workflows/ci.yml` that, on push and + pull_request targeting `main`, runs `npm ci` then `npx nuxi typecheck`. +- **R4.2** — THE workflow SHALL fail the job when typecheck reports any error + (i.e. rely on the non-zero exit code; no error-swallowing). +- **R4.3** — THE workflow SHALL pin `actions/checkout` and `actions/setup-node` + to major-version tags and use npm dependency caching. +- **R4.4** — THE workflow SHALL NOT include a unit-test job (deferred to a + follow-up that handles hledger-on-PATH; see #11). + +## Non-functional requirements + +- **NFR1 — Behavior preservation:** `npm test` SHALL pass after the change with + the same set of tests as before (the page fix may add one test; no test is + removed). +- **NFR2 — No `any` introduced:** the change SHALL NOT add `any`/`as any`; it + SHALL remove the one `any` in `editTx` (R3.3). Pre-existing permitted test + `any` (mocking, `catch (e: any)`) MAY remain. +- **NFR3 — Minimal diff:** fixes SHALL be confined to the files listed in the + design's "Files touched" table. + +## Out of scope + +- Adding a **unit-test job** to CI (needs hledger on PATH — see #11). +- The broader test-suite audit (skipped/gated tests, legacy paths) — that is + **#11**. +- Any refactor of `toRegisterRows` / `toTransactionInput` production types or + the `AccountRegister` component beyond the emit-contract alignment. +- Implementing transaction **edit** (still a "not yet supported" toast). diff --git a/.kiro/specs/fix-typecheck-errors/tasks.md b/.kiro/specs/fix-typecheck-errors/tasks.md new file mode 100644 index 0000000..922bf2a --- /dev/null +++ b/.kiro/specs/fix-typecheck-errors/tasks.md @@ -0,0 +1,71 @@ +# Tasks — Fix pre-existing typecheck errors + +> Traceability: **GitHub Issue #10**. Implements [design.md](./design.md), +> satisfies [requirements.md](./requirements.md). +> Verify each task with the single-file typecheck where possible: +> `npx vue-tsc --noEmit -p . 2>&1 | grep ` is unreliable under Nuxt, so +> use `npx nuxi typecheck 2>&1 | grep ` to confirm a file is clean, and a +> full `npx nuxi typecheck` only at the end. + +## Test call-site fixes (Group R2) + +- [x] **T1 — `utils/toRegisterRows.test.ts`** (38 errors). Add `!` at each + flagged index/destructure site (lines ~50–384); hoist `const row = rows[i]!` + where a row is reused across several asserts. _Verify:_ `npx nuxi typecheck + 2>&1 | grep toRegisterRows.test` → no output; `npx vitest run + utils/toRegisterRows.test.ts` passes. _Covers:_ R2.1–R2.4. + +- [x] **T2 — `utils/toTransactionInput.test.ts`** (13 errors, lines ~21–130). + `!` on indexed access. _Verify:_ typecheck grep clean + `npx vitest run + utils/toTransactionInput.test.ts`. _Covers:_ R2.1, R2.4. + +- [x] **T3 — `utils/toTransactionInput.property.test.ts`** (12 errors, lines + ~93–159). `!` on indexed access inside fast-check properties. _Verify:_ + typecheck grep clean + `npx vitest run utils/toTransactionInput.property.test.ts` + (allow extra time — property tests are slow). _Covers:_ R2.1, R2.4. + +- [x] **T4 — `utils/roundTrip.property.test.ts`** (7 errors, lines ~108–122). + `!` on destructured `row`. _Verify:_ typecheck grep clean + `npx vitest run + utils/roundTrip.property.test.ts`. _Covers:_ R2.1, R2.4. + +- [x] **T5 — `utils/toRegisterRows.property.test.ts`** (6 errors, lines + ~177–190). `!` on indexed/destructured row. _Verify:_ typecheck grep clean + + `npx vitest run utils/toRegisterRows.property.test.ts`. _Covers:_ R2.1, R2.4. + +- [x] **T6 — `server/api/__tests__/migration.test.ts`** (1 error, line 113). + `mockAppendTransaction.mock.calls[0]![0]`. _Verify:_ typecheck grep clean + + `npx vitest run server/api/__tests__/migration.test.ts`. _Covers:_ R2.1, R2.4. + +## Page component bug fix (Group R3) + +- [ ] **T7 — Fix `deleteTx`/`editTx` in `pages/accounts/[...path].vue`.** + Change `deleteTx(row: { transactionIndex: number })` → + `deleteTx(transactionIndex: number)`, updating both `deleting.value` and the + `query.index` to use `transactionIndex` directly. Change `editTx(_row: any)` → + `editTx(_index: number)`. _Verify:_ `npx nuxi typecheck 2>&1 | grep + '\[...path\]'` → no output (clears the lone TS2322). _Covers:_ R3.1–R3.3. + +- [x] **T8 — Regression guard for the delete path.** _Decision (2026-06-14):_ + no runtime test added — the suite has no component-mount harness and + `deleteTx` is not exported, so a test would require new devDeps + brittle Nuxt + UI stubbing. The fix is instead guarded by the **CI typecheck gate** (T9): + reverting `deleteTx` to a non-`number` param reintroduces the same `TS2322` + and fails CI. design.md + R3.4 updated to reflect this. _Covers:_ R3.4, NFR1. + +## CI gate (Group R4) + +- [x] **T9 — Add `.github/workflows/ci.yml`.** On `push` and `pull_request` to + `main`: checkout (`actions/checkout@v4`), `actions/setup-node@v4` (Node 20, + `cache: npm`), `npm ci`, then `npx nuxi typecheck`. No test job. _Verify:_ + `npx js-yaml .github/workflows/ci.yml` (or equivalent) parses; review the run + on the eventual PR. _Covers:_ R4.1–R4.4. + +## Final checkpoint + +- [x] **T10 — Full verification.** + 1. `npx nuxi typecheck` → exits 0, **zero** `error TS` lines (R1.1). + 2. `npm test` → full suite green (NFR1). + 3. `git diff --stat` confined to the design's "Files touched" table (NFR3); + confirm no config files changed (R1.2) and no new `any` (NFR2). + Then update `AI-MAP.md` if any quirk note is warranted (e.g. the delete-index + bug fix), and report results. diff --git a/AI-MAP.md b/AI-MAP.md index ecec6a9..26ed71b 100644 --- a/AI-MAP.md +++ b/AI-MAP.md @@ -16,7 +16,9 @@ living document — update it when the project's shape changes (see - `npm run dev` — dev server at http://localhost:3000 (needs hledger on PATH) - `npm run test` — `vitest run` - `npm run build` / `npm run preview` -- `npx nuxi typecheck` — root `tsconfig.json` extends `./.nuxt/tsconfig.json` +- `npx nuxi typecheck` — root `tsconfig.json` extends `./.nuxt/tsconfig.json`. + **Clean (0 errors) and CI-gated** via `.github/workflows/ci.yml` (typecheck-only + job on push/PR to `main`; no test job yet — Issue #10). ## Data flow `Pages/Components → composables → server/api → server/utils → hledger CLI / .journal` @@ -106,6 +108,7 @@ RegisterRow, BudgetCategory/Group, BudgetEnvelopeReport, RealAccount, AccountTre unbalanced from float drift. Amount transforms prefer `decimalMantissa`/ `decimalPlaces` over lossy `floatingPoint`. - **Writable journal = single flat file** — `include` is rejected on delete/upload. +- **`AccountRegister` emits `edit`/`delete` with a numeric index** (`row.original.transactionIndex`) — page handlers must accept `number`, not an object. (A prior `deleteTx({ transactionIndex })` mismatch silently sent `index: undefined`; fixed + guarded by the typecheck gate — Issue #10.) - **UTree leaf nodes** need `children: undefined` (not `[]`) to be selectable. - **UCard slots:** `header` / default / `footer` — there is no `#body`. - **tsconfig** must extend `./.nuxt/tsconfig.json` for typecheck. diff --git a/pages/accounts/[...path].vue b/pages/accounts/[...path].vue index 46bbcae..d64f95e 100644 --- a/pages/accounts/[...path].vue +++ b/pages/accounts/[...path].vue @@ -31,17 +31,17 @@ const loading = computed(() => regStatus.value === 'pending') const showModal = ref(false) const deleting = ref(null) -function editTx(_row: any) { +function editTx(_index: number) { toast.add({ title: 'Edit not yet supported in simplified mode', color: 'warning' }) } -async function deleteTx(row: { transactionIndex: number }) { +async function deleteTx(transactionIndex: number) { if (!confirm('Delete this transaction? This cannot be undone.')) return - deleting.value = row.transactionIndex + deleting.value = transactionIndex try { await $fetch('/api/transactions', { method: 'DELETE', - query: { index: row.transactionIndex }, + query: { index: transactionIndex }, }) toast.add({ title: 'Transaction deleted', color: 'success' }) await refreshAll() diff --git a/server/api/__tests__/migration.test.ts b/server/api/__tests__/migration.test.ts index ce19927..e68213f 100644 --- a/server/api/__tests__/migration.test.ts +++ b/server/api/__tests__/migration.test.ts @@ -110,7 +110,7 @@ describe('Migration: first assignment creates budget sub-accounts', () => { // The assignment transaction creates budget sub-accounts by writing postings to them // hledger creates accounts on first use — no separate account creation needed expect(mockAppendTransaction).toHaveBeenCalledTimes(1) - const txInput = mockAppendTransaction.mock.calls[0][0] + const txInput = mockAppendTransaction.mock.calls[0]![0] // Verify budget sub-account postings are present expect(txInput.postings).toEqual( diff --git a/utils/roundTrip.property.test.ts b/utils/roundTrip.property.test.ts index b8593ff..a5cf632 100644 --- a/utils/roundTrip.property.test.ts +++ b/utils/roundTrip.property.test.ts @@ -102,7 +102,7 @@ describe('Round-trip conversion — Property Tests', () => { const rows = toRegisterRows([hledgerTx], input.account) expect(rows).toHaveLength(1) - const row = rows[0] + const row = rows[0]! // Verify transfer detection expect(row.isTransfer).toBe(input.type === 'transfer') diff --git a/utils/toRegisterRows.property.test.ts b/utils/toRegisterRows.property.test.ts index 2c288c7..04b88bd 100644 --- a/utils/toRegisterRows.property.test.ts +++ b/utils/toRegisterRows.property.test.ts @@ -173,7 +173,7 @@ describe('toRegisterRows — Property Tests', () => { const rows = toRegisterRows(txs, testAccount) for (let i = 0; i < rows.length; i++) { - const row = rows[i] + const row = rows[i]! const otherAccount = row.categoryRaw const isRealAccount = otherAccount.startsWith('assets:') || otherAccount.startsWith('liabilities:') diff --git a/utils/toRegisterRows.test.ts b/utils/toRegisterRows.test.ts index 9061d75..b0d8325 100644 --- a/utils/toRegisterRows.test.ts +++ b/utils/toRegisterRows.test.ts @@ -46,7 +46,7 @@ describe('toRegisterRows', () => { }), ] - const [row] = toRegisterRows(txs, 'assets:checking') + const row = toRegisterRows(txs, 'assets:checking')[0]! expect(row.inflow).toBeNull() expect(row.outflow).toBe(5) }) @@ -62,7 +62,7 @@ describe('toRegisterRows', () => { }), ] - const [row] = toRegisterRows(txs, 'assets:checking') + const row = toRegisterRows(txs, 'assets:checking')[0]! expect(row.inflow).toBe(2000) expect(row.outflow).toBeNull() }) @@ -93,9 +93,9 @@ describe('toRegisterRows', () => { ] const rows = toRegisterRows(txs, 'assets:checking') - expect(rows[0].runningBalance).toBe(-5) - expect(rows[1].runningBalance).toBe(1995) - expect(rows[2].runningBalance).toBe(1945) + expect(rows[0]!.runningBalance).toBe(-5) + expect(rows[1]!.runningBalance).toBe(1995) + expect(rows[2]!.runningBalance).toBe(1945) }) // Issue #4 item 4: a date-filtered register seeds the running balance with the @@ -147,7 +147,7 @@ describe('toRegisterRows', () => { }), ] - const [row] = toRegisterRows(txs, 'assets:checking') + const row = toRegisterRows(txs, 'assets:checking')[0]! expect(row.isTransfer).toBe(true) expect(row.category).toBe('') expect(row.payee).toBe('Transfer: Savings') @@ -164,7 +164,7 @@ describe('toRegisterRows', () => { }), ] - const [row] = toRegisterRows(txs, 'assets:checking') + const row = toRegisterRows(txs, 'assets:checking')[0]! expect(row.isTransfer).toBe(true) expect(row.payee).toBe('Transfer: Credit-card') }) @@ -180,7 +180,7 @@ describe('toRegisterRows', () => { }), ] - const [row] = toRegisterRows(txs, 'assets:checking') + const row = toRegisterRows(txs, 'assets:checking')[0]! expect(row.isTransfer).toBe(false) expect(row.category).toBe('Dining') expect(row.categoryRaw).toBe('expenses:dining') @@ -199,7 +199,7 @@ describe('toRegisterRows', () => { }), ] - const [row] = toRegisterRows(txs, 'assets:checking') + const row = toRegisterRows(txs, 'assets:checking')[0]! expect(row.category).toBe('Split') expect(row.categoryRaw).toBe('') expect(row.isTransfer).toBe(false) @@ -234,7 +234,7 @@ describe('toRegisterRows', () => { const rows = toRegisterRows(txs, 'assets:checking') expect(rows).toHaveLength(1) - expect(rows[0].inflow).toBe(100) + expect(rows[0]!.inflow).toBe(100) }) it('preserves transaction metadata (date, index, status)', () => { @@ -250,7 +250,7 @@ describe('toRegisterRows', () => { }), ] - const [row] = toRegisterRows(txs, 'assets:checking') + const row = toRegisterRows(txs, 'assets:checking')[0]! expect(row.date).toBe('2025-03-20') expect(row.transactionIndex).toBe(42) expect(row.status).toBe('!') @@ -276,11 +276,11 @@ describe('toRegisterRows', () => { const rows = toRegisterRows(txs, 'assets:checking') expect(rows).toHaveLength(1) - expect(rows[0].outflow).toBe(1200) - expect(rows[0].inflow).toBeNull() - expect(rows[0].runningBalance).toBe(-1200) - expect(rows[0].categoryRaw).toBe('expenses:housing:rent') - expect(rows[0].isTransfer).toBe(false) + expect(rows[0]!.outflow).toBe(1200) + expect(rows[0]!.inflow).toBeNull() + expect(rows[0]!.runningBalance).toBe(-1200) + expect(rows[0]!.categoryRaw).toBe('expenses:housing:rent') + expect(rows[0]!.isTransfer).toBe(false) }) it('omits a budget assignment (checking → envelope nets to zero) from the real-account register', () => { @@ -344,8 +344,8 @@ describe('toRegisterRows', () => { const rows = toRegisterRows(txs, 'assets:checking') // Assignment (tx 2) is internal → dropped. Salary +2000, Rent -1200. expect(rows).toHaveLength(2) - expect(rows[0].runningBalance).toBe(2000) - expect(rows[1].runningBalance).toBe(800) + expect(rows[0]!.runningBalance).toBe(2000) + expect(rows[1]!.runningBalance).toBe(800) }) it('shows envelope-level activity when viewing a budget sub-account directly (R1.5)', () => { @@ -362,9 +362,9 @@ describe('toRegisterRows', () => { const rows = toRegisterRows(txs, 'assets:checking:budget:food') expect(rows).toHaveLength(2) - expect(rows[0].inflow).toBe(400) // funded from checking - expect(rows[1].outflow).toBe(60) // spent on groceries - expect(rows[1].runningBalance).toBe(340) + expect(rows[0]!.inflow).toBe(400) // funded from checking + expect(rows[1]!.outflow).toBe(60) // spent on groceries + expect(rows[1]!.runningBalance).toBe(340) }) it('flags a multi-commodity family posting instead of silently dropping a commodity (R6.3)', () => { @@ -378,7 +378,7 @@ describe('toRegisterRows', () => { }), ] - const [row] = toRegisterRows(txs, 'assets:checking') + const row = toRegisterRows(txs, 'assets:checking')[0]! expect(row.category).toBe('Multiple currencies') expect(row.inflow).toBeNull() expect(row.outflow).toBeNull() diff --git a/utils/toTransactionInput.property.test.ts b/utils/toTransactionInput.property.test.ts index 12da972..a50d816 100644 --- a/utils/toTransactionInput.property.test.ts +++ b/utils/toTransactionInput.property.test.ts @@ -90,7 +90,7 @@ describe('toTransactionInput — Property Tests', () => { expect(result.postings).toHaveLength(2) - const sum = result.postings[0].amount! + result.postings[1].amount! + const sum = result.postings[0]!.amount! + result.postings[1]!.amount! expect(Math.abs(sum)).toBeLessThan(1e-10) }), ) @@ -109,9 +109,9 @@ describe('toTransactionInput — Property Tests', () => { fc.property(arbExpenseInput, (input) => { const result = toTransactionInput(input) - expect(result.postings[0].account).toBe(input.category) - expect(result.postings[0].account.startsWith('expenses:')).toBe(true) - expect(result.postings[1].account).toBe(input.account) + expect(result.postings[0]!.account).toBe(input.category) + expect(result.postings[0]!.account.startsWith('expenses:')).toBe(true) + expect(result.postings[1]!.account).toBe(input.account) }), ) }) @@ -121,9 +121,9 @@ describe('toTransactionInput — Property Tests', () => { fc.property(arbIncomeInput, (input) => { const result = toTransactionInput(input) - expect(result.postings[0].account).toBe(input.account) - expect(result.postings[1].account).toBe(input.category) - expect(result.postings[1].account.startsWith('income:')).toBe(true) + expect(result.postings[0]!.account).toBe(input.account) + expect(result.postings[1]!.account).toBe(input.category) + expect(result.postings[1]!.account.startsWith('income:')).toBe(true) }), ) }) @@ -133,8 +133,8 @@ describe('toTransactionInput — Property Tests', () => { fc.property(arbTransferInput, (input) => { const result = toTransactionInput(input) - expect(result.postings[0].account).toBe(input.transferAccount) - expect(result.postings[1].account).toBe(input.account) + expect(result.postings[0]!.account).toBe(input.transferAccount) + expect(result.postings[1]!.account).toBe(input.account) }), ) }) @@ -156,7 +156,7 @@ describe('toTransactionInput — Property Tests', () => { const result = toTransactionInput(input) // Always balanced. - expect(result.postings[0].amount! + result.postings[1].amount!).toBe(0) + expect(result.postings[0]!.amount! + result.postings[1]!.amount!).toBe(0) // The current account's signed amount matches the direction. const accountPosting = result.postings.find(p => p.account === input.account)! diff --git a/utils/toTransactionInput.test.ts b/utils/toTransactionInput.test.ts index c164fab..5a9bcfa 100644 --- a/utils/toTransactionInput.test.ts +++ b/utils/toTransactionInput.test.ts @@ -18,7 +18,7 @@ describe('toTransactionInput', () => { expect(result.postings).toHaveLength(2) expect(result.postings[0]).toEqual({ account: 'expenses:dining', amount: 5, commodity: '$' }) expect(result.postings[1]).toEqual({ account: 'assets:checking', amount: -5, commodity: '$' }) - expect(result.postings[0].amount! + result.postings[1].amount!).toBe(0) + expect(result.postings[0]!.amount! + result.postings[1]!.amount!).toBe(0) expect(result.description).toBe('Coffee Shop') expect(result.date).toBe('2025-01-15') expect(result.status).toBe('*') @@ -39,7 +39,7 @@ describe('toTransactionInput', () => { expect(result.postings).toHaveLength(2) expect(result.postings[0]).toEqual({ account: 'assets:checking', amount: 2000, commodity: '$' }) expect(result.postings[1]).toEqual({ account: 'income:salary', amount: -2000, commodity: '$' }) - expect(result.postings[0].amount! + result.postings[1].amount!).toBe(0) + expect(result.postings[0]!.amount! + result.postings[1]!.amount!).toBe(0) expect(result.description).toBe('Employer') expect(result.date).toBe('2025-01-16') }) @@ -59,7 +59,7 @@ describe('toTransactionInput', () => { expect(result.postings).toHaveLength(2) expect(result.postings[0]).toEqual({ account: 'assets:savings', amount: 500, commodity: '$' }) expect(result.postings[1]).toEqual({ account: 'assets:checking', amount: -500, commodity: '$' }) - expect(result.postings[0].amount! + result.postings[1].amount!).toBe(0) + expect(result.postings[0]!.amount! + result.postings[1]!.amount!).toBe(0) }) it('converts an outgoing transfer (direction "out") — money leaves the account', () => { @@ -76,7 +76,7 @@ describe('toTransactionInput', () => { const result = toTransactionInput(input) expect(result.postings[0]).toEqual({ account: 'assets:savings', amount: 500, commodity: '$' }) expect(result.postings[1]).toEqual({ account: 'assets:checking', amount: -500, commodity: '$' }) - expect(result.postings[0].amount! + result.postings[1].amount!).toBe(0) + expect(result.postings[0]!.amount! + result.postings[1]!.amount!).toBe(0) }) it('converts an incoming transfer (direction "in") — money enters the account', () => { @@ -93,7 +93,7 @@ describe('toTransactionInput', () => { const result = toTransactionInput(input) expect(result.postings[0]).toEqual({ account: 'assets:checking', amount: 500, commodity: '$' }) expect(result.postings[1]).toEqual({ account: 'assets:savings', amount: -500, commodity: '$' }) - expect(result.postings[0].amount! + result.postings[1].amount!).toBe(0) + expect(result.postings[0]!.amount! + result.postings[1]!.amount!).toBe(0) }) it('uses provided commodity and status', () => { @@ -110,8 +110,8 @@ describe('toTransactionInput', () => { const result = toTransactionInput(input) - expect(result.postings[0].commodity).toBe('€') - expect(result.postings[1].commodity).toBe('€') + expect(result.postings[0]!.commodity).toBe('€') + expect(result.postings[1]!.commodity).toBe('€') expect(result.status).toBe('!') }) @@ -127,7 +127,7 @@ describe('toTransactionInput', () => { const result = toTransactionInput(input) - expect(result.postings[0].commodity).toBe('$') + expect(result.postings[0]!.commodity).toBe('$') expect(result.status).toBe('*') }) })