import { expect } from "@std/expect"; import { describe, it } from "@std/testing/bdd"; import ts from "typescript"; import { COMMONFABRIC_TYPES } from "./commonfabric-test-types.ts"; import { callSchemas, callsNamed, collect, parseModule, patternSchemas, } from "./transformed-ast.ts"; import { transformFiles, transformSource } from "./utils.ts"; const IMPORTS = `import { action, computed, pattern, Writable, type Default, type PerUser } from "commonfabric"; interface Stored { readonly name?: string } type Empty = Record; `; /** An emitted schema, read as a plain record. */ type Schema = Record; /** The schemas of the last emitted call to `callee`, for `source`. */ async function schemasOf( source: string, callee: string, ): Promise[]> { const output = await transformSource(IMPORTS + source, { types: COMMONFABRIC_TYPES, typeCheck: true, }); return callSchemas(parseModule(output), callee); } /** A pattern whose `computed()` reads the input cell `c`, of type `type`. */ function computedReading(type: string): string { return `export default pattern<{ c: ${type} }>(({ c }) => ({ s: computed(() => JSON.stringify(c.get())), }));`; } // Each value type a cell may hold, with the schema its `computed()` capture // emits. Every one declares a default, which the pattern body's view of the // cell does not carry. const CELL_VALUES: [title: string, type: string, capture: unknown][] = [ [ "a string with a default", `string | Default<"">`, { type: "string", default: "", asCell: ["readonly"] }, ], [ "a boolean with a default", `Default`, { type: "boolean", default: true, asCell: ["readonly"] }, ], [ "an array with an empty default", `number[] | Default<[]>`, { type: "array", items: { type: "number" }, default: [], asCell: ["readonly"], }, ], [ "an object with an empty-object default", `Default`, { $ref: "#/$defs/Stored", default: {}, asCell: ["readonly"] }, ], [ "an object in a union with an empty-record default", `Stored | Default`, { $ref: "#/$defs/Stored", default: {}, asCell: ["readonly"] }, ], ]; describe("aliased binding declared type", () => { describe("a binding declared by a generic input", () => { for ( const [title, valueType, valueSchema] of [ ["a number", "number", { type: ["number", "string"], default: "" }], ["an object", "{ count: number }", { anyOf: [ { type: "object", properties: { count: { type: "number" } }, required: ["count"], }, { type: "string" }, ], default: "", }], ] as const ) { for (const cell of [false, true]) { for (const action of [false, true]) { it(`retains ${title} and its default in ${action ? "an action" : "a computed"} ${cell ? "cell" : "value"} capture`, async () => { const output = await transformSource( `${IMPORTS} type Blank = string | Default<"">; interface Input { c: ${cell ? "Writable" : "T | Blank"}; } export default pattern>(({ c }) => ({ s: ${action ? "action" : "computed"}(() => JSON.stringify(${ cell ? "c.get()" : "c" })), }));`, { types: COMMONFABRIC_TYPES, typeCheck: true }, ); const schemas = callSchemas( parseModule(output), action ? "handler" : "lift", ); const capture = schemas[action ? 1 : 0]!; expect((capture.properties as Record).c).toEqual({ ...valueSchema, ...(cell ? { asCell: ["readonly"] } : {}), }); }); } } } it("retains the concrete type and default in a result with a scoped binding", async () => { const output = await transformSource( `${IMPORTS} type Blank = string | Default<"">; interface Input { c: T | Blank; nickname: PerUser; } export default pattern>(({ c, nickname }) => ({ c, nickname }));`, { types: COMMONFABRIC_TYPES, typeCheck: true }, ); const result = patternSchemas(parseModule(output)).output; expect((result.properties as Record).c).toEqual({ type: ["number", "string"], default: "", }); expect((result.properties as Record).nickname).toEqual({ type: "string", scope: "user", }); }); it("instantiates an inherited, nested, renamed binding from an imported input", async () => { const output = await transformFiles({ "/types.ts": `import type { Default } from "commonfabric"; type Blank = string | Default<"">; interface Fields { nested: { c: T | Blank }; } export interface Input extends Fields {}`, "/main.tsx": `import { computed, pattern } from "commonfabric"; import type { Input } from "./types.ts"; export default pattern>((({ nested: { c: renamed } }) => ({ s: computed(() => JSON.stringify(renamed)), })));`, }, { types: COMMONFABRIC_TYPES, typeCheck: true }); const [capture] = callSchemas(parseModule(output["/main.tsx"]!), "lift"); expect((capture!.properties as Record).renamed).toEqual({ type: ["number", "string"], default: "", }); }); it("instantiates a property declared in a generic input alias", async () => { const [capture] = await schemasOf( ` type Blank = string | Default<"">; type Input = { c: Writable }; export default pattern>(({ c }) => ({ s: computed(() => JSON.stringify(c.get())), }));`, "lift", ); expect((capture!.properties as Record).c).toEqual({ type: ["number", "string"], default: "", asCell: ["readonly"], }); }); it("retains the concrete fields of an alias supplied as the type argument", async () => { const [capture] = await schemasOf( ` type Blank = string | Default<"">; type Box = { value: T }; interface Input { c: Writable; } export default pattern>>(({ c }) => ({ s: computed(() => JSON.stringify(c.get())), }));`, "lift", ); expect((capture!.properties as Record).c).toEqual({ anyOf: [ { type: "object", properties: { value: { type: "number" } }, required: ["value"], }, { type: "string" }, ], default: "", asCell: ["readonly"], }); }); it("emits the instantiated declared type for an argument that names a generic type", async () => { // A node naming `Box` would be read from `Box`'s generic // declaration, with `value` unresolved. const [capture] = await schemasOf( ` type Blank = string | Default<"">; interface Box { value: T; } interface Input { c: T | Blank; } export default pattern>>(({ c }) => ({ s: computed(() => JSON.stringify(c)), }));`, "lift", ); expect((capture!.properties as Record).c).toEqual({ anyOf: [ { type: "string" }, { type: "object", properties: { value: { type: "number" } }, required: ["value"], }, ], default: "", }); }); it("emits a type argument that names a type in scope, with the default", async () => { const [capture] = await schemasOf( ` interface Input { c: Writable>; } export default pattern>(({ c }) => ({ s: computed(() => JSON.stringify(c.get())), }));`, "lift", ); expect((capture!.properties as Record).c).toEqual({ $ref: "#/$defs/Stored", default: {}, asCell: ["readonly"], }); }); it("keeps the value type of a cell whose default is an empty object", async () => { // With `Default<{}>` stripped, `Box | {}` reduces to `{}`, so the // pattern body's view of this cell holds no `value`. const [capture] = await schemasOf( ` interface Box { value?: T; } interface Input { c: Writable>; } export default pattern>>(({ c }) => ({ s: computed(() => JSON.stringify(c.get())), }));`, "lift", ); expect((capture!.properties as Record).c).toEqual({ anyOf: [ { type: "object", properties: { value: { type: ["number", "undefined"] } }, }, { type: "object", properties: {} }, ], default: {}, asCell: ["readonly"], }); }); it("keeps the value type of an argument named outside the capturing module", async () => { // `Shape` is not imported where the binding is captured, so a node // naming it would read as nothing there. const output = await transformFiles({ "/types.ts": `import type { Default } from "commonfabric"; export interface Shape { side?: number } interface Generic { v: T | Default<{}>; } export type Input = Generic;`, "/main.tsx": `import { computed, pattern } from "commonfabric"; import type { Input } from "./types.ts"; export default pattern(({ v }) => ({ s: computed(() => JSON.stringify(v)), }));`, }, { types: COMMONFABRIC_TYPES, typeCheck: true }); const [capture] = callSchemas(parseModule(output["/main.tsx"]!), "lift"); expect(capture).toEqual({ type: "object", properties: { v: { anyOf: [ { $ref: "#/$defs/Shape" }, { type: "object", properties: {} }, ], default: {}, }, }, required: ["v"], $defs: { Shape: { type: "object", properties: { side: { type: "number" } }, }, }, }); }); it("emits every branch of a union of instantiations", async () => { // One type parameter given two arguments has no single node that stands // for both, so neither branch may be substituted away. const [capture] = await schemasOf( ` type Blank = string | Default<"">; interface Input { c: T | Blank; } export default pattern | Input>(({ c }) => ({ s: computed(() => JSON.stringify(c)), }));`, "lift", ); expect((capture!.properties as Record).c).toEqual({ anyOf: [{ type: ["number", "string"] }, { type: "boolean" }], default: "", }); }); it("keeps the payload of a scope wrapper it printed, in the capture and the result", async () => { // The printed payload spells names that resolve to nothing where it is // emitted, so the wrapper's own type is what carries the default here. const output = await transformSource( `${IMPORTS} interface Box { value: T; } interface Input { c: PerUser>; } export default pattern>>(({ c }) => ({ c, s: computed(() => JSON.stringify(c)), }));`, { types: COMMONFABRIC_TYPES, typeCheck: true }, ); const root = parseModule(output); const scoped = { anyOf: [ { type: "object", properties: { value: { type: "number" } }, required: ["value"], }, { type: "object", properties: { value: { type: "number", enum: [0] } }, required: ["value"], }, ], default: { value: 0 }, scope: "user", }; const [capture] = callSchemas(root, "lift"); expect((capture!.properties as Record).c) .toEqual(scoped); expect( (patternSchemas(root).output.properties as Record).c, ).toEqual(scoped); }); for ( const [argument, items] of [ ["number", { type: "number" }], ["Box", { type: "object", properties: { value: { type: "number" } }, required: ["value"], }], ] as const ) { const source = `${IMPORTS} interface Box { value: T; } interface Input { c: PerUser>; } export default pattern>(({ c }) => ({ c, s: computed(() => JSON.stringify(c)), }));`; it(`retains the scope and default of a returned generic array of \`${argument}\``, async () => { const output = await transformSource(source, { types: COMMONFABRIC_TYPES, typeCheck: true, }); const root = parseModule(output); const scoped = { type: "array", items, default: [], scope: "user" }; const [capture] = callSchemas(root, "lift"); expect((capture!.properties as Schema).c).toEqual(scoped); expect((patternSchemas(root).output.properties as Schema).c) .toEqual(scoped); }); it(`annotates a captured generic array of \`${argument}\` with its scope wrapper`, async () => { const root = parseModule( await transformSource(source, { types: COMMONFABRIC_TYPES, typeCheck: true, }), ); const [captures] = callsNamed(root, "lift")[0]!.typeArguments!; const annotation = collect(captures!, ts.isPropertySignature) .find((member) => member.name.getText(root) === "c")!.type!; expect( ts.isTypeReferenceNode(annotation) ? annotation.typeName.getText(root) : annotation.getText(root), ).toBe("__cfHelpers.PerUser"); }); } it("keeps the argument's own type where the capturing module declares another of its name", async () => { // The printer writes the argument as a bare `Stored`, which names the // capturing module's own interface there. const output = await transformFiles({ "/types.ts": `import type { Default } from "commonfabric"; export interface Stored { remote?: string } interface Generic { v: T | Default<{}>; } export type Input = Generic;`, "/main.tsx": `import { computed, pattern } from "commonfabric"; import type { Input } from "./types.ts"; interface Stored { local: number } const unrelated: Stored = { local: 1 }; export default pattern(({ v }) => ({ s: computed(() => JSON.stringify([v, unrelated.local])), }));`, }, { types: COMMONFABRIC_TYPES, typeCheck: true }); const [capture] = callSchemas(parseModule(output["/main.tsx"]!), "lift"); expect((capture!.$defs as Record).Stored).toEqual({ type: "object", properties: { remote: { type: "string" } }, }); }); for ( const [title, input, argument, capture] of [ [ "a scope wrapper", `interface Input { c: PerUser>; }`, "Input", { type: "string", default: "", scope: "user" }, ], [ "a `Default`", `interface Input { c: Default; }`, `Input`, { type: "string", default: "x" }, ], ] as const ) { it(`instantiates a type parameter written inside ${title}`, async () => { const [lift] = await schemasOf( ` ${input} export default pattern<${argument}>(({ c }) => ({ s: computed(() => JSON.stringify(c)), }));`, "lift", ); expect((lift!.properties as Record).c) .toEqual(capture); }); } for ( const [shape, concrete] of [ ['T["value"]', { type: ["number", "string"], default: "" }], ["Writable", { default: "", anyOf: [ { type: "string" }, { type: "object", properties: { value: { type: "number" } }, required: ["value"], asCell: ["cell"], }, ], }], ["T[]", { default: "", anyOf: [ { type: "string" }, { type: "array", items: { type: "object", properties: { value: { type: "number" } }, required: ["value"], }, }, ], }], ["Box", { default: "", anyOf: [ { type: "string" }, { type: "object", properties: { nested: { type: "object", properties: { value: { type: "number" } }, required: ["value"], }, }, required: ["nested"], }, ], }], ] as const ) { it(`emits the instantiated declared type for a property containing ${shape}`, async () => { const [capture] = await schemasOf( ` type Blank = string | Default<"">; type Box = { nested: T }; interface Input { c: ${shape} | Blank; } export default pattern>(({ c }) => ({ s: computed(() => JSON.stringify(c)), }));`, "lift", ); expect((capture!.properties as Record).c).toEqual( concrete, ); }); } }); describe("a `computed()` capture of a cell declared through an alias", () => { for (const [title, type, capture] of CELL_VALUES) { it(`emits the schema of a cell written in place, for ${title}`, async () => { const [inline] = await schemasOf( computedReading(`Writable<${type}>`), "lift", ); const [aliased] = await schemasOf( `type TheCell = Writable<${type}>;\n${computedReading("TheCell")}`, "lift", ); expect((inline!.properties as Record).c) .toEqual(capture); expect(aliased).toEqual(inline); }); } it("emits the default of a value type named by an alias", async () => { // The value type is hoisted under its alias's name, as it is in the // pattern's own input schema, and the default goes with it. const [aliased] = await schemasOf( `type Blank = string | Default<"">;\n${ computedReading("Writable") }`, "lift", ); expect(aliased).toEqual({ type: "object", properties: { c: { $ref: "#/$defs/Blank", asCell: ["readonly"] } }, required: ["c"], $defs: { Blank: { type: "string", default: "" } }, }); }); it("emits the value schema and default of an alias imported from another module", async () => { // `Stored` and `Empty` are out of scope in the module that captures the // cell, so the emitted type cannot lean on their names. const output = await transformFiles({ "/types.ts": `import { Writable, type Default } from "commonfabric"; export interface Stored { readonly name?: string } export type Empty = Record; export type StoredCell = Writable>; `, "/main.tsx": `import { computed, pattern } from "commonfabric"; import type { StoredCell } from "./types.ts"; ${computedReading("StoredCell")}`, }, { types: COMMONFABRIC_TYPES }); const [aliased] = callSchemas(parseModule(output["/main.tsx"]!), "lift"); expect(aliased).toEqual({ type: "object", properties: { c: { $ref: "#/$defs/Stored", default: {}, asCell: ["readonly"] }, }, required: ["c"], $defs: { Stored: { type: "object", properties: { name: { type: "string" } } }, }, }); }); }); describe("a capture of a value declared through an alias", () => { it("emits the scope and default of an aliased `PerUser` in a `computed()`", async () => { // Whether the value's schema sits in place or under its alias's name is // schema generation's choice, so both are read from wherever it put the // schema. const [aliased] = await schemasOf( `type Nickname = PerUser>; export default pattern<{ c: Nickname }>(({ c }) => ({ s: computed(() => c.trim()), }));`, "lift", ); const c = (aliased!.properties as Record).c!; const value = typeof c.$ref === "string" ? (aliased!.$defs as Record)[ c.$ref.replace("#/$defs/", "") ]! : c; expect(value.type).toBe("string"); expect(value.default).toBe(""); expect(value.scope).toBe("user"); }); it("emits the author's own type that shares a wrapper's name as that type", async () => { const output = await transformSource( `import { computed, pattern } from "commonfabric"; type Default = { mine: T; tag?: V }; type Mine = Default; export default pattern<{ c: Mine }>(({ c }) => ({ s: computed(() => JSON.stringify(c)), }));`, { types: COMMONFABRIC_TYPES }, ); const [capture] = callSchemas(parseModule(output), "lift"); expect((capture!.$defs as Record).Mine!.properties) .toHaveProperty("mine", { type: "string" }); }); it("emits the default of an aliased cell in an `action()`", async () => { const [, state] = await schemasOf( `type Draft = Writable>; export default pattern<{ c: Draft }>(({ c }) => ({ log: action(() => { console.log(c.get()); }), }));`, "handler", ); expect((state!.properties as Record).c) .toEqual({ type: "string", default: "", asCell: ["readonly"] }); }); }); describe("a pattern result that returns an input binding", () => { it("emits the scope and default of an aliased `PerUser`", async () => { const output = await transformSource( `${IMPORTS}type Nickname = PerUser>; export default pattern<{ c: Nickname }>(({ c }) => ({ c }));`, { types: COMMONFABRIC_TYPES }, ); const result = patternSchemas(parseModule(output)).output; expect((result.properties as Record).c) .toEqual({ type: "string", default: "", scope: "user" }); }); }); });