| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289 |
- 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,
- );
- });
- });
|