Merge branch 'german/refactor-e3-contract-behaviour' into german/navara-refactor

This commit is contained in:
German Khokhlov committed 2026-11-10 15:27:12 +04:00
commit 33ccef9662
39 files changed
+7247 -5194

No files matched your search

+367
View File
@@ -0,0 +1,367 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createFakeEngine } from '../src/globe/testing/fakeEngine.js';
import { fakeContractOptions } from '../src/globe/testing/contractFixtures.js';
import { runContractSuite } from '../src/globe/testing/contractSuite/index.mjs';
const replace = (object, member, wrap) => {
object[member] = wrap(object[member].bind(object));
};
const handle = (engine, area, factory, mutate) =>
replace(engine[area], factory, (create) => (...args) => {
const value = create(...args);
mutate(value);
return value;
});
const mutants = [
[
'retarget omits followChanged',
'camera.following: set, retarget',
(e) => {
let previous;
replace(e.camera.followChanged, 'raiseEvent', (raise) => (value) => {
if (value !== previous) raise(value);
previous = value;
});
},
],
[
'pointer cancels an active flight',
'camera.flyTo: a pointer gesture',
(e) => {
replace(e.fake, 'input', (input) => (type, event) => {
if (type === 'down') e.camera.cancelFlight();
input(type, event);
});
},
],
[
'terrain errors are labelled as tiles',
'maps.errors: terrain failures',
(e) => {
replace(
e.maps.errors,
'raiseEvent',
(raise) => (error) => raise({ ...error, kind: 'tile' }),
);
},
],
[
'interrupted flight omits cancel',
'camera.flyTo: a newer flight',
(e) => {
replace(
e.camera,
'flyTo',
(fly) => (options) => fly({ ...options, cancel: undefined }),
);
},
],
[
'pure rotation omits changed',
'camera.changed: a pure heading',
(e) => {
let previous = e.camera.pose().position;
replace(e.camera.changed, 'raiseEvent', (raise) => (...args) => {
const position = e.camera.pose().position;
const moved =
Math.hypot(
position.x - previous.x,
position.y - previous.y,
position.z - previous.z,
) > 0.01;
previous = position;
if (moved) raise(...args);
});
},
],
[
'request during render is lost',
'render.request: a request inside',
(e) => {
let inFrame = false;
e.frames.preRender.addEventListener(() => {
inFrame = true;
});
e.frames.postRender.addEventListener(() => {
inFrame = false;
});
replace(e.render, 'request', (request) => (...args) => {
if (!inFrame) request(...args);
});
},
],
[
'terrain revision stays fixed',
'maps.terrainRevision: changes when setTerrain',
(e) => {
e.maps.terrainRevision = () => 0;
},
],
[
'removed mover remains listed',
'movers.list: shown follows',
(e) => {
const removed = [];
const list = e.movers.list;
handle(e, 'movers', 'createSet', (set) => {
replace(set, 'destroy', (destroy) => () => {
const row = list().find(({ id }) => id === set.id);
if (row) removed.push(row);
destroy();
});
});
e.movers.list = () => [...list(), ...removed];
},
],
[
'stop omits followChanged',
'camera.following: set, retarget',
(e) => {
replace(e.camera.followChanged, 'raiseEvent', (raise) => (value) => {
if (value !== null) raise(value);
});
},
],
[
'following forgets the handle',
'camera.following: set, retarget',
(e) => {
e.camera.following = () => null;
},
],
[
'retarget keeps the old target',
'camera.following: set, retarget',
(e) => {
handle(e, 'camera', 'follow', (follow) => {
follow.retarget = () => {};
});
},
],
[
'texture limit is not a power of two',
'render.maxTextureSize:',
(e) => {
e.render.maxTextureSize = () => 3;
},
],
[
'continuous mode never changes',
'render.setContinuous: continuous',
(e) => {
e.render.setContinuous = () => {};
},
],
[
'clock is never latched',
'clock.frameTime: advances before',
(e) => {
e.clock.frameTime = () => 0;
},
],
[
'terrain samples are reversed',
'surface.sampleTerrainMostDetailed: preserves',
(e) => {
replace(
e.surface,
'sampleTerrainMostDetailed',
(sample) =>
async (...args) =>
(await sample(...args)).reverse(),
);
},
],
[
'unknown terrain becomes zero',
'surface.sampleTerrainMostDetailed: preserves',
(e) => {
replace(
e.surface,
'sampleTerrainMostDetailed',
(sample) =>
async (...args) =>
(await sample(...args)).map((height) => height ?? 0),
);
},
],
[
'terrain rejection is swallowed',
'surface.sampleTerrainMostDetailed: passes',
(e) => {
replace(
e.surface,
'sampleTerrainMostDetailed',
(sample) =>
async (...args) => {
try {
return await sample(...args);
} catch {
return [];
}
},
);
},
],
[
'failed model reports no failure',
'MoverSet.failed:',
(e) => {
handle(e, 'movers', 'createSet', (set) => {
set.failed = () => false;
});
},
],
[
'failed model reports ready',
'MoverSet.failed:',
(e) => {
handle(e, 'movers', 'createSet', (set) => {
set.ready = () => true;
});
},
],
[
'hidden mover reports shown',
'movers.list: shown follows',
(e) => {
replace(
e.movers,
'list',
(list) => () => list().map((row) => ({ ...row, shown: true })),
);
},
],
[
'map error loses HTTP status',
'maps.errors: forwards source',
(e) => {
replace(
e.maps.errors,
'raiseEvent',
(raise) => (error) => raise({ ...error, status: undefined }),
);
},
],
[
'map errors are swallowed',
'maps.errors: forwards source',
(e) => {
e.maps.errors.raiseEvent = () => {};
},
],
[
'old map providers keep reporting',
'maps.errors: forwards source',
(e) => {
replace(e.maps, 'setBaseImagery', (set) => async (source) => {
await set(source);
source.provider?.errorEvent?.addEventListener((error) => {
e.maps.errors.raiseEvent({
source,
kind: 'tile',
status: error?.statusCode,
});
});
});
},
],
[
'flight remains active after completion',
'camera.flyTo: duration 0',
(e) => {
e.camera.flightActive = () => true;
},
],
[
'setView leaves the flight running',
'camera.setView: cancels an active',
(e) => {
e.camera.setView = ({ destination, orientation }) =>
e.fake.setPose({ position: destination, ...orientation });
},
],
[
'instant flight omits complete',
'camera.flyTo: duration 0',
(e) => {
replace(
e.camera,
'flyTo',
(fly) => (options) => fly({ ...options, complete: undefined }),
);
},
],
[
'hidden meshes remain shown',
'meshes.createMesh: picks use',
(e) => {
handle(e, 'meshes', 'createMesh', (mesh) => {
replace(
mesh,
'show',
(show) => (value) => show(value === false ? true : value),
);
});
},
],
[
'imagery ignores alpha changes',
'imagery.createLayer: layers are',
(e) => {
handle(e, 'imagery', 'createLayer', (layer) => {
layer.setAlpha = () => {};
});
},
],
[
'clearing tiles forgets base imagery',
'maps.setBaseImagery: setPhotorealTiles',
(e) => {
replace(e.maps, 'setPhotorealTiles', (set) => async (source) => {
await set(source);
if (source === null) e.fake.maps.imagery = null;
});
},
],
[
'tiles-only ground also draws on terrain',
'ground.createSet: tiles drape',
(e) => {
replace(
e.ground,
'createSet',
(create) => (options) =>
create({
...options,
drape: options.drape === 'tiles' ? 'both' : options.drape,
}),
);
},
],
];
for (const [name, killedBy, mutate] of mutants) {
test(`${name} — killed by ${killedBy}`, async () => {
const engine = createFakeEngine();
try {
mutate(engine);
const results = await runContractSuite(engine, {
...fakeContractOptions(engine),
maxWaitFrames: 160,
});
const check = results.find(({ name }) => name.startsWith(killedBy));
assert.ok(check, `No check named ${killedBy}`);
assert.equal(check.ok, false, `Surviving mutant: ${name}`);
assert.ok(
check.error.name === 'ContractError' ||
check.error.message === 'No frame requested',
`Unexpected failure: ${check.error.stack}`,
);
} finally {
engine.destroy();
}
});
}
+2 -1
View File
@@ -2285,7 +2285,8 @@
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
"src/i18n/locales/zh-Hant.json",
"src/globe/contract.js"
],
"external": ["cesium"]
},
+73 -12
View File
@@ -26,13 +26,22 @@ const option = (name) =>
args.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
if (!origin) {
console.error(
'Usage: node scripts/qa-contract-suite.mjs <origin> [--engine=cesium,navara] [--only=prefix,...] [--json=FILE]',
'Usage: node scripts/qa-contract-suite.mjs <origin> [--engine=cesium,navara] [--only=prefix,...] [--dpr=1|2] [--json=FILE]',
);
process.exit(2);
}
const engines = (option('engine') ?? 'cesium,navara').split(',');
// --only=prefix,prefix runs just the checks whose names start with one.
const only = option('only')?.split(',') ?? null;
const only =
option('only')
?.split(',')
.map((prefix) =>
/^(ground|imagery|movers|lines|meshes|maps):$/.test(prefix)
? prefix.replace(':', '.')
: prefix,
) ?? null;
const dpr = Number(option('dpr') ?? 1);
if (![1, 2].includes(dpr)) throw new Error('--dpr must be 1 or 2');
// Checks of state the running app drives itself, by name prefix, with the
// owner. The app sets contact sonar on every frame (src/cyberSonarEngine.js),
@@ -43,13 +52,15 @@ const APP_OWNED = Object.freeze([
async function runOn(browser, kind) {
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
await page.setViewport({ width: 1280, height: 800, deviceScaleFactor: dpr });
const pageErrors = [];
page.on('pageerror', (error) => pageErrors.push(error.message));
await page.setRequestInterception(true);
page.on('request', (request) => {
const { hostname } = new URL(request.url());
if (BLOCKED_HOSTS.test(hostname)) request.abort();
if (new URL(request.url()).pathname === '/__contract_missing_model.glb')
request.respond({ status: 404, body: 'Model unavailable' });
else if (BLOCKED_HOSTS.test(hostname)) request.abort();
else request.continue();
});
// A view in the link: the app starts there instead of flying to a city.
@@ -139,25 +150,53 @@ async function runOn(browser, kind) {
await page.mouse.move(...at);
}
});
await page.exposeFunction('__contractDrag', async (x, y, endX, endY) => {
const box = await page.evaluate(() => {
const rect =
window.__godsEyeView.engine.host.canvas.getBoundingClientRect();
return { left: rect.left, top: rect.top };
});
await page.mouse.move(box.left + x, box.top + y);
await page.mouse.down();
await page.mouse.move(box.left + endX, box.top + endY, { steps: 4 });
await page.mouse.up();
});
await page.exposeFunction('__contractResize', async (width, height) => {
await page.setViewport({
width: Math.round(width),
height: Math.round(height),
deviceScaleFactor: dpr,
});
});
const results = await page.evaluate(
async (appOwned, only) => {
const { engine } = window.__godsEyeView;
const waitFrames = (count) =>
new Promise((resolve) => {
new Promise((resolve, reject) => {
const timer = setTimeout(() => {
remove();
reject(new Error('Timed out waiting for a rendered frame'));
}, 10_000);
let left = count;
const remove = engine.frames.postRender.addEventListener(() => {
if (--left > 0) return;
remove();
clearTimeout(timer);
resolve();
});
engine.render.request('contract-suite');
});
const pointer = {
click: (x, y) => window.__contractPointer('click', x, y),
move: (x, y) => window.__contractPointer('move', x, y),
drag: (x, y, endX, endY) => window.__contractDrag(x, y, endX, endY),
};
const { createContractSuite } =
await import('/src/globe/testing/contractSuite.mjs');
const fixtureModule =
engine.kind === 'cesium'
? await import('/src/globe/cesium/testing/contractFixtures.js')
: await import('/src/globe/navara/testing/contractFixtures.js');
const fixtures = await fixtureModule.createContractFixtures();
const outcome = [];
// A real 2D canvas: a sprite atlas the engines can draw and pick.
const makeCanvas = (width, height) =>
@@ -189,6 +228,8 @@ async function runOn(browser, kind) {
pointer,
readRegion,
makeCanvas,
fixtures,
resize: (width, height) => window.__contractResize(width, height),
})) {
if (only && !only.some((prefix) => name.startsWith(prefix))) continue;
const owner = appOwned.find(([prefix]) => name.startsWith(prefix));
@@ -196,6 +237,12 @@ async function runOn(browser, kind) {
outcome.push({ name, skipped: owner[1] });
continue;
}
const knownFailure =
engine.kind === 'navara' &&
name ===
'meshes.createMesh: a single colour draws as asked within 10 per channel'
? 'Meshes are drawn before tone mapping'
: null;
try {
await run();
outcome.push({ name, ok: true });
@@ -203,24 +250,33 @@ async function runOn(browser, kind) {
outcome.push({
name,
ok: false,
...(knownFailure &&
String(error?.message).startsWith(
'mesh colour within 10 per channel',
) && { knownFailure }),
error: String(error?.message ?? error),
});
}
}
fixtures.destroy?.();
return outcome;
},
APP_OWNED,
only,
);
await page.close();
return { engine: kind, results, pageErrors };
return { engine: kind, dpr, results, pageErrors };
}
const browser = await puppeteer.launch({
headless: true,
// A Navara page can hold its main thread for minutes while it starts.
protocolTimeout: 600_000,
args: ['--use-angle=metal', '--enable-gpu', '--no-sandbox'],
args: [
`--use-angle=${process.env.QA_ANGLE ?? (process.platform === 'darwin' ? 'metal' : 'swiftshader')}`,
'--enable-gpu',
'--no-sandbox',
],
});
const report = [];
try {
@@ -229,13 +285,16 @@ try {
report.push(run);
const ran = run.results.filter(({ skipped }) => !skipped);
const failed = ran.filter(({ ok }) => !ok);
const known = failed.filter(({ knownFailure }) => knownFailure);
console.log(
`${kind}: ${ran.length - failed.length}/${ran.length} checks pass`,
`${kind} DPR ${dpr}: ${ran.length - failed.length}/${ran.length} checks pass; ${known.length} known failures; ${run.results.length - ran.length} skipped`,
);
for (const { name, skipped } of run.results)
if (skipped) console.log(` skipped ${name}: ${skipped}`);
for (const { name, error } of failed)
console.log(` FAIL ${name}: ${error}`);
for (const { name, error, knownFailure } of failed)
console.log(
` ${knownFailure ? 'KNOWN FAIL' : 'FAIL'} ${name}: ${error}`,
);
for (const message of run.pageErrors)
console.log(` page error: ${message}`);
}
@@ -246,7 +305,9 @@ const json = option('json');
if (json) fs.writeFileSync(json, `${JSON.stringify(report, null, 2)}\n`);
process.exit(
report.every(({ results }) =>
results.every(({ ok, skipped }) => ok || skipped),
results.every(
({ ok, skipped, knownFailure }) => ok || skipped || knownFailure,
),
)
? 0
: 1,