patchground 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. patchground/__init__.py +10 -0
  2. patchground/__main__.py +3 -0
  3. patchground/authoring/__init__.py +5 -0
  4. patchground/authoring/create.py +179 -0
  5. patchground/authoring/templates/python-debug/TICKET.md.tmpl +11 -0
  6. patchground/authoring/templates/python-debug/challenge.yaml.tmpl +21 -0
  7. patchground/authoring/templates/python-debug/checks/hidden/tests/test_starter_hidden.py.tmpl +13 -0
  8. patchground/authoring/templates/python-debug/checks/visible/tests/test_starter.py.tmpl +9 -0
  9. patchground/authoring/templates/python-debug/environment/Dockerfile.tmpl +3 -0
  10. patchground/authoring/templates/python-debug/solution/reference.patch.tmpl +8 -0
  11. patchground/authoring/templates/python-debug/workspace/pyproject.toml.tmpl +8 -0
  12. patchground/authoring/templates/python-debug/workspace/src/starter.py.tmpl +3 -0
  13. patchground/authoring/templates/python-feature/TICKET.md.tmpl +13 -0
  14. patchground/authoring/templates/python-feature/challenge.yaml.tmpl +22 -0
  15. patchground/authoring/templates/python-feature/checks/hidden/tests/test_starter_hidden.py.tmpl +13 -0
  16. patchground/authoring/templates/python-feature/checks/visible/tests/test_starter.py.tmpl +9 -0
  17. patchground/authoring/templates/python-feature/environment/Dockerfile.tmpl +3 -0
  18. patchground/authoring/templates/python-feature/solution/reference.patch.tmpl +9 -0
  19. patchground/authoring/templates/python-feature/workspace/pyproject.toml.tmpl +8 -0
  20. patchground/authoring/templates/python-feature/workspace/src/starter.py.tmpl +8 -0
  21. patchground/authoring/templates/typescript-debug/TICKET.md.tmpl +11 -0
  22. patchground/authoring/templates/typescript-debug/challenge.yaml.tmpl +21 -0
  23. patchground/authoring/templates/typescript-debug/checks/hidden/tests/starter-hidden.test.ts.tmpl +14 -0
  24. patchground/authoring/templates/typescript-debug/checks/visible/tests/starter.test.ts.tmpl +10 -0
  25. patchground/authoring/templates/typescript-debug/solution/reference.patch.tmpl +9 -0
  26. patchground/authoring/templates/typescript-debug/workspace/package.json.tmpl +6 -0
  27. patchground/authoring/templates/typescript-debug/workspace/src/starter.ts.tmpl +4 -0
  28. patchground/authoring/templates/typescript-feature/TICKET.md.tmpl +13 -0
  29. patchground/authoring/templates/typescript-feature/challenge.yaml.tmpl +22 -0
  30. patchground/authoring/templates/typescript-feature/checks/hidden/tests/starter-hidden.test.ts.tmpl +14 -0
  31. patchground/authoring/templates/typescript-feature/checks/visible/tests/starter.test.ts.tmpl +10 -0
  32. patchground/authoring/templates/typescript-feature/solution/reference.patch.tmpl +10 -0
  33. patchground/authoring/templates/typescript-feature/workspace/package.json.tmpl +6 -0
  34. patchground/authoring/templates/typescript-feature/workspace/src/starter.ts.tmpl +9 -0
  35. patchground/automation/__init__.py +5 -0
  36. patchground/automation/__main__.py +131 -0
  37. patchground/automation/workflow.py +214 -0
  38. patchground/catalog/README.md +10 -0
  39. patchground/catalog/cache-expiry/TICKET.md +49 -0
  40. patchground/catalog/cache-expiry/challenge.yaml +32 -0
  41. patchground/catalog/cache-expiry/checks/hidden/tests/test_cache_hidden.py +164 -0
  42. patchground/catalog/cache-expiry/checks/visible/tests/test_cache.py +56 -0
  43. patchground/catalog/cache-expiry/debrief/questions.md +4 -0
  44. patchground/catalog/cache-expiry/environment/Dockerfile +3 -0
  45. patchground/catalog/cache-expiry/solution/reference.patch +41 -0
  46. patchground/catalog/cache-expiry/workspace/pyproject.toml +8 -0
  47. patchground/catalog/cache-expiry/workspace/src/profilecache/__init__.py +6 -0
  48. patchground/catalog/cache-expiry/workspace/src/profilecache/cache.py +67 -0
  49. patchground/catalog/cache-expiry/workspace/src/profilecache/client.py +38 -0
  50. patchground/catalog/csv-import-row-errors/TICKET.md +97 -0
  51. patchground/catalog/csv-import-row-errors/challenge.yaml +33 -0
  52. patchground/catalog/csv-import-row-errors/checks/hidden/tests/test_import_hidden.py +359 -0
  53. patchground/catalog/csv-import-row-errors/checks/visible/tests/test_import.py +34 -0
  54. patchground/catalog/csv-import-row-errors/debrief/questions.md +5 -0
  55. patchground/catalog/csv-import-row-errors/environment/Dockerfile +3 -0
  56. patchground/catalog/csv-import-row-errors/solution/reference.patch +74 -0
  57. patchground/catalog/csv-import-row-errors/workspace/pyproject.toml +8 -0
  58. patchground/catalog/csv-import-row-errors/workspace/src/stockimport/__init__.py +4 -0
  59. patchground/catalog/csv-import-row-errors/workspace/src/stockimport/importer.py +69 -0
  60. patchground/catalog/csv-import-row-errors/workspace/src/stockimport/models.py +32 -0
  61. patchground/catalog/duplicate-charges-on-retry/TICKET.md +28 -0
  62. patchground/catalog/duplicate-charges-on-retry/challenge.yaml +30 -0
  63. patchground/catalog/duplicate-charges-on-retry/checks/hidden/tests/test_payment_regressions.py +87 -0
  64. patchground/catalog/duplicate-charges-on-retry/checks/visible/tests/test_checkout.py +34 -0
  65. patchground/catalog/duplicate-charges-on-retry/debrief/questions.md +6 -0
  66. patchground/catalog/duplicate-charges-on-retry/environment/Dockerfile +3 -0
  67. patchground/catalog/duplicate-charges-on-retry/solution/reference.patch +11 -0
  68. patchground/catalog/duplicate-charges-on-retry/workspace/pyproject.toml +8 -0
  69. patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/__init__.py +1 -0
  70. patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/gateway.py +38 -0
  71. patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/models.py +19 -0
  72. patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/service.py +23 -0
  73. patchground/catalog/event-bus-subscriptions/TICKET.md +108 -0
  74. patchground/catalog/event-bus-subscriptions/challenge.yaml +33 -0
  75. patchground/catalog/event-bus-subscriptions/checks/hidden/tests/event-bus-contract.test.ts +566 -0
  76. patchground/catalog/event-bus-subscriptions/checks/visible/tests/event-bus.test.ts +70 -0
  77. patchground/catalog/event-bus-subscriptions/debrief/questions.md +5 -0
  78. patchground/catalog/event-bus-subscriptions/environment/Dockerfile +7 -0
  79. patchground/catalog/event-bus-subscriptions/environment/package-lock.json +1054 -0
  80. patchground/catalog/event-bus-subscriptions/environment/package.json +13 -0
  81. patchground/catalog/event-bus-subscriptions/environment/vitest-runner.mjs +124 -0
  82. patchground/catalog/event-bus-subscriptions/solution/reference.patch +82 -0
  83. patchground/catalog/event-bus-subscriptions/workspace/package.json +6 -0
  84. patchground/catalog/event-bus-subscriptions/workspace/src/bus.ts +32 -0
  85. patchground/catalog/order-notifications-boundary/TICKET.md +51 -0
  86. patchground/catalog/order-notifications-boundary/challenge.yaml +32 -0
  87. patchground/catalog/order-notifications-boundary/checks/hidden/tests/test_boundary_hidden.py +87 -0
  88. patchground/catalog/order-notifications-boundary/checks/visible/tests/test_confirmation.py +41 -0
  89. patchground/catalog/order-notifications-boundary/debrief/questions.md +5 -0
  90. patchground/catalog/order-notifications-boundary/environment/Dockerfile +3 -0
  91. patchground/catalog/order-notifications-boundary/solution/reference.patch +77 -0
  92. patchground/catalog/order-notifications-boundary/workspace/pyproject.toml +8 -0
  93. patchground/catalog/order-notifications-boundary/workspace/src/orders/__init__.py +6 -0
  94. patchground/catalog/order-notifications-boundary/workspace/src/orders/app.py +9 -0
  95. patchground/catalog/order-notifications-boundary/workspace/src/orders/models.py +25 -0
  96. patchground/catalog/order-notifications-boundary/workspace/src/orders/service.py +31 -0
  97. patchground/catalog/order-notifications-boundary/workspace/src/orders/smtp_gateway.py +19 -0
  98. patchground/catalog/order-notifications-boundary/workspace/src/orders/sqlite_store.py +44 -0
  99. patchground/catalog/pagination-last-page/TICKET.md +44 -0
  100. patchground/catalog/pagination-last-page/challenge.yaml +31 -0
  101. patchground/catalog/pagination-last-page/checks/hidden/tests/test_pagination_hidden.py +32 -0
  102. patchground/catalog/pagination-last-page/checks/visible/tests/test_pagination.py +37 -0
  103. patchground/catalog/pagination-last-page/debrief/questions.md +9 -0
  104. patchground/catalog/pagination-last-page/environment/Dockerfile +3 -0
  105. patchground/catalog/pagination-last-page/solution/reference.patch +22 -0
  106. patchground/catalog/pagination-last-page/workspace/pyproject.toml +8 -0
  107. patchground/catalog/pagination-last-page/workspace/src/catalog/__init__.py +1 -0
  108. patchground/catalog/pagination-last-page/workspace/src/catalog/pagination.py +52 -0
  109. patchground/catalog/release-version-ordering/TICKET.md +72 -0
  110. patchground/catalog/release-version-ordering/challenge.yaml +31 -0
  111. patchground/catalog/release-version-ordering/checks/hidden/tests/releasekit_contract.test.ts +92 -0
  112. patchground/catalog/release-version-ordering/checks/visible/tests/releasekit.test.ts +31 -0
  113. patchground/catalog/release-version-ordering/debrief/questions.md +9 -0
  114. patchground/catalog/release-version-ordering/environment/Dockerfile +7 -0
  115. patchground/catalog/release-version-ordering/environment/package-lock.json +1054 -0
  116. patchground/catalog/release-version-ordering/environment/package.json +13 -0
  117. patchground/catalog/release-version-ordering/environment/vitest-runner.mjs +124 -0
  118. patchground/catalog/release-version-ordering/solution/reference.patch +36 -0
  119. patchground/catalog/release-version-ordering/workspace/README.md +7 -0
  120. patchground/catalog/release-version-ordering/workspace/src/releasekit/index.ts +2 -0
  121. patchground/catalog/release-version-ordering/workspace/src/releasekit/ordering.ts +41 -0
  122. patchground/catalog/release-version-ordering/workspace/src/releasekit/parse.ts +24 -0
  123. patchground/cli/__init__.py +1 -0
  124. patchground/cli/app.py +59 -0
  125. patchground/cli/authoring.py +41 -0
  126. patchground/cli/consent.py +77 -0
  127. patchground/cli/discovery.py +189 -0
  128. patchground/cli/doctor.py +249 -0
  129. patchground/cli/packs.py +142 -0
  130. patchground/cli/practice.py +141 -0
  131. patchground/cli/render.py +58 -0
  132. patchground/cli/reports.py +53 -0
  133. patchground/cli/sessions.py +195 -0
  134. patchground/cli/validate.py +117 -0
  135. patchground/packs/__init__.py +23 -0
  136. patchground/packs/builtin.py +53 -0
  137. patchground/packs/discovery.py +285 -0
  138. patchground/packs/execution.py +420 -0
  139. patchground/packs/models.py +98 -0
  140. patchground/packs/registry.py +318 -0
  141. patchground/packs/source.py +230 -0
  142. patchground/packs/store.py +420 -0
  143. patchground/py.typed +0 -0
  144. patchground/report/__init__.py +6 -0
  145. patchground/report/analysis.py +188 -0
  146. patchground/report/models.py +59 -0
  147. patchground/report/regression.py +597 -0
  148. patchground/report/render.py +92 -0
  149. patchground/report/workflow.py +84 -0
  150. patchground/runner_assets/Dockerfile +7 -0
  151. patchground/runner_assets/__init__.py +16 -0
  152. patchground/runner_assets/package-lock.json +1054 -0
  153. patchground/runner_assets/package.json +13 -0
  154. patchground/runner_assets/vitest-runner.mjs +124 -0
  155. patchground/safeio/__init__.py +115 -0
  156. patchground/sandbox/__init__.py +39 -0
  157. patchground/sandbox/_engine.py +23 -0
  158. patchground/sandbox/build.py +202 -0
  159. patchground/sandbox/errors.py +23 -0
  160. patchground/sandbox/junit.py +76 -0
  161. patchground/sandbox/probe.py +118 -0
  162. patchground/sandbox/run.py +535 -0
  163. patchground/sessions/__init__.py +44 -0
  164. patchground/sessions/diagnostics.py +145 -0
  165. patchground/sessions/files.py +113 -0
  166. patchground/sessions/git.py +70 -0
  167. patchground/sessions/history.py +386 -0
  168. patchground/sessions/history_ignore.py +279 -0
  169. patchground/sessions/models.py +126 -0
  170. patchground/sessions/practice.py +202 -0
  171. patchground/sessions/reset.py +121 -0
  172. patchground/sessions/store.py +203 -0
  173. patchground/sessions/submission.py +344 -0
  174. patchground/sessions/workflow.py +297 -0
  175. patchground/spec/__init__.py +35 -0
  176. patchground/spec/_schema.py +53 -0
  177. patchground/spec/loader.py +713 -0
  178. patchground/spec/models.py +394 -0
  179. patchground/spec/schema.py +50 -0
  180. patchground/verify/__init__.py +29 -0
  181. patchground/verify/errors.py +10 -0
  182. patchground/verify/pytest_adapter.py +95 -0
  183. patchground/verify/record.py +94 -0
  184. patchground/verify/reference.py +158 -0
  185. patchground/verify/result.py +199 -0
  186. patchground/verify/runners.py +47 -0
  187. patchground/verify/tampering.py +106 -0
  188. patchground/verify/validate.py +686 -0
  189. patchground/verify/vitest_adapter.py +252 -0
  190. patchground-0.1.0.dist-info/METADATA +258 -0
  191. patchground-0.1.0.dist-info/RECORD +194 -0
  192. patchground-0.1.0.dist-info/WHEEL +4 -0
  193. patchground-0.1.0.dist-info/entry_points.txt +3 -0
  194. patchground-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,33 @@
1
+ spec_version: "0.1"
2
+ id: event-bus-subscriptions
3
+ version: 1.0.1
4
+ title: Add unsubscribe and once to the in-process event bus
5
+ type: feature
6
+ summary: >-
7
+ A synchronous exact-topic event bus cannot take a subscription back or listen once. Add
8
+ unsubscribe and once with precise snapshot, re-entrancy and error-collection behavior.
9
+ languages: [typescript]
10
+ tags: [events, reentrancy, error-handling]
11
+ difficulty: intermediate
12
+ timebox_minutes: 45
13
+ ai_policy: allowed
14
+ environment:
15
+ dockerfile: environment/Dockerfile
16
+ services: []
17
+ test_command: node /opt/patchground/vitest-runner.mjs
18
+ checks:
19
+ visible: checks/visible
20
+ hidden: checks/hidden
21
+ solution: solution/reference.patch
22
+ expected_paths: [src/, tests/]
23
+ hints:
24
+ - Decide what a single emit needs to remember about the subscriptions it will call, and what may change underneath it while it runs.
25
+ - A once subscription has two moments that matter. Which one decides whether it may run again?
26
+ - Handle a handler's failure at the place where you call it, not where you finish the loop.
27
+ debrief:
28
+ questions: debrief/questions.md
29
+ focus: [requirements_analysis, test_quality, scope_judgment]
30
+ author: {name: Patchground contributors}
31
+ content_license: CC-BY-4.0
32
+ provenance: generated
33
+ generated_by: {model: "Claude Code via Orca worker (model unverified)", prompt_version: launch-event-bus-v1}
@@ -0,0 +1,566 @@
1
+ import { test, expect } from "vitest";
2
+ import { EventBus } from "../src/bus";
3
+
4
+ // Each test names the TICKET clause it covers (C1 to C8).
5
+
6
+ const NO_THROW = Symbol("emit did not throw");
7
+
8
+ function thrownBy(action: () => unknown): unknown {
9
+ try {
10
+ action();
11
+ } catch (error) {
12
+ return error;
13
+ }
14
+ return NO_THROW;
15
+ }
16
+
17
+ function errorsOf(thrown: unknown): unknown[] {
18
+ return (thrown as AggregateError).errors;
19
+ }
20
+
21
+ // ---- C1 topics ----
22
+
23
+ test("C1 topics match by exact string, including case and the empty string", () => {
24
+ const bus = new EventBus();
25
+ const log: string[] = [];
26
+ bus.on("User.Created", () => log.push("upper"));
27
+ bus.on("user.created", () => log.push("lower"));
28
+ bus.on("", () => log.push("empty"));
29
+ expect(bus.emit("user.created")).toBe(1);
30
+ expect(bus.emit("")).toBe(1);
31
+ expect(bus.emit("User.Created")).toBe(1);
32
+ expect(bus.emit("user")).toBe(0);
33
+ expect(log).toEqual(["lower", "empty", "upper"]);
34
+ });
35
+
36
+ test("C1 dotted and starred topics are plain strings", () => {
37
+ const bus = new EventBus();
38
+ const log: string[] = [];
39
+ bus.on("user.*", () => log.push("user.*"));
40
+ bus.on("*", () => log.push("*"));
41
+ expect(bus.emit("user.created")).toBe(0);
42
+ expect(bus.emit("anything")).toBe(0);
43
+ expect(log).toEqual([]);
44
+ expect(bus.emit("user.*")).toBe(1);
45
+ expect(bus.emit("*")).toBe(1);
46
+ expect(log).toEqual(["user.*", "*"]);
47
+ });
48
+
49
+ test("C1 separate bus instances share no subscriptions", () => {
50
+ const first = new EventBus();
51
+ const second = new EventBus();
52
+ let calls = 0;
53
+ first.on("tick", () => {
54
+ calls += 1;
55
+ });
56
+ expect(second.emit("tick")).toBe(0);
57
+ expect(calls).toBe(0);
58
+ expect(first.emit("tick")).toBe(1);
59
+ });
60
+
61
+ // ---- C2 order and duplicates ----
62
+
63
+ test("C2 on and once handlers run in registration order", () => {
64
+ const bus = new EventBus();
65
+ const log: string[] = [];
66
+ bus.on("t", () => log.push("a"));
67
+ bus.once("t", () => log.push("b"));
68
+ bus.on("t", () => log.push("c"));
69
+ bus.once("t", () => log.push("d"));
70
+ bus.emit("t");
71
+ expect(log).toEqual(["a", "b", "c", "d"]);
72
+ bus.emit("t");
73
+ expect(log).toEqual(["a", "b", "c", "d", "a", "c"]);
74
+ });
75
+
76
+ test("C2 the same function registered twice runs twice per emit", () => {
77
+ const bus = new EventBus();
78
+ let calls = 0;
79
+ const handler = () => {
80
+ calls += 1;
81
+ };
82
+ bus.on("t", handler);
83
+ bus.on("t", handler);
84
+ expect(bus.emit("t")).toBe(2);
85
+ expect(calls).toBe(2);
86
+ });
87
+
88
+ test("C2 on and once of the same function are separate subscriptions", () => {
89
+ const bus = new EventBus();
90
+ let calls = 0;
91
+ const handler = () => {
92
+ calls += 1;
93
+ };
94
+ bus.on("t", handler);
95
+ bus.once("t", handler);
96
+ expect(bus.emit("t")).toBe(2);
97
+ expect(bus.emit("t")).toBe(1);
98
+ expect(calls).toBe(3);
99
+ });
100
+
101
+ // ---- C3 payload ----
102
+
103
+ test("C3 handlers get exactly one argument which is the same reference", () => {
104
+ const bus = new EventBus();
105
+ const calls: unknown[][] = [];
106
+ const payload = { id: 1, tags: ["a"] };
107
+ bus.on("t", (...args: unknown[]) => calls.push(args));
108
+ bus.once("t", (...args: unknown[]) => calls.push(args));
109
+ bus.emit("t", payload);
110
+ expect(calls).toHaveLength(2);
111
+ for (const args of calls) {
112
+ expect(args).toHaveLength(1);
113
+ expect(args[0]).toBe(payload);
114
+ }
115
+ });
116
+
117
+ test("C3 an omitted payload is delivered as undefined", () => {
118
+ const bus = new EventBus();
119
+ const calls: unknown[][] = [];
120
+ bus.on("t", (...args: unknown[]) => calls.push(args));
121
+ bus.emit("t");
122
+ expect(calls).toEqual([[undefined]]);
123
+ expect(calls[0]).toHaveLength(1);
124
+ });
125
+
126
+ test("C3 falsy payloads are delivered unchanged", () => {
127
+ const bus = new EventBus();
128
+ const seen: unknown[] = [];
129
+ bus.on("t", (value) => seen.push(value));
130
+ for (const value of [0, "", false, null]) {
131
+ bus.emit("t", value);
132
+ }
133
+ expect(seen).toStrictEqual([0, "", false, null]);
134
+ });
135
+
136
+ // ---- C4 return value ----
137
+
138
+ test("C4 emit returns zero for a topic without subscribers and after all are removed", () => {
139
+ const bus = new EventBus();
140
+ expect(bus.emit("t")).toBe(0);
141
+ const off = bus.on("t", () => {});
142
+ const offOnce = bus.once("t", () => {});
143
+ off();
144
+ offOnce();
145
+ expect(bus.emit("t")).toBe(0);
146
+ });
147
+
148
+ test("C4 emit counts the invocations it made, once handlers included", () => {
149
+ const bus = new EventBus();
150
+ bus.on("t", () => {});
151
+ bus.once("t", () => {});
152
+ bus.on("t", () => {});
153
+ expect(bus.emit("t")).toBe(3);
154
+ expect(bus.emit("t")).toBe(2);
155
+ });
156
+
157
+ test("C4 handlers that return promises are not awaited and emit returns a number", () => {
158
+ const bus = new EventBus();
159
+ bus.on("t", () => Promise.resolve("ignored"));
160
+ bus.once("t", () => Promise.resolve("ignored"));
161
+ const result = bus.emit("t");
162
+ expect(result).toBe(2);
163
+ expect(typeof result).toBe("number");
164
+ });
165
+
166
+ // ---- C5 unsubscribe ----
167
+
168
+ test("C5 unsubscribe removes only its own subscription from later emits", () => {
169
+ const bus = new EventBus();
170
+ const log: string[] = [];
171
+ const offA = bus.on("t", () => log.push("a"));
172
+ bus.on("t", () => log.push("b"));
173
+ bus.emit("t");
174
+ offA();
175
+ bus.emit("t");
176
+ expect(log).toEqual(["a", "b", "b"]);
177
+ });
178
+
179
+ test("C5 unsubscribe is idempotent and never removes a duplicate registration", () => {
180
+ const bus = new EventBus();
181
+ let calls = 0;
182
+ const handler = () => {
183
+ calls += 1;
184
+ };
185
+ const first = bus.on("t", handler);
186
+ bus.on("t", handler);
187
+ first();
188
+ first();
189
+ first();
190
+ expect(bus.emit("t")).toBe(1);
191
+ expect(calls).toBe(1);
192
+ });
193
+
194
+ test("C5 unsubscribing a once handler before it runs prevents it from ever running", () => {
195
+ const bus = new EventBus();
196
+ let calls = 0;
197
+ const off = bus.once("t", () => {
198
+ calls += 1;
199
+ });
200
+ off();
201
+ expect(bus.emit("t")).toBe(0);
202
+ expect(calls).toBe(0);
203
+ });
204
+
205
+ test("C5 unsubscribing a once handler after it ran does nothing", () => {
206
+ const bus = new EventBus();
207
+ const log: string[] = [];
208
+ const off = bus.once("t", () => log.push("once"));
209
+ bus.on("t", () => log.push("on"));
210
+ bus.emit("t");
211
+ off();
212
+ off();
213
+ bus.emit("t");
214
+ expect(log).toEqual(["once", "on", "on"]);
215
+ });
216
+
217
+ test("C5 remaining subscriptions keep their order after removals and additions", () => {
218
+ const bus = new EventBus();
219
+ const log: string[] = [];
220
+ bus.on("t", () => log.push("a"));
221
+ const offB = bus.on("t", () => log.push("b"));
222
+ bus.on("t", () => log.push("c"));
223
+ offB();
224
+ bus.on("t", () => log.push("d"));
225
+ bus.emit("t");
226
+ expect(log).toEqual(["a", "c", "d"]);
227
+ });
228
+
229
+ test("C5 a handler can unsubscribe itself", () => {
230
+ const bus = new EventBus();
231
+ const log: string[] = [];
232
+ const off = bus.on("t", () => {
233
+ log.push("self");
234
+ off();
235
+ });
236
+ bus.on("t", () => log.push("next"));
237
+ expect(bus.emit("t")).toBe(2);
238
+ expect(bus.emit("t")).toBe(1);
239
+ expect(log).toEqual(["self", "next", "next"]);
240
+ });
241
+
242
+ // ---- C6 snapshot ----
243
+
244
+ test("C6 a handler added during emit runs from the next emit", () => {
245
+ const bus = new EventBus();
246
+ const log: string[] = [];
247
+ let added = false;
248
+ bus.on("t", () => {
249
+ log.push("first");
250
+ if (!added) {
251
+ added = true;
252
+ bus.on("t", () => log.push("added"));
253
+ }
254
+ });
255
+ expect(bus.emit("t")).toBe(1);
256
+ expect(log).toEqual(["first"]);
257
+ expect(bus.emit("t")).toBe(2);
258
+ expect(log).toEqual(["first", "first", "added"]);
259
+ });
260
+
261
+ test("C6 a once handler added during emit runs once from the next emit", () => {
262
+ const bus = new EventBus();
263
+ const log: string[] = [];
264
+ let added = false;
265
+ bus.on("t", () => {
266
+ log.push("first");
267
+ if (!added) {
268
+ added = true;
269
+ bus.once("t", () => log.push("added once"));
270
+ }
271
+ });
272
+ expect(bus.emit("t")).toBe(1);
273
+ expect(bus.emit("t")).toBe(2);
274
+ expect(bus.emit("t")).toBe(1);
275
+ expect(log).toEqual(["first", "first", "added once", "first"]);
276
+ });
277
+
278
+ test("C6 a handler removed by an earlier handler still runs in the current emit only", () => {
279
+ const bus = new EventBus();
280
+ const log: string[] = [];
281
+ let offSecond: () => void = () => {};
282
+ bus.on("t", () => {
283
+ log.push("first");
284
+ offSecond();
285
+ });
286
+ offSecond = bus.on("t", () => log.push("second"));
287
+ expect(bus.emit("t")).toBe(2);
288
+ expect(log).toEqual(["first", "second"]);
289
+ expect(bus.emit("t")).toBe(1);
290
+ expect(log).toEqual(["first", "second", "first"]);
291
+ });
292
+
293
+ test("C6 re-adding a function during emit does not run it again in that emit", () => {
294
+ const bus = new EventBus();
295
+ let calls = 0;
296
+ const handler = () => {
297
+ calls += 1;
298
+ };
299
+ let again = true;
300
+ bus.on("t", () => {
301
+ if (again) {
302
+ again = false;
303
+ bus.on("t", handler);
304
+ }
305
+ });
306
+ bus.on("t", handler);
307
+ bus.emit("t");
308
+ expect(calls).toBe(1);
309
+ bus.emit("t");
310
+ expect(calls).toBe(3);
311
+ });
312
+
313
+ test("C6 a nested emit captures its own snapshot after an unsubscribe", () => {
314
+ const bus = new EventBus();
315
+ const log: string[] = [];
316
+ let nested = false;
317
+ let offSecond: () => void = () => {};
318
+ bus.on("t", () => {
319
+ log.push("first");
320
+ if (!nested) {
321
+ nested = true;
322
+ offSecond();
323
+ log.push("nested count " + bus.emit("t"));
324
+ }
325
+ });
326
+ offSecond = bus.on("t", () => log.push("second"));
327
+ expect(bus.emit("t")).toBe(2);
328
+ expect(log).toEqual(["first", "first", "nested count 1", "second"]);
329
+ });
330
+
331
+ test("C6 a nested emit on another topic uses that topic's subscriptions", () => {
332
+ const bus = new EventBus();
333
+ const log: string[] = [];
334
+ bus.on("outer", () => {
335
+ log.push("outer 1");
336
+ log.push("inner count " + bus.emit("inner"));
337
+ });
338
+ bus.on("inner", () => log.push("inner"));
339
+ bus.on("outer", () => log.push("outer 2"));
340
+ expect(bus.emit("outer")).toBe(2);
341
+ expect(log).toEqual(["outer 1", "inner", "inner count 1", "outer 2"]);
342
+ });
343
+
344
+ // ---- C7 once ----
345
+
346
+ test("C7 a once handler runs for the first emit only", () => {
347
+ const bus = new EventBus();
348
+ let calls = 0;
349
+ bus.once("t", () => {
350
+ calls += 1;
351
+ });
352
+ bus.emit("t");
353
+ bus.emit("t");
354
+ bus.emit("t");
355
+ expect(calls).toBe(1);
356
+ });
357
+
358
+ test("C7 several once handlers each run once in registration order", () => {
359
+ const bus = new EventBus();
360
+ const log: string[] = [];
361
+ bus.once("t", () => log.push("a"));
362
+ bus.once("t", () => log.push("b"));
363
+ expect(bus.emit("t")).toBe(2);
364
+ expect(bus.emit("t")).toBe(0);
365
+ expect(log).toEqual(["a", "b"]);
366
+ });
367
+
368
+ test("C7 a throwing once handler stays consumed", () => {
369
+ const bus = new EventBus();
370
+ const failure = new Error("once failed");
371
+ let calls = 0;
372
+ bus.once("t", () => {
373
+ calls += 1;
374
+ throw failure;
375
+ });
376
+ const thrown = thrownBy(() => bus.emit("t"));
377
+ expect(thrown).toBeInstanceOf(AggregateError);
378
+ expect(errorsOf(thrown)).toEqual([failure]);
379
+ expect(bus.emit("t")).toBe(0);
380
+ expect(calls).toBe(1);
381
+ });
382
+
383
+ test("C7 a once handler that emits its own topic is not called again", () => {
384
+ const bus = new EventBus();
385
+ const log: string[] = [];
386
+ bus.once("t", () => {
387
+ log.push("once start");
388
+ log.push("nested count " + bus.emit("t"));
389
+ log.push("once end");
390
+ });
391
+ bus.on("t", () => log.push("on"));
392
+ expect(bus.emit("t")).toBe(2);
393
+ expect(log).toEqual(["once start", "on", "nested count 1", "once end", "on"]);
394
+ });
395
+
396
+ test("C7 a nested emit consumes a once handler before the outer emit reaches it", () => {
397
+ const bus = new EventBus();
398
+ const log: string[] = [];
399
+ let nested = false;
400
+ let nestedCount = -1;
401
+ bus.on("tick", () => {
402
+ log.push("first");
403
+ if (!nested) {
404
+ nested = true;
405
+ nestedCount = bus.emit("tick");
406
+ }
407
+ });
408
+ bus.once("tick", () => log.push("once"));
409
+ bus.on("tick", () => log.push("last"));
410
+ expect(bus.emit("tick")).toBe(2);
411
+ expect(nestedCount).toBe(3);
412
+ expect(log).toEqual(["first", "first", "once", "last", "last"]);
413
+ });
414
+
415
+ test("C7 unsubscribing a captured once handler does not cancel its turn in the current emit", () => {
416
+ const bus = new EventBus();
417
+ const log: string[] = [];
418
+ let offOnce: () => void = () => {};
419
+ bus.on("t", () => {
420
+ log.push("first");
421
+ offOnce();
422
+ });
423
+ offOnce = bus.once("t", () => log.push("once"));
424
+ expect(bus.emit("t")).toBe(2);
425
+ expect(log).toEqual(["first", "once"]);
426
+ expect(bus.emit("t")).toBe(1);
427
+ expect(log).toEqual(["first", "once", "first"]);
428
+ });
429
+
430
+ // ---- C8 errors ----
431
+
432
+ test("C8 a throwing handler does not stop later handlers of the snapshot", () => {
433
+ const bus = new EventBus();
434
+ const log: string[] = [];
435
+ bus.on("t", () => log.push("a"));
436
+ bus.on("t", () => {
437
+ throw new Error("b failed");
438
+ });
439
+ bus.on("t", () => log.push("c"));
440
+ const thrown = thrownBy(() => bus.emit("t"));
441
+ expect(thrown).toBeInstanceOf(AggregateError);
442
+ expect(log).toEqual(["a", "c"]);
443
+ });
444
+
445
+ test("C8 the AggregateError lists thrown values in invocation order", () => {
446
+ const bus = new EventBus();
447
+ const e1 = new Error("one");
448
+ const e2 = new RangeError("two");
449
+ const e3 = new TypeError("three");
450
+ bus.on("t", () => {
451
+ throw e1;
452
+ });
453
+ bus.on("t", () => {});
454
+ bus.once("t", () => {
455
+ throw e2;
456
+ });
457
+ bus.on("t", () => {
458
+ throw e3;
459
+ });
460
+ const errors = errorsOf(thrownBy(() => bus.emit("t")));
461
+ expect(errors).toHaveLength(3);
462
+ expect(errors[0]).toBe(e1);
463
+ expect(errors[1]).toBe(e2);
464
+ expect(errors[2]).toBe(e3);
465
+ });
466
+
467
+ test("C8 one failure still produces an AggregateError with one entry", () => {
468
+ const bus = new EventBus();
469
+ const failure = new Error("only");
470
+ bus.on("t", () => {
471
+ throw failure;
472
+ });
473
+ const thrown = thrownBy(() => bus.emit("t"));
474
+ expect(thrown).toBeInstanceOf(AggregateError);
475
+ expect(thrown).not.toBe(failure);
476
+ expect(errorsOf(thrown)).toHaveLength(1);
477
+ expect(errorsOf(thrown)[0]).toBe(failure);
478
+ });
479
+
480
+ test("C8 thrown values that are not errors are collected unchanged", () => {
481
+ const bus = new EventBus();
482
+ const marker = { reason: "object" };
483
+ bus.on("t", () => {
484
+ throw "text";
485
+ });
486
+ bus.on("t", () => {
487
+ throw undefined;
488
+ });
489
+ bus.on("t", () => {
490
+ throw 42;
491
+ });
492
+ bus.on("t", () => {
493
+ throw marker;
494
+ });
495
+ const errors = errorsOf(thrownBy(() => bus.emit("t")));
496
+ expect(errors).toHaveLength(4);
497
+ expect(errors[0]).toBe("text");
498
+ expect(errors[1]).toBe(undefined);
499
+ expect(errors[2]).toBe(42);
500
+ expect(errors[3]).toBe(marker);
501
+ });
502
+
503
+ test("C8 a nested emit that fails is collected as one entry", () => {
504
+ const bus = new EventBus();
505
+ const inner = new Error("inner failed");
506
+ bus.on("inner", () => {
507
+ throw inner;
508
+ });
509
+ bus.on("outer", () => {
510
+ bus.emit("inner");
511
+ });
512
+ bus.on("outer", () => {});
513
+ const outerErrors = errorsOf(thrownBy(() => bus.emit("outer")));
514
+ expect(outerErrors).toHaveLength(1);
515
+ expect(outerErrors[0]).toBeInstanceOf(AggregateError);
516
+ expect(errorsOf(outerErrors[0])).toHaveLength(1);
517
+ expect(errorsOf(outerErrors[0])[0]).toBe(inner);
518
+ });
519
+
520
+ test("C8 emit does not throw when no handler throws", () => {
521
+ const bus = new EventBus();
522
+ bus.on("t", () => {});
523
+ bus.once("t", () => {});
524
+ expect(thrownBy(() => bus.emit("t"))).toBe(NO_THROW);
525
+ });
526
+
527
+ test("C8 the bus stays usable after a failed emit", () => {
528
+ const bus = new EventBus();
529
+ const log: string[] = [];
530
+ bus.once("t", () => {
531
+ log.push("once");
532
+ throw new Error("once failed");
533
+ });
534
+ bus.on("t", () => log.push("kept"));
535
+ expect(thrownBy(() => bus.emit("t"))).toBeInstanceOf(AggregateError);
536
+ expect(bus.emit("t")).toBe(1);
537
+ expect(log).toEqual(["once", "kept", "kept"]);
538
+ });
539
+
540
+ test("C8 a handler that throws on every emit stays subscribed", () => {
541
+ const bus = new EventBus();
542
+ let calls = 0;
543
+ bus.on("t", () => {
544
+ calls += 1;
545
+ throw new Error("always");
546
+ });
547
+ expect(thrownBy(() => bus.emit("t"))).toBeInstanceOf(AggregateError);
548
+ expect(thrownBy(() => bus.emit("t"))).toBeInstanceOf(AggregateError);
549
+ expect(calls).toBe(2);
550
+ });
551
+
552
+ test("C8 changes made by a handler before it throws still apply", () => {
553
+ const bus = new EventBus();
554
+ const log: string[] = [];
555
+ let offLater: () => void = () => {};
556
+ bus.on("t", () => {
557
+ bus.on("t", () => log.push("added"));
558
+ offLater();
559
+ throw new Error("after changes");
560
+ });
561
+ offLater = bus.on("t", () => log.push("later"));
562
+ expect(thrownBy(() => bus.emit("t"))).toBeInstanceOf(AggregateError);
563
+ expect(log).toEqual(["later"]);
564
+ expect(thrownBy(() => bus.emit("t"))).toBeInstanceOf(AggregateError);
565
+ expect(log).toEqual(["later", "added"]);
566
+ });
@@ -0,0 +1,70 @@
1
+ import { test, expect } from "vitest";
2
+ import { EventBus } from "../src/bus";
3
+
4
+ test("emit calls the handlers of the exact topic in registration order", () => {
5
+ const bus = new EventBus();
6
+ const log: string[] = [];
7
+ bus.on("user.created", () => log.push("a"));
8
+ bus.on("user.deleted", () => log.push("other topic"));
9
+ bus.on("user.created", () => log.push("b"));
10
+ expect(bus.emit("user.created")).toBe(2);
11
+ expect(log).toEqual(["a", "b"]);
12
+ });
13
+
14
+ test("emit forwards the payload to the handler", () => {
15
+ const bus = new EventBus();
16
+ const seen: unknown[] = [];
17
+ const payload = { id: 7 };
18
+ bus.on("saved", (value) => seen.push(value));
19
+ bus.emit("saved", payload);
20
+ expect(seen[0]).toBe(payload);
21
+ });
22
+
23
+ test("emit returns zero when nothing is subscribed", () => {
24
+ const bus = new EventBus();
25
+ expect(bus.emit("nobody")).toBe(0);
26
+ });
27
+
28
+ test("the function returned by on stops later deliveries", () => {
29
+ const bus = new EventBus();
30
+ let calls = 0;
31
+ const off = bus.on("tick", () => {
32
+ calls += 1;
33
+ });
34
+ bus.emit("tick");
35
+ off();
36
+ bus.emit("tick");
37
+ expect(calls).toBe(1);
38
+ });
39
+
40
+ test("once runs for the first emit only", () => {
41
+ const bus = new EventBus();
42
+ let calls = 0;
43
+ bus.once("tick", () => {
44
+ calls += 1;
45
+ });
46
+ expect(bus.emit("tick")).toBe(1);
47
+ expect(bus.emit("tick")).toBe(0);
48
+ expect(calls).toBe(1);
49
+ });
50
+
51
+ test("a throwing handler does not stop the next one and emit throws an AggregateError", () => {
52
+ const bus = new EventBus();
53
+ const failure = new Error("boom");
54
+ let later = false;
55
+ bus.on("tick", () => {
56
+ throw failure;
57
+ });
58
+ bus.on("tick", () => {
59
+ later = true;
60
+ });
61
+ let caught: unknown;
62
+ try {
63
+ bus.emit("tick");
64
+ } catch (error) {
65
+ caught = error;
66
+ }
67
+ expect(later).toBe(true);
68
+ expect(caught).toBeInstanceOf(AggregateError);
69
+ expect((caught as AggregateError).errors).toEqual([failure]);
70
+ });
@@ -0,0 +1,5 @@
1
+ - Which sentences of the ticket told you what a handler added or removed during an emit should do, and how did you turn them into tests?
2
+ - Why can a once subscription be skipped at its turn in an outer emit, and why is an unsubscribed one not skipped? Where in your code is that difference?
3
+ - What did you decide about a handler that throws, and what state is the bus in afterwards?
4
+ - Which behavior did you keep unchanged, and which test protects it?
5
+ - If an AI assistant wrote part of the change, what did you verify yourself before accepting it?
@@ -0,0 +1,7 @@
1
+ FROM node:24.15.0-bookworm-slim@sha256:4e6b70dd6cbfc88c8157ba19aa3d9f9cce6ba4703576d55459e45efcbc9c5f5d
2
+ WORKDIR /opt/patchground
3
+ COPY package.json package-lock.json ./
4
+ RUN npm ci --ignore-scripts --no-audit --no-fund && npm cache clean --force
5
+ COPY vitest-runner.mjs ./
6
+ RUN chmod 644 package.json package-lock.json vitest-runner.mjs
7
+ WORKDIR /workspace