• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mobalazs / rotor-framework / 20096587030

10 Dec 2025 11:11AM UTC coverage: 69.168% (-16.5%) from 85.707%
20096587030

Pull #11

github

web-flow
Merge 015752b30 into 5c8e97a93
Pull Request #11: Refactor/plugin adapter and decorators

52 of 84 new or added lines in 8 files covered. (61.9%)

328 existing lines in 3 files now uncovered.

1438 of 2079 relevant lines covered (69.17%)

0.92 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

71.31
/src/source/plugins/ObserverPlugin.bs
1
namespace Rotor
2

3
    ' =====================================================================
4
    ' ObserverPlugin - Field change observation for roSGNodes
5
    '
6
    ' Rotor Framework plugin for observing field changes on roSGNodes.
7
    ' Manages observer registration, callback routing, and cleanup through
8
    ' widget lifecycle hooks.
9
    '
10
    ' Key Responsibilities:
11
    '   - Attaches/detaches observers to widget nodes during lifecycle
12
    '   - Routes native SceneGraph field change callbacks to registered observers
13
    '   - Manages observer lifecycle (once, until conditions)
14
    '   - Provides unique attachment IDs for tracking observers per node
15
    ' =====================================================================
16
    class ObserverPlugin extends Rotor.BasePlugin
17

18
        ' =============================================================
19
        ' MEMBER VARIABLES
20
        ' =============================================================
21

22
        observerStack as object         ' ObserverStack instance managing all active observers
23
        helperInterfaceId as object     ' Unique field ID used to store plugin metadata on nodes
24

25
        ' =============================================================
26
        ' CONSTRUCTOR
27
        ' =============================================================
28

29
        ' ---------------------------------------------------------------------
30
        ' new - Initializes the ObserverPlugin instance
31
        '
32
        ' @param {string} pluginKey - Plugin identifier
33
        '
34
        sub new(pluginKey = "observer" as string)
35
            super()
1✔
36
            m.pluginKey = pluginKey
1✔
37
        end sub
38

39
        ' =============================================================
40
        ' LIFECYCLE HOOKS
41
        ' =============================================================
42

43
        hooks = {
44
            ' ---------------------------------------------------------------------
45
            ' beforeMount - Attaches observers when widget is mounted
46
            '
47
            ' Called during widget creation to register all observers defined in the widget config.
48
            '
49
            ' @param {object} scope - Plugin instance (m)
50
            ' @param {object} widget - Widget being mounted
51
            '
52
            beforeMount: sub(scope as object, widget as object)
NEW
53
                config = widget[scope.pluginKey]
×
UNCOV
54
                scope.attach(widget.node, config, widget)
×
55
            end sub,
56

57
            ' ---------------------------------------------------------------------
58
            ' beforeUpdate - Updates observers when widget config changes
59
            '
60
            ' Detaches old observers and attaches new ones based on updated configuration.
61
            '
62
            ' @param {object} scope - Plugin instance (m)
63
            ' @param {object} widget - Widget being updated
64
            ' @param {object} newValue - New observer configuration
65
            ' @param {object} oldValue - Previous observer configuration
66
            '
67
            beforeUpdate: sub(scope as object, widget as object, newValue, oldValue = {})
68
                if oldValue <> invalid
×
69
                    scope.detach(widget.node)
×
70
                end if
NEW
71
                widget[scope.pluginKey] = newValue
×
72
                scope.attach(widget.node, newValue, widget)
×
73
            end sub,
74

75
            ' ---------------------------------------------------------------------
76
            ' beforeDestroy - Detaches all observers before widget destruction
77
            '
78
            ' Ensures cleanup of all observers when widget is removed from scene graph.
79
            '
80
            ' @param {object} scope - Plugin instance (m)
81
            ' @param {object} widget - Widget being destroyed
82
            '
83
            beforeDestroy: sub(scope as object, widget as object)
UNCOV
84
                scope.detach(widget.node)
×
85
            end sub
86
        }
87

88
        ' =============================================================
89
        ' INITIALIZATION
90
        ' =============================================================
91

92
        ' ---------------------------------------------------------------------
93
        ' init - Initializes plugin internal state
94
        '
95
        ' Creates the observer stack and sets up the helper interface ID
96
        ' used to track observers on nodes.
97
        '
98
        sub init()
99
            m.observerStack = new Rotor.ObserverPluginHelper.ObserverStack()
1✔
100
            m.helperInterfaceId = Rotor.ObserverPluginHelper.OBSERVER_HELPER_INTERFACE + "-" + m.pluginKey
1✔
101
        end sub
102

103
        ' =============================================================
104
        ' OBSERVER MANAGEMENT
105
        ' =============================================================
106

107
        ' ---------------------------------------------------------------------
108
        ' attach - Attaches observers to a node
109
        '
110
        ' Sets up helper interface on the node (if not already present) and registers
111
        ' all observers defined in the configuration.
112
        '
113
        ' @param {object} node - roSGNode to attach observers to
114
        ' @param {object} config - Observer configuration (single object or array)
115
        ' @param {object} listenerScope - Widget instance for callback execution
116
        '
117
        sub attach(node as object, config as object, listenerScope as object)
118
            ' Determine or create attachment ID for this node
119
            attachmentId = invalid
1✔
120
            if node.hasField(m.helperInterfaceId)
2✔
121
                ' Node already has helper interface - reuse existing attachmentId
122
                pluginHelperValue = node.getField(m.helperInterfaceId)
×
123
                attachmentId = pluginHelperValue.attachmentId
×
124
            else
125
                ' First time attaching to this node - create new attachmentId
3✔
126
                attachmentId = Rotor.Utils.getUUIDHex()
1✔
127

128
                if node <> invalid
3✔
129
                    ' Add helper interface field to node with plugin metadata
130
                    pluginHelperFields = Rotor.Utils.wrapObject(m.helperInterfaceId, {
1✔
131
                        pluginKey: m.pluginKey,
132
                        attachmentId: attachmentId
133
                    })
134
                    Rotor.Utils.setCustomFields(node, pluginHelperFields, true, false)
1✔
135
                end if
136
            end if
137

138
            ' Register each observer configuration
139
            if config <> invalid and config.Count() > 0 and attachmentId <> invalid
3✔
140
                observerConfigs = Rotor.Utils.ensureArray(config)
1✔
141
                for each observerConfig in observerConfigs
1✔
142
                    m.registerObserver(observerConfig, node, attachmentId, m.helperInterfaceId, listenerScope)
1✔
143
                end for
144
            end if
145
        end sub
146

147
        ' ---------------------------------------------------------------------
148
        ' registerObserver - Creates and registers a single observer instance
149
        '
150
        ' Creates an Observer object, stores it in the stack, and sets up the native
151
        ' SceneGraph observeFieldScoped call.
152
        '
153
        ' @param {object} observerConfig - Configuration for the specific observer
154
        ' @param {object} node - roSGNode being observed
155
        ' @param {string} attachmentId - Unique ID linking observers to this node
156
        ' @param {string} helperInterfaceId - Helper interface field ID
157
        ' @param {object} listenerScope - Widget scope for callback execution
158
        '
159
        sub registerObserver(observerConfig as object, node as object, attachmentId as string, helperInterfaceId as string, listenerScope as object)
160
            ' Create observer instance
161
            newObserver = new Rotor.ObserverPluginHelper.Observer(observerConfig, node, attachmentId, listenerScope, m.pluginKey)
1✔
162
            m.observerStack.set(newObserver.id, newObserver)
1✔
163

164
            ' Set up native SceneGraph observation
165
            fieldId = observerConfig.fieldId
1✔
166
            infoFields = newObserver.getInfoFields()
1✔
167
            node.observeFieldScoped(fieldId, "Rotor_ObserverPluginHelper_observerNativeCallback", infoFields)
1✔
168
        end sub
169

170
        ' ---------------------------------------------------------------------
171
        ' detach - Removes all observers associated with a node
172
        '
173
        ' Unobserves all fields and removes observer instances from the stack.
174
        '
175
        ' @param {dynamic} node - roSGNode to detach observers from
176
        '
177
        sub detach(node as dynamic)
178
            ' Get plugin helper metadata from node
179
            pluginHelperValue = node.getField(m.helperInterfaceId)
1✔
180
            if pluginHelperValue = invalid then return
1✔
181

UNCOV
182
            attachmentId = pluginHelperValue.attachmentId
×
UNCOV
183
            if attachmentId = invalid then return
×
184

185
            ' Find and remove all observers for this attachment
UNCOV
186
            observers = m.observerStack.findObserverByAttachmentId(attachmentId)
×
UNCOV
187
            for each observer in observers
×
UNCOV
188
                if observer.node <> invalid
×
UNCOV
189
                    observer.node.unobserveFieldScoped(observer.fieldId)
×
190
                end if
UNCOV
191
                m.observerStack.remove(observer.id) ' Calls observer.destroy()
×
192
            end for
193
        end sub
194

195
        ' =============================================================
196
        ' CALLBACK ROUTING
197
        ' =============================================================
198

199
        ' ---------------------------------------------------------------------
200
        ' observerCallbackRouter - Routes native callback to appropriate observers
201
        '
202
        ' Called by the global observerNativeCallback when a field changes.
203
        ' Finds matching observers and triggers their callbacks.
204
        '
205
        ' Handles:
206
        '   - Payload parsing via custom parsePayload functions
207
        '   - 'once' observers (removed after first trigger)
208
        '   - 'until' condition observers (removed when condition met)
209
        '
210
        ' @param {dynamic} value - New value of the observed field
211
        ' @param {object} extraInfo - Additional info from observeFieldScoped
212
        ' @param {string} fieldId - ID of the field that changed
213
        ' @param {string} attachmentId - Node attachment ID
214
        ' @param {string} pluginKey - Plugin instance key
215
        '
216
        sub observerCallbackRouter(value as dynamic, extraInfo as object, fieldId as string, attachmentId as string, pluginKey as string)
217
            ' Find all observers interested in this field change
218
            interestedObservers = m.observerStack.findObserversByAttachmentAndField(attachmentId, fieldId)
1✔
219

220
            for each observer in interestedObservers
1✔
221
                ' Build and parse payload
222
                payload = Rotor.Utils.wrapObject(fieldId, value)
1✔
223
                payload.append(extraInfo)
1✔
224
                parsedPayload = observer.parsePayload(payload)
1✔
225

226
                ' Execute observer callback
227
                observer.notify(parsedPayload)
1✔
228

229
                ' Handle observer removal conditions
230
                if observer.once = true or (Rotor.Utils.isFunction(observer.until) and true = observer.until(parsedPayload))
2✔
231
                    ' Unobserve before removing to prevent race conditions
232
                    if observer.node <> invalid
3✔
233
                        observer.node.unobserveFieldScoped(observer.fieldId)
1✔
234
                    end if
235
                    m.observerStack.remove(observer.id)
1✔
236
                end if
237
            end for
238
        end sub
239

240
        ' =============================================================
241
        ' CLEANUP
242
        ' =============================================================
243

244
        ' ---------------------------------------------------------------------
245
        ' destroy - Cleans up all observers
246
        '
247
        ' Called when the framework instance is destroyed.
248
        ' Unobserves all fields and removes all observer instances.
249
        '
250
        sub destroy()
251
            if m.observerStack = invalid then return
×
252

253
            ' Collect observer IDs to avoid mutation during iteration
254
            ids = []
×
255
            all = m.observerStack.getAll()
×
256
            for each id in all
×
257
                observer = all[id]
×
258
                if observer <> invalid and observer.node <> invalid
×
259
                    observer.node.unobserveFieldScoped(observer.fieldId)
×
260
                end if
261
                ids.push(id)
×
262
            end for
263

264
            ' Remove all observers
265
            for each id in ids
×
266
                m.observerStack.remove(id) ' Calls observer.destroy()
×
267
            end for
268

269
            ' Clear the stack
270
            m.observerStack.clear()
×
271
        end sub
272

273
    end class
274

275
    ' =================================================================
276
    ' OBSERVER PLUGIN HELPER NAMESPACE
277
    ' =================================================================
278

279
    namespace ObserverPluginHelper
280

281
        ' Prefix for helper field on nodes
282
        const OBSERVER_HELPER_INTERFACE = "rotorObserverPluginKeysHelper"
283

284
        ' ---------------------------------------------------------------------
285
        ' observerNativeCallback - Global callback for SceneGraph field changes
286
        '
287
        ' This function is registered with node.observeFieldScoped() and is called
288
        ' whenever an observed field changes. It extracts metadata, identifies
289
        ' the appropriate plugin instance, and routes to observerCallbackRouter.
290
        '
291
        ' @param {object} msg - roSGNodeEvent from the observed node
292
        '
293
        sub observerNativeCallback(msg as object)
294
            ' Extract event data
295
            extraInfo = msg.GetInfo()
1✔
296
            fieldId = msg.getField()
1✔
297
            value = msg.getData()
1✔
298

299
            ' Extract plugin metadata from helper field
300
            pluginKey = ""
1✔
301
            attachmentId = ""
1✔
302
            for each key in extraInfo
1✔
303
                if Left(key, Len(OBSERVER_HELPER_INTERFACE)) = OBSERVER_HELPER_INTERFACE
3✔
304
                    helperValue = extraInfo[key]
1✔
305
                    if helperValue <> invalid
3✔
306
                        pluginKey = helperValue.pluginKey
1✔
307
                        attachmentId = helperValue.attachmentId
1✔
308
                    end if
309
                    extraInfo.delete(key) ' Remove helper from payload
1✔
310
                    exit for
311
                end if
312
            end for
313

314
            ' Route to appropriate plugin instance
315
            if attachmentId <> "" and pluginKey <> ""
3✔
316
                globalScope = GetGlobalAA()
1✔
317
                frameworkInstance = globalScope.rotor_framework_helper.frameworkInstance
1✔
318
                plugin = invalid
1✔
319

320
                ' Handle special case for Rotor Animator observers
321
                if extraInfo?.isRotorAnimatorNode = true
3✔
322
                    plugin = frameworkInstance.animatorProvider.animatorObservber
1✔
UNCOV
323
                else
×
UNCOV
324
                    plugin = frameworkInstance.plugins[pluginKey]
×
325
                end if
326

327
                ' Execute callback router
328
                if plugin <> invalid
3✔
329
                    plugin.observerCallbackRouter(value, extraInfo, fieldId, attachmentId, pluginKey)
1✔
330
                end if
331
            end if
332
        end sub
333

334
        ' =====================================================================
335
        ' ObserverStack - Collection manager for Observer instances
336
        '
337
        ' Manages a collection of Observer instances with specialized lookup methods.
338
        '
339
        ' Provides:
340
        '   - Storage and retrieval of observers by ID
341
        '   - Lookup by attachment ID and field ID
342
        '   - Automatic cleanup on removal
343
        ' =====================================================================
344
        class ObserverStack extends Rotor.BaseStack
345

346
            ' ---------------------------------------------------------------------
347
            ' remove - Removes observer and triggers cleanup
348
            '
349
            ' Overrides base class to call observer.destroy() before removal.
350
            '
351
            ' @param {string} id - Observer ID to remove
352
            '
353
            override sub remove(id as string)
354
                item = m.get(id)
1✔
355
                if item <> invalid
3✔
356
                    item.destroy()
1✔
357
                end if
358
                super.remove(id)
1✔
359
            end sub
360

361
            ' ---------------------------------------------------------------------
362
            ' findObserversByAttachmentAndField - Finds observers by attachment and field
363
            '
364
            ' Returns all observers matching both the attachment ID and field ID.
365
            '
366
            ' @param {string} attachmentId - Node attachment ID
367
            ' @param {string} fieldId - Field ID
368
            ' @returns {object} Array of matching Observer instances
369
            '
370
            function findObserversByAttachmentAndField(attachmentId as string, fieldId as string) as object
371
                observers = []
1✔
372
                for each id in m.stack
1✔
373
                    observer = m.stack[id]
1✔
374
                    if observer.fieldId = fieldId and observer.attachmentId = attachmentId
3✔
375
                        observers.push(observer)
1✔
376
                    end if
377
                end for
378
                return observers
1✔
379
            end function
380

381
            ' ---------------------------------------------------------------------
382
            ' findObserverByAttachmentId - Finds all observers for an attachment
383
            '
384
            ' Returns all observers associated with a specific node attachment.
385
            '
386
            ' @param {string} attachmentId - Node attachment ID
387
            ' @returns {object} Array of matching Observer instances
388
            '
389
            function findObserverByAttachmentId(attachmentId as string) as object
UNCOV
390
                observers = []
×
UNCOV
391
                for each id in m.stack
×
UNCOV
392
                    observer = m.stack[id]
×
UNCOV
393
                    if observer.attachmentId = attachmentId
×
UNCOV
394
                        observers.push(observer)
×
395
                    end if
396
                end for
UNCOV
397
                return observers
×
398
            end function
399

400
        end class
401

402
        ' =====================================================================
403
        ' Observer - Single observer configuration for a node field
404
        '
405
        ' Represents a single observer configuration for a node field.
406
        '
407
        ' Responsibilities:
408
        '   - Stores observer configuration (callback, conditions, etc.)
409
        '   - Sets up initial field value if provided
410
        '   - Provides info fields for observeFieldScoped
411
        '   - Executes callbacks in correct scope
412
        '   - Manages cleanup of references
413
        ' =====================================================================
414
        class Observer
415

416
            ' =============================================================
417
            ' MEMBER VARIABLES
418
            ' =============================================================
419

420
            id as string                ' Unique observer ID
421
            node as object              ' roSGNode being observed
422
            pluginKey as string         ' Key of managing ObserverPlugin
423
            listenerScope as object     ' Widget scope for callback execution
424
            attachmentId as string      ' Node attachment ID
425
            fieldId as string           ' Field name being observed
426
            infoFields as object        ' Additional fields to include in callback info
427
            value as dynamic            ' Initial field value (if any)
428
            once as boolean             ' Remove observer after first trigger
429
            until as function           ' Conditional removal function
430
            callback as function        ' Callback function to execute
431
            parsePayload as function    ' Payload transformation function
432
            alwaysNotify as boolean     ' Field alwaysNotify flag
433

434
            ' =============================================================
435
            ' CONSTRUCTOR
436
            ' =============================================================
437

438
            ' ---------------------------------------------------------------------
439
            ' new - Creates an Observer instance
440
            '
441
            ' @param {object} config - Observer configuration
442
            ' @param {object} node - roSGNode to observe
443
            ' @param {string} attachmentId - Node attachment ID
444
            ' @param {object} listenerScope - Widget scope for callbacks
445
            ' @param {string} pluginKey - Managing plugin key
446
            '
447
            sub new(config as object, node as object, attachmentId as string, listenerScope as object, pluginKey as string)
448
                ' Generate unique ID
449
                m.id = (config.id ?? "ID") + "-" + Rotor.Utils.getUUIDHex()
1✔
450

451
                ' Store references
452
                m.node = node
1✔
453
                m.pluginKey = pluginKey
1✔
454
                m.listenerScope = listenerScope ?? {}
1✔
455
                m.attachmentId = attachmentId
1✔
456

457
                ' Extract configuration
458
                m.fieldId = config?.fieldId ?? ""
1✔
459
                m.infoFields = config?.infoFields ?? []
1✔
460
                m.value = config?.value
1✔
461
                m.alwaysNotify = config?.alwaysNotify ?? true
1✔
462
                m.once = config?.once ?? false
1✔
463
                m.until = config?.until
1✔
464

465
                ' Set callback (required)
466
                m.callback = config?.callback ?? sub() throw "Callback has not configured for observer"
1✔
467
                end Sub
468

469
                ' Set payload parser (optional)
470
                m.parsePayload = config?.parsePayload ?? function(payload)
1✔
471
                    return payload
472
                end function
473

474
                ' Set up initial field value if provided
475
                m.setupField(m.fieldId, m.value, m.alwaysNotify)
1✔
476
            end sub
477

478
            ' =============================================================
479
            ' FIELD SETUP
480
            ' =============================================================
481

482
            ' ---------------------------------------------------------------------
483
            ' setupField - Sets initial value on observed field
484
            '
485
            ' Creates or updates the field on the node if an initial value is provided.
486
            '
487
            ' @param {string} fieldId - Field name
488
            ' @param {dynamic} value - Initial value (if not invalid)
489
            ' @param {boolean} alwaysNotify - alwaysNotify flag value
490
            '
491
            sub setupField(fieldId as string, value as dynamic, alwaysNotify as boolean)
492
                fields = {}
1✔
493
                fields[m.fieldId] = value
1✔
494
                Rotor.Utils.setCustomFields(m.node, fields, m.value <> invalid, alwaysNotify)
1✔
495
            end sub
496

497
            ' =============================================================
498
            ' INFO FIELDS
499
            ' =============================================================
500

501
            ' ---------------------------------------------------------------------
502
            ' getInfoFields - Builds field list for observeFieldScoped
503
            '
504
            ' Combines user-defined infoFields with the helper interface ID.
505
            '
506
            ' @returns {object} Array of field names for observeFieldScoped
507
            '
508
            function getInfoFields() as object
509
                helperInterfaceId = Rotor.ObserverPluginHelper.OBSERVER_HELPER_INTERFACE + "-" + m.pluginKey
1✔
510
                infoFields = []
1✔
511
                infoFields.append(m.infoFields)
1✔
512
                infoFields.push(helperInterfaceId)
1✔
513
                return infoFields
1✔
514
            end function
515

516
            ' =============================================================
517
            ' CALLBACK EXECUTION
518
            ' =============================================================
519

520
            ' ---------------------------------------------------------------------
521
            ' notify - Executes observer callback
522
            '
523
            ' Calls the configured callback function in the correct scope with the payload.
524
            '
525
            ' @param {dynamic} payload - Data to pass to callback
526
            '
527
            sub notify(payload as dynamic)
528
                Rotor.Utils.callbackScoped(m.callback, m.listenerScope, payload)
1✔
529
            end sub
530

531
            ' =============================================================
532
            ' CLEANUP
533
            ' =============================================================
534

535
            ' ---------------------------------------------------------------------
536
            ' destroy - Cleans up observer references
537
            '
538
            ' Clears references to prevent memory leaks.
539
            '
540
            sub destroy()
541
                m.node = invalid
1✔
542
                m.listenerScope = invalid
1✔
543
            end sub
544

545
        end class
546

547
    end namespace ' ObserverPluginHelper
548

549
end namespace ' Rotor
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc