🔀 Merge chore/test-handler-runtime-arguments into main

This commit is contained in:
tmu committed 2026-09-21 13:09:10 +00:00
commit bf711cf9fc
3 files changed
+109 -11

No files matched your search

+4 -1
View File
@@ -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 4. **Refactor** — with the type system and the tests as the safety net, then
`npm run verify` as the definition-of-done gate. `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 The autocomplete tests (`src/util/__tests__/lsp-completion.test.ts` for the
helper, `src/primitive.test.ts` for the matcher's popup) are the helper, `src/primitive.test.ts` for the matcher's popup) are the
exception — exception —
+34
View File
@@ -37,6 +37,40 @@ in
build step, and the runner relies on the `.ts` import-extension convention (see build step, and the runner relies on the `.ts` import-extension convention (see
[tooling.md](./tooling.md#source-imports-use-ts-extensions)). [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 ## AAA ordering
The rule is in The rule is in
+71 -10
View File
@@ -23,10 +23,12 @@ test("getMatcher: exhaustive pattern infers one common return type", () => {
const matcher = factory({ const matcher = factory({
a: (s) => { a: (s) => {
expectTypeOf(s).toEqualTypeOf<"a">(); expectTypeOf(s).toEqualTypeOf<"a">();
assert.equal(s, "a");
return 1; return 1;
}, },
b: (s) => { b: (s) => {
expectTypeOf(s).toEqualTypeOf<"b">(); expectTypeOf(s).toEqualTypeOf<"b">();
assert.equal(s, "b");
return 2; return 2;
}, },
}); });
@@ -48,10 +50,13 @@ test("getMatcher: dispatches on numeric literal keys", () => {
const matcher = factory({ const matcher = factory({
1: (n) => { 1: (n) => {
expectTypeOf(n).toEqualTypeOf<1>(); expectTypeOf(n).toEqualTypeOf<1>();
// A numeric key must not reach its handler as the string `"1"`.
assert.equal(n, 1);
return n + 1; return n + 1;
}, },
2: (n) => { 2: (n) => {
expectTypeOf(n).toEqualTypeOf<2>(); expectTypeOf(n).toEqualTypeOf<2>();
assert.equal(n, 2);
return n * 10; return n * 10;
}, },
}); });
@@ -70,18 +75,22 @@ test("getMatcher: dispatches on mixed string and numeric keys", () => {
const matcher = factory({ const matcher = factory({
a: (s) => { a: (s) => {
expectTypeOf(s).toEqualTypeOf<"a">(); expectTypeOf(s).toEqualTypeOf<"a">();
assert.equal(s, "a");
return 1; return 1;
}, },
b: (s) => { b: (s) => {
expectTypeOf(s).toEqualTypeOf<"b">(); expectTypeOf(s).toEqualTypeOf<"b">();
assert.equal(s, "b");
return 2; return 2;
}, },
1: (n) => { 1: (n) => {
expectTypeOf(n).toEqualTypeOf<1>(); expectTypeOf(n).toEqualTypeOf<1>();
assert.equal(n, 1);
return 10; return 10;
}, },
2: (n) => { 2: (n) => {
expectTypeOf(n).toEqualTypeOf<2>(); expectTypeOf(n).toEqualTypeOf<2>();
assert.equal(n, 2);
return 20; return 20;
}, },
}); });
@@ -102,10 +111,14 @@ test("getMatcher: dispatches on boolean literals", () => {
const matcher = factory({ const matcher = factory({
true: (s) => { true: (s) => {
expectTypeOf(s).toEqualTypeOf<true>(); expectTypeOf(s).toEqualTypeOf<true>();
// 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; return 1;
}, },
false: (s) => { false: (s) => {
expectTypeOf(s).toEqualTypeOf<false>(); expectTypeOf(s).toEqualTypeOf<false>();
assert.equal(s, false);
return 2; return 2;
}, },
}); });
@@ -124,10 +137,12 @@ test("getMatcher: dispatches on null and undefined", () => {
const matcher = factory({ const matcher = factory({
null: (s) => { null: (s) => {
expectTypeOf(s).toEqualTypeOf<null>(); expectTypeOf(s).toEqualTypeOf<null>();
assert.equal(s, null);
return "null"; return "null";
}, },
undefined: (s) => { undefined: (s) => {
expectTypeOf(s).toEqualTypeOf<undefined>(); expectTypeOf(s).toEqualTypeOf<undefined>();
assert.equal(s, undefined);
return "undefined"; return "undefined";
}, },
}); });
@@ -151,12 +166,14 @@ test("getMatcher: a `_` fallback receives the unhandled keys", () => {
{ {
a: (s) => { a: (s) => {
expectTypeOf(s).toEqualTypeOf<"a">(); expectTypeOf(s).toEqualTypeOf<"a">();
assert.equal(s, "a");
return 1 as const; return 1 as const;
}, },
}, },
(s) => { (s) => {
// The fallback sees only the keys `a` did not handle. // The fallback sees only the keys `a` did not handle.
expectTypeOf(s).toEqualTypeOf<"b" | "c">(); expectTypeOf(s).toEqualTypeOf<"b" | "c">();
assert.ok(s === "b" || s === "c");
return 2 as const; return 2 as const;
}, },
); );
@@ -179,15 +196,20 @@ test("getMatcher: a `_` fallback routes mixed string and numeric gaps", () => {
{ {
a: (s) => { a: (s) => {
expectTypeOf(s).toEqualTypeOf<"a">(); expectTypeOf(s).toEqualTypeOf<"a">();
assert.equal(s, "a");
return "A"; return "A";
}, },
1: (n) => { 1: (n) => {
expectTypeOf(n).toEqualTypeOf<1>(); expectTypeOf(n).toEqualTypeOf<1>();
assert.equal(n, 1);
return "one"; return "one";
}, },
}, },
(s) => { (s) => {
expectTypeOf(s).toEqualTypeOf<"b" | 2>(); 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"; return "fallback";
}, },
); );
@@ -209,10 +231,12 @@ test("getMatcher: a `_` fallback receives unhandled boolean and nullish keys", (
{ {
a: (s) => { a: (s) => {
expectTypeOf(s).toEqualTypeOf<"a">(); expectTypeOf(s).toEqualTypeOf<"a">();
assert.equal(s, "a");
return 1 as const; return 1 as const;
}, },
null: (s) => { null: (s) => {
expectTypeOf(s).toEqualTypeOf<null>(); expectTypeOf(s).toEqualTypeOf<null>();
assert.equal(s, null);
return 2 as const; 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 // `true` and `false` are keyed as `"true"`/`"false"` but the
// fallback still sees them as booleans. // fallback still sees them as booleans.
expectTypeOf(s).toEqualTypeOf<true | false | undefined>(); expectTypeOf(s).toEqualTypeOf<true | false | undefined>();
assert.ok(s === true || s === false || s === undefined);
return 3 as const; return 3 as const;
}, },
); );
@@ -247,10 +272,12 @@ test("getMatcherW: exhaustive pattern widens to the union of handler returns", (
const matcher = factory({ const matcher = factory({
x: (s) => { x: (s) => {
expectTypeOf(s).toEqualTypeOf<"x">(); expectTypeOf(s).toEqualTypeOf<"x">();
assert.equal(s, "x");
return 1 as const; return 1 as const;
}, },
y: (s) => { y: (s) => {
expectTypeOf(s).toEqualTypeOf<"y">(); expectTypeOf(s).toEqualTypeOf<"y">();
assert.equal(s, "y");
return "two" as const; return "two" as const;
}, },
}); });
@@ -272,18 +299,22 @@ test("getMatcherW: dispatches on mixed string and numeric keys", () => {
const matcher = factory({ const matcher = factory({
a: (s) => { a: (s) => {
expectTypeOf(s).toEqualTypeOf<"a">(); expectTypeOf(s).toEqualTypeOf<"a">();
assert.equal(s, "a");
return "A" as const; return "A" as const;
}, },
b: (s) => { b: (s) => {
expectTypeOf(s).toEqualTypeOf<"b">(); expectTypeOf(s).toEqualTypeOf<"b">();
assert.equal(s, "b");
return "B" as const; return "B" as const;
}, },
1: (n) => { 1: (n) => {
expectTypeOf(n).toEqualTypeOf<1>(); expectTypeOf(n).toEqualTypeOf<1>();
assert.equal(n, 1);
return 10 as const; return 10 as const;
}, },
2: (n) => { 2: (n) => {
expectTypeOf(n).toEqualTypeOf<2>(); expectTypeOf(n).toEqualTypeOf<2>();
assert.equal(n, 2);
return 20 as const; return 20 as const;
}, },
}); });
@@ -306,18 +337,22 @@ test("getMatcherW: exhaustive boolean and nullish widen to the union", () => {
const matcher = factory({ const matcher = factory({
true: (s) => { true: (s) => {
expectTypeOf(s).toEqualTypeOf<true>(); expectTypeOf(s).toEqualTypeOf<true>();
assert.equal(s, true);
return "yes" as const; return "yes" as const;
}, },
false: (s) => { false: (s) => {
expectTypeOf(s).toEqualTypeOf<false>(); expectTypeOf(s).toEqualTypeOf<false>();
assert.equal(s, false);
return "no" as const; return "no" as const;
}, },
null: (s) => { null: (s) => {
expectTypeOf(s).toEqualTypeOf<null>(); expectTypeOf(s).toEqualTypeOf<null>();
assert.equal(s, null);
return 0 as const; return 0 as const;
}, },
undefined: (s) => { undefined: (s) => {
expectTypeOf(s).toEqualTypeOf<undefined>(); expectTypeOf(s).toEqualTypeOf<undefined>();
assert.equal(s, undefined);
return 1 as const; return 1 as const;
}, },
}); });
@@ -345,25 +380,28 @@ test("getMatcherW: a `_` fallback widens gaps into the union", () => {
{ {
x: (s) => { x: (s) => {
expectTypeOf(s).toEqualTypeOf<"x">(); expectTypeOf(s).toEqualTypeOf<"x">();
assert.equal(s, "x");
return 1 as const; return 1 as const;
}, },
}, },
(s) => { (s) => {
// The fallback sees only the keys `x` did not handle. // The fallback sees only the keys `x` did not handle.
expectTypeOf(s).toEqualTypeOf<"y" | "z">(); 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 // Assert
expectTypeOf(matcher).toEqualTypeOf< 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 // ❌ a value outside T must not be accepted by the matcher
expectTypeOf<"w">().not.toExtend<Parameters<typeof matcher>[0]>(); expectTypeOf<"w">().not.toExtend<Parameters<typeof matcher>[0]>();
assert.equal(matcher("x"), 1); assert.equal(matcher("x"), 1);
assert.equal(matcher("y"), "fallback"); assert.equal(matcher("y"), "y");
assert.equal(matcher("z"), "fallback"); assert.equal(matcher("z"), "z");
}); });
test("getMatcherW: a `_` fallback widens mixed string and numeric returns", () => { 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) => { a: (s) => {
expectTypeOf(s).toEqualTypeOf<"a">(); expectTypeOf(s).toEqualTypeOf<"a">();
assert.equal(s, "a");
return "A" as const; return "A" as const;
}, },
1: (n) => { 1: (n) => {
expectTypeOf(n).toEqualTypeOf<1>(); expectTypeOf(n).toEqualTypeOf<1>();
assert.equal(n, 1);
return 10 as const; return 10 as const;
}, },
}, },
(s) => { (s) => {
expectTypeOf(s).toEqualTypeOf<"b" | 2>(); 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 // Assert
// ❌ ReturnsStrict: mixed handler returns widen to their union. // ❌ ReturnsStrict: mixed handler returns widen to their union.
expectTypeOf(matcher).toEqualTypeOf< 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("a"), "A");
assert.equal(matcher("b"), "fallback"); assert.equal(matcher("b"), "b");
assert.equal(matcher(1), 10); 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 // The map's literal keys do not close an open universe, so the
// remainder stays `string` and the fallback is not redundant. // remainder stays `string` and the fallback is not redundant.
expectTypeOf(s).toEqualTypeOf<string>(); expectTypeOf(s).toEqualTypeOf<string>();
// 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; 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", () => { test("getMatcher: an unhandled shape throws without a fallback", () => {
// Arrange — an open universe types its handler map as an index signature, // Arrange — an open universe types its handler map as an index signature,
// so the type system cannot prove the runtime map is exhaustive. // so the type system cannot prove the runtime map is exhaustive.
const handlers: Record<string, () => number> = { a: () => 1 }; const handlers: Record<string, (shape: unknown) => number> = {
a: (shape) => {
// The raw shape reaches the handler, not the property key's text.
assert.equal(shape, "a");
return 1;
},
};
const matcher = getMatcher<string>()(handlers); const matcher = getMatcher<string>()(handlers);
// Act / Assert // 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", () => { test("getMatcher: an unhandled boolean shape throws without a fallback", () => {
// Arrange — an `string | boolean` universe widens its handler map to an // Arrange — an `string | boolean` universe widens its handler map to an
// index signature, so the runtime map's exhaustiveness is not provable. // index signature, so the runtime map's exhaustiveness is not provable.
const handlers: Record<string, () => number> = { true: () => 1 }; const handlers: Record<string, (shape: unknown) => 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<string | boolean>()(handlers); const matcher = getMatcher<string | boolean>()(handlers);
// Act / Assert // Act / Assert