import { describe, it } from "@std/testing/bdd"; import { expect } from "@std/expect"; import { SchemaGenerator } from "../../src/schema-generator.ts"; import { asObjectSchema, getTypeFromCode } from "../utils.ts"; // The local Default marker supplies the branded union read by the formatter. const PRELUDE = ` declare const DEFAULT_MARKER: unique symbol; type DefaultMarker = { readonly [DEFAULT_MARKER]: T }; type Default = (T & DefaultMarker) | T; interface Box { value: T; } `; /** The schema generated for the type alias `Root` declared in `code`. */ async function schemaOfRoot(code: string) { const { type, checker, typeNode } = await getTypeFromCode( PRELUDE + code, "Root", ); return asObjectSchema( new SchemaGenerator().generateSchema(type, checker, typeNode), ); } /** The schema of property `c` of the type alias `Root` declared in `code`. */ async function schemaOfC(code: string): Promise { return (await schemaOfRoot(code)).properties?.c; } /** `type` with every `T` in it replaced by `argument`. */ function inPlace(type: string, argument: string): string { return type.replace(/\bT\b/g, argument); } const boxOfNumber = { type: "object", properties: { value: { type: "number" } }, required: ["value"], }; describe("type-arguments", () => { it("emits one schema for equal arms of a generic marker union", async () => { expect( await schemaOfC(` declare const FRAMEWORK_MARKER: unique symbol; type FrameworkProvidedMarker = { readonly [FRAMEWORK_MARKER]: true }; type FrameworkProvided = (T & FrameworkProvidedMarker) | T; interface Input { c: FrameworkProvided } type Root = Input; `), ).toEqual({ type: "string" }); }); describe("a property declared in a generic interface", () => { // Each property is read for an instantiation of its interface, and its // schema is the one the property gets with the argument written in place. for ( const [declared, argument, schema] of [ [ `T | string | Default`, "number", { type: ["number", "string"], default: "" }, ], ["T | Default<0>", "number", { type: "number", default: 0 }], [ "Writable>", "number", { type: "number", default: 0, asCell: ["cell"] }, ], ["PerUser", "string", { type: "string", scope: "user" }], [ `PerUser>`, "string", { type: "string", default: "", scope: "user" }, ], [ "T[] | Default<[]>", "number", { type: "array", items: { type: "number" }, default: [] }, ], [ "Writable>", "Box", { type: "array", items: boxOfNumber, default: [], asCell: ["cell"] }, ], [ "Box | Default<{ value: 0 }>", "number", { ...boxOfNumber, default: { value: 0 } }, ], [ "{ value: T } | Default<{ value: 0 }>", "number", { ...boxOfNumber, default: { value: 0 } }, ], [ "Box | Default<{ value: T }>", "0", { type: "object", properties: { value: { enum: [0], type: "number" } }, required: ["value"], default: { value: 0 }, }, ], ] as const ) { it(`returns the schema of \`${declared}\` with \`T\` as \`${argument}\` written in place`, async () => { const instantiated = await schemaOfC( `interface Input { c: ${declared}; } type Root = Input<${argument}>;`, ); const written = await schemaOfC( `interface Input { c: ${ inPlace(declared, argument) }; } type Root = Input;`, ); expect({ instantiated, written }).toEqual({ instantiated: schema, written: schema, }); }); } it("uses a bound union argument to validate a full object default", async () => { const schema = await schemaOfC(` interface Input { c: Box | Default<{ value: 0 }> } type Root = Input; `); expect(schema).toEqual({ type: "object", properties: { value: { anyOf: [{ type: "number" }, { type: "string" }] }, }, required: ["value"], default: { value: 0 }, }); }); it("validates a full default against a bound anonymous index signature", async () => { const schema = await schemaOfC(` interface Input { c: { [name: string]: T } | Default<{ first: 0 }> } type Root = Input; `) as Record; expect(schema.type).toBe("object"); expect(schema.additionalProperties).toEqual({ type: "number" }); expect(schema.default).toEqual({ first: 0 }); }); it("validates a full default when the checker absorbs the bound literal into a wider union arm", async () => { expect( await schemaOfC(` interface Input { c: Box | Default<{ value: 0 }> } type Root = Input<0>; `), ).toEqual({ type: "object", properties: { value: { anyOf: [{ type: "number", enum: [0] }, { type: "number" }], }, }, required: ["value"], default: { value: 0 }, }); }); it("matches a member naming a generic declaration to its own instantiation among others of that declaration", async () => { // Matched to `Box`, `Box` would not cover the default, and the // object default would throw. const declared = "Box | Box | Default<{ value: 0 }>"; const instantiated = await schemaOfC( `interface Input { c: ${declared}; } type Root = Input;`, ); const written = await schemaOfC( `interface Input { c: ${ inPlace(declared, "number") }; } type Root = Input;`, ); expect({ instantiated, written }).toEqual({ instantiated: written, written: { anyOf: [ boxOfNumber, { type: "object", properties: { value: { type: "string" } }, required: ["value"], }, ], default: { value: 0 }, }, }); }); it("reads a default value from a bound parameter", async () => { expect( await schemaOfC( `interface Input { c: T | Default; } type Root = Input;`, ), ).toEqual({ type: "string", default: "configured" }); expect( await schemaOfC( `interface Input { c: Box | Default; } type Root = Input<{ value: 0 }>;`, ), ).toEqual({ ...boxOfNumber, default: { value: 0 } }); }); it("reads literal defaults from type-only argument bindings", async () => { for ( const [argument, value, expected] of [ ["string", '"configured"', { type: "string", default: "configured" }], ["number", "3", { type: "number", default: 3 }], ["boolean", "true", { type: "boolean", default: true }], ] ) { const { type, checker } = await getTypeFromCode( PRELUDE + ` interface Input { c: T | Default } type Root = Input<${argument}, ${value}>; `, "Root", ); const root = asObjectSchema( new SchemaGenerator().generateSchema(type, checker), ); expect(root.properties?.c).toEqual(expected); } }); it("rejects multiple defaults in a bound generic union", async () => { await expect(schemaOfC(` interface Input { c: T | Default<0> | Default<1> } type Root = Input; `)).rejects.toThrow( "Union types may contain at most one Default<> member", ); }); it("throws for an object default the argument does not cover, as the property written in place does", async () => { const message = "Default object union member is not assignable"; await expect(schemaOfC(` interface Input { c: { value: T } | Default<{ value: 0 }> } type Root = Input; `)).rejects.toThrow(message); await expect( schemaOfC( "interface Input { c: T | Default<{}>; } type Root = Input>;", ), ).rejects.toThrow(message); await expect( schemaOfC( "interface Input { c: Box | Default<{}>; } type Root = Input;", ), ).rejects.toThrow(message); }); }); describe("the declaration a property is read from", () => { const schema = { type: ["number", "string"], default: "" }; const declared = `T | string | Default`; it("reads a property of a generic type alias for its instantiation", async () => { const root = await schemaOfRoot( `type Input = { c: ${declared} }; type Root = { i: Input };`, ); expect(root.properties?.i).toEqual({ type: "object", properties: { c: schema }, required: ["c"], }); }); it("reads a property inherited from a generic base for the instantiation of the interface that extends it", async () => { const c = await schemaOfC( `interface Base { c: ${ declared.replace("T", "U") }; } interface Input extends Base {} type Root = Input;`, ); expect(c).toEqual(schema); }); it("reads a property inherited from a generic base by a non-generic interface", async () => { const root = await schemaOfRoot( `interface Base { c: ${ declared.replace("T", "U") }; } interface Concrete extends Base {} type Root = { r: Concrete };`, ); expect(root.$defs?.Concrete).toEqual({ type: "object", properties: { c: schema }, required: ["c"], }); }); it("reads a property of an instantiation nested in a non-generic object", async () => { const root = await schemaOfRoot( `interface Input { c: ${declared}; } type Root = { i: Input };`, ); expect(root.properties?.i).toEqual({ type: "object", properties: { c: schema }, required: ["c"], }); }); for ( const declaration of [ "interface Derived extends Base {}", "class Derived extends Base {}", ] ) { it(`reads an inherited default from a type-only ${declaration.startsWith("class") ? "class" : "interface"} instantiation`, async () => { const { type, checker } = await getTypeFromCode( PRELUDE + `class Base { c!: ${ inPlace(declared, "U") }; } ${declaration} type Root = Derived;`, "Root", ); const root = asObjectSchema( new SchemaGenerator().generateSchema(type, checker), ); expect(root.properties?.c).toEqual(schema); }); } it("reads a property of a mapped type over an instantiation under its bindings", async () => { const c = await schemaOfC( `interface Input { c: ${declared}; } type Root = Readonly>;`, ); expect(c).toEqual(schema); }); it("reads each instantiation of one declaration with its own argument", async () => { // `next` instantiates the declaration that holds it, so its `c` is read // with `T` as `boolean` within the reading of a `c` with `T` as // `number`. `next` refers to itself as well, so it is emitted as a // reference to a definition. const root = await schemaOfRoot( `interface Input { c: ${declared}; next?: Input; } type Root = Input;`, ); const { $ref } = root.properties?.next as { $ref: string }; const next = asObjectSchema( root.$defs?.[$ref.replace("#/$defs/", "")] ?? {}, ); expect({ c: root.properties?.c, nextC: next.properties?.c }).toEqual({ c: schema, nextC: { type: ["boolean", "string"], default: "" }, }); }); it("reads bound keys through library views in an intersection alias", async () => { const root = await schemaOfRoot(` interface Fields { text?: string; count?: number; } type State = Required> & { ready: boolean }; type Root = State<"text">; `); expect(root.properties).toEqual({ text: { type: "string" }, ready: { type: "boolean" }, }); expect(root.required).toEqual(["text", "ready"]); }); it("reads a bound never key as an empty projection", async () => { const root = await schemaOfRoot(` interface Fields { text?: string; count?: number; } type State = Required> & { ready: boolean }; type Root = State; `); expect(root.properties).toEqual({ ready: { type: "boolean" } }); expect(root.required).toEqual(["ready"]); }); it("reads a labelled literal's operator members from their concrete instantiation", async () => { const root = await schemaOfRoot(` type Cfc = T & { readonly __ct_cfc__?: M }; type Confidential = Cfc; type Input = Confidential<{ indexed: T["name" & keyof T]; mapped: { [K in keyof T]: T[K] }; conditional: T extends { name: infer U } ? U : never; }, readonly ["a"]>; type Root = Input<{ name: number }>; `); expect(root).toEqual({ type: "object", properties: { indexed: { type: "number" }, mapped: { type: "object", properties: { name: { type: "number" } }, required: ["name"], }, conditional: { type: "number" }, }, required: ["indexed", "mapped", "conditional"], ifc: { confidentiality: ["a"] }, }); }); it("reads operator members through a structural library view's bound operand", async () => { const root = await schemaOfRoot(` type Input = Readonly<{ indexed: T["name" & keyof T]; mapped: { [K in keyof T]: T[K] }; }>; type Root = Input<{ name: number }>; `); expect(root.properties).toEqual({ indexed: { type: "number" }, mapped: { type: "object", properties: { name: { type: "number" } }, required: ["name"], }, }); expect(root.required).toEqual(["indexed", "mapped"]); }); it("reads an unbound parameter from its own constraint", async () => { const root = await schemaOfRoot(` type Root = T; `); expect(root).toEqual({ type: "object", properties: { tag: { type: "string" } }, required: ["tag"], }); }); it("reports a deferred conditional reading when another argument is bound", async () => { const { type, checker, typeNode } = await getTypeFromCode( ` type Conditional = U extends string ? { text: T } : { value: T }; type Root = Conditional; `, "Root", ); const diagnostics: string[] = []; const root = new SchemaGenerator().generateSchema( type, checker, typeNode, { onDiagnostic: (diagnostic) => diagnostics.push(diagnostic.type) }, ); expect(root).toEqual({}); expect(diagnostics).toEqual(["schema-type:unread"]); }); it("reads a shared base once across a type-only inheritance diamond", async () => { const { type, checker } = await getTypeFromCode( PRELUDE + ` interface Shared { tag: string } interface Base extends Shared { c: T | Default<0> } interface Left extends Base {} interface Right extends Base {} interface Both extends Left, Right {} type Root = Both; `, "Root", ); const root = asObjectSchema( new SchemaGenerator().generateSchema(type, checker), ); expect(root.properties).toEqual({ tag: { type: "string" }, c: { type: "number", default: 0 }, }); expect(root.required?.toSorted()).toEqual(["c", "tag"]); }); it("reads an omitted key and a picked partial key under their alias bindings", async () => { const root = await schemaOfRoot(` type PartialBy = Omit & Partial>; type Root = PartialBy<{ text: string; count: number }, "count">; `); expect(root.properties).toEqual({ text: { type: "string" }, count: { type: "number" }, }); expect(root.required).toEqual(["text"]); }); it("reads a concrete template literal beside a bound parameter", async () => { const root = await schemaOfRoot(` type DID = \`did:\${string}\`; interface Input { value: T; ids: DID[]; } type Root = Input; `); expect(root.properties).toEqual({ value: { type: "number" }, ids: { type: "array", items: { type: "string" } }, }); }); it("keeps undefined beside an optional generic cell reference", async () => { const root = await schemaOfRoot(` interface Imported { authorProfile?: ProfileRef; } type Root = Imported>; `); expect(root.properties?.authorProfile).toEqual({ anyOf: [ { type: "object", properties: { name: { type: "string" } }, required: ["name"], asCell: ["cell"], }, { type: "undefined" }, ], }); expect(root.required).toBeUndefined(); }); it("keeps recursive array element types distinct under an enclosing array's bindings", async () => { for (const spelling of ["(Outer | Other)[]", "Array"]) { const { type, checker, typeNode } = await getTypeFromCode( ` interface Link { name: string; children: Link[] } interface Entry { title: string; children?: Link[]; parent?: Collection } interface Collection { entries: Entry[] } interface Outer { children: Link[]; parent: Collection | null } interface Other { other: number } type Root = ${spelling}; `, "Root", ); for (const node of [typeNode, undefined]) { const root = asObjectSchema( new SchemaGenerator().generateSchema(type, checker, node), ); const collection = asObjectSchema(root.$defs?.Collection!); const entries = asObjectSchema(collection.properties?.entries!); const array = typeof entries.$ref === "string" ? asObjectSchema(root.$defs?.[entries.$ref.slice(8)]!) : entries; expect(array.items).toEqual({ $ref: "#/$defs/Entry" }); expect(root.$defs?.Entry).toMatchObject({ properties: { title: { type: "string" } }, }); } } }); }); });