@inglorious/store 6.1.2 โ†’ 6.2.0

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.
package/README.md CHANGED
@@ -480,6 +480,74 @@ Notice: you don't need pending/fulfilled/rejected actions. You stay in control o
480
480
 
481
481
  All events triggered via `api.notify()` enter the queue and process together, maintaining predictability and testability.
482
482
 
483
+ ### ๐Ÿงช Testing
484
+
485
+ Event handlers are pure functions (or can be treated as such), making them easy to test in isolation, much like Redux reducers. The `@inglorious/store/test` module provides utility functions to make this even simpler.
486
+
487
+ #### `trigger(entity, handler, payload, api?)`
488
+
489
+ The `trigger` function executes an event handler on a single entity and returns the new state and any events that were dispatched.
490
+
491
+ ```javascript
492
+ import { trigger } from "@inglorious/store/test"
493
+
494
+ // Define your entity handler
495
+ function increment(entity, payload, api) {
496
+ entity.value += payload.amount
497
+ if (entity.value > 100) {
498
+ api.notify("overflow", { id: entity.id })
499
+ }
500
+ }
501
+
502
+ // Test it
503
+ const { entity, events } = trigger(
504
+ { type: "counter", id: "counter1", value: 99 },
505
+ increment,
506
+ { amount: 5 },
507
+ )
508
+
509
+ expect(entity.value).toBe(104)
510
+ expect(events).toEqual([{ type: "overflow", payload: { id: "counter1" } }])
511
+ ```
512
+
513
+ #### `createMockApi(entities)`
514
+
515
+ If your handler needs to interact with other entities via the `api`, you can create a mock API. This is useful for testing handlers that read from other parts of the state.
516
+
517
+ ```javascript
518
+ import { createMockApi, trigger } from "@inglorious/store/test"
519
+
520
+ // Create a mock API with some initial entities
521
+ const api = createMockApi({
522
+ counter1: { type: "counter", value: 10 },
523
+ counter2: { type: "counter", value: 20 },
524
+ })
525
+
526
+ // A handler that copies a value from another entity
527
+ function copyValue(entity, payload, api) {
528
+ const source = api.getEntity(payload.sourceId)
529
+ entity.value = source.value
530
+ }
531
+
532
+ // Trigger the handler with the custom mock API
533
+ const { entity } = trigger(
534
+ { type: "counter", id: "counter2", value: 20 },
535
+ copyValue,
536
+ { sourceId: "counter1" },
537
+ api,
538
+ )
539
+
540
+ expect(entity.value).toBe(10)
541
+ ```
542
+
543
+ The mock API provides:
544
+
545
+ - `getEntities()`: Returns all entities (frozen).
546
+ - `getEntity(id)`: Returns a specific entity by ID (frozen).
547
+ - `dispatch(event)`: Records an event for later assertions.
548
+ - `notify(type, payload)`: A convenience wrapper around `dispatch`.
549
+ - `getEvents()`: Returns all events that were dispatched.
550
+
483
551
  ### ๐ŸŒ Systems for Global Logic
484
552
 
485
553
  When you need to coordinate updates across multiple entities (not just respond to individual events), use systems. Systems run after all entity handlers for the same event, ensuring global consistency, and have write access to the entire state. This concept is the 'S' in the ECS Architecture (Entity-Component-System)!
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inglorious/store",
3
- "version": "6.1.2",
3
+ "version": "6.2.0",
4
4
  "description": "A state manager for real-time, collaborative apps, inspired by game development patterns and compatible with Redux.",
5
5
  "author": "IceOnFire <antony.mistretta@gmail.com> (https://ingloriouscoderz.it)",
6
6
  "license": "MIT",
package/src/test.js ADDED
@@ -0,0 +1,130 @@
1
+ import { create } from "mutative"
2
+
3
+ /**
4
+ * Creates a mock API for testing event handlers in isolation.
5
+ *
6
+ * The mock API provides read-only access to entities and tracks all events
7
+ * dispatched during handler execution. The entities are frozen to prevent
8
+ * accidental mutations - handlers should only mutate the draft state passed
9
+ * to them, not entities retrieved via `getEntity()` or `getEntities()`.
10
+ *
11
+ * @param {Object} entities - The entities state (will be frozen)
12
+ *
13
+ * @returns {Object} A mock API object with methods:
14
+ * - `getEntities()`: Returns all entities (frozen)
15
+ * - `getEntity(id)`: Returns a specific entity by ID (frozen)
16
+ * - `dispatch(event)`: Records an event (for assertions)
17
+ * - `notify(type, payload)`: Convenience method that calls dispatch
18
+ * - `getEvents()`: Returns all events that were dispatched
19
+ *
20
+ * @example
21
+ * const api = createMockApi({
22
+ * counter1: { type: 'counter', value: 0 }
23
+ * })
24
+ *
25
+ * // In your handler
26
+ * const entity = api.getEntity('counter1')
27
+ * api.notify('increment', { id: 'counter1' })
28
+ *
29
+ * // In your test
30
+ * expect(api.getEvents()).toEqual([
31
+ * { type: 'increment', payload: { id: 'counter1' } }
32
+ * ])
33
+ */
34
+ export function createMockApi(entities) {
35
+ const frozenEntities = Object.freeze(entities)
36
+ const events = []
37
+
38
+ return {
39
+ getEntities() {
40
+ return frozenEntities
41
+ },
42
+ getEntity(id) {
43
+ return frozenEntities[id]
44
+ },
45
+ dispatch(event) {
46
+ events.push(event)
47
+ },
48
+ notify(type, payload) {
49
+ this.dispatch({ type, payload })
50
+ },
51
+ getEvents() {
52
+ return events
53
+ },
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Triggers an event handler on a single entity for testing purposes.
59
+ *
60
+ * This function executes an event handler on a single entity with the given
61
+ * payload, using Mutative to provide a mutable draft. The handler can read
62
+ * other entities via the API (frozen, immutable) and mutate the draft entity.
63
+ * All events dispatched during execution are captured and returned.
64
+ *
65
+ * Use this for unit testing individual entity handlers. For integration testing
66
+ * of event cascades and the full event queue, use the actual store.
67
+ *
68
+ * @param {Object} entity - The entity to operate on
69
+ * @param {Function} eventHandler - The handler function to test. Should accept
70
+ * (draft, payload, api) where draft is the mutable entity
71
+ * @param {*} eventPayload - The payload to pass to the handler
72
+ * @param {Object} [api] - Optional custom mock API. If not provided, a default
73
+ * mock API will be created automatically with the entity as the only entity
74
+ *
75
+ * @returns {Object} An object containing:
76
+ * - `entity`: The new immutable entity after the handler executed
77
+ * - `events`: Array of all events dispatched during handler execution
78
+ *
79
+ * @example
80
+ * // Define your entity handler
81
+ * const increment = (entity, payload, api) => {
82
+ * entity.value += payload.amount
83
+ * if (entity.value > 100) {
84
+ * api.notify('overflow', { id: entity.id })
85
+ * }
86
+ * }
87
+ *
88
+ * // Test it
89
+ * const { entity, events } = trigger(
90
+ * { type: 'counter', id: 'counter1', value: 99 },
91
+ * increment,
92
+ * { amount: 5 }
93
+ * )
94
+ *
95
+ * expect(entity.value).toBe(104)
96
+ * expect(events).toEqual([
97
+ * { type: 'overflow', payload: { id: 'counter1' } }
98
+ * ])
99
+ *
100
+ * @example
101
+ * // With custom mock API to access other entities
102
+ * const api = createMockApi({
103
+ * counter1: { type: 'counter', value: 10 },
104
+ * counter2: { type: 'counter', value: 20 }
105
+ * })
106
+ *
107
+ * const copyValue = (entity, payload, api) => {
108
+ * const source = api.getEntity(payload.sourceId)
109
+ * entity.value = source.value
110
+ * }
111
+ *
112
+ * const { entity } = trigger(
113
+ * { type: 'counter', id: 'counter2', value: 20 },
114
+ * copyValue,
115
+ * { sourceId: 'counter1' },
116
+ * api
117
+ * )
118
+ *
119
+ * expect(entity.value).toBe(10)
120
+ */
121
+ export function trigger(entity, eventHandler, eventPayload, api) {
122
+ api ??= createMockApi({ entities: { [entity.id]: entity } })
123
+
124
+ return {
125
+ entity: create(entity, (draft) => {
126
+ eventHandler(draft, eventPayload, api)
127
+ }),
128
+ events: api.getEvents(),
129
+ }
130
+ }
@@ -0,0 +1,272 @@
1
+ import { describe, expect, it } from "vitest"
2
+
3
+ import { createMockApi, trigger } from "./test"
4
+
5
+ describe("createMockApi", () => {
6
+ it("should create a mock API with all required methods", () => {
7
+ const entities = { counter1: { type: "counter", value: 0 } }
8
+
9
+ const api = createMockApi(entities)
10
+
11
+ expect(api.getEntities).toBeDefined()
12
+ expect(api.getEntity).toBeDefined()
13
+ expect(api.dispatch).toBeDefined()
14
+ expect(api.notify).toBeDefined()
15
+ expect(api.getEvents).toBeDefined()
16
+ })
17
+
18
+ it("should return all entities via getEntities()", () => {
19
+ const entities = {
20
+ counter1: { type: "counter", value: 5 },
21
+ counter2: { type: "counter", value: 10 },
22
+ }
23
+
24
+ const api = createMockApi(entities)
25
+
26
+ expect(api.getEntities()).toEqual(entities)
27
+ })
28
+
29
+ it("should return a specific entity via getEntity()", () => {
30
+ const entities = {
31
+ counter1: { type: "counter", value: 5 },
32
+ counter2: { type: "counter", value: 10 },
33
+ }
34
+
35
+ const api = createMockApi(entities)
36
+
37
+ expect(api.getEntity("counter1")).toEqual({ type: "counter", value: 5 })
38
+ expect(api.getEntity("counter2")).toEqual({ type: "counter", value: 10 })
39
+ })
40
+
41
+ it("should return undefined for non-existent entity", () => {
42
+ const entities = {
43
+ counter1: { type: "counter", value: 5 },
44
+ }
45
+
46
+ const api = createMockApi(entities)
47
+
48
+ expect(api.getEntity("nonexistent")).toBeUndefined()
49
+ })
50
+
51
+ it("should track dispatched events", () => {
52
+ const entities = {
53
+ counter1: { type: "counter", value: 0 },
54
+ }
55
+
56
+ const api = createMockApi(entities)
57
+
58
+ api.dispatch({ type: "increment", payload: { id: "counter1" } })
59
+ api.dispatch({ type: "decrement", payload: { id: "counter1" } })
60
+
61
+ expect(api.getEvents()).toEqual([
62
+ { type: "increment", payload: { id: "counter1" } },
63
+ { type: "decrement", payload: { id: "counter1" } },
64
+ ])
65
+ })
66
+
67
+ it("should track events dispatched via notify()", () => {
68
+ const entities = {
69
+ counter1: { type: "counter", value: 0 },
70
+ }
71
+
72
+ const api = createMockApi(entities)
73
+
74
+ api.notify("increment", { id: "counter1", amount: 5 })
75
+ api.notify("overflow")
76
+
77
+ expect(api.getEvents()).toEqual([
78
+ { type: "increment", payload: { id: "counter1", amount: 5 } },
79
+ { type: "overflow", payload: undefined },
80
+ ])
81
+ })
82
+
83
+ it("should freeze entities to prevent mutations", () => {
84
+ const entities = {
85
+ counter1: { type: "counter", value: 0 },
86
+ }
87
+
88
+ const api = createMockApi(entities)
89
+ const allEntities = api.getEntities()
90
+
91
+ expect(() => {
92
+ allEntities.counter1 = { type: "counter", value: 999 }
93
+ }).toThrow()
94
+ })
95
+
96
+ it("should start with empty events array", () => {
97
+ const entities = {
98
+ counter1: { type: "counter", value: 0 },
99
+ }
100
+
101
+ const api = createMockApi(entities)
102
+
103
+ expect(api.getEvents()).toEqual([])
104
+ })
105
+ })
106
+
107
+ describe("trigger", () => {
108
+ it("should execute handler and return new entity", () => {
109
+ const entityBefore = { type: "counter", value: 0 }
110
+ const entityAfter = { type: "counter", value: 5 }
111
+
112
+ function increment(entity, amount) {
113
+ entity.value += amount
114
+ }
115
+
116
+ const { entity } = trigger(entityBefore, increment, 5)
117
+
118
+ expect(entity).toStrictEqual(entityAfter)
119
+ })
120
+
121
+ it("should not mutate original entity", () => {
122
+ const entityBefore = { type: "counter", value: 0 }
123
+
124
+ function increment(entity, amount) {
125
+ entity.value += amount
126
+ }
127
+
128
+ trigger(entityBefore, increment, 5)
129
+
130
+ expect(entityBefore.value).toBe(0)
131
+ })
132
+
133
+ it("should capture events dispatched during handler execution", () => {
134
+ const entityBefore = { type: "counter", value: 99 }
135
+ const entityAfter = { type: "counter", value: 104 }
136
+ const eventsAfter = [{ type: "overflow", payload: 104 }]
137
+
138
+ function increment(entity, amount, api) {
139
+ entity.value += amount
140
+
141
+ if (entity.value > 100) {
142
+ api.notify("overflow", entity.value)
143
+ }
144
+ }
145
+
146
+ const { entity, events } = trigger(entityBefore, increment, 5)
147
+
148
+ expect(entity).toStrictEqual(entityAfter)
149
+ expect(events).toStrictEqual(eventsAfter)
150
+ })
151
+
152
+ it("should work with handlers that do not dispatch events", () => {
153
+ const entityBefore = { type: "todo", text: "Buy milk", completed: false }
154
+ const entityAfter = { type: "todo", text: "Buy milk", completed: true }
155
+ const eventsAfter = []
156
+
157
+ function toggle(entity) {
158
+ entity.completed = !entity.completed
159
+ }
160
+
161
+ const { entity, events } = trigger(entityBefore, toggle)
162
+
163
+ expect(entity).toStrictEqual(entityAfter)
164
+ expect(events).toStrictEqual(eventsAfter)
165
+ })
166
+
167
+ it("should allow handler to read other entities via API", () => {
168
+ const entityBefore = { id: "counter2", type: "counter", value: 20 }
169
+ const entityAfter = { id: "counter2", type: "counter", value: 30 }
170
+
171
+ const mockApi = createMockApi({
172
+ counter1: { id: "counter1", type: "counter", value: 10 },
173
+ counter2: { id: "counter2", type: "counter", value: 20 },
174
+ })
175
+
176
+ function addFromSource(entity, sourceId, api) {
177
+ const source = api.getEntity(sourceId)
178
+ if (source) {
179
+ entity.value += source.value
180
+ }
181
+ }
182
+
183
+ const { entity } = trigger(entityBefore, addFromSource, "counter1", mockApi)
184
+
185
+ expect(entity).toStrictEqual(entityAfter)
186
+ })
187
+
188
+ it("should accept custom mock API", () => {
189
+ const entityBefore = { type: "counter", value: 0 }
190
+ const entityAfter = { type: "counter", value: 5 }
191
+ const eventsAfter = [{ type: "incremented", payload: undefined }]
192
+
193
+ const customApi = createMockApi(entityBefore)
194
+
195
+ function increment(entity, amount, api) {
196
+ entity.value += amount
197
+ api.notify("incremented")
198
+ }
199
+
200
+ const { entity } = trigger(entityBefore, increment, 5, customApi)
201
+
202
+ expect(entity).toStrictEqual(entityAfter)
203
+ expect(customApi.getEvents()).toStrictEqual(eventsAfter)
204
+ })
205
+
206
+ it("should handle multiple dispatched events", () => {
207
+ const entityBefore = { type: "counter", value: 50 }
208
+ const entityAfter = { type: "counter", value: 110 }
209
+ const eventsAfter = [
210
+ { type: "increment:start", payload: undefined },
211
+ { type: "milestone:hundred", payload: undefined },
212
+ { type: "increment:complete", payload: 110 },
213
+ ]
214
+
215
+ function increment(entity, amount, api) {
216
+ api.notify("increment:start")
217
+
218
+ entity.value += amount
219
+
220
+ if (entity.value >= 100) {
221
+ api.notify("milestone:hundred")
222
+ }
223
+
224
+ api.notify("increment:complete", entity.value)
225
+ }
226
+
227
+ const { entity, events } = trigger(entityBefore, increment, 60)
228
+
229
+ expect(entity).toStrictEqual(entityAfter)
230
+ expect(events).toStrictEqual(eventsAfter)
231
+ })
232
+
233
+ it("should work without payload", () => {
234
+ const entityBefore = { type: "counter", value: 5 }
235
+ const entityAfter = { type: "counter", value: 0 }
236
+
237
+ function reset(entity) {
238
+ entity.value = 0
239
+ }
240
+
241
+ const { entity } = trigger(entityBefore, reset)
242
+
243
+ expect(entity).toStrictEqual(entityAfter)
244
+ })
245
+
246
+ it("should work with complex entity mutations", () => {
247
+ const entityBefore = {
248
+ type: "player",
249
+ name: "Alice",
250
+ inventory: ["sword", "shield"],
251
+ stats: { health: 100, mana: 50 },
252
+ }
253
+ const entityAfter = {
254
+ type: "player",
255
+ name: "Alice",
256
+ inventory: ["sword", "shield", "potion"],
257
+ stats: { health: 100, mana: 50 },
258
+ }
259
+
260
+ function addItem(entity, item, api) {
261
+ entity.inventory.push(item)
262
+ api.notify("item:added", { item })
263
+ }
264
+
265
+ const { entity, events } = trigger(entityBefore, addItem, "potion")
266
+
267
+ expect(entity).toStrictEqual(entityAfter)
268
+ expect(events).toEqual([
269
+ { type: "item:added", payload: { item: "potion" } },
270
+ ])
271
+ })
272
+ })