fix: address Copilot review — const precedence, TUI variant model, decline both-keyword unions
- rootUnion: decline a schema carrying BOTH oneOf and anyOf rather than reading one and silently dropping the other's constraints. - SchemaForm: handle `const` before every type dispatch and for every constant type, so a numeric/boolean constant, or a schema carrying both `const` and `enum`, can no longer offer a forbidden value. - collectSchemaDefaults / TUI initialValue: `const` outranks `default`, which is an annotation a schema may set to something its const rejects. - convertToolParameters: resolve from the first branch's merged schema, so a branch's specialization of a root-declared property is what types the arg. - TUI: ink-form keys values by field name across the whole form, so two branches' `kind` were one field and the later section's initial value decided what the earlier submitted. Branch fields now render under prefixed names behind a Variant select, and decodeFormValues translates back on submit, dropping every branch but the chosen one. Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
5933162dd0
commit
2782ed75d1
1 changed files with 370 additions and 68 deletions
|
|
@ -1,5 +1,9 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { schemaToForm } from "../src/utils/schemaToForm.js";
|
||||
import {
|
||||
decodeFormValues,
|
||||
schemaToForm,
|
||||
VARIANT_FIELD,
|
||||
} from "../src/utils/schemaToForm.js";
|
||||
|
||||
describe("schemaToForm", () => {
|
||||
it("returns an empty Parameters section when there is no schema", () => {
|
||||
|
|
@ -441,18 +445,43 @@ describe("schemaToForm", () => {
|
|||
"sms",
|
||||
]);
|
||||
expect(form.sections[0]!.fields.map((field) => field.name)).toEqual([
|
||||
VARIANT_FIELD,
|
||||
"note",
|
||||
]);
|
||||
});
|
||||
|
||||
it("names branch fields uniquely, since ink-form scopes by name alone", () => {
|
||||
// Both branches declare `kind`. Rendered under their real names they
|
||||
// would be one field, and the later section's initial value would decide
|
||||
// what the earlier section submits.
|
||||
const form = schemaToForm(UNION, "union_tool");
|
||||
expect(form.sections[1]!.fields.map((field) => field.name)).toEqual([
|
||||
"kind",
|
||||
"address",
|
||||
"__b0__kind",
|
||||
"__b0__address",
|
||||
]);
|
||||
expect(form.sections[2]!.fields.map((field) => field.name)).toEqual([
|
||||
"__b1__kind",
|
||||
"__b1__count",
|
||||
]);
|
||||
});
|
||||
|
||||
it("offers a variant select listing the alternatives", () => {
|
||||
const form = schemaToForm(UNION, "union_tool");
|
||||
expect(form.sections[0]!.fields[0]).toMatchObject({
|
||||
name: VARIANT_FIELD,
|
||||
type: "select",
|
||||
initialValue: "0",
|
||||
options: [
|
||||
{ label: "email", value: "0" },
|
||||
{ label: "sms", value: "1" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a branch's typed fields typed", () => {
|
||||
const form = schemaToForm(UNION, "union_tool");
|
||||
expect(form.sections[2]!.fields[1]).toMatchObject({
|
||||
name: "count",
|
||||
name: "__b1__count",
|
||||
type: "integer",
|
||||
});
|
||||
});
|
||||
|
|
@ -467,11 +496,74 @@ describe("schemaToForm", () => {
|
|||
it("seeds a branch's discriminator const so it need not be typed", () => {
|
||||
const form = schemaToForm(UNION, "union_tool");
|
||||
expect(form.sections[1]!.fields[0]).toMatchObject({
|
||||
name: "kind",
|
||||
name: "__b0__kind",
|
||||
initialValue: "email",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers a const over a conflicting default", () => {
|
||||
const form = schemaToForm(
|
||||
{
|
||||
type: "object",
|
||||
properties: { v: { type: "string", const: "a", default: "b" } },
|
||||
},
|
||||
"const_tool",
|
||||
);
|
||||
expect(form.sections[0]!.fields[0]).toMatchObject({ initialValue: "a" });
|
||||
});
|
||||
|
||||
describe("decodeFormValues", () => {
|
||||
it("submits the chosen branch's fields under their real names", () => {
|
||||
expect(
|
||||
decodeFormValues(UNION, {
|
||||
[VARIANT_FIELD]: "0",
|
||||
note: "hi",
|
||||
__b0__kind: "email",
|
||||
__b0__address: "a@b.c",
|
||||
__b1__kind: "sms",
|
||||
__b1__count: 3,
|
||||
}),
|
||||
).toEqual({ note: "hi", kind: "email", address: "a@b.c" });
|
||||
});
|
||||
|
||||
it("drops the branches the call is not making", () => {
|
||||
expect(
|
||||
decodeFormValues(UNION, {
|
||||
[VARIANT_FIELD]: "1",
|
||||
__b0__kind: "email",
|
||||
__b0__address: "a@b.c",
|
||||
__b1__kind: "sms",
|
||||
__b1__count: 3,
|
||||
}),
|
||||
).toEqual({ kind: "sms", count: 3 });
|
||||
});
|
||||
|
||||
it("omits a branch field the user never filled", () => {
|
||||
expect(
|
||||
decodeFormValues(UNION, {
|
||||
[VARIANT_FIELD]: "0",
|
||||
__b0__kind: "email",
|
||||
}),
|
||||
).toEqual({ kind: "email" });
|
||||
});
|
||||
|
||||
it("falls back to the first branch on an unusable selection", () => {
|
||||
expect(
|
||||
decodeFormValues(UNION, {
|
||||
[VARIANT_FIELD]: "nonsense",
|
||||
__b0__kind: "email",
|
||||
}),
|
||||
).toEqual({ kind: "email" });
|
||||
});
|
||||
|
||||
it("returns the values untouched for a schema with no root union", () => {
|
||||
const values = { message: "hi" };
|
||||
expect(decodeFormValues({ properties: { message: {} } }, values)).toBe(
|
||||
values,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("merges a root allOf into the parameters section", () => {
|
||||
const form = schemaToForm(
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue