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
6 changed files with 370 additions and 68 deletions
|
|
@ -2049,6 +2049,57 @@ describe("SchemaForm multiline strings (#2042)", () => {
|
|||
expect(kind.value).toBe("email");
|
||||
});
|
||||
|
||||
it("renders a non-string constant read-only too", () => {
|
||||
// Reached before the number/boolean dispatch, so neither offers a value
|
||||
// the `const` forbids.
|
||||
renderWithMantine(
|
||||
<SchemaForm
|
||||
schema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
n: { type: "number", const: 7, title: "N" },
|
||||
b: { type: "boolean", const: true, title: "B" },
|
||||
},
|
||||
}}
|
||||
values={{}}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
(screen.getByRole("textbox", { name: /N/ }) as HTMLInputElement).value,
|
||||
).toBe("7");
|
||||
expect(
|
||||
(screen.getByRole("textbox", { name: /B/ }) as HTMLInputElement).value,
|
||||
).toBe("true");
|
||||
});
|
||||
|
||||
it("keeps a const out of an enum select", () => {
|
||||
// A schema carrying both would otherwise reach the select and offer the
|
||||
// enum's other members, each of which the `const` rejects.
|
||||
renderWithMantine(
|
||||
<SchemaForm
|
||||
schema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
mode: {
|
||||
type: "string",
|
||||
const: "fast",
|
||||
enum: ["fast", "slow"],
|
||||
title: "Mode",
|
||||
},
|
||||
},
|
||||
}}
|
||||
values={{}}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const mode = screen.getByRole("textbox", {
|
||||
name: /Mode/,
|
||||
}) as HTMLInputElement;
|
||||
expect(mode.readOnly).toBe(true);
|
||||
expect(mode.value).toBe("fast");
|
||||
});
|
||||
|
||||
it("renders no picker for a single-branch union but still shows its fields", () => {
|
||||
const schema: InspectorFormSchema = {
|
||||
type: "object",
|
||||
|
|
|
|||
|
|
@ -712,6 +712,35 @@ export function SchemaForm({
|
|||
const description = fieldSchema.description;
|
||||
const rawValue = resolveValue(values[fieldName], fieldSchema);
|
||||
|
||||
// A field pinned to a single value, checked **before** any type dispatch.
|
||||
// `const` admits exactly one value, so every widget below it — the enum
|
||||
// select, the number box, the checkbox — would offer values the schema
|
||||
// forbids, and a schema carrying both `const` and `enum` would otherwise
|
||||
// reach the select and submit a sibling of the one legal answer. Rendered
|
||||
// read-only rather than editable for the same reason.
|
||||
//
|
||||
// Display only: the value that is *submitted* comes from `values`, seeded
|
||||
// from the same `const` by `collectSchemaDefaults`, so a non-string
|
||||
// constant keeps its type on the wire however it is shown here.
|
||||
if (fieldSchema.const !== undefined) {
|
||||
const constValue = fieldSchema.const;
|
||||
return (
|
||||
<TextInput
|
||||
key={fieldName}
|
||||
label={label}
|
||||
description={description}
|
||||
withAsterisk={isRequired}
|
||||
readOnly
|
||||
disabled={disabled}
|
||||
value={
|
||||
typeof constValue === "string"
|
||||
? constValue
|
||||
: serializeJson(constValue)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// string with enum
|
||||
if (fieldSchema.type === "string" && fieldSchema.enum) {
|
||||
return (
|
||||
|
|
@ -750,26 +779,6 @@ export function SchemaForm({
|
|||
);
|
||||
}
|
||||
|
||||
// A string pinned to a single value. Rendered read-only rather than as an
|
||||
// editable box: `const` admits exactly one value, so anything the user
|
||||
// could type into it produces a call the schema rejects. This is what a
|
||||
// discriminated union's `kind`/`by` field is (#2123), and the picker has
|
||||
// already set it — but the rule is the keyword's, not the union's, so it
|
||||
// holds for a lone `const` property too.
|
||||
if (typeof fieldSchema.const === "string") {
|
||||
return (
|
||||
<TextInput
|
||||
key={fieldName}
|
||||
label={label}
|
||||
description={description}
|
||||
withAsterisk={isRequired}
|
||||
readOnly
|
||||
disabled={disabled}
|
||||
value={fieldSchema.const}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// plain string
|
||||
if (fieldSchema.type === "string") {
|
||||
const clearButton = rawValue ? (
|
||||
|
|
|
|||
|
|
@ -104,6 +104,25 @@ describe("JSON Utils", () => {
|
|||
).toEqual({ note: "hi", count: 42, enabled: true });
|
||||
});
|
||||
|
||||
it("prefers a branch's specialization of a root property (#2123)", () => {
|
||||
const specializing: Tool = {
|
||||
name: "specializing",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
// The root declares the name but constrains nothing; the branch is
|
||||
// what says it is a number.
|
||||
properties: { count: {} },
|
||||
anyOf: [
|
||||
{ type: "object", properties: { count: { type: "number" } } },
|
||||
{ type: "object", properties: { other: { type: "string" } } },
|
||||
],
|
||||
},
|
||||
};
|
||||
expect(convertToolParameters(specializing, { count: "3" })).toEqual({
|
||||
count: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("coerces a value whose schema lives on a root allOf branch (#2123)", () => {
|
||||
const allOfTool: Tool = {
|
||||
name: "allof-tool",
|
||||
|
|
|
|||
|
|
@ -63,13 +63,17 @@ describe("resolveRootUnion", () => {
|
|||
expect(branches[0].schema.anyOf).toBeUndefined();
|
||||
});
|
||||
|
||||
it("prefers oneOf when a schema carries both", () => {
|
||||
const { branches } = resolveRootUnion({
|
||||
it("declines a schema carrying both oneOf and anyOf", () => {
|
||||
// Independent keywords a value satisfies together, so reading one and
|
||||
// dropping the other would build a form missing real constraints.
|
||||
const { base, branches } = resolveRootUnion({
|
||||
type: "object",
|
||||
properties: { note: { type: "string" } },
|
||||
oneOf: [EMAIL],
|
||||
anyOf: [EMAIL, SMS],
|
||||
});
|
||||
expect(branches).toHaveLength(1);
|
||||
expect(branches).toEqual([]);
|
||||
expect(Object.keys(base.properties ?? {})).toEqual(["note"]);
|
||||
});
|
||||
|
||||
it("merges allOf branches unconditionally", () => {
|
||||
|
|
|
|||
|
|
@ -382,7 +382,10 @@ describe("root composition (#2123)", () => {
|
|||
).toEqual({ version: "1" });
|
||||
});
|
||||
|
||||
it("prefers an explicit default over a const", () => {
|
||||
it("prefers a const over a conflicting default", () => {
|
||||
// `default` is an annotation, not a constraint, so a schema may advertise
|
||||
// one its own `const` rejects — seeding it would submit an invalid value
|
||||
// through a read-only field.
|
||||
expect(
|
||||
collectSchemaDefaults({
|
||||
type: "object",
|
||||
|
|
@ -390,7 +393,7 @@ describe("root composition (#2123)", () => {
|
|||
v: { type: "string", const: "a", default: "b" },
|
||||
},
|
||||
}),
|
||||
).toEqual({ v: "b" });
|
||||
).toEqual({ v: "a" });
|
||||
});
|
||||
|
||||
it("collects defaults from a root allOf", () => {
|
||||
|
|
|
|||
|
|
@ -139,13 +139,19 @@ export function collectSchemaDefaults(
|
|||
// without this the form would *display* a hoisted default that never
|
||||
// reached the seeded values — the field would submit empty (#1928).
|
||||
const fieldSchema = normalizeNullableUnion(rawSchema);
|
||||
if (fieldSchema.default !== undefined) {
|
||||
result[fieldName] = fieldSchema.default;
|
||||
} else if (fieldSchema.const !== undefined) {
|
||||
if (fieldSchema.const !== undefined) {
|
||||
// `const` is a one-value enumeration, so the only submittable value is
|
||||
// already known — seeding it spares the user hand-typing a discriminator
|
||||
// the schema has fixed (#2123), and matches what a `default` would do.
|
||||
// the schema has fixed (#2123).
|
||||
//
|
||||
// It outranks `default`, which JSON Schema defines as an annotation
|
||||
// rather than a constraint: a schema may advertise a default its own
|
||||
// `const` rejects, and seeding that would submit an invalid argument
|
||||
// through a field rendered read-only, leaving the user no way to correct
|
||||
// it.
|
||||
result[fieldName] = fieldSchema.const;
|
||||
} else if (fieldSchema.default !== undefined) {
|
||||
result[fieldName] = fieldSchema.default;
|
||||
} else if (fieldSchema.type === "object" && fieldSchema.properties) {
|
||||
const nested = collectSchemaDefaults(fieldSchema);
|
||||
if (Object.keys(nested).length > 0) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue