import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' import test from 'node:test' const webSource = new URL('../../unreal_tran_web/src/features/', import.meta.url) const editorSource = await readFile(new URL('guide-legacy/demo-src/training-editor.js', webSource), 'utf8') const stepCreationMethods = editorSource.slice(editorSource.indexOf(' addStep()'), editorSource.indexOf(' removeStep()')) const storageFixture = () => { const values = new Map() return { getItem: key => values.get(key) ?? null, setItem: (key, value) => values.set(key, value), removeItem: key => values.delete(key) } } for (const runtime of ['guide-legacy/demo-src', 'virtual-training-legacy/upstream']) { const { createTrainingProject, normalizeTrainingProject, validateTrainingProject, saveTrainingProject, loadTrainingProject } = await import(new URL(`${runtime}/training-project-io.js`, webSource)) test(`${runtime}: physical authored configuration survives save and reopen`, () => { const project = createTrainingProject({ trainingMode: 'physical' }) project.modeConfig = { stationName: 'Fox inspection station', equipmentCode: 'FOX-PHY-001', gatewayProtocol: 'MQTT', sensorChannels: 'pressure,temperature', safetyInterlocks: 'emergency stop,door lock', } const storage = storageFixture() const saved = saveTrainingProject(storage, project, { status: 'draft' }) assert.deepEqual(loadTrainingProject(storage, saved.id).modeConfig, project.modeConfig) }) test(`${runtime}: clearing optional physical text does not restore demo values`, () => { const project = createTrainingProject({ trainingMode: 'physical' }) project.modeConfig = { stationName: '', equipmentCode: '', gatewayProtocol: '', sensorChannels: '', safetyInterlocks: '' } const storage = storageFixture() const saved = saveTrainingProject(storage, project, { status: 'draft' }) assert.deepEqual(loadTrainingProject(storage, saved.id).modeConfig, project.modeConfig) const report = validateTrainingProject(saved) assert.ok(report.warnings.some(item => item.path === 'modeConfig.stationName')) assert.ok(report.warnings.some(item => item.path === 'modeConfig.equipmentCode')) }) test(`${runtime}: confrontation configuration keeps a supported 50-member team and all authored text`, () => { const project = createTrainingProject({ trainingMode: 'confrontation' }) project.modeConfig = { redTeamName: 'Red maintenance', blueTeamName: 'Blue inspection', teamSize: 50, roundDuration: 75, objective: 'Find fault', eventInjections: 'sensor failure', winRule: 'Score then elapsed time', } const storage = storageFixture() const saved = saveTrainingProject(storage, project, { status: 'draft' }) const reloaded = loadTrainingProject(storage, saved.id) assert.deepEqual(reloaded.modeConfig, project.modeConfig) assert.equal(validateTrainingProject(reloaded).errors.some(item => item.path.startsWith('modeConfig.')), false) }) test(`${runtime}: explicit empty confrontation text stays empty and empty win rule warns`, () => { const project = createTrainingProject({ trainingMode: 'confrontation' }) for (const key of ['redTeamName', 'blueTeamName', 'objective', 'eventInjections', 'winRule']) project.modeConfig[key] = '' const normalized = normalizeTrainingProject(project) for (const key of ['redTeamName', 'blueTeamName', 'objective', 'eventInjections', 'winRule']) assert.equal(normalized.modeConfig[key], '') assert.ok(validateTrainingProject(normalized).warnings.some(item => item.path === 'modeConfig.winRule')) }) test(`${runtime}: invalid team sizes remain visible and fail local validation instead of silently clamping`, () => { for (const teamSize of [0, -1, 2.5, 201]) { const project = createTrainingProject({ trainingMode: 'confrontation' }) project.modeConfig.teamSize = teamSize const normalized = normalizeTrainingProject(project) assert.equal(normalized.modeConfig.teamSize, teamSize) assert.ok(validateTrainingProject(normalized).errors.some(item => item.path === 'modeConfig.teamSize')) } }) test(`${runtime}: invalid duration fails but existing sub-minute and long durations are preserved`, () => { for (const roundDuration of [0, -1]) { const project = createTrainingProject({ trainingMode: 'confrontation' }) project.modeConfig.roundDuration = roundDuration assert.ok(validateTrainingProject(project).errors.some(item => item.path === 'modeConfig.roundDuration')) } for (const roundDuration of [0.5, 1.5, 720]) { const project = createTrainingProject({ trainingMode: 'confrontation' }) project.modeConfig.roundDuration = roundDuration assert.equal(normalizeTrainingProject(project).modeConfig.roundDuration, roundDuration) assert.equal(validateTrainingProject(project).errors.some(item => item.path === 'modeConfig.roundDuration'), false) } }) test(`${runtime}: existing device event and team position contracts survive normalization and reorder`, () => { const project = createTrainingProject({ trainingMode: 'physical' }) const contract = { stepCode: 'PHY-FOX-INSPECT', actionCode: 'FOX_INSPECT', physicalEventRule: { source: 'VISION', eventType: 'fox.inspection.done', progressPercent: 50, manualConfirmAllowed: false }, allowedPositions: ['observer'], positionActions: { observer: ['FOX_INSPECT'] }, } Object.assign(project.steps[0], structuredClone(contract)) const stepId = project.steps[0].id const storage = storageFixture() const saved = saveTrainingProject(storage, project, { status: 'draft' }) const reopened = loadTrainingProject(storage, saved.id) reopened.steps.reverse() const normalized = normalizeTrainingProject(reopened) const retained = normalized.steps.find(step => step.id === stepId) for (const key of Object.keys(contract)) assert.deepEqual(retained[key], contract[key]) retained.physicalEventRule.source = 'MANUAL' assert.equal(project.steps[0].physicalEventRule.source, 'VISION') }) test(`${runtime}: add and duplicate allocate a new step without copying its source runtime codes`, () => { const Harness = new Function('clone', 'normalizeTrainingProject', `return class { ${stepCreationMethods} }`)(structuredClone, normalizeTrainingProject) for (const method of ['addStep', 'duplicateStep']) { const editor = new Harness() editor.project = createTrainingProject({ trainingMode: 'physical' }) editor.selectedStep = editor.project.steps[0] editor.selectedStep.stepCode = 'EXISTING-STEP' editor.selectedStep.actionCode = 'EXISTING_ACTION' const sourceId = editor.selectedStep.id for (const name of ['renumberSteps', 'markDirty', 'renderProjectHeader', 'renderScene', 'renderSteps', 'renderInspector', 'toast']) editor[name] = () => {} editor[method]() const created = editor.project.steps.find(step => step.id === editor.selectedStepId) const original = editor.project.steps.find(step => step.id === sourceId) assert.notEqual(created.id, original.id) assert.equal(created.stepCode, undefined) assert.equal(created.actionCode, undefined) assert.equal(original.stepCode, 'EXISTING-STEP') assert.equal(original.actionCode, 'EXISTING_ACTION') } }) }