form-value.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  1. import { readFile } from "node:fs/promises";
  2. import { join } from "node:path";
  3. import { fileURLToPath } from "node:url";
  4. import { contentSchemas } from "@nengine/core-schema";
  5. import { parse } from "yaml";
  6. import { describe, expect, it } from "vitest";
  7. import { deriveFormSchema } from "../src/renderer/form-schema.js";
  8. import type { FormFieldSchema, FormSchema } from "../src/renderer/form-schema.js";
  9. import {
  10. applyValue,
  11. coerceForValidate,
  12. detectUnionBranch,
  13. getPath,
  14. initialItemValue,
  15. initialValue,
  16. setPath,
  17. unionBranchValue,
  18. } from "../src/renderer/form-value.js";
  19. const sampleDir = fileURLToPath(new URL("../../../examples/sample-game/content", import.meta.url));
  20. const fixturesDir = fileURLToPath(new URL("./fixtures", import.meta.url));
  21. async function loadYaml(rel: string): Promise<unknown> {
  22. return parse(await readFile(join(sampleDir, rel), "utf8"));
  23. }
  24. function derive(kind: keyof typeof contentSchemas) {
  25. return deriveFormSchema(contentSchemas[kind]);
  26. }
  27. function fieldOf(schema: FormSchema, name: string): FormFieldSchema {
  28. const found = schema.fields.find((f) => f.name === name);
  29. if (found === undefined) throw new Error(`no field "${name}"`);
  30. return found;
  31. }
  32. /** derive → applyValue → coerceForValidate must be valid and JSON-identical
  33. * to the original def when nothing was edited. */
  34. async function expectRoundtrip(kind: keyof typeof contentSchemas, rel: string): Promise<void> {
  35. const def = await loadYaml(rel);
  36. const schema = contentSchemas[kind];
  37. const parsed = schema.safeParse(def);
  38. expect(parsed.success, `${kind} fixture must validate`).toBe(true);
  39. const formSchema = derive(kind);
  40. const working = applyValue(formSchema, def);
  41. const out = coerceForValidate(formSchema, working);
  42. const validated = schema.safeParse(out);
  43. expect(validated.success, `${kind} roundtrip must validate`).toBe(true);
  44. expect(JSON.stringify(out)).toBe(JSON.stringify(def));
  45. }
  46. describe("initialValue", () => {
  47. it("builds a blank item def", () => {
  48. const value = initialValue(derive("item"));
  49. expect(value).toEqual({ id: "", name: "", kind: "tool", stackable: false });
  50. });
  51. it("leaves optional fields absent and arrays empty", () => {
  52. const value = initialValue(derive("crop"));
  53. expect(value).toEqual({
  54. id: "",
  55. seedItem: "",
  56. stages: [],
  57. seasons: [],
  58. drops: [],
  59. });
  60. });
  61. it("picks the first enum value and the first union branch", () => {
  62. expect(initialValue(derive("interact"))).toEqual({ id: "", order: [] });
  63. expect(initialValue(derive("sprite"))).toEqual({ id: "", frames: [] });
  64. });
  65. it("starts a union at the first branch with the discriminator set", () => {
  66. const frames = fieldOf(derive("sprite"), "frames");
  67. const layers = fieldOf({ fields: frames.element?.fields ?? [] }, "layers");
  68. const layer = layers.element as FormFieldSchema;
  69. expect(initialItemValue(layer)).toEqual({ shape: "rect", x: 0, y: 0, fill: "" });
  70. });
  71. });
  72. describe("applyValue", () => {
  73. it("preserves unknown fields losslessly", async () => {
  74. const def = (await loadYaml("crops/carrot.yaml")) as Record<string, unknown>;
  75. def.customField = { nested: [1, 2, 3] };
  76. const working = applyValue(derive("crop"), def);
  77. expect(working.customField).toEqual({ nested: [1, 2, 3] });
  78. expect(working.seedItem).toBe("carrot-seed");
  79. const out = coerceForValidate(derive("crop"), working);
  80. expect(out.customField).toEqual({ nested: [1, 2, 3] });
  81. });
  82. it("coerces wrong-typed primitives to the schema shape", () => {
  83. const working = applyValue(derive("item"), { id: 5, name: true, kind: 3, stackable: "true", price: "10" });
  84. expect(working.id).toBe("5");
  85. expect(working.name).toBe("true");
  86. expect(working.kind).toBe(3);
  87. expect(working.stackable).toBe(true);
  88. expect(working.price).toBe(10);
  89. });
  90. it("keeps any-typed values as-is and fills required unions", () => {
  91. const working = applyValue(derive("behavior"), {});
  92. expect(working.when).toEqual({ op: "eq", path: "", value: "" });
  93. });
  94. });
  95. describe("coerceForValidate", () => {
  96. it("drops emptied optional fields but keeps required empties for validation", () => {
  97. const formSchema = derive("item");
  98. const value = {
  99. id: "",
  100. name: "hoe",
  101. kind: "tool",
  102. stackable: false,
  103. price: "",
  104. use: "",
  105. };
  106. const out = coerceForValidate(formSchema, value);
  107. expect(out).toEqual({ id: "", name: "hoe", kind: "tool", stackable: false });
  108. });
  109. it("parses any-typed strings back to numbers and booleans", () => {
  110. const fire = fieldOf(derive("behavior"), "fire");
  111. const action = fire.element as FormFieldSchema;
  112. const value = { action: "emit-event", event: "particle", payload: { count: "42", flag: "true", id: "sparkle" } };
  113. const out = coerceForValidate({ fields: [action] }, { fire: value });
  114. const payload = out.fire as { payload?: unknown } | undefined;
  115. expect(payload?.payload).toEqual({ count: 42, flag: true, id: "sparkle" });
  116. });
  117. });
  118. describe("union value model", () => {
  119. it("detects ConditionExpr branches by required fields and enum values", () => {
  120. const transitions = fieldOf(derive("statemachine"), "transitions");
  121. const when = fieldOf({ fields: transitions.element?.fields ?? [] }, "when");
  122. expect(detectUnionBranch(when, { op: "eq", path: "x", value: "y" })?.label).toBe("eq / gte / lte");
  123. expect(detectUnionBranch(when, { op: "exists", path: "x" })?.label).toBe("exists");
  124. expect(detectUnionBranch(when, { and: [{ op: "exists", path: "x" }] })?.label).toBe("And");
  125. expect(detectUnionBranch(when, { or: [] })?.label).toBe("Or");
  126. expect(detectUnionBranch(when, { not: { op: "exists", path: "x" } })?.label).toBe("Not");
  127. });
  128. it("detects discriminated union branches by the discriminator", () => {
  129. const frames = fieldOf(derive("sprite"), "frames");
  130. const layers = fieldOf({ fields: frames.element?.fields ?? [] }, "layers");
  131. const layer = layers.element as FormFieldSchema;
  132. expect(detectUnionBranch(layer, { shape: "rect", x: 1, y: 2, fill: "#fff" })?.id).toBe("rect");
  133. expect(detectUnionBranch(layer, { shape: "image", src: "a" })?.id).toBe("image");
  134. });
  135. it("builds a fresh branch value on switch", () => {
  136. const frames = fieldOf(derive("sprite"), "frames");
  137. const layers = fieldOf({ fields: frames.element?.fields ?? [] }, "layers");
  138. const layer = layers.element as FormFieldSchema;
  139. const image = (layer.branches ?? []).find((b) => b.id === "image");
  140. if (image === undefined) throw new Error("no image branch");
  141. expect(unionBranchValue(layer, image)).toEqual({ shape: "image", src: "" });
  142. });
  143. it("resolves building buildable literal branches", () => {
  144. const buildable = fieldOf(derive("building"), "buildable");
  145. expect(detectUnionBranch(buildable, "map")?.id).toBe("map");
  146. expect(detectUnionBranch(buildable, { tiles: ["grass"] })?.id).toBe("branch-2");
  147. });
  148. });
  149. describe("getPath/setPath", () => {
  150. it("reads and writes deep paths through arrays and objects", () => {
  151. const value: Record<string, unknown> = {};
  152. setPath(value, ["stages", 1, "spriteRef"], "grown");
  153. expect(getPath(value, ["stages", 1, "spriteRef"])).toBe("grown");
  154. expect(Array.isArray(getPath(value, ["stages"]))).toBe(true);
  155. setPath(value, ["stages", 0, "name"], "sprout");
  156. expect(getPath(value, ["stages"])).toEqual([{ name: "sprout" }, { spriteRef: "grown" }]);
  157. });
  158. });
  159. describe("roundtrip real content", () => {
  160. it("farm map", async () => {
  161. const def = parse(await readFile(join(fixturesDir, "farm.yaml"), "utf8"));
  162. const schema = contentSchemas.map;
  163. const formSchema = derive("map");
  164. const working = applyValue(formSchema, def);
  165. const out = coerceForValidate(formSchema, working);
  166. expect(schema.safeParse(out).success).toBe(true);
  167. expect(JSON.stringify(out)).toBe(JSON.stringify(def));
  168. });
  169. it("time", () => expectRoundtrip("time", "time.yaml"));
  170. it("weather", () => expectRoundtrip("weather", "weather.yaml"));
  171. it("interact", () => expectRoundtrip("interact", "interact.yaml"));
  172. it("item", () => expectRoundtrip("item", "items/hoe.yaml"));
  173. it("crop", () => expectRoundtrip("crop", "crops/carrot.yaml"));
  174. it("animal", () => expectRoundtrip("animal", "animals/chicken.yaml"));
  175. it("shop", () => expectRoundtrip("shop", "shops/general-store.yaml"));
  176. it("behavior", () => expectRoundtrip("behavior", "behaviors/starter-gift.yaml"));
  177. it("statemachine", () => expectRoundtrip("statemachine", "statemachines/carrot-quest.yaml"));
  178. it("dialogue", () => expectRoundtrip("dialogue", "dialogues/mayor.yaml"));
  179. it("building", () => expectRoundtrip("building", "buildings/shop.yaml"));
  180. it("sprite with rect/circle layers", () => expectRoundtrip("sprite", "sprites/player.yaml"));
  181. it("sprite with image layers", () => expectRoundtrip("sprite", "sprites/shop.yaml"));
  182. it("sound", () => expectRoundtrip("sound", "sounds/coin.yaml"));
  183. it("particle", () => expectRoundtrip("particle", "particles/dust.yaml"));
  184. it("shader", () => expectRoundtrip("shader", "shaders/rain-tint.yaml"));
  185. it("animation", () => expectRoundtrip("animation", "animations/player.yaml"));
  186. it("scene (inline def)", () => {
  187. const def = {
  188. id: "main",
  189. entities: [
  190. { type: "building", buildingId: "shop", x: 1, y: 2 },
  191. { type: "npc", npcId: "mayor", x: 3, y: 4, dialogue: "mayor", label: "Mayor" },
  192. { type: "animal", animalId: "chicken", x: 5, y: 6 },
  193. ],
  194. };
  195. const schema = contentSchemas.scene;
  196. const formSchema = derive("scene");
  197. const working = applyValue(formSchema, def);
  198. const out = coerceForValidate(formSchema, working);
  199. expect(schema.safeParse(out).success).toBe(true);
  200. expect(JSON.stringify(out)).toBe(JSON.stringify(def));
  201. });
  202. });
  203. describe("any-field coercion losslessness (bug-fix round)", () => {
  204. it("keeps leading-zero strings intact — '007' must never become 7 on save", () => {
  205. const schema = contentSchemas.behavior;
  206. const formSchema = derive("behavior");
  207. const def = {
  208. id: "t1",
  209. when: { op: "eq", path: "player.inventory.pumpkin", value: "007" },
  210. fire: [{ action: "set-state", path: "player.label", value: "007" }],
  211. };
  212. const working = applyValue(formSchema, def);
  213. const out = coerceForValidate(formSchema, working);
  214. expect(out.when.value).toBe("007");
  215. expect(out.fire[0].value).toBe("007");
  216. expect(schema.safeParse(out).success).toBe(true);
  217. });
  218. it("still coerces roundtrip-exact input like '42' to a number", () => {
  219. const formSchema = derive("behavior");
  220. const working = applyValue(formSchema, {
  221. id: "t1",
  222. when: { op: "eq", path: "player.inventory.pumpkin", value: "42" },
  223. fire: [],
  224. });
  225. const out = coerceForValidate(formSchema, working);
  226. expect(out.when.value).toBe(42);
  227. });
  228. });