✅ 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:
1 parent
1bdf7f0315
commit
7b90f898d3
1 file changed
+147
-11
+147
-11
@@ -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.
|
||||
interface Paint {
|
||||
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);
|
||||
});
|
||||
|
||||
test("getTaggedUnionMatcher: a fallback narrows a union-valued property", () => {
|
||||
test("property union: a fallback receives the unhandled values", () => {
|
||||
// Arrange
|
||||
interface Paint {
|
||||
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);
|
||||
});
|
||||
|
||||
test("getTaggedUnionMatcher: a numeric union-valued property narrows per handler", () => {
|
||||
test("property union: a numeric property narrows each handler", () => {
|
||||
// Arrange
|
||||
interface Version {
|
||||
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);
|
||||
});
|
||||
|
||||
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,
|
||||
// and matching `"x"` proves the key is present, so `type` becomes required.
|
||||
interface Maybe {
|
||||
@@ -1041,7 +1041,7 @@ test("getTaggedUnionMatcher: an optional discriminant narrows both handlers", ()
|
||||
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
|
||||
interface Maybe {
|
||||
type?: "x";
|
||||
@@ -1074,7 +1074,7 @@ test("getTaggedUnionMatcher: an optional discriminant fallback keeps the undefin
|
||||
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
|
||||
// the bare optional above; the two representations must not be conflated.
|
||||
interface Explicit {
|
||||
@@ -1107,7 +1107,7 @@ test("getTaggedUnionMatcher: explicit `undefined` stays distinct from absence",
|
||||
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.
|
||||
// Selecting members with `Extract<T, Record<K, V>>` keeps only 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);
|
||||
});
|
||||
|
||||
test("getTaggedUnionMatcherW: a union-valued property widens to the union of returns", () => {
|
||||
test("property union: getTaggedUnionMatcherW widens the returns", () => {
|
||||
// Arrange
|
||||
interface Paint {
|
||||
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);
|
||||
});
|
||||
|
||||
test("getTaggedUnionMatcher: a union-valued property still enforces its contract", () => {
|
||||
test("property union: enforces its contract", () => {
|
||||
// Arrange
|
||||
interface Paint {
|
||||
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);
|
||||
});
|
||||
|
||||
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
|
||||
// ============================================================================
|
||||
@@ -1238,6 +1287,7 @@ interface LabelsProbe {
|
||||
readonly tail?: string;
|
||||
readonly typeName?: string;
|
||||
readonly typeSource?: string;
|
||||
readonly key?: string;
|
||||
}
|
||||
|
||||
const labelsFor = ({
|
||||
@@ -1247,6 +1297,7 @@ const labelsFor = ({
|
||||
tail = "",
|
||||
typeName = "Shape",
|
||||
typeSource = SHAPE_SOURCE,
|
||||
key = "kind",
|
||||
}: LabelsProbe): Promise<readonly string[]> => {
|
||||
const session = new LspSession(REPO_ROOT);
|
||||
const target: CompletionTarget = {
|
||||
@@ -1254,7 +1305,7 @@ const labelsFor = ({
|
||||
source: [
|
||||
`import { ${factory} } from "./index.ts";`,
|
||||
typeSource,
|
||||
`const m = ${factory}<${typeName}>()("kind")({`,
|
||||
`const m = ${factory}<${typeName}>()("${key}")({`,
|
||||
body,
|
||||
`}${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`
|
||||
// may serve as the discriminant.
|
||||
const MIXED_SOURCE = `type Mixed = { id: Date; kind: "a"; a: number } | { id: Date; kind: "b"; b: number };`;
|
||||
|
||||
Reference in new issue
Block a user