playtest-session.test.ts 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  1. import { describe, expect, it } from "vitest";
  2. import { createPlaytestSession, keydownInput, saveAllFlushes } from "../src/renderer/playtest-session.js";
  3. describe("saveAllFlushes", () => {
  4. it("writes clean flushes and reports them as flushed", async () => {
  5. const written: string[] = [];
  6. const result = await saveAllFlushes(
  7. [
  8. { path: "a.yaml", content: "x" },
  9. { path: "b.yaml", content: "y" },
  10. ],
  11. async (path, content) => {
  12. written.push(`${path}:${content}`);
  13. },
  14. );
  15. expect(result.flushed).toEqual(["a.yaml", "b.yaml"]);
  16. expect(result.failures).toEqual([]);
  17. expect(written).toEqual(["a.yaml:x", "b.yaml:y"]);
  18. });
  19. it("records error flushes and rejecting writes as failures without throwing", async () => {
  20. const result = await saveAllFlushes(
  21. [
  22. { path: "bad.yaml", error: "map invalid" },
  23. { path: "throws.yaml", content: "z" },
  24. { path: "ok.yaml", content: "ok" },
  25. ],
  26. async (path) => {
  27. if (path === "throws.yaml") throw new Error("disk full");
  28. },
  29. );
  30. expect(result.flushed).toEqual(["ok.yaml"]);
  31. expect(result.failures).toEqual([
  32. { path: "bad.yaml", error: "map invalid" },
  33. { path: "throws.yaml", error: "disk full" },
  34. ]);
  35. });
  36. it("writes nothing when there are no flushes", async () => {
  37. const result = await saveAllFlushes([], async () => {
  38. throw new Error("unreachable");
  39. });
  40. expect(result.flushed).toEqual([]);
  41. expect(result.failures).toEqual([]);
  42. });
  43. });
  44. describe("keydownInput", () => {
  45. it("maps arrow keys to raw inputs", () => {
  46. expect(keydownInput("ArrowUp")).toBe("key:ArrowUp");
  47. expect(keydownInput("ArrowDown")).toBe("key:ArrowDown");
  48. expect(keydownInput("ArrowLeft")).toBe("key:ArrowLeft");
  49. expect(keydownInput("ArrowRight")).toBe("key:ArrowRight");
  50. });
  51. it("returns undefined for non-arrow keys", () => {
  52. expect(keydownInput("a")).toBeUndefined();
  53. expect(keydownInput("Enter")).toBeUndefined();
  54. expect(keydownInput(" ")).toBeUndefined();
  55. });
  56. });
  57. describe("createPlaytestSession", () => {
  58. it("runs save-all → compile → boot → playing, then stop tears down", async () => {
  59. const phases: string[] = [];
  60. let booted: unknown = null;
  61. let tornDown = false;
  62. const session = createPlaytestSession({
  63. collectFlushes: () => [{ path: "m.yaml", content: "m" }],
  64. write: async () => {},
  65. compile: async () => ({ format: "nengine-bundle", version: 1, content: {} }),
  66. boot: async (bundle) => {
  67. booted = bundle;
  68. },
  69. teardown: async () => {
  70. tornDown = true;
  71. },
  72. onPhase: (phase) => phases.push(phase),
  73. });
  74. const result = await session.start();
  75. expect(result.ok).toBe(true);
  76. expect(result.saved).toEqual(["m.yaml"]);
  77. expect(booted).toEqual({ format: "nengine-bundle", version: 1, content: {} });
  78. expect(session.phase).toBe("playing");
  79. expect(phases).toEqual(["saving", "compiling", "booting", "playing"]);
  80. await session.stop();
  81. expect(tornDown).toBe(true);
  82. expect(session.phase).toBe("idle");
  83. expect(phases[phases.length - 1]).toBe("idle");
  84. });
  85. it("fails on an un-serializable model and never compiles", async () => {
  86. let compiled = false;
  87. const session = createPlaytestSession({
  88. collectFlushes: () => [{ path: "bad.yaml", error: "map invalid" }],
  89. write: async () => {},
  90. compile: async () => {
  91. compiled = true;
  92. throw new Error("nope");
  93. },
  94. boot: async () => {},
  95. teardown: async () => {},
  96. });
  97. const result = await session.start();
  98. expect(result.ok).toBe(false);
  99. expect(result.saved).toEqual([]);
  100. expect(result.error).toContain("bad.yaml");
  101. expect(result.error).toContain("map invalid");
  102. expect(compiled).toBe(false);
  103. expect(session.phase).toBe("idle");
  104. });
  105. it("fails on a write rejection", async () => {
  106. const session = createPlaytestSession({
  107. collectFlushes: () => [{ path: "a.yaml", content: "a" }],
  108. write: async () => {
  109. throw new Error("disk full");
  110. },
  111. compile: async () => {
  112. throw new Error("unreachable");
  113. },
  114. boot: async () => {},
  115. teardown: async () => {},
  116. });
  117. const result = await session.start();
  118. expect(result.ok).toBe(false);
  119. expect(result.saved).toEqual([]);
  120. expect(result.error).toContain("disk full");
  121. });
  122. it("fails on a compile error with the compile message", async () => {
  123. let booted = false;
  124. const session = createPlaytestSession({
  125. collectFlushes: () => [],
  126. write: async () => {},
  127. compile: async () => {
  128. throw new Error("content validation failed");
  129. },
  130. boot: async () => {
  131. booted = true;
  132. },
  133. teardown: async () => {},
  134. });
  135. const result = await session.start();
  136. expect(result.ok).toBe(false);
  137. expect(result.error).toContain("content validation failed");
  138. expect(booted).toBe(false);
  139. expect(session.phase).toBe("idle");
  140. });
  141. it("fails on a boot error with the boot message", async () => {
  142. const session = createPlaytestSession({
  143. collectFlushes: () => [],
  144. write: async () => {},
  145. compile: async () => ({ format: "nengine-bundle", version: 1, content: {} }),
  146. boot: async () => {
  147. throw new Error("no map content loaded");
  148. },
  149. teardown: async () => {},
  150. });
  151. const result = await session.start();
  152. expect(result.ok).toBe(false);
  153. expect(result.error).toContain("no map content loaded");
  154. expect(session.phase).toBe("idle");
  155. });
  156. it("rejects start while already running", async () => {
  157. let resolveBoot: (() => void) | undefined;
  158. const session = createPlaytestSession({
  159. collectFlushes: () => [],
  160. write: async () => {},
  161. compile: async () => ({ format: "nengine-bundle", version: 1, content: {} }),
  162. boot: async () => {
  163. await new Promise<void>((resolve) => {
  164. resolveBoot = resolve;
  165. });
  166. },
  167. teardown: async () => {},
  168. });
  169. const first = session.start();
  170. await Promise.resolve();
  171. const second = await session.start();
  172. expect(second.ok).toBe(false);
  173. expect(second.error).toBe("playtest already running");
  174. resolveBoot?.();
  175. await first;
  176. expect(session.phase).toBe("playing");
  177. });
  178. it("stop is a no-op when idle", async () => {
  179. let tornDown = false;
  180. const session = createPlaytestSession({
  181. collectFlushes: () => [],
  182. write: async () => {},
  183. compile: async () => ({ format: "nengine-bundle", version: 1, content: {} }),
  184. boot: async () => {},
  185. teardown: async () => {
  186. tornDown = true;
  187. },
  188. });
  189. await session.stop();
  190. expect(tornDown).toBe(false);
  191. expect(session.phase).toBe("idle");
  192. });
  193. });