✅ Cover property-union autocomplete and rejection

Add property-union tests for the completion popup, a broad value in the
union, and value/stringification collisions. Rename the existing
property-union tests to a `property union:` prefix so they can be
selected with `--test-name-pattern`, and extend the autocomplete helper
with a `key` option so a probe can complete on a property other than
`kind`.
This commit is contained in:
tmu committed 2026-09-23 16:32:16 +00:00
1 parent 1bdf7f0315
commit 7b90f898d3
1 file changed
+147 -11
+147 -11
View File
@@ -893,10 +893,10 @@ test("getTaggedUnionMatcher: the maximum illegal tag universe is rejected", () =
}); });
// ============================================================================ // ============================================================================
// Union-valued and optional discriminants // Property unions
// ============================================================================ // ============================================================================
test("getTaggedUnionMatcher: a union-valued property narrows per handler", () => { test("property union: narrows each handler", () => {
// Arrange — one shape, not a union of variants; `color` is a union. // Arrange — one shape, not a union of variants; `color` is a union.
interface Paint { interface Paint {
readonly color: "red" | "green" | "blue"; readonly color: "red" | "green" | "blue";
@@ -939,7 +939,7 @@ test("getTaggedUnionMatcher: a union-valued property narrows per handler", () =>
assert.equal(describe({ color: "blue", value: 3 }), 3); assert.equal(describe({ color: "blue", value: 3 }), 3);
}); });
test("getTaggedUnionMatcher: a fallback narrows a union-valued property", () => { test("property union: a fallback receives the unhandled values", () => {
// Arrange // Arrange
interface Paint { interface Paint {
color: "red" | "green" | "blue"; color: "red" | "green" | "blue";
@@ -976,7 +976,7 @@ test("getTaggedUnionMatcher: a fallback narrows a union-valued property", () =>
assert.equal(describe({ color: "blue", value: 3 }), 2); assert.equal(describe({ color: "blue", value: 3 }), 2);
}); });
test("getTaggedUnionMatcher: a numeric union-valued property narrows per handler", () => { test("property union: a numeric property narrows each handler", () => {
// Arrange // Arrange
interface Version { interface Version {
code: 1 | 2 | 3; code: 1 | 2 | 3;
@@ -1010,7 +1010,7 @@ test("getTaggedUnionMatcher: a numeric union-valued property narrows per handler
assert.equal(describe({ code: 3, value: 30 }), 3); assert.equal(describe({ code: 3, value: 30 }), 3);
}); });
test("getTaggedUnionMatcher: an optional discriminant narrows both handlers", () => { test("property union: an optional property is required for a defined tag", () => {
// Arrange — `type?` is `"x" | undefined`; both handlers must be typed, // Arrange — `type?` is `"x" | undefined`; both handlers must be typed,
// and matching `"x"` proves the key is present, so `type` becomes required. // and matching `"x"` proves the key is present, so `type` becomes required.
interface Maybe { interface Maybe {
@@ -1041,7 +1041,7 @@ test("getTaggedUnionMatcher: an optional discriminant narrows both handlers", ()
assert.equal(describe({ value: 2 }), 2); assert.equal(describe({ value: 2 }), 2);
}); });
test("getTaggedUnionMatcher: an optional discriminant fallback keeps the undefined tag", () => { test("property union: an optional fallback receives the undefined tag", () => {
// Arrange // Arrange
interface Maybe { interface Maybe {
type?: "x"; type?: "x";
@@ -1074,7 +1074,7 @@ test("getTaggedUnionMatcher: an optional discriminant fallback keeps the undefin
assert.equal(describe({ value: 2 }), 2); assert.equal(describe({ value: 2 }), 2);
}); });
test("getTaggedUnionMatcher: explicit `undefined` stays distinct from absence", () => { test("property union: explicit `undefined` stays distinct from absence", () => {
// Arrange — `type?: "x" | undefined` admits an explicit `undefined`, unlike // Arrange — `type?: "x" | undefined` admits an explicit `undefined`, unlike
// the bare optional above; the two representations must not be conflated. // the bare optional above; the two representations must not be conflated.
interface Explicit { interface Explicit {
@@ -1107,7 +1107,7 @@ test("getTaggedUnionMatcher: explicit `undefined` stays distinct from absence",
assert.equal(describe({ value: 3 }), 2); assert.equal(describe({ value: 3 }), 2);
}); });
test("getTaggedUnionMatcher: a union-valued member is split, not dropped", () => { test("property union: a union-valued member is split, not dropped", () => {
// A member whose tag is itself a union sits beside a singleton-tag member. // A member whose tag is itself a union sits beside a singleton-tag member.
// Selecting members with `Extract<T, Record<K, V>>` keeps only the // Selecting members with `Extract<T, Record<K, V>>` keeps only the
// singleton; the distributive narrowing must keep both sides of the // singleton; the distributive narrowing must keep both sides of the
@@ -1139,7 +1139,7 @@ test("getTaggedUnionMatcher: a union-valued member is split, not dropped", () =>
assert.equal(describe({ kind: "b", b: 3 }), 2); assert.equal(describe({ kind: "b", b: 3 }), 2);
}); });
test("getTaggedUnionMatcherW: a union-valued property widens to the union of returns", () => { test("property union: getTaggedUnionMatcherW widens the returns", () => {
// Arrange // Arrange
interface Paint { interface Paint {
color: "red" | "green" | "blue"; color: "red" | "green" | "blue";
@@ -1173,7 +1173,7 @@ test("getTaggedUnionMatcherW: a union-valued property widens to the union of ret
assert.equal(describe({ color: "blue", value: 3 }), true); assert.equal(describe({ color: "blue", value: 3 }), true);
}); });
test("getTaggedUnionMatcher: a union-valued property still enforces its contract", () => { test("property union: enforces its contract", () => {
// Arrange // Arrange
interface Paint { interface Paint {
color: "red" | "green" | "blue"; color: "red" | "green" | "blue";
@@ -1195,6 +1195,55 @@ test("getTaggedUnionMatcher: a union-valued property still enforces its contract
factory({ red: () => 1, green: () => 2, blue: () => 3 }, () => 0); factory({ red: () => 1, green: () => 2, blue: () => 3 }, () => 0);
}); });
test("property union: a broad value in the union is rejected", () => {
// Arrange — the property is a union, but not a finite union of literals: a
// template literal cannot be proven exhaustive.
interface Template {
kind: "a" | `x-${number}`;
a: number;
}
// Act / Assert — the calls below must not compile
// @ts-expect-error a template literal is not a finite literal
getTaggedUnionMatcher<Template>()("kind")({ a: () => 1 });
// @ts-expect-error the widening factory rejects broad values too
getTaggedUnionMatcherW<Template>()("kind")({ a: () => 1 });
});
test("property union: a value/stringification collision is rejected", () => {
// Arrange — one property holding both a value and its stringification; both
// would key the same handler.
interface BooleanColliding {
kind: "true" | true;
a: number;
}
interface NumericColliding {
kind: "1" | 1;
a: number;
}
// Act / Assert — the calls below must not compile
// @ts-expect-error `true` collides with the string tag `"true"`
getTaggedUnionMatcher<BooleanColliding>()("kind")({ true: () => 1 });
// @ts-expect-error the number tag `1` collides with the string tag `"1"`
getTaggedUnionMatcher<NumericColliding>()("kind")({ "1": () => 1 });
// @ts-expect-error the widening factory rejects the collision too
getTaggedUnionMatcherW<BooleanColliding>()("kind")({ true: () => 1 });
});
test("property union: a collision is rejected beside legal members", () => {
// Arrange — the map is otherwise complete, so only the collision can make
// it invalid.
interface Colliding {
kind: "true" | true | "a";
a: number;
}
// Act / Assert — the call below must not compile
// @ts-expect-error `true` collides with the string tag `"true"`
getTaggedUnionMatcher<Colliding>()("kind")({ true: () => 1, a: () => 2 });
});
// ============================================================================ // ============================================================================
// Dispatch — runtime behavior // Dispatch — runtime behavior
// ============================================================================ // ============================================================================
@@ -1238,6 +1287,7 @@ interface LabelsProbe {
readonly tail?: string; readonly tail?: string;
readonly typeName?: string; readonly typeName?: string;
readonly typeSource?: string; readonly typeSource?: string;
readonly key?: string;
} }
const labelsFor = ({ const labelsFor = ({
@@ -1247,6 +1297,7 @@ const labelsFor = ({
tail = "", tail = "",
typeName = "Shape", typeName = "Shape",
typeSource = SHAPE_SOURCE, typeSource = SHAPE_SOURCE,
key = "kind",
}: LabelsProbe): Promise<readonly string[]> => { }: LabelsProbe): Promise<readonly string[]> => {
const session = new LspSession(REPO_ROOT); const session = new LspSession(REPO_ROOT);
const target: CompletionTarget = { const target: CompletionTarget = {
@@ -1254,7 +1305,7 @@ const labelsFor = ({
source: [ source: [
`import { ${factory} } from "./index.ts";`, `import { ${factory} } from "./index.ts";`,
typeSource, typeSource,
`const m = ${factory}<${typeName}>()("kind")({`, `const m = ${factory}<${typeName}>()("${key}")({`,
body, body,
`}${tail});`, `}${tail});`,
"", "",
@@ -1341,6 +1392,91 @@ test("autocomplete: boolean and nullish tags are offered by name", () => {
}); });
}); });
// A single shape whose property is a union: the popup is still keyed by the
// property's values, not by the members of a union type.
const PALETTE_SOURCE = `interface Palette { color: "red" | "green" | "blue"; value: number }`;
const FLAG_SOURCE = `interface Flag { kind: true | false | null | undefined; a: number }`;
test("property union: autocomplete offers the property's values", () => {
// Arrange
const name = "property_union_fresh";
// Act
const labels = labelsFor({
name,
factory: "getTaggedUnionMatcher",
typeName: "Palette",
typeSource: PALETTE_SOURCE,
key: "color",
body: " /*COMPLETE*/",
});
// Assert
return labels.then((result) => {
assert.deepEqual([...result], ["blue", "green", "red"]);
});
});
test("property union: autocomplete drops a handled value", () => {
// Arrange
const name = "property_union_after_key";
// Act
const labels = labelsFor({
name,
factory: "getTaggedUnionMatcher",
typeName: "Palette",
typeSource: PALETTE_SOURCE,
key: "color",
body: " red: () => 1,\n /*COMPLETE*/",
});
// Assert
return labels.then((result) => {
assert.deepEqual([...result], ["blue", "green"]);
});
});
test("property union: autocomplete makes the remaining values optional with a fallback", () => {
// Arrange
const name = "property_union_with_fallback";
// Act
const labels = labelsFor({
name,
factory: "getTaggedUnionMatcher",
typeName: "Palette",
typeSource: PALETTE_SOURCE,
key: "color",
body: " /*COMPLETE*/",
tail: ", () => 0",
});
// Assert
return labels.then((result) => {
assert.deepEqual([...result], ["blue?", "green?", "red?"]);
});
});
test("property union: autocomplete offers boolean and nullish values by name", () => {
// Arrange
const name = "property_union_boolean_nullish";
// Act
const labels = labelsFor({
name,
factory: "getTaggedUnionMatcher",
typeName: "Flag",
typeSource: FLAG_SOURCE,
body: " /*COMPLETE*/",
});
// Assert
return labels.then((result) => {
assert.deepEqual([...result], ["false", "null", "true", "undefined"]);
});
});
// `Mixed` has two common keys, but `id`'s value is not a tag, so only `kind` // `Mixed` has two common keys, but `id`'s value is not a tag, so only `kind`
// may serve as the discriminant. // may serve as the discriminant.
const MIXED_SOURCE = `type Mixed = { id: Date; kind: "a"; a: number } | { id: Date; kind: "b"; b: number };`; const MIXED_SOURCE = `type Mixed = { id: Date; kind: "a"; a: number } | { id: Date; kind: "b"; b: number };`;