import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { contentSchemas } from "@nengine/core-schema"; import { parse } from "yaml"; import { describe, expect, it } from "vitest"; import { deriveFormSchema } from "../src/renderer/form-schema.js"; import type { FormFieldSchema, FormSchema } from "../src/renderer/form-schema.js"; import { applyValue, coerceForValidate, detectUnionBranch, getPath, initialItemValue, initialValue, setPath, unionBranchValue, } from "../src/renderer/form-value.js"; const sampleDir = fileURLToPath(new URL("../../../examples/sample-game/content", import.meta.url)); const fixturesDir = fileURLToPath(new URL("./fixtures", import.meta.url)); async function loadYaml(rel: string): Promise { return parse(await readFile(join(sampleDir, rel), "utf8")); } function derive(kind: keyof typeof contentSchemas) { return deriveFormSchema(contentSchemas[kind]); } function fieldOf(schema: FormSchema, name: string): FormFieldSchema { const found = schema.fields.find((f) => f.name === name); if (found === undefined) throw new Error(`no field "${name}"`); return found; } /** derive → applyValue → coerceForValidate must be valid and JSON-identical * to the original def when nothing was edited. */ async function expectRoundtrip(kind: keyof typeof contentSchemas, rel: string): Promise { const def = await loadYaml(rel); const schema = contentSchemas[kind]; const parsed = schema.safeParse(def); expect(parsed.success, `${kind} fixture must validate`).toBe(true); const formSchema = derive(kind); const working = applyValue(formSchema, def); const out = coerceForValidate(formSchema, working); const validated = schema.safeParse(out); expect(validated.success, `${kind} roundtrip must validate`).toBe(true); expect(JSON.stringify(out)).toBe(JSON.stringify(def)); } describe("initialValue", () => { it("builds a blank item def", () => { const value = initialValue(derive("item")); expect(value).toEqual({ id: "", name: "", kind: "tool", stackable: false }); }); it("leaves optional fields absent and arrays empty", () => { const value = initialValue(derive("crop")); expect(value).toEqual({ id: "", seedItem: "", stages: [], seasons: [], drops: [], }); }); it("picks the first enum value and the first union branch", () => { expect(initialValue(derive("interact"))).toEqual({ id: "", order: [] }); expect(initialValue(derive("sprite"))).toEqual({ id: "", frames: [] }); }); it("starts a union at the first branch with the discriminator set", () => { const frames = fieldOf(derive("sprite"), "frames"); const layers = fieldOf({ fields: frames.element?.fields ?? [] }, "layers"); const layer = layers.element as FormFieldSchema; expect(initialItemValue(layer)).toEqual({ shape: "rect", x: 0, y: 0, fill: "" }); }); }); describe("applyValue", () => { it("preserves unknown fields losslessly", async () => { const def = (await loadYaml("crops/carrot.yaml")) as Record; def.customField = { nested: [1, 2, 3] }; const working = applyValue(derive("crop"), def); expect(working.customField).toEqual({ nested: [1, 2, 3] }); expect(working.seedItem).toBe("carrot-seed"); const out = coerceForValidate(derive("crop"), working); expect(out.customField).toEqual({ nested: [1, 2, 3] }); }); it("coerces wrong-typed primitives to the schema shape", () => { const working = applyValue(derive("item"), { id: 5, name: true, kind: 3, stackable: "true", price: "10" }); expect(working.id).toBe("5"); expect(working.name).toBe("true"); expect(working.kind).toBe(3); expect(working.stackable).toBe(true); expect(working.price).toBe(10); }); it("keeps any-typed values as-is and fills required unions", () => { const working = applyValue(derive("behavior"), {}); expect(working.when).toEqual({ op: "eq", path: "", value: "" }); }); }); describe("coerceForValidate", () => { it("drops emptied optional fields but keeps required empties for validation", () => { const formSchema = derive("item"); const value = { id: "", name: "hoe", kind: "tool", stackable: false, price: "", use: "", }; const out = coerceForValidate(formSchema, value); expect(out).toEqual({ id: "", name: "hoe", kind: "tool", stackable: false }); }); it("parses any-typed strings back to numbers and booleans", () => { const fire = fieldOf(derive("behavior"), "fire"); const action = fire.element as FormFieldSchema; const value = { action: "emit-event", event: "particle", payload: { count: "42", flag: "true", id: "sparkle" } }; const out = coerceForValidate({ fields: [action] }, { fire: value }); const payload = out.fire as { payload?: unknown } | undefined; expect(payload?.payload).toEqual({ count: 42, flag: true, id: "sparkle" }); }); }); describe("union value model", () => { it("detects ConditionExpr branches by required fields and enum values", () => { const transitions = fieldOf(derive("statemachine"), "transitions"); const when = fieldOf({ fields: transitions.element?.fields ?? [] }, "when"); expect(detectUnionBranch(when, { op: "eq", path: "x", value: "y" })?.label).toBe("eq / gte / lte"); expect(detectUnionBranch(when, { op: "exists", path: "x" })?.label).toBe("exists"); expect(detectUnionBranch(when, { and: [{ op: "exists", path: "x" }] })?.label).toBe("And"); expect(detectUnionBranch(when, { or: [] })?.label).toBe("Or"); expect(detectUnionBranch(when, { not: { op: "exists", path: "x" } })?.label).toBe("Not"); }); it("detects discriminated union branches by the discriminator", () => { const frames = fieldOf(derive("sprite"), "frames"); const layers = fieldOf({ fields: frames.element?.fields ?? [] }, "layers"); const layer = layers.element as FormFieldSchema; expect(detectUnionBranch(layer, { shape: "rect", x: 1, y: 2, fill: "#fff" })?.id).toBe("rect"); expect(detectUnionBranch(layer, { shape: "image", src: "a" })?.id).toBe("image"); }); it("builds a fresh branch value on switch", () => { const frames = fieldOf(derive("sprite"), "frames"); const layers = fieldOf({ fields: frames.element?.fields ?? [] }, "layers"); const layer = layers.element as FormFieldSchema; const image = (layer.branches ?? []).find((b) => b.id === "image"); if (image === undefined) throw new Error("no image branch"); expect(unionBranchValue(layer, image)).toEqual({ shape: "image", src: "" }); }); it("resolves building buildable literal branches", () => { const buildable = fieldOf(derive("building"), "buildable"); expect(detectUnionBranch(buildable, "map")?.id).toBe("map"); expect(detectUnionBranch(buildable, { tiles: ["grass"] })?.id).toBe("branch-2"); }); }); describe("getPath/setPath", () => { it("reads and writes deep paths through arrays and objects", () => { const value: Record = {}; setPath(value, ["stages", 1, "spriteRef"], "grown"); expect(getPath(value, ["stages", 1, "spriteRef"])).toBe("grown"); expect(Array.isArray(getPath(value, ["stages"]))).toBe(true); setPath(value, ["stages", 0, "name"], "sprout"); expect(getPath(value, ["stages"])).toEqual([{ name: "sprout" }, { spriteRef: "grown" }]); }); }); describe("roundtrip real content", () => { it("farm map", async () => { const def = parse(await readFile(join(fixturesDir, "farm.yaml"), "utf8")); const schema = contentSchemas.map; const formSchema = derive("map"); const working = applyValue(formSchema, def); const out = coerceForValidate(formSchema, working); expect(schema.safeParse(out).success).toBe(true); expect(JSON.stringify(out)).toBe(JSON.stringify(def)); }); it("time", () => expectRoundtrip("time", "time.yaml")); it("weather", () => expectRoundtrip("weather", "weather.yaml")); it("interact", () => expectRoundtrip("interact", "interact.yaml")); it("item", () => expectRoundtrip("item", "items/hoe.yaml")); it("crop", () => expectRoundtrip("crop", "crops/carrot.yaml")); it("animal", () => expectRoundtrip("animal", "animals/chicken.yaml")); it("shop", () => expectRoundtrip("shop", "shops/general-store.yaml")); it("behavior", () => expectRoundtrip("behavior", "behaviors/starter-gift.yaml")); it("statemachine", () => expectRoundtrip("statemachine", "statemachines/carrot-quest.yaml")); it("dialogue", () => expectRoundtrip("dialogue", "dialogues/mayor.yaml")); it("building", () => expectRoundtrip("building", "buildings/shop.yaml")); it("sprite with rect/circle layers", () => expectRoundtrip("sprite", "sprites/player.yaml")); it("sprite with image layers", () => expectRoundtrip("sprite", "sprites/shop.yaml")); it("sound", () => expectRoundtrip("sound", "sounds/coin.yaml")); it("particle", () => expectRoundtrip("particle", "particles/dust.yaml")); it("shader", () => expectRoundtrip("shader", "shaders/rain-tint.yaml")); it("animation", () => expectRoundtrip("animation", "animations/player.yaml")); it("scene (inline def)", () => { const def = { id: "main", entities: [ { type: "building", buildingId: "shop", x: 1, y: 2 }, { type: "npc", npcId: "mayor", x: 3, y: 4, dialogue: "mayor", label: "Mayor" }, { type: "animal", animalId: "chicken", x: 5, y: 6 }, ], }; const schema = contentSchemas.scene; const formSchema = derive("scene"); const working = applyValue(formSchema, def); const out = coerceForValidate(formSchema, working); expect(schema.safeParse(out).success).toBe(true); expect(JSON.stringify(out)).toBe(JSON.stringify(def)); }); });