Files
tiny-pattern-ts/development/testing.md
T
tmu 05fad0fcd6 📝 Record the autocomplete and matcher-design decisions
The matcher is now two three-overload factories; library.md documents why
the union merge, inferred universe, conditional RequireKeys and cases-first
paths were rejected, and lists the two open issues (the fallback sees all of
T; a redundant _ is still accepted). testing.md records the language server
as the autocomplete oracle, and CONTRIBUTING points the exception at the
matcher's own test file.

The backlog marks the design-doc and autocomplete groundwork done alongside
the adoption.
2026-09-17 20:59:28 +00:00

7.6 KiB

Testing

For this library the types are the feature, so a runtime-only test loop would verify the wrong thing. The commands are in CONTRIBUTING.md; this file records why the loop is shaped the way it is.

Type-driven development

The rules — the loop and the pairing rule — are in CONTRIBUTING.md § Testing discipline (type-driven). What follows is why and what was rejected.

Decision (2026-09)

The compile-time expectation is written before the runtime assertion, and both before the implementation.

Why

  • A runtime-only test can pass while the type is wrong, so a type-level library would ship a broken feature its tests bless.
  • The type error is a more precise spec than a failing assertion, because it states the exact expected type before the logic exists.

Rejected

  • Runtime-first (classic red/green): it verifies the value, not the contract, and the contract is the product.
  • Testing the type only: it would not catch handler dispatch or the _ fallback (see src/primitive.test.ts).

The runner is node --test --strip-types over src/**/*.test.ts and scripts/**/*.test.ts; the tiers are in CONTRIBUTING.md § Development commands. c8 uses V8 coverage, so the --strip-types source is instrumented without a build step, and the runner relies on the .ts import-extension convention (see tooling.md).

AAA ordering

The rule is in CONTRIBUTING.md § Testing discipline (type-driven).

Decision (2026-09)

Test bodies read arrange → act → assert: inputs (the factory) set up first, the subject exercised once from them, all checks last — types then runtime. The blocks are labeled with // Arrange / // Act / // Assert comments and separated by a blank line; an empty block drops its label.

Why

  • Interleaved setup/checks hide what runs vs. what is observed; the eye re-reads the block to find the seams.
  • A factory built mid-test invites a second throwaway call of the subject; arranging it once makes the positive construction and the negative Parameters<…> check share one source of truth.
  • Labels make the seams explicit, not inferred — grep-able and reviewable without reading the statements.

Rejected

  • Unlabeled ordering (bare blank lines): the seams still have to be found by reading; the labels cost nothing.

Test helpers

The rule is enforced by import/no-relative-parent-imports; this section records why the mechanism is shaped like this.

Decision (2026-09)

A shared test helper — code that scattered *.test.ts files import to do their testing — lives under src/util/__tests__/ and is addressed by the #test-utils/… self-reference (package.json#imports: "#test-utils/*": "./src/util/__tests__/*"), never by a relative path:

import { LspSession } from "#test-utils/lsp-completion.ts";

Why

  • The lint rule bans upward (../) imports, and a cross-cutting helper can always be placed above some scattered consumer, wherever it goes. Name beats path: a #test-utils/… specifier has no direction, so the rule never fires and file moves only touch the one mapping in package.json.
  • #… is Node's reserved prefix for private subpath imports: publishing package.json leaks nothing and resolves nothing for consumers.
  • __tests__ as the folder name is not about tests living there; it is the directory pattern tsconfig.build.json already excludes, so a helper can never be emitted into dist/ and shipped by accident.
  • Node's own resolver handles #… under --strip-types, and tsc resolves it via the same imports field — one mechanism for runtime and type gate, no loader needed.
  • The helper uses node: builtins (it drives a language server), so import/no-nodejs-modules is off for src/util/__tests__/** in .oxlintrc.json — the same exception the old scripts/** scope carried.
  • Helpers carry no library coupling: their tests probe in-memory documents whose contextual types are written inline, so only the helper's own contract (marker handling, position, label extraction) is under test. A test that asserts a matcher's popup belongs with the matcher.

Rejected

  • A helper beside the tests (src/util/test.ts, flat src/): composes only while src/ stays flat; the first nested test reaching it reintroduces the banned upward import.
  • Bare ~/… specifier: not valid in imports (keys must start with #) — resolution fails at runtime with ERR_MODULE_NOT_FOUND. A #~/… “home” shorthand was dropped in review; #test-utils/… states what it is.
  • tsconfig paths alias: resolves for tsc but not for plain node --test --strip-types (no loader hook), breaking the fast tier.
  • Turning import/no-relative-parent-imports off for test files: the rule still guards non-test helpers importing each other, and the exemption is only needed for the one specifier the mapping already solves cleanly.

Autocomplete

Decision (2026-09)

Completion is verified by driving the repo's own language server (tsc --lsp --stdio, the same server pi's LSP extension talks to) through the test helper #test-utils/lsp-completion.ts (src/util/__tests__/lsp-completion.ts), not through the type system:

node --strip-types src/util/__tests__/lsp-completion.ts <file> [<marker>]

The script prints the labels the server offers at a /*COMPLETE*/ marker inside <file> (the marker is stripped before the document is sent). Its LspSession is imported by src/util/__tests__/lsp-completion.test.ts — which tests the helper itself against inline documents, never the library's code — and by src/primitive.test.ts, where the same probe asserts the matcher's popup; the CLI is for manual inspection.

Why

  • Completion is a contextual-type property: it depends on which overload signature TypeScript picks for the object literal, and no type-level assertion observes that.
  • Parameters<typeof factory>[0] resolves only the last overload, so it is not the popup's contextual type either — see library.md § Matcher shape.
  • The server is the only ground truth; the script reproduces what the editor shows.

Rejected

  • expect-type would not work: there is no operator for “the popup offers these labels”. toExtend / toEqualTypeOf test assignability and cannot say which overload supplied the contextual type.
  • Checking by hand in the editor: not reproducible in review or by an agent.
  • @ts-expect-error at a completion position: it asserts the absence of a compile error, not the presence of specific labels.

Known issue

  • Each test spawns its own tsc server so the tests share no state and pass in any order; the file is an integration test (~1.6 s) that needs node_modules. didOpen is handled in order before the completion request, so no settle delay is needed.
  • The server answers some requests with a string id (client/registerCapability); the client must tolerate string | number ids or the server stalls.

Known issues

  • The type-aware linter misidentifies expectTypeOf() as a floating promise, so test files that use it (src/primitive.test.ts) carry a file-level oxlint-disable typescript/no-floating-promises with an explanatory comment. It is a known false positive, not a rule worth disabling project-wide (see tooling.md § oxlint-disable directives live next to the code).