✅ Assert the shape each handler receives
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.
This commit is contained in:
1 parent
aa6d71076c
commit
9e03c52473
3 files changed
+109
-11
No files matched your search
+4
-1
@@ -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 —
|
||||
|
||||
@@ -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
|
||||
|
||||
+71
-10
@@ -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<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;
|
||||
},
|
||||
false: (s) => {
|
||||
expectTypeOf(s).toEqualTypeOf<false>();
|
||||
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<null>();
|
||||
assert.equal(s, null);
|
||||
return "null";
|
||||
},
|
||||
undefined: (s) => {
|
||||
expectTypeOf(s).toEqualTypeOf<undefined>();
|
||||
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<null>();
|
||||
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<true | false | undefined>();
|
||||
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<true>();
|
||||
assert.equal(s, true);
|
||||
return "yes" as const;
|
||||
},
|
||||
false: (s) => {
|
||||
expectTypeOf(s).toEqualTypeOf<false>();
|
||||
assert.equal(s, false);
|
||||
return "no" as const;
|
||||
},
|
||||
null: (s) => {
|
||||
expectTypeOf(s).toEqualTypeOf<null>();
|
||||
assert.equal(s, null);
|
||||
return 0 as const;
|
||||
},
|
||||
undefined: (s) => {
|
||||
expectTypeOf(s).toEqualTypeOf<undefined>();
|
||||
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<Parameters<typeof matcher>[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<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;
|
||||
});
|
||||
|
||||
@@ -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<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);
|
||||
|
||||
// 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<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);
|
||||
|
||||
// Act / Assert
|
||||
|
||||
Reference in new issue
Block a user