@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.
- package/LICENSE +22 -0
- package/README.md +170 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +24 -0
- package/dist/mocks/CoherentMock.d.ts +74 -0
- package/dist/mocks/CoherentMock.js +148 -0
- package/dist/mocks/GarminSDKAdapter.d.ts +79 -0
- package/dist/mocks/GarminSDKAdapter.js +127 -0
- package/dist/mocks/MSFSGlobals.d.ts +6 -0
- package/dist/mocks/MSFSGlobals.js +514 -0
- package/dist/mocks/SDKAdapter.d.ts +126 -0
- package/dist/mocks/SDKAdapter.js +696 -0
- package/dist/mocks/SDKClasses.d.ts +32 -0
- package/dist/mocks/SDKClasses.js +118 -0
- package/dist/mocks/SimVarMock.d.ts +97 -0
- package/dist/mocks/SimVarMock.js +215 -0
- package/dist/mocks/index.d.ts +9 -0
- package/dist/mocks/index.js +25 -0
- package/dist/setupTests.d.ts +13 -0
- package/dist/setupTests.js +109 -0
- package/dist/test-utils/ComponentTestHelper.d.ts +73 -0
- package/dist/test-utils/ComponentTestHelper.js +558 -0
- package/dist/test-utils/ObservableTestHelper.d.ts +25 -0
- package/dist/test-utils/ObservableTestHelper.js +75 -0
- package/dist/test-utils/TestEnvironment.d.ts +76 -0
- package/dist/test-utils/TestEnvironment.js +227 -0
- package/dist/test-utils/index.d.ts +6 -0
- package/dist/test-utils/index.js +22 -0
- package/package.json +64 -0
|
@@ -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;
|