animation-schema.test.ts 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148
  1. import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
  2. import { tmpdir } from "node:os";
  3. import { join } from "node:path";
  4. import { describe, expect, it } from "vitest";
  5. import {
  6. actionSchema,
  7. animationSchema,
  8. ContentLoader,
  9. ContentRegistry,
  10. World,
  11. type AnimationDef,
  12. type BehaviorRuntimeCtx,
  13. } from "../src/index.js";
  14. import { runActions } from "../src/systems/behavior-runtime.js";
  15. const VALID_DEF: AnimationDef = {
  16. id: "player",
  17. initial: "idle",
  18. states: [
  19. { id: "idle", spriteRef: "player", loop: true },
  20. { id: "walk", spriteRef: "player-walk", fps: 8, loop: true },
  21. ],
  22. transitions: [
  23. { from: "idle", to: "walk", on: "move" },
  24. { from: "walk", to: "idle", on: "idle" },
  25. ],
  26. };
  27. describe("animation content kind", () => {
  28. it("accepts a valid def with states, optional fps/loop, and optional transitions", () => {
  29. const result = animationSchema.safeParse(VALID_DEF);
  30. expect(result.success).toBe(true);
  31. expect(result.success && result.data.states[1]).toEqual({ id: "walk", spriteRef: "player-walk", fps: 8, loop: true });
  32. });
  33. it("accepts a minimal def without transitions", () => {
  34. const minimal = animationSchema.safeParse({ id: "player", initial: "idle", states: [{ id: "idle", spriteRef: "player" }] });
  35. expect(minimal.success).toBe(true);
  36. });
  37. it("rejects a bad initial state (not a declared state)", () => {
  38. const result = animationSchema.safeParse({ id: "player", initial: "nope", states: [{ id: "idle", spriteRef: "player" }] });
  39. expect(result.success).toBe(false);
  40. if (!result.success) {
  41. expect(result.error.issues.some((issue) => issue.path.join(".") === "initial" && /must be a declared state/.test(issue.message))).toBe(true);
  42. }
  43. });
  44. it("rejects duplicate state ids", () => {
  45. const result = animationSchema.safeParse({
  46. id: "player",
  47. initial: "idle",
  48. states: [
  49. { id: "idle", spriteRef: "player" },
  50. { id: "idle", spriteRef: "player-walk" },
  51. ],
  52. });
  53. expect(result.success).toBe(false);
  54. if (!result.success) {
  55. expect(result.error.issues.some((issue) => issue.path.join(".") === "states" && /duplicate state id/.test(issue.message))).toBe(true);
  56. }
  57. });
  58. it("rejects transitions referencing undeclared states", () => {
  59. const result = animationSchema.safeParse({
  60. id: "player",
  61. initial: "idle",
  62. states: [{ id: "idle", spriteRef: "player" }],
  63. transitions: [{ from: "idle", to: "dance", on: "x" }],
  64. });
  65. expect(result.success).toBe(false);
  66. if (!result.success) {
  67. expect(result.error.issues.some((issue) => issue.path.join(".") === "transitions.0.to" && /must be a declared state/.test(issue.message))).toBe(true);
  68. }
  69. });
  70. it("rejects non-positive fps and strict unknown keys", () => {
  71. expect(animationSchema.safeParse({ id: "x", initial: "a", states: [{ id: "a", spriteRef: "s", fps: 0 }] }).success).toBe(false);
  72. expect(animationSchema.safeParse({ id: "x", initial: "a", states: [{ id: "a", spriteRef: "s", extra: true }] }).success).toBe(false);
  73. expect(animationSchema.safeParse({ id: "x", initial: "a", states: [{ id: "a", spriteRef: "s" }], extra: true }).success).toBe(false);
  74. });
  75. it("loads and validates animation YAML from content/animations/", async () => {
  76. const tmp = await mkdtemp(join(tmpdir(), "farm-animation-"));
  77. try {
  78. await mkdir(join(tmp, "animations"), { recursive: true });
  79. await writeFile(join(tmp, "animations", "player.yaml"), [
  80. "id: player",
  81. "initial: idle",
  82. "states:",
  83. " - { id: idle, spriteRef: player, loop: true }",
  84. " - { id: walk, spriteRef: player-walk, fps: 8 }",
  85. "",
  86. ].join("\n"));
  87. const registry = new ContentRegistry();
  88. const loader = new ContentLoader(registry);
  89. const report = await loader.load(tmp);
  90. expect(report.ok).toBe(true);
  91. expect(registry.ids("animation")).toEqual(["player"]);
  92. expect(registry.get<AnimationDef>("animation", "player")).toEqual({
  93. id: "player",
  94. initial: "idle",
  95. states: [
  96. { id: "idle", spriteRef: "player", loop: true },
  97. { id: "walk", spriteRef: "player-walk", fps: 8 },
  98. ],
  99. });
  100. } finally {
  101. await rm(tmp, { recursive: true, force: true });
  102. }
  103. });
  104. it("rejects a loader-level invalid animation file without aborting siblings", async () => {
  105. const tmp = await mkdtemp(join(tmpdir(), "farm-animation-bad-"));
  106. try {
  107. await mkdir(join(tmp, "animations"), { recursive: true });
  108. await writeFile(join(tmp, "animations", "bad.yaml"), "id: bad\ninitial: nope\nstates: [{ id: idle, spriteRef: player }]\n");
  109. const registry = new ContentRegistry();
  110. const loader = new ContentLoader(registry);
  111. const report = await loader.load(tmp);
  112. expect(report.ok).toBe(false);
  113. const bad = report.items.find((i) => i.id === "bad");
  114. expect(bad?.status).toBe("error");
  115. expect(registry.get("animation", "bad")).toBeUndefined();
  116. } finally {
  117. await rm(tmp, { recursive: true, force: true });
  118. }
  119. });
  120. });
  121. describe("set-state null clears an entity's anim field (D38)", () => {
  122. it("accepts null as a set-state value in the action schema", () => {
  123. const parsed = actionSchema.safeParse({ action: "set-state", path: "player.anim", value: null });
  124. expect(parsed.success).toBe(true);
  125. });
  126. it("runtime set-state writes null, clearing a previously-set anim string", () => {
  127. const world = new World();
  128. world.create({ id: "player", type: "player", x: 0, y: 0, anim: "dance" });
  129. const ctx: BehaviorRuntimeCtx = { world };
  130. runActions(ctx, [{ action: "set-state", path: "player.anim", value: null }]);
  131. expect(world.get("player")).toMatchObject({ anim: null });
  132. runActions(ctx, [{ action: "set-state", path: "player.anim", value: "dance" }]);
  133. expect(world.get("player")).toMatchObject({ anim: "dance" });
  134. runActions(ctx, [{ action: "set-state", path: "player.anim", value: null }]);
  135. expect(world.get("player")).toMatchObject({ anim: null });
  136. });
  137. });