letter-punk

Testing

Letter Punk uses Node’s built-in test runner (node:test + node:assert/strict). No test framework dependency is installed — this keeps the project’s zero-bundler, minimal-tooling posture intact.

Running the suite

npm test

This runs node --test, which auto-discovers every file under test/.

Layout

Why these two modules first

gameLogic.js and dictionaryValidator.js are both written as pure, dependency-injected factories (createGameEngine(options), createDictionaryValidator(options)) with no hard-coded DOM, fetch, or window access baked into their construction. Every test builds a small in-memory harness: a fixed test board, a mocked validateWord/fetchImpl/ptrieFactory, and plain callback arrays that record onStateChange/onMessage/onWordResult events for assertions. No network calls, no browser globals, no real dictionary files are touched.

One exception: dictionaryValidator.js’s API-fallback path reads window.location.href directly (a hard dependency on running in a browser). The one test exercising that path stubs a minimal globalThis.window for its duration and tears it down in t.after() — this is a test-environment workaround, not a source change.

Current coverage

gameLogic.js (createGameEngine):

dictionaryValidator.js (createDictionaryValidator):

buildLogic.js (partial — see “Not covered yet”):

shareLink.js (encodeShareHash/decodeShareHash):

shareText.js (formatMaskedShareText):

shareText.js (formatUnmaskedShareText, see docs/development.md):

The multi-word backspace path is worth calling out: after accepting two words, deleting back through the second word’s letters resets an internal starterLocked flag, so continued deletes fully empty the builder while the first word’s required starting letter is still active — typing the wrong letter at that point correctly triggers “This word must start with X.” That interaction isn’t obvious from reading appendToken or removeLastToken in isolation; test/gameLogic.test.js traces it step by step so a future refactor can’t silently break it.

puzzleFetcher.js (createPuzzleFetcher): the one client-side module here with real date-dependent branching, made testable without mocking Date by having fixtures compute their own “today”/”yesterday”/”tomorrow” ids the same way the module does (isoDate(offsetDays) in the test file mirrors getTodayPuzzleId’s formatting), so the suite stays correct on whatever real day it runs.

historyManager.js (getPlayerStats/recordFinishedGame): the module reads/writes the bare global localStorage rather than an injected store, so the test file installs a minimal in-memory shim (globalThis.localStorage) in a beforeEach — real persistence within a test, not the silent no-op the module’s own try/catch would otherwise produce under plain Node.

analyticsClient.js (trackPuzzleLoad/trackWordSubmit/trackGameSolved): the module calls the bare global fetch, so the test file stubs globalThis.fetch in a beforeEach to capture calls instead of letting the real fetch throw on the relative /api/event URL (which it would, outside a browser — the module’s own try/catch already absorbs that in production, but stubbing is also just the right way to verify what would have been sent).

Not covered yet

Adding a new test

Follow the harness pattern already in test/gameLogic.test.js and test/dictionaryValidator.test.js: inject mocks for anything that would otherwise touch the network, the DOM, or window, and assert against the returned snapshot/result objects and recorded callback events rather than internal state.