pi-mono/packages/chord/test/contracts/declare.test.ts
Mario Zechner e9c0ff4d84 feat(chord): Chord v2 is the package's root
src/next is src (the main export), src/next-node is src/node with short subpaths (/http, /workers, /plugins,
/plugins/runtime, /app, /surfaces, /web, /react, /tui, /tui/launcher); the foundations get /json and /watch;
Chord v1 moves to src/old (/old, /old/node) until p2 runs on v2; tests and docs move likewise (test/, docs/,
test/old, docs/old). Durable and env import the foundations from their subpaths.
2026-11-02 22:36:55 +00:00

288 lines
9.3 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { arr, evolve, hostKind, int, keyed, local, method, obj, remote, str } from "../../src/contracts/index.ts";
import { currentVariants, LOCAL } from "../../src/contracts/internal.ts";
const Note = obj({ text: str() });
const Server = hostKind("server");
const hostAddress = () => Server();
/** Declares through an untyped door, as a JavaScript caller would. */
const untyped = {
remote: remote as (name: unknown, declaration: unknown) => unknown,
local: local as (name: unknown, declaration: unknown) => unknown,
};
describe("declaration checks", () => {
it("accept a remote and a local declaration", () => {
const Notes = remote("notes", {
version: 1,
params: { session: str() },
hostAddress,
variants: {
editor: { state: { notes: arr(Note) }, methods: { add: method(Note, str()), clear: method() } },
reader: { state: { notes: arr(Note) } },
},
});
expect(Notes).toMatchObject({ kind: "remote", name: "notes", version: 1 });
expect(Notes.fingerprint).toMatch(/^[0-9a-f]{16}$/);
const Info = local("app.session-info", {
version: 3,
params: {},
members: { init: obj({ title: str() }), clear: method() },
});
expect(Info).toMatchObject({ kind: "local", name: "app.session-info", version: 3 });
});
// Each case declares a valid `hostAddress` unless it checks that one, so the check it names is the one reached.
it.each([
[
"empty name",
"",
{ version: 1, params: {}, hostAddress, variants: { a: {} } },
'A contract name "": expected lowercase',
],
[
"name not a string",
1,
{ version: 1, params: {}, hostAddress, variants: { a: {} } },
"A contract name 1: expected lowercase",
],
[
"an uppercase name",
"Notes",
{ version: 1, params: {}, hostAddress, variants: { a: {} } },
"expected lowercase",
],
[
"a name with a colon",
"a:b",
{ version: 1, params: {}, hostAddress, variants: { a: {} } },
"expected lowercase",
],
[
"version zero",
"n",
{ version: 0, params: {}, hostAddress, variants: { a: {} } },
"n.version: expected a positive integer",
],
[
"fractional version",
"n",
{ version: 1.5, params: {}, hostAddress, variants: { a: {} } },
"n.version: expected a positive integer",
],
[
"unsafe version",
"n",
{ version: 2 ** 53, params: {}, hostAddress, variants: { a: {} } },
"n.version: expected a positive integer",
],
[
"unknown declaration key",
"n",
{ version: 1, params: {}, hostAddress, variants: { a: {} }, place: "x" },
'n: unknown key "place"',
],
[
"a host address that is no function",
"n",
{ version: 1, params: {}, variants: { a: {} }, hostAddress: "x" },
"n.hostAddress: expected a function of the params",
],
[
"no host address",
"n",
{ version: 1, params: {}, variants: { a: {} } },
"n.hostAddress: expected a function of the params",
],
[
"no variants",
"n",
{ version: 1, params: {}, hostAddress, variants: {} },
"n.variants: at least one variant is required",
],
["missing params", "n", { version: 1, hostAddress, variants: { a: {} } }, "n.params: expected an object"],
[
"unknown variant key",
"n",
{ version: 1, params: {}, hostAddress, variants: { a: { members: {} } } },
'n.a: unknown key "members"',
],
[
"a schema under methods",
"n",
{ version: 1, params: {}, hostAddress, variants: { a: { methods: { m: str() } } } },
"n.a.methods.m: expected method(...)",
],
[
"a method under state",
"n",
{ version: 1, params: {}, hostAddress, variants: { a: { state: { s: method() } } } },
"n.a.state.s: a method belongs in methods",
],
[
"bad state schema",
"n",
{ version: 1, params: {}, hostAddress, variants: { a: { state: { s: { not: {} } } } } },
'n.a.state.s: unsupported keyword "not"',
],
[
"open arguments",
"n",
{
version: 1,
params: {},
hostAddress,
variants: { a: { methods: { m: method({ type: "object", properties: {} }) } } },
},
"n.a.methods.m.args: argument objects must be closed",
],
[
"bad result",
"n",
{ version: 1, params: {}, hostAddress, variants: { a: { methods: { m: method(undefined, { not: {} }) } } } },
'n.a.methods.m.result: unsupported keyword "not"',
],
])("remote refuses %s", (_case, name, declaration, message) => {
expect(() => untyped.remote(name, declaration)).toThrow(message);
});
it.each([
["unknown key", { version: 1, params: {}, members: {}, variants: {} }, 'i: unknown key "variants"'],
["a host address", { version: 1, params: {}, members: {}, hostAddress }, 'i: unknown key "hostAddress"'],
[
"bad member schema",
{ version: 1, params: {}, members: { s: { not: {} } } },
'i.members.s: unsupported keyword "not"',
],
[
"open arguments",
{ version: 1, params: {}, members: { m: method({ type: "object", properties: {} }) } },
"i.members.m.args: argument objects must be closed",
],
])("local refuses %s", (_case, declaration, message) => {
expect(() => untyped.local("i", declaration)).toThrow(message);
});
it("refuse a remote declaration without a host address at the type level", () => {
expect(() =>
// @ts-expect-error: every remote type declares its host address
remote("n", { version: 1, params: {}, variants: { a: {} } }),
).toThrow("n.hostAddress: expected a function of the params");
});
it("does not check state for closed objects", () => {
expect(() =>
local("i", {
version: 1,
params: {},
members: { s: { type: "object", properties: { a: str() }, required: ["a"] } },
}),
).not.toThrow();
});
});
describe("D11: members across variants", () => {
it("refuses a name that is state in one variant and a method in another", () => {
expect(() =>
remote("n", {
version: 1,
params: {},
hostAddress,
// @ts-expect-error: `x` is a method in variant b
variants: { a: { state: { x: str() } }, b: { methods: { x: method() } } },
}),
).toThrow('n.b.methods.x: "x" is a state member; its kind never changes');
});
it("refuses one name as state and method within a variant", () => {
expect(() =>
remote("n", {
version: 1,
params: {},
hostAddress,
// @ts-expect-error: `x` is both state and a method
variants: { a: { state: { x: str() }, methods: { x: method() } } },
}),
).toThrow('n.a.methods.x: "x" is a state member');
});
it("refuses a method with different signatures in two variants", () => {
expect(() =>
remote("n", {
version: 1,
params: {},
hostAddress,
// @ts-expect-error: `m` takes a string in a and an integer in b
variants: { a: { methods: { m: method(str()) } }, b: { methods: { m: method(int()) } } },
}),
).toThrow("n.b.methods.m: a method has the same signature in every variant");
expect(() =>
remote("n", {
version: 1,
params: {},
hostAddress,
variants: {
a: { methods: { m: method(str(), str()) } },
b: { methods: { m: method(str({ max: 3 }), str()) } },
},
}),
).toThrow("n.b.methods.m: a method has the same signature in every variant");
});
it("allows state schemas to differ per variant (redaction)", () => {
expect(() =>
remote("n", {
version: 1,
params: {},
hostAddress,
variants: {
editor: { state: { notes: arr(obj({ text: str(), author: str() })) }, methods: { m: method(str()) } },
reader: { state: { notes: arr(obj({ text: str() })) }, methods: { m: method(str()) } },
},
}),
).not.toThrow();
});
});
describe("reserved member names", () => {
it.each(["then", "release", "variant", "state", "retry"])("refuses %s in remote, local and evolve", (name) => {
expect(() =>
remote("n", { version: 1, params: {}, hostAddress, variants: { v: { state: { [name]: str() } } } }),
).toThrow(`n.v.state.${name}: "${name}" is reserved: a reference has it`);
expect(() => local("n", { version: 1, params: {}, members: { [name]: method() } })).toThrow(
`n.members.${name}: "${name}" is reserved: a reference has it`,
);
const base = local("n", { version: 1, params: {}, members: {} });
expect(() => evolve(base, (e) => e.addState(name, str()))).toThrow(
`n v2.members.${name}: "${name}" is reserved: a reference has it`,
);
});
});
describe("keyed members (slice 6a §12)", () => {
it("declare a keyed member in a local contract: its own kind and a node of its own in the fingerprint", () => {
const Keyed = local("app.store", { version: 1, params: {}, members: { notes: keyed(Note), add: method() } });
const State = local("app.store", { version: 1, params: {}, members: { notes: Note, add: method() } });
const variant = currentVariants(Keyed).get(LOCAL)!;
expect([[...variant.keyed.keys()], [...variant.state.keys()]]).toEqual([["notes"], []]);
expect(Keyed.fingerprint).not.toBe(State.fingerprint);
// Evolving the contract carries its keyed members over unchanged (their versions are slice 7's).
const next = evolve(Keyed, (e) => e.addMethod("clear", str(), str()));
expect(currentVariants(next).get(LOCAL)!.keyed.has("notes")).toBe(true);
});
it("refuse a keyed member in a remote contract, at the type level and at declaration", () => {
expect(() =>
remote("app.notes", {
version: 1,
params: {},
hostAddress,
// @ts-expect-error keyed members are only in local contracts
variants: { editor: { state: { notes: keyed(Note) } } },
}),
).toThrow("app.notes.editor.state.notes: keyed members are only in local contracts");
});
});