Closes #2015, folded into #1928 rather than left as a follow-up. The TUI's `schemaToForm` has the same defect the web `SchemaForm` had: its local `JsonSchemaProperty` declares only `type?: string` — no `anyOf`, no array-valued `type` — so a `.nullish()` argument has no top-level `type` or `enum` for the dispatcher to read and falls to the `switch` default, losing its select. Milder than the web bug (a plain text field, not a corrupting JSON textarea), but the same class. Fixing it in a second place is the argument for not having the logic in a first place. So the collapse moves to `core/json/nullableUnion.ts` as `normalizeNullableUnion`, generic over a minimal `NullableUnionSchema` shape — the least a caller must expose to be *recognized* as a nullable union — so neither client has to adopt the other's schema type. Both form builders now share one copy and cannot drift on which schemas they render. - `core/json/nullableUnion.ts` — the shared collapse, plus tests at `clients/web/src/test/core/nullableUnion.test.ts` (the web suite is what gates `core/`). - `clients/web`: `schemaUtils.normalizeUnionType` is gone; `SchemaForm` and `resolveRefsInMessage` call the core function directly. - `clients/tui`: `schemaToForm` normalizes each property before dispatch, and `JsonSchemaProperty` grows the `anyOf` / array-`type` fields the encoding uses. Verified by driving the built TUI under a pty against the `nullable-fields-http.json` showcase server. Before, every argument of `record_shipment` rendered as a plain text field; after, `direction` is a select ("No value"), `quantity` an integer, `express` a boolean ("[Not set]"). Signed-off-by: cliffhall <cliff@futurescale.com>
329 lines
9.7 KiB
TypeScript
329 lines
9.7 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { schemaToForm } from "../src/utils/schemaToForm.js";
|
|
|
|
describe("schemaToForm", () => {
|
|
it("returns an empty Parameters section when there is no schema", () => {
|
|
const form = schemaToForm(null, "noParams");
|
|
expect(form.title).toBe("Test Tool: noParams");
|
|
expect(form.sections).toEqual([{ title: "Parameters", fields: [] }]);
|
|
});
|
|
|
|
it("returns an empty Parameters section when properties are absent", () => {
|
|
const form = schemaToForm({}, "empty");
|
|
expect(form.sections[0]?.fields).toEqual([]);
|
|
});
|
|
|
|
it("treats a non-object property value as an empty schema instead of throwing", () => {
|
|
// A malformed server schema whose property value is null/primitive must not
|
|
// crash — the field degrades to a plain string input labelled by its key.
|
|
const form = schemaToForm(
|
|
{ properties: { bad: null, worse: 42 } },
|
|
"malformed",
|
|
);
|
|
const fields = form.sections[0]?.fields ?? [];
|
|
expect(fields).toHaveLength(2);
|
|
expect(
|
|
fields.map((f) => ({ name: f.name, type: f.type, label: f.label })),
|
|
).toEqual([
|
|
{ name: "bad", type: "string", label: "bad" },
|
|
{ name: "worse", type: "string", label: "worse" },
|
|
]);
|
|
});
|
|
|
|
it("maps each JSON Schema type to the matching ink-form field type", () => {
|
|
const form = schemaToForm(
|
|
{
|
|
properties: {
|
|
name: { type: "string", title: "Name" },
|
|
age: { type: "integer", minimum: 0, maximum: 120 },
|
|
ratio: { type: "number", minimum: 0, maximum: 1 },
|
|
active: { type: "boolean" },
|
|
mystery: { type: "object" },
|
|
},
|
|
required: ["name"],
|
|
},
|
|
"typed",
|
|
);
|
|
|
|
const fields = form.sections[0]!.fields;
|
|
const byName = Object.fromEntries(fields.map((f) => [f.name, f]));
|
|
|
|
expect(byName.name).toMatchObject({
|
|
type: "string",
|
|
label: "Name",
|
|
required: true,
|
|
});
|
|
expect(byName.age).toMatchObject({ type: "integer", min: 0, max: 120 });
|
|
expect(byName.ratio).toMatchObject({ type: "float", min: 0, max: 1 });
|
|
expect(byName.active).toMatchObject({ type: "boolean" });
|
|
// Unknown types fall back to string, and label falls back to the key.
|
|
expect(byName.mystery).toMatchObject({ type: "string", label: "mystery" });
|
|
});
|
|
|
|
it("builds a select field from an enum", () => {
|
|
const form = schemaToForm(
|
|
{ properties: { color: { type: "string", enum: ["red", "blue"] } } },
|
|
"enum",
|
|
);
|
|
expect(form.sections[0]!.fields[0]).toMatchObject({
|
|
type: "select",
|
|
options: [
|
|
{ label: "red", value: "red" },
|
|
{ label: "blue", value: "blue" },
|
|
],
|
|
});
|
|
});
|
|
|
|
it("builds a select field from an array-of-enum on items.enum alone", () => {
|
|
const form = schemaToForm(
|
|
{
|
|
properties: {
|
|
// Standard array-of-enums shape: options come from `items.enum` with
|
|
// NO top-level `enum`. The array branch keys on `items.enum` alone
|
|
// (matching the web guard), so this renders as a select.
|
|
tags: {
|
|
type: "array",
|
|
items: { enum: ["a", "b"] },
|
|
},
|
|
},
|
|
},
|
|
"arrayEnum",
|
|
);
|
|
expect(form.sections[0]!.fields[0]).toMatchObject({
|
|
type: "select",
|
|
options: [
|
|
{ label: "a", value: "a" },
|
|
{ label: "b", value: "b" },
|
|
],
|
|
});
|
|
});
|
|
|
|
it("still builds a select for an array-of-enum that also carries a top-level enum", () => {
|
|
const form = schemaToForm(
|
|
{
|
|
properties: {
|
|
tags: {
|
|
type: "array",
|
|
enum: ["a", "b"],
|
|
items: { enum: ["a", "b"] },
|
|
},
|
|
},
|
|
},
|
|
"arrayEnumRedundant",
|
|
);
|
|
expect(form.sections[0]!.fields[0]).toMatchObject({
|
|
type: "select",
|
|
options: [
|
|
{ label: "a", value: "a" },
|
|
{ label: "b", value: "b" },
|
|
],
|
|
});
|
|
});
|
|
|
|
it("uses enumNames as single-select labels while keeping raw values", () => {
|
|
const form = schemaToForm(
|
|
{
|
|
properties: {
|
|
pet: {
|
|
type: "string",
|
|
enum: ["pet-1", "pet-2"],
|
|
enumNames: ["Cats", "Dogs"],
|
|
},
|
|
},
|
|
},
|
|
"titledEnum",
|
|
);
|
|
expect(form.sections[0]!.fields[0]).toMatchObject({
|
|
type: "select",
|
|
options: [
|
|
{ label: "Cats", value: "pet-1" },
|
|
{ label: "Dogs", value: "pet-2" },
|
|
],
|
|
});
|
|
});
|
|
|
|
it("falls back to raw single-select labels when enumNames length mismatches", () => {
|
|
const form = schemaToForm(
|
|
{
|
|
properties: {
|
|
pet: {
|
|
type: "string",
|
|
enum: ["pet-1", "pet-2"],
|
|
// Only one name for two values — a wrong-length zip would
|
|
// mislabel, so the raw values are used as labels.
|
|
enumNames: ["Cats"],
|
|
},
|
|
},
|
|
},
|
|
"mismatchedEnum",
|
|
);
|
|
expect(form.sections[0]!.fields[0]).toMatchObject({
|
|
options: [
|
|
{ label: "pet-1", value: "pet-1" },
|
|
{ label: "pet-2", value: "pet-2" },
|
|
],
|
|
});
|
|
});
|
|
|
|
it("uses items.enumNames as array-of-enum labels while keeping raw values", () => {
|
|
const form = schemaToForm(
|
|
{
|
|
properties: {
|
|
pets: {
|
|
type: "array",
|
|
items: { enum: ["pet-1", "pet-2"], enumNames: ["Cats", "Dogs"] },
|
|
},
|
|
},
|
|
},
|
|
"titledArrayEnum",
|
|
);
|
|
expect(form.sections[0]!.fields[0]).toMatchObject({
|
|
type: "select",
|
|
options: [
|
|
{ label: "Cats", value: "pet-1" },
|
|
{ label: "Dogs", value: "pet-2" },
|
|
],
|
|
});
|
|
});
|
|
|
|
it("falls back to raw array-of-enum labels when items.enumNames length mismatches", () => {
|
|
const form = schemaToForm(
|
|
{
|
|
properties: {
|
|
pets: {
|
|
type: "array",
|
|
items: { enum: ["pet-1", "pet-2"], enumNames: ["Cats"] },
|
|
},
|
|
},
|
|
},
|
|
"mismatchedArrayEnum",
|
|
);
|
|
expect(form.sections[0]!.fields[0]).toMatchObject({
|
|
options: [
|
|
{ label: "pet-1", value: "pet-1" },
|
|
{ label: "pet-2", value: "pet-2" },
|
|
],
|
|
});
|
|
});
|
|
|
|
it("carries a JSON Schema default through as the field's initialValue", () => {
|
|
const form = schemaToForm(
|
|
{ properties: { greeting: { type: "string", default: "hi" } } },
|
|
"withDefault",
|
|
);
|
|
expect(form.sections[0]!.fields[0]).toMatchObject({ initialValue: "hi" });
|
|
});
|
|
|
|
// #2015 (the TUI twin of #1928): an argument declared with Zod's `.nullish()`
|
|
// has no top-level `type`/`enum` — they sit on the surviving `anyOf` branch —
|
|
// so before normalization every one of these degraded to a plain text field.
|
|
describe("nullable unions", () => {
|
|
it("renders a select for an anyOf string-enum|null argument", () => {
|
|
const form = schemaToForm(
|
|
{
|
|
properties: {
|
|
direction: {
|
|
anyOf: [
|
|
{ type: "string", enum: ["envio", "recebimento"] },
|
|
{ type: "null" },
|
|
],
|
|
},
|
|
},
|
|
},
|
|
"nullableEnum",
|
|
);
|
|
expect(form.sections[0]!.fields[0]).toMatchObject({
|
|
name: "direction",
|
|
type: "select",
|
|
options: [
|
|
{ label: "envio", value: "envio" },
|
|
{ label: "recebimento", value: "recebimento" },
|
|
],
|
|
});
|
|
});
|
|
|
|
it("maps the remaining nullable scalar branches to their typed fields", () => {
|
|
const form = schemaToForm(
|
|
{
|
|
properties: {
|
|
reference: { anyOf: [{ type: "string" }, { type: "null" }] },
|
|
quantity: {
|
|
anyOf: [
|
|
{ type: "integer", minimum: 1, maximum: 9 },
|
|
{ type: "null" },
|
|
],
|
|
},
|
|
ratio: { anyOf: [{ type: "number" }, { type: "null" }] },
|
|
express: { anyOf: [{ type: "boolean" }, { type: "null" }] },
|
|
},
|
|
},
|
|
"nullableScalars",
|
|
);
|
|
const fields = form.sections[0]!.fields;
|
|
expect(fields.map((f) => ({ name: f.name, type: f.type }))).toEqual([
|
|
{ name: "reference", type: "string" },
|
|
{ name: "quantity", type: "integer" },
|
|
{ name: "ratio", type: "float" },
|
|
{ name: "express", type: "boolean" },
|
|
]);
|
|
// The branch's own constraints are hoisted along with its type.
|
|
expect(fields[1]).toMatchObject({ min: 1, max: 9 });
|
|
});
|
|
|
|
it("renders a select for a nullable array-of-enum argument", () => {
|
|
const form = schemaToForm(
|
|
{
|
|
properties: {
|
|
tags: {
|
|
anyOf: [
|
|
{ type: "array", items: { enum: ["a", "b"] } },
|
|
{ type: "null" },
|
|
],
|
|
},
|
|
},
|
|
},
|
|
"nullableArrayEnum",
|
|
);
|
|
expect(form.sections[0]!.fields[0]).toMatchObject({
|
|
type: "select",
|
|
options: [
|
|
{ label: "a", value: "a" },
|
|
{ label: "b", value: "b" },
|
|
],
|
|
});
|
|
});
|
|
|
|
it("collapses the type: [T, null] encoding too", () => {
|
|
const form = schemaToForm(
|
|
{
|
|
properties: {
|
|
note: { type: ["string", "null"] },
|
|
flag: { type: ["boolean", "null"] },
|
|
},
|
|
},
|
|
"typeArrayNull",
|
|
);
|
|
expect(
|
|
form.sections[0]!.fields.map((f) => ({ name: f.name, type: f.type })),
|
|
).toEqual([
|
|
{ name: "note", type: "string" },
|
|
{ name: "flag", type: "boolean" },
|
|
]);
|
|
});
|
|
|
|
it("leaves a union of two real types as a plain string field", () => {
|
|
const form = schemaToForm(
|
|
{
|
|
properties: {
|
|
mixed: { anyOf: [{ type: "string" }, { type: "number" }] },
|
|
},
|
|
},
|
|
"realUnion",
|
|
);
|
|
expect(form.sections[0]!.fields[0]).toMatchObject({
|
|
name: "mixed",
|
|
type: "string",
|
|
});
|
|
});
|
|
});
|
|
});
|