sprite-sound.test.ts 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
  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. ContentLoader,
  7. ContentRegistry,
  8. spriteSchema,
  9. soundSchema,
  10. type SoundDef,
  11. type SpriteDef,
  12. } from "../src/index.js";
  13. const SPRITE_YAML = [
  14. "id: parsnip-mature",
  15. "frames:",
  16. " - name: idle",
  17. " durationTicks: 1",
  18. " layers:",
  19. " - { shape: rect, x: 8, y: 8, w: 16, h: 16, fill: '#2e7d32' }",
  20. " - { shape: circle, x: 16, y: 8, r: 6, fill: '#66bb6a' }",
  21. "",
  22. ].join("\n");
  23. const SOUND_YAML = [
  24. "id: coin",
  25. "synth:",
  26. " wave: sine",
  27. " freq: 880",
  28. " durationMs: 120",
  29. " gain: 0.5",
  30. " slideTo: 1320",
  31. "",
  32. ].join("\n");
  33. async function setupLoader(): Promise<{ registry: ContentRegistry; loader: ContentLoader; tmp: string }> {
  34. const tmp = await mkdtemp(join(tmpdir(), "farm-sprite-sound-"));
  35. await mkdir(join(tmp, "sprites"), { recursive: true });
  36. await mkdir(join(tmp, "sounds"), { recursive: true });
  37. await writeFile(join(tmp, "sprites", "parsnip-mature.yaml"), SPRITE_YAML);
  38. await writeFile(join(tmp, "sounds", "coin.yaml"), SOUND_YAML);
  39. const registry = new ContentRegistry();
  40. const loader = new ContentLoader(registry);
  41. return { registry, loader, tmp };
  42. }
  43. describe("sprite content kind", () => {
  44. it("loads and validates sprite YAML from content/sprites/", async () => {
  45. const { registry, loader, tmp } = await setupLoader();
  46. try {
  47. const report = await loader.load(tmp);
  48. expect(report.ok).toBe(true);
  49. expect(registry.ids("sprite")).toEqual(["parsnip-mature"]);
  50. const sprite = registry.get<SpriteDef>("sprite", "parsnip-mature");
  51. expect(sprite).toEqual({
  52. id: "parsnip-mature",
  53. frames: [
  54. {
  55. name: "idle",
  56. durationTicks: 1,
  57. layers: [
  58. { shape: "rect", x: 8, y: 8, w: 16, h: 16, fill: "#2e7d32" },
  59. { shape: "circle", x: 16, y: 8, r: 6, fill: "#66bb6a" },
  60. ],
  61. },
  62. ],
  63. });
  64. } finally {
  65. await rm(tmp, { recursive: true, force: true });
  66. }
  67. });
  68. it("rejects an invalid sprite def (bad fill, unknown shape, strict unknown keys)", () => {
  69. expect(
  70. spriteSchema.safeParse({
  71. id: "bad",
  72. frames: [{ layers: [{ shape: "rect", x: 0, y: 0, w: 4, h: 4, fill: "green" }] }],
  73. }).success,
  74. ).toBe(false);
  75. expect(
  76. spriteSchema.safeParse({
  77. id: "bad",
  78. frames: [{ layers: [{ shape: "triangle", x: 0, y: 0, fill: "#ffffff" }] }],
  79. }).success,
  80. ).toBe(false);
  81. expect(
  82. spriteSchema.safeParse({
  83. id: "bad",
  84. frames: [{ layers: [{ shape: "rect", x: 0, y: 0, w: 4, h: 4, fill: "#ffffff", extra: true }] }],
  85. }).success,
  86. ).toBe(false);
  87. expect(spriteSchema.safeParse({ id: "bad", frames: [] }).success).toBe(false);
  88. });
  89. it("reports a bad sprite file as a loader error without crashing siblings", async () => {
  90. const { registry, loader, tmp } = await setupLoader();
  91. try {
  92. await writeFile(join(tmp, "sprites", "broken.yaml"), "id: broken\nframes: []\n");
  93. const report = await loader.load(tmp);
  94. expect(report.ok).toBe(false);
  95. const broken = report.items.find((i) => i.id === "broken");
  96. expect(broken?.status).toBe("error");
  97. expect(broken?.errors?.join(" ")).toMatch(/frames/);
  98. expect(registry.get("sprite", "parsnip-mature")).toBeDefined();
  99. expect(registry.get("sprite", "broken")).toBeUndefined();
  100. } finally {
  101. await rm(tmp, { recursive: true, force: true });
  102. }
  103. });
  104. });
  105. describe("sound content kind", () => {
  106. it("loads and validates sound YAML from content/sounds/", async () => {
  107. const { registry, loader, tmp } = await setupLoader();
  108. try {
  109. const report = await loader.load(tmp);
  110. expect(report.ok).toBe(true);
  111. expect(registry.ids("sound")).toEqual(["coin"]);
  112. const sound = registry.get<SoundDef>("sound", "coin");
  113. expect(sound).toEqual({
  114. id: "coin",
  115. synth: { wave: "sine", freq: 880, durationMs: 120, gain: 0.5, slideTo: 1320 },
  116. });
  117. } finally {
  118. await rm(tmp, { recursive: true, force: true });
  119. }
  120. });
  121. it("rejects an invalid sound def (neither synth nor src, bad wave, strict keys)", () => {
  122. expect(soundSchema.safeParse({ id: "silent" }).success).toBe(false);
  123. expect(soundSchema.safeParse({ id: "bad", synth: { wave: "noise", freq: 1, durationMs: 1 } }).success).toBe(false);
  124. expect(
  125. soundSchema.safeParse({ id: "bad", synth: { wave: "sine", freq: 1, durationMs: 1, extra: true } }).success,
  126. ).toBe(false);
  127. });
  128. it("accepts a src-only def and a synth+src def", () => {
  129. expect(soundSchema.safeParse({ id: "file-only", src: "sfx/coin.ogg" }).success).toBe(true);
  130. expect(
  131. soundSchema.safeParse({
  132. id: "both",
  133. synth: { wave: "square", freq: 440, durationMs: 50 },
  134. src: "sfx/coin.ogg",
  135. }).success,
  136. ).toBe(true);
  137. });
  138. });