Nevar pievienot vairāk kā 25 tēmas Tēmai ir jāsākas ar burtu vai ciparu, tā var saturēt domu zīmes ('-') un var būt līdz 35 simboliem gara.
 
 
 
 

229 rindas
13 KiB

  1. import assert from 'node:assert/strict'
  2. import test from 'node:test'
  3. import { readFileSync } from 'node:fs'
  4. import { createRequire } from 'node:module'
  5. const requireWeb = createRequire(new URL('../../unreal_tran_web/package.json', import.meta.url))
  6. const ts = requireWeb('typescript')
  7. const web = new URL('../../unreal_tran_web/src/features/', import.meta.url)
  8. const compile = file => ts.transpileModule(readFileSync(new URL(file, web), 'utf8'), {
  9. compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext },
  10. }).outputText
  11. const dataUrl = source => `data:text/javascript;base64,${Buffer.from(source).toString('base64')}`
  12. const documentUrl = dataUrl(compile('training/trainingDocument.ts'))
  13. const bridgeUrl = dataUrl(compile('training-legacy/bridge.ts').replace(/from ['"]\.\.\/training\/trainingDocument['"]/, `from '${documentUrl}'`))
  14. const { toFormalContent, toLegacyProject } = await import(bridgeUrl)
  15. const { normalizeTrainingContent, validateTrainingProject, resequenceSteps, CONFRONTATION_DEFAULT_POSITIONS } = await import(documentUrl)
  16. const { createTrainingProject, normalizeTrainingProject } = await import(new URL('guide-legacy/demo-src/training-project-io.js', web))
  17. function fixture(mode) {
  18. const legacy = createTrainingProject({ trainingMode: mode.toLowerCase(), id: 'mode-fixture' })
  19. legacy.steps = legacy.steps.slice(0, 2)
  20. legacy.steps[0].id = 'step-first'
  21. legacy.steps[1].id = 'step-last'
  22. const content = toFormalContent(legacy, mode).content
  23. return { id: '163', name: 'Channel test', type: 'TRAINING', trainingMode: mode, status: 'DRAFT', content }
  24. }
  25. const open = project => normalizeTrainingProject(toLegacyProject(project))
  26. const saved = (legacy, previous) => toFormalContent(legacy, previous.trainingMode, previous.content).content
  27. test('physical UI fields replace the authoritative default profile and survive reopen', () => {
  28. const project = fixture('PHYSICAL')
  29. project.content.enabledChannels = ['VIRTUAL', 'PHYSICAL']
  30. project.content.channelProfiles.VIRTUAL = { type: 'VIRTUAL', renderMode: 'retain virtual settings' }
  31. project.content.channelProfiles.PHYSICAL.manualConfirmAllowed = false
  32. const original = structuredClone(project.content)
  33. const legacy = open(project)
  34. const changes = { stationName: 'Truck diagnostic bay', equipmentCode: 'TRUCK-TEST-001', gatewayProtocol: 'MODBUS TCP TEST',
  35. sensorChannels: 'pressure,position,test-signal', safetyInterlocks: 'E-STOP TEST' }
  36. Object.assign(legacy.modeConfig, changes)
  37. const result = saved(legacy, project)
  38. assert.deepEqual(result.modeConfig, result.channelProfiles.PHYSICAL)
  39. for (const [field, value] of Object.entries(changes)) assert.equal(result.modeConfig[field], value)
  40. assert.equal(result.modeConfig.manualConfirmAllowed, false)
  41. for (const [field, value] of Object.entries(original.channelProfiles.VIRTUAL)) {
  42. assert.deepEqual(result.channelProfiles.VIRTUAL[field], value)
  43. }
  44. const reopened = open({ ...project, content: result })
  45. for (const [field, value] of Object.entries(changes)) assert.equal(reopened.modeConfig[field], value)
  46. assert.deepEqual(project.content, original)
  47. })
  48. test('confrontation canonical minutes populate UI and user edits update both default profile and mirror', () => {
  49. const project = fixture('CONFRONTATION')
  50. const profile = { ...project.content.modeConfig, roundDurationMinutes: 7, submitPositions: ['COMMANDER'],
  51. positionActions: { SUBMIT: ['COMMANDER'] }, teamSize: 2 }
  52. delete profile.roundDuration
  53. project.content.modeConfig = profile
  54. project.content.channelProfiles.CONFRONTATION = profile
  55. const legacy = open(project)
  56. assert.equal(legacy.modeConfig.roundDuration, 7)
  57. Object.assign(legacy.modeConfig, { roundDuration: 12, redTeamName: 'Red maintenance', blueTeamName: 'Blue maintenance',
  58. eventInjections: 'signal drift', objective: 'isolate test fault', winRule: 'fewer errors wins' })
  59. const result = saved(legacy, project)
  60. assert.equal(result.modeConfig.roundDurationMinutes, 12)
  61. assert.equal(result.modeConfig.roundDuration, 12)
  62. assert.deepEqual(result.modeConfig, result.channelProfiles.CONFRONTATION)
  63. assert.deepEqual(result.modeConfig.submitPositions, ['COMMANDER'])
  64. assert.deepEqual(result.modeConfig.positionActions, { SUBMIT: ['COMMANDER'] })
  65. assert.equal(open({ ...project, content: result }).modeConfig.roundDuration, 12)
  66. })
  67. test('seconds override keeps its exact duration until the displayed minutes are edited', () => {
  68. const project = fixture('CONFRONTATION')
  69. Object.assign(project.content.modeConfig, { durationSeconds: 90, roundDurationMinutes: 30 })
  70. project.content.channelProfiles.CONFRONTATION = structuredClone(project.content.modeConfig)
  71. const legacy = open(project)
  72. assert.equal(legacy.modeConfig.roundDuration, 1.5)
  73. assert.equal(saved(legacy, project).modeConfig.durationSeconds, 90)
  74. legacy.modeConfig.roundDuration = 7
  75. const result = saved(legacy, project)
  76. assert.equal(result.modeConfig.durationSeconds, 420)
  77. assert.equal(result.modeConfig.roundDurationMinutes, 7)
  78. assert.equal(open({ ...project, content: result }).modeConfig.roundDuration, 7)
  79. })
  80. test('runtime codes, explicit event sources, confirmation policy and roles survive legacy normalization/save', () => {
  81. const project = fixture('PHYSICAL')
  82. Object.assign(project.content.steps[0], {
  83. stepCode: 'CUSTOM-FIRST', actionCode: 'CUSTOM-FIRST-ACTION',
  84. physicalEventRule: { source: 'DEVICE', eventType: 'PRESSURE_READY', progressPercent: 25, manualConfirmAllowed: false },
  85. allowedPositions: ['MECHANIC'], positionActions: { CHECK: ['MECHANIC'] },
  86. })
  87. Object.assign(project.content.steps[1], { physicalEventRule: { source: 'VISION', eventType: 'VISION_PASS', progressPercent: 100, manualConfirmAllowed: false } })
  88. const result = saved(open(project), project)
  89. assert.equal(result.steps[0].stepCode, 'CUSTOM-FIRST')
  90. assert.equal(result.steps[0].actionCode, 'CUSTOM-FIRST-ACTION')
  91. assert.deepEqual(result.steps[0].physicalEventRule, project.content.steps[0].physicalEventRule)
  92. assert.deepEqual(result.steps[1].physicalEventRule, project.content.steps[1].physicalEventRule)
  93. assert.deepEqual(result.steps[0].allowedPositions, ['MECHANIC'])
  94. assert.deepEqual(result.steps[0].positionActions, { CHECK: ['MECHANIC'] })
  95. })
  96. test('reordering uses step identity for event rules and rebuilds monotonic progress in new order', () => {
  97. const project = fixture('PHYSICAL')
  98. project.content.steps[0].physicalEventRule = { source: 'DEVICE', eventType: 'PRESSURE_READY', progressPercent: 25, manualConfirmAllowed: false }
  99. project.content.steps[1].physicalEventRule = { source: 'VISION', eventType: 'VISION_PASS', progressPercent: 100, manualConfirmAllowed: false }
  100. const legacy = open(project)
  101. legacy.steps.reverse()
  102. const result = saved(legacy, project)
  103. assert.deepEqual(result.steps.map(step => step.id), ['step-last', 'step-first'])
  104. assert.deepEqual(result.steps.map(step => step.physicalEventRule.source), ['VISION', 'DEVICE'])
  105. assert.deepEqual(result.steps.map(step => step.physicalEventRule.progressPercent), [50, 100])
  106. assert.deepEqual(result.steps.map(step => step.order), [1, 2])
  107. })
  108. test('adding and deleting steps derives valid physical progress without copying another step role or event', () => {
  109. const project = fixture('PHYSICAL')
  110. const legacy = open(project)
  111. legacy.steps.splice(1, 0, { ...legacy.steps[0], id: 'new-step' })
  112. delete legacy.steps[1].physicalEventRule
  113. delete legacy.steps[1].stepCode
  114. delete legacy.steps[1].actionCode
  115. const result = saved(legacy, project)
  116. assert.deepEqual(result.steps.map(step => step.physicalEventRule.progressPercent), [33, 67, 100])
  117. assert.equal(result.steps[1].physicalEventRule.source, 'MANUAL')
  118. assert.equal(new Set(result.steps.map(step => step.stepCode)).size, 3)
  119. assert.equal(new Set(result.steps.map(step => step.actionCode)).size, 3)
  120. assert.equal(result.steps[0].stepCode, project.content.steps[0].stepCode)
  121. assert.equal(result.steps[2].stepCode, project.content.steps[1].stepCode)
  122. assert.ok(/^[A-Za-z][A-Za-z0-9._-]{1,99}$/.test(result.steps[1].physicalEventRule.eventType))
  123. const next = { ...project, content: result }
  124. const reduced = open(next)
  125. reduced.steps = reduced.steps.slice(0, 1)
  126. assert.equal(saved(reduced, next).steps[0].physicalEventRule.progressPercent, 100)
  127. })
  128. test('duplicating a saved step gives the new identity distinct runtime codes while keeping the original stable', () => {
  129. const project = fixture('CONFRONTATION')
  130. const legacy = open(project)
  131. legacy.steps.push({ ...legacy.steps[0], id: 'copied-step', stepCode: project.content.steps[0].stepCode,
  132. actionCode: project.content.steps[0].actionCode })
  133. const result = saved(legacy, project)
  134. assert.equal(result.steps[0].stepCode, project.content.steps[0].stepCode)
  135. assert.equal(result.steps[0].actionCode, project.content.steps[0].actionCode)
  136. assert.equal(new Set(result.steps.map(step => step.stepCode)).size, 3)
  137. assert.equal(new Set(result.steps.map(step => step.actionCode)).size, 3)
  138. })
  139. test('copied action-specific role mapping follows the new step action instead of pointing back to its source', () => {
  140. for (const retainCopiedCode of [true, false]) {
  141. const project = fixture('CONFRONTATION')
  142. const sourceCode = project.content.steps[0].actionCode
  143. project.content.steps[0].positionActions = { [sourceCode]: ['MECHANIC'] }
  144. const legacy = open(project)
  145. const copied = { ...legacy.steps[0], id: 'copied-step', positionActions: { [sourceCode]: ['MECHANIC'] } }
  146. if (!retainCopiedCode) delete copied.actionCode
  147. legacy.steps.push(copied)
  148. const result = saved(legacy, project)
  149. const newStep = result.steps.at(-1)
  150. assert.notEqual(newStep.actionCode, sourceCode)
  151. assert.deepEqual(newStep.positionActions, { [newStep.actionCode]: ['MECHANIC'] })
  152. assert.deepEqual(result.steps[0].positionActions, { [sourceCode]: ['MECHANIC'] })
  153. }
  154. })
  155. test('old documents without channelProfiles migrate edited modeConfig without losing other enabled channel configuration', () => {
  156. const project = fixture('CONFRONTATION')
  157. delete project.content.channelProfiles
  158. delete project.content.enabledChannels
  159. const legacy = open(project)
  160. legacy.modeConfig.roundDuration = 9
  161. const result = saved(legacy, project)
  162. assert.deepEqual(result.enabledChannels, ['CONFRONTATION'])
  163. assert.equal(result.channelProfiles.CONFRONTATION.roundDurationMinutes, 9)
  164. assert.deepEqual(result.modeConfig, result.channelProfiles.CONFRONTATION)
  165. assert.deepEqual(normalizeTrainingContent(result, 'CONFRONTATION').modeConfig, result.modeConfig)
  166. })
  167. test('explicitly empty step positions remain empty through editor save and are blocked by publish validation', () => {
  168. const project = fixture('CONFRONTATION')
  169. const legacy = open(project)
  170. legacy.steps[0].allowedPositions = []
  171. const result = saved(legacy, project)
  172. assert.deepEqual(result.steps[0].allowedPositions, [], 'clearing all roles must not restore previous or default grants')
  173. assert.deepEqual(open({ ...project, content: result }).steps[0].allowedPositions, [])
  174. const validation = validateTrainingProject({ ...project, code: 'TEST-CONF', content: result })
  175. assert.ok(validation.errors.some(issue => issue.path === 'steps[0].allowedPositions'))
  176. assert.equal(validation.ok, false)
  177. })
  178. test('only missing legacy step positions receive compatibility defaults; explicit malformed or empty values grant no roles', () => {
  179. const project = fixture('CONFRONTATION')
  180. for (const supplied of [[], null, 'COMMANDER']) {
  181. const content = structuredClone(project.content)
  182. content.steps[0].allowedPositions = supplied
  183. assert.deepEqual(normalizeTrainingContent(content, 'CONFRONTATION').steps[0].allowedPositions, [])
  184. }
  185. delete project.content.steps[0].allowedPositions
  186. assert.deepEqual(normalizeTrainingContent(project.content, 'CONFRONTATION').steps[0].allowedPositions, CONFRONTATION_DEFAULT_POSITIONS)
  187. })
  188. test('reordering never repopulates an explicitly empty role set and retains a restricted role set', () => {
  189. const project = fixture('CONFRONTATION')
  190. project.content.steps[0].allowedPositions = []
  191. project.content.steps[1].allowedPositions = ['COMMANDER']
  192. const reordered = resequenceSteps([...project.content.steps].reverse(), 'CONFRONTATION')
  193. assert.deepEqual(reordered.map(step => step.allowedPositions), [['COMMANDER'], []])
  194. const legacy = open(project)
  195. legacy.steps.reverse()
  196. assert.deepEqual(saved(legacy, project).steps.map(step => step.allowedPositions), [['COMMANDER'], []])
  197. })
  198. test('new runtime codes avoid case-insensitive collisions using the server uniqueness rules', () => {
  199. const project = fixture('CONFRONTATION')
  200. project.content.steps = project.content.steps.slice(0, 1)
  201. project.content.steps[0].stepCode = 'confrontation-s02'
  202. project.content.steps[0].actionCode = 'confrontation-a02'
  203. const legacy = open(project)
  204. const copied = { ...legacy.steps[0], id: 'copied-case-step' }
  205. delete copied.stepCode
  206. delete copied.actionCode
  207. legacy.steps.push(copied)
  208. const result = saved(legacy, project)
  209. assert.equal(result.steps[0].stepCode, 'confrontation-s02')
  210. assert.equal(result.steps[0].actionCode, 'confrontation-a02')
  211. assert.equal(new Set(result.steps.map(step => step.stepCode.toUpperCase())).size, 2)
  212. assert.equal(new Set(result.steps.map(step => step.actionCode.toUpperCase())).size, 2)
  213. })