From 9e03c524732ec8757b4617a44ce2130059185c8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Mon, 21 Sep 2026 11:51:58 +0000 Subject: [PATCH] :white_check_mark: Assert the shape each handler receives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A handler's `expectTypeOf(shape)` only proved the type the compiler inferred; nothing observed the argument `dispatch` passed. Passing `String(shape)` at the call site therefore kept the whole suite green while breaking every key whose property name differs from its value (`true`, `null`, `1`). Pair each handler expectation with a runtime assertion: `assert.equal` where a handler runs for one shape, the disjunction over the set a `_` fallback accepts where it runs for several. Where the exact value matters the fallback returns its shape verbatim and the call site asserts it; a widening pattern absorbs the remainder type into the return union, so the claim stays strict-free. The mutation above now fails 10 of the 33 tests. Rule: CONTRIBUTING.md, rationale: development/testing.md § Handler arguments. --- CONTRIBUTING.md | 5 ++- development/testing.md | 34 ++++++++++++++++++ src/primitive.test.ts | 81 ++++++++++++++++++++++++++++++++++++------ 3 files changed, 109 insertions(+), 11 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 52c1928..1fcd42e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -66,7 +66,10 @@ before the implementation. The loop is **type → red → green → refactor**: 4. **Refactor** — with the type system and the tests as the safety net, then `npm run verify` as the definition-of-done gate. -Every test pairs an `expectTypeOf(...)` with an `assert.*`; keep them together. +Every test pairs an `expectTypeOf(...)` with an `assert.*`; keep them together +— including the expectations inside handler bodies, which pair with an +assertion on the value dispatch passed, not only the type it inferred (see +[development/testing.md § Handler arguments](./development/testing.md#handler-arguments)). The autocomplete tests (`src/util/__tests__/lsp-completion.test.ts` for the helper, `src/primitive.test.ts` for the matcher's popup) are the exception — diff --git a/development/testing.md b/development/testing.md index 0a2926f..bcbf33d 100644 --- a/development/testing.md +++ b/development/testing.md @@ -37,6 +37,40 @@ in build step, and the runner relies on the `.ts` import-extension convention (see [tooling.md](./tooling.md#source-imports-use-ts-extensions)). +## Handler arguments + +#### Decision (2026-09) + +A handler's `expectTypeOf(shape)` is always paired with an assertion on the +argument `dispatch` actually passed: `assert.equal` where the handler runs for +one shape, `assert.ok(s === … || s === …)` over the set a `_` fallback accepts +(`assert` is imported as `strict`, so each comparison is `Object.is`). Where a +test should also prove that the _exact_ value reached the handler unchanged, +the fallback returns the shape verbatim and the call site asserts it. + +#### Why + +- The parameter's type is what the compiler inferred from the pattern; the + argument is what the runtime passed. Only the second can drift, and the keys + whose property name differs from their value (`true`, `null`, `1`) are + exactly where it can — see [library.md](./library.md). +- `String(shape)` at the call site keeps every type expectation and every + return-value assertion green; the argument assertions fail (10 of the 33 + tests). Without them the suite never looks at the passed argument. +- Returning the shape verbatim costs a widening pattern nothing: the remainder + type joins the union of handler returns in place of a marker literal, so the + test still shows the widening it is named for. + +#### Rejected + +- A recorded `unknown[]` of every fallback call compared with `deepEqual`: + strong, but it couples the assertion to call order, and the sink sits three + blocks away from the value it observes. +- `typeof` checks: they cannot separate `2` from its key text `"2"`, which is + the drift a fallback with a numeric remainder can hit. +- One expected value asserted inline in a fallback: its argument is a _set_ of + shapes, so only the disjunction holds on every call. + ## AAA ordering The rule is in diff --git a/src/primitive.test.ts b/src/primitive.test.ts index 7045094..e0f1b4b 100644 --- a/src/primitive.test.ts +++ b/src/primitive.test.ts @@ -23,10 +23,12 @@ test("getMatcher: exhaustive pattern infers one common return type", () => { const matcher = factory({ a: (s) => { expectTypeOf(s).toEqualTypeOf<"a">(); + assert.equal(s, "a"); return 1; }, b: (s) => { expectTypeOf(s).toEqualTypeOf<"b">(); + assert.equal(s, "b"); return 2; }, }); @@ -48,10 +50,13 @@ test("getMatcher: dispatches on numeric literal keys", () => { const matcher = factory({ 1: (n) => { expectTypeOf(n).toEqualTypeOf<1>(); + // A numeric key must not reach its handler as the string `"1"`. + assert.equal(n, 1); return n + 1; }, 2: (n) => { expectTypeOf(n).toEqualTypeOf<2>(); + assert.equal(n, 2); return n * 10; }, }); @@ -70,18 +75,22 @@ test("getMatcher: dispatches on mixed string and numeric keys", () => { const matcher = factory({ a: (s) => { expectTypeOf(s).toEqualTypeOf<"a">(); + assert.equal(s, "a"); return 1; }, b: (s) => { expectTypeOf(s).toEqualTypeOf<"b">(); + assert.equal(s, "b"); return 2; }, 1: (n) => { expectTypeOf(n).toEqualTypeOf<1>(); + assert.equal(n, 1); return 10; }, 2: (n) => { expectTypeOf(n).toEqualTypeOf<2>(); + assert.equal(n, 2); return 20; }, }); @@ -102,10 +111,14 @@ test("getMatcher: dispatches on boolean literals", () => { const matcher = factory({ true: (s) => { expectTypeOf(s).toEqualTypeOf(); + // The `true` key is a property name; the handler must still be + // called with the boolean `true`, not the string `"true"`. + assert.equal(s, true); return 1; }, false: (s) => { expectTypeOf(s).toEqualTypeOf(); + assert.equal(s, false); return 2; }, }); @@ -124,10 +137,12 @@ test("getMatcher: dispatches on null and undefined", () => { const matcher = factory({ null: (s) => { expectTypeOf(s).toEqualTypeOf(); + assert.equal(s, null); return "null"; }, undefined: (s) => { expectTypeOf(s).toEqualTypeOf(); + assert.equal(s, undefined); return "undefined"; }, }); @@ -151,12 +166,14 @@ test("getMatcher: a `_` fallback receives the unhandled keys", () => { { a: (s) => { expectTypeOf(s).toEqualTypeOf<"a">(); + assert.equal(s, "a"); return 1 as const; }, }, (s) => { // The fallback sees only the keys `a` did not handle. expectTypeOf(s).toEqualTypeOf<"b" | "c">(); + assert.ok(s === "b" || s === "c"); return 2 as const; }, ); @@ -179,15 +196,20 @@ test("getMatcher: a `_` fallback routes mixed string and numeric gaps", () => { { a: (s) => { expectTypeOf(s).toEqualTypeOf<"a">(); + assert.equal(s, "a"); return "A"; }, 1: (n) => { expectTypeOf(n).toEqualTypeOf<1>(); + assert.equal(n, 1); return "one"; }, }, (s) => { expectTypeOf(s).toEqualTypeOf<"b" | 2>(); + // The gap mixes a string and a number, so the number must reach the + // fallback as `2`, never as its key text `"2"`. + assert.ok(s === "b" || s === 2); return "fallback"; }, ); @@ -209,10 +231,12 @@ test("getMatcher: a `_` fallback receives unhandled boolean and nullish keys", ( { a: (s) => { expectTypeOf(s).toEqualTypeOf<"a">(); + assert.equal(s, "a"); return 1 as const; }, null: (s) => { expectTypeOf(s).toEqualTypeOf(); + assert.equal(s, null); return 2 as const; }, }, @@ -220,6 +244,7 @@ test("getMatcher: a `_` fallback receives unhandled boolean and nullish keys", ( // `true` and `false` are keyed as `"true"`/`"false"` but the // fallback still sees them as booleans. expectTypeOf(s).toEqualTypeOf(); + assert.ok(s === true || s === false || s === undefined); return 3 as const; }, ); @@ -247,10 +272,12 @@ test("getMatcherW: exhaustive pattern widens to the union of handler returns", ( const matcher = factory({ x: (s) => { expectTypeOf(s).toEqualTypeOf<"x">(); + assert.equal(s, "x"); return 1 as const; }, y: (s) => { expectTypeOf(s).toEqualTypeOf<"y">(); + assert.equal(s, "y"); return "two" as const; }, }); @@ -272,18 +299,22 @@ test("getMatcherW: dispatches on mixed string and numeric keys", () => { const matcher = factory({ a: (s) => { expectTypeOf(s).toEqualTypeOf<"a">(); + assert.equal(s, "a"); return "A" as const; }, b: (s) => { expectTypeOf(s).toEqualTypeOf<"b">(); + assert.equal(s, "b"); return "B" as const; }, 1: (n) => { expectTypeOf(n).toEqualTypeOf<1>(); + assert.equal(n, 1); return 10 as const; }, 2: (n) => { expectTypeOf(n).toEqualTypeOf<2>(); + assert.equal(n, 2); return 20 as const; }, }); @@ -306,18 +337,22 @@ test("getMatcherW: exhaustive boolean and nullish widen to the union", () => { const matcher = factory({ true: (s) => { expectTypeOf(s).toEqualTypeOf(); + assert.equal(s, true); return "yes" as const; }, false: (s) => { expectTypeOf(s).toEqualTypeOf(); + assert.equal(s, false); return "no" as const; }, null: (s) => { expectTypeOf(s).toEqualTypeOf(); + assert.equal(s, null); return 0 as const; }, undefined: (s) => { expectTypeOf(s).toEqualTypeOf(); + assert.equal(s, undefined); return 1 as const; }, }); @@ -345,25 +380,28 @@ test("getMatcherW: a `_` fallback widens gaps into the union", () => { { x: (s) => { expectTypeOf(s).toEqualTypeOf<"x">(); + assert.equal(s, "x"); return 1 as const; }, }, (s) => { // The fallback sees only the keys `x` did not handle. expectTypeOf(s).toEqualTypeOf<"y" | "z">(); - return "fallback" as const; + // Returning the shape verbatim lets the `Assert` block check the + // exact value dispatch passed. + return s; }, ); // Assert expectTypeOf(matcher).toEqualTypeOf< - (shape: "x" | "y" | "z") => 1 | "fallback" + (shape: "x" | "y" | "z") => 1 | "y" | "z" >(); // ❌ a value outside T must not be accepted by the matcher expectTypeOf<"w">().not.toExtend[0]>(); assert.equal(matcher("x"), 1); - assert.equal(matcher("y"), "fallback"); - assert.equal(matcher("z"), "fallback"); + assert.equal(matcher("y"), "y"); + assert.equal(matcher("z"), "z"); }); test("getMatcherW: a `_` fallback widens mixed string and numeric returns", () => { @@ -375,28 +413,34 @@ test("getMatcherW: a `_` fallback widens mixed string and numeric returns", () = { a: (s) => { expectTypeOf(s).toEqualTypeOf<"a">(); + assert.equal(s, "a"); return "A" as const; }, 1: (n) => { expectTypeOf(n).toEqualTypeOf<1>(); + assert.equal(n, 1); return 10 as const; }, }, (s) => { expectTypeOf(s).toEqualTypeOf<"b" | 2>(); - return "fallback" as const; + // The number must arrive as `2`, not as its key text `"2"`. + assert.ok(s === "b" || s === 2); + // Returning the shape verbatim lets the `Assert` block check the + // exact value dispatch passed. + return s; }, ); // Assert // ❌ ReturnsStrict: mixed handler returns widen to their union. expectTypeOf(matcher).toEqualTypeOf< - (shape: "a" | "b" | 1 | 2) => "A" | 10 | "fallback" + (shape: "a" | "b" | 1 | 2) => "A" | 10 | "b" | 2 >(); assert.equal(matcher("a"), "A"); - assert.equal(matcher("b"), "fallback"); + assert.equal(matcher("b"), "b"); assert.equal(matcher(1), 10); - assert.equal(matcher(2), "fallback"); + assert.equal(matcher(2), 2); }); // ============================================================================ @@ -455,6 +499,10 @@ test("getMatcher: an open universe keeps the fallback's remainder open", () => { // The map's literal keys do not close an open universe, so the // remainder stays `string` and the fallback is not redundant. expectTypeOf(s).toEqualTypeOf(); + // A strict pattern fixes one `R` for every handler, so this fallback + // cannot return its shape; `typeof` is the strongest claim the value + // makes on its own — any string passes, including `""`. + assert.equal(typeof s, "string"); return 2 as const; }); @@ -488,7 +536,13 @@ test("getMatcherW factory rejects patterns outside its contract", () => { test("getMatcher: an unhandled shape throws without a fallback", () => { // Arrange — an open universe types its handler map as an index signature, // so the type system cannot prove the runtime map is exhaustive. - const handlers: Record number> = { a: () => 1 }; + const handlers: Record number> = { + a: (shape) => { + // The raw shape reaches the handler, not the property key's text. + assert.equal(shape, "a"); + return 1; + }, + }; const matcher = getMatcher()(handlers); // Act / Assert @@ -499,7 +553,14 @@ test("getMatcher: an unhandled shape throws without a fallback", () => { test("getMatcher: an unhandled boolean shape throws without a fallback", () => { // Arrange — an `string | boolean` universe widens its handler map to an // index signature, so the runtime map's exhaustiveness is not provable. - const handlers: Record number> = { true: () => 1 }; + const handlers: Record number> = { + true: (shape) => { + // `true` indexes the map as the property `"true"`, but the handler + // is still called with the boolean. + assert.equal(shape, true); + return 1; + }, + }; const matcher = getMatcher()(handlers); // Act / Assert