fix: address Copilot review rounds 7 and 8
- rootUnion: a `oneOf` demands that EXACTLY one alternative match, which flattening cannot preserve, so it is offered only when a discriminator makes the alternatives mutually exclusive; `anyOf` makes no such claim and is unaffected. An empty `additionalProperties` schema is the equivalent of `true` and no longer declines a union. - selectBranchIndex: falls back to the branch whose own required fields the values supply, so an undiscriminated union's values open the picker on the shape they describe rather than on the first branch. - admitsNull: honors a sibling `nullable: true` beside `const: null`. - SchemaForm: a value is carried only where the incoming branch leaves the root's declaration as it found it; anything it declares itself is reset, since it may type the name differently from where the value was typed. `hasOwn`/`fromEntries` throughout, so `constructor` is not read as a supplied value and `__proto__` is not dropped into the prototype setter. - TUI: the same prototype-safe construction on the way out of the form, and the chosen branch's `required` list is checked at submit — rendering a branch's fields optional makes the FORM satisfiable, not the call, so the missing names are reported rather than a known-invalid call being sent. Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
c15e30414f
commit
85b8e4601c
2 changed files with 492 additions and 42 deletions
|
|
@ -739,6 +739,12 @@ describe("admitsNull", () => {
|
|||
expect(admitsNull({ type: ["string", "null"], const: null })).toBe(true);
|
||||
});
|
||||
|
||||
it("honors a sibling `nullable` flag", () => {
|
||||
expect(admitsNull({ type: "string", nullable: true, const: null })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not override a sibling that rejects null", () => {
|
||||
// `const` is conjunctive with its siblings, not an override: both of
|
||||
// these reject every value, so claiming nullability would let the
|
||||
|
|
|
|||
|
|
@ -327,6 +327,16 @@ describe("resolveRootUnion", () => {
|
|||
expect(branches).toEqual([]);
|
||||
});
|
||||
|
||||
it("treats an empty additionalProperties schema as permissive", () => {
|
||||
// `{}` is the JSON Schema equivalent of `true` — it constrains nothing.
|
||||
const { branches } = resolveRootUnion({
|
||||
type: "object",
|
||||
additionalProperties: {},
|
||||
anyOf: [EMAIL, SMS],
|
||||
});
|
||||
expect(branches).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("allows a restrictive additionalProperties the branches stay within", () => {
|
||||
const { branches } = resolveRootUnion({
|
||||
type: "object",
|
||||
|
|
@ -435,6 +445,66 @@ describe("resolveRootUnion", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("oneOf exclusivity", () => {
|
||||
it("offers a discriminated oneOf", () => {
|
||||
expect(
|
||||
resolveRootUnion({ type: "object", oneOf: [EMAIL, SMS] }).branches,
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("declines a oneOf whose alternatives are not mutually exclusive", () => {
|
||||
// `oneOf` demands that EXACTLY one alternative match; flattening offers
|
||||
// them as if any would do. Here entering `a` satisfies both, so a call
|
||||
// the form calls valid is one the server refuses.
|
||||
const { branches } = resolveRootUnion({
|
||||
type: "object",
|
||||
oneOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: { a: { type: "string" } },
|
||||
required: ["a"],
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
properties: { a: { type: "string" }, b: { type: "string" } },
|
||||
required: ["a"],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(branches).toEqual([]);
|
||||
});
|
||||
|
||||
it("still offers the same alternatives under anyOf", () => {
|
||||
// `anyOf` makes no exclusivity claim, so overlapping alternatives are
|
||||
// exactly what it means.
|
||||
const { branches } = resolveRootUnion({
|
||||
type: "object",
|
||||
anyOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: { a: { type: "string" } },
|
||||
required: ["a"],
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
properties: { a: { type: "string" }, b: { type: "string" } },
|
||||
required: ["a"],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(branches).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("declines a oneOf whose named discriminator does not distinguish", () => {
|
||||
const { branches } = resolveRootUnion({
|
||||
type: "object",
|
||||
discriminator: { propertyName: "kind" },
|
||||
oneOf: [EMAIL, { ...EMAIL, properties: { ...EMAIL.properties } }],
|
||||
});
|
||||
expect(branches).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unions it declines to offer", () => {
|
||||
// Each of these would produce a picker with an option that renders
|
||||
// nothing, which is the failure this module exists to prevent.
|
||||
|
|
@ -572,6 +642,29 @@ describe("resolveRootUnion", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("falls back to the branch whose required fields the values supply", () => {
|
||||
// An undiscriminated union still has shapes, and the required-field gate
|
||||
// accepts any satisfied branch — so the picker must open on the one the
|
||||
// values satisfy rather than showing a shape they do not describe.
|
||||
const undiscriminated = resolveRootUnion({
|
||||
type: "object",
|
||||
anyOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: { address: { type: "string" } },
|
||||
required: ["address"],
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
properties: { phone: { type: "string" } },
|
||||
required: ["phone"],
|
||||
},
|
||||
],
|
||||
}).branches;
|
||||
expect(selectBranchIndex(undiscriminated, { phone: "555" })).toBe(1);
|
||||
expect(selectBranchIndex(undiscriminated, {})).toBeNull();
|
||||
});
|
||||
|
||||
it("reports none when the values identify nothing", () => {
|
||||
expect(selectBranchIndex(branches, {})).toBeNull();
|
||||
expect(selectBranchIndex(branches, { kind: "other" })).toBeNull();
|
||||
|
|
|
|||
Loading…
Reference in a new issue