fix: honor sibling constraints on nullability, guard numeric item enums
Copilot round 6: no visible comments, three suppressed. All valid.
1. JSON Schema keywords at one level are conjunctive, so a syntactic null does
not by itself mean the schema accepts null. `{ type: ["string", "null"],
enum: ["envio"] }` names "null" and still rejects it — yet the collapse
marked it `nullable: true`, giving the field a clear button that emits a
value the schema forbids, and `admitsNull` let required-field gating accept
it. Both now consult `nullExcludedBySiblings`.
The distinction that makes this safe: a *branch's* `enum` is not a sibling.
In `anyOf: [{ type: "string", enum: [...] }, { type: "null" }]` — the #1928
shape — the enum constrains that branch alone and the union still permits
null, so `nullable` is read off the ORIGINAL schema rather than the hoisted
merge. Reading the merge would have called the headline case non-nullable.
Flattening is unaffected either way; only the nullability claim is withheld.
2. The collapse newly routed a nullable array of numeric item enums into the
string-only `MultiSelect` path, which would submit `["1"]` for `[1]`. Gated
on `isStringEnum`, matching `toConstOptions`; `isStringEnum` is now a type
predicate so the narrowing is real rather than asserted.
3. Invalid JSON and an empty field both report `undefined`, so an optional
complex argument could be dropped from the submission while invalid text sat
visible in the box. The field now says so via `error`. Note a *required*
field was already gated — its value is `undefined`, which
`hasMissingRequiredFields` treats as missing — so this is specifically the
optional case. Disabling Execute/Open/Submit on an invalid optional draft
needs a validity channel out of `SchemaForm` into all four call sites, which
is wider than this fix; filed separately.
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
32d6f80bc1
commit
86ba42633c
1 changed files with 214 additions and 18 deletions
|
|
@ -312,6 +312,35 @@ describe("normalizeNullableUnion", () => {
|
|||
});
|
||||
});
|
||||
|
||||
// Flattening still happens — only the nullability claim is withheld, because
|
||||
// the sibling enum rules null out even though the type list names it.
|
||||
it("flattens but does not mark nullable when a sibling enum excludes null", () => {
|
||||
expect(
|
||||
normalizeNullableUnion({
|
||||
type: ["string", "null"],
|
||||
enum: ["envio", "recebimento"],
|
||||
}),
|
||||
).toEqual({
|
||||
type: "string",
|
||||
enum: ["envio", "recebimento"],
|
||||
anyOf: undefined,
|
||||
nullable: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("still marks nullable when the enum lives on an anyOf branch", () => {
|
||||
expect(
|
||||
normalizeNullableUnion({
|
||||
anyOf: [{ type: "string", enum: ["envio"] }, { type: "null" }],
|
||||
}),
|
||||
).toEqual({
|
||||
type: "string",
|
||||
enum: ["envio"],
|
||||
anyOf: undefined,
|
||||
nullable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves a mismatched enumNames alone rather than inventing an alignment", () => {
|
||||
expect(
|
||||
normalizeNullableUnion({
|
||||
|
|
@ -328,20 +357,6 @@ describe("normalizeNullableUnion", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("leaves a null-free enum untouched", () => {
|
||||
expect(
|
||||
normalizeNullableUnion({
|
||||
type: ["string", "null"],
|
||||
enum: ["a", "b"],
|
||||
}),
|
||||
).toEqual({
|
||||
type: "string",
|
||||
enum: ["a", "b"],
|
||||
anyOf: undefined,
|
||||
nullable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes type array array|null", () => {
|
||||
expect(normalizeNullableUnion({ type: ["array", "null"] })).toEqual({
|
||||
type: "array",
|
||||
|
|
@ -424,4 +439,42 @@ describe("admitsNull", () => {
|
|||
it("ignores non-object anyOf members instead of throwing", () => {
|
||||
expect(admitsNull({ anyOf: ["null", ["null"], 7] })).toBe(false);
|
||||
});
|
||||
|
||||
// JSON Schema keywords at one level are conjunctive, so a syntactic null does
|
||||
// not by itself mean the schema accepts null. `{ type: ["string","null"],
|
||||
// enum: ["envio"] }` names "null" and still rejects it.
|
||||
it("is false when a sibling enum excludes null", () => {
|
||||
expect(admitsNull({ type: ["string", "null"], enum: ["envio"] })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
admitsNull({
|
||||
anyOf: [{ type: "string" }, { type: "null" }],
|
||||
enum: ["a"],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("is true when a sibling enum offers null", () => {
|
||||
expect(
|
||||
admitsNull({ type: ["string", "null"], enum: ["envio", null] }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("is false when a sibling const pins a non-null value", () => {
|
||||
expect(admitsNull({ type: ["string", "null"], const: "envio" })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(admitsNull({ type: ["string", "null"], const: null })).toBe(true);
|
||||
});
|
||||
|
||||
// A branch's enum is scoped to that branch, not a sibling of the union — this
|
||||
// is the #1928 shape, and reading it as a sibling would call it non-nullable.
|
||||
it("is unaffected by an enum that lives inside an anyOf branch", () => {
|
||||
expect(
|
||||
admitsNull({
|
||||
anyOf: [{ type: "string", enum: ["envio"] }, { type: "null" }],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue