@piemekanika/x-machina 0.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/AGENTS.md +50 -0
- package/README.md +108 -0
- package/package.json +28 -0
- package/src/XMachina.ts +97 -0
- package/src/__tests__/XMachina.test.ts +185 -0
- package/src/helpers/__tests__/deepMerge.test.ts +60 -0
- package/src/helpers/__tests__/dummyClone.test.ts +94 -0
- package/src/helpers/__tests__/isObject.test.ts +33 -0
- package/src/helpers/__tests__/jsonSafe.test.ts +166 -0
- package/src/helpers/__tests__/makeReadonlyObject.test.ts +171 -0
- package/src/helpers/__tests__/toPromise.test.ts +36 -0
- package/src/helpers/deepMerge.ts +21 -0
- package/src/helpers/dummyClone.ts +15 -0
- package/src/helpers/isObject.ts +3 -0
- package/src/helpers/jsonSafe.ts +71 -0
- package/src/helpers/makeReadonlyObject.ts +34 -0
- package/src/helpers/toPromise.ts +5 -0
- package/src/index.ts +2 -0
- package/src/types.ts +11 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +9 -0
package/AGENTS.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# x-machina Rules
|
|
2
|
+
|
|
3
|
+
## Package Manager
|
|
4
|
+
|
|
5
|
+
Always use `bun` for package management. Do NOT use `npm`, `yarn`, or `pnpm`.
|
|
6
|
+
|
|
7
|
+
## Type Checking
|
|
8
|
+
|
|
9
|
+
Use `bun run check` for type checking. Do NOT use `npx tsc` or `npm run check`.
|
|
10
|
+
|
|
11
|
+
## Testing
|
|
12
|
+
|
|
13
|
+
Use `bun run test` for running tests. Do NOT use `npm test` or `yarn test`.
|
|
14
|
+
|
|
15
|
+
Use the AAA (Arrange, Act, Assert) pattern. Keep a blank line between each section.
|
|
16
|
+
|
|
17
|
+
Example:
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
test('adds two numbers', () => {
|
|
21
|
+
|
|
22
|
+
const a = 1;
|
|
23
|
+
const b = 2;
|
|
24
|
+
|
|
25
|
+
const result = add(a, b);
|
|
26
|
+
|
|
27
|
+
expect(result).toBe(3);
|
|
28
|
+
});
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## JSDoc
|
|
32
|
+
|
|
33
|
+
Write JSDoc comments for functions and classes.
|
|
34
|
+
|
|
35
|
+
Omit types in JSDoc since TypeScript already provides type information.
|
|
36
|
+
|
|
37
|
+
Use blank lines to separate description, params, and returns.
|
|
38
|
+
|
|
39
|
+
Example:
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
/**
|
|
43
|
+
* Adds two numbers together.
|
|
44
|
+
*
|
|
45
|
+
* @param a - The first number.
|
|
46
|
+
* @param b - The second number.
|
|
47
|
+
*
|
|
48
|
+
* @returns The sum of the two numbers.
|
|
49
|
+
*/
|
|
50
|
+
```
|
package/README.md
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# x-machina
|
|
2
|
+
|
|
3
|
+
A lightweight TypeScript state machine with typed context and transition handlers.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bun install
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## How It Works
|
|
12
|
+
|
|
13
|
+
XMachina manages state through a configuration of states, each with transition handlers.
|
|
14
|
+
|
|
15
|
+
- **initialState** - The starting state
|
|
16
|
+
- **initialContext** - Arbitrary data object attached to the machine
|
|
17
|
+
- **states** - Map of state names to transition handlers
|
|
18
|
+
|
|
19
|
+
Transition handlers receive `(context, eventData?)` and return a new state name or `null` to stay in the current state. Handlers can mutate context directly.
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
### Order Processing
|
|
24
|
+
|
|
25
|
+
```typescript
|
|
26
|
+
type OrderContext = {
|
|
27
|
+
orderId: string
|
|
28
|
+
items: { name: string; quantity: number; price: number }[]
|
|
29
|
+
totalAmount: number
|
|
30
|
+
customerEmail: string
|
|
31
|
+
trackingNumber?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type OrderState = 'pending' | 'paid' | 'shipped' | 'delivered' | 'cancelled'
|
|
35
|
+
|
|
36
|
+
const order = new XMachina<OrderContext, OrderState>({
|
|
37
|
+
initialState: 'pending',
|
|
38
|
+
initialContext: {
|
|
39
|
+
orderId: 'ORD-12345',
|
|
40
|
+
items: [
|
|
41
|
+
{ name: 'Widget', quantity: 2, price: 9.99 },
|
|
42
|
+
],
|
|
43
|
+
totalAmount: 19.98,
|
|
44
|
+
customerEmail: 'alice@example.com',
|
|
45
|
+
},
|
|
46
|
+
states: {
|
|
47
|
+
pending: {
|
|
48
|
+
transitions: {
|
|
49
|
+
pay: (ctx, paymentId) => {
|
|
50
|
+
ctx.trackingNumber = `PAY-${paymentId}`
|
|
51
|
+
return 'paid'
|
|
52
|
+
},
|
|
53
|
+
cancel: () => 'cancelled',
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
paid: {
|
|
57
|
+
transitions: {
|
|
58
|
+
ship: (ctx, tracking) => {
|
|
59
|
+
ctx.trackingNumber = tracking
|
|
60
|
+
return 'shipped'
|
|
61
|
+
},
|
|
62
|
+
cancel: () => 'cancelled',
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
shipped: {
|
|
66
|
+
transitions: {
|
|
67
|
+
deliver: () => 'delivered',
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
delivered: { transitions: {} },
|
|
71
|
+
cancelled: { transitions: {} },
|
|
72
|
+
},
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
await order.transition('pay', 'txn_abc123')
|
|
76
|
+
console.log(order.state) // 'paid'
|
|
77
|
+
console.log(order.context.trackingNumber) // 'PAY-txn_abc123'
|
|
78
|
+
|
|
79
|
+
await order.transition('ship', '1Z999AA10123456784')
|
|
80
|
+
console.log(order.state) // 'shipped'
|
|
81
|
+
|
|
82
|
+
await order.transition('deliver')
|
|
83
|
+
console.log(order.state) // 'delivered'
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Dumping State
|
|
87
|
+
|
|
88
|
+
Use `getJsonDump()` to serialize the current state and context as JSON. Useful for logging and debugging.
|
|
89
|
+
|
|
90
|
+
```typescript
|
|
91
|
+
console.log(order.getJsonDump())
|
|
92
|
+
// {"state":"delivered","context":{"orderId":"ORD-12345",...}}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Development
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
bun install # Install dependencies
|
|
99
|
+
bun test # Run tests once
|
|
100
|
+
bun test:watch # Watch mode for tests
|
|
101
|
+
bun run build # Build to dist/
|
|
102
|
+
bun run build:watch # Watch mode for build
|
|
103
|
+
bun dev # Dev mode (test watch)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## License
|
|
107
|
+
|
|
108
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@piemekanika/x-machina",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Dmitrii Beliakov",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"module": "./dist/index.js",
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"import": "./dist/index.js",
|
|
13
|
+
"types": "./dist/index.d.ts"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"check": "tsgo --noEmit",
|
|
18
|
+
"test": "vitest --run",
|
|
19
|
+
"test:watch": "vitest --watch",
|
|
20
|
+
"build": "tsgo && bun build src/index.ts --outdir dist --format esm --target node",
|
|
21
|
+
"build:watch": "bun --watch run build",
|
|
22
|
+
"dev": "bun --watch run test"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@typescript/native-preview": "^7.0.0-dev.20260320.1",
|
|
26
|
+
"vitest": "4.1.0"
|
|
27
|
+
}
|
|
28
|
+
}
|
package/src/XMachina.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { deepMerge } from './helpers/deepMerge'
|
|
2
|
+
import { toPromise } from './helpers/toPromise'
|
|
3
|
+
import { makeReadonlyObject } from './helpers/makeReadonlyObject'
|
|
4
|
+
import { dummyClone } from './helpers/dummyClone'
|
|
5
|
+
import { MachinaContext, MachinaStates, TransitionFn } from './types'
|
|
6
|
+
|
|
7
|
+
export type XMachinaParams<C extends MachinaContext, S extends string> = {
|
|
8
|
+
initialState: S
|
|
9
|
+
initialContext: C
|
|
10
|
+
states: MachinaStates<C, S>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class XMachina<C extends MachinaContext, S extends string> {
|
|
14
|
+
private _state: S
|
|
15
|
+
private _context: C
|
|
16
|
+
private states: MachinaStates<C, S>
|
|
17
|
+
|
|
18
|
+
constructor(params: XMachinaParams<C, S>) {
|
|
19
|
+
this._state = params.initialState
|
|
20
|
+
this._context = dummyClone(params.initialContext)
|
|
21
|
+
this.states = params.states
|
|
22
|
+
|
|
23
|
+
return this
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
get state(): S {
|
|
27
|
+
return this._state
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
get context(): Readonly<C> {
|
|
31
|
+
return makeReadonlyObject(this._context)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
get isFinal(): boolean {
|
|
35
|
+
return this.states[this._state] === null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
private getHandlerByEventName(event: string) {
|
|
39
|
+
if (this.isFinal) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`Cannot transition from final state '${this._state}'`,
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
const stateDefinition = this.states[this.state]
|
|
45
|
+
if (!stateDefinition) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`No transition handler for event '${event}' in state '${this.state}'`,
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
const transitions: Record<string, TransitionFn<C, S>> | undefined =
|
|
51
|
+
stateDefinition.transitions
|
|
52
|
+
const handler = transitions?.[event]
|
|
53
|
+
|
|
54
|
+
if (!handler) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`No transition handler for event '${event}' in state '${this.state}'`,
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return handler
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async transition(event: string, data?: any): Promise<void> {
|
|
64
|
+
const handler = this.getHandlerByEventName(event)
|
|
65
|
+
|
|
66
|
+
const newState = await toPromise(() => handler(this._context, data))()
|
|
67
|
+
|
|
68
|
+
if (newState) {
|
|
69
|
+
await this.goTo(newState)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private async goTo(newState: S): Promise<void> {
|
|
74
|
+
if (!(newState in this.states)) {
|
|
75
|
+
throw new Error(`State '${newState}' is not defined`)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
this._state = newState
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
updateContext(patch: Partial<C>): void {
|
|
82
|
+
if (this.isFinal) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
`Cannot update context in final state '${this._state}'`,
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
this._context = deepMerge(this._context, patch) as C
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
getJsonDump(): string {
|
|
92
|
+
return JSON.stringify({
|
|
93
|
+
state: this.state,
|
|
94
|
+
context: this.context,
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
2
|
+
import { XMachina } from '../XMachina'
|
|
3
|
+
|
|
4
|
+
type TestContext = { count: number; user?: { name: string } }
|
|
5
|
+
type TestState = 'idle' | 'active' | 'finished'
|
|
6
|
+
|
|
7
|
+
describe('XMachina', () => {
|
|
8
|
+
let machine: XMachina<TestContext, TestState>
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
machine = new XMachina<TestContext, TestState>({
|
|
12
|
+
initialState: 'idle',
|
|
13
|
+
initialContext: { count: 0 },
|
|
14
|
+
states: {
|
|
15
|
+
idle: {
|
|
16
|
+
transitions: {
|
|
17
|
+
start: (ctx) => {
|
|
18
|
+
ctx.count = 1
|
|
19
|
+
return 'active'
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
active: {
|
|
24
|
+
transitions: {
|
|
25
|
+
finish: () => 'finished',
|
|
26
|
+
increment: (ctx) => {
|
|
27
|
+
ctx.count += 1
|
|
28
|
+
return null
|
|
29
|
+
},
|
|
30
|
+
setUser: (ctx, data) => {
|
|
31
|
+
ctx.user = data
|
|
32
|
+
return null
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
finished: {
|
|
37
|
+
transitions: {},
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe('constructor', () => {
|
|
44
|
+
it('sets initial state', () => {
|
|
45
|
+
expect(machine.state).toBe('idle')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('sets initial context', () => {
|
|
49
|
+
expect(machine.context).toEqual({ count: 0 })
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('does not mutate original context object', () => {
|
|
53
|
+
const originalContext = { count: 0 }
|
|
54
|
+
const m = new XMachina({ initialState: 'idle', initialContext: originalContext, states: { idle: {} } })
|
|
55
|
+
m.updateContext({ count: 5 })
|
|
56
|
+
expect(originalContext).toEqual({ count: 0 })
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('returns this from constructor', () => {
|
|
60
|
+
const m = new XMachina({ initialState: 'idle', initialContext: { count: 0 }, states: { idle: {} } })
|
|
61
|
+
expect(m).toBe(m)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('can chain methods after construction', () => {
|
|
65
|
+
const m = new XMachina({ initialState: 'idle', initialContext: { count: 0 }, states: { idle: {} } })
|
|
66
|
+
const result = m.updateContext({ count: 5 })
|
|
67
|
+
expect(result).toBeUndefined()
|
|
68
|
+
expect(m.context.count).toBe(5)
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('state getter', () => {
|
|
73
|
+
it('returns current state', () => {
|
|
74
|
+
expect(machine.state).toBe('idle')
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
describe('context getter', () => {
|
|
79
|
+
it('returns readonly context', () => {
|
|
80
|
+
const context = machine.context
|
|
81
|
+
expect(context).toEqual({ count: 0 })
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('returns a frozen copy of the context', () => {
|
|
85
|
+
const context = machine.context as { count: number }
|
|
86
|
+
expect(() => ((context as any).count = 999)).toThrow()
|
|
87
|
+
expect(machine.context).toEqual({ count: 0 })
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
describe('transition', () => {
|
|
92
|
+
it('changes state when transition handler returns new state', async () => {
|
|
93
|
+
await machine.transition('start')
|
|
94
|
+
expect(machine.state).toBe('active')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('updates context when handler modifies it', async () => {
|
|
98
|
+
await machine.transition('start')
|
|
99
|
+
expect(machine.context.count).toBe(1)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('stays in same state when handler returns null', async () => {
|
|
103
|
+
await machine.transition('start')
|
|
104
|
+
await machine.transition('increment')
|
|
105
|
+
expect(machine.state).toBe('active')
|
|
106
|
+
expect(machine.context.count).toBe(2)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('can pass data to transition handler', async () => {
|
|
110
|
+
await machine.transition('start')
|
|
111
|
+
await machine.transition('setUser', { name: 'Alice' })
|
|
112
|
+
expect(machine.context.user).toEqual({ name: 'Alice' })
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('throws when no transition handler for event in current state', async () => {
|
|
116
|
+
await expect(machine.transition('finish')).rejects.toThrow(
|
|
117
|
+
"No transition handler for event 'finish' in state 'idle'",
|
|
118
|
+
)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('throws when transition returns undefined state', async () => {
|
|
122
|
+
const machineWithBadTransition = new XMachina<TestContext, TestState>({
|
|
123
|
+
initialState: 'idle',
|
|
124
|
+
initialContext: { count: 0 },
|
|
125
|
+
states: {
|
|
126
|
+
idle: {
|
|
127
|
+
transitions: {
|
|
128
|
+
goToUnknown: () => 'unknown' as TestState,
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
active: { transitions: {} },
|
|
132
|
+
finished: { transitions: {} },
|
|
133
|
+
},
|
|
134
|
+
})
|
|
135
|
+
await expect(
|
|
136
|
+
machineWithBadTransition.transition('goToUnknown'),
|
|
137
|
+
).rejects.toThrow("State 'unknown' is not defined")
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
describe('updateContext', () => {
|
|
142
|
+
it('merges partial updates into context', () => {
|
|
143
|
+
machine.updateContext({ count: 10 })
|
|
144
|
+
expect(machine.context).toEqual({ count: 10 })
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('deep merges nested objects', () => {
|
|
148
|
+
machine.updateContext({ user: { name: 'Bob' } })
|
|
149
|
+
expect(machine.context).toEqual({ count: 0, user: { name: 'Bob' } })
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('preserves existing properties not being updated', () => {
|
|
153
|
+
machine.updateContext({ count: 5 })
|
|
154
|
+
expect(machine.context.count).toBe(5)
|
|
155
|
+
expect(machine.context.count).toBeDefined()
|
|
156
|
+
})
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
describe('getJsonDump', () => {
|
|
160
|
+
it('returns JSON string with current state and context', () => {
|
|
161
|
+
const dump = machine.getJsonDump()
|
|
162
|
+
const parsed = JSON.parse(dump)
|
|
163
|
+
expect(parsed.state).toBe('idle')
|
|
164
|
+
expect(parsed.context).toEqual({ count: 0 })
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('reflects context changes after updateContext', () => {
|
|
168
|
+
machine.updateContext({ count: 42 })
|
|
169
|
+
const parsed = JSON.parse(machine.getJsonDump())
|
|
170
|
+
expect(parsed.context.count).toBe(42)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('reflects state changes after transition', async () => {
|
|
174
|
+
await machine.transition('start')
|
|
175
|
+
const parsed = JSON.parse(machine.getJsonDump())
|
|
176
|
+
expect(parsed.state).toBe('active')
|
|
177
|
+
expect(parsed.context.count).toBe(1)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('returns valid JSON that can be parsed', () => {
|
|
181
|
+
const dump = machine.getJsonDump()
|
|
182
|
+
expect(() => JSON.parse(dump)).not.toThrow()
|
|
183
|
+
})
|
|
184
|
+
})
|
|
185
|
+
})
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { deepMerge } from '../deepMerge';
|
|
3
|
+
|
|
4
|
+
describe('deepMerge', () => {
|
|
5
|
+
it('merges two flat objects', () => {
|
|
6
|
+
const target = { a: 1, b: 2 };
|
|
7
|
+
const source = { b: 3, c: 4 };
|
|
8
|
+
expect(deepMerge(target, source)).toEqual({ a: 1, b: 3, c: 4 });
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('does not mutate original objects', () => {
|
|
12
|
+
const target = { a: 1 };
|
|
13
|
+
const source = { b: 2 };
|
|
14
|
+
deepMerge(target, source);
|
|
15
|
+
expect(target).toEqual({ a: 1 });
|
|
16
|
+
expect(source).toEqual({ b: 2 });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('deeply merges nested objects', () => {
|
|
20
|
+
const target = { a: { b: 1, c: 2 } };
|
|
21
|
+
const source = { a: { b: 3, d: 4 } };
|
|
22
|
+
expect(deepMerge(target, source)).toEqual({ a: { b: 3, c: 2, d: 4 } });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('adds new nested objects from source', () => {
|
|
26
|
+
const target = { a: 1 };
|
|
27
|
+
const source = { b: { nested: true } };
|
|
28
|
+
expect(deepMerge(target, source)).toEqual({ a: 1, b: { nested: true } });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('overwrites primitives with source values', () => {
|
|
32
|
+
const target = { a: 1, b: 'old' };
|
|
33
|
+
const source = { b: 'new', c: true };
|
|
34
|
+
expect(deepMerge(target, source)).toEqual({ a: 1, b: 'new', c: true });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('handles empty objects', () => {
|
|
38
|
+
expect(deepMerge({}, {})).toEqual({});
|
|
39
|
+
expect(deepMerge({ a: 1 }, {})).toEqual({ a: 1 });
|
|
40
|
+
expect(deepMerge({}, { b: 2 })).toEqual({ b: 2 });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('handles arrays - source replaces target array', () => {
|
|
44
|
+
const target = { arr: [1, 2, 3] };
|
|
45
|
+
const source = { arr: [4, 5] };
|
|
46
|
+
expect(deepMerge(target, source)).toEqual({ arr: [4, 5] });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('handles null values in source', () => {
|
|
50
|
+
const target = { a: 1 };
|
|
51
|
+
const source = { a: null };
|
|
52
|
+
expect(deepMerge(target, source)).toEqual({ a: null });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('handles undefined values in source', () => {
|
|
56
|
+
const target = { a: 1 };
|
|
57
|
+
const source = { a: undefined };
|
|
58
|
+
expect(deepMerge(target, source)).toEqual({ a: undefined });
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { dummyClone } from '../dummyClone';
|
|
3
|
+
|
|
4
|
+
describe('dummyClone', () => {
|
|
5
|
+
it('clones a plain object', () => {
|
|
6
|
+
const original = { a: 1, b: 2, c: 3 };
|
|
7
|
+
|
|
8
|
+
const result = dummyClone(original);
|
|
9
|
+
|
|
10
|
+
expect(result).toEqual(original);
|
|
11
|
+
expect(result).not.toBe(original);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('clones an array', () => {
|
|
15
|
+
const original = [1, 2, 3, 4, 5];
|
|
16
|
+
|
|
17
|
+
const result = dummyClone(original);
|
|
18
|
+
|
|
19
|
+
expect(result).toEqual(original);
|
|
20
|
+
expect(result).not.toBe(original);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('clones nested objects', () => {
|
|
24
|
+
const original = {
|
|
25
|
+
user: {
|
|
26
|
+
name: 'Alice',
|
|
27
|
+
address: {
|
|
28
|
+
city: 'Wonderland',
|
|
29
|
+
zip: '12345',
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
tags: ['a', 'b'],
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const result = dummyClone(original);
|
|
36
|
+
|
|
37
|
+
expect(result).toEqual(original);
|
|
38
|
+
expect(result).not.toBe(original);
|
|
39
|
+
expect(result.user).not.toBe(original.user);
|
|
40
|
+
expect(result.user.address).not.toBe(original.user.address);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('handles primitive values', () => {
|
|
44
|
+
expect(dummyClone(42)).toBe(42);
|
|
45
|
+
expect(dummyClone('hello')).toBe('hello');
|
|
46
|
+
expect(dummyClone(true)).toBe(true);
|
|
47
|
+
expect(dummyClone(false)).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('handles null', () => {
|
|
51
|
+
expect(dummyClone(null)).toBe(null);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('handles undefined', () => {
|
|
55
|
+
expect(dummyClone(undefined)).toBe(undefined);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('handles empty object', () => {
|
|
59
|
+
const original = {};
|
|
60
|
+
|
|
61
|
+
const result = dummyClone(original);
|
|
62
|
+
|
|
63
|
+
expect(result).toEqual(original);
|
|
64
|
+
expect(result).not.toBe(original);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('handles empty array', () => {
|
|
68
|
+
const original: number[] = [];
|
|
69
|
+
|
|
70
|
+
const result = dummyClone(original);
|
|
71
|
+
|
|
72
|
+
expect(result).toEqual(original);
|
|
73
|
+
expect(result).not.toBe(original);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('converts Date to string (JSON limitation)', () => {
|
|
77
|
+
const original = { date: new Date('2024-01-01T00:00:00.000Z') };
|
|
78
|
+
|
|
79
|
+
const result = dummyClone(original);
|
|
80
|
+
|
|
81
|
+
expect(typeof result.date).toBe('string');
|
|
82
|
+
expect(result.date).toBe('2024-01-01T00:00:00.000Z');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('can modify clone without affecting original', () => {
|
|
86
|
+
const original = { a: 1, b: 2 };
|
|
87
|
+
|
|
88
|
+
const result = dummyClone(original);
|
|
89
|
+
result.b = 99;
|
|
90
|
+
|
|
91
|
+
expect(original.b).toBe(2);
|
|
92
|
+
expect(result.b).toBe(99);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { isObject } from '../isObject';
|
|
3
|
+
|
|
4
|
+
describe('isObject', () => {
|
|
5
|
+
it('returns true for plain objects', () => {
|
|
6
|
+
expect(isObject({})).toBe(true);
|
|
7
|
+
expect(isObject({ a: 1 })).toBe(true);
|
|
8
|
+
expect(isObject({ nested: { deep: true } })).toBe(true);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('returns false for arrays', () => {
|
|
12
|
+
expect(isObject([])).toBe(false);
|
|
13
|
+
expect(isObject([1, 2, 3])).toBe(false);
|
|
14
|
+
expect(isObject([{}, {}])).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('returns false for null', () => {
|
|
18
|
+
expect(isObject(null)).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('returns false for primitives', () => {
|
|
22
|
+
expect(isObject('string')).toBe(false);
|
|
23
|
+
expect(isObject(123)).toBe(false);
|
|
24
|
+
expect(isObject(true)).toBe(false);
|
|
25
|
+
expect(isObject(undefined)).toBe(false);
|
|
26
|
+
expect(isObject(Symbol('test'))).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('returns false for functions', () => {
|
|
30
|
+
expect(isObject(() => {})).toBe(false);
|
|
31
|
+
expect(isObject(function () {})).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { isJsonSafe, assertJsonSafe, toJson, fromJson } from '../jsonSafe';
|
|
3
|
+
|
|
4
|
+
describe('isJsonSafe', () => {
|
|
5
|
+
it('returns true for primitives', () => {
|
|
6
|
+
expect(isJsonSafe(null)).toBe(true);
|
|
7
|
+
expect(isJsonSafe(true)).toBe(true);
|
|
8
|
+
expect(isJsonSafe(false)).toBe(true);
|
|
9
|
+
expect(isJsonSafe(0)).toBe(true);
|
|
10
|
+
expect(isJsonSafe(42)).toBe(true);
|
|
11
|
+
expect(isJsonSafe(-3.14)).toBe(true);
|
|
12
|
+
expect(isJsonSafe('')).toBe(true);
|
|
13
|
+
expect(isJsonSafe('hello')).toBe(true);
|
|
14
|
+
expect(isJsonSafe('hello world')).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('returns true for plain objects', () => {
|
|
18
|
+
expect(isJsonSafe({})).toBe(true);
|
|
19
|
+
expect(isJsonSafe({ a: 1 })).toBe(true);
|
|
20
|
+
expect(isJsonSafe({ nested: { deep: true } })).toBe(true);
|
|
21
|
+
expect(isJsonSafe({ arr: [1, 2, 3] })).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('returns true for arrays', () => {
|
|
25
|
+
expect(isJsonSafe([])).toBe(true);
|
|
26
|
+
expect(isJsonSafe([1, 2, 3])).toBe(true);
|
|
27
|
+
expect(isJsonSafe([{}, [], 'string'])).toBe(true);
|
|
28
|
+
expect(isJsonSafe([[1, 2], [3, 4]])).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('returns true for Date', () => {
|
|
32
|
+
expect(isJsonSafe(new Date())).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('returns true for RegExp', () => {
|
|
36
|
+
expect(isJsonSafe(/test/)).toBe(true);
|
|
37
|
+
expect(isJsonSafe(new RegExp('pattern'))).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('returns false for functions', () => {
|
|
41
|
+
expect(isJsonSafe(() => {})).toBe(false);
|
|
42
|
+
expect(isJsonSafe(function () {})).toBe(false);
|
|
43
|
+
expect(isJsonSafe(Math.random)).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('returns false for undefined', () => {
|
|
47
|
+
expect(isJsonSafe(undefined)).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('returns false for symbols', () => {
|
|
51
|
+
expect(isJsonSafe(Symbol('test'))).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('returns false for bigint', () => {
|
|
55
|
+
expect(isJsonSafe(BigInt(123))).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('returns false for Map', () => {
|
|
59
|
+
expect(isJsonSafe(new Map())).toBe(false);
|
|
60
|
+
expect(isJsonSafe(new Map([['a', 1]]))).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('returns false for Set', () => {
|
|
64
|
+
expect(isJsonSafe(new Set())).toBe(false);
|
|
65
|
+
expect(isJsonSafe(new Set([1, 2, 3]))).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('returns false for Error', () => {
|
|
69
|
+
expect(isJsonSafe(new Error())).toBe(false);
|
|
70
|
+
expect(isJsonSafe(new TypeError('msg'))).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('returns false for objects with unsafe values', () => {
|
|
74
|
+
expect(isJsonSafe({ fn: () => {} })).toBe(false);
|
|
75
|
+
expect(isJsonSafe({ u: undefined })).toBe(false);
|
|
76
|
+
expect(isJsonSafe([() => {}])).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('returns true for deeply nested safe objects', () => {
|
|
80
|
+
expect(isJsonSafe({ a: { b: { c: { d: { e: 1 } } } } })).toBe(true);
|
|
81
|
+
expect(isJsonSafe({ arr: [{ nested: { arr: [1, 2, { deep: true }] } }] })).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('assertJsonSafe', () => {
|
|
86
|
+
it('does not throw for safe values', () => {
|
|
87
|
+
expect(() => assertJsonSafe(null)).not.toThrow();
|
|
88
|
+
expect(() => assertJsonSafe({})).not.toThrow();
|
|
89
|
+
expect(() => assertJsonSafe({ a: 1 })).not.toThrow();
|
|
90
|
+
expect(() => assertJsonSafe([1, 2, 3])).not.toThrow();
|
|
91
|
+
expect(() => assertJsonSafe(new Date())).not.toThrow();
|
|
92
|
+
expect(() => assertJsonSafe({ nested: { deep: true } })).not.toThrow();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('throws with path for function at root', () => {
|
|
96
|
+
expect(() => assertJsonSafe((() => {}) as any)).toThrow('Function at root is not JSON-safe');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('throws with path for function in object', () => {
|
|
100
|
+
expect(() => assertJsonSafe({ fn: (() => {}) as any })).toThrow('Function at fn is not JSON-safe');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('throws with path for undefined at root', () => {
|
|
104
|
+
expect(() => assertJsonSafe(undefined)).toThrow('Undefined at root is not JSON-safe');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('throws with path for undefined in array', () => {
|
|
108
|
+
expect(() => assertJsonSafe([1, undefined as any, 3])).toThrow('Undefined at [1] is not JSON-safe');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('throws with path for Map', () => {
|
|
112
|
+
expect(() => assertJsonSafe(new Map())).toThrow('Map at is not JSON-safe');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('throws with path for Set', () => {
|
|
116
|
+
expect(() => assertJsonSafe(new Set())).toThrow('Set at is not JSON-safe');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('throws with path for Error', () => {
|
|
120
|
+
expect(() => assertJsonSafe(new Error())).toThrow('Error at is not JSON-safe');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('includes nested paths', () => {
|
|
124
|
+
expect(() => assertJsonSafe({ a: { b: { c: { d: { e: undefined as any } } } } }))
|
|
125
|
+
.toThrow('a.b.c.d.e');
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe('toJson', () => {
|
|
130
|
+
it('serializes safe values to JSON string', () => {
|
|
131
|
+
expect(toJson(null)).toBe('null');
|
|
132
|
+
expect(toJson(true)).toBe('true');
|
|
133
|
+
expect(toJson(42)).toBe('42');
|
|
134
|
+
expect(toJson('hello')).toBe('"hello"');
|
|
135
|
+
expect(toJson({ a: 1 })).toBe('{"a":1}');
|
|
136
|
+
expect(toJson([1, 2, 3])).toBe('[1,2,3]');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('serializes nested objects', () => {
|
|
140
|
+
const obj = { a: { b: { c: 1 } }, arr: [1, 2, { nested: true }] };
|
|
141
|
+
expect(toJson(obj)).toBe('{"a":{"b":{"c":1}},"arr":[1,2,{"nested":true}]}');
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe('fromJson', () => {
|
|
146
|
+
it('parses JSON string to object', () => {
|
|
147
|
+
expect(fromJson('null')).toBe(null);
|
|
148
|
+
expect(fromJson('true')).toBe(true);
|
|
149
|
+
expect(fromJson('42')).toBe(42);
|
|
150
|
+
expect(fromJson('"hello"')).toBe('hello');
|
|
151
|
+
expect(fromJson('{"a":1}')).toEqual({ a: 1 });
|
|
152
|
+
expect(fromJson('[1,2,3]')).toEqual([1, 2, 3]);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('parses nested objects', () => {
|
|
156
|
+
const json = '{"a":{"b":{"c":1}},"arr":[1,2,{"nested":true}]}';
|
|
157
|
+
expect(fromJson(json)).toEqual({ a: { b: { c: 1 } }, arr: [1, 2, { nested: true }] });
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('round-trips safely through toJson and fromJson', () => {
|
|
161
|
+
const original = { a: 1, b: { c: 2 }, arr: [1, 2, 3], nested: { deep: { value: 'test' } } };
|
|
162
|
+
const json = toJson(original);
|
|
163
|
+
const restored = fromJson<typeof original>(json);
|
|
164
|
+
expect(restored).toEqual(original);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { makeReadonlyObject } from '../makeReadonlyObject';
|
|
3
|
+
|
|
4
|
+
describe('makeReadonlyObject', () => {
|
|
5
|
+
describe('returns new object', () => {
|
|
6
|
+
it('does not modify the original object', () => {
|
|
7
|
+
const original = { a: 1, b: 2 };
|
|
8
|
+
const readonly = makeReadonlyObject(original);
|
|
9
|
+
|
|
10
|
+
original.a = 99;
|
|
11
|
+
expect(original.a).toBe(99);
|
|
12
|
+
expect(readonly.a).toBe(1);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('returns a different reference', () => {
|
|
16
|
+
const original = { a: 1 };
|
|
17
|
+
const readonly = makeReadonlyObject(original);
|
|
18
|
+
expect(readonly).not.toBe(original);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('setting properties throws', () => {
|
|
23
|
+
it('throws when setting an existing property', () => {
|
|
24
|
+
const readonly = makeReadonlyObject({ a: 1 });
|
|
25
|
+
expect(() => {
|
|
26
|
+
readonly.a = 2;
|
|
27
|
+
}).toThrow(TypeError);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('throws when adding a new property', () => {
|
|
31
|
+
const readonly = makeReadonlyObject({ a: 1 });
|
|
32
|
+
expect(() => {
|
|
33
|
+
readonly.b = 2;
|
|
34
|
+
}).toThrow(TypeError);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('error message includes property name', () => {
|
|
38
|
+
const readonly = makeReadonlyObject({ a: 1 });
|
|
39
|
+
expect(() => {
|
|
40
|
+
readonly.a = 2;
|
|
41
|
+
}).toThrow('Cannot set property "a"');
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('deleting properties throws', () => {
|
|
46
|
+
it('throws when deleting a property', () => {
|
|
47
|
+
const readonly = makeReadonlyObject({ a: 1 });
|
|
48
|
+
expect(() => {
|
|
49
|
+
delete readonly.a;
|
|
50
|
+
}).toThrow(TypeError);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('error message includes property name', () => {
|
|
54
|
+
const readonly = makeReadonlyObject({ a: 1 });
|
|
55
|
+
expect(() => {
|
|
56
|
+
delete readonly.a;
|
|
57
|
+
}).toThrow('Cannot delete property "a"');
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('reading properties works', () => {
|
|
62
|
+
it('returns primitive values', () => {
|
|
63
|
+
const readonly = makeReadonlyObject({ a: 1, b: 'string', c: true });
|
|
64
|
+
expect(readonly.a).toBe(1);
|
|
65
|
+
expect(readonly.b).toBe('string');
|
|
66
|
+
expect(readonly.c).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('returns nested objects as readonly proxies', () => {
|
|
70
|
+
const readonly = makeReadonlyObject({ nested: { deep: 42 } });
|
|
71
|
+
expect(readonly.nested.deep).toBe(42);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('nested objects are readonly', () => {
|
|
76
|
+
it('throws when setting a nested property', () => {
|
|
77
|
+
const readonly = makeReadonlyObject({ nested: { deep: 42 } });
|
|
78
|
+
expect(() => {
|
|
79
|
+
readonly.nested.deep = 100;
|
|
80
|
+
}).toThrow(TypeError);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('throws when adding a nested property', () => {
|
|
84
|
+
const readonly = makeReadonlyObject({ nested: { deep: 42 } });
|
|
85
|
+
expect(() => {
|
|
86
|
+
readonly.nested.newProp = 'value';
|
|
87
|
+
}).toThrow(TypeError);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('throws when deleting a nested property', () => {
|
|
91
|
+
const readonly = makeReadonlyObject({ nested: { deep: 42 } });
|
|
92
|
+
expect(() => {
|
|
93
|
+
delete readonly.nested.deep;
|
|
94
|
+
}).toThrow(TypeError);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('arrays are readonly', () => {
|
|
99
|
+
it('throws when mutating an array', () => {
|
|
100
|
+
const readonly = makeReadonlyObject([1, 2, 3]);
|
|
101
|
+
expect(() => {
|
|
102
|
+
readonly.push(4);
|
|
103
|
+
}).toThrow(TypeError);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('throws when setting array element', () => {
|
|
107
|
+
const readonly = makeReadonlyObject([1, 2, 3]);
|
|
108
|
+
expect(() => {
|
|
109
|
+
readonly[0] = 99;
|
|
110
|
+
}).toThrow(TypeError);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('throws when adding array element', () => {
|
|
114
|
+
const readonly = makeReadonlyObject([1, 2, 3]);
|
|
115
|
+
expect(() => {
|
|
116
|
+
readonly[10] = 99;
|
|
117
|
+
}).toThrow(TypeError);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('reading array elements works', () => {
|
|
121
|
+
const readonly = makeReadonlyObject([1, 2, 3]);
|
|
122
|
+
expect(readonly[0]).toBe(1);
|
|
123
|
+
expect(readonly.length).toBe(3);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe('handles edge cases', () => {
|
|
128
|
+
it('returns null as-is', () => {
|
|
129
|
+
const result = makeReadonlyObject(null);
|
|
130
|
+
expect(result).toBe(null);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('returns primitives as-is', () => {
|
|
134
|
+
expect(makeReadonlyObject(42)).toBe(42);
|
|
135
|
+
expect(makeReadonlyObject('string')).toBe('string');
|
|
136
|
+
expect(makeReadonlyObject(true)).toBe(true);
|
|
137
|
+
expect(makeReadonlyObject(undefined)).toBe(undefined);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('handles empty objects', () => {
|
|
141
|
+
const readonly = makeReadonlyObject({});
|
|
142
|
+
expect(() => {
|
|
143
|
+
readonly.a = 1;
|
|
144
|
+
}).toThrow(TypeError);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('handles deeply nested structures', () => {
|
|
148
|
+
const readonly = makeReadonlyObject({
|
|
149
|
+
level1: {
|
|
150
|
+
level2: {
|
|
151
|
+
level3: {
|
|
152
|
+
value: 'deep',
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
expect(readonly.level1.level2.level3.value).toBe('deep');
|
|
158
|
+
expect(() => {
|
|
159
|
+
readonly.level1.level2.level3.value = 'changed';
|
|
160
|
+
}).toThrow(TypeError);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('handles arrays containing objects', () => {
|
|
164
|
+
const readonly = makeReadonlyObject([{ a: 1 }, { b: 2 }]);
|
|
165
|
+
expect(readonly[0].a).toBe(1);
|
|
166
|
+
expect(() => {
|
|
167
|
+
readonly[0].a = 99;
|
|
168
|
+
}).toThrow(TypeError);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { toPromise } from '../toPromise'
|
|
3
|
+
|
|
4
|
+
describe('toPromise', () => {
|
|
5
|
+
it('should return a function that returns a promise', () => {
|
|
6
|
+
const syncFn = () => 'result'
|
|
7
|
+
const promisified = toPromise(syncFn)
|
|
8
|
+
expect(typeof promisified).toBe('function')
|
|
9
|
+
const result = promisified()
|
|
10
|
+
expect(result).toBeInstanceOf(Promise)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('should wrap sync function and return resolved promise', async () => {
|
|
14
|
+
const syncFn = () => 'sync result'
|
|
15
|
+
const result = await toPromise(syncFn)()
|
|
16
|
+
expect(result).toBe('sync result')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('should wrap async function and return the same promise', async () => {
|
|
20
|
+
const asyncFn = async () => 'async result'
|
|
21
|
+
const result = await toPromise(asyncFn)()
|
|
22
|
+
expect(result).toBe('async result')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('should pass through the result value of sync function returning undefined', async () => {
|
|
26
|
+
const syncFn = () => undefined
|
|
27
|
+
const result = await toPromise(syncFn)()
|
|
28
|
+
expect(result).toBeUndefined()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('should pass through the result value of async function returning null', async () => {
|
|
32
|
+
const asyncFn = async () => null
|
|
33
|
+
const result = await toPromise(asyncFn)()
|
|
34
|
+
expect(result).toBeNull()
|
|
35
|
+
})
|
|
36
|
+
})
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { isObject } from './isObject';
|
|
2
|
+
|
|
3
|
+
export function deepMerge(target: any, source: any): any {
|
|
4
|
+
const output = { ...target };
|
|
5
|
+
|
|
6
|
+
if (isObject(target) && isObject(source)) {
|
|
7
|
+
Object.keys(source).forEach(key => {
|
|
8
|
+
if (isObject(source[key])) {
|
|
9
|
+
if (!(key in target)) {
|
|
10
|
+
output[key] = source[key];
|
|
11
|
+
} else {
|
|
12
|
+
output[key] = deepMerge(target[key], source[key]);
|
|
13
|
+
}
|
|
14
|
+
} else {
|
|
15
|
+
output[key] = source[key];
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return output;
|
|
21
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creates a deep clone of a value using JSON parse/stringify.
|
|
3
|
+
* This is a simple but limited cloning approach - it only works
|
|
4
|
+
* with JSON-safe values and will convert Date objects to strings.
|
|
5
|
+
*
|
|
6
|
+
* @param obj - The value to clone.
|
|
7
|
+
*
|
|
8
|
+
* @returns A deep clone of the input value.
|
|
9
|
+
*/
|
|
10
|
+
export function dummyClone<T>(obj: T): T {
|
|
11
|
+
if (obj === undefined) {
|
|
12
|
+
return undefined as T;
|
|
13
|
+
}
|
|
14
|
+
return JSON.parse(JSON.stringify(obj)) as T;
|
|
15
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
export function isJsonSafe(value: unknown): boolean {
|
|
2
|
+
if (value === null || typeof value === 'boolean' || typeof value === 'number' || typeof value === 'string') {
|
|
3
|
+
return true;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
if (Array.isArray(value)) {
|
|
7
|
+
return value.every(isJsonSafe);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
if (typeof value === 'object') {
|
|
11
|
+
if (value instanceof Date) {
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
if (value instanceof RegExp) {
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
if (value instanceof Map || value instanceof Set) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
if (value instanceof Error) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return Object.entries(value).every(([k, v]) => typeof k === 'string' && isJsonSafe(v));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (typeof value === 'function' || typeof value === 'undefined' || typeof value === 'symbol' || typeof value === 'bigint') {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function assertJsonSafe(value: unknown, path = ''): void {
|
|
35
|
+
if (value === null || typeof value === 'boolean' || typeof value === 'number' || typeof value === 'string') {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (Array.isArray(value)) {
|
|
40
|
+
value.forEach((item, i) => assertJsonSafe(item, `${path}[${i}]`));
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (typeof value === 'object') {
|
|
45
|
+
if (value instanceof Date) return;
|
|
46
|
+
if (value instanceof RegExp) return;
|
|
47
|
+
if (value instanceof Map) throw new TypeError(`Map at ${path} is not JSON-safe`);
|
|
48
|
+
if (value instanceof Set) throw new TypeError(`Set at ${path} is not JSON-safe`);
|
|
49
|
+
if (value instanceof Error) throw new TypeError(`Error at ${path} is not JSON-safe`);
|
|
50
|
+
|
|
51
|
+
for (const [k, v] of Object.entries(value)) {
|
|
52
|
+
assertJsonSafe(v, path ? `${path}.${k}` : k);
|
|
53
|
+
}
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (typeof value === 'function') throw new TypeError(`Function at ${path || 'root'} is not JSON-safe`);
|
|
58
|
+
if (typeof value === 'undefined') throw new TypeError(`Undefined at ${path || 'root'} is not JSON-safe`);
|
|
59
|
+
if (typeof value === 'symbol') throw new TypeError(`Symbol at ${path || 'root'} is not JSON-safe`);
|
|
60
|
+
if (typeof value === 'bigint') throw new TypeError(`BigInt at ${path || 'root'} is not JSON-safe`);
|
|
61
|
+
|
|
62
|
+
throw new TypeError(`Value at ${path || 'root'} is not JSON-safe`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function toJson(value: unknown): string {
|
|
66
|
+
return JSON.stringify(value);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function fromJson<T>(json: string): T {
|
|
70
|
+
return JSON.parse(json) as T;
|
|
71
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { isObject } from './isObject';
|
|
2
|
+
import { dummyClone } from './dummyClone';
|
|
3
|
+
|
|
4
|
+
export function makeReadonlyObject<T extends object>(obj: T): Readonly<T> {
|
|
5
|
+
if (obj === null) {
|
|
6
|
+
return null as unknown as Readonly<T>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
if (typeof obj !== 'object' && typeof obj !== 'function') {
|
|
10
|
+
return obj;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const cloned = dummyClone(obj);
|
|
14
|
+
|
|
15
|
+
const handler: ProxyHandler<object> = {
|
|
16
|
+
set(_target, property, _value) {
|
|
17
|
+
throw new TypeError(`Cannot set property "${String(property)}": object is readonly`);
|
|
18
|
+
},
|
|
19
|
+
deleteProperty(_target, property) {
|
|
20
|
+
throw new TypeError(`Cannot delete property "${String(property)}": object is readonly`);
|
|
21
|
+
},
|
|
22
|
+
get(target, property, receiver) {
|
|
23
|
+
const value = Reflect.get(target, property, receiver);
|
|
24
|
+
|
|
25
|
+
if (isObject(value) || Array.isArray(value)) {
|
|
26
|
+
return new Proxy(value, handler);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return value;
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
return new Proxy(cloned, handler) as Readonly<T>;
|
|
34
|
+
}
|
package/src/index.ts
ADDED
package/src/types.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type MachinaContext = Record<string, any>;
|
|
2
|
+
|
|
3
|
+
export type TransitionFn<C extends MachinaContext, S extends string> =
|
|
4
|
+
(context: C, event?: any) => (S | null) | Promise<S | null>;
|
|
5
|
+
|
|
6
|
+
export type MachinaStates<C extends MachinaContext, S extends string> = Record<
|
|
7
|
+
S,
|
|
8
|
+
{
|
|
9
|
+
transitions?: Record<string, TransitionFn<C, S>>
|
|
10
|
+
} | null
|
|
11
|
+
>
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"lib": ["ESNext"],
|
|
7
|
+
"declaration": true,
|
|
8
|
+
"declarationMap": true,
|
|
9
|
+
"outDir": "./dist",
|
|
10
|
+
"rootDir": "./src",
|
|
11
|
+
"strict": true,
|
|
12
|
+
"esModuleInterop": true,
|
|
13
|
+
"skipLibCheck": true,
|
|
14
|
+
"forceConsistentCasingInFileNames": true,
|
|
15
|
+
"resolveJsonModule": true,
|
|
16
|
+
"isolatedModules": true
|
|
17
|
+
},
|
|
18
|
+
"include": ["src/**/*"],
|
|
19
|
+
"exclude": ["node_modules", "dist", "**/__tests__/**"]
|
|
20
|
+
}
|