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