mutts 1.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.
Files changed (82) hide show
  1. package/README.md +150 -0
  2. package/dist/chunks/decorator-BXsign4Z.js +176 -0
  3. package/dist/chunks/decorator-BXsign4Z.js.map +1 -0
  4. package/dist/chunks/decorator-CPbZNnsX.esm.js +168 -0
  5. package/dist/chunks/decorator-CPbZNnsX.esm.js.map +1 -0
  6. package/dist/decorator.d.ts +50 -0
  7. package/dist/decorator.esm.js +2 -0
  8. package/dist/decorator.esm.js.map +1 -0
  9. package/dist/decorator.js +11 -0
  10. package/dist/decorator.js.map +1 -0
  11. package/dist/destroyable.d.ts +48 -0
  12. package/dist/destroyable.esm.js +91 -0
  13. package/dist/destroyable.esm.js.map +1 -0
  14. package/dist/destroyable.js +98 -0
  15. package/dist/destroyable.js.map +1 -0
  16. package/dist/eventful.d.ts +11 -0
  17. package/dist/eventful.esm.js +88 -0
  18. package/dist/eventful.esm.js.map +1 -0
  19. package/dist/eventful.js +90 -0
  20. package/dist/eventful.js.map +1 -0
  21. package/dist/index.d.ts +15 -0
  22. package/dist/index.esm.js +7 -0
  23. package/dist/index.esm.js.map +1 -0
  24. package/dist/index.js +52 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/indexable.d.ts +31 -0
  27. package/dist/indexable.esm.js +85 -0
  28. package/dist/indexable.esm.js.map +1 -0
  29. package/dist/indexable.js +89 -0
  30. package/dist/indexable.js.map +1 -0
  31. package/dist/mutts.umd.js +2 -0
  32. package/dist/mutts.umd.js.map +1 -0
  33. package/dist/mutts.umd.min.js +2 -0
  34. package/dist/mutts.umd.min.js.map +1 -0
  35. package/dist/promiseChain.d.ts +11 -0
  36. package/dist/promiseChain.esm.js +72 -0
  37. package/dist/promiseChain.esm.js.map +1 -0
  38. package/dist/promiseChain.js +74 -0
  39. package/dist/promiseChain.js.map +1 -0
  40. package/dist/reactive.d.ts +114 -0
  41. package/dist/reactive.esm.js +1455 -0
  42. package/dist/reactive.esm.js.map +1 -0
  43. package/dist/reactive.js +1472 -0
  44. package/dist/reactive.js.map +1 -0
  45. package/dist/std-decorators.d.ts +17 -0
  46. package/dist/std-decorators.esm.js +161 -0
  47. package/dist/std-decorators.esm.js.map +1 -0
  48. package/dist/std-decorators.js +169 -0
  49. package/dist/std-decorators.js.map +1 -0
  50. package/docs/decorator.md +300 -0
  51. package/docs/destroyable.md +294 -0
  52. package/docs/events.md +225 -0
  53. package/docs/indexable.md +561 -0
  54. package/docs/promiseChain.md +218 -0
  55. package/docs/reactive.md +2072 -0
  56. package/docs/std-decorators.md +558 -0
  57. package/package.json +132 -0
  58. package/src/decorator.test.ts +495 -0
  59. package/src/decorator.ts +205 -0
  60. package/src/destroyable.test.ts +155 -0
  61. package/src/destroyable.ts +158 -0
  62. package/src/eventful.test.ts +380 -0
  63. package/src/eventful.ts +69 -0
  64. package/src/index.ts +7 -0
  65. package/src/indexable.test.ts +388 -0
  66. package/src/indexable.ts +124 -0
  67. package/src/promiseChain.test.ts +201 -0
  68. package/src/promiseChain.ts +99 -0
  69. package/src/reactive/array.test.ts +923 -0
  70. package/src/reactive/array.ts +352 -0
  71. package/src/reactive/core.test.ts +1663 -0
  72. package/src/reactive/core.ts +866 -0
  73. package/src/reactive/index.ts +28 -0
  74. package/src/reactive/interface.test.ts +1477 -0
  75. package/src/reactive/interface.ts +231 -0
  76. package/src/reactive/map.test.ts +866 -0
  77. package/src/reactive/map.ts +162 -0
  78. package/src/reactive/set.test.ts +289 -0
  79. package/src/reactive/set.ts +142 -0
  80. package/src/std-decorators.test.ts +679 -0
  81. package/src/std-decorators.ts +182 -0
  82. package/src/utils.ts +52 -0
@@ -0,0 +1,155 @@
1
+ import {
2
+ allocated,
3
+ allocatedValues,
4
+ Destroyable,
5
+ DestructionError,
6
+ destructor,
7
+ } from './destroyable'
8
+
9
+ function tick(ms: number = 0) {
10
+ return new Promise((resolve) => setTimeout(resolve, ms))
11
+ }
12
+
13
+ const gc = global.gc
14
+
15
+ async function collectGarbages() {
16
+ await tick()
17
+ gc!()
18
+ await tick()
19
+ }
20
+
21
+ describe('Destroyable', () => {
22
+ describe('with base class and destructor object', () => {
23
+ it('should create destroyable class with custom destructor', async () => {
24
+ let receivedAllocated: any = null
25
+
26
+ class MyClass extends Destroyable({
27
+ destructor(allocated) {
28
+ receivedAllocated = allocated
29
+ },
30
+ }) {
31
+ constructor(public name: string) {
32
+ super()
33
+ this[allocatedValues].name = name
34
+ }
35
+ }
36
+
37
+ ;(() => {
38
+ const obj = new MyClass('test')
39
+ expect(obj.name).toBe('test')
40
+ })()
41
+ await collectGarbages()
42
+ expect(receivedAllocated.name).toBe('test')
43
+ })
44
+
45
+ it('should pass constructor arguments to base class', () => {
46
+ class BaseClass {
47
+ constructor(public value: number) {}
48
+ }
49
+
50
+ const DestroyableBaseClass = Destroyable(BaseClass, {
51
+ destructor: () => {},
52
+ })
53
+
54
+ const obj = new DestroyableBaseClass(42)
55
+ expect(obj.value).toBe(42)
56
+ })
57
+
58
+ it('should throw error when accessing destroyed object', () => {
59
+ class MyClass {
60
+ constructor(public name: string) {}
61
+ }
62
+
63
+ const DestroyableMyClass = Destroyable(MyClass, {
64
+ destructor: () => {},
65
+ })
66
+
67
+ const obj = new DestroyableMyClass('test')
68
+ DestroyableMyClass.destroy(obj)
69
+
70
+ expect(() => obj.name).toThrow(DestructionError)
71
+ expect(() => {
72
+ obj.name = 'value'
73
+ }).toThrow(DestructionError)
74
+ })
75
+ })
76
+
77
+ describe('with destructor object only', () => {
78
+ it('should create destroyable class from scratch', () => {
79
+ let destructorCalled = false
80
+
81
+ const DestroyableClass = Destroyable({
82
+ destructor: () => {
83
+ destructorCalled = true
84
+ },
85
+ })
86
+
87
+ const obj = new DestroyableClass()
88
+ expect(DestroyableClass.isDestroyable(obj)).toBe(true)
89
+ expect(destructorCalled).toBe(false)
90
+
91
+ const result = DestroyableClass.destroy(obj)
92
+ expect(result).toBe(true)
93
+ expect(destructorCalled).toBe(true)
94
+ })
95
+ })
96
+
97
+ describe('with base class only', () => {
98
+ it('should create destroyable class with default destructor', () => {
99
+ class MyClass {
100
+ constructor(public name: string) {}
101
+ }
102
+
103
+ const DestroyableMyClass = Destroyable(MyClass)
104
+
105
+ expect(() => new DestroyableMyClass('test')).toThrow(DestructionError)
106
+ })
107
+ })
108
+
109
+ describe('class with [destructor] method', () => {
110
+ it('should call [destructor] method with allocated values', async () => {
111
+ let receivedAllocated: any = null
112
+
113
+ class MyClass extends Destroyable() {
114
+ constructor(public name: string) {
115
+ super()
116
+ this[allocatedValues].name = name
117
+ }
118
+ [destructor](allocated: any) {
119
+ receivedAllocated = allocated
120
+ }
121
+ }
122
+
123
+ ;(() => {
124
+ const obj = new MyClass('test')
125
+ expect(obj.name).toBe('test')
126
+ })()
127
+ await collectGarbages()
128
+ expect(receivedAllocated.name).toBe('test')
129
+ })
130
+ })
131
+ describe('decorators usage', () => {
132
+ it('should collect allocated from decorators', async () => {
133
+ let receivedAllocated: any = null
134
+
135
+ class MyClass extends Destroyable() {
136
+ @allocated
137
+ accessor name: string
138
+ constructor(name: string) {
139
+ super()
140
+ this.name = name
141
+ }
142
+ [destructor](allocated: any) {
143
+ receivedAllocated = allocated
144
+ }
145
+ }
146
+
147
+ ;(() => {
148
+ const obj = new MyClass('test')
149
+ expect(obj.name).toBe('test')
150
+ })()
151
+ await collectGarbages()
152
+ expect(receivedAllocated.name).toBe('test')
153
+ })
154
+ })
155
+ })
@@ -0,0 +1,158 @@
1
+ import { decorator } from './decorator'
2
+
3
+ // Integrated with `using` statement via Symbol.dispose
4
+ const fr = new FinalizationRegistry<() => void>((f) => f())
5
+ export const destructor = Symbol('destructor')
6
+ export const allocatedValues = Symbol('allocated')
7
+ export class DestructionError extends Error {
8
+ static throw<_T = void>(msg: string) {
9
+ return () => {
10
+ throw new DestructionError(msg)
11
+ }
12
+ }
13
+ constructor(msg: string) {
14
+ super(`Object is destroyed. ${msg}`)
15
+ this.name = 'DestroyedAccessError'
16
+ }
17
+ }
18
+ const destroyedHandler = {
19
+ [Symbol.toStringTag]: 'MutTs Destroyable',
20
+ get: DestructionError.throw('Cannot access destroyed object'),
21
+ set: DestructionError.throw('Cannot access destroyed object'),
22
+ } as const
23
+
24
+ abstract class AbstractDestroyable<Allocated> {
25
+ abstract [destructor](allocated: Allocated): void
26
+ [Symbol.dispose](): void {
27
+ this[destructor](this as unknown as Allocated)
28
+ }
29
+ }
30
+
31
+ interface Destructor<Allocated> {
32
+ destructor(allocated: Allocated): void
33
+ }
34
+
35
+ export function Destroyable<
36
+ T extends new (
37
+ ...args: any[]
38
+ ) => any,
39
+ Allocated extends Partial<typeof this>,
40
+ >(
41
+ base: T,
42
+ destructorObj: Destructor<Allocated>
43
+ ): (new (
44
+ ...args: ConstructorParameters<T>
45
+ ) => InstanceType<T> & { [allocatedValues]: Allocated }) & {
46
+ destroy(obj: InstanceType<T>): boolean
47
+ isDestroyable(obj: InstanceType<T>): boolean
48
+ }
49
+
50
+ export function Destroyable<Allocated extends Record<PropertyKey, any> = Record<PropertyKey, any>>(
51
+ destructorObj: Destructor<Allocated>
52
+ ): (new () => { [allocatedValues]: Allocated }) & {
53
+ destroy(obj: any): boolean
54
+ isDestroyable(obj: any): boolean
55
+ }
56
+
57
+ export function Destroyable<
58
+ T extends new (
59
+ ...args: any[]
60
+ ) => any,
61
+ Allocated extends Record<PropertyKey, any> = Record<PropertyKey, any>,
62
+ >(
63
+ base: T
64
+ ): (new (
65
+ ...args: ConstructorParameters<T>
66
+ ) => AbstractDestroyable<Allocated> & InstanceType<T> & { [allocatedValues]: Allocated }) & {
67
+ destroy(obj: InstanceType<T>): boolean
68
+ isDestroyable(obj: InstanceType<T>): boolean
69
+ }
70
+
71
+ export function Destroyable<
72
+ Allocated extends Record<PropertyKey, any> = Record<PropertyKey, any>,
73
+ >(): abstract new () => (AbstractDestroyable<Allocated> & {
74
+ [allocatedValues]: Allocated
75
+ }) & {
76
+ destroy(obj: any): boolean
77
+ isDestroyable(obj: any): boolean
78
+ }
79
+
80
+ export function Destroyable<
81
+ T extends new (
82
+ ...args: any[]
83
+ ) => any,
84
+ Allocated extends Record<PropertyKey, any> = Record<PropertyKey, any>,
85
+ >(base?: T | Destructor<Allocated>, destructorObj?: Destructor<Allocated>) {
86
+ if (base && typeof base !== 'function') {
87
+ destructorObj = base as Destructor<Allocated>
88
+ base = undefined
89
+ }
90
+ if (!base) {
91
+ base = class {} as T
92
+ }
93
+
94
+ return class Destroyable extends (base as T) {
95
+ static readonly destructors = new WeakMap<any, () => void>()
96
+ static destroy(obj: Destroyable) {
97
+ const destructor = Destroyable.destructors.get(obj)
98
+ if (!destructor) return false
99
+ fr.unregister(obj)
100
+ Destroyable.destructors.delete(obj)
101
+ Object.setPrototypeOf(obj, new Proxy({}, destroyedHandler))
102
+ // Clear all own properties
103
+ for (const key of Object.getOwnPropertyNames(obj)) {
104
+ delete (obj as any)[key]
105
+ }
106
+ destructor()
107
+ return true
108
+ }
109
+ static isDestroyable(obj: Destroyable) {
110
+ return Destroyable.destructors.has(obj)
111
+ }
112
+
113
+ declare [forwardProperties]: PropertyKey[]
114
+ readonly [allocatedValues]: Allocated
115
+ constructor(...args: any[]) {
116
+ super(...args)
117
+ const allocated = {} as Allocated
118
+ this[allocatedValues] = allocated
119
+ // @ts-expect-error `this` is an AbstractDestroyable
120
+ const myDestructor = destructorObj?.destructor ?? this[destructor]
121
+ if (!myDestructor) {
122
+ throw new DestructionError('Destructor is not defined')
123
+ }
124
+ function destruction() {
125
+ myDestructor(allocated)
126
+ }
127
+ Destroyable.destructors.set(this, destruction)
128
+ fr.register(this, destruction, this)
129
+ }
130
+ }
131
+ }
132
+
133
+ const forwardProperties = Symbol('forwardProperties')
134
+ export const allocated = decorator({
135
+ setter(original, propertyKey) {
136
+ return function (value) {
137
+ this[allocatedValues][propertyKey] = value
138
+ return original.call(this, value)
139
+ }
140
+ },
141
+ })
142
+
143
+ export function callOnGC(cb: () => void) {
144
+ let called = false
145
+ const forward = () => {
146
+ if (called) return
147
+ called = true
148
+ cb()
149
+ }
150
+ fr.register(forward, cb, cb)
151
+ return forward
152
+ }
153
+
154
+ // Context Manager Protocol for `with` statement integration
155
+ export interface ContextManager<T = any> {
156
+ [Symbol.dispose](): void
157
+ value?: T
158
+ }
@@ -0,0 +1,380 @@
1
+ import { Eventful } from './eventful'
2
+
3
+ describe('Eventful', () => {
4
+ // Define test event types
5
+ interface TestEvents extends Record<string, (...args: any[]) => void> {
6
+ userLogin: (userId: string, timestamp: Date) => void
7
+ dataUpdate: (data: any[]) => void
8
+ error: (error: Error) => void
9
+ simple: () => void
10
+ withNumber: (value: number) => void
11
+ }
12
+
13
+ class TestEventful extends Eventful<TestEvents> {
14
+ // Test class that extends Eventful
15
+ // Just make `emit` public for testing
16
+ public emit<EventType extends keyof TestEvents>(
17
+ event: EventType,
18
+ ...args: Parameters<TestEvents[EventType]>
19
+ ) {
20
+ super.emit(event, ...args)
21
+ }
22
+ }
23
+
24
+ describe('basic functionality', () => {
25
+ it('should register and emit single events', () => {
26
+ const eventful = new TestEventful()
27
+ let callCount = 0
28
+ let receivedUserId = ''
29
+ let receivedTimestamp: Date | null = null
30
+
31
+ eventful.on('userLogin', (userId, timestamp) => {
32
+ callCount++
33
+ receivedUserId = userId
34
+ receivedTimestamp = timestamp
35
+ })
36
+
37
+ const testDate = new Date()
38
+ eventful.emit('userLogin', 'user123', testDate)
39
+
40
+ expect(callCount).toBe(1)
41
+ expect(receivedUserId).toBe('user123')
42
+ expect(receivedTimestamp).toBe(testDate)
43
+ })
44
+
45
+ it('should register and emit events with no parameters', () => {
46
+ const eventful = new TestEventful()
47
+ let callCount = 0
48
+
49
+ eventful.on('simple', () => {
50
+ callCount++
51
+ })
52
+
53
+ eventful.emit('simple')
54
+
55
+ expect(callCount).toBe(1)
56
+ })
57
+
58
+ it('should register and emit events with primitive parameters', () => {
59
+ const eventful = new TestEventful()
60
+ let receivedValue = 0
61
+
62
+ eventful.on('withNumber', (value) => {
63
+ receivedValue = value
64
+ })
65
+
66
+ eventful.emit('withNumber', 42)
67
+
68
+ expect(receivedValue).toBe(42)
69
+ })
70
+ })
71
+
72
+ describe('multiple listeners', () => {
73
+ it('should support multiple listeners for the same event', () => {
74
+ const eventful = new TestEventful()
75
+ let callCount1 = 0
76
+ let callCount2 = 0
77
+
78
+ eventful.on('simple', () => callCount1++)
79
+ eventful.on('simple', () => callCount2++)
80
+
81
+ eventful.emit('simple')
82
+
83
+ expect(callCount1).toBe(1)
84
+ expect(callCount2).toBe(1)
85
+ })
86
+
87
+ it('should call all listeners in registration order', () => {
88
+ const eventful = new TestEventful()
89
+ const callOrder: number[] = []
90
+
91
+ eventful.on('simple', () => callOrder.push(1))
92
+ eventful.on('simple', () => callOrder.push(2))
93
+ eventful.on('simple', () => callOrder.push(3))
94
+
95
+ eventful.emit('simple')
96
+
97
+ expect(callOrder).toEqual([1, 2, 3])
98
+ })
99
+ })
100
+
101
+ describe('unsubscribe functionality', () => {
102
+ it('should return unsubscribe function for single event', () => {
103
+ const eventful = new TestEventful()
104
+ let callCount = 0
105
+
106
+ const unsubscribe = eventful.on('simple', () => callCount++)
107
+
108
+ eventful.emit('simple')
109
+ expect(callCount).toBe(1)
110
+
111
+ unsubscribe()
112
+ eventful.emit('simple')
113
+ expect(callCount).toBe(1) // Should not increment
114
+ })
115
+
116
+ it('should unsubscribe specific callback when provided', () => {
117
+ const eventful = new TestEventful()
118
+ let callCount1 = 0
119
+ let callCount2 = 0
120
+
121
+ const callback1 = () => callCount1++
122
+ const callback2 = () => callCount2++
123
+
124
+ eventful.on('simple', callback1)
125
+ eventful.on('simple', callback2)
126
+
127
+ eventful.emit('simple')
128
+ expect(callCount1).toBe(1)
129
+ expect(callCount2).toBe(1)
130
+
131
+ eventful.off('simple', callback1)
132
+ eventful.emit('simple')
133
+ expect(callCount1).toBe(1) // Should not increment
134
+ expect(callCount2).toBe(2) // Should still increment
135
+ })
136
+
137
+ it('should remove all listeners when no callback provided to off', () => {
138
+ const eventful = new TestEventful()
139
+ let callCount1 = 0
140
+ let callCount2 = 0
141
+
142
+ eventful.on('simple', () => callCount1++)
143
+ eventful.on('simple', () => callCount2++)
144
+
145
+ eventful.emit('simple')
146
+ expect(callCount1).toBe(1)
147
+ expect(callCount2).toBe(1)
148
+
149
+ eventful.off('simple')
150
+ eventful.emit('simple')
151
+ expect(callCount1).toBe(1) // Should not increment
152
+ expect(callCount2).toBe(1) // Should not increment
153
+ })
154
+ })
155
+
156
+ describe('bulk event registration', () => {
157
+ it('should register multiple events at once', () => {
158
+ const eventful = new TestEventful()
159
+ let loginCount = 0
160
+ let dataCount = 0
161
+ let errorCount = 0
162
+
163
+ eventful.on({
164
+ userLogin: () => loginCount++,
165
+ dataUpdate: () => dataCount++,
166
+ error: () => errorCount++,
167
+ })
168
+
169
+ eventful.emit('userLogin', 'user123', new Date())
170
+ eventful.emit('dataUpdate', [1, 2, 3])
171
+ eventful.emit('error', new Error('test'))
172
+
173
+ expect(loginCount).toBe(1)
174
+ expect(dataCount).toBe(1)
175
+ expect(errorCount).toBe(1)
176
+ })
177
+
178
+ it('should unsubscribe multiple events at once', () => {
179
+ const eventful = new TestEventful()
180
+ let loginCount = 0
181
+ let dataCount = 0
182
+
183
+ const loginCallback = () => loginCount++
184
+ const dataCallback = () => dataCount++
185
+
186
+ eventful.on('userLogin', loginCallback)
187
+ eventful.on('dataUpdate', dataCallback)
188
+
189
+ eventful.emit('userLogin', 'user123', new Date())
190
+ eventful.emit('dataUpdate', [1, 2, 3])
191
+ expect(loginCount).toBe(1)
192
+ expect(dataCount).toBe(1)
193
+
194
+ eventful.off({
195
+ userLogin: loginCallback,
196
+ dataUpdate: dataCallback,
197
+ })
198
+
199
+ eventful.emit('userLogin', 'user123', new Date())
200
+ eventful.emit('dataUpdate', [1, 2, 3])
201
+ expect(loginCount).toBe(1) // Should not increment
202
+ expect(dataCount).toBe(1) // Should not increment
203
+ })
204
+ })
205
+
206
+ describe('global hooks', () => {
207
+ it('should call global hooks for all events', () => {
208
+ const eventful = new TestEventful()
209
+ const hookCalls: Array<{ event: string; args: any[] }> = []
210
+
211
+ eventful.hook((event, ...args) => {
212
+ hookCalls.push({ event: String(event), args })
213
+ })
214
+
215
+ eventful.emit('userLogin', 'user123', new Date())
216
+ eventful.emit('simple')
217
+ eventful.emit('withNumber', 42)
218
+
219
+ expect(hookCalls).toHaveLength(3)
220
+ expect(hookCalls[0].event).toBe('userLogin')
221
+ expect(hookCalls[0].args).toEqual(['user123', expect.any(Date)])
222
+ expect(hookCalls[1].event).toBe('simple')
223
+ expect(hookCalls[1].args).toEqual([])
224
+ expect(hookCalls[2].event).toBe('withNumber')
225
+ expect(hookCalls[2].args).toEqual([42])
226
+ })
227
+
228
+ it('should support multiple global hooks', () => {
229
+ const eventful = new TestEventful()
230
+ let hook1Count = 0
231
+ let hook2Count = 0
232
+
233
+ eventful.hook(() => hook1Count++)
234
+ eventful.hook(() => hook2Count++)
235
+
236
+ eventful.emit('simple')
237
+
238
+ expect(hook1Count).toBe(1)
239
+ expect(hook2Count).toBe(1)
240
+ })
241
+
242
+ it('should return unsubscribe function for hooks', () => {
243
+ const eventful = new TestEventful()
244
+ let hookCount = 0
245
+
246
+ const unsubscribe = eventful.hook(() => hookCount++)
247
+
248
+ eventful.emit('simple')
249
+ expect(hookCount).toBe(1)
250
+
251
+ unsubscribe()
252
+ eventful.emit('simple')
253
+ expect(hookCount).toBe(1) // Should not increment
254
+ })
255
+
256
+ it('should prevent duplicate hook registration', () => {
257
+ const eventful = new TestEventful()
258
+ let hookCount = 0
259
+
260
+ const hookCallback = () => hookCount++
261
+
262
+ eventful.hook(hookCallback)
263
+ eventful.hook(hookCallback) // Should not add duplicate
264
+
265
+ eventful.emit('simple')
266
+ expect(hookCount).toBe(1) // Should only be called once
267
+ })
268
+ })
269
+
270
+ describe('combined functionality', () => {
271
+ it('should call both specific listeners and global hooks', () => {
272
+ const eventful = new TestEventful()
273
+ let specificCount = 0
274
+ let hookCount = 0
275
+
276
+ eventful.on('simple', () => specificCount++)
277
+ eventful.hook(() => hookCount++)
278
+
279
+ eventful.emit('simple')
280
+
281
+ expect(specificCount).toBe(1)
282
+ expect(hookCount).toBe(1)
283
+ })
284
+
285
+ it('should handle complex event scenarios', () => {
286
+ const eventful = new TestEventful()
287
+ const events: string[] = []
288
+
289
+ // Register multiple listeners
290
+ eventful.on('userLogin', () => events.push('login1'))
291
+ eventful.on('userLogin', () => events.push('login2'))
292
+ eventful.on('dataUpdate', () => events.push('data1'))
293
+
294
+ // Register global hook
295
+ eventful.hook((event, ..._args) => {
296
+ events.push(`hook:${String(event)}`)
297
+ })
298
+
299
+ // Emit events
300
+ eventful.emit('userLogin', 'user123', new Date())
301
+ eventful.emit('dataUpdate', [1, 2, 3])
302
+
303
+ expect(events).toEqual(['login1', 'login2', 'hook:userLogin', 'data1', 'hook:dataUpdate'])
304
+ })
305
+ })
306
+
307
+ describe('edge cases', () => {
308
+ it('should handle emitting events with no listeners', () => {
309
+ const eventful = new TestEventful()
310
+
311
+ // Should not throw
312
+ expect(() => {
313
+ eventful.emit('userLogin', 'user123', new Date())
314
+ eventful.emit('simple')
315
+ }).not.toThrow()
316
+ })
317
+
318
+ it('should handle unsubscribing non-existent callbacks', () => {
319
+ const eventful = new TestEventful()
320
+
321
+ // Should not throw
322
+ expect(() => {
323
+ eventful.off('simple', () => {})
324
+ eventful.off('simple')
325
+ }).not.toThrow()
326
+ })
327
+
328
+ it('should handle unsubscribing from non-existent events', () => {
329
+ const eventful = new TestEventful()
330
+
331
+ // Should not throw
332
+ expect(() => {
333
+ eventful.off('nonExistentEvent' as any)
334
+ }).not.toThrow()
335
+ })
336
+
337
+ it('should handle multiple unsubscribes of the same callback', () => {
338
+ const eventful = new TestEventful()
339
+ let callCount = 0
340
+
341
+ const callback = () => callCount++
342
+ const unsubscribe = eventful.on('simple', callback)
343
+
344
+ eventful.emit('simple')
345
+ expect(callCount).toBe(1)
346
+
347
+ unsubscribe()
348
+ unsubscribe() // Should not throw
349
+ unsubscribe() // Should not throw
350
+
351
+ eventful.emit('simple')
352
+ expect(callCount).toBe(1) // Should not increment
353
+ })
354
+ })
355
+
356
+ describe('type safety', () => {
357
+ it('should enforce correct parameter types', () => {
358
+ const eventful = new TestEventful()
359
+
360
+ // These should compile without errors
361
+ eventful.on('userLogin', (userId: string, timestamp: Date) => {
362
+ expect(typeof userId).toBe('string')
363
+ expect(timestamp).toBeInstanceOf(Date)
364
+ })
365
+
366
+ eventful.on('dataUpdate', (data: any[]) => {
367
+ expect(Array.isArray(data)).toBe(true)
368
+ })
369
+
370
+ eventful.on('error', (error: Error) => {
371
+ expect(error).toBeInstanceOf(Error)
372
+ })
373
+
374
+ // Emit with correct types
375
+ eventful.emit('userLogin', 'user123', new Date())
376
+ eventful.emit('dataUpdate', [1, 2, 3])
377
+ eventful.emit('error', new Error('test'))
378
+ })
379
+ })
380
+ })