import { z } from "zod"; import { animalSchema, buildingSchema, cropSchema, dialogueSchema, interactSchema, itemSchema, sceneSchema, shopSchema, soundSchema, spriteSchema, stateMachineSchema, triggerSchema, weatherSchema, } from "@nengine/core-schema"; import { describe, expect, it } from "vitest"; import { FormSchemaError, deriveFormSchema, humanizeId, refKindForField } from "../src/renderer/form-schema.js"; import type { FormFieldSchema } from "../src/renderer/form-schema.js"; function field(schema: z.ZodTypeAny, name: string): FormFieldSchema { const found = deriveFormSchema(schema).fields.find((f) => f.name === name); if (found === undefined) throw new Error(`no field "${name}"`); return found; } function fieldIn(fields: FormFieldSchema[], name: string): FormFieldSchema { const found = fields.find((f) => f.name === name); if (found === undefined) throw new Error(`no field "${name}"`); return found; } describe("humanizeId", () => { it("splits camelCase and snake into capitalized words", () => { expect(humanizeId("spriteRef")).toBe("Sprite ref"); expect(humanizeId("dayLengthTicks")).toBe("Day length ticks"); expect(humanizeId("emitRate")).toBe("Emit rate"); expect(humanizeId("waterRequired")).toBe("Water required"); expect(humanizeId("id")).toBe("Id"); expect(humanizeId("tick_in_day")).toBe("Tick in day"); }); }); describe("refKindForField", () => { it("maps Id/Ref/Src suffixes to content kinds", () => { expect(refKindForField("itemId")).toBe("item"); expect(refKindForField("spriteRef")).toBe("sprite"); expect(refKindForField("buildingId")).toBe("building"); expect(refKindForField("animalId")).toBe("animal"); expect(refKindForField("dialogueId")).toBe("dialogue"); expect(refKindForField("soundId")).toBe("sound"); expect(refKindForField("particleId")).toBe("particle"); }); it("maps exact-name table entries", () => { expect(refKindForField("seedItem")).toBe("item"); expect(refKindForField("sound")).toBe("sound"); expect(refKindForField("shop")).toBe("shop"); expect(refKindForField("dialogue")).toBe("dialogue"); expect(refKindForField("src")).toBe("asset"); expect(refKindForField("filters")).toBe("shader"); }); it("leaves names without a matching kind as plain strings", () => { expect(refKindForField("tilesetRef")).toBeNull(); expect(refKindForField("npcId")).toBeNull(); expect(refKindForField("id")).toBeNull(); expect(refKindForField("name")).toBeNull(); expect(refKindForField("to")).toBeNull(); expect(refKindForField("from")).toBeNull(); }); }); describe("deriveFormSchema primitives", () => { it("derives an item schema", () => { const form = deriveFormSchema(itemSchema); const names = form.fields.map((f) => f.name); expect(names).toEqual(["id", "name", "kind", "stackable", "price", "use"]); expect(field(itemSchema, "id").type).toBe("string"); expect(field(itemSchema, "name").type).toBe("string"); expect(field(itemSchema, "kind").type).toBe("enum"); expect(field(itemSchema, "kind").enumValues).toEqual(["tool", "seed", "material", "consumable", "decoration"]); expect(field(itemSchema, "stackable").type).toBe("boolean"); expect(field(itemSchema, "price").type).toBe("number"); expect(field(itemSchema, "price").optional).toBe(true); expect(field(itemSchema, "price").integer).toBe(true); expect(field(itemSchema, "price").numberMin).toBe(0); expect(field(itemSchema, "use").optional).toBe(true); expect(field(itemSchema, "use").type).toBe("string"); }); it("derives number min/max hints", () => { expect(field(cropSchema, "id").type).toBe("string"); const days = fieldIn(field(cropSchema, "stages").element?.fields ?? [], "days"); expect(days.type).toBe("number"); expect(days.integer).toBe(true); expect(days.numberMin).toBe(1); }); }); describe("deriveFormSchema arrays and refs", () => { it("derives crop stages with spriteRef and drops with itemId", () => { const stages = field(cropSchema, "stages"); expect(stages.type).toBe("array"); expect(stages.element?.type).toBe("object"); const spriteRef = fieldIn(stages.element?.fields ?? [], "spriteRef"); expect(spriteRef.type).toBe("ref"); expect(spriteRef.refKind).toBe("sprite"); expect(spriteRef.optional).toBe(true); expect(field(cropSchema, "seedItem").type).toBe("ref"); expect(field(cropSchema, "seedItem").refKind).toBe("item"); const drops = field(cropSchema, "drops"); expect(drops.type).toBe("array"); const itemId = fieldIn(drops.element?.fields ?? [], "itemId"); expect(itemId.type).toBe("ref"); expect(itemId.refKind).toBe("item"); expect(field(cropSchema, "seasons").element?.type).toBe("string"); }); it("derives animal stages and produces", () => { const stages = field(animalSchema, "stages"); expect(fieldIn(stages.element?.fields ?? [], "spriteRef").refKind).toBe("sprite"); const produces = field(animalSchema, "produces"); expect(fieldIn(produces.element?.fields ?? [], "itemId").refKind).toBe("item"); expect(fieldIn(produces.element?.fields ?? [], "chance").optional).toBe(true); }); it("derives shop lines", () => { const buys = field(shopSchema, "buys"); expect(buys.type).toBe("array"); expect(fieldIn(buys.element?.fields ?? [], "itemId").type).toBe("ref"); expect(fieldIn(buys.element?.fields ?? [], "price").integer).toBe(true); const sells = field(shopSchema, "sells"); expect(fieldIn(sells.element?.fields ?? [], "stock").optional).toBe(true); }); it("derives sprite frames and the layer discriminated union", () => { const frames = field(spriteSchema, "frames"); expect(frames.type).toBe("array"); const layers = fieldIn(frames.element?.fields ?? [], "layers"); expect(layers.type).toBe("array"); const layer = layers.element; expect(layer?.type).toBe("union"); expect(layer?.discriminator).toBe("shape"); const branches = layer?.branches ?? []; expect(branches.map((b) => b.id)).toEqual(["rect", "circle", "image"]); const rect = branches[0]; expect(rect?.label).toBe("Rect"); const rectFields = rect?.fields ?? []; expect(rectFields.map((f) => f.name)).toEqual(["x", "y", "w", "h", "r", "fill"]); expect(fieldIn(rectFields, "w").optional).toBe(true); const fill = fieldIn(rectFields, "fill"); expect(fill.pattern).toBe("^#[0-9a-fA-F]{6}$"); const image = branches[2]; const src = fieldIn(image?.fields ?? [], "src"); expect(src.type).toBe("ref"); expect(src.refKind).toBe("asset"); expect(field(spriteSchema, "filters").type).toBe("array"); expect(field(spriteSchema, "filters").element?.type).toBe("ref"); expect(field(spriteSchema, "filters").element?.refKind).toBe("shader"); }); }); describe("deriveFormSchema unions", () => { it("derives statemachine transitions with a ConditionExpr union", () => { const transitions = field(stateMachineSchema, "transitions"); expect(transitions.type).toBe("array"); const when = fieldIn(transitions.element?.fields ?? [], "when"); expect(when.type).toBe("union"); expect(when.optional).toBe(true); const branches = when.branches ?? []; expect(branches.length).toBe(5); expect(branches[0]?.label).toBe("eq / gte / lte"); expect(branches[1]?.label).toBe("exists"); expect(branches.map((b) => b.label)).toContain("And"); expect(branches.map((b) => b.label)).toContain("Or"); expect(branches.map((b) => b.label)).toContain("Not"); const andBranch = branches.find((b) => b.label === "And"); expect(andBranch?.fields?.[0]?.type).toBe("array"); }); it("derives the schedule at union (enum + object)", () => { const onSchedule = field(stateMachineSchema, "onSchedule"); const at = fieldIn(onSchedule.element?.fields ?? [], "at"); expect(at.type).toBe("union"); const branches = at.branches ?? []; expect(branches[0]?.primitive?.type).toBe("enum"); expect(branches[0]?.primitive?.enumValues).toEqual(["day-start", "season-change"]); expect(branches[1]?.fields?.map((f) => f.name)).toEqual(["day", "season", "tickInDay"]); }); it("derives the behavior action union with implicit action discriminator", () => { const fire = field(triggerSchema, "fire"); expect(fire.type).toBe("array"); const action = fire.element; expect(action?.type).toBe("union"); expect(action?.discriminator).toBe("action"); const branches = action?.branches ?? []; expect(branches.map((b) => b.id)).toEqual(["set-state", "add", "emit-event", "give-item", "play-sound", "dialogue"]); const giveItem = branches.find((b) => b.id === "give-item"); expect(fieldIn(giveItem?.fields ?? [], "itemId").refKind).toBe("item"); const playSound = branches.find((b) => b.id === "play-sound"); expect(fieldIn(playSound?.fields ?? [], "sound").refKind).toBe("sound"); }); it("derives building buildable as a union of literals and object", () => { const buildable = field(buildingSchema, "buildable"); expect(buildable.type).toBe("union"); const branches = buildable.branches ?? []; expect(branches.map((b) => b.id)).toEqual(["map", "any", "branch-2"]); expect(branches[0]?.primitive?.type).toBe("enum"); expect(branches[0]?.primitive?.enumValues).toEqual(["map"]); expect(branches[1]?.primitive?.enumValues).toEqual(["any"]); expect(branches[2]?.fields?.map((f) => f.name)).toEqual(["tiles"]); }); it("derives dialogue nodes with choices", () => { const nodes = field(dialogueSchema, "nodes"); expect(nodes.type).toBe("array"); const choices = fieldIn(nodes.element?.fields ?? [], "choices"); expect(choices.type).toBe("array"); expect(choices.optional).toBe(true); expect(fieldIn(choices.element?.fields ?? [], "text").type).toBe("string"); expect(fieldIn(choices.element?.fields ?? [], "to").type).toBe("string"); expect(fieldIn(choices.element?.fields ?? [], "when").type).toBe("union"); expect(field(dialogueSchema, "startNode").type).toBe("string"); }); }); describe("deriveFormSchema scene union", () => { it("derives scene entities as a discriminated union over type", () => { const entities = field(sceneSchema, "entities"); expect(entities.type).toBe("array"); const entity = entities.element; expect(entity?.type).toBe("union"); expect(entity?.discriminator).toBe("type"); const branches = entity?.branches ?? []; expect(branches.map((b) => b.id)).toEqual(["building", "npc", "animal"]); const building = branches[0]; expect(building?.label).toBe("Building"); const buildingFields = building?.fields ?? []; expect(buildingFields.map((f) => f.name)).toEqual(["buildingId", "x", "y"]); expect(fieldIn(buildingFields, "buildingId").type).toBe("ref"); expect(fieldIn(buildingFields, "buildingId").refKind).toBe("building"); const npc = branches[1]; const npcFields = npc?.fields ?? []; expect(fieldIn(npcFields, "npcId").type).toBe("string"); expect(fieldIn(npcFields, "dialogue").type).toBe("ref"); expect(fieldIn(npcFields, "dialogue").refKind).toBe("dialogue"); const animal = branches[2]; expect(fieldIn(animal?.fields ?? [], "animalId").refKind).toBe("animal"); }); }); describe("deriveFormSchema records and single-file kinds", () => { it("derives weather defaults as a record of objects", () => { const defaults = field(weatherSchema, "defaults"); expect(defaults.type).toBe("record"); expect(defaults.optional).toBe(true); expect(defaults.recordValue?.type).toBe("object"); expect(field(weatherSchema, "seasons").type).toBe("array"); }); it("derives interact order as an enum array", () => { const order = field(interactSchema, "order"); expect(order.type).toBe("array"); expect(order.element?.type).toBe("enum"); expect(order.element?.enumValues).toEqual(["harvest", "water", "sow", "till"]); }); it("derives sound synth and src", () => { const synth = field(soundSchema, "synth"); expect(synth.type).toBe("object"); expect(synth.optional).toBe(true); expect(fieldIn(synth.fields ?? [], "wave").enumValues).toEqual(["sine", "square", "triangle", "sawtooth"]); const src = field(soundSchema, "src"); expect(src.type).toBe("ref"); expect(src.refKind).toBe("asset"); }); }); describe("deriveFormSchema error reporting", () => { it("throws on unsupported zod constructs instead of mis-rendering", () => { expect(() => deriveFormSchema(z.tuple([z.string(), z.number()]))).toThrow(FormSchemaError); expect(() => deriveFormSchema(z.intersection(z.object({ a: z.string() }), z.object({ b: z.number() })))).toThrow( FormSchemaError, ); }); });