Skip to content

Commit c9b57a5

Browse files
committed
Add unit + jsdom integration test suite
- package.json with jsdom devDep; npm test / npm run build scripts - test/helpers: loadApp (script-tag injection), setupJsdom (preseeds wallet, stubs fetch/Chart/canvas/scrollIntoView, returns teardown for clock interval) - Guarded module.exports footers on 02-utils, 04b-lot-engine, 05-compute so pure modules are require()-able from Node; no-op in browser - 6 unit tests (lot-engine assigned-PUT/CALLED/CLOSED, compute netCost, fmt precision, existing merge) and 2 jsdom integration tests (addTrade, quickOutcome) - Separate test CI job in .github/workflows/test.yml - Docs updated: CLAUDE.md (Tests section, line counts, rule 6), CONTRIBUTING.md, README.md
1 parent 2e05eb1 commit c9b57a5

19 files changed

Lines changed: 1186 additions & 8 deletions

.github/workflows/test.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
name: test
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
8+
jobs:
9+
test:
10+
runs-on: ubuntu-latest
11+
steps:
12+
- uses: actions/checkout@v4
13+
- uses: actions/setup-node@v4
14+
with:
15+
node-version: '20'
16+
- run: npm ci
17+
- run: npm test

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
.DS_Store
2+
node_modules/
23
public/
34
.copilot-loop/
45
memory/

CLAUDE.md

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,11 @@ globals, and 17-boot.js runs an IIFE last to bootstrap the app.
4040
|------|------:|-----------------------|
4141
| `01-state.js` | 18 | `HW_WALLET_KEY`, `HW_HOLDINGS_KEY`, `HW_SYNCED_KEY`, `trades[]`, `sAsset/sType/sFilter/sPlatform/sSizeUnit/sPpnlTab/sCpnlPeriod`, `sHistOutcome/sHistFrom/sHistTo`, `livePrices{}`, `MIN_SIZE`, `ASSET_COLORS`, `mergeAsset` |
4242
| `01a-outcomes.js` | 32 | `OUTCOMES` registry: per-outcome `{title, badgeClass, platforms}`. Single source of truth for outcome display data + picker membership. Lot-lifecycle effects live in the Lot Engine, not here |
43-
| `02-utils.js` | 29 | `today()`, `save()` (also kicks `scheduleCloudPush`), `fmt()` (max 2dp), `sk()` (K-abbrev), `loadWallet()`, `saveWallet()`, `toast(msg, kind?)` (`'ok'`/`'err'`/`'info'`) |
43+
| `02-utils.js` | 33 | `today()`, `save()` (also kicks `scheduleCloudPush`), `fmt()` (max 2dp), `sk()` (K-abbrev), `loadWallet()`, `saveWallet()`, `toast(msg, kind?)` (`'ok'`/`'err'`/`'info'`). Dual-exports `today/fmt/sk` for Node tests |
4444
| `03-form-controls.js` | 212 | `setAsset/setType/setPlatform/setSizeUnit/setOut/setFilter/setPpnlTab`, `refreshLotPicker`, `autoFillFromLot`, `autoDTE`, history filters: `setHistOutcome/setHistFrom/setHistTo/clearHistFilters` |
4545
| `04-trade-crud.js` | 39 | `addTrade()` (HOLDING-only — adds spot from drawer), `clearForm`, `deleteTrade`, `quickOutcome` (fires toasts) |
46-
| `04b-lot-engine.js` | 136 | `lotNetCost(costBasis, lotPremiums, size)` and `lotEngine(assetTrades)``{lots, portfolioPnl, portfolioPremiums, putOnlyPnl, tradeAccounting}`. **Single source of truth** for wheel invariants (see Lot model below). Pure; dual-exported for Node. **Key invariant:** assigned-PUT premium IS credited to the new lot's `lotPremiums` |
47-
| `05-compute.js` | 85 | `compute(assetFilter)``{streams, lots, allRows, displayRows}`. Cross-asset orchestrator: per-asset grouping, calls `lotEngine`, applies asset filter, sorts, assigns idx, derives display fields (`returnPct`, `monthly`, `annual`, `lotPnl`) |
46+
| `04b-lot-engine.js` | 140 | `lotNetCost(costBasis, lotPremiums, size)` and `lotEngine(assetTrades)``{lots, portfolioPnl, portfolioPremiums, putOnlyPnl, tradeAccounting}`. **Single source of truth** for wheel invariants (see Lot model below). Pure; dual-exported for Node. **Key invariant:** assigned-PUT premium IS credited to the new lot's `lotPremiums` |
47+
| `05-compute.js` | 89 | `compute(assetFilter)``{streams, lots, allRows, displayRows}`. Cross-asset orchestrator: per-asset grouping, calls `lotEngine`, applies asset filter, sorts, assigns idx, derives display fields (`returnPct`, `monthly`, `annual`, `lotPnl`). Dual-exports `compute` for Node tests (reads `trades`/`lotEngine` from globals — set them before `require`) |
4848
| `05a-merge-open-lots.js` | 113 | `mergeOpenLots(trades, asset)``trades'`. Pure helper that merges all open lots for one asset (size-weighted `costBasis`, summed `lotPremiums`, earliest opener kept, CALL `lotNum` cleared). Prefers `lotEngine`, falls back to `compute` or a HOLDING/ASSIGNED heuristic for Node tests |
4949
| `06-render-table.js` | 452 | `sortOpen/sortHist`, `renderExpiryTable` (today badge + mobile cards), `fetchExpiryPrices` (CoinGecko, calls full `render()` on success), `rTable` (holdings cards, open & history tables, history filter application), `rStats` (just delegates to `renderExpiryTable`), `exportHistoryCSV` (downloads filtered history as CSV) |
5050
| `07-render-charts.js` | 640 | `setCpnlPeriod` (1M/3M/ALL), `rCpnlChart` (cumulative premium hero + npnl sparkline), `rCharts` (Premium P&L total/monthly tabs), `cOpts` (Chart.js options factory) |
@@ -74,6 +74,34 @@ as starting anchors, not exact addresses. Re-grep if a function moved.
7474
4. **Don't wrap the script in `DOMContentLoaded`.** Inline `onclick=` handlers
7575
need globals, and `17-boot.js` runs at parse time.
7676
5. **Don't use `maximumFractionDigits: 0` in `fmt()`** — it would round strikes.
77+
6. **Run `npm test` after touching anything covered by tests** (lot engine,
78+
compute, merge, fmt, addTrade/quickOutcome flows). CI gates on it.
79+
80+
---
81+
82+
## Tests
83+
84+
- **Install once:** `npm install` (only devDep is `jsdom`; runtime stays zero-dep).
85+
- **Run:** `npm test``node --test test/unit/*.test.js test/integration/*.test.js`.
86+
- **Layout:**
87+
- `test/unit/*.test.js` — pure-logic (lot-engine, compute, merge, fmt). Each
88+
test `require()`s the source module directly via the dual-export footer.
89+
- `test/integration/*.test.js` — jsdom; boots the full app via `setupJsdom()`,
90+
drives globals like `addTrade()` / `quickOutcome()`, asserts on
91+
`localStorage` and DOM.
92+
- `test/helpers/loadApp.js` — concatenates `src/js/*.js` and injects as a
93+
`<script>` element so classic-script semantics put `function` decls on `window`.
94+
- `test/helpers/setupJsdom.js` — preseeds wallet, stubs `fetch` (never resolves,
95+
so post-test render() callbacks don't fire after teardown), `Chart`, canvas
96+
`getContext`, `scrollIntoView`. Returns `{ window, teardown }`; jsdom tests
97+
must call `t.after(teardown)` to release the clock interval.
98+
- **Dual-export pattern** (used by `02-utils.js`, `04b-lot-engine.js`,
99+
`05-compute.js`, `05a-merge-open-lots.js`): a guarded footer
100+
`if (typeof module !== 'undefined' && module.exports) module.exports = {...}`.
101+
No-op in the browser; `require()`-able from Node tests. `build.py` does no
102+
stripping — the footer ships into `hyperwheel.html` and is harmless.
103+
- **CI:** `.github/workflows/test.yml` runs `npm ci && npm test` separately from
104+
the build job.
77105

78106
---
79107

CONTRIBUTING.md

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,35 @@ merged.
3434

3535
## Testing
3636

37-
There is no automated test suite yet. Manually verify:
37+
The project ships zero runtime dependencies, but tests use `jsdom` as a
38+
dev-only dependency.
39+
40+
```bash
41+
npm install # one-time, installs jsdom
42+
npm test # runs unit + jsdom integration tests
43+
```
44+
45+
Tests live under `test/`:
46+
47+
- `test/unit/*.test.js` — pure-logic tests (lot engine, compute, merge, fmt)
48+
- `test/integration/*.test.js` — jsdom tests that boot the full app
49+
- `test/helpers/{loadApp,setupJsdom}.js` — shared harness
50+
51+
CI runs `npm test` on every push and PR via `.github/workflows/test.yml`.
52+
53+
Modules that need to be `require()`-able from Node (e.g. `04b-lot-engine.js`)
54+
use a guarded dual-export footer:
55+
56+
```js
57+
if (typeof module !== 'undefined' && module.exports) {
58+
module.exports = { lotEngine };
59+
}
60+
```
61+
62+
The guard is a no-op in the browser (`module` is undefined there), so the
63+
single-file build is unaffected.
64+
65+
In addition, manually verify:
3866

3967
1. The page loads with no console errors.
4068
2. Trade entry, edit, delete, and merge flows still work.

README.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,13 +69,18 @@ python3 build.py --check
6969
That assembles `hyperwheel.html` and `public/index.html` and runs a Node
7070
syntax check on the assembled script. **Never edit the built files directly.**
7171

72-
Pure modules (Lot Engine, `mergeOpenLots`, `lotNetCost`) have a Node-only
73-
test harness — no npm, no bundler:
72+
The runtime ships zero dependencies, but the test suite uses `jsdom` as a
73+
dev-only dependency. Unit tests cover the pure modules (lot engine, compute,
74+
merge, fmt); integration tests boot the full app inside jsdom and drive
75+
`addTrade()` / `quickOutcome()` flows.
7476

7577
```bash
76-
node --test test/
78+
npm install # one-time, installs jsdom
79+
npm test
7780
```
7881

82+
CI runs both `python3 build.py --check` and `npm test` on every push and PR.
83+
7984
See [`CLAUDE.md`](./CLAUDE.md) for the full source map, file-by-file function
8085
index, lot model, and architectural notes. See [`CONTEXT.md`](./CONTEXT.md)
8186
for the wheel-strategy domain glossary.

hyperwheel.html

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1020,6 +1020,10 @@ <h3>Delete all trades?</h3>
10201020
}, 2400);
10211021
}
10221022

1023+
if (typeof module !== 'undefined' && module.exports) {
1024+
module.exports = { today, fmt, sk };
1025+
}
1026+
10231027
// ── FORM CONTROLS
10241028
function setAsset(a) {
10251029
sAsset = a;
@@ -1410,6 +1414,10 @@ <h3>Delete all trades?</h3>
14101414
return { lots, portfolioPnl, portfolioPremiums, putOnlyPnl, tradeAccounting };
14111415
}
14121416

1417+
if (typeof module !== 'undefined' && module.exports) {
1418+
module.exports = { lotEngine, lotNetCost };
1419+
}
1420+
14131421
// ── COMPUTE ──────────────────────────────────────────────────
14141422
// Orchestrator: per-asset, runs the lot engine then enriches each
14151423
// trade with display fields (return%, monthly/annual, lotPnl, net
@@ -1496,6 +1504,10 @@ <h3>Delete all trades?</h3>
14961504
return { streams, lots, allRows, displayRows };
14971505
}
14981506

1507+
if (typeof module !== 'undefined' && module.exports) {
1508+
module.exports = { compute };
1509+
}
1510+
14991511
// ── MERGE OPEN LOTS (pure helper) ─────────────────────────────
15001512
// Exports mergeOpenLots(trades, asset) -> newTrades
15011513

0 commit comments

Comments
 (0)