import assert from 'node:assert/strict' import test from 'node:test' import { readFileSync } from 'node:fs' import { createRequire } from 'node:module' const requireWeb = createRequire(new URL('../../unreal_tran_web/package.json', import.meta.url)) const ts = requireWeb('typescript') const web = new URL('../../unreal_tran_web/src/features/', import.meta.url) const compile = file => ts.transpileModule(readFileSync(new URL(file, web), 'utf8'), { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext }, }).outputText const dataUrl = source => `data:text/javascript;base64,${Buffer.from(source).toString('base64')}` const documentUrl = dataUrl(compile('training/trainingDocument.ts')) const bridgeUrl = dataUrl(compile('training-legacy/bridge.ts').replace(/from ['"]\.\.\/training\/trainingDocument['"]/, `from '${documentUrl}'`)) const { toFormalContent, toLegacyProject } = await import(bridgeUrl) const { normalizeTrainingContent, validateTrainingProject, resequenceSteps, CONFRONTATION_DEFAULT_POSITIONS } = await import(documentUrl) const { createTrainingProject, normalizeTrainingProject } = await import(new URL('guide-legacy/demo-src/training-project-io.js', web)) function fixture(mode) { const legacy = createTrainingProject({ trainingMode: mode.toLowerCase(), id: 'mode-fixture' }) legacy.steps = legacy.steps.slice(0, 2) legacy.steps[0].id = 'step-first' legacy.steps[1].id = 'step-last' const content = toFormalContent(legacy, mode).content return { id: '163', name: 'Channel test', type: 'TRAINING', trainingMode: mode, status: 'DRAFT', content } } const open = project => normalizeTrainingProject(toLegacyProject(project)) const saved = (legacy, previous) => toFormalContent(legacy, previous.trainingMode, previous.content).content test('physical UI fields replace the authoritative default profile and survive reopen', () => { const project = fixture('PHYSICAL') project.content.enabledChannels = ['VIRTUAL', 'PHYSICAL'] project.content.channelProfiles.VIRTUAL = { type: 'VIRTUAL', renderMode: 'retain virtual settings' } project.content.channelProfiles.PHYSICAL.manualConfirmAllowed = false const original = structuredClone(project.content) const legacy = open(project) const changes = { stationName: 'Truck diagnostic bay', equipmentCode: 'TRUCK-TEST-001', gatewayProtocol: 'MODBUS TCP TEST', sensorChannels: 'pressure,position,test-signal', safetyInterlocks: 'E-STOP TEST' } Object.assign(legacy.modeConfig, changes) const result = saved(legacy, project) assert.deepEqual(result.modeConfig, result.channelProfiles.PHYSICAL) for (const [field, value] of Object.entries(changes)) assert.equal(result.modeConfig[field], value) assert.equal(result.modeConfig.manualConfirmAllowed, false) for (const [field, value] of Object.entries(original.channelProfiles.VIRTUAL)) { assert.deepEqual(result.channelProfiles.VIRTUAL[field], value) } const reopened = open({ ...project, content: result }) for (const [field, value] of Object.entries(changes)) assert.equal(reopened.modeConfig[field], value) assert.deepEqual(project.content, original) }) test('confrontation canonical minutes populate UI and user edits update both default profile and mirror', () => { const project = fixture('CONFRONTATION') const profile = { ...project.content.modeConfig, roundDurationMinutes: 7, submitPositions: ['COMMANDER'], positionActions: { SUBMIT: ['COMMANDER'] }, teamSize: 2 } delete profile.roundDuration project.content.modeConfig = profile project.content.channelProfiles.CONFRONTATION = profile const legacy = open(project) assert.equal(legacy.modeConfig.roundDuration, 7) Object.assign(legacy.modeConfig, { roundDuration: 12, redTeamName: 'Red maintenance', blueTeamName: 'Blue maintenance', eventInjections: 'signal drift', objective: 'isolate test fault', winRule: 'fewer errors wins' }) const result = saved(legacy, project) assert.equal(result.modeConfig.roundDurationMinutes, 12) assert.equal(result.modeConfig.roundDuration, 12) assert.deepEqual(result.modeConfig, result.channelProfiles.CONFRONTATION) assert.deepEqual(result.modeConfig.submitPositions, ['COMMANDER']) assert.deepEqual(result.modeConfig.positionActions, { SUBMIT: ['COMMANDER'] }) assert.equal(open({ ...project, content: result }).modeConfig.roundDuration, 12) }) test('seconds override keeps its exact duration until the displayed minutes are edited', () => { const project = fixture('CONFRONTATION') Object.assign(project.content.modeConfig, { durationSeconds: 90, roundDurationMinutes: 30 }) project.content.channelProfiles.CONFRONTATION = structuredClone(project.content.modeConfig) const legacy = open(project) assert.equal(legacy.modeConfig.roundDuration, 1.5) assert.equal(saved(legacy, project).modeConfig.durationSeconds, 90) legacy.modeConfig.roundDuration = 7 const result = saved(legacy, project) assert.equal(result.modeConfig.durationSeconds, 420) assert.equal(result.modeConfig.roundDurationMinutes, 7) assert.equal(open({ ...project, content: result }).modeConfig.roundDuration, 7) }) test('runtime codes, explicit event sources, confirmation policy and roles survive legacy normalization/save', () => { const project = fixture('PHYSICAL') Object.assign(project.content.steps[0], { stepCode: 'CUSTOM-FIRST', actionCode: 'CUSTOM-FIRST-ACTION', physicalEventRule: { source: 'DEVICE', eventType: 'PRESSURE_READY', progressPercent: 25, manualConfirmAllowed: false }, allowedPositions: ['MECHANIC'], positionActions: { CHECK: ['MECHANIC'] }, }) Object.assign(project.content.steps[1], { physicalEventRule: { source: 'VISION', eventType: 'VISION_PASS', progressPercent: 100, manualConfirmAllowed: false } }) const result = saved(open(project), project) assert.equal(result.steps[0].stepCode, 'CUSTOM-FIRST') assert.equal(result.steps[0].actionCode, 'CUSTOM-FIRST-ACTION') assert.deepEqual(result.steps[0].physicalEventRule, project.content.steps[0].physicalEventRule) assert.deepEqual(result.steps[1].physicalEventRule, project.content.steps[1].physicalEventRule) assert.deepEqual(result.steps[0].allowedPositions, ['MECHANIC']) assert.deepEqual(result.steps[0].positionActions, { CHECK: ['MECHANIC'] }) }) test('reordering uses step identity for event rules and rebuilds monotonic progress in new order', () => { const project = fixture('PHYSICAL') project.content.steps[0].physicalEventRule = { source: 'DEVICE', eventType: 'PRESSURE_READY', progressPercent: 25, manualConfirmAllowed: false } project.content.steps[1].physicalEventRule = { source: 'VISION', eventType: 'VISION_PASS', progressPercent: 100, manualConfirmAllowed: false } const legacy = open(project) legacy.steps.reverse() const result = saved(legacy, project) assert.deepEqual(result.steps.map(step => step.id), ['step-last', 'step-first']) assert.deepEqual(result.steps.map(step => step.physicalEventRule.source), ['VISION', 'DEVICE']) assert.deepEqual(result.steps.map(step => step.physicalEventRule.progressPercent), [50, 100]) assert.deepEqual(result.steps.map(step => step.order), [1, 2]) }) test('adding and deleting steps derives valid physical progress without copying another step role or event', () => { const project = fixture('PHYSICAL') const legacy = open(project) legacy.steps.splice(1, 0, { ...legacy.steps[0], id: 'new-step' }) delete legacy.steps[1].physicalEventRule delete legacy.steps[1].stepCode delete legacy.steps[1].actionCode const result = saved(legacy, project) assert.deepEqual(result.steps.map(step => step.physicalEventRule.progressPercent), [33, 67, 100]) assert.equal(result.steps[1].physicalEventRule.source, 'MANUAL') assert.equal(new Set(result.steps.map(step => step.stepCode)).size, 3) assert.equal(new Set(result.steps.map(step => step.actionCode)).size, 3) assert.equal(result.steps[0].stepCode, project.content.steps[0].stepCode) assert.equal(result.steps[2].stepCode, project.content.steps[1].stepCode) assert.ok(/^[A-Za-z][A-Za-z0-9._-]{1,99}$/.test(result.steps[1].physicalEventRule.eventType)) const next = { ...project, content: result } const reduced = open(next) reduced.steps = reduced.steps.slice(0, 1) assert.equal(saved(reduced, next).steps[0].physicalEventRule.progressPercent, 100) }) test('duplicating a saved step gives the new identity distinct runtime codes while keeping the original stable', () => { const project = fixture('CONFRONTATION') const legacy = open(project) legacy.steps.push({ ...legacy.steps[0], id: 'copied-step', stepCode: project.content.steps[0].stepCode, actionCode: project.content.steps[0].actionCode }) const result = saved(legacy, project) assert.equal(result.steps[0].stepCode, project.content.steps[0].stepCode) assert.equal(result.steps[0].actionCode, project.content.steps[0].actionCode) assert.equal(new Set(result.steps.map(step => step.stepCode)).size, 3) assert.equal(new Set(result.steps.map(step => step.actionCode)).size, 3) }) test('copied action-specific role mapping follows the new step action instead of pointing back to its source', () => { for (const retainCopiedCode of [true, false]) { const project = fixture('CONFRONTATION') const sourceCode = project.content.steps[0].actionCode project.content.steps[0].positionActions = { [sourceCode]: ['MECHANIC'] } const legacy = open(project) const copied = { ...legacy.steps[0], id: 'copied-step', positionActions: { [sourceCode]: ['MECHANIC'] } } if (!retainCopiedCode) delete copied.actionCode legacy.steps.push(copied) const result = saved(legacy, project) const newStep = result.steps.at(-1) assert.notEqual(newStep.actionCode, sourceCode) assert.deepEqual(newStep.positionActions, { [newStep.actionCode]: ['MECHANIC'] }) assert.deepEqual(result.steps[0].positionActions, { [sourceCode]: ['MECHANIC'] }) } }) test('old documents without channelProfiles migrate edited modeConfig without losing other enabled channel configuration', () => { const project = fixture('CONFRONTATION') delete project.content.channelProfiles delete project.content.enabledChannels const legacy = open(project) legacy.modeConfig.roundDuration = 9 const result = saved(legacy, project) assert.deepEqual(result.enabledChannels, ['CONFRONTATION']) assert.equal(result.channelProfiles.CONFRONTATION.roundDurationMinutes, 9) assert.deepEqual(result.modeConfig, result.channelProfiles.CONFRONTATION) assert.deepEqual(normalizeTrainingContent(result, 'CONFRONTATION').modeConfig, result.modeConfig) }) test('explicitly empty step positions remain empty through editor save and are blocked by publish validation', () => { const project = fixture('CONFRONTATION') const legacy = open(project) legacy.steps[0].allowedPositions = [] const result = saved(legacy, project) assert.deepEqual(result.steps[0].allowedPositions, [], 'clearing all roles must not restore previous or default grants') assert.deepEqual(open({ ...project, content: result }).steps[0].allowedPositions, []) const validation = validateTrainingProject({ ...project, code: 'TEST-CONF', content: result }) assert.ok(validation.errors.some(issue => issue.path === 'steps[0].allowedPositions')) assert.equal(validation.ok, false) }) test('only missing legacy step positions receive compatibility defaults; explicit malformed or empty values grant no roles', () => { const project = fixture('CONFRONTATION') for (const supplied of [[], null, 'COMMANDER']) { const content = structuredClone(project.content) content.steps[0].allowedPositions = supplied assert.deepEqual(normalizeTrainingContent(content, 'CONFRONTATION').steps[0].allowedPositions, []) } delete project.content.steps[0].allowedPositions assert.deepEqual(normalizeTrainingContent(project.content, 'CONFRONTATION').steps[0].allowedPositions, CONFRONTATION_DEFAULT_POSITIONS) }) test('reordering never repopulates an explicitly empty role set and retains a restricted role set', () => { const project = fixture('CONFRONTATION') project.content.steps[0].allowedPositions = [] project.content.steps[1].allowedPositions = ['COMMANDER'] const reordered = resequenceSteps([...project.content.steps].reverse(), 'CONFRONTATION') assert.deepEqual(reordered.map(step => step.allowedPositions), [['COMMANDER'], []]) const legacy = open(project) legacy.steps.reverse() assert.deepEqual(saved(legacy, project).steps.map(step => step.allowedPositions), [['COMMANDER'], []]) }) test('new runtime codes avoid case-insensitive collisions using the server uniqueness rules', () => { const project = fixture('CONFRONTATION') project.content.steps = project.content.steps.slice(0, 1) project.content.steps[0].stepCode = 'confrontation-s02' project.content.steps[0].actionCode = 'confrontation-a02' const legacy = open(project) const copied = { ...legacy.steps[0], id: 'copied-case-step' } delete copied.stepCode delete copied.actionCode legacy.steps.push(copied) const result = saved(legacy, project) assert.equal(result.steps[0].stepCode, 'confrontation-s02') assert.equal(result.steps[0].actionCode, 'confrontation-a02') assert.equal(new Set(result.steps.map(step => step.stepCode.toUpperCase())).size, 2) assert.equal(new Set(result.steps.map(step => step.actionCode.toUpperCase())).size, 2) })