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.
- package/LICENSE +373 -0
- package/README.md +327 -267
- package/package.json +9 -6
- package/src/Setup.dsa.ts +37 -9
- package/src/dialog/builders/list-view-builder.ts +6 -1
- package/src/dialog/builders/slider-builder.ts +1 -1
- package/src/examples/01-hello-world.dsa.ts +8 -0
- package/src/examples/02-persistence-dialog.dsa.ts +21 -0
- package/src/examples/02-persistence-dialog.ts +68 -0
- package/src/examples/03-simple-dialog.dsa.ts +23 -0
- package/src/examples/03-simple-dialog.ts +47 -0
- package/src/examples/04-settings-dialog.dsa.ts +29 -0
- package/src/examples/04-settings-dialog.ts +83 -0
- package/src/examples/05-list-dialog.dsa.ts +53 -0
- package/src/examples/05-list-dialog.ts +88 -0
- package/src/examples/06-showcase-dialog.dsa.ts +87 -0
- package/src/examples/06-showcase-dialog.ts +518 -0
- package/src/helpers/action-helper.ts +2 -2
- package/src/helpers/custom-action-helper.ts +5 -4
- package/src/helpers/custom-action-installer-helper.ts +39 -10
- package/src/helpers/file-helper.ts +1 -1
- package/src/helpers/list-view-helper.ts +1 -1
- package/src/helpers/message-box-helper.ts +2 -2
- package/src/helpers/node-helper.ts +14 -14
- package/src/helpers/pane-helper.ts +5 -5
- package/src/helpers/scene-helper.ts +9 -9
- package/src/helpers/viewport-helper.ts +2 -2
- package/src/lib/observable.test.ts +416 -0
- package/src/lib/observable.ts +24 -18
- package/src/lib/tree-node.test.ts +21 -0
- package/tsconfig.json +34 -109
- package/webpack.config.js +1 -0
- package/src/samples/hello-world.dsa.ts +0 -8
- package/src/samples/sample-dialog.dsa.ts +0 -47
- package/src/samples/sample-dialog.ts +0 -49
- /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)
|
|
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 |
|
|
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
|
|
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
|
+
})
|
package/src/lib/observable.ts
CHANGED
|
@@ -40,24 +40,25 @@ export class Observable<T> {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
this._isSetting = true;
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|