@inglorious/store 8.0.0 → 8.0.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inglorious/store",
3
- "version": "8.0.0",
3
+ "version": "8.0.1",
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",
@@ -42,17 +42,19 @@
42
42
  }
43
43
  },
44
44
  "files": [
45
- "src"
45
+ "src",
46
+ "!src/**/*.test.js",
47
+ "types"
46
48
  ],
47
49
  "publishConfig": {
48
50
  "access": "public"
49
51
  },
50
52
  "dependencies": {
51
53
  "mutative": "^1.3.0",
52
- "@inglorious/utils": "3.7.0"
54
+ "@inglorious/utils": "3.7.1"
53
55
  },
54
56
  "peerDependencies": {
55
- "@inglorious/utils": "3.7.0"
57
+ "@inglorious/utils": "3.7.1"
56
58
  },
57
59
  "devDependencies": {
58
60
  "prettier": "^3.6.2",
@@ -0,0 +1,10 @@
1
+ import type { BaseEntity, EntitiesState, Middleware } from "../store"
2
+
3
+ export function createDevtools<
4
+ T extends BaseEntity = BaseEntity,
5
+ S extends EntitiesState<T> = EntitiesState<T>,
6
+ >(
7
+ config?: any,
8
+ ): {
9
+ middleware: Middleware<T, S>
10
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./select"
2
+ export * from "./store"
3
+ export * from "./test"
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Input selector function that takes state and returns a value
3
+ */
4
+ export type InputSelector<TState = any, TResult = any> = (
5
+ state: TState,
6
+ ) => TResult
7
+
8
+ /**
9
+ * Output selector function (memoized)
10
+ */
11
+ export type OutputSelector<TState = any, TResult = any> = (
12
+ state: TState,
13
+ ) => TResult
14
+
15
+ /**
16
+ * Creates a memoized selector function.
17
+ * @param inputSelectors - An array of input selector functions.
18
+ * @param resultFunc - A function that receives the results of the input selectors and returns a computed value.
19
+ * @returns A memoized selector function that, when called, returns the selected state.
20
+ */
21
+ export function createSelector<TState = any, TResult = any>(
22
+ inputSelectors: InputSelector<TState, any>[],
23
+ resultFunc: (...args: any[]) => TResult,
24
+ ): OutputSelector<TState, TResult>
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Base entity structure
3
+ */
4
+ export interface BaseEntity {
5
+ type: string
6
+ [key: string]: any
7
+ }
8
+
9
+ /**
10
+ * Event structure
11
+ */
12
+ export interface Event<T = any> {
13
+ type: string
14
+ payload?: T
15
+ }
16
+
17
+ /**
18
+ * Entity type definition with event handlers
19
+ */
20
+ export type EntityType<TEntity extends BaseEntity = BaseEntity> = {
21
+ [K: string]: (entity: TEntity, payload: any, api: Api) => void
22
+ }
23
+
24
+ /**
25
+ * System definition with event handlers
26
+ */
27
+ export type System<TState extends EntitiesState = EntitiesState> = {
28
+ [K: string]: (state: TState, payload: any, api: Api) => void
29
+ }
30
+
31
+ /**
32
+ * State structure (entities indexed by ID)
33
+ */
34
+ export type EntitiesState<TEntity extends BaseEntity = BaseEntity> = {
35
+ [id: string]: TEntity
36
+ }
37
+
38
+ /**
39
+ * Types configuration
40
+ */
41
+ export type TypesConfig<TEntity extends BaseEntity = BaseEntity> = {
42
+ [typeName: string]: EntityType<TEntity>
43
+ }
44
+
45
+ /**
46
+ * Store configuration
47
+ */
48
+ export interface StoreConfig<
49
+ TEntity extends BaseEntity = BaseEntity,
50
+ TState extends EntitiesState<TEntity> = EntitiesState<TEntity>,
51
+ > {
52
+ types?: TypesConfig<TEntity>
53
+ entities?: TState
54
+ systems?: System<TState>[]
55
+ middlewares?: Middleware<TEntity, TState>[]
56
+ mode?: "eager" | "batched"
57
+ }
58
+
59
+ /**
60
+ * Listener function for state updates
61
+ */
62
+ export type Listener = () => void
63
+
64
+ /**
65
+ * Unsubscribe function
66
+ */
67
+ export type Unsubscribe = () => void
68
+
69
+ /**
70
+ * API object exposed to handlers
71
+ */
72
+ export interface Api<
73
+ TEntity extends BaseEntity = BaseEntity,
74
+ TState extends EntitiesState<TEntity> = EntitiesState<TEntity>,
75
+ > {
76
+ getTypes: () => TypesConfig<TEntity>
77
+ getType: (typeName: string) => EntityType<TEntity>
78
+ getEntities: () => TState
79
+ getEntity: (id: string) => TEntity | undefined
80
+ dispatch: (event: Event) => void
81
+ notify: (type: string, payload?: any) => void
82
+ [key: string]: any // For middleware extras
83
+ }
84
+
85
+ /**
86
+ * Base store interface
87
+ */
88
+ export interface Store<
89
+ TEntity extends BaseEntity = BaseEntity,
90
+ TState extends EntitiesState<TEntity> = EntitiesState<TEntity>,
91
+ > {
92
+ subscribe: (listener: Listener) => Unsubscribe
93
+ update: () => Event[]
94
+ notify: (type: string, payload?: any) => void
95
+ dispatch: (event: Event) => void
96
+ getTypes: () => TypesConfig<TEntity>
97
+ getType: (typeName: string) => EntityType<TEntity>
98
+ getState: () => TState
99
+ setState: (nextState: TState) => void
100
+ reset: () => void
101
+ _api?: Api<TEntity, TState>
102
+ extras?: Record<string, any>
103
+ }
104
+
105
+ /**
106
+ * Middleware function type
107
+ */
108
+ export type Middleware<
109
+ TEntity extends BaseEntity = BaseEntity,
110
+ TState extends EntitiesState<TEntity> = EntitiesState<TEntity>,
111
+ > = (store: Store<TEntity, TState>) => Store<TEntity, TState>
112
+
113
+ /**
114
+ * Built-in event payloads
115
+ */
116
+ export interface MorphEventPayload {
117
+ id: string
118
+ type: string
119
+ }
120
+
121
+ export type AddEventPayload<TEntity extends BaseEntity = BaseEntity> =
122
+ TEntity & {
123
+ id: string
124
+ }
125
+
126
+ export type RemoveEventPayload = string
127
+
128
+ /**
129
+ * Creates a store to manage state and events
130
+ */
131
+ export function createStore<
132
+ TEntity extends BaseEntity = BaseEntity,
133
+ TState extends EntitiesState<TEntity> = EntitiesState<TEntity>,
134
+ >(config: StoreConfig<TEntity, TState>): Store<TEntity, TState>
135
+
136
+ /**
137
+ * Creates an API object
138
+ */
139
+ export function createApi<
140
+ TEntity extends BaseEntity = BaseEntity,
141
+ TState extends EntitiesState<TEntity> = EntitiesState<TEntity>,
142
+ >(
143
+ store: Store<TEntity, TState>,
144
+ extras?: Record<string, any>,
145
+ ): Api<TEntity, TState>
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Mock API for testing (subset of full API)
3
+ */
4
+ export interface MockApi<
5
+ TEntity extends BaseEntity = BaseEntity,
6
+ TState extends EntitiesState<TEntity> = EntitiesState<TEntity>,
7
+ > {
8
+ getEntities: () => TState
9
+ getEntity: (id: string) => TEntity | undefined
10
+ dispatch: (event: Event) => void
11
+ notify: (type: string, payload?: any) => void
12
+ getEvents: () => Event[]
13
+ }
14
+
15
+ /**
16
+ * Result of trigger function
17
+ */
18
+ export interface TriggerResult<TState extends EntitiesState = EntitiesState> {
19
+ entities: TState
20
+ events: Event[]
21
+ }
22
+
23
+ /**
24
+ * Creates a mock API for testing event handlers
25
+ */
26
+ export function createMockApi<
27
+ TEntity extends BaseEntity = BaseEntity,
28
+ TState extends EntitiesState<TEntity> = EntitiesState<TEntity>,
29
+ >(entities: TState): MockApi<TEntity, TState>
30
+
31
+ /**
32
+ * Triggers an event handler in isolation for testing
33
+ */
34
+ export function trigger<
35
+ TEntity extends BaseEntity = BaseEntity,
36
+ TState extends EntitiesState<TEntity> = EntitiesState<TEntity>,
37
+ TPayload = any,
38
+ >(
39
+ entities: TState,
40
+ eventHandler: (
41
+ state: TState,
42
+ payload: TPayload,
43
+ api: MockApi<TEntity, TState>,
44
+ ) => void,
45
+ eventPayload: TPayload,
46
+ api?: MockApi<TEntity, TState>,
47
+ ): TriggerResult<TState>
@@ -1,51 +0,0 @@
1
- import { expect, test } from "vitest"
2
-
3
- import { augmentEntities, augmentEntity } from "./entities.js"
4
-
5
- test("augmentEntity should add an id to an entity", () => {
6
- const entity = { type: "player", health: 100 }
7
- const result = augmentEntity("player1", entity)
8
-
9
- expect(result).toStrictEqual({ id: "player1", type: "player", health: 100 })
10
- })
11
-
12
- test("augmentEntity should not mutate the original entity", () => {
13
- const entity = { type: "player" }
14
- augmentEntity("player1", entity)
15
-
16
- expect(entity).toStrictEqual({ type: "player" })
17
- expect(entity).not.toHaveProperty("id")
18
- })
19
-
20
- test("augmentEntity should overwrite an existing id property", () => {
21
- const entity = { id: "oldId", type: "player" }
22
- const result = augmentEntity("newId", entity)
23
-
24
- expect(result).toStrictEqual({ id: "newId", type: "player" })
25
- })
26
-
27
- test("augmentEntities should augment a collection of entities", () => {
28
- const entities = {
29
- player1: { type: "player", health: 100 },
30
- enemy1: { type: "enemy", damage: 10 },
31
- }
32
- const result = augmentEntities(entities)
33
-
34
- expect(result).toStrictEqual({
35
- player1: { id: "player1", type: "player", health: 100 },
36
- enemy1: { id: "enemy1", type: "enemy", damage: 10 },
37
- })
38
- })
39
-
40
- test("augmentEntities should handle an empty collection", () => {
41
- const result = augmentEntities({})
42
-
43
- expect(result).toStrictEqual({})
44
- })
45
-
46
- test("augmentEntities should not mutate the original entities object", () => {
47
- const entities = { player1: { type: "player" } }
48
- augmentEntities(entities)
49
-
50
- expect(entities.player1).not.toHaveProperty("id")
51
- })
@@ -1,214 +0,0 @@
1
- import { performance } from "node:perf_hooks"
2
-
3
- import { expect, test, vi } from "vitest"
4
-
5
- import { EventMap } from "./event-map.js"
6
-
7
- test("constructor should initialize the event map from types and entities", () => {
8
- const types = {
9
- player: {
10
- update: () => {},
11
- fire: () => {},
12
- },
13
- enemy: {
14
- update: () => {},
15
- },
16
- item: {}, // Type with no events
17
- }
18
- const entities = {
19
- player1: { type: "player" },
20
- player2: { type: "player" },
21
- enemy1: { type: "enemy" },
22
- item1: { type: "item" },
23
- ghost1: { type: "ghost" }, // Type that doesn't exist
24
- }
25
-
26
- const eventMap = new EventMap(types, entities)
27
-
28
- expect(eventMap.getEntitiesForEvent("update")).toStrictEqual([
29
- "player1",
30
- "player2",
31
- "enemy1",
32
- ])
33
- expect(eventMap.getEntitiesForEvent("fire")).toStrictEqual([
34
- "player1",
35
- "player2",
36
- ])
37
-
38
- // 'item' type has no events, so it shouldn't be in the map
39
- expect(eventMap.getEntitiesForEvent("item")).toStrictEqual([])
40
- // 'ghost' type doesn't exist, so it should be ignored
41
- expect(eventMap.getEntitiesForEvent("ghost")).toStrictEqual([])
42
- })
43
-
44
- test("addEntity should add an entity to the correct event sets", () => {
45
- const types = {
46
- player: {
47
- update: () => {},
48
- jump: () => {},
49
- },
50
- }
51
- const eventMap = new EventMap(types, {})
52
-
53
- eventMap.addEntity("player1", types.player, "player")
54
-
55
- expect(eventMap.getEntitiesForEvent("update")).toStrictEqual(["player1"])
56
- expect(eventMap.getEntitiesForEvent("jump")).toStrictEqual(["player1"])
57
-
58
- // Add another entity of the same type
59
- eventMap.addEntity("player2", types.player, "player")
60
- expect(eventMap.getEntitiesForEvent("update")).toStrictEqual([
61
- "player1",
62
- "player2",
63
- ])
64
- expect(eventMap.getEntitiesForEvent("jump")).toStrictEqual([
65
- "player1",
66
- "player2",
67
- ])
68
- })
69
-
70
- test("removeEntity should remove an entity from its event sets", () => {
71
- const types = {
72
- player: {
73
- update: () => {},
74
- fire: () => {},
75
- },
76
- }
77
- const entities = {
78
- player1: { type: "player" },
79
- player2: { type: "player" },
80
- }
81
- const eventMap = new EventMap(types, entities)
82
-
83
- eventMap.removeEntity("player1", types.player, "player")
84
-
85
- expect(eventMap.getEntitiesForEvent("update")).toStrictEqual(["player2"])
86
- expect(eventMap.getEntitiesForEvent("fire")).toStrictEqual(["player2"])
87
-
88
- // Removing a non-existent entity should not throw an error
89
- expect(() =>
90
- eventMap.removeEntity("player3", types.player, "player"),
91
- ).not.toThrow()
92
- })
93
-
94
- test("getEntitiesForEvent should return the correct set of entities for an event", () => {
95
- const types = {
96
- player: { update: () => {} },
97
- enemy: { update: () => {} },
98
- }
99
- const entities = {
100
- player1: { type: "player" },
101
- enemy1: { type: "enemy" },
102
- }
103
- const eventMap = new EventMap(types, entities)
104
-
105
- const updateEntities = eventMap.getEntitiesForEvent("update")
106
- expect(updateEntities).toStrictEqual(["player1", "enemy1"])
107
-
108
- const fireEntities = eventMap.getEntitiesForEvent("fire")
109
- expect(fireEntities).toStrictEqual([])
110
- })
111
-
112
- test("getEntitiesForEvent should handle scoped events correctly", () => {
113
- const types = {
114
- form: { submit: () => {} },
115
- button: { submit: () => {} },
116
- }
117
- const entities = {
118
- loginForm: { type: "form" },
119
- signupForm: { type: "form" },
120
- submitButton: { type: "button" },
121
- }
122
- const eventMap = new EventMap(types, entities)
123
-
124
- // Scoped to a type: 'form:submit' should only return form entities
125
- expect(eventMap.getEntitiesForEvent("form:submit")).toStrictEqual([
126
- "loginForm",
127
- "signupForm",
128
- ])
129
-
130
- // Scoped to a specific entity by ID: '#loginForm:submit'
131
- expect(eventMap.getEntitiesForEvent("#loginForm:submit")).toStrictEqual([
132
- "loginForm",
133
- ])
134
-
135
- // Scoped to a specific entity by type and ID: 'form#loginForm:submit'
136
- expect(eventMap.getEntitiesForEvent("form#loginForm:submit")).toStrictEqual([
137
- "loginForm",
138
- ])
139
-
140
- // Scoped to a non-existent entity ID
141
- expect(eventMap.getEntitiesForEvent("#nonExistent:submit")).toStrictEqual([])
142
-
143
- // Scoped to an entity that exists but doesn't handle the event
144
- expect(eventMap.getEntitiesForEvent("#loginForm:click")).toStrictEqual([])
145
-
146
- // Scoped to an entity ID, but with the wrong type specified
147
- expect(eventMap.getEntitiesForEvent("button#loginForm:submit")).toStrictEqual(
148
- [],
149
- )
150
-
151
- // Broadcast event should return all entities with the handler
152
- expect(eventMap.getEntitiesForEvent("submit")).toStrictEqual([
153
- "loginForm",
154
- "signupForm",
155
- "submitButton",
156
- ])
157
- })
158
-
159
- test("EventMap provides a significant performance benefit for event handling", async () => {
160
- const ENTITY_COUNT = 10000
161
- const { entities, types } = createTestEntities(ENTITY_COUNT)
162
- const eventMap = new EventMap(types, entities)
163
-
164
- // We'll use a mock function to ensure the "work" is consistent
165
- const updateHandler = vi.fn()
166
- types.updater.update = updateHandler
167
-
168
- // --- Simulation A: The Old Way (iterating all entities) ---
169
- const oldWayStartTime = performance.now()
170
- for (const id in entities) {
171
- const entity = entities[id]
172
- const type = types[entity.type]
173
- if (type.update) {
174
- type.update()
175
- }
176
- }
177
- const oldWayTime = performance.now() - oldWayStartTime
178
-
179
- // Reset the mock for the next simulation
180
- updateHandler.mockClear()
181
-
182
- // --- Simulation B: The New Way (using EventMap) ---
183
- const newWayStartTime = performance.now()
184
- const updaterIds = eventMap.getEntitiesForEvent("update")
185
- for (const id of updaterIds) {
186
- const entity = entities[id]
187
- const type = types[entity.type]
188
- type.update()
189
- }
190
- const newWayTime = performance.now() - newWayStartTime
191
-
192
- // Assertions to verify correctness
193
- expect(oldWayTime).toBeGreaterThan(newWayTime)
194
- expect(updateHandler).toHaveBeenCalledTimes(updaterIds.length)
195
- })
196
-
197
- // Helper function to create a large set of test entities
198
- function createTestEntities(count) {
199
- const entities = {}
200
- const types = {}
201
- // 10% of entities will have a mock 'update' handler
202
- const updaterType = { update: vi.fn() }
203
- const staticType = {}
204
-
205
- for (let i = 0; i < count; i++) {
206
- const isUpdater = Math.random() < 0.1
207
- const typeId = isUpdater ? "updater" : "static"
208
- entities[`entity-${i}`] = { id: `entity-${i}`, type: typeId }
209
- }
210
- types["updater"] = updaterType
211
- types["static"] = staticType
212
-
213
- return { entities, types }
214
- }
package/src/store.test.js DELETED
@@ -1,178 +0,0 @@
1
- import { expect, test } from "vitest"
2
-
3
- import { createStore } from "./store.js"
4
-
5
- test("it should process events by mutating state inside handlers", () => {
6
- const config = {
7
- types: {
8
- kitty: {
9
- feed(entity) {
10
- entity.isFed = true
11
- },
12
- },
13
- },
14
- entities: {
15
- kitty1: { type: "kitty" },
16
- },
17
- }
18
- const afterState = {
19
- kitty1: {
20
- id: "kitty1",
21
- type: "kitty",
22
- isFed: true,
23
- },
24
- }
25
-
26
- const store = createStore(config)
27
- store.notify("feed")
28
- store.update()
29
-
30
- const state = store.getState()
31
- expect(state).toStrictEqual(afterState)
32
- })
33
-
34
- test("it should process an event queue in the same update cycle", () => {
35
- const config = {
36
- types: {
37
- kitty: {
38
- feed(entity) {
39
- entity.isFed = true
40
- },
41
- update(entity) {
42
- entity.isMeowing = true
43
- },
44
- },
45
- },
46
-
47
- entities: {
48
- kitty1: { type: "kitty" },
49
- },
50
- }
51
- const afterState = {
52
- kitty1: {
53
- id: "kitty1",
54
- type: "kitty",
55
- isFed: true,
56
- isMeowing: true,
57
- },
58
- }
59
-
60
- const store = createStore(config)
61
- store.notify("feed")
62
- store.notify("update")
63
- store.update()
64
-
65
- const state = store.getState()
66
- expect(state).toStrictEqual(afterState)
67
- })
68
-
69
- test("it should send an event from an entity and process it in the same update cycle in batched mode", () => {
70
- const config = {
71
- types: {
72
- doggo: {
73
- update(entity, dt, api) {
74
- api.notify("bark")
75
- },
76
- },
77
- kitty: {
78
- bark(entity) {
79
- entity.position = "far"
80
- },
81
- },
82
- },
83
-
84
- entities: {
85
- doggo1: { type: "doggo" },
86
- kitty1: { type: "kitty", position: "near" },
87
- },
88
-
89
- updateMode: "manual",
90
- }
91
- const afterState = {
92
- doggo1: { id: "doggo1", type: "doggo" },
93
- kitty1: { id: "kitty1", type: "kitty", position: "far" },
94
- }
95
-
96
- const store = createStore(config)
97
- const api = { notify: store.notify }
98
- store.notify("update")
99
- store.update(api)
100
-
101
- const state = store.getState()
102
- expect(state).toStrictEqual(afterState)
103
- })
104
-
105
- test("it should add an entity via an 'add' event", () => {
106
- const config = {
107
- types: {
108
- kitty: {},
109
- },
110
- entities: {},
111
- }
112
- const newEntity = { id: "kitty1", type: "kitty" }
113
- const afterState = {
114
- kitty1: { id: "kitty1", type: "kitty" },
115
- }
116
-
117
- const store = createStore(config)
118
- store.notify("add", newEntity)
119
- store.update()
120
-
121
- const state = store.getState()
122
- expect(state).toStrictEqual(afterState)
123
- })
124
-
125
- test("it should remove an entity via a 'remove' event", () => {
126
- const config = {
127
- types: {},
128
- entities: {
129
- kitty1: { type: "kitty" },
130
- },
131
- }
132
- const store = createStore(config)
133
-
134
- store.notify("remove", "kitty1")
135
- store.update()
136
-
137
- const state = store.getState()
138
- expect(state.kitty1).toBeUndefined()
139
- })
140
-
141
- test("it should change an entity's behavior via a 'morph' event", () => {
142
- const Caterpillar = {
143
- eat(entity) {
144
- entity.isFull = true
145
- },
146
- }
147
- const Butterfly = {
148
- fly(entity) {
149
- entity.hasFlown = true
150
- },
151
- }
152
-
153
- const config = {
154
- types: {
155
- bug: Caterpillar,
156
- },
157
-
158
- entities: {
159
- bug: { type: "bug" },
160
- },
161
- }
162
-
163
- const store = createStore(config)
164
-
165
- store.notify("eat")
166
- store.update()
167
-
168
- expect(store.getState()).toStrictEqual({
169
- bug: { id: "bug", type: "bug", isFull: true },
170
- })
171
-
172
- store.notify("morph", { name: "bug", type: [Caterpillar, Butterfly] })
173
- store.notify("fly")
174
- store.update()
175
- expect(store.getState()).toStrictEqual({
176
- bug: { id: "bug", type: "bug", isFull: true, hasFlown: true },
177
- })
178
- })
package/src/test.test.js DELETED
@@ -1,272 +0,0 @@
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
- })
package/src/types.test.js DELETED
@@ -1,87 +0,0 @@
1
- import { expect, test } from "vitest"
2
-
3
- import { augmentType, augmentTypes } from "./types.js"
4
-
5
- test("should handle a single behavior object (mixin)", () => {
6
- const behavior = { onFire: () => "bang" }
7
- const result = augmentType(behavior)
8
-
9
- expect(result).toStrictEqual(behavior)
10
- // Ensure it's a new object, not the same reference
11
- expect(result).not.toBe(behavior)
12
- })
13
-
14
- test("should compose an array of behavior objects (mixins)", () => {
15
- const behavior1 = { onFire: () => "bang", onMove: () => "vroom" }
16
- const behavior2 = { onJump: () => "boing", onFire: () => "pow" } // onFire overwrites
17
- const result = augmentType([behavior1, behavior2])
18
-
19
- expect(result).toHaveProperty("onMove")
20
- expect(result).toHaveProperty("onJump")
21
- expect(result).toHaveProperty("onFire")
22
- expect(result.onFire()).toBe("pow")
23
- })
24
-
25
- test("should handle a single behavior function", () => {
26
- const behavior = (type) => ({ ...type, onJump: () => "boing" })
27
- const result = augmentType(behavior)
28
-
29
- expect(result).toHaveProperty("onJump")
30
- expect(result.onJump()).toBe("boing")
31
- })
32
-
33
- test("should compose an array of behavior functions (pipe)", () => {
34
- const behavior1 = (type) => ({ ...type, onMove: () => "vroom" })
35
- const behavior2 = (type) => ({ ...type, onJump: () => "boing" })
36
- const result = augmentType([behavior1, behavior2])
37
-
38
- expect(result).toHaveProperty("onMove")
39
- expect(result).toHaveProperty("onJump")
40
- expect(result.onMove()).toBe("vroom")
41
- expect(result.onJump()).toBe("boing")
42
- })
43
-
44
- test("should compose a mixed array of objects and functions", () => {
45
- const mixin1 = { onFire: () => "bang" }
46
- const behavior1 = (type) => ({ ...type, onJump: () => "boing" })
47
- const mixin2 = { onMove: () => "vroom", onFire: () => "pow" } // onFire overwrites
48
-
49
- const result = augmentType([mixin1, behavior1, mixin2])
50
-
51
- expect(result).toHaveProperty("onFire")
52
- expect(result).toHaveProperty("onJump")
53
- expect(result).toHaveProperty("onMove")
54
- expect(result.onFire()).toBe("pow")
55
- expect(result.onJump()).toBe("boing")
56
- expect(result.onMove()).toBe("vroom")
57
- })
58
-
59
- test("should return an empty object for an empty array", () => {
60
- const result = augmentType([])
61
- expect(result).toStrictEqual({})
62
- })
63
-
64
- test("should handle undefined or null input gracefully", () => {
65
- expect(augmentType(undefined)).toStrictEqual({})
66
- expect(augmentType(null)).toStrictEqual({})
67
- })
68
-
69
- test("should augment a map of types correctly", () => {
70
- const types = {
71
- player: { onJump: () => "boing" },
72
- enemy: [
73
- { onMove: () => "stomp" },
74
- (type) => ({ ...type, onAttack: () => "bite" }),
75
- ],
76
- item: [],
77
- }
78
-
79
- const result = augmentTypes(types)
80
-
81
- expect(result.player).toStrictEqual({ onJump: expect.any(Function) })
82
- expect(result.enemy).toStrictEqual({
83
- onMove: expect.any(Function),
84
- onAttack: expect.any(Function),
85
- })
86
- expect(result.item).toStrictEqual({})
87
- })