feat: finalize corrected-route planning and Rerun recording review
This commit is contained in:
@@ -184,6 +184,15 @@ function preparation(overrides = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
test("replay preserves an optional corrected-map identity and rejects malformed versions", () => {
|
||||
const decoded = decodeObservationSessionReplay(replay({ map_generation: "b".repeat(64) }));
|
||||
assert.equal(decoded.mapGeneration, "b".repeat(64));
|
||||
assert.equal(decodeObservationSessionReplay(replay()).mapGeneration, undefined);
|
||||
for (const value of [null, "latest", "../private", 123]) {
|
||||
assert.throws(() => decodeObservationSessionReplay(replay({ map_generation: value })), /версию/);
|
||||
}
|
||||
});
|
||||
|
||||
const preparationEtag = '"prepare-20260717T131400Z"';
|
||||
|
||||
test("session catalog decodes canonical snake_case into a path-free camelCase model", () => {
|
||||
@@ -412,7 +421,7 @@ test("data recordings keep the compact session dropdown and laboratory results s
|
||||
assert.doesNotMatch(laboratorySource, /fetchAdvancedLaboratoryResults/);
|
||||
});
|
||||
|
||||
test("recording preparation statuses share the viewer's left alignment", async () => {
|
||||
test("recording preparation statuses occupy the viewer's upper-right corner", async () => {
|
||||
const spatialStyles = await readFile(
|
||||
new URL("../../../packages/spatial-ui/src/spatial.css", import.meta.url),
|
||||
"utf8",
|
||||
@@ -422,9 +431,10 @@ test("recording preparation statuses share the viewer's left alignment", async (
|
||||
spatialStyles.indexOf(".scene-operation-status {"),
|
||||
);
|
||||
|
||||
assert.match(statusStack, /left:\s*0\.85rem/);
|
||||
assert.match(statusStack, /justify-items:\s*start/);
|
||||
assert.doesNotMatch(statusStack, /right:/);
|
||||
assert.match(statusStack, /right:\s*0\.85rem/);
|
||||
assert.match(statusStack, /top:\s*0\.85rem/);
|
||||
assert.match(statusStack, /justify-items:\s*end/);
|
||||
assert.doesNotMatch(statusStack, /left:|bottom:/);
|
||||
});
|
||||
|
||||
test("source and laboratory catalogs are requested as disjoint backend projections", async () => {
|
||||
|
||||
@@ -150,14 +150,15 @@ test("follow toggles retain the current recorded camera journal", () => {
|
||||
assert.notEqual(afterReset, before);
|
||||
});
|
||||
|
||||
test("observation camera windows tile from the bottom-right above the live timeline", () => {
|
||||
test("observation camera windows tile from the bottom-left above the live timeline", () => {
|
||||
const bounds = { width: 1280, height: 720 };
|
||||
const left = initialObservationWindowRect(0, 2, bounds);
|
||||
const right = initialObservationWindowRect(1, 2, bounds);
|
||||
|
||||
assert.equal(left.y, right.y);
|
||||
assert.ok(left.x + left.width < right.x);
|
||||
assert.equal(right.x + right.width, bounds.width - 18);
|
||||
assert.equal(left.x, 18);
|
||||
assert.ok(right.x + right.width <= bounds.width - 18);
|
||||
assert.ok(right.y + right.height <= bounds.height - 64);
|
||||
});
|
||||
|
||||
@@ -323,10 +324,14 @@ test("recorded observation timeline rejects empty and non-finite ranges", () =>
|
||||
test("accumulation control normalizes UI values and distinguishes a single frame", () => {
|
||||
assert.equal(normalizeAccumulationSeconds(-3), 0);
|
||||
assert.equal(normalizeAccumulationSeconds(12.6), 13);
|
||||
assert.equal(normalizeAccumulationSeconds(999), 120);
|
||||
assert.equal(normalizeAccumulationSeconds(999), 999);
|
||||
assert.equal(normalizeAccumulationSeconds(1800), 1800);
|
||||
assert.equal(normalizeAccumulationSeconds(9999), 9999);
|
||||
assert.equal(normalizeAccumulationSeconds(Number.NaN), 0);
|
||||
assert.equal(formatAccumulationDuration(0), "Кадр");
|
||||
assert.equal(formatAccumulationDuration(12), "12 с");
|
||||
assert.equal(formatAccumulationDuration(1800), "30 мин");
|
||||
assert.equal(formatAccumulationDuration(125), "2 мин 5 с");
|
||||
});
|
||||
|
||||
test("spatial timeline renders synchronized accumulation and playback controls", () => {
|
||||
@@ -606,6 +611,32 @@ test("recorded blueprint fetch is bounded, strict and sends only display setting
|
||||
);
|
||||
});
|
||||
|
||||
test("recorded tracking quantizes the native fractional-nanosecond cursor without accepting invalid times", async () => {
|
||||
const bodies = [];
|
||||
const request = currentTimeNs => fetchRecordedBlueprintRrd(
|
||||
"/api/v1/observation-sessions/session-1/blueprint.rrd",
|
||||
{accumulationSeconds: 10, showGrid: true, showPoints: true,
|
||||
showTrajectory: true, pointSize: 0.5, colorMode: "intensity",
|
||||
palette: "turbo", customColor: "#ffffff"},
|
||||
{applicationId: "nodedc_mission_core_recorded", recordingId: "recording-001"},
|
||||
{origin: "http://127.0.0.1:8000", blueprintSessionId: "a".repeat(32),
|
||||
cameraEye: {position: [3, 4, 5], lookTarget: [1, 2, 0], eyeUp: [0, 0, 1]},
|
||||
eyeRelativeToTracking: true, currentTimeNs,
|
||||
fetcher: async (_input, init) => {
|
||||
bodies.push(JSON.parse(init.body));
|
||||
return new Response(new Uint8Array([0x52, 0x52, 0x46, 0x32]), {
|
||||
headers: {"Content-Type": "application/vnd.rerun.rrd"},
|
||||
});
|
||||
}},
|
||||
);
|
||||
await request(536_460_021_972.65625);
|
||||
assert.equal(bodies[0].current_time_ns, 536_460_021_973);
|
||||
for (const invalid of [-0.1, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) {
|
||||
await assert.rejects(request(invalid), /Unsafe recorded blueprint request/);
|
||||
}
|
||||
assert.equal(bodies.length, 1);
|
||||
});
|
||||
|
||||
test("recorded point colors use one strict same-origin component overlay", async () => {
|
||||
const endpoint = resolveRecordedPointColorsUrl(
|
||||
"/api/v1/observation-sessions/session-1/recording.rrd",
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {before,after,test} from 'node:test';
|
||||
import React from 'react';
|
||||
import {renderToStaticMarkup} from 'react-dom/server';
|
||||
import {createServer} from 'vite';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
|
||||
let server,usePlanner,Settings;
|
||||
before(async()=>{
|
||||
server=await createServer({appType:'custom',logLevel:'silent',server:{middlewareMode:true}});
|
||||
({useMissionPlanner:usePlanner}=await server.ssrLoadModule('/src/core/missions/useMissionPlanner.ts'));
|
||||
({PlanningProjectSettings:Settings}=await server.ssrLoadModule('/src/components/missions/PlanningProjectSettings.tsx'));
|
||||
});
|
||||
after(async()=>{await server?.close();});
|
||||
|
||||
// Bounded hook dispatcher: execute the real state/effect transitions, without
|
||||
// mounting another browser or replacing the production hook with a fake.
|
||||
function harness(){
|
||||
const slots=[];let index=0,pending=[];
|
||||
const hooks={
|
||||
useState(initial){const i=index++;if(!slots[i])slots[i]={value:initial};return [slots[i].value,v=>{slots[i].value=typeof v==='function'?v(slots[i].value):v;}];},
|
||||
useEffect(fn,deps){const i=index++,old=slots[i];if(!old||deps.some((d,n)=>!Object.is(d,old.deps[n])))pending.push(()=>{old?.cleanup?.();slots[i]={deps,cleanup:fn()};});},
|
||||
useMemo(fn){return fn();},useCallback(fn){return fn;},
|
||||
};
|
||||
const render=()=>{index=0;const internal=React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,old=internal.H;internal.H=hooks;try{return usePlanner();}finally{internal.H=old;}};
|
||||
return {render,async settle(){let state;for(let n=0;n<5;n++){state=render();const effects=pending;pending=[];effects.forEach(fn=>fn());await new Promise(resolve=>setImmediate(resolve));}return render();},close(){slots.forEach(s=>s.cleanup?.());}};
|
||||
}
|
||||
const source={schema_version:'missioncore.planning-source/v1',session_id:'ring',generation:'a'.repeat(64),label:'Ring',units:'m',path_m:580,
|
||||
poses:Array.from({length:60},(_,index)=>({index,position:[index*10,0,0],distance_m:index*10}))};
|
||||
|
||||
test('same recording reselection cannot collapse the reference or disable a named project',async()=>{
|
||||
const oldFetch=globalThis.fetch,requests=[];const h=harness();
|
||||
globalThis.fetch=async(url,options={})=>{
|
||||
requests.push({url,body:options.body&&JSON.parse(options.body)});
|
||||
if(options.method==='POST')return {ok:true,json:async()=>({id:'draft',revision:1,name:'Seam',zone:{session_id:'ring',generation:source.generation},route:{start_index:0,end_index:59,direction:'forward'}})};
|
||||
return {ok:true,json:async()=>url.includes('/sources/')?source:{items:[],next_cursor:null}};
|
||||
};
|
||||
try{
|
||||
let p=await h.settle();p.chooseSource('ring');p.setName('Seam');p=await h.settle();
|
||||
assert.equal(p.poses.length,60);assert.equal(p.ready,true);
|
||||
for(let i=0;i<4;i++){p.chooseSource('ring');p=await h.settle();assert.equal(p.poses.length,60);assert.equal(p.ready,true);}
|
||||
await p.save();const body=requests.find(r=>r.body)?.body;
|
||||
assert.equal(body.whole_recording,true);assert.equal(body.start_index,undefined);assert.equal(body.end_index,undefined);
|
||||
assert.ok(requests.some(r=>r.url.includes('scope=standalone')));
|
||||
p=h.render();p.newDraft();p=await h.settle();p.chooseSource('ring');p.setName('Next');p=await h.settle();
|
||||
assert.equal(p.poses.length,60);assert.equal(p.ready,true);
|
||||
assert.equal(p.setStart,undefined);assert.equal(p.setEnd,undefined);
|
||||
}finally{h.close();globalThis.fetch=oldFetch;}
|
||||
});
|
||||
|
||||
test('product removes reference crop controls and Data requests independent captures',async()=>{
|
||||
const settings=await readFile(new URL('../src/components/missions/PlanningProjectSettings.tsx',import.meta.url),'utf8');
|
||||
assert.doesNotMatch(settings,/Участок эталона|Начало участка|Конец участка|30 м от начала участка|p\.set(Start|End)/);
|
||||
assert.match(settings,/Вся запись/);
|
||||
const selector=await readFile(new URL('../src/components/ObservationSessionSelect.tsx',import.meta.url),'utf8');
|
||||
assert.match(selector,/scope: "standalone"/);
|
||||
});
|
||||
|
||||
test('planner offers one direct live start without a repeated-pass mode or catalogue',async()=>{
|
||||
const p={name:'Ring',poses:source.poses,source,sessionId:'ring',options:[{value:'ring',label:'Ring'}],direction:'forward',ready:true};
|
||||
const render=(overrides={},starting=false)=>renderToStaticMarkup(React.createElement(Settings,{p:{...p,...overrides},starting,onStart:()=>{}}));
|
||||
const ready=render();
|
||||
assert.doesNotMatch(ready,/Повторный проход|Из записи|Источник повторного прохода|Повторная запись|После запуска/);
|
||||
const startButton=html=>[...html.matchAll(/<button\b[^>]*>[\s\S]*?<\/button>/g)].map(m=>m[0]).find(button=>button.includes('Начать новый проход'));
|
||||
assert.ok(startButton(ready));
|
||||
assert.doesNotMatch(startButton(ready),/disabled=/);
|
||||
for(const state of [{ready:false},{busy:true},{poses:source.poses.slice(0,1)}])assert.match(startButton(render(state)),/disabled=/);
|
||||
assert.match(startButton(render({},true)),/disabled=/);
|
||||
let started=0;
|
||||
const tree=Settings({p,starting:false,onStart:()=>{started++;}});
|
||||
tree.props.children[1].props.children[1].props.onClick();
|
||||
assert.equal(started,1);
|
||||
const workspace=await readFile(new URL('../src/workspaces/missions/MissionPlannerWorkspace.tsx',import.meta.url),'utf8');
|
||||
assert.doesNotMatch(workspace,/useRegistrationTest|setMode|t\.run\(/);
|
||||
assert.match(workspace,/live\.begin\(draft\)/);
|
||||
});
|
||||
@@ -1,101 +1,42 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { after, before, test } from "node:test";
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server, createRecordedRerunCameraJournal, initialEye;
|
||||
let server, readNativeRerunCameraEye;
|
||||
before(async () => {
|
||||
server = await createServer({ appType: "custom", logLevel: "silent",
|
||||
server: { middlewareMode: true } });
|
||||
const module = await server.ssrLoadModule(
|
||||
"/src/components/rerun/recordedRerunCameraJournal.ts");
|
||||
createRecordedRerunCameraJournal = module.createRecordedRerunCameraJournal;
|
||||
initialEye = module.RECORDED_RERUN_ORBITAL_EYE;
|
||||
({ readNativeRerunCameraEye } = await server.ssrLoadModule(
|
||||
"/src/components/rerun/recordedRerunCameraJournal.ts"));
|
||||
});
|
||||
after(async () => { await server?.close(); });
|
||||
|
||||
class FakeEvent {
|
||||
constructor(type, init = {}) { this.type = type; Object.assign(this, init); }
|
||||
}
|
||||
|
||||
class FakeTarget {
|
||||
listeners = new Map();
|
||||
emitted = [];
|
||||
addEventListener(type, listener) {
|
||||
const listeners = this.listeners.get(type) ?? [];
|
||||
listeners.push(listener); this.listeners.set(type, listeners);
|
||||
}
|
||||
removeEventListener(type, listener) {
|
||||
this.listeners.set(type, (this.listeners.get(type) ?? []).filter(item => item !== listener));
|
||||
}
|
||||
dispatchEvent(event) {
|
||||
this.emitted.push(event);
|
||||
for (const listener of this.listeners.get(event.type) ?? []) listener(event);
|
||||
return true;
|
||||
test("camera snapshot copies exact native pose, without approximating input", () => {
|
||||
const native = { position: [287, -74, 8], lookTarget: [286, -72, 0], eyeUp: [0, 0, 1] };
|
||||
const eye = readNativeRerunCameraEye(native);
|
||||
assert.deepEqual(eye, native);
|
||||
native.position[0] = 999;
|
||||
assert.equal(eye.position[0], 287);
|
||||
assert.equal(readNativeRerunCameraEye(null), null);
|
||||
});
|
||||
|
||||
test("invalid native pose fails closed instead of substituting a guessed camera", () => {
|
||||
for (const value of [{}, "pose", { position: [NaN, 0, 1], lookTarget: [0, 0, 0], eyeUp: [0, 0, 1] }]) {
|
||||
assert.throws(() => readNativeRerunCameraEye(value), /Invalid native Rerun camera/);
|
||||
}
|
||||
}
|
||||
|
||||
const radius = (eye) => Math.hypot(
|
||||
eye.position[0] - eye.lookTarget[0],
|
||||
eye.position[1] - eye.lookTarget[1],
|
||||
eye.position[2] - eye.lookTarget[2],
|
||||
);
|
||||
|
||||
test("recorded orbital eye tracks only 3D viewport navigation", () => {
|
||||
const scope = new FakeTarget();
|
||||
const timers = [];
|
||||
Object.assign(scope, {
|
||||
WheelEvent: FakeEvent,
|
||||
requestAnimationFrame(callback) { callback(); return 1; },
|
||||
setTimeout(callback) { timers.push(callback); return timers.length; },
|
||||
});
|
||||
const canvas = new FakeTarget();
|
||||
Object.assign(canvas, {
|
||||
clientHeight: 200,
|
||||
isConnected: true,
|
||||
getBoundingClientRect: () => ({
|
||||
left: 100, right: 500, top: 50, bottom: 250, width: 400, height: 200,
|
||||
}),
|
||||
});
|
||||
const journal = createRecordedRerunCameraJournal(canvas, scope);
|
||||
journal.configure(initialEye, 0.46);
|
||||
const pointer = (type, x, y, buttons) => new FakeEvent(type, {
|
||||
clientX: x, clientY: y, button: 0, buttons, pointerId: 7, pointerType: "mouse",
|
||||
altKey: false, ctrlKey: false, metaKey: false, shiftKey: false,
|
||||
});
|
||||
|
||||
canvas.dispatchEvent(pointer("pointerdown", 200, 100, 1));
|
||||
scope.dispatchEvent(pointer("pointermove", 240, 120, 1));
|
||||
scope.dispatchEvent(pointer("pointerup", 240, 120, 0));
|
||||
assert.deepEqual(journal.current(), initialEye);
|
||||
|
||||
canvas.dispatchEvent(pointer("pointerdown", 380, 100, 1));
|
||||
scope.dispatchEvent(pointer("pointermove", 420, 120, 1));
|
||||
scope.dispatchEvent(pointer("pointerup", 420, 120, 0));
|
||||
const rotated = journal.current();
|
||||
assert.notDeepEqual(rotated.position, initialEye.position);
|
||||
assert.ok(Math.abs(radius(rotated) - radius(initialEye)) < 1e-9);
|
||||
|
||||
journal.setMaxOrbitalRadius(40);
|
||||
scope.dispatchEvent(new FakeEvent("wheel", {
|
||||
clientX: 380, clientY: 130, deltaX: 0, deltaY: 2_000, deltaMode: 0,
|
||||
altKey: false, ctrlKey: false, metaKey: false, shiftKey: false,
|
||||
}));
|
||||
assert.ok(Math.abs(radius(journal.current()) - 40) < 1e-9);
|
||||
|
||||
journal.configure(rotated, 0.46);
|
||||
|
||||
scope.dispatchEvent(new FakeEvent("wheel", {
|
||||
clientX: 0, clientY: 0, deltaX: 0, deltaY: -20, deltaMode: 0,
|
||||
altKey: false, ctrlKey: false, metaKey: false, shiftKey: false,
|
||||
}));
|
||||
const zoomed = journal.current();
|
||||
assert.ok(Math.abs(radius(zoomed) - radius(rotated) * Math.exp(-20 / 200)) < 1e-9);
|
||||
|
||||
const snapshot = journal.current();
|
||||
journal.setSpatialViewportStart(0.8);
|
||||
scope.dispatchEvent(new FakeEvent("wheel", {
|
||||
clientX: 380, clientY: 130, deltaX: 0, deltaY: -20, deltaMode: 0,
|
||||
}));
|
||||
assert.deepEqual(journal.current(), snapshot);
|
||||
journal.dispose();
|
||||
});
|
||||
|
||||
test("iframe owner reads the native camera and installs no shadow input listeners", () => {
|
||||
const owner = readFileSync(new URL("../src/components/rerun/recordedRerunOwner.ts", import.meta.url), "utf8");
|
||||
const camera = readFileSync(new URL("../src/components/rerun/recordedRerunCameraJournal.ts", import.meta.url), "utf8");
|
||||
assert.match(owner, /get_camera_eye\?\.\(\)/);
|
||||
assert.doesNotMatch(owner + camera, /createRecordedRerunCameraJournal|addEventListener|Math\.exp/);
|
||||
});
|
||||
|
||||
test("display blueprint activation carries the current native eye, not the embedded preset", () => {
|
||||
const viewport = readFileSync(new URL("../src/components/RerunViewport.tsx", import.meta.url), "utf8");
|
||||
assert.match(viewport, /!enablingFollow \? active\.getCameraEye\?\.\(\) \?\? undefined : undefined/);
|
||||
assert.match(viewport, /requestBlueprint\(\s*firstEye,\s*firstEyeIsTrackingRelative,/);
|
||||
assert.match(viewport, /currentTimeNs: eyeRelativeToTracking \? currentTimeNs : undefined/);
|
||||
});
|
||||
|
||||
@@ -59,6 +59,17 @@ function bridge(native, mount = {}) {
|
||||
return parent;
|
||||
}
|
||||
|
||||
test("camera snapshot crosses the disposable realm as copied primitive data", () => {
|
||||
const eye = { position: [300, 7, 8], lookTarget: [298, 9, 0], eyeUp: [0, 0, 1] };
|
||||
const { facade, dispose } = bridge({ stop() {}, get_camera_eye: () => eye });
|
||||
const received = facade.get_camera_eye();
|
||||
assert.deepEqual(received, eye);
|
||||
assert.notEqual(received, eye);
|
||||
assert.notEqual(received.position, eye.position);
|
||||
dispose();
|
||||
assert.throws(() => facade.get_camera_eye(), /disposed/);
|
||||
});
|
||||
|
||||
test("recorded realm terminates on close even if upstream stop throws", async (t) => {
|
||||
const f = fixture(t, { stopFails: true });
|
||||
const scope = createIsolatedRerunHost(f.host);
|
||||
|
||||
@@ -251,6 +251,10 @@ test("recorded RRD bytes are never split across LogChannel.send_rrd calls", asyn
|
||||
assert.match(source, /recordedPerceptionLayers\.costmap,/);
|
||||
assert.match(source, /recordedPerceptionLayers\.costmap !== undefined && status !== "ready"/);
|
||||
assert.match(source, /perceptionLayers\.costmap === undefined \? \{\} : \{\s*show_costmap: perceptionLayers\.costmap,\s*reactivate_updates: true/s);
|
||||
// Ordinary archives need activation too, not only the LAB costmap path.
|
||||
assert.match(source, /requestBlueprint\(\s*firstEye,\s*firstEyeIsTrackingRelative,[\s\S]*?\n\s*true,\s*\);/);
|
||||
// Display changes must not trigger a full-file camera-bounds scan.
|
||||
assert.match(source, /currentTimeNs: eyeRelativeToTracking \? currentTimeNs : undefined/);
|
||||
assert.match(source, /viewer\.start\(\s*rerunViewerInitialSource\(resolvedSource\)/s);
|
||||
assert.doesNotMatch(source, /rerunViewerOpenOptions/);
|
||||
assert.match(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import test from "node:test";
|
||||
@@ -7,13 +8,41 @@ const root = resolve(import.meta.dirname, "..");
|
||||
const packageRoot = resolve(root, "node_modules/@rerun-io/web-viewer");
|
||||
const readJson = (path) => JSON.parse(readFileSync(path, "utf8"));
|
||||
|
||||
test("Mission Core uses the exact upstream Rerun 0.36.3 web package", () => {
|
||||
test("Mission Core pins Rerun 0.36.3 with the bounded native navigation installer", () => {
|
||||
const application = readJson(resolve(root, "package.json"));
|
||||
const installed = readJson(resolve(packageRoot, "package.json"));
|
||||
|
||||
assert.equal(application.dependencies["@rerun-io/web-viewer"], "0.36.3");
|
||||
assert.equal(installed.version, "0.36.3");
|
||||
assert.equal(application.scripts.postinstall, undefined);
|
||||
assert.equal(application.scripts.postinstall, "node scripts/install-rerun-navigation.mjs");
|
||||
assert.equal(application.scripts.prebuild, application.scripts.postinstall);
|
||||
});
|
||||
|
||||
test("native navigation artifacts and source patch match their provenance", () => {
|
||||
const vendorRoot = resolve(root, "vendor/rerun-web-viewer-0.36.3");
|
||||
const manifest = readJson(resolve(vendorRoot, "navigation-build.json"));
|
||||
const sha = bytes => createHash("sha256").update(bytes).digest("hex");
|
||||
assert.equal(manifest.upstreamVersion, "0.36.3");
|
||||
assert.equal(manifest.upstreamCommit, "6ded109d33c549e98185f7c95fa8009d44e4adef");
|
||||
assert.equal(sha(readFileSync(resolve(vendorRoot, "NODEDC_NAVIGATION.patch"))), manifest.patchSha256);
|
||||
for (const [name, identity] of Object.entries(manifest.files)) {
|
||||
if (!identity.artifact) continue;
|
||||
assert.equal(sha(readFileSync(resolve(vendorRoot, identity.artifact))), identity.sha256, name);
|
||||
assert.equal(sha(readFileSync(resolve(packageRoot, name))), identity.sha256, name);
|
||||
}
|
||||
});
|
||||
|
||||
test("generated JS and native WASM share one ABI including the camera snapshot", () => {
|
||||
const glue = readFileSync(resolve(packageRoot, "re_viewer.js"), "utf8");
|
||||
const wasm = new WebAssembly.Module(readFileSync(resolve(packageRoot, "re_viewer_bg.wasm")));
|
||||
const names = new Set(WebAssembly.Module.exports(wasm).map(item => item.name));
|
||||
assert.ok(names.has("webhandle_nodedc_camera_eye"));
|
||||
for (const [, name] of glue.matchAll(/\bwasm\.([a-zA-Z_$][\w$]*)/g)) {
|
||||
assert.ok(names.has(name), `Missing WASM export: ${name}`);
|
||||
}
|
||||
assert.match(readFileSync(resolve(packageRoot, "index.js"), "utf8"), /this\.#handle\.nodedc_camera_eye\(\)/);
|
||||
assert.match(readFileSync(resolve(packageRoot, "index.d.ts"), "utf8"), /get_camera_eye\(\)/);
|
||||
assert.match(readFileSync(resolve(packageRoot, "re_viewer.d.ts"), "utf8"), /nodedc_camera_eye\(\)/);
|
||||
});
|
||||
|
||||
test("the active application never imports or installs the archived vendor fork", () => {
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {before, after, test} from 'node:test';
|
||||
import {createServer} from 'vite';
|
||||
import {createElement} from 'react';
|
||||
import {renderToStaticMarkup} from 'react-dom/server';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
import {runInNewContext} from 'node:vm';
|
||||
import ts from 'typescript';
|
||||
|
||||
let server, profile, points, defaults, Controls, Timeline;
|
||||
before(async () => {
|
||||
server = await createServer({appType: 'custom', logLevel: 'silent', server: {middlewareMode: true}});
|
||||
profile = await server.ssrLoadModule('/src/core/observation/sessionDisplayProfile.ts');
|
||||
points = await server.ssrLoadModule('/src/core/observation/recordedPointDisplay.ts');
|
||||
defaults = (await server.ssrLoadModule('/src/sceneSettings.ts')).defaultSceneSettings;
|
||||
({SceneDisplayControls: Controls} = await server.ssrLoadModule('../../packages/spatial-ui/src/SceneDisplayControls.tsx'));
|
||||
({ObservationTimeline: Timeline} = await server.ssrLoadModule('/src/components/ObservationTimeline.tsx'));
|
||||
});
|
||||
after(async () => {await server?.close();});
|
||||
|
||||
const document = (settings, id='session-a') => ({schema_version: 'missioncore.session-display-profile/v1',
|
||||
session_id: id, scene_settings: profile.encodeSessionDisplay(settings)});
|
||||
|
||||
test('session profile round trips all display settings and fractional decimation', () => {
|
||||
const settings = {...defaults, accumulationMaxSeconds: 600, accumulationSeconds: 590, pointDecimationPercent: 49.5};
|
||||
assert.deepEqual(profile.decodeSessionDisplay(document(settings), 'session-a'), settings);
|
||||
assert.throws(() => profile.decodeSessionDisplay(document(settings), 'session-b'), /другой записи/);
|
||||
for (const percent of [-1, 100.1, NaN, Infinity]) {
|
||||
assert.throws(() => profile.decodeSessionDisplay(document({...settings, pointDecimationPercent: percent}), 'session-a'));
|
||||
}
|
||||
for (const percent of [0, 50, 100]) assert.equal(profile.decodeSessionDisplay(document({...settings,
|
||||
pointDecimationPercent: percent}), 'session-a').pointDecimationPercent, percent);
|
||||
assert.equal(defaults.accumulationMaxSeconds, 180);
|
||||
});
|
||||
|
||||
test('timeline uses per-session range instead of a fixed duration cap', () => {
|
||||
const html = renderToStaticMarkup(createElement(Timeline, {accumulationSeconds: 47,
|
||||
accumulationMaxSeconds: 600, onAccumulationChange: () => {}}));
|
||||
assert.match(html, /max="600"/);
|
||||
});
|
||||
|
||||
test('display exposes precise thinning only for saved playback', () => {
|
||||
const props = {displayDraft: defaults, stageDisplayPatch: () => {}, commitDisplayPatch: () => {}, flushDisplaySettings: () => {}};
|
||||
assert.match(renderToStaticMarkup(createElement(Controls, {...props, replayPresented: true})), /Прореживание облака/);
|
||||
assert.doesNotMatch(renderToStaticMarkup(createElement(Controls, props)), /Прореживание облака/);
|
||||
});
|
||||
|
||||
test('open Display previews 100%, 0% and accumulation; close alone persists the latest draft', async () => {
|
||||
// Execute the actual composition callbacks with deterministic timers. This
|
||||
// catches the former open-inspector early return, not just helper behavior.
|
||||
const source = await readFile(new URL('../src/App.tsx', import.meta.url), 'utf8');
|
||||
const ast = ts.createSourceFile('App.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
||||
const callbacks = new Map();
|
||||
const names = ['stageDisplayPatch', 'flushDisplaySettings', 'commitDisplaySettings'];
|
||||
let close;
|
||||
function visit(node) {
|
||||
if (ts.isVariableDeclaration(node) && names.includes(node.name.getText(ast))) {
|
||||
callbacks.set(node.name.getText(ast), node.initializer.getText(ast));
|
||||
}
|
||||
if (ts.isBinaryExpression(node) && node.left.getText(ast) === 'closeDisplayRef.current') close = node.getText(ast);
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
visit(ast);
|
||||
assert.equal(callbacks.size, names.length); assert.ok(close);
|
||||
const applied = [], saved = [], timers = new Map(); let timerId = 0;
|
||||
const context = {
|
||||
useCallback: fn => fn, displayWindowOpenRef: {current: true}, replayActiveRef: {current: true},
|
||||
displayDraftRef: {current: {...defaults, pointDecimationPercent: 86}}, setDisplayDraft() {},
|
||||
viewerSettingsCommitTimerRef: {current: null}, viewerSettingsQuietPeriodMs: 750,
|
||||
sceneSettingsCommitterRef: {current: {enqueue: value => applied.push(value)}}, closeDisplayRef: {current: null},
|
||||
sessionDisplayProfile: {edited() {}, save: value => saved.push(value)},
|
||||
window: {setTimeout: fn => {timers.set(++timerId, fn); return timerId;}, clearTimeout: id => timers.delete(id)},
|
||||
};
|
||||
const code = [...callbacks].map(([name, init]) => `const ${name} = ${init};`).join('\n') +
|
||||
`\n${close}; ({stageDisplayPatch, flushDisplaySettings, close: closeDisplayRef.current});`;
|
||||
const actions = runInNewContext(ts.transpileModule(code, {compilerOptions: {target: ts.ScriptTarget.ES2022}}).outputText, context);
|
||||
actions.stageDisplayPatch({pointDecimationPercent: 100});
|
||||
assert.equal(applied.at(-1).pointDecimationPercent, 100);
|
||||
actions.stageDisplayPatch({pointDecimationPercent: 0});
|
||||
assert.equal(applied.at(-1).pointDecimationPercent, 0);
|
||||
actions.stageDisplayPatch({accumulationSeconds: 180});
|
||||
actions.flushDisplaySettings();
|
||||
assert.equal(applied.at(-1).accumulationSeconds, 180);
|
||||
actions.stageDisplayPatch({accumulationSeconds: 10});
|
||||
actions.flushDisplaySettings();
|
||||
assert.equal(applied.at(-1).accumulationSeconds, 10);
|
||||
actions.stageDisplayPatch({pointDecimationPercent: 49.5});
|
||||
assert.equal(timers.size, 1);
|
||||
const pending = [...timers.values()][0]; timers.clear(); pending();
|
||||
assert.equal(applied.at(-1).pointDecimationPercent, 49.5);
|
||||
assert.equal(applied.at(-1).accumulationSeconds, 10);
|
||||
assert.equal(saved.length, 0);
|
||||
actions.close();
|
||||
assert.equal(saved.length, 1);
|
||||
assert.equal(saved[0].pointDecimationPercent, 49.5);
|
||||
assert.equal(saved[0].accumulationSeconds, 10);
|
||||
});
|
||||
|
||||
test('display stream validates fragmented header and sends bounded chunks', async () => {
|
||||
const previous = globalThis.window; globalThis.window = {location: {origin: 'http://localhost'}};
|
||||
try {
|
||||
const wire = [78,80,68,49,8,0,0,0,82,82,70,50,1,2,3,4,0,0,0,0];
|
||||
const chunks = [new Uint8Array(wire.slice(0,1)), new Uint8Array(wire.slice(1,7)),
|
||||
new Uint8Array(wire.slice(7,10)), new Uint8Array(wire.slice(10))];
|
||||
const sent = []; let request;
|
||||
const fetcher = async (url, options) => {request = {url, ...options}; return new Response(new ReadableStream({
|
||||
start(controller) {chunks.forEach(chunk => controller.enqueue(chunk)); controller.close();},
|
||||
}), {headers: {'Content-Type': 'application/vnd.nodedc.point-display-stream'}});};
|
||||
const total = await points.streamRecordedPointDisplay('/api/v1/observation-sessions/session-a/blueprint.rrd',
|
||||
{...defaults, pointDecimationPercent: 49.5}, {applicationId: 'app', recordingId: 'rec'}, 'a'.repeat(32),
|
||||
new AbortController().signal, chunk => sent.push(...chunk), fetcher, 'a'.repeat(64));
|
||||
assert.equal(total, 20); assert.deepEqual(sent, [82,82,70,50,1,2,3,4]);
|
||||
assert.equal(JSON.parse(request.body).point_decimation_percent, 49.5);
|
||||
assert.equal(JSON.parse(request.body).source_generation, 'a'.repeat(64));
|
||||
assert.match(request.url, /point-display\.rrd$/);
|
||||
} finally {globalThis.window = previous;}
|
||||
});
|
||||
|
||||
test('invalid, cross-origin and canceled streams never become active', async () => {
|
||||
const previous = globalThis.window; globalThis.window = {location: {origin: 'http://localhost'}};
|
||||
try {
|
||||
const call = (url, signal, fetcher) => points.streamRecordedPointDisplay(url, {...defaults, pointDecimationPercent: 50},
|
||||
{applicationId: 'app', recordingId: 'rec'}, 'b'.repeat(32), signal, () => assert.fail('must not publish'), fetcher);
|
||||
const url = '/api/v1/observation-sessions/session-a/blueprint.rrd';
|
||||
await assert.rejects(call('https://elsewhere.test'+url, new AbortController().signal, () => assert.fail('must not fetch')));
|
||||
await assert.rejects(call(url, new AbortController().signal, async () => new Response('bad!', {
|
||||
headers: {'Content-Type': 'application/vnd.nodedc.point-display-stream'}})), /Некорректный поток/);
|
||||
await assert.rejects(call(url, new AbortController().signal, async () => new Response('NPD1', {
|
||||
headers: {'Content-Type': 'application/vnd.nodedc.point-display-stream'}})), /не завершён/);
|
||||
const abort = new AbortController(); abort.abort();
|
||||
await assert.rejects(call(url, abort.signal, async () => new Response('RRF2', {
|
||||
headers: {'Content-Type': 'application/vnd.nodedc.point-display-stream'}})), /Aborted/);
|
||||
} finally {globalThis.window = previous;}
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { before, after, test } from 'node:test';
|
||||
import { createServer } from 'vite';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
let server, api;
|
||||
before(async () => {
|
||||
server = await createServer({ appType: 'custom', logLevel: 'silent', server: { middlewareMode: true } });
|
||||
api = await server.ssrLoadModule('/src/core/observation/sessionOverviewSpatial.ts');
|
||||
});
|
||||
after(async () => { await server?.close(); });
|
||||
test('comparison pins identity and changes geometry without requesting a camera reset', async () => {
|
||||
const oldFetch = globalThis.fetch, oldWindow = globalThis.window;
|
||||
const requests = [];
|
||||
globalThis.window = { location: { origin: 'http://localhost:8000' } };
|
||||
globalThis.fetch = async (url, options) => {
|
||||
requests.push({ url: String(url), body: JSON.parse(options.body) });
|
||||
return { ok: true, arrayBuffer: async () => new ArrayBuffer(8), headers: new Headers({ 'X-Overview-Visible-Points': '42' }) };
|
||||
};
|
||||
try {
|
||||
const source = '/api/v1/observation-sessions/example/overview/scene.rrd?generation=' + 'a'.repeat(64);
|
||||
const signal = new AbortController().signal;
|
||||
for (const representation of ['original', 'corrected', 'original']) {
|
||||
const result = await api.updateOverviewSpatial(source, 80, null, 1.5, signal, 'b'.repeat(64), representation);
|
||||
assert.equal(result.eye, null);
|
||||
assert.equal(result.visiblePoints, 42);
|
||||
}
|
||||
assert.deepEqual(requests.map(r => r.body.representation), ['original', 'corrected', 'original']);
|
||||
for (const request of requests) {
|
||||
assert.equal(request.body.mode, null);
|
||||
assert.equal(request.body.comparison_generation, 'b'.repeat(64));
|
||||
assert.equal(request.body.generation, 'a'.repeat(64));
|
||||
assert.ok(request.url.endsWith('/overview/spatial'));
|
||||
}
|
||||
await assert.rejects(api.updateOverviewSpatial(source, null, null, 1, signal, null, 'corrected'), /недоступна/);
|
||||
assert.equal(requests.length, 3);
|
||||
} finally { globalThis.fetch = oldFetch; globalThis.window = oldWindow; }
|
||||
});
|
||||
test('comparison control is opt-in while default geometry and pinned planner versions are shared', async () => {
|
||||
const source = await readFile(new URL('../src/components/observation/SessionOverviewScene.tsx', import.meta.url), 'utf8');
|
||||
assert.match(source, /compareVersions=false/);
|
||||
assert.match(source, /}, \[sourceUrl, retry\]\);/);
|
||||
assert.match(source, /appliedMode.current === mode \? null : mode/);
|
||||
assert.doesNotMatch(source, /key=\{representation\}|setRetry\([^\n]*representation/);
|
||||
assert.match(source, /label="Версия облака"/);
|
||||
assert.match(source, /setAppliedRepresentation\(comparison \? representation/);
|
||||
assert.match(source, /setRepresentation\(data.default_representation/);
|
||||
assert.match(source, /const comparison = metadata\?\.comparison/);
|
||||
assert.match(source, /compareVersions && comparison/);
|
||||
const planner = await readFile(new URL('../src/components/missions/MissionZonePreview.tsx', import.meta.url), 'utf8');
|
||||
assert.doesNotMatch(planner, /compareVersions/);
|
||||
assert.match(planner, /reference_generation=\$\{encodeURIComponent\(generation\)\}/);
|
||||
const logic = await readFile(new URL('../src/core/missions/useMissionPlanner.ts', import.meta.url), 'utf8');
|
||||
assert.match(logic, /setPinnedGeneration\(next.zone.generation\)/);
|
||||
assert.match(logic, /setPinnedGeneration\(null\)/);
|
||||
});
|
||||
@@ -13,7 +13,8 @@ before(async () => {
|
||||
after(async () => { await server?.close(); });
|
||||
|
||||
const render = (focused = false) => renderToStaticMarkup(createElement(SpatialScene, {
|
||||
viewportRef: { current: null }, primaryFocused: focused, toolbar: null, renderer: null,
|
||||
viewportRef: { current: null }, primaryFocused: focused, toolbar: null,
|
||||
renderer: createElement('div', { 'data-testid': 'renderer' }, 'RENDERER'),
|
||||
sourceControls: createElement('div', { className: focused ? 'scene-focus-exit' : 'scene-source-controls' }, 'SOURCE_CONTROLS'),
|
||||
status: { label: 'Накопление данных', tone: 'neutral', message: 'Сканер неподвижен.' },
|
||||
metrics: createElement('div', null, 'METRICS'),
|
||||
@@ -37,6 +38,18 @@ test('focus exit remains viewport-owned outside the hidden information stack', (
|
||||
assert.equal((markup.match(/SOURCE_CONTROLS/g) ?? []).length, 1);
|
||||
});
|
||||
|
||||
test('normal and expanded scenes retain the renderer without mouse-navigation copy', async () => {
|
||||
for (const focused of [false, true]) {
|
||||
const markup = render(focused);
|
||||
assert.match(markup, /data-testid="renderer">RENDERER/);
|
||||
assert.doesNotMatch(markup, /scene-navigation-hint|Навигация по 3D-сцене|ЛКМ|ПКМ|Колесо/);
|
||||
}
|
||||
const source = await readFile(new URL('../../../packages/spatial-ui/src/SpatialScene.tsx', import.meta.url), 'utf8');
|
||||
const css = await readFile(new URL('../../../packages/spatial-ui/src/observation.css', import.meta.url), 'utf8');
|
||||
assert.doesNotMatch(source, /navigationReady|scene-navigation-hint/);
|
||||
assert.doesNotMatch(css, /scene-navigation-hint/);
|
||||
});
|
||||
|
||||
test('scene layout uses flow, retains compact metrics and removes only the calibration perimeter', async () => {
|
||||
const css = await readFile(new URL('../../../packages/spatial-ui/src/spatial.css', import.meta.url), 'utf8');
|
||||
const responsive = await readFile(new URL('../src/styles/responsive.css', import.meta.url), 'utf8');
|
||||
@@ -48,3 +61,13 @@ test('scene layout uses flow, retains compact metrics and removes only the calib
|
||||
assert.doesNotMatch(responsive, /\.scene-metrics\s*\{\s*display: none/);
|
||||
assert.match(calibration, /\.xgrids-k1-spatial-controls \{[^}]*border: 0;/);
|
||||
});
|
||||
|
||||
test('preparation status shares the source controls top axis on the opposite side', async () => {
|
||||
const css = await readFile(new URL('../../../packages/spatial-ui/src/spatial.css', import.meta.url), 'utf8');
|
||||
const stack = css.match(/\.scene-operation-status-stack \{[^}]*\}/)?.[0] ?? '';
|
||||
assert.match(stack, /top: 0\.85rem/);
|
||||
assert.match(stack, /right: 0\.85rem/);
|
||||
assert.match(stack, /min-height: 2\.75rem/);
|
||||
assert.match(stack, /align-content: center/);
|
||||
assert.doesNotMatch(stack, /left:|bottom:/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user