Files
NODEDC_MISSION_CORE/apps/control-station/test/sessionDisplayProfile.test.mjs
T

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;}
});