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