form-schema.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289
  1. import { z } from "zod";
  2. import {
  3. animalSchema,
  4. buildingSchema,
  5. cropSchema,
  6. dialogueSchema,
  7. interactSchema,
  8. itemSchema,
  9. sceneSchema,
  10. shopSchema,
  11. soundSchema,
  12. spriteSchema,
  13. stateMachineSchema,
  14. triggerSchema,
  15. weatherSchema,
  16. } from "@nengine/core-schema";
  17. import { describe, expect, it } from "vitest";
  18. import { FormSchemaError, deriveFormSchema, humanizeId, refKindForField } from "../src/renderer/form-schema.js";
  19. import type { FormFieldSchema } from "../src/renderer/form-schema.js";
  20. function field(schema: z.ZodTypeAny, name: string): FormFieldSchema {
  21. const found = deriveFormSchema(schema).fields.find((f) => f.name === name);
  22. if (found === undefined) throw new Error(`no field "${name}"`);
  23. return found;
  24. }
  25. function fieldIn(fields: FormFieldSchema[], name: string): FormFieldSchema {
  26. const found = fields.find((f) => f.name === name);
  27. if (found === undefined) throw new Error(`no field "${name}"`);
  28. return found;
  29. }
  30. describe("humanizeId", () => {
  31. it("splits camelCase and snake into capitalized words", () => {
  32. expect(humanizeId("spriteRef")).toBe("Sprite ref");
  33. expect(humanizeId("dayLengthTicks")).toBe("Day length ticks");
  34. expect(humanizeId("emitRate")).toBe("Emit rate");
  35. expect(humanizeId("waterRequired")).toBe("Water required");
  36. expect(humanizeId("id")).toBe("Id");
  37. expect(humanizeId("tick_in_day")).toBe("Tick in day");
  38. });
  39. });
  40. describe("refKindForField", () => {
  41. it("maps Id/Ref/Src suffixes to content kinds", () => {
  42. expect(refKindForField("itemId")).toBe("item");
  43. expect(refKindForField("spriteRef")).toBe("sprite");
  44. expect(refKindForField("buildingId")).toBe("building");
  45. expect(refKindForField("animalId")).toBe("animal");
  46. expect(refKindForField("dialogueId")).toBe("dialogue");
  47. expect(refKindForField("soundId")).toBe("sound");
  48. expect(refKindForField("particleId")).toBe("particle");
  49. });
  50. it("maps exact-name table entries", () => {
  51. expect(refKindForField("seedItem")).toBe("item");
  52. expect(refKindForField("sound")).toBe("sound");
  53. expect(refKindForField("shop")).toBe("shop");
  54. expect(refKindForField("dialogue")).toBe("dialogue");
  55. expect(refKindForField("src")).toBe("asset");
  56. expect(refKindForField("filters")).toBe("shader");
  57. });
  58. it("leaves names without a matching kind as plain strings", () => {
  59. expect(refKindForField("tilesetRef")).toBeNull();
  60. expect(refKindForField("npcId")).toBeNull();
  61. expect(refKindForField("id")).toBeNull();
  62. expect(refKindForField("name")).toBeNull();
  63. expect(refKindForField("to")).toBeNull();
  64. expect(refKindForField("from")).toBeNull();
  65. });
  66. });
  67. describe("deriveFormSchema primitives", () => {
  68. it("derives an item schema", () => {
  69. const form = deriveFormSchema(itemSchema);
  70. const names = form.fields.map((f) => f.name);
  71. expect(names).toEqual(["id", "name", "kind", "stackable", "price", "use"]);
  72. expect(field(itemSchema, "id").type).toBe("string");
  73. expect(field(itemSchema, "name").type).toBe("string");
  74. expect(field(itemSchema, "kind").type).toBe("enum");
  75. expect(field(itemSchema, "kind").enumValues).toEqual(["tool", "seed", "material", "consumable", "decoration"]);
  76. expect(field(itemSchema, "stackable").type).toBe("boolean");
  77. expect(field(itemSchema, "price").type).toBe("number");
  78. expect(field(itemSchema, "price").optional).toBe(true);
  79. expect(field(itemSchema, "price").integer).toBe(true);
  80. expect(field(itemSchema, "price").numberMin).toBe(0);
  81. expect(field(itemSchema, "use").optional).toBe(true);
  82. expect(field(itemSchema, "use").type).toBe("string");
  83. });
  84. it("derives number min/max hints", () => {
  85. expect(field(cropSchema, "id").type).toBe("string");
  86. const days = fieldIn(field(cropSchema, "stages").element?.fields ?? [], "days");
  87. expect(days.type).toBe("number");
  88. expect(days.integer).toBe(true);
  89. expect(days.numberMin).toBe(1);
  90. });
  91. });
  92. describe("deriveFormSchema arrays and refs", () => {
  93. it("derives crop stages with spriteRef and drops with itemId", () => {
  94. const stages = field(cropSchema, "stages");
  95. expect(stages.type).toBe("array");
  96. expect(stages.element?.type).toBe("object");
  97. const spriteRef = fieldIn(stages.element?.fields ?? [], "spriteRef");
  98. expect(spriteRef.type).toBe("ref");
  99. expect(spriteRef.refKind).toBe("sprite");
  100. expect(spriteRef.optional).toBe(true);
  101. expect(field(cropSchema, "seedItem").type).toBe("ref");
  102. expect(field(cropSchema, "seedItem").refKind).toBe("item");
  103. const drops = field(cropSchema, "drops");
  104. expect(drops.type).toBe("array");
  105. const itemId = fieldIn(drops.element?.fields ?? [], "itemId");
  106. expect(itemId.type).toBe("ref");
  107. expect(itemId.refKind).toBe("item");
  108. expect(field(cropSchema, "seasons").element?.type).toBe("string");
  109. });
  110. it("derives animal stages and produces", () => {
  111. const stages = field(animalSchema, "stages");
  112. expect(fieldIn(stages.element?.fields ?? [], "spriteRef").refKind).toBe("sprite");
  113. const produces = field(animalSchema, "produces");
  114. expect(fieldIn(produces.element?.fields ?? [], "itemId").refKind).toBe("item");
  115. expect(fieldIn(produces.element?.fields ?? [], "chance").optional).toBe(true);
  116. });
  117. it("derives shop lines", () => {
  118. const buys = field(shopSchema, "buys");
  119. expect(buys.type).toBe("array");
  120. expect(fieldIn(buys.element?.fields ?? [], "itemId").type).toBe("ref");
  121. expect(fieldIn(buys.element?.fields ?? [], "price").integer).toBe(true);
  122. const sells = field(shopSchema, "sells");
  123. expect(fieldIn(sells.element?.fields ?? [], "stock").optional).toBe(true);
  124. });
  125. it("derives sprite frames and the layer discriminated union", () => {
  126. const frames = field(spriteSchema, "frames");
  127. expect(frames.type).toBe("array");
  128. const layers = fieldIn(frames.element?.fields ?? [], "layers");
  129. expect(layers.type).toBe("array");
  130. const layer = layers.element;
  131. expect(layer?.type).toBe("union");
  132. expect(layer?.discriminator).toBe("shape");
  133. const branches = layer?.branches ?? [];
  134. expect(branches.map((b) => b.id)).toEqual(["rect", "circle", "image"]);
  135. const rect = branches[0];
  136. expect(rect?.label).toBe("Rect");
  137. const rectFields = rect?.fields ?? [];
  138. expect(rectFields.map((f) => f.name)).toEqual(["x", "y", "w", "h", "r", "fill"]);
  139. expect(fieldIn(rectFields, "w").optional).toBe(true);
  140. const fill = fieldIn(rectFields, "fill");
  141. expect(fill.pattern).toBe("^#[0-9a-fA-F]{6}$");
  142. const image = branches[2];
  143. const src = fieldIn(image?.fields ?? [], "src");
  144. expect(src.type).toBe("ref");
  145. expect(src.refKind).toBe("asset");
  146. expect(field(spriteSchema, "filters").type).toBe("array");
  147. expect(field(spriteSchema, "filters").element?.type).toBe("ref");
  148. expect(field(spriteSchema, "filters").element?.refKind).toBe("shader");
  149. });
  150. });
  151. describe("deriveFormSchema unions", () => {
  152. it("derives statemachine transitions with a ConditionExpr union", () => {
  153. const transitions = field(stateMachineSchema, "transitions");
  154. expect(transitions.type).toBe("array");
  155. const when = fieldIn(transitions.element?.fields ?? [], "when");
  156. expect(when.type).toBe("union");
  157. expect(when.optional).toBe(true);
  158. const branches = when.branches ?? [];
  159. expect(branches.length).toBe(5);
  160. expect(branches[0]?.label).toBe("eq / gte / lte");
  161. expect(branches[1]?.label).toBe("exists");
  162. expect(branches.map((b) => b.label)).toContain("And");
  163. expect(branches.map((b) => b.label)).toContain("Or");
  164. expect(branches.map((b) => b.label)).toContain("Not");
  165. const andBranch = branches.find((b) => b.label === "And");
  166. expect(andBranch?.fields?.[0]?.type).toBe("array");
  167. });
  168. it("derives the schedule at union (enum + object)", () => {
  169. const onSchedule = field(stateMachineSchema, "onSchedule");
  170. const at = fieldIn(onSchedule.element?.fields ?? [], "at");
  171. expect(at.type).toBe("union");
  172. const branches = at.branches ?? [];
  173. expect(branches[0]?.primitive?.type).toBe("enum");
  174. expect(branches[0]?.primitive?.enumValues).toEqual(["day-start", "season-change"]);
  175. expect(branches[1]?.fields?.map((f) => f.name)).toEqual(["day", "season", "tickInDay"]);
  176. });
  177. it("derives the behavior action union with implicit action discriminator", () => {
  178. const fire = field(triggerSchema, "fire");
  179. expect(fire.type).toBe("array");
  180. const action = fire.element;
  181. expect(action?.type).toBe("union");
  182. expect(action?.discriminator).toBe("action");
  183. const branches = action?.branches ?? [];
  184. expect(branches.map((b) => b.id)).toEqual(["set-state", "add", "emit-event", "give-item", "play-sound", "dialogue"]);
  185. const giveItem = branches.find((b) => b.id === "give-item");
  186. expect(fieldIn(giveItem?.fields ?? [], "itemId").refKind).toBe("item");
  187. const playSound = branches.find((b) => b.id === "play-sound");
  188. expect(fieldIn(playSound?.fields ?? [], "sound").refKind).toBe("sound");
  189. });
  190. it("derives building buildable as a union of literals and object", () => {
  191. const buildable = field(buildingSchema, "buildable");
  192. expect(buildable.type).toBe("union");
  193. const branches = buildable.branches ?? [];
  194. expect(branches.map((b) => b.id)).toEqual(["map", "any", "branch-2"]);
  195. expect(branches[0]?.primitive?.type).toBe("enum");
  196. expect(branches[0]?.primitive?.enumValues).toEqual(["map"]);
  197. expect(branches[1]?.primitive?.enumValues).toEqual(["any"]);
  198. expect(branches[2]?.fields?.map((f) => f.name)).toEqual(["tiles"]);
  199. });
  200. it("derives dialogue nodes with choices", () => {
  201. const nodes = field(dialogueSchema, "nodes");
  202. expect(nodes.type).toBe("array");
  203. const choices = fieldIn(nodes.element?.fields ?? [], "choices");
  204. expect(choices.type).toBe("array");
  205. expect(choices.optional).toBe(true);
  206. expect(fieldIn(choices.element?.fields ?? [], "text").type).toBe("string");
  207. expect(fieldIn(choices.element?.fields ?? [], "to").type).toBe("string");
  208. expect(fieldIn(choices.element?.fields ?? [], "when").type).toBe("union");
  209. expect(field(dialogueSchema, "startNode").type).toBe("string");
  210. });
  211. });
  212. describe("deriveFormSchema scene union", () => {
  213. it("derives scene entities as a discriminated union over type", () => {
  214. const entities = field(sceneSchema, "entities");
  215. expect(entities.type).toBe("array");
  216. const entity = entities.element;
  217. expect(entity?.type).toBe("union");
  218. expect(entity?.discriminator).toBe("type");
  219. const branches = entity?.branches ?? [];
  220. expect(branches.map((b) => b.id)).toEqual(["building", "npc", "animal"]);
  221. const building = branches[0];
  222. expect(building?.label).toBe("Building");
  223. const buildingFields = building?.fields ?? [];
  224. expect(buildingFields.map((f) => f.name)).toEqual(["buildingId", "x", "y"]);
  225. expect(fieldIn(buildingFields, "buildingId").type).toBe("ref");
  226. expect(fieldIn(buildingFields, "buildingId").refKind).toBe("building");
  227. const npc = branches[1];
  228. const npcFields = npc?.fields ?? [];
  229. expect(fieldIn(npcFields, "npcId").type).toBe("string");
  230. expect(fieldIn(npcFields, "dialogue").type).toBe("ref");
  231. expect(fieldIn(npcFields, "dialogue").refKind).toBe("dialogue");
  232. const animal = branches[2];
  233. expect(fieldIn(animal?.fields ?? [], "animalId").refKind).toBe("animal");
  234. });
  235. });
  236. describe("deriveFormSchema records and single-file kinds", () => {
  237. it("derives weather defaults as a record of objects", () => {
  238. const defaults = field(weatherSchema, "defaults");
  239. expect(defaults.type).toBe("record");
  240. expect(defaults.optional).toBe(true);
  241. expect(defaults.recordValue?.type).toBe("object");
  242. expect(field(weatherSchema, "seasons").type).toBe("array");
  243. });
  244. it("derives interact order as an enum array", () => {
  245. const order = field(interactSchema, "order");
  246. expect(order.type).toBe("array");
  247. expect(order.element?.type).toBe("enum");
  248. expect(order.element?.enumValues).toEqual(["harvest", "water", "sow", "till"]);
  249. });
  250. it("derives sound synth and src", () => {
  251. const synth = field(soundSchema, "synth");
  252. expect(synth.type).toBe("object");
  253. expect(synth.optional).toBe(true);
  254. expect(fieldIn(synth.fields ?? [], "wave").enumValues).toEqual(["sine", "square", "triangle", "sawtooth"]);
  255. const src = field(soundSchema, "src");
  256. expect(src.type).toBe("ref");
  257. expect(src.refKind).toBe("asset");
  258. });
  259. });
  260. describe("deriveFormSchema error reporting", () => {
  261. it("throws on unsupported zod constructs instead of mis-rendering", () => {
  262. expect(() => deriveFormSchema(z.tuple([z.string(), z.number()]))).toThrow(FormSchemaError);
  263. expect(() => deriveFormSchema(z.intersection(z.object({ a: z.string() }), z.object({ b: z.number() })))).toThrow(
  264. FormSchemaError,
  265. );
  266. });
  267. });