import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { actionSchema, animationSchema, ContentLoader, ContentRegistry, World, type AnimationDef, type BehaviorRuntimeCtx, } from "../src/index.js"; import { runActions } from "../src/systems/behavior-runtime.js"; const VALID_DEF: AnimationDef = { id: "player", initial: "idle", states: [ { id: "idle", spriteRef: "player", loop: true }, { id: "walk", spriteRef: "player-walk", fps: 8, loop: true }, ], transitions: [ { from: "idle", to: "walk", on: "move" }, { from: "walk", to: "idle", on: "idle" }, ], }; describe("animation content kind", () => { it("accepts a valid def with states, optional fps/loop, and optional transitions", () => { const result = animationSchema.safeParse(VALID_DEF); expect(result.success).toBe(true); expect(result.success && result.data.states[1]).toEqual({ id: "walk", spriteRef: "player-walk", fps: 8, loop: true }); }); it("accepts a minimal def without transitions", () => { const minimal = animationSchema.safeParse({ id: "player", initial: "idle", states: [{ id: "idle", spriteRef: "player" }] }); expect(minimal.success).toBe(true); }); it("rejects a bad initial state (not a declared state)", () => { const result = animationSchema.safeParse({ id: "player", initial: "nope", states: [{ id: "idle", spriteRef: "player" }] }); expect(result.success).toBe(false); if (!result.success) { expect(result.error.issues.some((issue) => issue.path.join(".") === "initial" && /must be a declared state/.test(issue.message))).toBe(true); } }); it("rejects duplicate state ids", () => { const result = animationSchema.safeParse({ id: "player", initial: "idle", states: [ { id: "idle", spriteRef: "player" }, { id: "idle", spriteRef: "player-walk" }, ], }); expect(result.success).toBe(false); if (!result.success) { expect(result.error.issues.some((issue) => issue.path.join(".") === "states" && /duplicate state id/.test(issue.message))).toBe(true); } }); it("rejects transitions referencing undeclared states", () => { const result = animationSchema.safeParse({ id: "player", initial: "idle", states: [{ id: "idle", spriteRef: "player" }], transitions: [{ from: "idle", to: "dance", on: "x" }], }); expect(result.success).toBe(false); if (!result.success) { expect(result.error.issues.some((issue) => issue.path.join(".") === "transitions.0.to" && /must be a declared state/.test(issue.message))).toBe(true); } }); it("rejects non-positive fps and strict unknown keys", () => { expect(animationSchema.safeParse({ id: "x", initial: "a", states: [{ id: "a", spriteRef: "s", fps: 0 }] }).success).toBe(false); expect(animationSchema.safeParse({ id: "x", initial: "a", states: [{ id: "a", spriteRef: "s", extra: true }] }).success).toBe(false); expect(animationSchema.safeParse({ id: "x", initial: "a", states: [{ id: "a", spriteRef: "s" }], extra: true }).success).toBe(false); }); it("loads and validates animation YAML from content/animations/", async () => { const tmp = await mkdtemp(join(tmpdir(), "farm-animation-")); try { await mkdir(join(tmp, "animations"), { recursive: true }); await writeFile(join(tmp, "animations", "player.yaml"), [ "id: player", "initial: idle", "states:", " - { id: idle, spriteRef: player, loop: true }", " - { id: walk, spriteRef: player-walk, fps: 8 }", "", ].join("\n")); const registry = new ContentRegistry(); const loader = new ContentLoader(registry); const report = await loader.load(tmp); expect(report.ok).toBe(true); expect(registry.ids("animation")).toEqual(["player"]); expect(registry.get("animation", "player")).toEqual({ id: "player", initial: "idle", states: [ { id: "idle", spriteRef: "player", loop: true }, { id: "walk", spriteRef: "player-walk", fps: 8 }, ], }); } finally { await rm(tmp, { recursive: true, force: true }); } }); it("rejects a loader-level invalid animation file without aborting siblings", async () => { const tmp = await mkdtemp(join(tmpdir(), "farm-animation-bad-")); try { await mkdir(join(tmp, "animations"), { recursive: true }); await writeFile(join(tmp, "animations", "bad.yaml"), "id: bad\ninitial: nope\nstates: [{ id: idle, spriteRef: player }]\n"); const registry = new ContentRegistry(); const loader = new ContentLoader(registry); const report = await loader.load(tmp); expect(report.ok).toBe(false); const bad = report.items.find((i) => i.id === "bad"); expect(bad?.status).toBe("error"); expect(registry.get("animation", "bad")).toBeUndefined(); } finally { await rm(tmp, { recursive: true, force: true }); } }); }); describe("set-state null clears an entity's anim field (D38)", () => { it("accepts null as a set-state value in the action schema", () => { const parsed = actionSchema.safeParse({ action: "set-state", path: "player.anim", value: null }); expect(parsed.success).toBe(true); }); it("runtime set-state writes null, clearing a previously-set anim string", () => { const world = new World(); world.create({ id: "player", type: "player", x: 0, y: 0, anim: "dance" }); const ctx: BehaviorRuntimeCtx = { world }; runActions(ctx, [{ action: "set-state", path: "player.anim", value: null }]); expect(world.get("player")).toMatchObject({ anim: null }); runActions(ctx, [{ action: "set-state", path: "player.anim", value: "dance" }]); expect(world.get("player")).toMatchObject({ anim: "dance" }); runActions(ctx, [{ action: "set-state", path: "player.anim", value: null }]); expect(world.get("player")).toMatchObject({ anim: null }); }); });