From d0dd8641f2a7f86e5c561a6e13d1fb4ba673558e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Thu, 24 Sep 2026 12:13:36 +0000 Subject: [PATCH] :white_check_mark: Assert README example results Assign every documented matcher result to a variable and assert it with `assert.equal`, so the compiled README doc-tests verify behavior instead of merely running the code. The examples import `node:assert` themselves to stay copy-pasteable; the generator now merges that import into its prelude assert rather than rejecting it (only `node:test` remains reserved). --- README.md | 18 ++++++++++++++++-- development/docs.md | 5 +++-- scripts/create-doc-tests.ts | 25 ++++++++++++++++--------- 3 files changed, 35 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index f715767..e8acbbf 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ builder. Calling the builder with a handler map keyed by `T`'s members returns a matcher: a function from `T` to the common return type. ```ts +import { strict as assert } from "node:assert"; import { getPrimitiveUnionMatcher } from "tiny-pattern-ts"; const matchAnswer = getPrimitiveUnionMatcher<"yes" | "no">(); @@ -56,13 +57,15 @@ const reply = matchAnswer({ no: () => "declined", }); -reply("yes"); // "agreed" +const answer = reply("yes"); +assert.equal(answer, "agreed"); ``` Add a fallback as the second argument to leave members unhandled; the fallback receives the remainder: ```ts +import { strict as assert } from "node:assert"; import { getPrimitiveUnionMatcher } from "tiny-pattern-ts"; const matchLabel = getPrimitiveUnionMatcher<"yes" | "no" | "maybe">(); @@ -71,6 +74,12 @@ const label = matchLabel( { yes: () => "agreed", no: () => "declined" }, (other) => `not sure: ${other}`, // other: "maybe" ); + +const answer = label("yes"); +assert.equal(answer, "agreed"); + +const fallback = label("maybe"); +assert.equal(fallback, "not sure: maybe"); ``` `getPrimitiveUnionMatcherW` is the same builder, but the matcher's return type @@ -83,6 +92,7 @@ function takes the discriminant property's name and returns the handler-map builder, keyed by that property's tags. ```ts +import { strict as assert } from "node:assert"; import { getTaggedUnionMatcher } from "tiny-pattern-ts"; type Shape = @@ -95,7 +105,11 @@ const area = matchShape({ square: (s) => s.side ** 2, }); -area({ kind: "circle", radius: 2 }); +const circleArea = area({ kind: "circle", radius: 2 }); +assert.equal(circleArea, Math.PI * 4); + +const squareArea = area({ kind: "square", side: 3 }); +assert.equal(squareArea, 9); ``` `getTaggedUnionMatcherW` is the widening counterpart, exactly as in the diff --git a/development/docs.md b/development/docs.md index d93aa0d..8fa727c 100644 --- a/development/docs.md +++ b/development/docs.md @@ -38,8 +38,9 @@ Compile every `ts / `typescript fence in the prose docs into a real `docs/` + `examples/` list is the natural extension; `development/` must never be scanned (its fences are illustrative, not compilable). - The generator hoists and merges leading imports, rewrites `tiny-pattern-ts` to - the `#test-tiny-pattern-ts` source alias, and rejects an example that imports - `node:assert` / `node:test` (the prelude already binds both). Titles are the + the `#test-tiny-pattern-ts` source alias, merges an example's `node:assert` + import into the prelude assert, and rejects an example that imports + `node:test` (the prelude binds `test`). Titles are the immediately preceding paragraph; a fence with no such paragraph is a fatal error, which keeps every example described. - `oxlint src/doc-test` reports "No files found" because the generated diff --git a/scripts/create-doc-tests.ts b/scripts/create-doc-tests.ts index 3628dd9..ad23f15 100644 --- a/scripts/create-doc-tests.ts +++ b/scripts/create-doc-tests.ts @@ -46,18 +46,14 @@ const OUTPUT_DIR = "src/doc-test/__generated__"; const TYPESCRIPT_LANGS: ReadonlySet = new Set(["ts", "typescript"]); /** - * Modules the generated file already imports. An example that imports one of - * these would collide with the prelude binding (duplicate `test` / `assert`), so - * it is surfaced as a fatal error and the example is rewritten. + * Modules the generated file already binds. An example that imports `node:test` + * would collide with the prelude `test` binding, so it is surfaced as a fatal + * error. `node:assert` is allowed: it is merged into the prelude assert import. */ -const PRELUDE_MODULES: ReadonlySet = new Set([ - "node:assert", - "node:test", -]); +const PRELUDE_MODULES: ReadonlySet = new Set(["node:test"]); /** One line of the prelude every generated file starts with. */ const PRELUDE_TEST = 'import { test } from "node:test";'; -const PRELUDE_ASSERT = 'import { strict as assert } from "node:assert";'; /** Indentation applied to every fence body line inside the `test` callback. */ const INDENT = " "; @@ -325,6 +321,17 @@ const handleNode = (state: BuilderState, name: string, node: Block): void => { } }; +/** + * Guarantee `assert` is in scope in every generated file. The import is merged + * into any `node:assert` import an example already declares, so an example can + * stay self-contained without colliding with the harness. + */ +const ensurePreludeAssert = (named: Map): void => { + addNamedImport(named, { source: "node:assert", isType: false }, [ + "strict as assert", + ]); +}; + /** Walk one Markdown file and collect its cases and hoisted imports. */ const parseDoc = ( name: string, @@ -339,6 +346,7 @@ const parseDoc = ( for (const node of fromMarkdown(markdown).children) { handleNode(state, name, node); } + ensurePreludeAssert(state.named); return { named: state.named, passthrough: state.passthrough, @@ -368,7 +376,6 @@ const buildHeader = (name: string, imports: readonly string[]): string[] => { `// Source: ${name}`, EMPTY, PRELUDE_TEST, - PRELUDE_ASSERT, ]; if (imports.length > INITIAL_COUNT) { header.push(EMPTY, ...imports);