@inglorious/store 8.0.0 → 9.0.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 +2 -3
- package/package.json +6 -4
- package/src/api.js +1 -0
- package/src/event-map.js +2 -2
- package/src/store.js +21 -18
- package/types/client/devtools.d.ts +10 -0
- package/types/index.d.ts +3 -0
- package/types/select.d.ts +24 -0
- package/types/store.d.ts +147 -0
- package/types/test.d.ts +47 -0
- package/src/entities.test.js +0 -51
- package/src/event-map.test.js +0 -214
- package/src/store.test.js +0 -178
- package/src/test.test.js +0 -272
- package/src/types.test.js +0 -87
package/README.md
CHANGED
|
@@ -30,7 +30,7 @@ Game engines solved state complexity years ago — Inglorious Store brings those
|
|
|
30
30
|
- ✅ Entity-based state (manage multiple instances effortlessly)
|
|
31
31
|
- ✅ No action creators, thunks, or slices
|
|
32
32
|
- ✅ Predictable, testable, purely functional code
|
|
33
|
-
- ✅ Built-in lifecycle events (`add`, `remove
|
|
33
|
+
- ✅ Built-in lifecycle events (`add`, `remove`)
|
|
34
34
|
- ✅ 10x faster immutability than Redux Toolkit (Mutative vs Immer)
|
|
35
35
|
|
|
36
36
|
---
|
|
@@ -363,7 +363,6 @@ Inglorious Store has a few built-in events that you can use:
|
|
|
363
363
|
|
|
364
364
|
- `add`: adds a new entity to the state. Triggers a `create` lifecycle event.
|
|
365
365
|
- `remove`: removes an entity from the state. Triggers a `destroy` lifecycle event.
|
|
366
|
-
- `morph`: changes the behavior of a type (advanced, used by middlewares/rendering systems)
|
|
367
366
|
|
|
368
367
|
The lifecycle events can be used to define event handlers similar to constructor and destructor methods in OOP:
|
|
369
368
|
|
|
@@ -718,12 +717,12 @@ Each handler receives three arguments:
|
|
|
718
717
|
- `dispatch(action)` - optional, if you prefer Redux-style dispatching
|
|
719
718
|
- `getTypes()` - type definitions (for middleware)
|
|
720
719
|
- `getType(typeName)` - type definition (for overriding)
|
|
720
|
+
- `setType(typeName, type)` - change the behavior of a type
|
|
721
721
|
|
|
722
722
|
### Built-in Events
|
|
723
723
|
|
|
724
724
|
- **`create(entity)`** - triggered when entity added via `add` event, visible only to that entity
|
|
725
725
|
- **`destroy(entity)`** - triggered when entity removed via `remove` event, visible only to that entity
|
|
726
|
-
- **`morph(typeName, newType)`** - used to change the behavior of a type on the fly
|
|
727
726
|
|
|
728
727
|
### Notify vs Dispatch
|
|
729
728
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inglorious/store",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "9.0.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",
|
|
@@ -42,17 +42,19 @@
|
|
|
42
42
|
}
|
|
43
43
|
},
|
|
44
44
|
"files": [
|
|
45
|
-
"src"
|
|
45
|
+
"src",
|
|
46
|
+
"types",
|
|
47
|
+
"!src/**/*.test.js"
|
|
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.
|
|
54
|
+
"@inglorious/utils": "3.7.1"
|
|
53
55
|
},
|
|
54
56
|
"peerDependencies": {
|
|
55
|
-
"@inglorious/utils": "3.7.
|
|
57
|
+
"@inglorious/utils": "3.7.1"
|
|
56
58
|
},
|
|
57
59
|
"devDependencies": {
|
|
58
60
|
"prettier": "^3.6.2",
|
package/src/api.js
CHANGED
package/src/event-map.js
CHANGED
|
@@ -44,7 +44,7 @@ export class EventMap {
|
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
46
|
* Adds an entity's ID to the Sets for all event handlers defined in its type.
|
|
47
|
-
* This should be called when a new entity is created or its type is
|
|
47
|
+
* This should be called when a new entity is created or its type is changed via setType.
|
|
48
48
|
*
|
|
49
49
|
* @param {string} entityId - The ID of the entity.
|
|
50
50
|
* @param {Type} type - The augmented type object of the entity.
|
|
@@ -71,7 +71,7 @@ export class EventMap {
|
|
|
71
71
|
|
|
72
72
|
/**
|
|
73
73
|
* Removes an entity's ID from the Sets for all event handlers defined in its type.
|
|
74
|
-
* This should be called when an entity is removed or its type is
|
|
74
|
+
* This should be called when an entity is removed or its type is changed via setType.
|
|
75
75
|
*
|
|
76
76
|
* @param {string} entityId - The ID of the entity.
|
|
77
77
|
* @param {Type} type - The augmented type object of the entity.
|
package/src/store.js
CHANGED
|
@@ -37,6 +37,7 @@ export function createStore({
|
|
|
37
37
|
dispatch, // needed for compatibility with Redux
|
|
38
38
|
getTypes,
|
|
39
39
|
getType,
|
|
40
|
+
setType,
|
|
40
41
|
getState,
|
|
41
42
|
setState,
|
|
42
43
|
reset,
|
|
@@ -89,24 +90,6 @@ export function createStore({
|
|
|
89
90
|
processedEvents.push(event)
|
|
90
91
|
|
|
91
92
|
// Handle special system events
|
|
92
|
-
if (event.type === "morph") {
|
|
93
|
-
const { name, type } = event.payload
|
|
94
|
-
|
|
95
|
-
const oldType = types[name]
|
|
96
|
-
|
|
97
|
-
originalTypes[name] = type
|
|
98
|
-
types[name] = augmentType(type)
|
|
99
|
-
const newType = types[name]
|
|
100
|
-
|
|
101
|
-
for (const [id, entity] of Object.entries(draft)) {
|
|
102
|
-
if (entity.type === name) {
|
|
103
|
-
eventMap.removeEntity(id, oldType, name)
|
|
104
|
-
eventMap.addEntity(id, newType, name)
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
continue
|
|
108
|
-
}
|
|
109
|
-
|
|
110
93
|
if (event.type === "add") {
|
|
111
94
|
const { id, ...entity } = event.payload
|
|
112
95
|
draft[id] = augmentEntity(id, entity)
|
|
@@ -200,6 +183,26 @@ export function createStore({
|
|
|
200
183
|
return types[typeName]
|
|
201
184
|
}
|
|
202
185
|
|
|
186
|
+
/**
|
|
187
|
+
* Sets an augmented type configuration given its name.
|
|
188
|
+
* @param {string} typeName - The name of the type to set.
|
|
189
|
+
* @param {Object} type - The type configuration.
|
|
190
|
+
*/
|
|
191
|
+
function setType(typeName, type) {
|
|
192
|
+
const oldType = types[typeName]
|
|
193
|
+
|
|
194
|
+
originalTypes[typeName] = type
|
|
195
|
+
types[typeName] = augmentType(type)
|
|
196
|
+
const newType = types[typeName]
|
|
197
|
+
|
|
198
|
+
for (const [id, entity] of Object.entries(state)) {
|
|
199
|
+
if (entity.type === typeName) {
|
|
200
|
+
eventMap.removeEntity(id, oldType, typeName)
|
|
201
|
+
eventMap.addEntity(id, newType, typeName)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
203
206
|
/**
|
|
204
207
|
* Retrieves the current state.
|
|
205
208
|
* @returns {Object} The current state.
|
package/types/index.d.ts
ADDED
|
@@ -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>
|
package/types/store.d.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
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
|
+
setType: (typeName: string, type: EntityType<TEntity>) => void
|
|
79
|
+
getEntities: () => TState
|
|
80
|
+
getEntity: (id: string) => TEntity | undefined
|
|
81
|
+
dispatch: (event: Event) => void
|
|
82
|
+
notify: (type: string, payload?: any) => void
|
|
83
|
+
[key: string]: any // For middleware extras
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Base store interface
|
|
88
|
+
*/
|
|
89
|
+
export interface Store<
|
|
90
|
+
TEntity extends BaseEntity = BaseEntity,
|
|
91
|
+
TState extends EntitiesState<TEntity> = EntitiesState<TEntity>,
|
|
92
|
+
> {
|
|
93
|
+
subscribe: (listener: Listener) => Unsubscribe
|
|
94
|
+
update: () => Event[]
|
|
95
|
+
notify: (type: string, payload?: any) => void
|
|
96
|
+
dispatch: (event: Event) => void
|
|
97
|
+
getTypes: () => TypesConfig<TEntity>
|
|
98
|
+
getType: (typeName: string) => EntityType<TEntity>
|
|
99
|
+
setType: (typeName: string, type: EntityType<TEntity>) => void
|
|
100
|
+
getState: () => TState
|
|
101
|
+
setState: (nextState: TState) => void
|
|
102
|
+
reset: () => void
|
|
103
|
+
_api?: Api<TEntity, TState>
|
|
104
|
+
extras?: Record<string, any>
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Middleware function type
|
|
109
|
+
*/
|
|
110
|
+
export type Middleware<
|
|
111
|
+
TEntity extends BaseEntity = BaseEntity,
|
|
112
|
+
TState extends EntitiesState<TEntity> = EntitiesState<TEntity>,
|
|
113
|
+
> = (store: Store<TEntity, TState>) => Store<TEntity, TState>
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Built-in event payloads
|
|
117
|
+
*/
|
|
118
|
+
export interface MorphEventPayload {
|
|
119
|
+
id: string
|
|
120
|
+
type: string
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export type AddEventPayload<TEntity extends BaseEntity = BaseEntity> =
|
|
124
|
+
TEntity & {
|
|
125
|
+
id: string
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export type RemoveEventPayload = string
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Creates a store to manage state and events
|
|
132
|
+
*/
|
|
133
|
+
export function createStore<
|
|
134
|
+
TEntity extends BaseEntity = BaseEntity,
|
|
135
|
+
TState extends EntitiesState<TEntity> = EntitiesState<TEntity>,
|
|
136
|
+
>(config: StoreConfig<TEntity, TState>): Store<TEntity, TState>
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Creates an API object
|
|
140
|
+
*/
|
|
141
|
+
export function createApi<
|
|
142
|
+
TEntity extends BaseEntity = BaseEntity,
|
|
143
|
+
TState extends EntitiesState<TEntity> = EntitiesState<TEntity>,
|
|
144
|
+
>(
|
|
145
|
+
store: Store<TEntity, TState>,
|
|
146
|
+
extras?: Record<string, any>,
|
|
147
|
+
): Api<TEntity, TState>
|
package/types/test.d.ts
ADDED
|
@@ -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>
|
package/src/entities.test.js
DELETED
|
@@ -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
|
-
})
|
package/src/event-map.test.js
DELETED
|
@@ -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
|
-
})
|