@avimate/msfs-jest-utils 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,558 @@
1
+ "use strict";
2
+ /**
3
+ * Helper utilities for testing FSComponent components.
4
+ *
5
+ * Provides utilities to render components, query DOM, and test component behavior.
6
+ */
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.ComponentTestHelper = void 0;
9
+ const msfs_sdk_1 = require("@microsoft/msfs-sdk");
10
+ /**
11
+ * Recursively matches VNodes to actual DOM nodes and fixes Ref pointers.
12
+ * This effectively "heals" the disconnect caused by cloning/rebuilding during render.
13
+ *
14
+ * This solves the "Ghost Reference" problem where refs point to initial
15
+ * VNode instances instead of the final DOM nodes in the container.
16
+ *
17
+ * This is Phase 2 of the ref reconciliation process. Phase 1 (direct DOM queries)
18
+ * handles most cases efficiently. This function provides a fallback for complex
19
+ * nested structures where position-based or attribute-based matching is needed.
20
+ *
21
+ * Matching strategies (in order of preference):
22
+ * 1. Position-based matching by tag name (fast, works for simple parent-child relationships)
23
+ * 2. ID-based matching via querySelector (most reliable for deeply nested elements)
24
+ * 3. Class-based matching via querySelector (fallback for elements without IDs)
25
+ *
26
+ * @param vnode - The VNode to reconcile refs for
27
+ * @param domNode - The corresponding DOM node
28
+ * @param rootContainer - Root container element for ID-based lookups (optional, auto-detected)
29
+ * @param matchedDomNodes - WeakSet to track which DOM nodes have already been matched (prevents duplicates)
30
+ */
31
+ function reconcileRefs(vnode, domNode, rootContainer, matchedDomNodes = new WeakSet()) {
32
+ if (!vnode || !domNode)
33
+ return;
34
+ // Store root container for ID-based lookups of deeply nested elements
35
+ if (!rootContainer && domNode instanceof Element) {
36
+ rootContainer = domNode;
37
+ }
38
+ // 1. If this VNode has a ref, FORCE it to point to the real DOM node
39
+ if (vnode.props?.ref && typeof vnode.props.ref === 'object' && 'instance' in vnode.props.ref) {
40
+ // Only update if ref.instance is not already set to a valid element in the container
41
+ // This preserves matches from the direct DOM walk (Phase 1)
42
+ if (!vnode.props.ref.instance || !rootContainer || !rootContainer.contains(vnode.props.ref.instance)) {
43
+ // Check if this DOM node is already matched to another ref (prevent duplicates)
44
+ if (!matchedDomNodes.has(domNode)) {
45
+ vnode.props.ref.instance = domNode;
46
+ matchedDomNodes.add(domNode); // Mark as matched
47
+ // If this VNode has an ID, try to find it in the root container as a fallback
48
+ // This helps with deeply nested elements where position matching might fail
49
+ if (vnode.props?.id && typeof vnode.props.id === 'string' && rootContainer) {
50
+ const foundById = rootContainer.querySelector(`#${vnode.props.id}`);
51
+ if (foundById && foundById !== domNode && !matchedDomNodes.has(foundById)) {
52
+ // Use the element found by ID if it's different (more reliable for nested elements)
53
+ vnode.props.ref.instance = foundById;
54
+ matchedDomNodes.delete(domNode); // Remove old match
55
+ matchedDomNodes.add(foundById); // Mark new match
56
+ }
57
+ }
58
+ }
59
+ }
60
+ }
61
+ // 2. If this VNode tracks an internal instance, update that too (optional but safe)
62
+ if (vnode.instance) {
63
+ vnode.instance = domNode;
64
+ }
65
+ // 3. Recurse through children
66
+ if (vnode.children && domNode instanceof Element) {
67
+ // Flatten children array (handle nested arrays from .map() calls)
68
+ const flattenChildren = (children) => {
69
+ const result = [];
70
+ for (const child of children) {
71
+ if (Array.isArray(child)) {
72
+ result.push(...flattenChildren(child));
73
+ }
74
+ else if (child !== null && child !== undefined && typeof child !== 'boolean') {
75
+ result.push(child);
76
+ }
77
+ }
78
+ return result;
79
+ };
80
+ const vnodeChildren = Array.isArray(vnode.children)
81
+ ? flattenChildren(vnode.children)
82
+ : [vnode.children].filter(c => c !== null && c !== undefined && typeof c !== 'boolean');
83
+ if (vnodeChildren.length > 0) {
84
+ // Get DOM children - we need both elements AND text nodes for accurate matching
85
+ // because VNode children can include text nodes
86
+ const allDomChildren = Array.from(domNode.childNodes);
87
+ // Separate elements from text nodes for matching
88
+ const domElementChildren = allDomChildren.filter(node => node.nodeType !== Node.TEXT_NODE);
89
+ // Also track text nodes for cases where VNode has text children
90
+ const domTextChildren = allDomChildren.filter(node => node.nodeType === Node.TEXT_NODE);
91
+ // Track both element and text indices separately
92
+ let domElementIndex = 0;
93
+ let domTextIndex = 0;
94
+ for (const childVNode of vnodeChildren) {
95
+ if (!childVNode)
96
+ continue;
97
+ if (typeof childVNode === 'boolean')
98
+ continue;
99
+ // Handle text VNodes (strings/numbers) - these don't have refs
100
+ if (typeof childVNode === 'string' || typeof childVNode === 'number') {
101
+ // Text nodes don't need ref reconciliation, but we should advance text index
102
+ // if there's a corresponding text node in DOM
103
+ if (domTextIndex < domTextChildren.length) {
104
+ domTextIndex++;
105
+ }
106
+ continue;
107
+ }
108
+ // Handle element VNodes (these can have refs)
109
+ if (typeof childVNode === 'object' && childVNode.type && typeof childVNode.type === 'string') {
110
+ let matchedDom = null;
111
+ let matchedIndex = -1;
112
+ // Strategy 1: Position-based matching (works well for simple parent-child relationships)
113
+ // Try this first for better performance and accuracy in common cases
114
+ if (domElementIndex < domElementChildren.length) {
115
+ const candidate = domElementChildren[domElementIndex];
116
+ // Match by tag name - this is reliable for simple parent-child relationships
117
+ if (candidate.tagName.toLowerCase() === childVNode.type.toLowerCase()) {
118
+ matchedDom = candidate;
119
+ matchedIndex = domElementIndex;
120
+ }
121
+ }
122
+ // Strategy 2: ID-based matching (most reliable for deeply nested elements)
123
+ // Use this when position-based fails or when we have an ID
124
+ if (!matchedDom && childVNode.props?.id && typeof childVNode.props.id === 'string' && rootContainer) {
125
+ const foundById = rootContainer.querySelector(`#${childVNode.props.id}`);
126
+ if (foundById && domNode.contains(foundById)) {
127
+ matchedDom = foundById;
128
+ matchedIndex = domElementChildren.indexOf(foundById);
129
+ }
130
+ }
131
+ // Strategy 2.5: Data-* attribute matching (for elements with unique data attributes)
132
+ // Use this when position and ID matching fail but we have unique data attributes
133
+ if (!matchedDom && childVNode.props && rootContainer) {
134
+ const dataAttrs = Object.keys(childVNode.props)
135
+ .filter(key => {
136
+ // Match data-* (kebab-case) or data followed by uppercase (camelCase like dataRange)
137
+ return key.startsWith('data-') ||
138
+ (key.startsWith('data') && key.length > 4 && key[4] === key[4].toUpperCase());
139
+ })
140
+ .map(key => {
141
+ // Convert camelCase to kebab-case if needed
142
+ let attr = key;
143
+ if (key.startsWith('data') && !key.startsWith('data-')) {
144
+ // It's camelCase like "dataRange" - convert to "data-range"
145
+ attr = 'data-' + key.substring(4).replace(/([A-Z])/g, '-$1').toLowerCase();
146
+ }
147
+ return { attr, value: childVNode.props[key] };
148
+ });
149
+ if (dataAttrs.length > 0) {
150
+ let selector = childVNode.type || '*';
151
+ dataAttrs.forEach(({ attr, value }) => {
152
+ const kebabAttr = attr.replace(/([A-Z])/g, '-$1').toLowerCase();
153
+ const escapedValue = String(value).replace(/"/g, '\\"');
154
+ selector += `[${kebabAttr}="${escapedValue}"]`;
155
+ });
156
+ const foundByData = rootContainer.querySelector(selector);
157
+ if (foundByData && domNode.contains(foundByData)) {
158
+ // Verify tag name matches
159
+ if (!childVNode.type || foundByData.tagName.toLowerCase() === childVNode.type.toLowerCase()) {
160
+ // Check if already matched
161
+ if (!matchedDomNodes.has(foundByData)) {
162
+ const indexInChildren = domElementChildren.indexOf(foundByData);
163
+ if (indexInChildren >= 0) {
164
+ matchedDom = foundByData;
165
+ matchedIndex = indexInChildren;
166
+ }
167
+ }
168
+ }
169
+ }
170
+ }
171
+ }
172
+ // Strategy 3: Class-based matching (fallback for elements without IDs)
173
+ // Only use if position and ID matching both failed
174
+ if (!matchedDom && childVNode.props?.class && domNode instanceof Element) {
175
+ let classNames = [];
176
+ if (typeof childVNode.props.class === 'string') {
177
+ classNames = childVNode.props.class.split(/\s+/).filter((c) => c.length > 0);
178
+ }
179
+ else if (typeof childVNode.props.class === 'object' && childVNode.props.class !== null) {
180
+ classNames = Object.keys(childVNode.props.class).filter(k => childVNode.props.class[k]);
181
+ }
182
+ if (classNames.length > 0) {
183
+ const primaryClass = classNames[0];
184
+ // Build selector with tag name if available (more specific = better match)
185
+ const selector = childVNode.type
186
+ ? `${childVNode.type}.${primaryClass}`
187
+ : `.${primaryClass}`;
188
+ // Search within current subtree only - get all matches
189
+ const allMatchesByClass = Array.from(domNode.querySelectorAll(selector));
190
+ // Find first match that hasn't been assigned to another ref
191
+ const foundByClass = allMatchesByClass.find(el => {
192
+ // Verify tag name matches if specified
193
+ if (childVNode.type && el.tagName.toLowerCase() !== childVNode.type.toLowerCase()) {
194
+ return false;
195
+ }
196
+ // Verify it's actually a child/descendant of domNode
197
+ const parent = el.parentNode;
198
+ if (!(parent === domNode || (parent && domNode.contains(parent)))) {
199
+ return false;
200
+ }
201
+ // Check if already matched
202
+ if (matchedDomNodes.has(el)) {
203
+ return false;
204
+ }
205
+ return true;
206
+ });
207
+ if (foundByClass) {
208
+ const indexInChildren = domElementChildren.indexOf(foundByClass);
209
+ if (indexInChildren >= 0) {
210
+ matchedDom = foundByClass;
211
+ matchedIndex = indexInChildren;
212
+ }
213
+ }
214
+ }
215
+ }
216
+ if (matchedDom && matchedIndex >= 0) {
217
+ // Recursively fix the child - pass matchedDomNodes to track assignments
218
+ reconcileRefs(childVNode, matchedDom, rootContainer, matchedDomNodes);
219
+ // Advance past the matched node
220
+ domElementIndex = matchedIndex + 1;
221
+ }
222
+ else if (domElementIndex < domElementChildren.length) {
223
+ // Last resort: use next element if tag matches
224
+ const fallback = domElementChildren[domElementIndex];
225
+ // Only use fallback if tag name matches (safer than blind matching)
226
+ if (!childVNode.type || fallback.tagName.toLowerCase() === childVNode.type.toLowerCase()) {
227
+ reconcileRefs(childVNode, fallback, rootContainer, matchedDomNodes);
228
+ domElementIndex++;
229
+ }
230
+ // If tag doesn't match, skip this VNode to avoid incorrect ref assignments
231
+ }
232
+ }
233
+ // Text VNodes (strings/numbers) don't have refs, so we skip them
234
+ // They're handled when we recurse into their parent element
235
+ }
236
+ }
237
+ }
238
+ }
239
+ class ComponentTestHelper {
240
+ constructor(env) {
241
+ this.env = env;
242
+ this.container = env.getDocument().createElement('div');
243
+ env.getDocument().body.appendChild(this.container);
244
+ }
245
+ /**
246
+ * Render a component to DOM
247
+ */
248
+ renderComponent(ComponentClass, // Use 'any' to avoid version conflicts
249
+ props) {
250
+ // Create component instance
251
+ const component = new ComponentClass(props);
252
+ // Call onBeforeRender if exists
253
+ if (component.onBeforeRender) {
254
+ component.onBeforeRender();
255
+ }
256
+ // Render component - this creates the VNode structure
257
+ const vnode = component.render();
258
+ if (!vnode) {
259
+ throw new Error('Component render() returned null');
260
+ }
261
+ // Phase 1: Collect all refs from VNode tree BEFORE FSComponent.render() modifies it
262
+ // FSComponent.render() may modify the VNode tree in place, so we need to collect refs first
263
+ const refsMap = new Map();
264
+ const collectRefs = (vnode) => {
265
+ if (vnode === null || vnode === undefined)
266
+ return;
267
+ // JSX conditionals can produce booleans (e.g. `cond && <El />`)
268
+ if (typeof vnode === 'boolean')
269
+ return;
270
+ // Arrays from `.map()` should be traversed.
271
+ if (Array.isArray(vnode)) {
272
+ vnode.forEach(collectRefs);
273
+ return;
274
+ }
275
+ if (vnode.props?.ref && typeof vnode.props.ref === 'object' && 'instance' in vnode.props.ref) {
276
+ refsMap.set(vnode.props.ref, vnode);
277
+ }
278
+ if (vnode.children) {
279
+ const children = Array.isArray(vnode.children) ? vnode.children : [vnode.children];
280
+ children.forEach((child) => collectRefs(child));
281
+ }
282
+ };
283
+ collectRefs(vnode); // Collect BEFORE FSComponent.render() modifies the tree
284
+ // Render to DOM - this is when refs get populated and elements are added to container
285
+ msfs_sdk_1.FSComponent.render(vnode, this.container);
286
+ // Get the root element from the container (NOT from vnode.instance)
287
+ // After FSComponent.render(), elements may have been cloned/adopted, so vnode.instance
288
+ // might point to the old element. The actual rendered element is in the container.
289
+ const element = this.container.firstElementChild;
290
+ if (!element) {
291
+ throw new Error('Component did not render any DOM element to container');
292
+ }
293
+ // FIX: Reconcile the Refs!
294
+ // Two-phase approach solves the "Ghost Reference" problem:
295
+ //
296
+ // Phase 1: Direct DOM queries (fast, reliable)
297
+ // - Collect all refs from VNode tree
298
+ // - Match by ID using querySelector (most reliable)
299
+ // - Match by class+tag for elements without IDs
300
+ // - This handles 90%+ of cases efficiently
301
+ //
302
+ // Phase 2: Recursive VNode-to-DOM matching (comprehensive fallback)
303
+ // - Handles complex nested structures
304
+ // - Uses position, ID, and class-based matching strategies
305
+ // - Ensures no refs are left unset
306
+ // Phase 1: Match collected refs to DOM elements (refs were collected before FSComponent.render())
307
+ // Match refs to DOM elements using querySelector (more reliable than walking)
308
+ // This direct DOM query approach is faster and more accurate than position-based matching
309
+ refsMap.forEach((vnode, ref) => {
310
+ // Skip if ref is already correctly set (from a previous match)
311
+ // Check if ref.instance is a valid Node and is in the container
312
+ if (ref.instance && ref.instance instanceof Node) {
313
+ try {
314
+ if (this.container.contains(ref.instance)) {
315
+ return; // Ref is already correctly set
316
+ }
317
+ }
318
+ catch (e) {
319
+ // If contains() fails, the ref is definitely not in the container, continue matching
320
+ }
321
+ }
322
+ // Strategy 1: Match by ID first (most reliable - IDs are unique)
323
+ if (vnode.props?.id && typeof vnode.props.id === 'string') {
324
+ const found = this.container.querySelector(`#${vnode.props.id}`);
325
+ if (found) {
326
+ // Verify tag name matches if specified (extra safety check)
327
+ if (!vnode.type || found.tagName.toLowerCase() === vnode.type.toLowerCase()) {
328
+ ref.instance = found;
329
+ return; // ID match is definitive, no need to try class matching
330
+ }
331
+ }
332
+ }
333
+ // Strategy 1.5: Match by data-* attributes (UNIQUE identifiers)
334
+ // This handles cases where multiple elements share the same class but have unique data attributes
335
+ if (!ref.instance && vnode.props) {
336
+ // Collect all data-* attributes from props
337
+ // Handle both camelCase (dataRange) and kebab-case (data-range) prop names
338
+ const dataAttrs = Object.keys(vnode.props)
339
+ .filter(key => {
340
+ // Match data-* (kebab-case) or data followed by uppercase (camelCase like dataRange)
341
+ return key.startsWith('data-') ||
342
+ (key.startsWith('data') && key.length > 4 && key[4] === key[4].toUpperCase());
343
+ })
344
+ .map(key => {
345
+ // Convert camelCase to kebab-case if needed
346
+ let attr = key;
347
+ if (key.startsWith('data') && !key.startsWith('data-')) {
348
+ // It's camelCase like "dataRange" - convert to "data-range"
349
+ attr = 'data-' + key.substring(4).replace(/([A-Z])/g, '-$1').toLowerCase();
350
+ }
351
+ return { attr, value: vnode.props[key] };
352
+ });
353
+ if (dataAttrs.length > 0) {
354
+ // Build selector: circle[data-range="25"]
355
+ let selector = vnode.type || '*';
356
+ dataAttrs.forEach(({ attr, value }) => {
357
+ // Convert camelCase to kebab-case (dataRange → data-range)
358
+ // Also handle already-kebab-case (data-range stays data-range)
359
+ const kebabAttr = attr.replace(/([A-Z])/g, '-$1').toLowerCase();
360
+ // Escape value for CSS selector (handle special characters)
361
+ const escapedValue = String(value).replace(/"/g, '\\"');
362
+ selector += `[${kebabAttr}="${escapedValue}"]`;
363
+ });
364
+ const foundByData = this.container.querySelector(selector);
365
+ if (foundByData) {
366
+ // Verify tag name matches
367
+ if (!vnode.type || foundByData.tagName.toLowerCase() === vnode.type.toLowerCase()) {
368
+ // CRITICAL: Verify the element is actually in the container
369
+ // This ensures we're pointing to the cloned element, not the original
370
+ if (!this.container.contains(foundByData)) {
371
+ // Element found but not in container - skip this match
372
+ return;
373
+ }
374
+ // Check if this element is already assigned to another ref (prevent duplicates)
375
+ const isAlreadyAssigned = Array.from(refsMap.keys()).some(otherRef => otherRef !== ref && otherRef.instance === foundByData);
376
+ if (!isAlreadyAssigned) {
377
+ ref.instance = foundByData;
378
+ return; // Success - move to next ref
379
+ }
380
+ }
381
+ }
382
+ }
383
+ }
384
+ // Strategy 2: Match by class (for elements without IDs)
385
+ // This is less reliable since classes aren't unique, but works for most test cases
386
+ if (vnode.props?.class) {
387
+ let classNames = [];
388
+ if (typeof vnode.props.class === 'string') {
389
+ // Split by spaces and filter empty strings
390
+ classNames = vnode.props.class.split(/\s+/).filter((c) => c.length > 0);
391
+ }
392
+ else if (typeof vnode.props.class === 'object' && vnode.props.class !== null) {
393
+ // Handle class object: { 'class-name': true, 'other-class': false }
394
+ classNames = Object.keys(vnode.props.class).filter(k => vnode.props.class[k]);
395
+ }
396
+ if (classNames.length > 0) {
397
+ // Use the first class for selector (most specific)
398
+ const primaryClass = classNames[0];
399
+ // Build selector with tag name if available (more specific = better match)
400
+ const selector = vnode.type
401
+ ? `${vnode.type}.${primaryClass}`
402
+ : `.${primaryClass}`;
403
+ // Get all matches, not just the first one
404
+ const allMatches = Array.from(this.container.querySelectorAll(selector));
405
+ // Find first match that hasn't been assigned to another ref
406
+ const found = allMatches.find(el => {
407
+ // Verify tag name matches if specified
408
+ if (vnode.type && el.tagName.toLowerCase() !== vnode.type.toLowerCase()) {
409
+ return false;
410
+ }
411
+ // Additional verification: check if all classes match (if multiple classes)
412
+ if (classNames.length > 1 && !classNames.every(cn => el.classList.contains(cn))) {
413
+ return false;
414
+ }
415
+ // Check if this element is already assigned to another ref (prevent duplicates)
416
+ const isAlreadyAssigned = Array.from(refsMap.keys()).some(otherRef => otherRef !== ref && otherRef.instance === el);
417
+ return !isAlreadyAssigned;
418
+ });
419
+ if (found) {
420
+ // CRITICAL: Verify the element is actually in the container
421
+ // This ensures we're pointing to the cloned element, not the original
422
+ if (this.container.contains(found)) {
423
+ ref.instance = found;
424
+ return;
425
+ }
426
+ }
427
+ }
428
+ }
429
+ // If neither ID nor class matching worked, Phase 2 (reconcileRefs) will handle it
430
+ // This ensures we don't leave any refs unset
431
+ });
432
+ // Phase 2: Recursive VNode-to-DOM matching for any remaining cases
433
+ // Create a WeakSet to track matched nodes and prevent duplicate assignments
434
+ const matchedDomNodes = new WeakSet();
435
+ reconcileRefs(vnode, element, this.container, matchedDomNodes);
436
+ // Call onAfterRender AFTER the VNode is in the DOM and refs are populated AND reconciled
437
+ // This matches the MSFS SDK lifecycle - onAfterRender is called after render is complete
438
+ // Now when onAfterRender runs, all refs (including child refs) point to the correct DOM nodes!
439
+ if (component.onAfterRender) {
440
+ component.onAfterRender(vnode);
441
+ }
442
+ return { component, element, vnode };
443
+ }
444
+ /**
445
+ * Query selector within container
446
+ */
447
+ querySelector(selector) {
448
+ return this.container.querySelector(selector);
449
+ }
450
+ /**
451
+ * Query selector all within container
452
+ */
453
+ querySelectorAll(selector) {
454
+ return this.container.querySelectorAll(selector);
455
+ }
456
+ /**
457
+ * Get container element
458
+ */
459
+ getContainer() {
460
+ return this.container;
461
+ }
462
+ /**
463
+ * Clean up - remove container from DOM
464
+ */
465
+ cleanup() {
466
+ if (this.container.parentNode) {
467
+ this.container.parentNode.removeChild(this.container);
468
+ }
469
+ }
470
+ /**
471
+ * Wait for async updates (useful for Subject subscriptions)
472
+ */
473
+ async waitForUpdate(ms = 10) {
474
+ return new Promise(resolve => setTimeout(resolve, ms));
475
+ }
476
+ /**
477
+ * Get text content of element
478
+ */
479
+ getTextContent(selector) {
480
+ const element = this.querySelector(selector);
481
+ return element ? element.textContent : null;
482
+ }
483
+ /**
484
+ * Check if element has class
485
+ */
486
+ hasClass(selector, className) {
487
+ const element = this.querySelector(selector);
488
+ return element ? element.classList.contains(className) : false;
489
+ }
490
+ /**
491
+ * Get attribute value
492
+ */
493
+ getAttribute(selector, attrName) {
494
+ const element = this.querySelector(selector);
495
+ return element ? element.getAttribute(attrName) : null;
496
+ }
497
+ /**
498
+ * Get computed style property
499
+ */
500
+ getStyle(selector, property) {
501
+ const element = this.querySelector(selector);
502
+ if (!element)
503
+ return '';
504
+ const style = this.env.getWindow().getComputedStyle(element);
505
+ return style.getPropertyValue(property);
506
+ }
507
+ /**
508
+ * Query SVG element by selector
509
+ */
510
+ querySelectorSVG(selector) {
511
+ const element = this.querySelector(selector);
512
+ // Check if it's an SVG element - use namespace or tagName check
513
+ if (!element)
514
+ return null;
515
+ if (element instanceof SVGElement)
516
+ return element;
517
+ // Fallback: check if namespaceURI is SVG namespace
518
+ if (element.namespaceURI === 'http://www.w3.org/2000/svg') {
519
+ return element;
520
+ }
521
+ return null;
522
+ }
523
+ /**
524
+ * Query all SVG elements by selector
525
+ */
526
+ querySelectorAllSVG(selector) {
527
+ const elements = this.querySelectorAll(selector);
528
+ // Filter to only SVG elements - check namespace or instanceof
529
+ const svgElements = [];
530
+ elements.forEach(el => {
531
+ if (el instanceof SVGElement) {
532
+ svgElements.push(el);
533
+ }
534
+ else if (el.namespaceURI === 'http://www.w3.org/2000/svg') {
535
+ // In jsdom, SVG elements have the correct namespace but may not be instanceof SVGElement
536
+ svgElements.push(el);
537
+ }
538
+ });
539
+ return svgElements;
540
+ }
541
+ /**
542
+ * Get SVG attribute value
543
+ */
544
+ getSVGAttribute(selector, attrName) {
545
+ const element = this.querySelectorSVG(selector);
546
+ return element ? element.getAttribute(attrName) : null;
547
+ }
548
+ /**
549
+ * Check if SVG element exists and has specific attribute value
550
+ */
551
+ hasSVGAttribute(selector, attrName, value) {
552
+ const element = this.querySelectorSVG(selector);
553
+ if (!element)
554
+ return false;
555
+ return element.getAttribute(attrName) === value;
556
+ }
557
+ }
558
+ exports.ComponentTestHelper = ComponentTestHelper;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Helper utilities for testing observables and reactive components.
3
+ */
4
+ import { Subscribable, Subject } from '../mocks/SDKAdapter';
5
+ /**
6
+ * Helper for testing observables in components
7
+ */
8
+ export declare class ObservableTestHelper {
9
+ /**
10
+ * Wait for observable to emit a value
11
+ */
12
+ static waitForValue<T>(observable: Subscribable<T>, predicate?: (value: T) => boolean, timeout?: number): Promise<T>;
13
+ /**
14
+ * Collect all values emitted by an observable
15
+ */
16
+ static collectValues<T>(observable: Subscribable<T>, count: number, timeout?: number): Promise<T[]>;
17
+ /**
18
+ * Create a test subject with initial value
19
+ */
20
+ static createTestSubject<T>(initialValue: T): Subject<T>;
21
+ /**
22
+ * Simulate observable updates over time
23
+ */
24
+ static simulateUpdates<T>(subject: Subject<T>, values: T[], interval?: number): Promise<void>;
25
+ }
@@ -0,0 +1,75 @@
1
+ "use strict";
2
+ /**
3
+ * Helper utilities for testing observables and reactive components.
4
+ */
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.ObservableTestHelper = void 0;
7
+ // Import from our SDK adapter instead of real SDK
8
+ const SDKAdapter_1 = require("../mocks/SDKAdapter");
9
+ /**
10
+ * Helper for testing observables in components
11
+ */
12
+ class ObservableTestHelper {
13
+ /**
14
+ * Wait for observable to emit a value
15
+ */
16
+ static async waitForValue(observable, predicate, timeout = 1000) {
17
+ return new Promise((resolve, reject) => {
18
+ let subscription = null;
19
+ subscription = observable.sub((value) => {
20
+ if (!predicate || predicate(value)) {
21
+ if (subscription) {
22
+ subscription.destroy();
23
+ }
24
+ resolve(value);
25
+ }
26
+ }, true);
27
+ setTimeout(() => {
28
+ if (subscription) {
29
+ subscription.destroy();
30
+ }
31
+ reject(new Error(`Timeout waiting for observable value (${timeout}ms)`));
32
+ }, timeout);
33
+ });
34
+ }
35
+ /**
36
+ * Collect all values emitted by an observable
37
+ */
38
+ static collectValues(observable, count, timeout = 1000) {
39
+ return new Promise((resolve, reject) => {
40
+ const values = [];
41
+ const subscription = observable.sub((value) => {
42
+ values.push(value);
43
+ if (values.length >= count) {
44
+ subscription.destroy();
45
+ resolve(values);
46
+ }
47
+ }, true);
48
+ setTimeout(() => {
49
+ subscription.destroy();
50
+ if (values.length > 0) {
51
+ resolve(values);
52
+ }
53
+ else {
54
+ reject(new Error(`Timeout collecting observable values (${timeout}ms)`));
55
+ }
56
+ }, timeout);
57
+ });
58
+ }
59
+ /**
60
+ * Create a test subject with initial value
61
+ */
62
+ static createTestSubject(initialValue) {
63
+ return SDKAdapter_1.Subject.create(initialValue);
64
+ }
65
+ /**
66
+ * Simulate observable updates over time
67
+ */
68
+ static async simulateUpdates(subject, values, interval = 50) {
69
+ for (const value of values) {
70
+ await new Promise(resolve => setTimeout(resolve, interval));
71
+ subject.set(value);
72
+ }
73
+ }
74
+ }
75
+ exports.ObservableTestHelper = ObservableTestHelper;