dazscript-framework 0.3.1 → 1.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.
Files changed (36) hide show
  1. package/LICENSE +373 -0
  2. package/README.md +327 -267
  3. package/package.json +9 -6
  4. package/src/Setup.dsa.ts +37 -9
  5. package/src/dialog/builders/list-view-builder.ts +6 -1
  6. package/src/dialog/builders/slider-builder.ts +1 -1
  7. package/src/examples/01-hello-world.dsa.ts +8 -0
  8. package/src/examples/02-persistence-dialog.dsa.ts +21 -0
  9. package/src/examples/02-persistence-dialog.ts +68 -0
  10. package/src/examples/03-simple-dialog.dsa.ts +23 -0
  11. package/src/examples/03-simple-dialog.ts +47 -0
  12. package/src/examples/04-settings-dialog.dsa.ts +29 -0
  13. package/src/examples/04-settings-dialog.ts +83 -0
  14. package/src/examples/05-list-dialog.dsa.ts +53 -0
  15. package/src/examples/05-list-dialog.ts +88 -0
  16. package/src/examples/06-showcase-dialog.dsa.ts +87 -0
  17. package/src/examples/06-showcase-dialog.ts +518 -0
  18. package/src/helpers/action-helper.ts +2 -2
  19. package/src/helpers/custom-action-helper.ts +5 -4
  20. package/src/helpers/custom-action-installer-helper.ts +39 -10
  21. package/src/helpers/file-helper.ts +1 -1
  22. package/src/helpers/list-view-helper.ts +1 -1
  23. package/src/helpers/message-box-helper.ts +2 -2
  24. package/src/helpers/node-helper.ts +14 -14
  25. package/src/helpers/pane-helper.ts +5 -5
  26. package/src/helpers/scene-helper.ts +9 -9
  27. package/src/helpers/viewport-helper.ts +2 -2
  28. package/src/lib/observable.test.ts +416 -0
  29. package/src/lib/observable.ts +24 -18
  30. package/src/lib/tree-node.test.ts +21 -0
  31. package/tsconfig.json +34 -109
  32. package/webpack.config.js +1 -0
  33. package/src/samples/hello-world.dsa.ts +0 -8
  34. package/src/samples/sample-dialog.dsa.ts +0 -47
  35. package/src/samples/sample-dialog.ts +0 -49
  36. /package/src/{samples → examples}/config.ts +0 -0
@@ -10,7 +10,7 @@ export const error = (message: string, writeLog: boolean = true) => {
10
10
  }
11
11
 
12
12
  export const warning = (message: string) => {
13
- MessageBox.warning(message, "Warning", "Ok")
13
+ MessageBox.warning(message, "Warning", "Ok", "")
14
14
  }
15
15
 
16
16
  export const confirm = (message?: string): { ok: boolean, cancel: boolean } => {
@@ -20,7 +20,7 @@ export const confirm = (message?: string): { ok: boolean, cancel: boolean } => {
20
20
  }
21
21
 
22
22
  export const prompt = (text: string, title: string, button0: string, button1?: string): { cancel: boolean, selection: number } => {
23
- const response = MessageBox.question(text, title, button0, button1, "Cancel")
23
+ const response = MessageBox.question(text, title, button0, button1 ?? "", "Cancel")
24
24
 
25
25
  return { cancel: response === 0, selection: response };
26
26
  }
@@ -26,7 +26,7 @@ export const isFigure = (node: DzNode): boolean => {
26
26
  * @param node
27
27
  * @returns The root of the node, if the node is part of a figure, return the figure (skeleton) otherwise return the node itself
28
28
  */
29
- export const getRoot = (node: DzNode): DzNode => {
29
+ export const getRoot = (node: DzNode | null): DzNode | null => {
30
30
  if (node && node.className() === "DzBone" && node.getSkeleton)
31
31
  return node.getSkeleton();
32
32
  else
@@ -44,8 +44,8 @@ export const getFigure = (node: DzNode): DzSkeleton | null => {
44
44
  * @returns The first property with the given name or internal name, or NULL.
45
45
  */
46
46
  export const findProperty = <T extends DzProperty = DzProperty>(node: DzNode, name: string): T | null => {
47
- return sceneHelper.findPropertyOnNode(name, node) as T
48
- ?? sceneHelper.findPropertyOnNodeByInternalName(name, node) as T
47
+ return sceneHelper.findPropertyOnNode(name, node) as unknown as T
48
+ ?? sceneHelper.findPropertyOnNodeByInternalName(name, node) as unknown as T
49
49
  }
50
50
 
51
51
  /**
@@ -90,7 +90,7 @@ export const isChildOf = (node: DzNode, figure: DzNode): boolean => {
90
90
  * @returns the fitting target (skeleton) of the node or null
91
91
  */
92
92
  export const getFittingTarget = (node: DzNode): DzSkeleton | null => {
93
- return getRoot(node).getSkeleton()?.getFollowTarget()
93
+ return getRoot(node)?.getSkeleton()?.getFollowTarget() ?? null
94
94
  }
95
95
 
96
96
  /**
@@ -99,7 +99,7 @@ export const getFittingTarget = (node: DzNode): DzSkeleton | null => {
99
99
  * @param target if specified, check if the node is fitted to the target node, otherwise check if the node is fitted to any other node
100
100
  * @returns true if the node is fitted to another node
101
101
  */
102
- export const isFitting = (figure: DzSkeleton, target?: DzNode): boolean => {
102
+ export const isFitting = (figure: DzSkeleton | null, target?: DzNode): boolean => {
103
103
  return target
104
104
  ? figure?.getFollowTarget() == target
105
105
  : figure?.getFollowTarget() != null
@@ -140,12 +140,12 @@ export const getTransforms = (node: DzNode, include: { rotations?: boolean, tran
140
140
  export const getRotations = (node: DzNode, includeExtraRotations: boolean = true): DzFloatProperty[] => {
141
141
  if (!node) return [];
142
142
 
143
- const xRotate = sceneHelper.findPropertyOnNode('XRotate', node) as DzFloatProperty;
144
- const xRotate2 = sceneHelper.findPropertyOnNode('XRotate2', node) as DzFloatProperty;
145
- const yRotate = sceneHelper.findPropertyOnNode('YRotate', node) as DzFloatProperty;
146
- const yRotate2 = sceneHelper.findPropertyOnNode('YRotate2', node) as DzFloatProperty;
147
- const zRotate = sceneHelper.findPropertyOnNode('ZRotate', node) as DzFloatProperty;
148
- const zRotate2 = sceneHelper.findPropertyOnNode('ZRotate2', node) as DzFloatProperty;
143
+ const xRotate = sceneHelper.findPropertyOnNode('XRotate', node) as unknown as DzFloatProperty;
144
+ const xRotate2 = sceneHelper.findPropertyOnNode('XRotate2', node) as unknown as DzFloatProperty;
145
+ const yRotate = sceneHelper.findPropertyOnNode('YRotate', node) as unknown as DzFloatProperty;
146
+ const yRotate2 = sceneHelper.findPropertyOnNode('YRotate2', node) as unknown as DzFloatProperty;
147
+ const zRotate = sceneHelper.findPropertyOnNode('ZRotate', node) as unknown as DzFloatProperty;
148
+ const zRotate2 = sceneHelper.findPropertyOnNode('ZRotate2', node) as unknown as DzFloatProperty;
149
149
 
150
150
  if (includeExtraRotations) {
151
151
  return [xRotate, xRotate2, yRotate, yRotate2, zRotate, zRotate2].filter(Boolean);
@@ -162,7 +162,7 @@ export const getRotations = (node: DzNode, includeExtraRotations: boolean = true
162
162
  export const getRotationsForAxis = (node: DzNode, axis: 'x' | 'y' | 'z'): DzFloatProperty => {
163
163
  debug(`Getting rotation property for axis: ${axis} on node: ${node.getName()} - isBone: ${isBone(node)}`);
164
164
 
165
- let prop: DzFloatProperty | undefined;
165
+ let prop: DzFloatProperty | null = null;
166
166
 
167
167
  if (isBone(node)) {
168
168
  prop = getNamedRotationForAxis(node, axis);
@@ -175,7 +175,7 @@ export const getRotationsForAxis = (node: DzNode, axis: 'x' | 'y' | 'z'): DzFloa
175
175
  prop = find(
176
176
  getRotations(node, false),
177
177
  (rotation) => rotation.getName().valueOf()[0].toLowerCase() === axis
178
- ) as DzFloatProperty;
178
+ ) as unknown as DzFloatProperty;
179
179
  }
180
180
 
181
181
  return prop;
@@ -185,7 +185,7 @@ export const getNamedRotations = (node: DzNode): DzFloatProperty[] => {
185
185
  const rotations: DzFloatProperty[] = []
186
186
 
187
187
  for (let namedAxis in NamedAxis) {
188
- let property = sceneHelper.findPropertyOnNodeByLabel(NamedAxis[namedAxis], node) as DzFloatProperty
188
+ let property = sceneHelper.findPropertyOnNodeByLabel(NamedAxis[namedAxis], node) as unknown as DzFloatProperty
189
189
  if (property) rotations.push(property)
190
190
  }
191
191
 
@@ -1,11 +1,11 @@
1
1
  import { mainWindow } from '@dsf/core/global'
2
2
 
3
3
  const findPane = <T extends DzPane>(className: string): T => {
4
- return mainWindow.getPaneMgr().findPane(className) as T
4
+ return mainWindow.getPaneMgr().findPane(className) as unknown as T
5
5
  }
6
6
 
7
7
  export const getSmartContentPane = (): DzSmartContentPane => {
8
- return <DzSmartContentPane>findPane("DzSmartContentPane")
8
+ return findPane("DzSmartContentPane") as unknown as DzSmartContentPane
9
9
  }
10
10
 
11
11
  export const getSurfacesPane = (): DzSurfacesPane => {
@@ -20,10 +20,10 @@ export const getParametersPane = (): DzParametersPane => {
20
20
  return findPane<DzParametersPane>("DzParametersPane")
21
21
  }
22
22
 
23
- export const getParametersPaneNodeEditor = (): DzPropertySideNavHierarchy => {
23
+ export const getParametersPaneNodeEditor = (): DzPropertySideNavHierarchy | null => {
24
24
  return findPane<DzParametersPane>("DzParametersPane")?.getNodeEditor() ?? null
25
25
  }
26
26
 
27
- export const getPaneNodeEditor = (pane: DzAbstractNodeEditorPane): DzPropertySideNavHierarchy => {
27
+ export const getPaneNodeEditor = (pane: DzAbstractNodeEditorPane): DzPropertySideNavHierarchy | null => {
28
28
  return pane.getNodeEditor ? pane.getNodeEditor() : null
29
- }
29
+ }
@@ -4,9 +4,9 @@ import { getRoot, isFigure } from './node-helper'
4
4
  import { getPaneNodeEditor, getParametersPane, getParametersPaneNodeEditor } from './pane-helper'
5
5
 
6
6
  export const getSelectedOf = <T>(typeName: string): DzNode[] => {
7
- let nodes = []
7
+ let nodes: DzNode[] = []
8
8
  for (let node of scene.getSelectedNodeList()) {
9
- if (!node.inherits(typeName)) continue
9
+ if (!node || !node.inherits(typeName)) continue
10
10
  nodes.push(node)
11
11
  }
12
12
  return nodes
@@ -45,7 +45,7 @@ export const getFigures = (): DzSkeleton[] => {
45
45
  * @returns the figure skeleton, or null if there is no current selection or the selected node is not part of a figure
46
46
  */
47
47
  export const getSelectedFigure = (): DzSkeleton | null => {
48
- return getSelectedNode()?.getSkeleton?.()
48
+ return getSelectedNode()?.getSkeleton?.() ?? null
49
49
  }
50
50
 
51
51
  /**
@@ -66,12 +66,12 @@ export const getSelectedNodes = (): DzNode[] => {
66
66
  return scene.getSelectedNodeList()
67
67
  }
68
68
 
69
- export const getSelectedRoot = (): DzNode => {
69
+ export const getSelectedRoot = (): DzNode | null => {
70
70
  return getRoot(getSelectedNode())
71
71
  }
72
72
 
73
73
  export const getSelectedRoots = (): DzNode[] => {
74
- return distinct(getSelectedNodes().map(n => getRoot(n)))
74
+ return distinct(getSelectedNodes().map(n => getRoot(n))).filter(n => n !== null) as DzNode[]
75
75
  }
76
76
 
77
77
  export const getSelectedProperties = (): DzProperty[] => {
@@ -79,7 +79,7 @@ export const getSelectedProperties = (): DzProperty[] => {
79
79
  }
80
80
 
81
81
  export const getSelectedNumericProperties = (): (DzFloatProperty | DzIntProperty | DzBoolProperty)[] => {
82
- return getParametersPane()?.getNodeEditor()?.getPropertySelections(true).map(p => p as DzFloatProperty | DzIntProperty | DzBoolProperty) ?? []
82
+ return getParametersPane()?.getNodeEditor()?.getPropertySelections(true).map(p => p as unknown as DzFloatProperty | DzIntProperty | DzBoolProperty) ?? []
83
83
  }
84
84
 
85
85
  export const getSelectedPropertiesOfType = <TProperty extends DzProperty>(type: string): TProperty[] => {
@@ -125,7 +125,7 @@ export const getCurrentFrame = (): number => {
125
125
  }
126
126
 
127
127
  export const getEndFrame = (): number => {
128
- return scene.getAnimRange().end / scene.getTimeStep().valueOf()
128
+ return scene.getAnimRange().end.valueOf() / scene.getTimeStep().valueOf()
129
129
  }
130
130
 
131
131
  /**
@@ -133,7 +133,7 @@ export const getEndFrame = (): number => {
133
133
  * @returns the last frame number (0-based)
134
134
  */
135
135
  export const getLastFrame = (): number => {
136
- return scene.getAnimRange().end / scene.getTimeStep().valueOf()
136
+ return scene.getAnimRange().end.valueOf() / scene.getTimeStep().valueOf()
137
137
  }
138
138
 
139
139
  export const timeToFrame = (time: DzTime): number => {
@@ -142,4 +142,4 @@ export const timeToFrame = (time: DzTime): number => {
142
142
 
143
143
  export const frameToTime = (frame: number): DzTime => {
144
144
  return new DzTime(scene.getTimeStep().valueOf() * frame)
145
- }
145
+ }
@@ -2,7 +2,7 @@ import { mainWindow } from '@dsf/core/global'
2
2
 
3
3
  export const selectUniversalRotateTool = (coordinateSpace?: number): DzUniversalRotateTool => {
4
4
  const viewportMgr = mainWindow.getViewportMgr()
5
- const tool = viewportMgr.findTool('DzUniversalRotateTool') as DzUniversalRotateTool
5
+ const tool = viewportMgr.findTool('DzUniversalRotateTool') as unknown as DzUniversalRotateTool
6
6
  viewportMgr.setActiveTool(tool)
7
7
  if (coordinateSpace) tool.setCoordinateSpace(coordinateSpace)
8
8
  return tool
@@ -19,4 +19,4 @@ export const getAuxViewport = (): DzViewport | null => {
19
19
  if (viewport.name === 'AuxViewportView') return viewport
20
20
  }
21
21
  return null
22
- }
22
+ }
@@ -0,0 +1,416 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { BooleanObservable, Observable } from './observable'
3
+
4
+ // ─── Observable ───────────────────────────────────────────────────────────────
5
+
6
+ describe('Observable — constructor', () => {
7
+ it('initializes with undefined when no value is provided', () => {
8
+ const obs = new Observable<string>()
9
+ expect(obs.value).toBeUndefined()
10
+ })
11
+
12
+ it('initializes with the provided value', () => {
13
+ const obs = new Observable(42)
14
+ expect(obs.value).toBe(42)
15
+ })
16
+
17
+ it('registers an onChange callback provided at construction', () => {
18
+ const cb = vi.fn()
19
+ const obs = new Observable<string>('a', cb)
20
+ obs.value = 'b'
21
+ expect(cb).toHaveBeenCalledWith('b')
22
+ })
23
+ })
24
+
25
+ describe('Observable — value setter', () => {
26
+ it('updates the stored value', () => {
27
+ const obs = new Observable(1)
28
+ obs.value = 2
29
+ expect(obs.value).toBe(2)
30
+ })
31
+
32
+ it('fires connected callbacks with the new value', () => {
33
+ const cb = vi.fn()
34
+ const obs = new Observable(0)
35
+ obs.connect(cb)
36
+ obs.value = 99
37
+ expect(cb).toHaveBeenCalledOnce()
38
+ expect(cb).toHaveBeenCalledWith(99)
39
+ })
40
+
41
+ it('fires all connected callbacks', () => {
42
+ const cb1 = vi.fn()
43
+ const cb2 = vi.fn()
44
+ const obs = new Observable(0)
45
+ obs.connect(cb1).connect(cb2)
46
+ obs.value = 1
47
+ expect(cb1).toHaveBeenCalledWith(1)
48
+ expect(cb2).toHaveBeenCalledWith(1)
49
+ })
50
+
51
+ it('does not fire callbacks when the value is unchanged', () => {
52
+ const cb = vi.fn()
53
+ const obs = new Observable('same')
54
+ obs.connect(cb)
55
+ obs.value = 'same'
56
+ expect(cb).not.toHaveBeenCalled()
57
+ })
58
+ })
59
+
60
+ describe('Observable — connect / disconnect', () => {
61
+ it('returns this for chaining', () => {
62
+ const obs = new Observable(0)
63
+ const cb = vi.fn()
64
+ expect(obs.connect(cb)).toBe(obs)
65
+ })
66
+
67
+ it('does not register the same callback twice', () => {
68
+ const cb = vi.fn()
69
+ const obs = new Observable(0)
70
+ obs.connect(cb).connect(cb)
71
+ obs.value = 1
72
+ expect(cb).toHaveBeenCalledOnce()
73
+ })
74
+
75
+ it('stops firing a disconnected callback', () => {
76
+ const cb = vi.fn()
77
+ const obs = new Observable(0)
78
+ obs.connect(cb)
79
+ obs.disconnect(cb)
80
+ obs.value = 1
81
+ expect(cb).not.toHaveBeenCalled()
82
+ })
83
+
84
+ it('ignores disconnect of a callback that was never connected', () => {
85
+ const obs = new Observable(0)
86
+ expect(() => obs.disconnect(vi.fn())).not.toThrow()
87
+ })
88
+ })
89
+
90
+ describe('Observable — pause / resume', () => {
91
+ it('suppresses callbacks inside the pause block', () => {
92
+ const cb = vi.fn()
93
+ const obs = new Observable(0)
94
+ obs.connect(cb)
95
+ obs.pause(() => { obs.value = 1 })
96
+ expect(cb).not.toHaveBeenCalled()
97
+ })
98
+
99
+ it('still updates the value during the pause block', () => {
100
+ const obs = new Observable(0)
101
+ obs.pause(() => { obs.value = 99 })
102
+ expect(obs.value).toBe(99)
103
+ })
104
+
105
+ it('resumes callbacks after the pause block completes', () => {
106
+ const cb = vi.fn()
107
+ const obs = new Observable(0)
108
+ obs.connect(cb)
109
+ obs.pause(() => { obs.value = 1 })
110
+ obs.value = 2
111
+ expect(cb).toHaveBeenCalledOnce()
112
+ expect(cb).toHaveBeenCalledWith(2)
113
+ })
114
+
115
+ it('suppresses callbacks when paused manually without a block', () => {
116
+ const cb = vi.fn()
117
+ const obs = new Observable(0)
118
+ obs.connect(cb)
119
+ obs.pause()
120
+ obs.value = 1
121
+ expect(cb).not.toHaveBeenCalled()
122
+ })
123
+
124
+ it('resumes callbacks after an explicit resume()', () => {
125
+ const cb = vi.fn()
126
+ const obs = new Observable(0)
127
+ obs.connect(cb)
128
+ obs.pause()
129
+ obs.resume()
130
+ obs.value = 1
131
+ expect(cb).toHaveBeenCalledWith(1)
132
+ })
133
+ })
134
+
135
+ describe('Observable — trigger', () => {
136
+ it('fires all callbacks with the current value', () => {
137
+ const cb = vi.fn()
138
+ const obs = new Observable('x')
139
+ obs.connect(cb)
140
+ obs.trigger()
141
+ expect(cb).toHaveBeenCalledWith('x')
142
+ })
143
+
144
+ it('does not fire callbacks when paused', () => {
145
+ const cb = vi.fn()
146
+ const obs = new Observable('x')
147
+ obs.connect(cb)
148
+ obs.pause()
149
+ obs.trigger()
150
+ expect(cb).not.toHaveBeenCalled()
151
+ })
152
+ })
153
+
154
+ describe('Observable — setSilently', () => {
155
+ it('updates the value without firing callbacks', () => {
156
+ const cb = vi.fn()
157
+ const obs = new Observable(0)
158
+ obs.connect(cb)
159
+ obs.setSilently(5)
160
+ expect(obs.value).toBe(5)
161
+ expect(cb).not.toHaveBeenCalled()
162
+ })
163
+ })
164
+
165
+ describe('Observable — toJSON', () => {
166
+ it('returns the current value', () => {
167
+ const obs = new Observable(42)
168
+ expect(obs.toJSON()).toBe(42)
169
+ })
170
+ })
171
+
172
+ describe('Observable — intercept: beforeChange', () => {
173
+ it('transforms the incoming value before it is applied', () => {
174
+ const obs = new Observable<string>('')
175
+ obs.intercept((_prev, current) => current.toUpperCase())
176
+ obs.value = 'hello'
177
+ expect(obs.value).toBe('HELLO')
178
+ })
179
+
180
+ it('passes the transformed value to callbacks', () => {
181
+ const cb = vi.fn()
182
+ const obs = new Observable<string>('')
183
+ obs.intercept((_prev, current) => current.trim())
184
+ obs.connect(cb)
185
+ obs.value = ' hi '
186
+ expect(cb).toHaveBeenCalledWith('hi')
187
+ })
188
+
189
+ it('blocks a change when beforeChange returns the previous value', () => {
190
+ const cb = vi.fn()
191
+ const obs = new Observable(5)
192
+ // reject negative numbers — return prev unchanged
193
+ obs.intercept((prev, current) => current < 0 ? prev : current)
194
+ obs.connect(cb)
195
+ obs.value = -1
196
+ expect(obs.value).toBe(5)
197
+ expect(cb).not.toHaveBeenCalled()
198
+ })
199
+ })
200
+
201
+ describe('Observable — intercept: afterChange', () => {
202
+ it('applies a post-set transformation until the value stabilizes', () => {
203
+ const obs = new Observable(0)
204
+ // clamp to [0, 100]: if already in range afterChange returns same value → loop ends
205
+ obs.intercept(
206
+ (_prev, current) => current,
207
+ (_prev, current) => Math.max(0, Math.min(100, current))
208
+ )
209
+ obs.value = 150
210
+ expect(obs.value).toBe(100)
211
+ })
212
+
213
+ it('fires callbacks with the afterChange result', () => {
214
+ const cb = vi.fn()
215
+ const obs = new Observable(0)
216
+ obs.intercept(
217
+ (_prev, current) => current,
218
+ (_prev, current) => Math.max(0, Math.min(100, current))
219
+ )
220
+ obs.connect(cb)
221
+ obs.value = 200
222
+ expect(cb).toHaveBeenLastCalledWith(100)
223
+ })
224
+
225
+ it('throws when afterChange never stabilizes (infinite loop guard)', () => {
226
+ const obs = new Observable(0)
227
+ obs.intercept(
228
+ (_prev, current) => current,
229
+ (_prev, current) => current + 1 // always returns a new value
230
+ )
231
+ expect(() => { obs.value = 1 }).toThrow(/afterChange exceeded depth/)
232
+ })
233
+ })
234
+
235
+ describe('Observable — re-entrant set (coalescing)', () => {
236
+ it('applies only the last value requested during an active callback', () => {
237
+ const obs = new Observable(0)
238
+ const calls: number[] = []
239
+
240
+ obs.connect((v) => {
241
+ calls.push(v)
242
+ if (v === 1) {
243
+ obs.value = 2 // queued as pending
244
+ obs.value = 3 // overwrites pending — only 3 should run
245
+ }
246
+ })
247
+
248
+ obs.value = 1
249
+ expect(obs.value).toBe(3)
250
+ expect(calls).toEqual([1, 3])
251
+ })
252
+
253
+ it('does not produce a chained set when the pending value equals current', () => {
254
+ const cb = vi.fn()
255
+ const obs = new Observable(0)
256
+
257
+ obs.connect(() => {
258
+ obs.value = obs.value // set to same — should be a no-op after coalescing
259
+ })
260
+ obs.connect(cb)
261
+
262
+ obs.value = 1
263
+ // cb fires once (for the initial set to 1); no chained set because pending === current
264
+ expect(cb).toHaveBeenCalledOnce()
265
+ })
266
+ })
267
+
268
+ describe('Observable — chained sets (listener-driven)', () => {
269
+ it('processes a value requested by a listener after the current cycle', () => {
270
+ const obs = new Observable(0)
271
+ const calls: number[] = []
272
+
273
+ obs.connect((v) => {
274
+ calls.push(v)
275
+ if (v === 1) obs.value = 2 // request one further change only
276
+ })
277
+
278
+ obs.value = 1
279
+ expect(obs.value).toBe(2)
280
+ expect(calls).toEqual([1, 2])
281
+ })
282
+
283
+ it('throws when a listener causes an infinite chain of distinct values (loop guard)', () => {
284
+ const obs = new Observable(0)
285
+ obs.connect((v) => { obs.value = v + 1 }) // always requests a different value
286
+ expect(() => { obs.value = 1 }).toThrow(/Too many chained set operations/)
287
+ })
288
+ })
289
+
290
+ describe('Observable — custom equals', () => {
291
+ it('uses the provided comparator to decide whether the value changed', () => {
292
+ const cb = vi.fn()
293
+ const obs = new Observable<string>('hello', undefined, (a, b) => a?.toLowerCase() === b?.toLowerCase())
294
+ obs.connect(cb)
295
+ obs.value = 'HELLO' // equal by custom comparator → no change
296
+ expect(cb).not.toHaveBeenCalled()
297
+ expect(obs.value).toBe('hello')
298
+ })
299
+ })
300
+
301
+ // ─── BooleanObservable ────────────────────────────────────────────────────────
302
+
303
+ describe('Observable — resilience: callback throws', () => {
304
+ it('remains usable after a callback throws', () => {
305
+ const obs = new Observable(0)
306
+ const boom = () => { throw new Error('boom') }
307
+ obs.connect(boom)
308
+ expect(() => { obs.value = 1 }).toThrow('boom')
309
+
310
+ // disconnect the bad callback, then confirm _isSetting was cleared
311
+ obs.disconnect(boom)
312
+ const cb = vi.fn()
313
+ obs.connect(cb)
314
+ obs.value = 2
315
+ expect(cb).toHaveBeenCalledWith(2)
316
+ })
317
+
318
+ it('resumes notifications after the pause block throws', () => {
319
+ const cb = vi.fn()
320
+ const obs = new Observable(0)
321
+ obs.connect(cb)
322
+ expect(() => obs.pause(() => { throw new Error('boom') })).toThrow('boom')
323
+
324
+ // _paused must be false so future changes still fire callbacks
325
+ obs.value = 1
326
+ expect(cb).toHaveBeenCalledWith(1)
327
+ })
328
+ })
329
+
330
+ describe('Observable — subscriber list mutation during fire', () => {
331
+ it('does not call a callback that disconnects itself during the current fire', () => {
332
+ const obs = new Observable(0)
333
+ const calls: string[] = []
334
+
335
+ const selfRemove = () => {
336
+ calls.push('selfRemove')
337
+ obs.disconnect(selfRemove)
338
+ }
339
+ const other = () => calls.push('other')
340
+
341
+ obs.connect(selfRemove).connect(other)
342
+ obs.value = 1
343
+
344
+ expect(calls).toEqual(['selfRemove', 'other'])
345
+
346
+ // selfRemove was disconnected — should not fire on the next change
347
+ calls.length = 0
348
+ obs.value = 2
349
+ expect(calls).toEqual(['other'])
350
+ })
351
+
352
+ it('does not call a callback added during the current fire', () => {
353
+ const obs = new Observable(0)
354
+ const late = vi.fn()
355
+
356
+ obs.connect(() => { obs.connect(late) })
357
+ obs.value = 1
358
+
359
+ // late was added mid-fire from a snapshot, should not have been called yet
360
+ expect(late).not.toHaveBeenCalled()
361
+
362
+ // but it is registered for future changes
363
+ obs.value = 2
364
+ expect(late).toHaveBeenCalledWith(2)
365
+ })
366
+ })
367
+
368
+ describe('BooleanObservable — combine', () => {
369
+ it('initializes to true when all combined observables are true', () => {
370
+ const a = new BooleanObservable(true)
371
+ const b = new BooleanObservable(true)
372
+ const combined = new BooleanObservable()
373
+ combined.combine(a, b)
374
+ expect(combined.value).toBe(true)
375
+ })
376
+
377
+ it('initializes to false when any combined observable is false', () => {
378
+ const a = new BooleanObservable(true)
379
+ const b = new BooleanObservable(false)
380
+ const combined = new BooleanObservable()
381
+ combined.combine(a, b)
382
+ expect(combined.value).toBe(false)
383
+ })
384
+
385
+ it('updates to false when a combined observable becomes false', () => {
386
+ const a = new BooleanObservable(true)
387
+ const b = new BooleanObservable(true)
388
+ const combined = new BooleanObservable()
389
+ combined.combine(a, b)
390
+ b.value = false
391
+ expect(combined.value).toBe(false)
392
+ })
393
+
394
+ it('updates to true when all combined observables become true', () => {
395
+ const a = new BooleanObservable(true)
396
+ const b = new BooleanObservable(false)
397
+ const combined = new BooleanObservable()
398
+ combined.combine(a, b)
399
+ b.value = true
400
+ expect(combined.value).toBe(true)
401
+ })
402
+
403
+ it('throws when attempting to combine with itself', () => {
404
+ const obs = new BooleanObservable(true)
405
+ expect(() => obs.combine(obs)).toThrow('Cannot combine with itself')
406
+ })
407
+
408
+ it('does not stack overflow or throw when combined observables change (circular guard)', () => {
409
+ const a = new BooleanObservable(true)
410
+ const b = new BooleanObservable(true)
411
+ const combined = new BooleanObservable()
412
+ combined.combine(a, b)
413
+ expect(() => { a.value = false }).not.toThrow()
414
+ expect(combined.value).toBe(false)
415
+ })
416
+ })
@@ -40,24 +40,25 @@ export class Observable<T> {
40
40
  }
41
41
 
42
42
  this._isSetting = true;
43
- this._value = candidate;
44
- if (!this._paused) this._onChange.slice().forEach(cb => cb(this._value));
45
-
46
- // Apply afterChange transformations (bounded)
47
- let transformed = this._afterChange?.(prev, this._value);
48
- while (transformed !== undefined && !this._equals(this._value, transformed)) {
49
- afterDepth++;
50
- if (afterDepth > Observable.MAX_AFTER_CHANGE_DEPTH) {
51
- this._isSetting = false;
52
- this._pendingValue = undefined;
53
- throw new Error(`Observable: _afterChange exceeded depth ${Observable.MAX_AFTER_CHANGE_DEPTH}. Possible loop.`);
54
- }
55
- this._value = transformed;
43
+ try {
44
+ this._value = candidate;
56
45
  if (!this._paused) this._onChange.slice().forEach(cb => cb(this._value));
57
- transformed = this._afterChange?.(prev, this._value);
58
- }
59
46
 
60
- this._isSetting = false;
47
+ // Apply afterChange transformations (bounded)
48
+ let transformed = this._afterChange?.(prev, this._value);
49
+ while (transformed !== undefined && !this._equals(this._value, transformed)) {
50
+ afterDepth++;
51
+ if (afterDepth > Observable.MAX_AFTER_CHANGE_DEPTH) {
52
+ this._pendingValue = undefined;
53
+ throw new Error(`Observable: _afterChange exceeded depth ${Observable.MAX_AFTER_CHANGE_DEPTH}. Possible loop.`);
54
+ }
55
+ this._value = transformed;
56
+ if (!this._paused) this._onChange.slice().forEach(cb => cb(this._value));
57
+ transformed = this._afterChange?.(prev, this._value);
58
+ }
59
+ } finally {
60
+ this._isSetting = false;
61
+ }
61
62
 
62
63
  // If a listener requested a new value during this cycle, process it
63
64
  if (this._pendingValue !== undefined && !this._equals(this._value, this._pendingValue)) {
@@ -99,8 +100,13 @@ export class Observable<T> {
99
100
 
100
101
  pause(callback?: () => void): void {
101
102
  this._paused = true;
102
- callback?.();
103
- this.resume();
103
+ if (callback !== undefined) {
104
+ try {
105
+ callback();
106
+ } finally {
107
+ this.resume();
108
+ }
109
+ }
104
110
  }
105
111
  resume(): void { this._paused = false; }
106
112