import { describe, expect, it } from "@effect/vitest"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { coerceNumber, defaultForSchema, isRenderableObjectSchema, missingRequiredFields, removeProperty, setProperty, } from "./json-schema-form"; // Mirror the panel's decode boundary so the round-trip assertions use the exact // same path the production code uses (no raw JSON.parse). const decodeArgsJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString); const SCHEMA = { type: "object", required: ["email"], properties: { email: { type: "string" }, age: { type: "integer" }, tags: { type: "array", items: { type: "string" } }, role: { enum: ["a", "b"] }, }, } as const; describe("missingRequiredFields (Run gate)", () => { it("reports unfilled required args, ignores optionals, never gates without required", () => { // unset / empty-string / null required → still missing (Run stays disabled) expect(missingRequiredFields(SCHEMA, undefined, {})).toEqual(["email"]); expect(missingRequiredFields(SCHEMA, undefined, { email: "" })).toEqual(["email"]); expect(missingRequiredFields(SCHEMA, undefined, { email: null })).toEqual(["email"]); // present + non-empty required → nothing missing; optionals never gate expect(missingRequiredFields(SCHEMA, undefined, { email: "a@b.c" })).toEqual([]); expect(missingRequiredFields(SCHEMA, undefined, { email: "a@b.c", age: 0 })).toEqual([]); // no required / no schema → never gates a zero-arg or schema-less tool expect(missingRequiredFields({ type: "object", properties: {} }, undefined, {})).toEqual([]); expect(missingRequiredFields(null, undefined, {})).toEqual([]); }); }); describe("json-schema-form value transforms", () => { it("lossless round-trip with object + nested array + enum (omit-unset)", () => { const valueIn: Record = { email: "x@y.z", tags: ["p"], role: "a", }; // Edit email, then append a tag — the rebuilds the form would produce. const afterEmail = setProperty(valueIn, "email", "q@y.z"); const tags = Array.isArray(afterEmail.tags) ? afterEmail.tags : []; const rebuilt = setProperty(afterEmail, "tags", [...tags, "r"]); const json = JSON.stringify(rebuilt, null, 2); const decoded = decodeArgsJson(json); expect(Option.isSome(decoded)).toBe(true); const value = Option.getOrElse(decoded, () => ({}) as unknown); expect(value).toEqual({ email: "q@y.z", tags: ["p", "r"], role: "a" }); // `age` was never present — it must not appear after the round-trip. expect(Object.keys(value as object).sort()).toEqual(["email", "role", "tags"]); }); it("omit-unset-optional: editing only required field never invents optional keys", () => { const rebuilt = setProperty({}, "email", "a@b.c"); expect(Object.keys(rebuilt)).toEqual(["email"]); // No `null` placeholders serialized for omitted optionals. expect(JSON.stringify(rebuilt)).toBe('{"email":"a@b.c"}'); }); it("removeProperty deletes an optional key and leaves siblings intact", () => { const value: Record = { email: "x@y.z", age: 30 }; const next = removeProperty(value, "age"); expect("age" in next).toBe(false); expect(next).toEqual({ email: "x@y.z" }); // Original is not mutated. expect("age" in value).toBe(true); }); it("number coercion stores JS numbers, not strings, and round-trips as JSON number", () => { const next = setProperty({}, "age", coerceNumber("123", true)); expect(next.age).toBe(123); expect(typeof next.age).toBe("number"); expect(JSON.stringify(next)).toBe('{"age":123}'); // Invalid input yields undefined (caller keeps last-valid; never emits it). expect(coerceNumber("abc", true)).toBeUndefined(); expect(coerceNumber("", false)).toBeUndefined(); // Integer truncation. expect(coerceNumber("3.9", true)).toBe(3); expect(coerceNumber("3.5", false)).toBe(3.5); }); it("defaultForSchema seeds the right empty value per type", () => { const root = { $defs: {} }; expect(defaultForSchema({ type: "string" }, root)).toBe(""); expect(defaultForSchema({ type: "integer" }, root)).toBe(0); expect(defaultForSchema({ type: "boolean" }, root)).toBe(false); expect(defaultForSchema({ type: "object" }, root)).toEqual({}); expect(defaultForSchema({ type: "array" }, root)).toEqual([]); expect(defaultForSchema({ enum: ["a", "b"] }, root)).toBe("a"); expect(defaultForSchema({ const: 7 }, root)).toBe(7); }); it("isRenderableObjectSchema gates form-mode availability", () => { expect(isRenderableObjectSchema(SCHEMA)).toBe(true); // $ref resolving (via definitions) to an object is renderable. expect( isRenderableObjectSchema( { $ref: "#/$defs/Args" }, { Args: { type: "object", properties: { a: { type: "string" } } } }, ), ).toBe(true); expect(isRenderableObjectSchema({ type: "string" })).toBe(false); expect(isRenderableObjectSchema(undefined)).toBe(false); // Unresolvable $ref → not renderable. expect(isRenderableObjectSchema({ $ref: "#/$defs/Missing" })).toBe(false); }); it("invalid / non-object JSON disables the Form tab (the toggle guard)", () => { const parsedArgsFor = (raw: string): Record | null => { const decoded = decodeArgsJson(raw); if (Option.isNone(decoded)) return null; const v = decoded.value; return typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Record) : null; }; const formDisabled = (schemaAvailable: boolean, parsed: Record | null) => !schemaAvailable || parsed === null; // Malformed JSON → parsedArgs null → Form tab un-selectable. expect(parsedArgsFor("{ broken")).toBeNull(); expect(formDisabled(true, parsedArgsFor("{ broken"))).toBe(true); // Valid but non-object JSON also yields null. expect(parsedArgsFor("[]")).toBeNull(); expect(parsedArgsFor("5")).toBeNull(); expect(formDisabled(true, parsedArgsFor("5"))).toBe(true); // Valid object + available schema → form enabled. expect(parsedArgsFor('{"email":"x@y.z"}')).toEqual({ email: "x@y.z" }); expect(formDisabled(true, parsedArgsFor('{"email":"x@y.z"}'))).toBe(false); }); });