@react-three-dom/playwright 0.1.1 → 0.2.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/dist/index.cjs +1302 -147
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +587 -73
- package/dist/index.d.ts +587 -73
- package/dist/index.js +1295 -148
- package/dist/index.js.map +1 -1
- package/package.json +11 -9
package/dist/index.cjs
CHANGED
|
@@ -5,6 +5,48 @@ var test$1 = require('@playwright/test');
|
|
|
5
5
|
// src/fixtures.ts
|
|
6
6
|
|
|
7
7
|
// src/waiters.ts
|
|
8
|
+
async function waitForReadyBridge(page, timeout) {
|
|
9
|
+
const deadline = Date.now() + timeout;
|
|
10
|
+
const pollMs = 100;
|
|
11
|
+
while (Date.now() < deadline) {
|
|
12
|
+
const state = await page.evaluate(() => {
|
|
13
|
+
const api = window.__R3F_DOM__;
|
|
14
|
+
if (!api) return { exists: false };
|
|
15
|
+
return {
|
|
16
|
+
exists: true,
|
|
17
|
+
ready: api._ready,
|
|
18
|
+
error: api._error ?? null,
|
|
19
|
+
count: api.getCount()
|
|
20
|
+
};
|
|
21
|
+
});
|
|
22
|
+
if (state.exists && state.ready) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (state.exists && !state.ready && state.error) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
`[react-three-dom] Bridge initialization failed: ${state.error}
|
|
28
|
+
The <ThreeDom> component mounted but threw during setup. Check the browser console for the full stack trace.`
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
await page.waitForTimeout(pollMs);
|
|
32
|
+
}
|
|
33
|
+
const finalState = await page.evaluate(() => {
|
|
34
|
+
const api = window.__R3F_DOM__;
|
|
35
|
+
if (!api) return { exists: false, ready: false, error: null };
|
|
36
|
+
return { exists: true, ready: api._ready, error: api._error ?? null };
|
|
37
|
+
});
|
|
38
|
+
if (finalState.exists && finalState.error) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
`[react-three-dom] Bridge initialization failed: ${finalState.error}
|
|
41
|
+
The <ThreeDom> component mounted but threw during setup.`
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
throw new Error(
|
|
45
|
+
`[react-three-dom] Timed out after ${timeout}ms waiting for the bridge to be ready.
|
|
46
|
+
Bridge exists: ${finalState.exists}, ready: ${finalState.ready}.
|
|
47
|
+
Ensure <ThreeDom> is mounted inside your <Canvas> component.`
|
|
48
|
+
);
|
|
49
|
+
}
|
|
8
50
|
async function waitForSceneReady(page, options = {}) {
|
|
9
51
|
const {
|
|
10
52
|
stableChecks = 3,
|
|
@@ -12,9 +54,7 @@ async function waitForSceneReady(page, options = {}) {
|
|
|
12
54
|
timeout = 1e4
|
|
13
55
|
} = options;
|
|
14
56
|
const deadline = Date.now() + timeout;
|
|
15
|
-
await page
|
|
16
|
-
timeout
|
|
17
|
-
});
|
|
57
|
+
await waitForReadyBridge(page, timeout);
|
|
18
58
|
let lastCount = -1;
|
|
19
59
|
let stableRuns = 0;
|
|
20
60
|
while (Date.now() < deadline) {
|
|
@@ -32,11 +72,46 @@ async function waitForSceneReady(page, options = {}) {
|
|
|
32
72
|
`waitForSceneReady timed out after ${timeout}ms. Last count: ${lastCount}, stable runs: ${stableRuns}/${stableChecks}`
|
|
33
73
|
);
|
|
34
74
|
}
|
|
75
|
+
async function waitForObject(page, idOrUuid, options = {}) {
|
|
76
|
+
const {
|
|
77
|
+
bridgeTimeout = 3e4,
|
|
78
|
+
objectTimeout = 4e4,
|
|
79
|
+
pollIntervalMs = 200
|
|
80
|
+
} = options;
|
|
81
|
+
await waitForReadyBridge(page, bridgeTimeout);
|
|
82
|
+
const deadline = Date.now() + objectTimeout;
|
|
83
|
+
while (Date.now() < deadline) {
|
|
84
|
+
const found = await page.evaluate(
|
|
85
|
+
(id) => {
|
|
86
|
+
const api = window.__R3F_DOM__;
|
|
87
|
+
if (!api || !api._ready) return false;
|
|
88
|
+
return (api.getByTestId(id) ?? api.getByUuid(id)) !== null;
|
|
89
|
+
},
|
|
90
|
+
idOrUuid
|
|
91
|
+
);
|
|
92
|
+
if (found) return;
|
|
93
|
+
await page.waitForTimeout(pollIntervalMs);
|
|
94
|
+
}
|
|
95
|
+
const diagnostics = await page.evaluate(() => {
|
|
96
|
+
const api = window.__R3F_DOM__;
|
|
97
|
+
if (!api) return { bridgeExists: false, ready: false, count: 0, error: null };
|
|
98
|
+
return {
|
|
99
|
+
bridgeExists: true,
|
|
100
|
+
ready: api._ready,
|
|
101
|
+
count: api.getCount(),
|
|
102
|
+
error: api._error ?? null
|
|
103
|
+
};
|
|
104
|
+
});
|
|
105
|
+
throw new Error(
|
|
106
|
+
`waitForObject("${idOrUuid}") timed out after ${objectTimeout}ms. Bridge: ${diagnostics.bridgeExists ? "exists" : "missing"}, ready: ${diagnostics.ready}, objectCount: ${diagnostics.count}` + (diagnostics.error ? `, error: ${diagnostics.error}` : "") + `. Is the object rendered with userData.testId="${idOrUuid}" or uuid="${idOrUuid}"?`
|
|
107
|
+
);
|
|
108
|
+
}
|
|
35
109
|
async function waitForIdle(page, options = {}) {
|
|
36
110
|
const {
|
|
37
111
|
idleFrames = 10,
|
|
38
112
|
timeout = 1e4
|
|
39
113
|
} = options;
|
|
114
|
+
await waitForReadyBridge(page, timeout);
|
|
40
115
|
const settled = await page.evaluate(
|
|
41
116
|
([frames, timeoutMs]) => {
|
|
42
117
|
return new Promise((resolve) => {
|
|
@@ -48,8 +123,17 @@ async function waitForIdle(page, options = {}) {
|
|
|
48
123
|
resolve(false);
|
|
49
124
|
return;
|
|
50
125
|
}
|
|
51
|
-
const
|
|
52
|
-
|
|
126
|
+
const api = window.__R3F_DOM__;
|
|
127
|
+
if (!api || !api._ready) {
|
|
128
|
+
if (api && api._error) {
|
|
129
|
+
resolve(`Bridge error: ${api._error}`);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
requestAnimationFrame(check);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const snap = api.snapshot();
|
|
136
|
+
const json = JSON.stringify(snap.tree);
|
|
53
137
|
if (json === lastJson && json !== "") {
|
|
54
138
|
stableCount++;
|
|
55
139
|
if (stableCount >= frames) {
|
|
@@ -67,76 +151,269 @@ async function waitForIdle(page, options = {}) {
|
|
|
67
151
|
},
|
|
68
152
|
[idleFrames, timeout]
|
|
69
153
|
);
|
|
154
|
+
if (typeof settled === "string") {
|
|
155
|
+
throw new Error(`waitForIdle failed: ${settled}`);
|
|
156
|
+
}
|
|
70
157
|
if (!settled) {
|
|
71
158
|
throw new Error(`waitForIdle timed out after ${timeout}ms`);
|
|
72
159
|
}
|
|
73
160
|
}
|
|
74
|
-
async function
|
|
75
|
-
|
|
161
|
+
async function waitForNewObject(page, options = {}) {
|
|
162
|
+
const {
|
|
163
|
+
type,
|
|
164
|
+
nameContains,
|
|
165
|
+
pollIntervalMs = 100,
|
|
166
|
+
timeout = 1e4
|
|
167
|
+
} = options;
|
|
168
|
+
const baselineUuids = await page.evaluate(() => {
|
|
76
169
|
const api = window.__R3F_DOM__;
|
|
77
|
-
if (!api)
|
|
78
|
-
api.
|
|
170
|
+
if (!api) return [];
|
|
171
|
+
const snap = api.snapshot();
|
|
172
|
+
const uuids = [];
|
|
173
|
+
function collect(node) {
|
|
174
|
+
uuids.push(node.uuid);
|
|
175
|
+
for (const child of node.children) {
|
|
176
|
+
collect(child);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
collect(snap.tree);
|
|
180
|
+
return uuids;
|
|
181
|
+
});
|
|
182
|
+
const deadline = Date.now() + timeout;
|
|
183
|
+
while (Date.now() < deadline) {
|
|
184
|
+
await page.waitForTimeout(pollIntervalMs);
|
|
185
|
+
const result = await page.evaluate(
|
|
186
|
+
([filterType, filterName, knownUuids]) => {
|
|
187
|
+
const api = window.__R3F_DOM__;
|
|
188
|
+
if (!api) return null;
|
|
189
|
+
const snap = api.snapshot();
|
|
190
|
+
const known = new Set(knownUuids);
|
|
191
|
+
const newObjects = [];
|
|
192
|
+
function scan(node) {
|
|
193
|
+
if (!known.has(node.uuid)) {
|
|
194
|
+
const typeMatch = !filterType || node.type === filterType;
|
|
195
|
+
const nameMatch = !filterName || node.name.includes(filterName);
|
|
196
|
+
if (typeMatch && nameMatch) {
|
|
197
|
+
newObjects.push({
|
|
198
|
+
uuid: node.uuid,
|
|
199
|
+
name: node.name,
|
|
200
|
+
type: node.type,
|
|
201
|
+
visible: node.visible,
|
|
202
|
+
testId: node.testId,
|
|
203
|
+
position: node.position,
|
|
204
|
+
rotation: node.rotation,
|
|
205
|
+
scale: node.scale
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
for (const child of node.children) {
|
|
210
|
+
scan(child);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
scan(snap.tree);
|
|
214
|
+
if (newObjects.length === 0) return null;
|
|
215
|
+
return {
|
|
216
|
+
newObjects,
|
|
217
|
+
newUuids: newObjects.map((o) => o.uuid),
|
|
218
|
+
count: newObjects.length
|
|
219
|
+
};
|
|
220
|
+
},
|
|
221
|
+
[type ?? null, nameContains ?? null, baselineUuids]
|
|
222
|
+
);
|
|
223
|
+
if (result) {
|
|
224
|
+
return result;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const filterDesc = [
|
|
228
|
+
type ? `type="${type}"` : null,
|
|
229
|
+
nameContains ? `nameContains="${nameContains}"` : null
|
|
230
|
+
].filter(Boolean).join(", ");
|
|
231
|
+
throw new Error(
|
|
232
|
+
`waitForNewObject timed out after ${timeout}ms. No new objects appeared${filterDesc ? ` matching ${filterDesc}` : ""}. Baseline had ${baselineUuids.length} objects.`
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// src/interactions.ts
|
|
237
|
+
var DEFAULT_AUTO_WAIT_TIMEOUT = 5e3;
|
|
238
|
+
var AUTO_WAIT_POLL_MS = 100;
|
|
239
|
+
async function autoWaitForObject(page, idOrUuid, timeout = DEFAULT_AUTO_WAIT_TIMEOUT) {
|
|
240
|
+
const deadline = Date.now() + timeout;
|
|
241
|
+
while (Date.now() < deadline) {
|
|
242
|
+
const state = await page.evaluate(
|
|
243
|
+
(id) => {
|
|
244
|
+
const api = window.__R3F_DOM__;
|
|
245
|
+
if (!api) return { bridge: "missing" };
|
|
246
|
+
if (!api._ready) {
|
|
247
|
+
return {
|
|
248
|
+
bridge: "not-ready",
|
|
249
|
+
error: api._error ?? null
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
const found = (api.getByTestId(id) ?? api.getByUuid(id)) !== null;
|
|
253
|
+
return { bridge: "ready", found };
|
|
254
|
+
},
|
|
255
|
+
idOrUuid
|
|
256
|
+
);
|
|
257
|
+
if (state.bridge === "ready" && state.found) {
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (state.bridge === "not-ready" && state.error) {
|
|
261
|
+
throw new Error(
|
|
262
|
+
`[react-three-dom] Bridge initialization failed: ${state.error}
|
|
263
|
+
Cannot perform interaction on "${idOrUuid}".`
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
await page.waitForTimeout(AUTO_WAIT_POLL_MS);
|
|
267
|
+
}
|
|
268
|
+
const finalState = await page.evaluate(
|
|
269
|
+
(id) => {
|
|
270
|
+
const api = window.__R3F_DOM__;
|
|
271
|
+
if (!api) return { bridge: false, ready: false, count: 0, error: null, found: false };
|
|
272
|
+
return {
|
|
273
|
+
bridge: true,
|
|
274
|
+
ready: api._ready,
|
|
275
|
+
count: api.getCount(),
|
|
276
|
+
error: api._error ?? null,
|
|
277
|
+
found: (api.getByTestId(id) ?? api.getByUuid(id)) !== null
|
|
278
|
+
};
|
|
279
|
+
},
|
|
280
|
+
idOrUuid
|
|
281
|
+
);
|
|
282
|
+
if (!finalState.bridge) {
|
|
283
|
+
throw new Error(
|
|
284
|
+
`[react-three-dom] Auto-wait timed out after ${timeout}ms: bridge not found.
|
|
285
|
+
Ensure <ThreeDom> is mounted inside your <Canvas> component.`
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
throw new Error(
|
|
289
|
+
`[react-three-dom] Auto-wait timed out after ${timeout}ms: object "${idOrUuid}" not found.
|
|
290
|
+
Bridge: ready=${finalState.ready}, objectCount=${finalState.count}` + (finalState.error ? `, error=${finalState.error}` : "") + `.
|
|
291
|
+
Ensure the object has userData.testId="${idOrUuid}" or uuid="${idOrUuid}".`
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
async function autoWaitForBridge(page, timeout = DEFAULT_AUTO_WAIT_TIMEOUT) {
|
|
295
|
+
const deadline = Date.now() + timeout;
|
|
296
|
+
while (Date.now() < deadline) {
|
|
297
|
+
const state = await page.evaluate(() => {
|
|
298
|
+
const api = window.__R3F_DOM__;
|
|
299
|
+
if (!api) return { exists: false };
|
|
300
|
+
return {
|
|
301
|
+
exists: true,
|
|
302
|
+
ready: api._ready,
|
|
303
|
+
error: api._error ?? null
|
|
304
|
+
};
|
|
305
|
+
});
|
|
306
|
+
if (state.exists && state.ready) return;
|
|
307
|
+
if (state.exists && !state.ready && state.error) {
|
|
308
|
+
throw new Error(
|
|
309
|
+
`[react-three-dom] Bridge initialization failed: ${state.error}`
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
await page.waitForTimeout(AUTO_WAIT_POLL_MS);
|
|
313
|
+
}
|
|
314
|
+
throw new Error(
|
|
315
|
+
`[react-three-dom] Auto-wait timed out after ${timeout}ms: bridge not ready.
|
|
316
|
+
Ensure <ThreeDom> is mounted inside your <Canvas> component.`
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
async function click(page, idOrUuid, timeout) {
|
|
320
|
+
await autoWaitForObject(page, idOrUuid, timeout);
|
|
321
|
+
await page.evaluate((id) => {
|
|
322
|
+
window.__R3F_DOM__.click(id);
|
|
79
323
|
}, idOrUuid);
|
|
80
324
|
}
|
|
81
|
-
async function doubleClick(page, idOrUuid) {
|
|
325
|
+
async function doubleClick(page, idOrUuid, timeout) {
|
|
326
|
+
await autoWaitForObject(page, idOrUuid, timeout);
|
|
82
327
|
await page.evaluate((id) => {
|
|
83
|
-
|
|
84
|
-
if (!api) throw new Error("react-three-dom bridge not found. Is <ThreeDom> mounted?");
|
|
85
|
-
api.doubleClick(id);
|
|
328
|
+
window.__R3F_DOM__.doubleClick(id);
|
|
86
329
|
}, idOrUuid);
|
|
87
330
|
}
|
|
88
|
-
async function contextMenu(page, idOrUuid) {
|
|
331
|
+
async function contextMenu(page, idOrUuid, timeout) {
|
|
332
|
+
await autoWaitForObject(page, idOrUuid, timeout);
|
|
89
333
|
await page.evaluate((id) => {
|
|
90
|
-
|
|
91
|
-
if (!api) throw new Error("react-three-dom bridge not found. Is <ThreeDom> mounted?");
|
|
92
|
-
api.contextMenu(id);
|
|
334
|
+
window.__R3F_DOM__.contextMenu(id);
|
|
93
335
|
}, idOrUuid);
|
|
94
336
|
}
|
|
95
|
-
async function hover(page, idOrUuid) {
|
|
337
|
+
async function hover(page, idOrUuid, timeout) {
|
|
338
|
+
await autoWaitForObject(page, idOrUuid, timeout);
|
|
96
339
|
await page.evaluate((id) => {
|
|
97
|
-
|
|
98
|
-
if (!api) throw new Error("react-three-dom bridge not found. Is <ThreeDom> mounted?");
|
|
99
|
-
api.hover(id);
|
|
340
|
+
window.__R3F_DOM__.hover(id);
|
|
100
341
|
}, idOrUuid);
|
|
101
342
|
}
|
|
102
|
-
async function drag(page, idOrUuid, delta) {
|
|
343
|
+
async function drag(page, idOrUuid, delta, timeout) {
|
|
344
|
+
await autoWaitForObject(page, idOrUuid, timeout);
|
|
103
345
|
await page.evaluate(
|
|
104
|
-
([id, d]) => {
|
|
105
|
-
|
|
106
|
-
if (!api) throw new Error("react-three-dom bridge not found. Is <ThreeDom> mounted?");
|
|
107
|
-
api.drag(id, d);
|
|
346
|
+
async ([id, d]) => {
|
|
347
|
+
await window.__R3F_DOM__.drag(id, d);
|
|
108
348
|
},
|
|
109
349
|
[idOrUuid, delta]
|
|
110
350
|
);
|
|
111
351
|
}
|
|
112
|
-
async function wheel(page, idOrUuid, options) {
|
|
352
|
+
async function wheel(page, idOrUuid, options, timeout) {
|
|
353
|
+
await autoWaitForObject(page, idOrUuid, timeout);
|
|
113
354
|
await page.evaluate(
|
|
114
355
|
([id, opts]) => {
|
|
115
|
-
|
|
116
|
-
if (!api) throw new Error("react-three-dom bridge not found. Is <ThreeDom> mounted?");
|
|
117
|
-
api.wheel(id, opts);
|
|
356
|
+
window.__R3F_DOM__.wheel(id, opts);
|
|
118
357
|
},
|
|
119
358
|
[idOrUuid, options]
|
|
120
359
|
);
|
|
121
360
|
}
|
|
122
|
-
async function pointerMiss(page) {
|
|
361
|
+
async function pointerMiss(page, timeout) {
|
|
362
|
+
await autoWaitForBridge(page, timeout);
|
|
123
363
|
await page.evaluate(() => {
|
|
124
|
-
|
|
125
|
-
if (!api) throw new Error("react-three-dom bridge not found. Is <ThreeDom> mounted?");
|
|
126
|
-
api.pointerMiss();
|
|
364
|
+
window.__R3F_DOM__.pointerMiss();
|
|
127
365
|
});
|
|
128
366
|
}
|
|
367
|
+
async function drawPathOnCanvas(page, points, options, timeout) {
|
|
368
|
+
await autoWaitForBridge(page, timeout);
|
|
369
|
+
return page.evaluate(
|
|
370
|
+
async ([pts, opts]) => {
|
|
371
|
+
return window.__R3F_DOM__.drawPath(pts, opts ?? void 0);
|
|
372
|
+
},
|
|
373
|
+
[points, options ?? null]
|
|
374
|
+
);
|
|
375
|
+
}
|
|
129
376
|
|
|
130
377
|
// src/fixtures.ts
|
|
131
378
|
var R3FFixture = class {
|
|
132
|
-
constructor(_page) {
|
|
379
|
+
constructor(_page, opts) {
|
|
133
380
|
this._page = _page;
|
|
381
|
+
this._debugListenerAttached = false;
|
|
382
|
+
if (opts?.debug) {
|
|
383
|
+
this._attachDebugListener();
|
|
384
|
+
}
|
|
134
385
|
}
|
|
135
386
|
/** The underlying Playwright Page. */
|
|
136
387
|
get page() {
|
|
137
388
|
return this._page;
|
|
138
389
|
}
|
|
139
390
|
// -----------------------------------------------------------------------
|
|
391
|
+
// Debug logging
|
|
392
|
+
// -----------------------------------------------------------------------
|
|
393
|
+
/**
|
|
394
|
+
* Enable debug logging. Turns on `window.__R3F_DOM_DEBUG__` in the browser
|
|
395
|
+
* and forwards all `[r3f-dom:*]` console messages to the Node.js test terminal.
|
|
396
|
+
*
|
|
397
|
+
* Call before `page.goto()` to capture setup logs, or after to capture
|
|
398
|
+
* interaction logs.
|
|
399
|
+
*/
|
|
400
|
+
async enableDebug() {
|
|
401
|
+
this._attachDebugListener();
|
|
402
|
+
await this._page.evaluate(() => {
|
|
403
|
+
window.__R3F_DOM_DEBUG__ = true;
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
_attachDebugListener() {
|
|
407
|
+
if (this._debugListenerAttached) return;
|
|
408
|
+
this._debugListenerAttached = true;
|
|
409
|
+
this._page.on("console", (msg) => {
|
|
410
|
+
const text = msg.text();
|
|
411
|
+
if (text.startsWith("[r3f-dom:")) {
|
|
412
|
+
console.log(text);
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
// -----------------------------------------------------------------------
|
|
140
417
|
// Queries
|
|
141
418
|
// -----------------------------------------------------------------------
|
|
142
419
|
/** Get object metadata by testId or uuid. Returns null if not found. */
|
|
@@ -169,36 +446,153 @@ var R3FFixture = class {
|
|
|
169
446
|
return api ? api.getCount() : 0;
|
|
170
447
|
});
|
|
171
448
|
}
|
|
449
|
+
/**
|
|
450
|
+
* Get all objects of a given Three.js type (e.g. "Mesh", "Group", "Line").
|
|
451
|
+
* Useful for BIM/CAD apps to find all walls, doors, etc. by object type.
|
|
452
|
+
*/
|
|
453
|
+
async getByType(type) {
|
|
454
|
+
return this._page.evaluate((t) => {
|
|
455
|
+
const api = window.__R3F_DOM__;
|
|
456
|
+
return api ? api.getByType(t) : [];
|
|
457
|
+
}, type);
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Get objects that have a specific userData key (and optionally matching value).
|
|
461
|
+
* Useful for BIM/CAD apps where objects are tagged with metadata like
|
|
462
|
+
* `userData.category = "wall"` or `userData.floorId = 2`.
|
|
463
|
+
*/
|
|
464
|
+
async getByUserData(key, value) {
|
|
465
|
+
return this._page.evaluate(({ k, v }) => {
|
|
466
|
+
const api = window.__R3F_DOM__;
|
|
467
|
+
return api ? api.getByUserData(k, v) : [];
|
|
468
|
+
}, { k: key, v: value });
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Count objects of a given Three.js type.
|
|
472
|
+
* More efficient than `getByType(type).then(arr => arr.length)`.
|
|
473
|
+
*/
|
|
474
|
+
async getCountByType(type) {
|
|
475
|
+
return this._page.evaluate((t) => {
|
|
476
|
+
const api = window.__R3F_DOM__;
|
|
477
|
+
return api ? api.getCountByType(t) : 0;
|
|
478
|
+
}, type);
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Batch lookup: get metadata for multiple objects by testId or uuid in a
|
|
482
|
+
* single browser round-trip. Returns a record from id to metadata (or null).
|
|
483
|
+
*
|
|
484
|
+
* Much more efficient than calling `getObject()` in a loop for BIM/CAD
|
|
485
|
+
* scenes with many objects.
|
|
486
|
+
*
|
|
487
|
+
* @example
|
|
488
|
+
* ```typescript
|
|
489
|
+
* const results = await r3f.getObjects(['wall-1', 'door-2', 'window-3']);
|
|
490
|
+
* expect(results['wall-1']).not.toBeNull();
|
|
491
|
+
* expect(results['door-2']?.type).toBe('Mesh');
|
|
492
|
+
* ```
|
|
493
|
+
*/
|
|
494
|
+
async getObjects(ids) {
|
|
495
|
+
return this._page.evaluate((idList) => {
|
|
496
|
+
const api = window.__R3F_DOM__;
|
|
497
|
+
if (!api) {
|
|
498
|
+
const result = {};
|
|
499
|
+
for (const id of idList) result[id] = null;
|
|
500
|
+
return result;
|
|
501
|
+
}
|
|
502
|
+
return api.getObjects(idList);
|
|
503
|
+
}, ids);
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Log the scene tree to the test terminal for debugging.
|
|
507
|
+
*
|
|
508
|
+
* Prints a human-readable tree like:
|
|
509
|
+
* ```
|
|
510
|
+
* Scene "root"
|
|
511
|
+
* ├─ Mesh "chair-primary" [testId: chair-primary] visible
|
|
512
|
+
* │ └─ BoxGeometry
|
|
513
|
+
* ├─ DirectionalLight "sun-light" [testId: sun-light] visible
|
|
514
|
+
* └─ Group "furniture"
|
|
515
|
+
* ├─ Mesh "table-top" [testId: table-top] visible
|
|
516
|
+
* └─ Mesh "vase" [testId: vase] visible
|
|
517
|
+
* ```
|
|
518
|
+
*/
|
|
519
|
+
async logScene() {
|
|
520
|
+
const snap = await this.snapshot();
|
|
521
|
+
if (!snap) {
|
|
522
|
+
console.log("[r3f-dom] logScene: no scene snapshot available (bridge not ready?)");
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
const lines = formatSceneTree(snap.tree);
|
|
526
|
+
console.log(`
|
|
527
|
+
[r3f-dom] Scene tree (${snap.objectCount} objects):
|
|
528
|
+
${lines}
|
|
529
|
+
`);
|
|
530
|
+
}
|
|
172
531
|
// -----------------------------------------------------------------------
|
|
173
532
|
// Interactions
|
|
174
533
|
// -----------------------------------------------------------------------
|
|
175
|
-
/**
|
|
176
|
-
|
|
177
|
-
|
|
534
|
+
/**
|
|
535
|
+
* Click a 3D object by testId or uuid.
|
|
536
|
+
* Auto-waits for the bridge and the object to exist before clicking.
|
|
537
|
+
* @param timeout Optional auto-wait timeout in ms. Default: 5000
|
|
538
|
+
*/
|
|
539
|
+
async click(idOrUuid, timeout) {
|
|
540
|
+
return click(this._page, idOrUuid, timeout);
|
|
178
541
|
}
|
|
179
|
-
/**
|
|
180
|
-
|
|
181
|
-
|
|
542
|
+
/**
|
|
543
|
+
* Double-click a 3D object by testId or uuid.
|
|
544
|
+
* Auto-waits for the object to exist.
|
|
545
|
+
*/
|
|
546
|
+
async doubleClick(idOrUuid, timeout) {
|
|
547
|
+
return doubleClick(this._page, idOrUuid, timeout);
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Right-click / context-menu a 3D object by testId or uuid.
|
|
551
|
+
* Auto-waits for the object to exist.
|
|
552
|
+
*/
|
|
553
|
+
async contextMenu(idOrUuid, timeout) {
|
|
554
|
+
return contextMenu(this._page, idOrUuid, timeout);
|
|
182
555
|
}
|
|
183
|
-
/**
|
|
184
|
-
|
|
185
|
-
|
|
556
|
+
/**
|
|
557
|
+
* Hover over a 3D object by testId or uuid.
|
|
558
|
+
* Auto-waits for the object to exist.
|
|
559
|
+
*/
|
|
560
|
+
async hover(idOrUuid, timeout) {
|
|
561
|
+
return hover(this._page, idOrUuid, timeout);
|
|
186
562
|
}
|
|
187
|
-
/**
|
|
188
|
-
|
|
189
|
-
|
|
563
|
+
/**
|
|
564
|
+
* Drag a 3D object with a world-space delta vector.
|
|
565
|
+
* Auto-waits for the object to exist.
|
|
566
|
+
*/
|
|
567
|
+
async drag(idOrUuid, delta, timeout) {
|
|
568
|
+
return drag(this._page, idOrUuid, delta, timeout);
|
|
190
569
|
}
|
|
191
|
-
/**
|
|
192
|
-
|
|
193
|
-
|
|
570
|
+
/**
|
|
571
|
+
* Dispatch a wheel/scroll event on a 3D object.
|
|
572
|
+
* Auto-waits for the object to exist.
|
|
573
|
+
*/
|
|
574
|
+
async wheel(idOrUuid, options, timeout) {
|
|
575
|
+
return wheel(this._page, idOrUuid, options, timeout);
|
|
194
576
|
}
|
|
195
|
-
/**
|
|
196
|
-
|
|
197
|
-
|
|
577
|
+
/**
|
|
578
|
+
* Click empty space to trigger onPointerMissed handlers.
|
|
579
|
+
* Auto-waits for the bridge to be ready.
|
|
580
|
+
*/
|
|
581
|
+
async pointerMiss(timeout) {
|
|
582
|
+
return pointerMiss(this._page, timeout);
|
|
198
583
|
}
|
|
199
|
-
/**
|
|
200
|
-
|
|
201
|
-
|
|
584
|
+
/**
|
|
585
|
+
* Draw a freeform path on the canvas. Dispatches pointerdown → N × pointermove → pointerup.
|
|
586
|
+
* Designed for canvas drawing/annotation/whiteboard apps.
|
|
587
|
+
* Auto-waits for the bridge to be ready.
|
|
588
|
+
*
|
|
589
|
+
* @param points Array of screen-space points (min 2). { x, y } in CSS pixels relative to canvas.
|
|
590
|
+
* @param options Drawing options (stepDelayMs, pointerType, clickAtEnd)
|
|
591
|
+
* @param timeout Optional auto-wait timeout in ms. Default: 5000
|
|
592
|
+
* @returns { eventCount, pointCount }
|
|
593
|
+
*/
|
|
594
|
+
async drawPath(points, options, timeout) {
|
|
595
|
+
return drawPathOnCanvas(this._page, points, options, timeout);
|
|
202
596
|
}
|
|
203
597
|
// -----------------------------------------------------------------------
|
|
204
598
|
// Waiters
|
|
@@ -210,6 +604,14 @@ var R3FFixture = class {
|
|
|
210
604
|
async waitForSceneReady(options) {
|
|
211
605
|
return waitForSceneReady(this._page, options);
|
|
212
606
|
}
|
|
607
|
+
/**
|
|
608
|
+
* Wait until the bridge is available and an object with the given testId or
|
|
609
|
+
* uuid exists. Use this instead of waitForSceneReady when the scene count
|
|
610
|
+
* never stabilizes (e.g. async model loading, continuous animations).
|
|
611
|
+
*/
|
|
612
|
+
async waitForObject(idOrUuid, options) {
|
|
613
|
+
return waitForObject(this._page, idOrUuid, options);
|
|
614
|
+
}
|
|
213
615
|
/**
|
|
214
616
|
* Wait until no object properties have changed for a number of consecutive
|
|
215
617
|
* animation frames. Useful after triggering interactions or animations.
|
|
@@ -217,6 +619,17 @@ var R3FFixture = class {
|
|
|
217
619
|
async waitForIdle(options) {
|
|
218
620
|
return waitForIdle(this._page, options);
|
|
219
621
|
}
|
|
622
|
+
/**
|
|
623
|
+
* Wait until one or more new objects appear in the scene that were not
|
|
624
|
+
* present when this call was made. Perfect for drawing apps where
|
|
625
|
+
* `drawPath()` creates new geometry asynchronously.
|
|
626
|
+
*
|
|
627
|
+
* @param options Filter by type, nameContains, timeout, pollInterval
|
|
628
|
+
* @returns Metadata of the newly added object(s)
|
|
629
|
+
*/
|
|
630
|
+
async waitForNewObject(options) {
|
|
631
|
+
return waitForNewObject(this._page, options);
|
|
632
|
+
}
|
|
220
633
|
// -----------------------------------------------------------------------
|
|
221
634
|
// Selection (for inspector integration)
|
|
222
635
|
// -----------------------------------------------------------------------
|
|
@@ -236,155 +649,897 @@ var R3FFixture = class {
|
|
|
236
649
|
});
|
|
237
650
|
}
|
|
238
651
|
};
|
|
652
|
+
function formatSceneTree(node, prefix = "", isLast = true) {
|
|
653
|
+
const connector = prefix === "" ? "" : isLast ? "\u2514\u2500 " : "\u251C\u2500 ";
|
|
654
|
+
const childPrefix = prefix === "" ? "" : prefix + (isLast ? " " : "\u2502 ");
|
|
655
|
+
let label = node.type;
|
|
656
|
+
if (node.name) label += ` "${node.name}"`;
|
|
657
|
+
if (node.testId) label += ` [testId: ${node.testId}]`;
|
|
658
|
+
label += node.visible ? " visible" : " HIDDEN";
|
|
659
|
+
let result = prefix + connector + label + "\n";
|
|
660
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
661
|
+
const child = node.children[i];
|
|
662
|
+
const last = i === node.children.length - 1;
|
|
663
|
+
result += formatSceneTree(child, childPrefix, last);
|
|
664
|
+
}
|
|
665
|
+
return result;
|
|
666
|
+
}
|
|
239
667
|
var test = test$1.test.extend({
|
|
240
668
|
r3f: async ({ page }, use) => {
|
|
241
669
|
const fixture = new R3FFixture(page);
|
|
242
670
|
await use(fixture);
|
|
243
671
|
}
|
|
244
672
|
});
|
|
673
|
+
function createR3FTest(options) {
|
|
674
|
+
return test$1.test.extend({
|
|
675
|
+
r3f: async ({ page }, use) => {
|
|
676
|
+
const fixture = new R3FFixture(page, options);
|
|
677
|
+
await use(fixture);
|
|
678
|
+
}
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
var DEFAULT_TIMEOUT = 5e3;
|
|
682
|
+
var DEFAULT_INTERVAL = 100;
|
|
683
|
+
async function fetchSceneCount(page) {
|
|
684
|
+
return page.evaluate(() => {
|
|
685
|
+
const api = window.__R3F_DOM__;
|
|
686
|
+
return api ? api.getCount() : 0;
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
async function fetchCountByType(page, type) {
|
|
690
|
+
return page.evaluate((t) => {
|
|
691
|
+
const api = window.__R3F_DOM__;
|
|
692
|
+
return api ? api.getCountByType(t) : 0;
|
|
693
|
+
}, type);
|
|
694
|
+
}
|
|
695
|
+
async function fetchTotalTriangles(page) {
|
|
696
|
+
return page.evaluate(() => {
|
|
697
|
+
const api = window.__R3F_DOM__;
|
|
698
|
+
if (!api) return 0;
|
|
699
|
+
const bridge = api;
|
|
700
|
+
const snap = bridge.snapshot();
|
|
701
|
+
let total = 0;
|
|
702
|
+
function walk(node) {
|
|
703
|
+
const meta = bridge.getByUuid(node.uuid);
|
|
704
|
+
if (meta && meta.triangleCount) total += meta.triangleCount;
|
|
705
|
+
for (const child of node.children) {
|
|
706
|
+
walk(child);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
walk(snap.tree);
|
|
710
|
+
return total;
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
async function fetchMeta(page, id) {
|
|
714
|
+
return page.evaluate((i) => {
|
|
715
|
+
const api = window.__R3F_DOM__;
|
|
716
|
+
if (!api) return null;
|
|
717
|
+
return api.getByTestId(i) ?? api.getByUuid(i) ?? null;
|
|
718
|
+
}, id);
|
|
719
|
+
}
|
|
720
|
+
async function fetchInsp(page, id) {
|
|
721
|
+
return page.evaluate((i) => {
|
|
722
|
+
const api = window.__R3F_DOM__;
|
|
723
|
+
if (!api) return null;
|
|
724
|
+
return api.inspect(i);
|
|
725
|
+
}, id);
|
|
726
|
+
}
|
|
727
|
+
function parseTol(v, def) {
|
|
728
|
+
const o = typeof v === "number" ? { tolerance: v } : v ?? {};
|
|
729
|
+
return {
|
|
730
|
+
timeout: o.timeout ?? DEFAULT_TIMEOUT,
|
|
731
|
+
interval: o.interval ?? DEFAULT_INTERVAL,
|
|
732
|
+
tolerance: o.tolerance ?? def
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
function notFound(name, id, detail, timeout) {
|
|
736
|
+
return {
|
|
737
|
+
pass: false,
|
|
738
|
+
message: () => `Expected object "${id}" ${detail}, but it was not found (waited ${timeout}ms)`,
|
|
739
|
+
name
|
|
740
|
+
};
|
|
741
|
+
}
|
|
245
742
|
var expect = test$1.expect.extend({
|
|
246
|
-
//
|
|
247
|
-
// toExist
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
const
|
|
743
|
+
// ========================= TIER 1 — Metadata ============================
|
|
744
|
+
// --- toExist ---
|
|
745
|
+
async toExist(r3f, id, opts) {
|
|
746
|
+
const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
|
|
747
|
+
const interval = opts?.interval ?? DEFAULT_INTERVAL;
|
|
748
|
+
const isNot = this.isNot;
|
|
749
|
+
let meta = null;
|
|
750
|
+
try {
|
|
751
|
+
await test$1.expect.poll(async () => {
|
|
752
|
+
meta = await fetchMeta(r3f.page, id);
|
|
753
|
+
return meta !== null;
|
|
754
|
+
}, { timeout, intervals: [interval] }).toBe(!isNot);
|
|
755
|
+
} catch {
|
|
756
|
+
}
|
|
251
757
|
const pass = meta !== null;
|
|
252
758
|
return {
|
|
253
759
|
pass,
|
|
254
|
-
message: () => pass ? `Expected object "${
|
|
760
|
+
message: () => pass ? `Expected object "${id}" to NOT exist, but it does` : `Expected object "${id}" to exist, but it was not found (waited ${timeout}ms)`,
|
|
255
761
|
name: "toExist",
|
|
256
|
-
expected:
|
|
762
|
+
expected: id,
|
|
257
763
|
actual: meta
|
|
258
764
|
};
|
|
259
765
|
},
|
|
260
|
-
//
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
};
|
|
766
|
+
// --- toBeVisible ---
|
|
767
|
+
async toBeVisible(r3f, id, opts) {
|
|
768
|
+
const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
|
|
769
|
+
const interval = opts?.interval ?? DEFAULT_INTERVAL;
|
|
770
|
+
const isNot = this.isNot;
|
|
771
|
+
let meta = null;
|
|
772
|
+
try {
|
|
773
|
+
await test$1.expect.poll(async () => {
|
|
774
|
+
meta = await fetchMeta(r3f.page, id);
|
|
775
|
+
return meta?.visible ?? false;
|
|
776
|
+
}, { timeout, intervals: [interval] }).toBe(!isNot);
|
|
777
|
+
} catch {
|
|
271
778
|
}
|
|
272
|
-
|
|
779
|
+
if (!meta) return notFound("toBeVisible", id, "to be visible", timeout);
|
|
780
|
+
const m = meta;
|
|
273
781
|
return {
|
|
274
|
-
pass,
|
|
275
|
-
message: () =>
|
|
782
|
+
pass: m.visible,
|
|
783
|
+
message: () => m.visible ? `Expected "${id}" to NOT be visible, but it is` : `Expected "${id}" to be visible, but visible=${m.visible} (waited ${timeout}ms)`,
|
|
276
784
|
name: "toBeVisible",
|
|
277
785
|
expected: true,
|
|
278
|
-
actual:
|
|
786
|
+
actual: m.visible
|
|
279
787
|
};
|
|
280
788
|
},
|
|
281
|
-
//
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
789
|
+
// --- toHavePosition ---
|
|
790
|
+
async toHavePosition(r3f, id, expected, tolOpts) {
|
|
791
|
+
const { timeout, interval, tolerance } = parseTol(tolOpts, 0.01);
|
|
792
|
+
const isNot = this.isNot;
|
|
793
|
+
let meta = null;
|
|
794
|
+
let pass = false;
|
|
795
|
+
let delta = [0, 0, 0];
|
|
796
|
+
try {
|
|
797
|
+
await test$1.expect.poll(async () => {
|
|
798
|
+
meta = await fetchMeta(r3f.page, id);
|
|
799
|
+
if (!meta) return false;
|
|
800
|
+
delta = [Math.abs(meta.position[0] - expected[0]), Math.abs(meta.position[1] - expected[1]), Math.abs(meta.position[2] - expected[2])];
|
|
801
|
+
pass = delta.every((d) => d <= tolerance);
|
|
802
|
+
return pass;
|
|
803
|
+
}, { timeout, intervals: [interval] }).toBe(!isNot);
|
|
804
|
+
} catch {
|
|
293
805
|
}
|
|
294
|
-
|
|
295
|
-
const
|
|
296
|
-
const pass = isFinite(bounds.min) && isFinite(bounds.max);
|
|
806
|
+
if (!meta) return notFound("toHavePosition", id, `to have position [${expected}]`, timeout);
|
|
807
|
+
const m = meta;
|
|
297
808
|
return {
|
|
298
809
|
pass,
|
|
299
|
-
message: () => pass ? `Expected
|
|
300
|
-
name: "
|
|
301
|
-
expected
|
|
302
|
-
actual:
|
|
810
|
+
message: () => pass ? `Expected "${id}" to NOT be at [${expected}] (\xB1${tolerance})` : `Expected "${id}" at [${expected}] (\xB1${tolerance}), got [${m.position}] (\u0394 [${delta.map((d) => d.toFixed(4))}]) (waited ${timeout}ms)`,
|
|
811
|
+
name: "toHavePosition",
|
|
812
|
+
expected,
|
|
813
|
+
actual: m.position
|
|
303
814
|
};
|
|
304
815
|
},
|
|
305
|
-
//
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
816
|
+
// --- toHaveRotation ---
|
|
817
|
+
async toHaveRotation(r3f, id, expected, tolOpts) {
|
|
818
|
+
const { timeout, interval, tolerance } = parseTol(tolOpts, 0.01);
|
|
819
|
+
const isNot = this.isNot;
|
|
820
|
+
let meta = null;
|
|
821
|
+
let pass = false;
|
|
822
|
+
let delta = [0, 0, 0];
|
|
823
|
+
try {
|
|
824
|
+
await test$1.expect.poll(async () => {
|
|
825
|
+
meta = await fetchMeta(r3f.page, id);
|
|
826
|
+
if (!meta) return false;
|
|
827
|
+
delta = [Math.abs(meta.rotation[0] - expected[0]), Math.abs(meta.rotation[1] - expected[1]), Math.abs(meta.rotation[2] - expected[2])];
|
|
828
|
+
pass = delta.every((d) => d <= tolerance);
|
|
829
|
+
return pass;
|
|
830
|
+
}, { timeout, intervals: [interval] }).toBe(!isNot);
|
|
831
|
+
} catch {
|
|
316
832
|
}
|
|
317
|
-
|
|
318
|
-
const
|
|
319
|
-
const dx = Math.abs(ax - ex);
|
|
320
|
-
const dy = Math.abs(ay - ey);
|
|
321
|
-
const dz = Math.abs(az - ez);
|
|
322
|
-
const pass = dx <= tolerance && dy <= tolerance && dz <= tolerance;
|
|
833
|
+
if (!meta) return notFound("toHaveRotation", id, `to have rotation [${expected}]`, timeout);
|
|
834
|
+
const m = meta;
|
|
323
835
|
return {
|
|
324
836
|
pass,
|
|
325
|
-
message: () => pass ? `Expected
|
|
326
|
-
name: "
|
|
837
|
+
message: () => pass ? `Expected "${id}" to NOT have rotation [${expected}] (\xB1${tolerance})` : `Expected "${id}" rotation [${expected}] (\xB1${tolerance}), got [${m.rotation}] (\u0394 [${delta.map((d) => d.toFixed(4))}]) (waited ${timeout}ms)`,
|
|
838
|
+
name: "toHaveRotation",
|
|
327
839
|
expected,
|
|
328
|
-
actual:
|
|
840
|
+
actual: m.rotation
|
|
329
841
|
};
|
|
330
842
|
},
|
|
331
|
-
//
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
843
|
+
// --- toHaveScale ---
|
|
844
|
+
async toHaveScale(r3f, id, expected, tolOpts) {
|
|
845
|
+
const { timeout, interval, tolerance } = parseTol(tolOpts, 0.01);
|
|
846
|
+
const isNot = this.isNot;
|
|
847
|
+
let meta = null;
|
|
848
|
+
let pass = false;
|
|
849
|
+
let delta = [0, 0, 0];
|
|
850
|
+
try {
|
|
851
|
+
await test$1.expect.poll(async () => {
|
|
852
|
+
meta = await fetchMeta(r3f.page, id);
|
|
853
|
+
if (!meta) return false;
|
|
854
|
+
delta = [Math.abs(meta.scale[0] - expected[0]), Math.abs(meta.scale[1] - expected[1]), Math.abs(meta.scale[2] - expected[2])];
|
|
855
|
+
pass = delta.every((d) => d <= tolerance);
|
|
856
|
+
return pass;
|
|
857
|
+
}, { timeout, intervals: [interval] }).toBe(!isNot);
|
|
858
|
+
} catch {
|
|
342
859
|
}
|
|
343
|
-
|
|
344
|
-
const
|
|
345
|
-
const pass = withinTolerance(bounds.min, expected.min) && withinTolerance(bounds.max, expected.max);
|
|
860
|
+
if (!meta) return notFound("toHaveScale", id, `to have scale [${expected}]`, timeout);
|
|
861
|
+
const m = meta;
|
|
346
862
|
return {
|
|
347
863
|
pass,
|
|
348
|
-
message: () => pass ? `Expected
|
|
349
|
-
name: "
|
|
864
|
+
message: () => pass ? `Expected "${id}" to NOT have scale [${expected}] (\xB1${tolerance})` : `Expected "${id}" scale [${expected}] (\xB1${tolerance}), got [${m.scale}] (\u0394 [${delta.map((d) => d.toFixed(4))}]) (waited ${timeout}ms)`,
|
|
865
|
+
name: "toHaveScale",
|
|
350
866
|
expected,
|
|
351
|
-
actual:
|
|
867
|
+
actual: m.scale
|
|
352
868
|
};
|
|
353
869
|
},
|
|
354
|
-
//
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
const
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
870
|
+
// --- toHaveType ---
|
|
871
|
+
async toHaveType(r3f, id, expectedType, opts) {
|
|
872
|
+
const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
|
|
873
|
+
const interval = opts?.interval ?? DEFAULT_INTERVAL;
|
|
874
|
+
const isNot = this.isNot;
|
|
875
|
+
let meta = null;
|
|
876
|
+
let pass = false;
|
|
877
|
+
try {
|
|
878
|
+
await test$1.expect.poll(async () => {
|
|
879
|
+
meta = await fetchMeta(r3f.page, id);
|
|
880
|
+
if (!meta) return false;
|
|
881
|
+
pass = meta.type === expectedType;
|
|
882
|
+
return pass;
|
|
883
|
+
}, { timeout, intervals: [interval] }).toBe(!isNot);
|
|
884
|
+
} catch {
|
|
885
|
+
}
|
|
886
|
+
if (!meta) return notFound("toHaveType", id, `to have type "${expectedType}"`, timeout);
|
|
887
|
+
const m = meta;
|
|
888
|
+
return {
|
|
889
|
+
pass,
|
|
890
|
+
message: () => pass ? `Expected "${id}" to NOT have type "${expectedType}"` : `Expected "${id}" type "${expectedType}", got "${m.type}" (waited ${timeout}ms)`,
|
|
891
|
+
name: "toHaveType",
|
|
892
|
+
expected: expectedType,
|
|
893
|
+
actual: m.type
|
|
894
|
+
};
|
|
895
|
+
},
|
|
896
|
+
// --- toHaveName ---
|
|
897
|
+
async toHaveName(r3f, id, expectedName, opts) {
|
|
898
|
+
const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
|
|
899
|
+
const interval = opts?.interval ?? DEFAULT_INTERVAL;
|
|
900
|
+
const isNot = this.isNot;
|
|
901
|
+
let meta = null;
|
|
902
|
+
let pass = false;
|
|
903
|
+
try {
|
|
904
|
+
await test$1.expect.poll(async () => {
|
|
905
|
+
meta = await fetchMeta(r3f.page, id);
|
|
906
|
+
if (!meta) return false;
|
|
907
|
+
pass = meta.name === expectedName;
|
|
908
|
+
return pass;
|
|
909
|
+
}, { timeout, intervals: [interval] }).toBe(!isNot);
|
|
910
|
+
} catch {
|
|
911
|
+
}
|
|
912
|
+
if (!meta) return notFound("toHaveName", id, `to have name "${expectedName}"`, timeout);
|
|
913
|
+
const m = meta;
|
|
914
|
+
return {
|
|
915
|
+
pass,
|
|
916
|
+
message: () => pass ? `Expected "${id}" to NOT have name "${expectedName}"` : `Expected "${id}" name "${expectedName}", got "${m.name}" (waited ${timeout}ms)`,
|
|
917
|
+
name: "toHaveName",
|
|
918
|
+
expected: expectedName,
|
|
919
|
+
actual: m.name
|
|
920
|
+
};
|
|
921
|
+
},
|
|
922
|
+
// --- toHaveGeometryType ---
|
|
923
|
+
async toHaveGeometryType(r3f, id, expectedGeo, opts) {
|
|
924
|
+
const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
|
|
925
|
+
const interval = opts?.interval ?? DEFAULT_INTERVAL;
|
|
926
|
+
const isNot = this.isNot;
|
|
927
|
+
let meta = null;
|
|
928
|
+
let pass = false;
|
|
929
|
+
try {
|
|
930
|
+
await test$1.expect.poll(async () => {
|
|
931
|
+
meta = await fetchMeta(r3f.page, id);
|
|
932
|
+
if (!meta) return false;
|
|
933
|
+
pass = meta.geometryType === expectedGeo;
|
|
934
|
+
return pass;
|
|
935
|
+
}, { timeout, intervals: [interval] }).toBe(!isNot);
|
|
936
|
+
} catch {
|
|
937
|
+
}
|
|
938
|
+
if (!meta) return notFound("toHaveGeometryType", id, `to have geometry "${expectedGeo}"`, timeout);
|
|
939
|
+
const m = meta;
|
|
940
|
+
return {
|
|
941
|
+
pass,
|
|
942
|
+
message: () => pass ? `Expected "${id}" to NOT have geometry "${expectedGeo}"` : `Expected "${id}" geometry "${expectedGeo}", got "${m.geometryType ?? "none"}" (waited ${timeout}ms)`,
|
|
943
|
+
name: "toHaveGeometryType",
|
|
944
|
+
expected: expectedGeo,
|
|
945
|
+
actual: m.geometryType
|
|
946
|
+
};
|
|
947
|
+
},
|
|
948
|
+
// --- toHaveMaterialType ---
|
|
949
|
+
async toHaveMaterialType(r3f, id, expectedMat, opts) {
|
|
950
|
+
const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
|
|
951
|
+
const interval = opts?.interval ?? DEFAULT_INTERVAL;
|
|
952
|
+
const isNot = this.isNot;
|
|
953
|
+
let meta = null;
|
|
954
|
+
let pass = false;
|
|
955
|
+
try {
|
|
956
|
+
await test$1.expect.poll(async () => {
|
|
957
|
+
meta = await fetchMeta(r3f.page, id);
|
|
958
|
+
if (!meta) return false;
|
|
959
|
+
pass = meta.materialType === expectedMat;
|
|
960
|
+
return pass;
|
|
961
|
+
}, { timeout, intervals: [interval] }).toBe(!isNot);
|
|
962
|
+
} catch {
|
|
963
|
+
}
|
|
964
|
+
if (!meta) return notFound("toHaveMaterialType", id, `to have material "${expectedMat}"`, timeout);
|
|
965
|
+
const m = meta;
|
|
966
|
+
return {
|
|
967
|
+
pass,
|
|
968
|
+
message: () => pass ? `Expected "${id}" to NOT have material "${expectedMat}"` : `Expected "${id}" material "${expectedMat}", got "${m.materialType ?? "none"}" (waited ${timeout}ms)`,
|
|
969
|
+
name: "toHaveMaterialType",
|
|
970
|
+
expected: expectedMat,
|
|
971
|
+
actual: m.materialType
|
|
972
|
+
};
|
|
973
|
+
},
|
|
974
|
+
// --- toHaveChildCount ---
|
|
975
|
+
async toHaveChildCount(r3f, id, expectedCount, opts) {
|
|
976
|
+
const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
|
|
977
|
+
const interval = opts?.interval ?? DEFAULT_INTERVAL;
|
|
978
|
+
const isNot = this.isNot;
|
|
979
|
+
let meta = null;
|
|
980
|
+
let actual = 0;
|
|
981
|
+
try {
|
|
982
|
+
await test$1.expect.poll(async () => {
|
|
983
|
+
meta = await fetchMeta(r3f.page, id);
|
|
984
|
+
if (!meta) return false;
|
|
985
|
+
actual = meta.childrenUuids.length;
|
|
986
|
+
return actual === expectedCount;
|
|
987
|
+
}, { timeout, intervals: [interval] }).toBe(!isNot);
|
|
988
|
+
} catch {
|
|
365
989
|
}
|
|
366
|
-
|
|
990
|
+
if (!meta) return notFound("toHaveChildCount", id, `to have ${expectedCount} children`, timeout);
|
|
367
991
|
const pass = actual === expectedCount;
|
|
368
992
|
return {
|
|
369
993
|
pass,
|
|
370
|
-
message: () => pass ? `Expected
|
|
994
|
+
message: () => pass ? `Expected "${id}" to NOT have ${expectedCount} children` : `Expected "${id}" ${expectedCount} children, got ${actual} (waited ${timeout}ms)`,
|
|
995
|
+
name: "toHaveChildCount",
|
|
996
|
+
expected: expectedCount,
|
|
997
|
+
actual
|
|
998
|
+
};
|
|
999
|
+
},
|
|
1000
|
+
// --- toHaveParent ---
|
|
1001
|
+
async toHaveParent(r3f, id, expectedParent, opts) {
|
|
1002
|
+
const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
|
|
1003
|
+
const interval = opts?.interval ?? DEFAULT_INTERVAL;
|
|
1004
|
+
const isNot = this.isNot;
|
|
1005
|
+
let meta = null;
|
|
1006
|
+
let parentMeta = null;
|
|
1007
|
+
let pass = false;
|
|
1008
|
+
try {
|
|
1009
|
+
await test$1.expect.poll(async () => {
|
|
1010
|
+
meta = await fetchMeta(r3f.page, id);
|
|
1011
|
+
if (!meta?.parentUuid) return false;
|
|
1012
|
+
parentMeta = await fetchMeta(r3f.page, meta.parentUuid);
|
|
1013
|
+
if (!parentMeta) return false;
|
|
1014
|
+
pass = parentMeta.uuid === expectedParent || parentMeta.testId === expectedParent || parentMeta.name === expectedParent;
|
|
1015
|
+
return pass;
|
|
1016
|
+
}, { timeout, intervals: [interval] }).toBe(!isNot);
|
|
1017
|
+
} catch {
|
|
1018
|
+
}
|
|
1019
|
+
if (!meta) return notFound("toHaveParent", id, `to have parent "${expectedParent}"`, timeout);
|
|
1020
|
+
const m = meta;
|
|
1021
|
+
const pm = parentMeta;
|
|
1022
|
+
const parentLabel = pm?.testId ?? pm?.name ?? m.parentUuid;
|
|
1023
|
+
return {
|
|
1024
|
+
pass,
|
|
1025
|
+
message: () => pass ? `Expected "${id}" to NOT have parent "${expectedParent}"` : `Expected "${id}" parent "${expectedParent}", got "${parentLabel}" (waited ${timeout}ms)`,
|
|
1026
|
+
name: "toHaveParent",
|
|
1027
|
+
expected: expectedParent,
|
|
1028
|
+
actual: parentLabel
|
|
1029
|
+
};
|
|
1030
|
+
},
|
|
1031
|
+
// --- toHaveInstanceCount ---
|
|
1032
|
+
async toHaveInstanceCount(r3f, id, expectedCount, opts) {
|
|
1033
|
+
const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
|
|
1034
|
+
const interval = opts?.interval ?? DEFAULT_INTERVAL;
|
|
1035
|
+
const isNot = this.isNot;
|
|
1036
|
+
let meta = null;
|
|
1037
|
+
let actual = 0;
|
|
1038
|
+
try {
|
|
1039
|
+
await test$1.expect.poll(async () => {
|
|
1040
|
+
meta = await fetchMeta(r3f.page, id);
|
|
1041
|
+
actual = meta?.instanceCount ?? 0;
|
|
1042
|
+
return actual === expectedCount;
|
|
1043
|
+
}, { timeout, intervals: [interval] }).toBe(!isNot);
|
|
1044
|
+
} catch {
|
|
1045
|
+
}
|
|
1046
|
+
if (!meta) return notFound("toHaveInstanceCount", id, `to have instance count ${expectedCount}`, timeout);
|
|
1047
|
+
const pass = actual === expectedCount;
|
|
1048
|
+
return {
|
|
1049
|
+
pass,
|
|
1050
|
+
message: () => pass ? `Expected "${id}" to NOT have instance count ${expectedCount}` : `Expected "${id}" instance count ${expectedCount}, got ${actual} (waited ${timeout}ms)`,
|
|
371
1051
|
name: "toHaveInstanceCount",
|
|
372
1052
|
expected: expectedCount,
|
|
373
1053
|
actual
|
|
374
1054
|
};
|
|
1055
|
+
},
|
|
1056
|
+
// ========================= TIER 2 — Inspection ==========================
|
|
1057
|
+
// --- toBeInFrustum ---
|
|
1058
|
+
async toBeInFrustum(r3f, id, opts) {
|
|
1059
|
+
const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
|
|
1060
|
+
const interval = opts?.interval ?? DEFAULT_INTERVAL;
|
|
1061
|
+
const isNot = this.isNot;
|
|
1062
|
+
let insp = null;
|
|
1063
|
+
let pass = false;
|
|
1064
|
+
try {
|
|
1065
|
+
await test$1.expect.poll(async () => {
|
|
1066
|
+
insp = await fetchInsp(r3f.page, id);
|
|
1067
|
+
if (!insp) return false;
|
|
1068
|
+
const fin = (v) => v.every(Number.isFinite);
|
|
1069
|
+
pass = fin(insp.bounds.min) && fin(insp.bounds.max);
|
|
1070
|
+
return pass;
|
|
1071
|
+
}, { timeout, intervals: [interval] }).toBe(!isNot);
|
|
1072
|
+
} catch {
|
|
1073
|
+
}
|
|
1074
|
+
if (!insp) return notFound("toBeInFrustum", id, "to be in frustum", timeout);
|
|
1075
|
+
const i = insp;
|
|
1076
|
+
return {
|
|
1077
|
+
pass,
|
|
1078
|
+
message: () => pass ? `Expected "${id}" to NOT be in the camera frustum` : `Expected "${id}" in frustum, but bounds are invalid (waited ${timeout}ms)`,
|
|
1079
|
+
name: "toBeInFrustum",
|
|
1080
|
+
expected: "finite bounds",
|
|
1081
|
+
actual: i.bounds
|
|
1082
|
+
};
|
|
1083
|
+
},
|
|
1084
|
+
// --- toHaveBounds ---
|
|
1085
|
+
async toHaveBounds(r3f, id, expected, tolOpts) {
|
|
1086
|
+
const { timeout, interval, tolerance } = parseTol(tolOpts, 0.1);
|
|
1087
|
+
const isNot = this.isNot;
|
|
1088
|
+
let insp = null;
|
|
1089
|
+
let pass = false;
|
|
1090
|
+
try {
|
|
1091
|
+
await test$1.expect.poll(async () => {
|
|
1092
|
+
insp = await fetchInsp(r3f.page, id);
|
|
1093
|
+
if (!insp) return false;
|
|
1094
|
+
const w = (a, b) => a.every((v, j) => Math.abs(v - b[j]) <= tolerance);
|
|
1095
|
+
pass = w(insp.bounds.min, expected.min) && w(insp.bounds.max, expected.max);
|
|
1096
|
+
return pass;
|
|
1097
|
+
}, { timeout, intervals: [interval] }).toBe(!isNot);
|
|
1098
|
+
} catch {
|
|
1099
|
+
}
|
|
1100
|
+
if (!insp) return notFound("toHaveBounds", id, "to have specific bounds", timeout);
|
|
1101
|
+
const i = insp;
|
|
1102
|
+
return {
|
|
1103
|
+
pass,
|
|
1104
|
+
message: () => pass ? `Expected "${id}" to NOT have bounds min:${JSON.stringify(expected.min)} max:${JSON.stringify(expected.max)}` : `Expected "${id}" bounds min:${JSON.stringify(expected.min)} max:${JSON.stringify(expected.max)}, got min:${JSON.stringify(i.bounds.min)} max:${JSON.stringify(i.bounds.max)} (waited ${timeout}ms)`,
|
|
1105
|
+
name: "toHaveBounds",
|
|
1106
|
+
expected,
|
|
1107
|
+
actual: i.bounds
|
|
1108
|
+
};
|
|
1109
|
+
},
|
|
1110
|
+
// --- toHaveColor ---
|
|
1111
|
+
async toHaveColor(r3f, id, expectedColor, opts) {
|
|
1112
|
+
const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
|
|
1113
|
+
const interval = opts?.interval ?? DEFAULT_INTERVAL;
|
|
1114
|
+
const isNot = this.isNot;
|
|
1115
|
+
const norm = expectedColor.startsWith("#") ? expectedColor.toLowerCase() : `#${expectedColor.toLowerCase()}`;
|
|
1116
|
+
let insp = null;
|
|
1117
|
+
let actual;
|
|
1118
|
+
let pass = false;
|
|
1119
|
+
try {
|
|
1120
|
+
await test$1.expect.poll(async () => {
|
|
1121
|
+
insp = await fetchInsp(r3f.page, id);
|
|
1122
|
+
if (!insp?.material?.color) return false;
|
|
1123
|
+
actual = insp.material.color.toLowerCase();
|
|
1124
|
+
pass = actual === norm;
|
|
1125
|
+
return pass;
|
|
1126
|
+
}, { timeout, intervals: [interval] }).toBe(!isNot);
|
|
1127
|
+
} catch {
|
|
1128
|
+
}
|
|
1129
|
+
if (!insp) return notFound("toHaveColor", id, `to have color "${norm}"`, timeout);
|
|
1130
|
+
return {
|
|
1131
|
+
pass,
|
|
1132
|
+
message: () => pass ? `Expected "${id}" to NOT have color "${norm}"` : `Expected "${id}" color "${norm}", got "${actual ?? "no color"}" (waited ${timeout}ms)`,
|
|
1133
|
+
name: "toHaveColor",
|
|
1134
|
+
expected: norm,
|
|
1135
|
+
actual
|
|
1136
|
+
};
|
|
1137
|
+
},
|
|
1138
|
+
// --- toHaveOpacity ---
|
|
1139
|
+
async toHaveOpacity(r3f, id, expectedOpacity, tolOpts) {
|
|
1140
|
+
const { timeout, interval, tolerance } = parseTol(tolOpts, 0.01);
|
|
1141
|
+
const isNot = this.isNot;
|
|
1142
|
+
let insp = null;
|
|
1143
|
+
let actual;
|
|
1144
|
+
let pass = false;
|
|
1145
|
+
try {
|
|
1146
|
+
await test$1.expect.poll(async () => {
|
|
1147
|
+
insp = await fetchInsp(r3f.page, id);
|
|
1148
|
+
if (!insp?.material) return false;
|
|
1149
|
+
actual = insp.material.opacity;
|
|
1150
|
+
pass = actual !== void 0 && Math.abs(actual - expectedOpacity) <= tolerance;
|
|
1151
|
+
return pass;
|
|
1152
|
+
}, { timeout, intervals: [interval] }).toBe(!isNot);
|
|
1153
|
+
} catch {
|
|
1154
|
+
}
|
|
1155
|
+
if (!insp) return notFound("toHaveOpacity", id, `to have opacity ${expectedOpacity}`, timeout);
|
|
1156
|
+
return {
|
|
1157
|
+
pass,
|
|
1158
|
+
message: () => pass ? `Expected "${id}" to NOT have opacity ${expectedOpacity} (\xB1${tolerance})` : `Expected "${id}" opacity ${expectedOpacity} (\xB1${tolerance}), got ${actual ?? "no material"} (waited ${timeout}ms)`,
|
|
1159
|
+
name: "toHaveOpacity",
|
|
1160
|
+
expected: expectedOpacity,
|
|
1161
|
+
actual
|
|
1162
|
+
};
|
|
1163
|
+
},
|
|
1164
|
+
// --- toBeTransparent ---
|
|
1165
|
+
async toBeTransparent(r3f, id, opts) {
|
|
1166
|
+
const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
|
|
1167
|
+
const interval = opts?.interval ?? DEFAULT_INTERVAL;
|
|
1168
|
+
const isNot = this.isNot;
|
|
1169
|
+
let insp = null;
|
|
1170
|
+
let pass = false;
|
|
1171
|
+
try {
|
|
1172
|
+
await test$1.expect.poll(async () => {
|
|
1173
|
+
insp = await fetchInsp(r3f.page, id);
|
|
1174
|
+
if (!insp?.material) return false;
|
|
1175
|
+
pass = insp.material.transparent === true;
|
|
1176
|
+
return pass;
|
|
1177
|
+
}, { timeout, intervals: [interval] }).toBe(!isNot);
|
|
1178
|
+
} catch {
|
|
1179
|
+
}
|
|
1180
|
+
if (!insp) return notFound("toBeTransparent", id, "to be transparent", timeout);
|
|
1181
|
+
const i = insp;
|
|
1182
|
+
return {
|
|
1183
|
+
pass,
|
|
1184
|
+
message: () => pass ? `Expected "${id}" to NOT be transparent` : `Expected "${id}" transparent=true, got ${i.material?.transparent ?? "no material"} (waited ${timeout}ms)`,
|
|
1185
|
+
name: "toBeTransparent",
|
|
1186
|
+
expected: true,
|
|
1187
|
+
actual: i.material?.transparent
|
|
1188
|
+
};
|
|
1189
|
+
},
|
|
1190
|
+
// --- toHaveVertexCount ---
|
|
1191
|
+
async toHaveVertexCount(r3f, id, expectedCount, opts) {
|
|
1192
|
+
const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
|
|
1193
|
+
const interval = opts?.interval ?? DEFAULT_INTERVAL;
|
|
1194
|
+
const isNot = this.isNot;
|
|
1195
|
+
let meta = null;
|
|
1196
|
+
let actual = 0;
|
|
1197
|
+
try {
|
|
1198
|
+
await test$1.expect.poll(async () => {
|
|
1199
|
+
meta = await fetchMeta(r3f.page, id);
|
|
1200
|
+
actual = meta?.vertexCount ?? 0;
|
|
1201
|
+
return actual === expectedCount;
|
|
1202
|
+
}, { timeout, intervals: [interval] }).toBe(!isNot);
|
|
1203
|
+
} catch {
|
|
1204
|
+
}
|
|
1205
|
+
if (!meta) return notFound("toHaveVertexCount", id, `to have ${expectedCount} vertices`, timeout);
|
|
1206
|
+
const pass = actual === expectedCount;
|
|
1207
|
+
return {
|
|
1208
|
+
pass,
|
|
1209
|
+
message: () => pass ? `Expected "${id}" to NOT have ${expectedCount} vertices` : `Expected "${id}" ${expectedCount} vertices, got ${actual} (waited ${timeout}ms)`,
|
|
1210
|
+
name: "toHaveVertexCount",
|
|
1211
|
+
expected: expectedCount,
|
|
1212
|
+
actual
|
|
1213
|
+
};
|
|
1214
|
+
},
|
|
1215
|
+
// --- toHaveTriangleCount ---
|
|
1216
|
+
async toHaveTriangleCount(r3f, id, expectedCount, opts) {
|
|
1217
|
+
const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
|
|
1218
|
+
const interval = opts?.interval ?? DEFAULT_INTERVAL;
|
|
1219
|
+
const isNot = this.isNot;
|
|
1220
|
+
let meta = null;
|
|
1221
|
+
let actual = 0;
|
|
1222
|
+
try {
|
|
1223
|
+
await test$1.expect.poll(async () => {
|
|
1224
|
+
meta = await fetchMeta(r3f.page, id);
|
|
1225
|
+
actual = meta?.triangleCount ?? 0;
|
|
1226
|
+
return actual === expectedCount;
|
|
1227
|
+
}, { timeout, intervals: [interval] }).toBe(!isNot);
|
|
1228
|
+
} catch {
|
|
1229
|
+
}
|
|
1230
|
+
if (!meta) return notFound("toHaveTriangleCount", id, `to have ${expectedCount} triangles`, timeout);
|
|
1231
|
+
const pass = actual === expectedCount;
|
|
1232
|
+
return {
|
|
1233
|
+
pass,
|
|
1234
|
+
message: () => pass ? `Expected "${id}" to NOT have ${expectedCount} triangles` : `Expected "${id}" ${expectedCount} triangles, got ${actual} (waited ${timeout}ms)`,
|
|
1235
|
+
name: "toHaveTriangleCount",
|
|
1236
|
+
expected: expectedCount,
|
|
1237
|
+
actual
|
|
1238
|
+
};
|
|
1239
|
+
},
|
|
1240
|
+
// --- toHaveUserData ---
|
|
1241
|
+
async toHaveUserData(r3f, id, key, expectedValue, opts) {
|
|
1242
|
+
const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
|
|
1243
|
+
const interval = opts?.interval ?? DEFAULT_INTERVAL;
|
|
1244
|
+
const isNot = this.isNot;
|
|
1245
|
+
let insp = null;
|
|
1246
|
+
let actual;
|
|
1247
|
+
let pass = false;
|
|
1248
|
+
try {
|
|
1249
|
+
await test$1.expect.poll(async () => {
|
|
1250
|
+
insp = await fetchInsp(r3f.page, id);
|
|
1251
|
+
if (!insp) return false;
|
|
1252
|
+
if (!(key in insp.userData)) return false;
|
|
1253
|
+
if (expectedValue === void 0) {
|
|
1254
|
+
pass = true;
|
|
1255
|
+
return true;
|
|
1256
|
+
}
|
|
1257
|
+
actual = insp.userData[key];
|
|
1258
|
+
pass = JSON.stringify(actual) === JSON.stringify(expectedValue);
|
|
1259
|
+
return pass;
|
|
1260
|
+
}, { timeout, intervals: [interval] }).toBe(!isNot);
|
|
1261
|
+
} catch {
|
|
1262
|
+
}
|
|
1263
|
+
if (!insp) return notFound("toHaveUserData", id, `to have userData.${key}`, timeout);
|
|
1264
|
+
return {
|
|
1265
|
+
pass,
|
|
1266
|
+
message: () => {
|
|
1267
|
+
if (expectedValue === void 0) {
|
|
1268
|
+
return pass ? `Expected "${id}" to NOT have userData key "${key}"` : `Expected "${id}" to have userData key "${key}", but missing (waited ${timeout}ms)`;
|
|
1269
|
+
}
|
|
1270
|
+
return pass ? `Expected "${id}" to NOT have userData.${key} = ${JSON.stringify(expectedValue)}` : `Expected "${id}" userData.${key} = ${JSON.stringify(expectedValue)}, got ${JSON.stringify(actual)} (waited ${timeout}ms)`;
|
|
1271
|
+
},
|
|
1272
|
+
name: "toHaveUserData",
|
|
1273
|
+
expected: expectedValue ?? `key "${key}"`,
|
|
1274
|
+
actual
|
|
1275
|
+
};
|
|
1276
|
+
},
|
|
1277
|
+
// --- toHaveMapTexture ---
|
|
1278
|
+
async toHaveMapTexture(r3f, id, expectedName, opts) {
|
|
1279
|
+
const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
|
|
1280
|
+
const interval = opts?.interval ?? DEFAULT_INTERVAL;
|
|
1281
|
+
const isNot = this.isNot;
|
|
1282
|
+
let insp = null;
|
|
1283
|
+
let actual;
|
|
1284
|
+
let pass = false;
|
|
1285
|
+
try {
|
|
1286
|
+
await test$1.expect.poll(async () => {
|
|
1287
|
+
insp = await fetchInsp(r3f.page, id);
|
|
1288
|
+
if (!insp?.material?.map) return false;
|
|
1289
|
+
actual = insp.material.map;
|
|
1290
|
+
if (!expectedName) {
|
|
1291
|
+
pass = true;
|
|
1292
|
+
return true;
|
|
1293
|
+
}
|
|
1294
|
+
pass = actual === expectedName;
|
|
1295
|
+
return pass;
|
|
1296
|
+
}, { timeout, intervals: [interval] }).toBe(!isNot);
|
|
1297
|
+
} catch {
|
|
1298
|
+
}
|
|
1299
|
+
if (!insp) return notFound("toHaveMapTexture", id, "to have a map texture", timeout);
|
|
1300
|
+
return {
|
|
1301
|
+
pass,
|
|
1302
|
+
message: () => {
|
|
1303
|
+
if (!expectedName) {
|
|
1304
|
+
return pass ? `Expected "${id}" to NOT have a map texture` : `Expected "${id}" to have a map texture, but none found (waited ${timeout}ms)`;
|
|
1305
|
+
}
|
|
1306
|
+
return pass ? `Expected "${id}" to NOT have map "${expectedName}"` : `Expected "${id}" map "${expectedName}", got "${actual ?? "none"}" (waited ${timeout}ms)`;
|
|
1307
|
+
},
|
|
1308
|
+
name: "toHaveMapTexture",
|
|
1309
|
+
expected: expectedName ?? "any map",
|
|
1310
|
+
actual
|
|
1311
|
+
};
|
|
1312
|
+
},
|
|
1313
|
+
// =========================================================================
|
|
1314
|
+
// Scene-level matchers (no object ID — operate on the whole scene)
|
|
1315
|
+
// =========================================================================
|
|
1316
|
+
/**
|
|
1317
|
+
* Assert the total number of objects in the scene.
|
|
1318
|
+
* Auto-retries until the count matches or timeout.
|
|
1319
|
+
*
|
|
1320
|
+
* @example expect(r3f).toHaveObjectCount(42);
|
|
1321
|
+
* @example expect(r3f).toHaveObjectCount(42, { timeout: 10_000 });
|
|
1322
|
+
*/
|
|
1323
|
+
async toHaveObjectCount(r3f, expected, options) {
|
|
1324
|
+
const isNot = this.isNot;
|
|
1325
|
+
const timeout = options?.timeout ?? DEFAULT_TIMEOUT;
|
|
1326
|
+
const interval = options?.interval ?? DEFAULT_INTERVAL;
|
|
1327
|
+
let actual = -1;
|
|
1328
|
+
let pass = false;
|
|
1329
|
+
try {
|
|
1330
|
+
await test$1.expect.poll(async () => {
|
|
1331
|
+
actual = await fetchSceneCount(r3f.page);
|
|
1332
|
+
pass = actual === expected;
|
|
1333
|
+
return pass;
|
|
1334
|
+
}, { timeout, intervals: [interval] }).toBe(!isNot);
|
|
1335
|
+
} catch {
|
|
1336
|
+
}
|
|
1337
|
+
return {
|
|
1338
|
+
pass,
|
|
1339
|
+
message: () => pass ? `Expected scene to NOT have ${expected} objects, but it does` : `Expected scene to have ${expected} objects, got ${actual} (waited ${timeout}ms)`,
|
|
1340
|
+
name: "toHaveObjectCount",
|
|
1341
|
+
expected,
|
|
1342
|
+
actual
|
|
1343
|
+
};
|
|
1344
|
+
},
|
|
1345
|
+
/**
|
|
1346
|
+
* Assert the total number of objects is at least `min`.
|
|
1347
|
+
* Useful for BIM scenes where the exact count may vary slightly.
|
|
1348
|
+
*
|
|
1349
|
+
* @example expect(r3f).toHaveObjectCountGreaterThan(10);
|
|
1350
|
+
*/
|
|
1351
|
+
async toHaveObjectCountGreaterThan(r3f, min, options) {
|
|
1352
|
+
const isNot = this.isNot;
|
|
1353
|
+
const timeout = options?.timeout ?? DEFAULT_TIMEOUT;
|
|
1354
|
+
const interval = options?.interval ?? DEFAULT_INTERVAL;
|
|
1355
|
+
let actual = -1;
|
|
1356
|
+
let pass = false;
|
|
1357
|
+
try {
|
|
1358
|
+
await test$1.expect.poll(async () => {
|
|
1359
|
+
actual = await fetchSceneCount(r3f.page);
|
|
1360
|
+
pass = actual > min;
|
|
1361
|
+
return pass;
|
|
1362
|
+
}, { timeout, intervals: [interval] }).toBe(!isNot);
|
|
1363
|
+
} catch {
|
|
1364
|
+
}
|
|
1365
|
+
return {
|
|
1366
|
+
pass,
|
|
1367
|
+
message: () => pass ? `Expected scene to have at most ${min} objects, but has ${actual}` : `Expected scene to have more than ${min} objects, got ${actual} (waited ${timeout}ms)`,
|
|
1368
|
+
name: "toHaveObjectCountGreaterThan",
|
|
1369
|
+
expected: `> ${min}`,
|
|
1370
|
+
actual
|
|
1371
|
+
};
|
|
1372
|
+
},
|
|
1373
|
+
/**
|
|
1374
|
+
* Assert the count of objects of a specific Three.js type.
|
|
1375
|
+
* Auto-retries until the count matches or timeout.
|
|
1376
|
+
*
|
|
1377
|
+
* @example expect(r3f).toHaveCountByType('Mesh', 5);
|
|
1378
|
+
* @example expect(r3f).toHaveCountByType('Line', 10, { timeout: 10_000 });
|
|
1379
|
+
*/
|
|
1380
|
+
async toHaveCountByType(r3f, type, expected, options) {
|
|
1381
|
+
const isNot = this.isNot;
|
|
1382
|
+
const timeout = options?.timeout ?? DEFAULT_TIMEOUT;
|
|
1383
|
+
const interval = options?.interval ?? DEFAULT_INTERVAL;
|
|
1384
|
+
let actual = -1;
|
|
1385
|
+
let pass = false;
|
|
1386
|
+
try {
|
|
1387
|
+
await test$1.expect.poll(async () => {
|
|
1388
|
+
actual = await fetchCountByType(r3f.page, type);
|
|
1389
|
+
pass = actual === expected;
|
|
1390
|
+
return pass;
|
|
1391
|
+
}, { timeout, intervals: [interval] }).toBe(!isNot);
|
|
1392
|
+
} catch {
|
|
1393
|
+
}
|
|
1394
|
+
return {
|
|
1395
|
+
pass,
|
|
1396
|
+
message: () => pass ? `Expected scene to NOT have ${expected} "${type}" objects, but it does` : `Expected ${expected} "${type}" objects, got ${actual} (waited ${timeout}ms)`,
|
|
1397
|
+
name: "toHaveCountByType",
|
|
1398
|
+
expected,
|
|
1399
|
+
actual
|
|
1400
|
+
};
|
|
1401
|
+
},
|
|
1402
|
+
/**
|
|
1403
|
+
* Assert the total triangle count across all meshes in the scene.
|
|
1404
|
+
* Use as a performance budget guard — fail if the scene exceeds a threshold.
|
|
1405
|
+
*
|
|
1406
|
+
* @example expect(r3f).toHaveTotalTriangleCount(50000);
|
|
1407
|
+
* @example expect(r3f).not.toHaveTotalTriangleCountGreaterThan(100000); // budget guard
|
|
1408
|
+
*/
|
|
1409
|
+
async toHaveTotalTriangleCount(r3f, expected, options) {
|
|
1410
|
+
const isNot = this.isNot;
|
|
1411
|
+
const timeout = options?.timeout ?? DEFAULT_TIMEOUT;
|
|
1412
|
+
const interval = options?.interval ?? DEFAULT_INTERVAL;
|
|
1413
|
+
let actual = -1;
|
|
1414
|
+
let pass = false;
|
|
1415
|
+
try {
|
|
1416
|
+
await test$1.expect.poll(async () => {
|
|
1417
|
+
actual = await fetchTotalTriangles(r3f.page);
|
|
1418
|
+
pass = actual === expected;
|
|
1419
|
+
return pass;
|
|
1420
|
+
}, { timeout, intervals: [interval] }).toBe(!isNot);
|
|
1421
|
+
} catch {
|
|
1422
|
+
}
|
|
1423
|
+
return {
|
|
1424
|
+
pass,
|
|
1425
|
+
message: () => pass ? `Expected scene to NOT have ${expected} total triangles, but it does` : `Expected ${expected} total triangles, got ${actual} (waited ${timeout}ms)`,
|
|
1426
|
+
name: "toHaveTotalTriangleCount",
|
|
1427
|
+
expected,
|
|
1428
|
+
actual
|
|
1429
|
+
};
|
|
1430
|
+
},
|
|
1431
|
+
/**
|
|
1432
|
+
* Assert the total triangle count is at most `max`.
|
|
1433
|
+
* Perfect as a performance budget guard to prevent scene bloat.
|
|
1434
|
+
*
|
|
1435
|
+
* @example expect(r3f).toHaveTotalTriangleCountLessThan(100_000);
|
|
1436
|
+
*/
|
|
1437
|
+
async toHaveTotalTriangleCountLessThan(r3f, max, options) {
|
|
1438
|
+
const isNot = this.isNot;
|
|
1439
|
+
const timeout = options?.timeout ?? DEFAULT_TIMEOUT;
|
|
1440
|
+
const interval = options?.interval ?? DEFAULT_INTERVAL;
|
|
1441
|
+
let actual = -1;
|
|
1442
|
+
let pass = false;
|
|
1443
|
+
try {
|
|
1444
|
+
await test$1.expect.poll(async () => {
|
|
1445
|
+
actual = await fetchTotalTriangles(r3f.page);
|
|
1446
|
+
pass = actual < max;
|
|
1447
|
+
return pass;
|
|
1448
|
+
}, { timeout, intervals: [interval] }).toBe(!isNot);
|
|
1449
|
+
} catch {
|
|
1450
|
+
}
|
|
1451
|
+
return {
|
|
1452
|
+
pass,
|
|
1453
|
+
message: () => pass ? `Expected scene to have at least ${max} triangles, but has ${actual}` : `Expected scene to have fewer than ${max} triangles, got ${actual} (waited ${timeout}ms)`,
|
|
1454
|
+
name: "toHaveTotalTriangleCountLessThan",
|
|
1455
|
+
expected: `< ${max}`,
|
|
1456
|
+
actual
|
|
1457
|
+
};
|
|
375
1458
|
}
|
|
376
1459
|
});
|
|
377
1460
|
|
|
1461
|
+
// src/pathGenerators.ts
|
|
1462
|
+
function linePath(start, end, steps = 10, pressure = 0.5) {
|
|
1463
|
+
const points = [];
|
|
1464
|
+
const totalSteps = steps + 1;
|
|
1465
|
+
for (let i = 0; i <= totalSteps; i++) {
|
|
1466
|
+
const t = i / totalSteps;
|
|
1467
|
+
points.push({
|
|
1468
|
+
x: start.x + (end.x - start.x) * t,
|
|
1469
|
+
y: start.y + (end.y - start.y) * t,
|
|
1470
|
+
pressure
|
|
1471
|
+
});
|
|
1472
|
+
}
|
|
1473
|
+
return points;
|
|
1474
|
+
}
|
|
1475
|
+
function curvePath(start, control, end, steps = 20, pressure = 0.5) {
|
|
1476
|
+
const points = [];
|
|
1477
|
+
for (let i = 0; i <= steps; i++) {
|
|
1478
|
+
const t = i / steps;
|
|
1479
|
+
const invT = 1 - t;
|
|
1480
|
+
points.push({
|
|
1481
|
+
x: invT * invT * start.x + 2 * invT * t * control.x + t * t * end.x,
|
|
1482
|
+
y: invT * invT * start.y + 2 * invT * t * control.y + t * t * end.y,
|
|
1483
|
+
pressure
|
|
1484
|
+
});
|
|
1485
|
+
}
|
|
1486
|
+
return points;
|
|
1487
|
+
}
|
|
1488
|
+
function rectPath(topLeft, bottomRight, pointsPerSide = 5, pressure = 0.5) {
|
|
1489
|
+
const topRight = { x: bottomRight.x, y: topLeft.y };
|
|
1490
|
+
const bottomLeft = { x: topLeft.x, y: bottomRight.y };
|
|
1491
|
+
const sides = [
|
|
1492
|
+
[topLeft, topRight],
|
|
1493
|
+
[topRight, bottomRight],
|
|
1494
|
+
[bottomRight, bottomLeft],
|
|
1495
|
+
[bottomLeft, topLeft]
|
|
1496
|
+
];
|
|
1497
|
+
const points = [];
|
|
1498
|
+
for (const [from, to] of sides) {
|
|
1499
|
+
for (let i = 0; i < pointsPerSide; i++) {
|
|
1500
|
+
const t = i / pointsPerSide;
|
|
1501
|
+
points.push({
|
|
1502
|
+
x: from.x + (to.x - from.x) * t,
|
|
1503
|
+
y: from.y + (to.y - from.y) * t,
|
|
1504
|
+
pressure
|
|
1505
|
+
});
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
points.push({ x: topLeft.x, y: topLeft.y, pressure });
|
|
1509
|
+
return points;
|
|
1510
|
+
}
|
|
1511
|
+
function circlePath(center, radiusX, radiusY, steps = 36, pressure = 0.5) {
|
|
1512
|
+
const ry = radiusY ?? radiusX;
|
|
1513
|
+
const points = [];
|
|
1514
|
+
for (let i = 0; i <= steps; i++) {
|
|
1515
|
+
const angle = i / steps * Math.PI * 2;
|
|
1516
|
+
points.push({
|
|
1517
|
+
x: center.x + Math.cos(angle) * radiusX,
|
|
1518
|
+
y: center.y + Math.sin(angle) * ry,
|
|
1519
|
+
pressure
|
|
1520
|
+
});
|
|
1521
|
+
}
|
|
1522
|
+
return points;
|
|
1523
|
+
}
|
|
1524
|
+
|
|
378
1525
|
exports.R3FFixture = R3FFixture;
|
|
1526
|
+
exports.circlePath = circlePath;
|
|
379
1527
|
exports.click = click;
|
|
380
1528
|
exports.contextMenu = contextMenu;
|
|
1529
|
+
exports.createR3FTest = createR3FTest;
|
|
1530
|
+
exports.curvePath = curvePath;
|
|
381
1531
|
exports.doubleClick = doubleClick;
|
|
382
1532
|
exports.drag = drag;
|
|
1533
|
+
exports.drawPathOnCanvas = drawPathOnCanvas;
|
|
383
1534
|
exports.expect = expect;
|
|
384
1535
|
exports.hover = hover;
|
|
1536
|
+
exports.linePath = linePath;
|
|
385
1537
|
exports.pointerMiss = pointerMiss;
|
|
1538
|
+
exports.rectPath = rectPath;
|
|
386
1539
|
exports.test = test;
|
|
387
1540
|
exports.waitForIdle = waitForIdle;
|
|
1541
|
+
exports.waitForNewObject = waitForNewObject;
|
|
1542
|
+
exports.waitForObject = waitForObject;
|
|
388
1543
|
exports.waitForSceneReady = waitForSceneReady;
|
|
389
1544
|
exports.wheel = wheel;
|
|
390
1545
|
//# sourceMappingURL=index.cjs.map
|