135 lines
8.4 KiB
JavaScript
135 lines
8.4 KiB
JavaScript
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;}
|
|
});
|