- The seeding effect re-applies constants as well as filling absences: an in-place schema change can move a `const` the user cannot edit, and the read-only field then displayed the new value while `values` still held the old one — which is what would be submitted. Ordinary defaults are still only ever added, so an edited field keeps what the user put there. - hasMissingIn checks own-property presence: a required argument legally named `constructor` resolved to the inherited one and read as supplied, enabling a submit the schema rejects. - The TUI keeps the declared name as a branch field's fallback title. The prefix is an internal field NAME, and buildFields labels from its map key — so the showcase displayed `__b0__address` where the schema says `address`. Signed-off-by: cliffhall <cliff@futurescale.com>
652 lines
21 KiB
TypeScript
652 lines
21 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import {
|
|
getDataType,
|
|
tryParseJson,
|
|
updateValueAtPath,
|
|
getValueAtPath,
|
|
collectSchemaDefaults,
|
|
hasMissingRequiredFields,
|
|
applySchemaConstants,
|
|
seedSchemaValues,
|
|
} from "./jsonUtils";
|
|
import type { InspectorFormSchema } from "./jsonUtils";
|
|
|
|
describe("getDataType", () => {
|
|
it("returns 'array' for arrays", () => {
|
|
expect(getDataType([])).toBe("array");
|
|
expect(getDataType([1, 2, 3])).toBe("array");
|
|
});
|
|
|
|
it("returns 'null' for null", () => {
|
|
expect(getDataType(null)).toBe("null");
|
|
});
|
|
|
|
it("returns the typeof for primitives and objects", () => {
|
|
expect(getDataType("foo")).toBe("string");
|
|
expect(getDataType(42)).toBe("number");
|
|
expect(getDataType(true)).toBe("boolean");
|
|
expect(getDataType(undefined)).toBe("undefined");
|
|
expect(getDataType({ a: 1 })).toBe("object");
|
|
});
|
|
});
|
|
|
|
describe("tryParseJson", () => {
|
|
it("parses valid JSON objects", () => {
|
|
expect(tryParseJson('{"a":1}')).toEqual({ success: true, data: { a: 1 } });
|
|
});
|
|
|
|
it("parses valid JSON arrays", () => {
|
|
expect(tryParseJson("[1,2,3]")).toEqual({
|
|
success: true,
|
|
data: [1, 2, 3],
|
|
});
|
|
});
|
|
|
|
it("returns the original string for non-object/array input", () => {
|
|
expect(tryParseJson("hello")).toEqual({ success: false, data: "hello" });
|
|
expect(tryParseJson("42")).toEqual({ success: false, data: "42" });
|
|
});
|
|
|
|
it("returns the original string for malformed JSON", () => {
|
|
expect(tryParseJson("{ not: json }")).toEqual({
|
|
success: false,
|
|
data: "{ not: json }",
|
|
});
|
|
});
|
|
|
|
it("handles empty / whitespace input", () => {
|
|
expect(tryParseJson("")).toEqual({ success: false, data: "" });
|
|
expect(tryParseJson(" ")).toEqual({ success: false, data: " " });
|
|
});
|
|
});
|
|
|
|
describe("updateValueAtPath", () => {
|
|
it("returns the value when path is empty", () => {
|
|
expect(updateValueAtPath({ a: 1 }, [], "replaced")).toBe("replaced");
|
|
});
|
|
|
|
it("updates a nested object property", () => {
|
|
const original = { a: { b: 1 } };
|
|
const result = updateValueAtPath(original, ["a", "b"], 2);
|
|
expect(result).toEqual({ a: { b: 2 } });
|
|
expect(original).toEqual({ a: { b: 1 } });
|
|
});
|
|
|
|
it("creates missing nested keys", () => {
|
|
expect(updateValueAtPath({}, ["a", "b"], 1)).toEqual({ a: { b: 1 } });
|
|
});
|
|
|
|
it("updates an array element", () => {
|
|
expect(updateValueAtPath([1, 2, 3], ["1"], 9)).toEqual([1, 9, 3]);
|
|
});
|
|
|
|
it("extends arrays with null padding when index is out of bounds", () => {
|
|
expect(updateValueAtPath([1], ["3"], 9)).toEqual([1, null, null, 9]);
|
|
});
|
|
|
|
it("creates an array when path[0] is numeric and obj is null/undefined", () => {
|
|
expect(updateValueAtPath(null, ["0", "name"], "x")).toEqual([
|
|
{ name: "x" },
|
|
]);
|
|
});
|
|
|
|
it("creates an object when path[0] is non-numeric and obj is null/undefined", () => {
|
|
expect(updateValueAtPath(undefined, ["a"], 1)).toEqual({ a: 1 });
|
|
});
|
|
|
|
it("returns the original array on invalid (NaN) array index", () => {
|
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
const original = [1, 2, 3];
|
|
expect(updateValueAtPath(original, ["x"], 9)).toBe(original);
|
|
consoleSpy.mockRestore();
|
|
});
|
|
|
|
it("returns the original array on negative index", () => {
|
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
const original = [1, 2, 3];
|
|
expect(updateValueAtPath(original, ["-1"], 9)).toBe(original);
|
|
consoleSpy.mockRestore();
|
|
});
|
|
|
|
it("returns the original on non-object/array primitive at path", () => {
|
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
expect(updateValueAtPath(42 as never, ["a"], 1)).toBe(42);
|
|
consoleSpy.mockRestore();
|
|
});
|
|
|
|
it("handles deeply nested array+object paths", () => {
|
|
expect(updateValueAtPath({ a: [{ b: 1 }] }, ["a", "0", "b"], 9)).toEqual({
|
|
a: [{ b: 9 }],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("getValueAtPath", () => {
|
|
it("returns the value at an object path", () => {
|
|
expect(getValueAtPath({ a: { b: 2 } }, ["a", "b"])).toBe(2);
|
|
});
|
|
|
|
it("returns the value at an array index", () => {
|
|
expect(getValueAtPath([10, 20, 30], ["1"])).toBe(20);
|
|
});
|
|
|
|
it("returns defaultValue when key is missing", () => {
|
|
expect(getValueAtPath({ a: 1 }, ["b"], "fallback")).toBe("fallback");
|
|
});
|
|
|
|
it("returns defaultValue for null/undefined obj", () => {
|
|
expect(getValueAtPath(null, ["a"], "fb")).toBe("fb");
|
|
expect(getValueAtPath(undefined, ["a"], "fb")).toBe("fb");
|
|
});
|
|
|
|
it("returns defaultValue for out-of-bounds array index", () => {
|
|
expect(getValueAtPath([1, 2], ["5"], "fb")).toBe("fb");
|
|
expect(getValueAtPath([1, 2], ["x"], "fb")).toBe("fb");
|
|
expect(getValueAtPath([1, 2], ["-1"], "fb")).toBe("fb");
|
|
});
|
|
|
|
it("returns the obj itself when path is empty", () => {
|
|
expect(getValueAtPath({ a: 1 }, [])).toEqual({ a: 1 });
|
|
});
|
|
|
|
it("returns defaultValue (null) when not specified and path doesn't exist", () => {
|
|
expect(getValueAtPath({}, ["x"])).toBe(null);
|
|
});
|
|
|
|
it("returns defaultValue for primitive at path", () => {
|
|
expect(getValueAtPath({ a: 5 }, ["a", "b"], "fb")).toBe("fb");
|
|
});
|
|
});
|
|
|
|
describe("updateValueAtPath edge case suppression", () => {
|
|
beforeEach(() => {
|
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
});
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("logs when array index is invalid", () => {
|
|
updateValueAtPath([1], ["x"], 9);
|
|
expect(console.error).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("collectSchemaDefaults", () => {
|
|
it("collects defaults across field types and omits fields without one", () => {
|
|
const schema: InspectorFormSchema = {
|
|
type: "object",
|
|
properties: {
|
|
firstLine: {
|
|
type: "string",
|
|
default: "It was a dark and stormy night.",
|
|
},
|
|
integer: { type: "integer", default: 42 },
|
|
number: { type: "number", default: 3.14 },
|
|
choice: { type: "string", enum: ["a", "b"], default: "a" },
|
|
picks: { type: "array", items: { enum: ["x", "y"] }, default: ["x"] },
|
|
// No default — must be absent from the result, not undefined.
|
|
name: { type: "string", title: "Name" },
|
|
},
|
|
};
|
|
expect(collectSchemaDefaults(schema)).toEqual({
|
|
firstLine: "It was a dark and stormy night.",
|
|
integer: 42,
|
|
number: 3.14,
|
|
choice: "a",
|
|
picks: ["x"],
|
|
});
|
|
});
|
|
|
|
it("recurses into nested object schemas and skips empty ones", () => {
|
|
const schema: InspectorFormSchema = {
|
|
type: "object",
|
|
properties: {
|
|
db: {
|
|
type: "object",
|
|
properties: {
|
|
host: { type: "string", default: "localhost" },
|
|
port: { type: "integer" },
|
|
},
|
|
},
|
|
empty: {
|
|
type: "object",
|
|
properties: { note: { type: "string" } },
|
|
},
|
|
},
|
|
};
|
|
expect(collectSchemaDefaults(schema)).toEqual({
|
|
db: { host: "localhost" },
|
|
});
|
|
});
|
|
|
|
it("returns an empty object when the schema has no properties", () => {
|
|
expect(collectSchemaDefaults({ type: "object" })).toEqual({});
|
|
});
|
|
|
|
// The form normalizes a nullable union before rendering, so this has to as
|
|
// well: a nested object's `properties` live on the union's surviving branch,
|
|
// and a default only visible there would be displayed but never submitted.
|
|
it("collects defaults hoisted out of a nullable object union", () => {
|
|
const schema: InspectorFormSchema = {
|
|
type: "object",
|
|
properties: {
|
|
profile: {
|
|
anyOf: [
|
|
{
|
|
type: "object",
|
|
properties: { nick: { type: "string", default: "ada" } },
|
|
},
|
|
{ type: "null" },
|
|
],
|
|
},
|
|
},
|
|
};
|
|
expect(collectSchemaDefaults(schema)).toEqual({
|
|
profile: { nick: "ada" },
|
|
});
|
|
});
|
|
|
|
it("keeps a default declared on the union wrapper itself", () => {
|
|
const schema: InspectorFormSchema = {
|
|
type: "object",
|
|
properties: {
|
|
direction: {
|
|
default: "envio",
|
|
anyOf: [{ type: "string", enum: ["envio"] }, { type: "null" }],
|
|
},
|
|
},
|
|
};
|
|
expect(collectSchemaDefaults(schema)).toEqual({ direction: "envio" });
|
|
});
|
|
});
|
|
|
|
describe("hasMissingRequiredFields", () => {
|
|
const schema: InspectorFormSchema = {
|
|
type: "object",
|
|
properties: { name: { type: "string" }, age: { type: "integer" } },
|
|
required: ["name"],
|
|
};
|
|
|
|
it("is false when no fields are required", () => {
|
|
expect(hasMissingRequiredFields({ type: "object" }, {})).toBe(false);
|
|
});
|
|
|
|
it("is true when a required field is absent, null, or empty", () => {
|
|
expect(hasMissingRequiredFields(schema, {})).toBe(true);
|
|
expect(hasMissingRequiredFields(schema, { name: null })).toBe(true);
|
|
expect(hasMissingRequiredFields(schema, { name: "" })).toBe(true);
|
|
});
|
|
|
|
it("is false when every required field has a value", () => {
|
|
expect(hasMissingRequiredFields(schema, { name: "Ada" })).toBe(false);
|
|
// A non-required field being empty does not matter.
|
|
expect(hasMissingRequiredFields(schema, { name: "Ada", age: "" })).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
// JSON Schema `required` constrains presence, not content — so an explicit
|
|
// `null` satisfies a required field whose schema admits null. Since #1928 the
|
|
// user can produce exactly that by clearing a nullable enum, and treating it
|
|
// as missing would disable submit on a value the schema calls valid.
|
|
it("accepts an explicit null for a required field that admits null", () => {
|
|
const nullable: InspectorFormSchema = {
|
|
type: "object",
|
|
properties: {
|
|
direction: {
|
|
anyOf: [{ type: "string", enum: ["envio"] }, { type: "null" }],
|
|
},
|
|
},
|
|
required: ["direction"],
|
|
};
|
|
expect(hasMissingRequiredFields(nullable, { direction: null })).toBe(false);
|
|
expect(hasMissingRequiredFields(nullable, {})).toBe(true);
|
|
});
|
|
|
|
it("accepts an explicit null for a required type: [T, null] field", () => {
|
|
const nullable: InspectorFormSchema = {
|
|
type: "object",
|
|
properties: { note: { type: ["string", "null"] } },
|
|
required: ["note"],
|
|
};
|
|
expect(hasMissingRequiredFields(nullable, { note: null })).toBe(false);
|
|
});
|
|
|
|
it("still rejects null for a required field whose schema is not nullable", () => {
|
|
expect(hasMissingRequiredFields(schema, { name: null })).toBe(true);
|
|
});
|
|
|
|
// The renderer's collapse only handles a two-member union, but null admission
|
|
// has no such limit — this one renders through the JSON fallback, where the
|
|
// user can still type `null`, and the schema plainly accepts it.
|
|
it("accepts an explicit null for a required three-member union", () => {
|
|
const wide: InspectorFormSchema = {
|
|
type: "object",
|
|
properties: {
|
|
mixed: {
|
|
anyOf: [{ type: "string" }, { type: "number" }, { type: "null" }],
|
|
},
|
|
},
|
|
required: ["mixed"],
|
|
};
|
|
expect(hasMissingRequiredFields(wide, { mixed: null })).toBe(false);
|
|
});
|
|
|
|
it("rejects null for a required field the schema does not describe", () => {
|
|
const undescribed: InspectorFormSchema = {
|
|
type: "object",
|
|
properties: {},
|
|
required: ["ghost"],
|
|
};
|
|
expect(hasMissingRequiredFields(undescribed, { ghost: null })).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("root composition (#2123)", () => {
|
|
const UNION: InspectorFormSchema = {
|
|
type: "object",
|
|
properties: { note: { type: "string" } },
|
|
anyOf: [
|
|
{
|
|
type: "object",
|
|
properties: {
|
|
kind: { type: "string", const: "email" },
|
|
address: { type: "string" },
|
|
},
|
|
required: ["kind", "address"],
|
|
},
|
|
{
|
|
type: "object",
|
|
properties: {
|
|
kind: { type: "string", const: "sms" },
|
|
phone: { type: "string" },
|
|
},
|
|
required: ["kind", "phone"],
|
|
},
|
|
],
|
|
};
|
|
|
|
it("seeds the first branch's defaults, including its const", () => {
|
|
expect(collectSchemaDefaults(UNION)).toEqual({ kind: "email" });
|
|
});
|
|
|
|
it("does not seed fields of branches the form is not showing", () => {
|
|
expect(collectSchemaDefaults(UNION)).not.toHaveProperty("phone");
|
|
});
|
|
|
|
it("seeds a const property on an ordinary schema too", () => {
|
|
expect(
|
|
collectSchemaDefaults({
|
|
type: "object",
|
|
properties: { version: { type: "string", const: "1" } },
|
|
}),
|
|
).toEqual({ version: "1" });
|
|
});
|
|
|
|
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",
|
|
properties: {
|
|
v: { type: "string", const: "a", default: "b" },
|
|
},
|
|
}),
|
|
).toEqual({ v: "a" });
|
|
});
|
|
|
|
it("collects defaults from a root allOf", () => {
|
|
expect(
|
|
collectSchemaDefaults({
|
|
type: "object",
|
|
allOf: [
|
|
{
|
|
type: "object",
|
|
properties: { merged: { type: "string", default: "m" } },
|
|
},
|
|
],
|
|
}),
|
|
).toEqual({ merged: "m" });
|
|
});
|
|
|
|
it("seeds the branch the known values identify, not the first", () => {
|
|
// What the App deep link does: seed defaults, then overlay its `appArgs`.
|
|
// Seeding branch 0 underneath branch 1's args would leave `address` in the
|
|
// submitted arguments, invisible to a form showing the SMS branch.
|
|
expect(collectSchemaDefaults(UNION, { kind: "sms" })).toEqual({
|
|
kind: "sms",
|
|
});
|
|
});
|
|
|
|
it("accepts a required field pinned to null", () => {
|
|
// `const: null` admits null and nothing else, so seeding it must not leave
|
|
// submit disabled on a value the user cannot change.
|
|
const schema: InspectorFormSchema = {
|
|
type: "object",
|
|
properties: { nothing: { const: null } },
|
|
required: ["nothing"],
|
|
};
|
|
const values = collectSchemaDefaults(schema);
|
|
expect(values).toEqual({ nothing: null });
|
|
expect(hasMissingRequiredFields(schema, values)).toBe(false);
|
|
});
|
|
|
|
it("re-applies a branch's constants over conflicting supplied values", () => {
|
|
// A read-only field cannot be corrected by the user, so a deep link
|
|
// disagreeing with a `const` must not survive into the submitted arguments.
|
|
expect(applySchemaConstants(UNION, { kind: "sms", note: "hi" })).toEqual({
|
|
kind: "sms",
|
|
note: "hi",
|
|
});
|
|
expect(applySchemaConstants(UNION, { kind: "nonsense" })).toEqual({
|
|
kind: "email",
|
|
});
|
|
});
|
|
|
|
it("seeds a field named __proto__", () => {
|
|
// A plain assignment would invoke the legacy prototype setter, leaving a
|
|
// required pinned field displayed read-only and seeded with nothing.
|
|
const seeded = collectSchemaDefaults({
|
|
type: "object",
|
|
properties: Object.fromEntries([
|
|
["__proto__", { type: "string", const: "kept" }],
|
|
]),
|
|
});
|
|
expect(Object.hasOwn(seeded, "__proto__")).toBe(true);
|
|
});
|
|
|
|
it("seeds defaults from a nested object's own union branch", () => {
|
|
// The nested form renders that branch, so its read-only discriminator has
|
|
// to reach the submitted values too.
|
|
expect(
|
|
collectSchemaDefaults({
|
|
type: "object",
|
|
properties: {
|
|
config: {
|
|
type: "object",
|
|
anyOf: [
|
|
{
|
|
type: "object",
|
|
properties: {
|
|
kind: { type: "string", const: "email" },
|
|
address: { type: "string" },
|
|
},
|
|
},
|
|
{
|
|
type: "object",
|
|
properties: {
|
|
kind: { type: "string", const: "sms" },
|
|
phone: { type: "string" },
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
}),
|
|
).toEqual({ config: { kind: "email" } });
|
|
});
|
|
|
|
describe("seedSchemaValues", () => {
|
|
const NESTED: InspectorFormSchema = {
|
|
type: "object",
|
|
properties: {
|
|
config: {
|
|
type: "object",
|
|
anyOf: [
|
|
{
|
|
type: "object",
|
|
properties: {
|
|
kind: { type: "string", const: "email" },
|
|
retries: { type: "number", default: 1 },
|
|
},
|
|
},
|
|
{
|
|
type: "object",
|
|
properties: {
|
|
kind: { type: "string", const: "sms" },
|
|
retries: { type: "number", default: 3 },
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
};
|
|
|
|
it("keeps a nested branch's defaults beside the supplied values", () => {
|
|
// A shallow spread would replace the whole `config` object, so the SMS
|
|
// branch's `retries` would be displayed by the form and never submitted.
|
|
expect(seedSchemaValues(NESTED, { config: { kind: "sms" } })).toEqual({
|
|
config: { kind: "sms", retries: 3 },
|
|
});
|
|
});
|
|
|
|
it("lets the supplied value win where the two meet", () => {
|
|
expect(
|
|
seedSchemaValues(NESTED, { config: { kind: "sms", retries: 9 } }),
|
|
).toEqual({ config: { kind: "sms", retries: 9 } });
|
|
});
|
|
|
|
it("does not treat an array as a nested object", () => {
|
|
const withArray: InspectorFormSchema = {
|
|
type: "object",
|
|
properties: { items: { type: "array" } },
|
|
};
|
|
expect(seedSchemaValues(withArray, { items: [1, 2] })).toEqual({
|
|
items: [1, 2],
|
|
});
|
|
});
|
|
|
|
it("keeps a supplied argument named __proto__", () => {
|
|
const seeded = seedSchemaValues(
|
|
{ type: "object", properties: {} },
|
|
Object.fromEntries([["__proto__", "kept"]]),
|
|
);
|
|
expect(Object.hasOwn(seeded, "__proto__")).toBe(true);
|
|
});
|
|
});
|
|
|
|
it("re-applies a nested object's constants", () => {
|
|
// The overlay replaces the whole nested object rather than merging into it,
|
|
// so a link naming `{ config: { kind: "sms" } }` would otherwise slip past
|
|
// the pinned `kind` the nested form displays.
|
|
const schema: InspectorFormSchema = {
|
|
type: "object",
|
|
properties: {
|
|
config: {
|
|
type: "object",
|
|
properties: {
|
|
kind: { type: "string", const: "email" },
|
|
to: { type: "string" },
|
|
},
|
|
},
|
|
},
|
|
};
|
|
expect(
|
|
applySchemaConstants(schema, { config: { kind: "sms", to: "a@b.c" } }),
|
|
).toEqual({ config: { kind: "email", to: "a@b.c" } });
|
|
});
|
|
|
|
it("leaves values alone when nothing is pinned", () => {
|
|
const values = { a: 1 };
|
|
expect(
|
|
applySchemaConstants(
|
|
{ type: "object", properties: { a: { type: "number" } } },
|
|
values,
|
|
),
|
|
).toBe(values);
|
|
});
|
|
|
|
it("accepts an empty string a const pins the field to", () => {
|
|
// Rendered read-only, so treating the seeded value as missing would
|
|
// disable submit on a value the user cannot change.
|
|
const pinnedEmpty: InspectorFormSchema = {
|
|
type: "object",
|
|
properties: { kind: { type: "string", const: "" } },
|
|
required: ["kind"],
|
|
};
|
|
expect(hasMissingRequiredFields(pinnedEmpty, { kind: "" })).toBe(false);
|
|
// An ordinary required string is still missing when left blank.
|
|
expect(
|
|
hasMissingRequiredFields(
|
|
{
|
|
type: "object",
|
|
properties: { kind: { type: "string" } },
|
|
required: ["kind"],
|
|
},
|
|
{ kind: "" },
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("does not read an inherited property as a supplied answer", () => {
|
|
// `constructor` is a legal argument name; reading it off the prototype
|
|
// would enable a submit the schema rejects.
|
|
const schema: InspectorFormSchema = {
|
|
type: "object",
|
|
// Built through `fromEntries`: in an object literal, `constructor` is
|
|
// TypeScript's own inherited member rather than a plain key.
|
|
properties: Object.fromEntries([["constructor", { type: "string" }]]),
|
|
required: ["constructor"],
|
|
};
|
|
expect(hasMissingRequiredFields(schema, {})).toBe(true);
|
|
expect(hasMissingRequiredFields(schema, { constructor: "x" })).toBe(false);
|
|
});
|
|
|
|
it("blocks submission while no branch is satisfied", () => {
|
|
expect(hasMissingRequiredFields(UNION, {})).toBe(true);
|
|
expect(hasMissingRequiredFields(UNION, { kind: "email" })).toBe(true);
|
|
});
|
|
|
|
it("does not count a branch whose discriminator the values contradict", () => {
|
|
// `{ kind: "sms", address: … }` supplies everything the EMAIL branch
|
|
// requires while carrying a `kind` that branch rejects — and the SMS
|
|
// branch it does name is still missing `phone`.
|
|
expect(
|
|
hasMissingRequiredFields(UNION, { kind: "sms", address: "a@b.c" }),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("allows submission once one branch is satisfied", () => {
|
|
expect(hasMissingRequiredFields(UNION, { kind: "sms", phone: "555" })).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
it("gates on required fields declared only in a root allOf", () => {
|
|
const schema: InspectorFormSchema = {
|
|
type: "object",
|
|
allOf: [
|
|
{
|
|
type: "object",
|
|
properties: { a: { type: "string" } },
|
|
required: ["a"],
|
|
},
|
|
],
|
|
};
|
|
expect(hasMissingRequiredFields(schema, {})).toBe(true);
|
|
expect(hasMissingRequiredFields(schema, { a: "x" })).toBe(false);
|
|
});
|
|
});
|