| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224 |
- 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<unknown> {
- 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<void> {
- 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<string, unknown>;
- 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<string, unknown> = {};
- 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));
- });
- });
|