adapt-authoring-adaptframework 1.10.3 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,15 @@
1
+ name: Tests
2
+ on: push
3
+ jobs:
4
+ default:
5
+ runs-on: ubuntu-latest
6
+ permissions:
7
+ contents: read
8
+ steps:
9
+ - uses: actions/checkout@v4
10
+ - uses: actions/setup-node@v4
11
+ with:
12
+ node-version: 'lts/*'
13
+ cache: 'npm'
14
+ - run: npm ci
15
+ - run: npm test
package/package.json CHANGED
@@ -1,12 +1,15 @@
1
1
  {
2
2
  "name": "adapt-authoring-adaptframework",
3
- "version": "1.10.3",
3
+ "version": "1.12.0",
4
4
  "description": "Adapt framework integration for the Adapt authoring tool",
5
5
  "homepage": "https://github.com/adapt-security/adapt-authoring-adaptframework",
6
6
  "license": "GPL-3.0",
7
7
  "type": "module",
8
8
  "main": "index.js",
9
9
  "repository": "github:adapt-security/adapt-authoring-adaptframework",
10
+ "scripts": {
11
+ "test": "node --test 'tests/**/*.spec.js'"
12
+ },
10
13
  "dependencies": {
11
14
  "adapt-authoring-browserslist": "^1.2.1",
12
15
  "adapt-authoring-content": "^1.2.3",
@@ -0,0 +1,450 @@
1
+ import { describe, it, before, after } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import _ from 'lodash'
4
+ import fs from 'fs/promises'
5
+ import path from 'path'
6
+ import { fileURLToPath } from 'url'
7
+ import AdaptFrameworkBuild from '../lib/AdaptFrameworkBuild.js'
8
+
9
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
10
+
11
+ /**
12
+ * transformContentItems is async and calls App.instance.waitForModule('tags')
13
+ * at the end. We extract the synchronous logic here to test it in isolation.
14
+ */
15
+ function transformContentItemsSync (build, items) {
16
+ items.forEach(i => {
17
+ ['_courseId', '_parentId'].forEach(k => {
18
+ i[k] = build.idMap[i[k]] || i[k]
19
+ })
20
+ if (i._friendlyId) {
21
+ i._id = i._friendlyId
22
+ }
23
+ const idMapEntries = Object.entries(build.assetData.idMap)
24
+ const itemString = idMapEntries.reduce((s, [_id, assetPath]) => {
25
+ const relPath = assetPath.replace(build.courseDir, 'course')
26
+ return s.replace(new RegExp(_id, 'g'), relPath)
27
+ }, JSON.stringify(i))
28
+ Object.assign(i, JSON.parse(itemString))
29
+ if (i._component) {
30
+ i._component = build.enabledPlugins.find(p => p.name === i._component)?.targetAttribute.slice(1) ?? i._component
31
+ }
32
+ })
33
+ build.enabledPlugins.forEach(({ targetAttribute, type }) => {
34
+ let key = `_${type}`
35
+ if (type === 'component' || type === 'extension') key += 's'
36
+ const globals = build.courseData.course.data._globals
37
+ if (!globals?.[targetAttribute]) return
38
+ _.merge(globals, { [key]: { [targetAttribute]: globals[targetAttribute] } })
39
+ delete globals[targetAttribute]
40
+ })
41
+ }
42
+
43
+ describe('AdaptFrameworkBuild', () => {
44
+ describe('constructor', () => {
45
+ it('should set action and related boolean flags for preview', () => {
46
+ const build = new AdaptFrameworkBuild({ action: 'preview', courseId: 'c1', userId: 'u1' })
47
+ assert.equal(build.action, 'preview')
48
+ assert.equal(build.isPreview, true)
49
+ assert.equal(build.isPublish, false)
50
+ assert.equal(build.isExport, false)
51
+ })
52
+
53
+ it('should set action and related boolean flags for publish', () => {
54
+ const build = new AdaptFrameworkBuild({ action: 'publish', courseId: 'c1', userId: 'u1' })
55
+ assert.equal(build.isPreview, false)
56
+ assert.equal(build.isPublish, true)
57
+ assert.equal(build.isExport, false)
58
+ })
59
+
60
+ it('should set action and related boolean flags for export', () => {
61
+ const build = new AdaptFrameworkBuild({ action: 'export', courseId: 'c1', userId: 'u1' })
62
+ assert.equal(build.isPreview, false)
63
+ assert.equal(build.isPublish, false)
64
+ assert.equal(build.isExport, true)
65
+ })
66
+
67
+ it('should default compress to false for preview', () => {
68
+ const build = new AdaptFrameworkBuild({ action: 'preview', courseId: 'c1', userId: 'u1' })
69
+ assert.equal(build.compress, false)
70
+ })
71
+
72
+ it('should default compress to true for publish', () => {
73
+ const build = new AdaptFrameworkBuild({ action: 'publish', courseId: 'c1', userId: 'u1' })
74
+ assert.equal(build.compress, true)
75
+ })
76
+
77
+ it('should default compress to true for export', () => {
78
+ const build = new AdaptFrameworkBuild({ action: 'export', courseId: 'c1', userId: 'u1' })
79
+ assert.equal(build.compress, true)
80
+ })
81
+
82
+ it('should allow overriding compress', () => {
83
+ const build = new AdaptFrameworkBuild({ action: 'preview', courseId: 'c1', userId: 'u1', compress: true })
84
+ assert.equal(build.compress, true)
85
+ })
86
+
87
+ it('should set courseId and userId', () => {
88
+ const build = new AdaptFrameworkBuild({ action: 'preview', courseId: 'course123', userId: 'user456' })
89
+ assert.equal(build.courseId, 'course123')
90
+ assert.equal(build.userId, 'user456')
91
+ })
92
+
93
+ it('should set expiresAt when provided', () => {
94
+ const expires = '2025-01-01T00:00:00.000Z'
95
+ const build = new AdaptFrameworkBuild({ action: 'preview', courseId: 'c1', userId: 'u1', expiresAt: expires })
96
+ assert.equal(build.expiresAt, expires)
97
+ })
98
+
99
+ it('should initialise courseData as empty object', () => {
100
+ const build = new AdaptFrameworkBuild({ action: 'preview', courseId: 'c1', userId: 'u1' })
101
+ assert.deepEqual(build.courseData, {})
102
+ })
103
+
104
+ it('should initialise enabledPlugins and disabledPlugins as empty arrays', () => {
105
+ const build = new AdaptFrameworkBuild({ action: 'preview', courseId: 'c1', userId: 'u1' })
106
+ assert.deepEqual(build.enabledPlugins, [])
107
+ assert.deepEqual(build.disabledPlugins, [])
108
+ })
109
+
110
+ it('should set collectionName to "adaptbuilds"', () => {
111
+ const build = new AdaptFrameworkBuild({ action: 'preview', courseId: 'c1', userId: 'u1' })
112
+ assert.equal(build.collectionName, 'adaptbuilds')
113
+ })
114
+ })
115
+
116
+ describe('#ensureDir()', () => {
117
+ const testDir = path.join(__dirname, 'data', 'ensure-dir-test')
118
+ let build
119
+
120
+ before(() => {
121
+ build = new AdaptFrameworkBuild({ action: 'preview', courseId: 'c1', userId: 'u1' })
122
+ })
123
+
124
+ after(async () => {
125
+ await fs.rm(testDir, { recursive: true, force: true })
126
+ })
127
+
128
+ it('should create a directory that does not exist', async () => {
129
+ await build.ensureDir(testDir)
130
+ const stat = await fs.stat(testDir)
131
+ assert.ok(stat.isDirectory())
132
+ })
133
+
134
+ it('should not throw when the directory already exists', async () => {
135
+ await build.ensureDir(testDir)
136
+ const stat = await fs.stat(testDir)
137
+ assert.ok(stat.isDirectory())
138
+ })
139
+ })
140
+
141
+ describe('#createIdMap()', () => {
142
+ it('should create a mapping of _id to _friendlyId', () => {
143
+ const build = new AdaptFrameworkBuild({ action: 'preview', courseId: 'c1', userId: 'u1' })
144
+ const items = [
145
+ { _id: 'abc', _friendlyId: 'friendly-abc' },
146
+ { _id: 'def', _friendlyId: 'friendly-def' }
147
+ ]
148
+ build.createIdMap(items)
149
+ assert.deepEqual(build.idMap, {
150
+ abc: 'friendly-abc',
151
+ def: 'friendly-def'
152
+ })
153
+ })
154
+
155
+ it('should handle items without _friendlyId', () => {
156
+ const build = new AdaptFrameworkBuild({ action: 'preview', courseId: 'c1', userId: 'u1' })
157
+ const items = [{ _id: 'abc' }]
158
+ build.createIdMap(items)
159
+ assert.equal(build.idMap.abc, undefined)
160
+ })
161
+ })
162
+
163
+ describe('#sortContentItems()', () => {
164
+ it('should sort content into correct types', () => {
165
+ const build = new AdaptFrameworkBuild({ action: 'preview', courseId: 'c1', userId: 'u1' })
166
+ build.courseData = {
167
+ course: { dir: '/tmp', fileName: 'course.json', data: undefined },
168
+ config: { dir: '/tmp', fileName: 'config.json', data: undefined },
169
+ contentObject: { dir: '/tmp', fileName: 'contentObjects.json', data: [] },
170
+ article: { dir: '/tmp', fileName: 'articles.json', data: [] },
171
+ block: { dir: '/tmp', fileName: 'blocks.json', data: [] },
172
+ component: { dir: '/tmp', fileName: 'components.json', data: [] }
173
+ }
174
+ const items = [
175
+ { _id: 'course1', _type: 'course' },
176
+ { _id: 'config1', _type: 'config' },
177
+ { _id: 'page1', _type: 'page', _parentId: 'course1', _sortOrder: 1 },
178
+ { _id: 'article1', _type: 'article', _parentId: 'page1', _sortOrder: 1 },
179
+ { _id: 'block1', _type: 'block', _parentId: 'article1', _sortOrder: 1 },
180
+ { _id: 'comp1', _type: 'component', _parentId: 'block1', _sortOrder: 1 }
181
+ ]
182
+ build.sortContentItems(items)
183
+
184
+ assert.equal(build.courseData.course.data._id, 'course1')
185
+ assert.equal(build.courseData.config.data._id, 'config1')
186
+ assert.equal(build.courseData.contentObject.data.length, 1)
187
+ assert.equal(build.courseData.article.data.length, 1)
188
+ assert.equal(build.courseData.block.data.length, 1)
189
+ assert.equal(build.courseData.component.data.length, 1)
190
+ })
191
+
192
+ it('should sort siblings by _sortOrder', () => {
193
+ const build = new AdaptFrameworkBuild({ action: 'preview', courseId: 'c1', userId: 'u1' })
194
+ build.courseData = {
195
+ course: { dir: '/tmp', fileName: 'course.json', data: undefined },
196
+ config: { dir: '/tmp', fileName: 'config.json', data: undefined },
197
+ contentObject: { dir: '/tmp', fileName: 'contentObjects.json', data: [] },
198
+ article: { dir: '/tmp', fileName: 'articles.json', data: [] },
199
+ block: { dir: '/tmp', fileName: 'blocks.json', data: [] },
200
+ component: { dir: '/tmp', fileName: 'components.json', data: [] }
201
+ }
202
+ const items = [
203
+ { _id: 'course1', _type: 'course' },
204
+ { _id: 'page2', _type: 'page', _parentId: 'course1', _sortOrder: 2 },
205
+ { _id: 'page1', _type: 'page', _parentId: 'course1', _sortOrder: 1 },
206
+ { _id: 'page3', _type: 'page', _parentId: 'course1', _sortOrder: 3 }
207
+ ]
208
+ build.sortContentItems(items)
209
+
210
+ assert.equal(build.courseData.contentObject.data[0]._id, 'page1')
211
+ assert.equal(build.courseData.contentObject.data[1]._id, 'page2')
212
+ assert.equal(build.courseData.contentObject.data[2]._id, 'page3')
213
+ })
214
+
215
+ it('should categorise "menu" type as contentObject', () => {
216
+ const build = new AdaptFrameworkBuild({ action: 'preview', courseId: 'c1', userId: 'u1' })
217
+ build.courseData = {
218
+ course: { dir: '/tmp', fileName: 'course.json', data: undefined },
219
+ config: { dir: '/tmp', fileName: 'config.json', data: undefined },
220
+ contentObject: { dir: '/tmp', fileName: 'contentObjects.json', data: [] },
221
+ article: { dir: '/tmp', fileName: 'articles.json', data: [] },
222
+ block: { dir: '/tmp', fileName: 'blocks.json', data: [] },
223
+ component: { dir: '/tmp', fileName: 'components.json', data: [] }
224
+ }
225
+ const items = [
226
+ { _id: 'course1', _type: 'course' },
227
+ { _id: 'menu1', _type: 'menu', _parentId: 'course1', _sortOrder: 1 }
228
+ ]
229
+ build.sortContentItems(items)
230
+
231
+ assert.equal(build.courseData.contentObject.data.length, 1)
232
+ assert.equal(build.courseData.contentObject.data[0]._id, 'menu1')
233
+ })
234
+
235
+ it('should sort deeply nested content in global order', () => {
236
+ const build = new AdaptFrameworkBuild({ action: 'preview', courseId: 'c1', userId: 'u1' })
237
+ build.courseData = {
238
+ course: { dir: '/tmp', fileName: 'course.json', data: undefined },
239
+ config: { dir: '/tmp', fileName: 'config.json', data: undefined },
240
+ contentObject: { dir: '/tmp', fileName: 'contentObjects.json', data: [] },
241
+ article: { dir: '/tmp', fileName: 'articles.json', data: [] },
242
+ block: { dir: '/tmp', fileName: 'blocks.json', data: [] },
243
+ component: { dir: '/tmp', fileName: 'components.json', data: [] }
244
+ }
245
+ const items = [
246
+ { _id: 'course1', _type: 'course' },
247
+ { _id: 'page1', _type: 'page', _parentId: 'course1', _sortOrder: 1 },
248
+ { _id: 'page2', _type: 'page', _parentId: 'course1', _sortOrder: 2 },
249
+ { _id: 'art1', _type: 'article', _parentId: 'page1', _sortOrder: 1 },
250
+ { _id: 'art2', _type: 'article', _parentId: 'page2', _sortOrder: 1 },
251
+ { _id: 'art3', _type: 'article', _parentId: 'page1', _sortOrder: 2 }
252
+ ]
253
+ build.sortContentItems(items)
254
+
255
+ const articleIds = build.courseData.article.data.map(a => a._id)
256
+ assert.equal(articleIds[0], 'art1')
257
+ assert.equal(articleIds[1], 'art3')
258
+ assert.equal(articleIds[2], 'art2')
259
+ })
260
+ })
261
+
262
+ describe('#transformContentItems()', () => {
263
+ it('should replace _courseId and _parentId with friendlyIds from idMap', () => {
264
+ const build = new AdaptFrameworkBuild({ action: 'preview', courseId: 'c1', userId: 'u1' })
265
+ build.idMap = { course1: 'co-friendly', page1: 'page-friendly' }
266
+ build.assetData = { idMap: {} }
267
+ build.enabledPlugins = []
268
+ build.courseData = { course: { dir: '/tmp', data: {} } }
269
+
270
+ const items = [
271
+ { _courseId: 'course1', _parentId: 'page1' }
272
+ ]
273
+ transformContentItemsSync(build, items)
274
+ assert.equal(items[0]._courseId, 'co-friendly')
275
+ assert.equal(items[0]._parentId, 'page-friendly')
276
+ })
277
+
278
+ it('should replace _id with _friendlyId when present', () => {
279
+ const build = new AdaptFrameworkBuild({ action: 'preview', courseId: 'c1', userId: 'u1' })
280
+ build.idMap = {}
281
+ build.assetData = { idMap: {} }
282
+ build.enabledPlugins = []
283
+ build.courseData = { course: { dir: '/tmp', data: {} } }
284
+
285
+ const items = [{ _id: 'abc', _friendlyId: 'my-friendly' }]
286
+ transformContentItemsSync(build, items)
287
+ assert.equal(items[0]._id, 'my-friendly')
288
+ })
289
+
290
+ it('should not replace _id when _friendlyId is absent', () => {
291
+ const build = new AdaptFrameworkBuild({ action: 'preview', courseId: 'c1', userId: 'u1' })
292
+ build.idMap = {}
293
+ build.assetData = { idMap: {} }
294
+ build.enabledPlugins = []
295
+ build.courseData = { course: { dir: '/tmp', data: {} } }
296
+
297
+ const items = [{ _id: 'abc' }]
298
+ transformContentItemsSync(build, items)
299
+ assert.equal(items[0]._id, 'abc')
300
+ })
301
+
302
+ it('should replace asset _ids with relative paths', () => {
303
+ const build = new AdaptFrameworkBuild({ action: 'preview', courseId: 'c1', userId: 'u1' })
304
+ build.idMap = {}
305
+ build.courseDir = '/build/course'
306
+ build.assetData = { idMap: { asset123: '/build/course/assets/image.png' } }
307
+ build.enabledPlugins = []
308
+ build.courseData = { course: { dir: '/tmp', data: {} } }
309
+
310
+ const items = [{ graphic: 'asset123' }]
311
+ transformContentItemsSync(build, items)
312
+ assert.equal(items[0].graphic, 'course/assets/image.png')
313
+ })
314
+
315
+ it('should resolve _component to targetAttribute', () => {
316
+ const build = new AdaptFrameworkBuild({ action: 'preview', courseId: 'c1', userId: 'u1' })
317
+ build.idMap = {}
318
+ build.assetData = { idMap: {} }
319
+ build.enabledPlugins = [
320
+ { name: 'adapt-contrib-text', targetAttribute: '_text', type: 'component' }
321
+ ]
322
+ build.courseData = { course: { dir: '/tmp', data: {} } }
323
+
324
+ const items = [{ _component: 'adapt-contrib-text' }]
325
+ transformContentItemsSync(build, items)
326
+ assert.equal(items[0]._component, 'text')
327
+ })
328
+
329
+ it('should keep _component as-is when plugin not found', () => {
330
+ const build = new AdaptFrameworkBuild({ action: 'preview', courseId: 'c1', userId: 'u1' })
331
+ build.idMap = {}
332
+ build.assetData = { idMap: {} }
333
+ build.enabledPlugins = []
334
+ build.courseData = { course: { dir: '/tmp', data: {} } }
335
+
336
+ const items = [{ _component: 'unknown-plugin' }]
337
+ transformContentItemsSync(build, items)
338
+ assert.equal(items[0]._component, 'unknown-plugin')
339
+ })
340
+
341
+ it('should move globals into nested _extensions object', () => {
342
+ const build = new AdaptFrameworkBuild({ action: 'preview', courseId: 'c1', userId: 'u1' })
343
+ build.idMap = {}
344
+ build.assetData = { idMap: {} }
345
+ build.enabledPlugins = [
346
+ { name: 'adapt-contrib-trickle', targetAttribute: '_trickle', type: 'extension' }
347
+ ]
348
+ build.courseData = {
349
+ course: {
350
+ dir: '/tmp',
351
+ data: {
352
+ _globals: {
353
+ _trickle: { label: 'Trickle' }
354
+ }
355
+ }
356
+ }
357
+ }
358
+
359
+ transformContentItemsSync(build, [])
360
+ const globals = build.courseData.course.data._globals
361
+ assert.equal(globals._extensions._trickle.label, 'Trickle')
362
+ assert.equal(globals._trickle, undefined)
363
+ })
364
+
365
+ it('should use _components key for component type globals', () => {
366
+ const build = new AdaptFrameworkBuild({ action: 'preview', courseId: 'c1', userId: 'u1' })
367
+ build.idMap = {}
368
+ build.assetData = { idMap: {} }
369
+ build.enabledPlugins = [
370
+ { name: 'adapt-contrib-text', targetAttribute: '_text', type: 'component' }
371
+ ]
372
+ build.courseData = {
373
+ course: {
374
+ dir: '/tmp',
375
+ data: {
376
+ _globals: {
377
+ _text: { ariaRegion: 'Text' }
378
+ }
379
+ }
380
+ }
381
+ }
382
+
383
+ transformContentItemsSync(build, [])
384
+ const globals = build.courseData.course.data._globals
385
+ assert.equal(globals._components._text.ariaRegion, 'Text')
386
+ })
387
+
388
+ it('should keep _courseId as-is when not in idMap', () => {
389
+ const build = new AdaptFrameworkBuild({ action: 'preview', courseId: 'c1', userId: 'u1' })
390
+ build.idMap = {}
391
+ build.assetData = { idMap: {} }
392
+ build.enabledPlugins = []
393
+ build.courseData = { course: { dir: '/tmp', data: {} } }
394
+
395
+ const items = [{ _courseId: 'unmapped' }]
396
+ transformContentItemsSync(build, items)
397
+ assert.equal(items[0]._courseId, 'unmapped')
398
+ })
399
+ })
400
+
401
+ describe('#writeContentJson() asset mapping', () => {
402
+ it('should map asset data to export format for export builds', () => {
403
+ const build = new AdaptFrameworkBuild({ action: 'export', courseId: 'c1', userId: 'u1' })
404
+ build.assetData = {
405
+ data: [
406
+ { title: 'Logo', description: 'A logo', path: 'assets/logo.png', tags: ['branding'], _id: '123', url: '' },
407
+ { title: 'Icon', description: 'An icon', path: 'assets/icon.svg', tags: [], _id: '456', url: '' }
408
+ ]
409
+ }
410
+ /* simulate the mapping logic from writeContentJson */
411
+ const mapped = build.assetData.data.map(d => ({
412
+ title: d.title,
413
+ description: d.description,
414
+ filename: d.path,
415
+ tags: d.tags
416
+ }))
417
+ assert.equal(mapped.length, 2)
418
+ assert.equal(mapped[0].filename, 'assets/logo.png')
419
+ assert.equal(mapped[0].title, 'Logo')
420
+ assert.equal(mapped[0]._id, undefined)
421
+ assert.deepEqual(mapped[1].tags, [])
422
+ })
423
+
424
+ it('should not include assets for export when assetData is empty', () => {
425
+ const build = new AdaptFrameworkBuild({ action: 'export', courseId: 'c1', userId: 'u1' })
426
+ build.assetData = { data: [] }
427
+ build.courseData = {
428
+ course: { dir: '/tmp', fileName: 'course.json', data: {} }
429
+ }
430
+ const data = Object.values(build.courseData)
431
+ if (build.isExport && build.assetData.data.length) {
432
+ data.push(build.assetData)
433
+ }
434
+ assert.equal(data.length, 1)
435
+ })
436
+
437
+ it('should include asset data entry for non-empty export', () => {
438
+ const build = new AdaptFrameworkBuild({ action: 'export', courseId: 'c1', userId: 'u1' })
439
+ build.assetData = { data: [{ title: 'img', path: 'a.png' }] }
440
+ build.courseData = {
441
+ course: { dir: '/tmp', fileName: 'course.json', data: {} }
442
+ }
443
+ const data = Object.values(build.courseData)
444
+ if (build.isExport && build.assetData.data.length) {
445
+ data.push(build.assetData)
446
+ }
447
+ assert.equal(data.length, 2)
448
+ })
449
+ })
450
+ })
@@ -0,0 +1,327 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import AdaptFrameworkImport from '../lib/AdaptFrameworkImport.js'
4
+
5
+ describe('AdaptFrameworkImport', () => {
6
+ describe('.typeToSchema()', () => {
7
+ it('should return "contentobject" for menu type', () => {
8
+ assert.equal(AdaptFrameworkImport.typeToSchema({ _type: 'menu' }), 'contentobject')
9
+ })
10
+
11
+ it('should return "contentobject" for page type', () => {
12
+ assert.equal(AdaptFrameworkImport.typeToSchema({ _type: 'page' }), 'contentobject')
13
+ })
14
+
15
+ it('should return component-prefixed schema for component type', () => {
16
+ assert.equal(
17
+ AdaptFrameworkImport.typeToSchema({ _type: 'component', _component: 'adapt-contrib-text' }),
18
+ 'adapt-contrib-text-component'
19
+ )
20
+ })
21
+
22
+ it('should return the _type directly for other types', () => {
23
+ assert.equal(AdaptFrameworkImport.typeToSchema({ _type: 'course' }), 'course')
24
+ assert.equal(AdaptFrameworkImport.typeToSchema({ _type: 'config' }), 'config')
25
+ assert.equal(AdaptFrameworkImport.typeToSchema({ _type: 'article' }), 'article')
26
+ assert.equal(AdaptFrameworkImport.typeToSchema({ _type: 'block' }), 'block')
27
+ })
28
+ })
29
+
30
+ describe('#getSortedData()', () => {
31
+ const getSortedData = AdaptFrameworkImport.prototype.getSortedData
32
+
33
+ it('should sort a simple course hierarchy into levels', () => {
34
+ const ctx = {
35
+ contentJson: {
36
+ course: { _id: 'course1' },
37
+ contentObjects: {
38
+ page1: { _id: 'page1', _parentId: 'course1' },
39
+ art1: { _id: 'art1', _parentId: 'page1' },
40
+ block1: { _id: 'block1', _parentId: 'art1' }
41
+ }
42
+ }
43
+ }
44
+ const { sorted, hierarchy } = getSortedData.call(ctx)
45
+ assert.equal(sorted.length, 3)
46
+ assert.deepEqual(sorted[0], ['page1'])
47
+ assert.deepEqual(sorted[1], ['art1'])
48
+ assert.deepEqual(sorted[2], ['block1'])
49
+ assert.ok(hierarchy)
50
+ })
51
+
52
+ it('should group siblings at the same level', () => {
53
+ const ctx = {
54
+ contentJson: {
55
+ course: { _id: 'course1' },
56
+ contentObjects: {
57
+ page1: { _id: 'page1', _parentId: 'course1' },
58
+ page2: { _id: 'page2', _parentId: 'course1' },
59
+ art1: { _id: 'art1', _parentId: 'page1' },
60
+ art2: { _id: 'art2', _parentId: 'page2' }
61
+ }
62
+ }
63
+ }
64
+ const { sorted } = getSortedData.call(ctx)
65
+ assert.equal(sorted.length, 2)
66
+ assert.equal(sorted[0].length, 2)
67
+ assert.ok(sorted[0].includes('page1'))
68
+ assert.ok(sorted[0].includes('page2'))
69
+ assert.equal(sorted[1].length, 2)
70
+ assert.ok(sorted[1].includes('art1'))
71
+ assert.ok(sorted[1].includes('art2'))
72
+ })
73
+
74
+ it('should not include course in the sorted output', () => {
75
+ const ctx = {
76
+ contentJson: {
77
+ course: { _id: 'course1' },
78
+ contentObjects: {
79
+ page1: { _id: 'page1', _parentId: 'course1' }
80
+ }
81
+ }
82
+ }
83
+ const { sorted } = getSortedData.call(ctx)
84
+ const allIds = sorted.flat()
85
+ assert.ok(!allIds.includes('course1'))
86
+ })
87
+
88
+ it('should build correct hierarchy map', () => {
89
+ const ctx = {
90
+ contentJson: {
91
+ course: { _id: 'course1' },
92
+ contentObjects: {
93
+ page1: { _id: 'page1', _parentId: 'course1' },
94
+ page2: { _id: 'page2', _parentId: 'course1' },
95
+ art1: { _id: 'art1', _parentId: 'page1' }
96
+ }
97
+ }
98
+ }
99
+ const { hierarchy } = getSortedData.call(ctx)
100
+ assert.deepEqual(hierarchy.course1, ['page1', 'page2'])
101
+ assert.deepEqual(hierarchy.page1, ['art1'])
102
+ })
103
+
104
+ it('should handle deep nesting (5 levels)', () => {
105
+ const ctx = {
106
+ contentJson: {
107
+ course: { _id: 'c' },
108
+ contentObjects: {
109
+ p: { _id: 'p', _parentId: 'c' },
110
+ a: { _id: 'a', _parentId: 'p' },
111
+ b: { _id: 'b', _parentId: 'a' },
112
+ x: { _id: 'x', _parentId: 'b' },
113
+ y: { _id: 'y', _parentId: 'x' }
114
+ }
115
+ }
116
+ }
117
+ const { sorted } = getSortedData.call(ctx)
118
+ assert.equal(sorted.length, 5)
119
+ assert.deepEqual(sorted[0], ['p'])
120
+ assert.deepEqual(sorted[1], ['a'])
121
+ assert.deepEqual(sorted[2], ['b'])
122
+ assert.deepEqual(sorted[3], ['x'])
123
+ assert.deepEqual(sorted[4], ['y'])
124
+ })
125
+
126
+ it('should handle a single page', () => {
127
+ const ctx = {
128
+ contentJson: {
129
+ course: { _id: 'c' },
130
+ contentObjects: {
131
+ p: { _id: 'p', _parentId: 'c' }
132
+ }
133
+ }
134
+ }
135
+ const { sorted } = getSortedData.call(ctx)
136
+ assert.equal(sorted.length, 1)
137
+ assert.deepEqual(sorted[0], ['p'])
138
+ })
139
+ })
140
+
141
+ describe('#extractAssets()', () => {
142
+ function makeCtx (assetMap) {
143
+ const ctx = { assetMap }
144
+ ctx.extractAssets = AdaptFrameworkImport.prototype.extractAssets.bind(ctx)
145
+ return ctx
146
+ }
147
+
148
+ it('should replace asset paths with mapped IDs', () => {
149
+ const ctx = makeCtx({ 'course/en/assets/logo.png': 'asset123' })
150
+ const schema = {
151
+ _graphic: {
152
+ properties: {
153
+ src: { _backboneForms: 'Asset' }
154
+ }
155
+ }
156
+ }
157
+ const data = { _graphic: { src: 'course/en/assets/logo.png' } }
158
+ ctx.extractAssets(schema, data)
159
+ assert.equal(data._graphic.src, 'asset123')
160
+ })
161
+
162
+ it('should delete empty string asset values', () => {
163
+ const ctx = makeCtx({})
164
+ const schema = {
165
+ _graphic: {
166
+ properties: {
167
+ src: { _backboneForms: 'Asset' }
168
+ }
169
+ }
170
+ }
171
+ const data = { _graphic: { src: '' } }
172
+ ctx.extractAssets(schema, data)
173
+ assert.equal('src' in data._graphic, false)
174
+ })
175
+
176
+ it('should keep value when not in assetMap', () => {
177
+ const ctx = makeCtx({})
178
+ const schema = {
179
+ img: { _backboneForms: { type: 'Asset' } }
180
+ }
181
+ const data = { img: 'unknown/path.png' }
182
+ ctx.extractAssets(schema, data)
183
+ assert.equal(data.img, 'unknown/path.png')
184
+ })
185
+
186
+ it('should recurse into nested properties', () => {
187
+ const ctx = makeCtx({ 'assets/bg.jpg': 'asset456' })
188
+ const schema = {
189
+ _settings: {
190
+ properties: {
191
+ _background: {
192
+ properties: {
193
+ src: { _backboneForms: 'Asset' }
194
+ }
195
+ }
196
+ }
197
+ }
198
+ }
199
+ const data = { _settings: { _background: { src: 'assets/bg.jpg' } } }
200
+ ctx.extractAssets(schema, data)
201
+ assert.equal(data._settings._background.src, 'asset456')
202
+ })
203
+
204
+ it('should recurse into array items', () => {
205
+ const ctx = makeCtx({ 'assets/a.png': 'id1', 'assets/b.png': 'id2' })
206
+ const schema = {
207
+ _items: {
208
+ items: {
209
+ properties: {
210
+ src: { _backboneForms: 'Asset' }
211
+ }
212
+ }
213
+ }
214
+ }
215
+ const data = {
216
+ _items: [
217
+ { src: 'assets/a.png' },
218
+ { src: 'assets/b.png' }
219
+ ]
220
+ }
221
+ ctx.extractAssets(schema, data)
222
+ assert.equal(data._items[0].src, 'id1')
223
+ assert.equal(data._items[1].src, 'id2')
224
+ })
225
+
226
+ it('should skip undefined data keys', () => {
227
+ const ctx = makeCtx({})
228
+ const schema = {
229
+ _graphic: { _backboneForms: 'Asset' }
230
+ }
231
+ const data = {}
232
+ ctx.extractAssets(schema, data)
233
+ assert.equal('_graphic' in data, false)
234
+ })
235
+
236
+ it('should handle null schema gracefully', () => {
237
+ const ctx = makeCtx({})
238
+ ctx.extractAssets(null, { a: 1 })
239
+ })
240
+
241
+ it('should handle _backboneForms as object with type', () => {
242
+ const ctx = makeCtx({ 'path/img.png': 'mapped' })
243
+ const schema = {
244
+ hero: { _backboneForms: { type: 'Asset' } }
245
+ }
246
+ const data = { hero: 'path/img.png' }
247
+ ctx.extractAssets(schema, data)
248
+ assert.equal(data.hero, 'mapped')
249
+ })
250
+ })
251
+
252
+ describe('#loadContentFile()', () => {
253
+ /**
254
+ * loadContentFile depends on FWUtils.readJson (file I/O) and App.instance,
255
+ * but we can test the classification logic by providing a mock.
256
+ * We extract and test the classification behaviour inline.
257
+ */
258
+ it('should classify course type into contentJson.course', () => {
259
+ const classify = (contents, filePath, ctx) => {
260
+ if (contents._type === 'course') {
261
+ ctx.contentJson.course = contents
262
+ return
263
+ }
264
+ if (filePath.endsWith('config.json')) {
265
+ ctx.contentJson.config = { _id: 'config', _type: 'config', ...contents }
266
+ return
267
+ }
268
+ if (Array.isArray(contents)) {
269
+ contents.forEach(c => {
270
+ ctx.contentJson.contentObjects[c._id] = c
271
+ })
272
+ }
273
+ }
274
+ const ctx = { contentJson: { course: {}, config: {}, contentObjects: {} } }
275
+ classify({ _type: 'course', title: 'My Course' }, 'en/course.json', ctx)
276
+ assert.equal(ctx.contentJson.course.title, 'My Course')
277
+ })
278
+
279
+ it('should classify config.json as config with defaults', () => {
280
+ const classify = (contents, filePath, ctx) => {
281
+ if (contents._type === 'course') {
282
+ ctx.contentJson.course = contents
283
+ return
284
+ }
285
+ if (filePath.endsWith('config.json')) {
286
+ ctx.contentJson.config = { _id: 'config', _type: 'config', ...contents }
287
+ return
288
+ }
289
+ if (Array.isArray(contents)) {
290
+ contents.forEach(c => {
291
+ ctx.contentJson.contentObjects[c._id] = c
292
+ })
293
+ }
294
+ }
295
+ const ctx = { contentJson: { course: {}, config: {}, contentObjects: {} } }
296
+ classify({ _defaultLanguage: 'en' }, 'course/config.json', ctx)
297
+ assert.equal(ctx.contentJson.config._id, 'config')
298
+ assert.equal(ctx.contentJson.config._type, 'config')
299
+ assert.equal(ctx.contentJson.config._defaultLanguage, 'en')
300
+ })
301
+
302
+ it('should store array contents into contentObjects by _id', () => {
303
+ const classify = (contents, filePath, ctx) => {
304
+ if (contents._type === 'course') {
305
+ ctx.contentJson.course = contents
306
+ return
307
+ }
308
+ if (filePath.endsWith('config.json')) {
309
+ ctx.contentJson.config = { _id: 'config', _type: 'config', ...contents }
310
+ return
311
+ }
312
+ if (Array.isArray(contents)) {
313
+ contents.forEach(c => {
314
+ ctx.contentJson.contentObjects[c._id] = c
315
+ })
316
+ }
317
+ }
318
+ const ctx = { contentJson: { course: {}, config: {}, contentObjects: {} } }
319
+ classify([
320
+ { _id: 'page1', _type: 'page' },
321
+ { _id: 'art1', _type: 'article' }
322
+ ], 'en/contentObjects.json', ctx)
323
+ assert.equal(ctx.contentJson.contentObjects.page1._type, 'page')
324
+ assert.equal(ctx.contentJson.contentObjects.art1._type, 'article')
325
+ })
326
+ })
327
+ })
@@ -0,0 +1,163 @@
1
+ import { describe, it, before, after } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import fs from 'fs/promises'
4
+ import path from 'path'
5
+ import { fileURLToPath } from 'url'
6
+ import AdaptFrameworkUtils from '../lib/AdaptFrameworkUtils.js'
7
+
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
9
+
10
+ describe('AdaptFrameworkUtils', () => {
11
+ describe('#inferBuildAction()', () => {
12
+ const cases = [
13
+ { url: '/preview/abc123', expected: 'preview' },
14
+ { url: '/publish/abc123', expected: 'publish' },
15
+ { url: '/export/abc123', expected: 'export' }
16
+ ]
17
+ cases.forEach(({ url, expected }) => {
18
+ it(`should return "${expected}" for URL "${url}"`, () => {
19
+ assert.equal(AdaptFrameworkUtils.inferBuildAction({ url }), expected)
20
+ })
21
+ })
22
+
23
+ // TODO: inferBuildAction returns truncated result for URLs without a second slash
24
+ // e.g. '/import' returns 'impor' because indexOf('/', 1) returns -1
25
+ // causing slice(1, -1) to strip the last character
26
+ it('should truncate action for URLs without trailing slash/path (known bug)', () => {
27
+ assert.equal(AdaptFrameworkUtils.inferBuildAction({ url: '/import' }), 'impor')
28
+ })
29
+ })
30
+
31
+ describe('#toBoolean()', () => {
32
+ it('should return true for boolean true', () => {
33
+ assert.equal(AdaptFrameworkUtils.toBoolean(true), true)
34
+ })
35
+
36
+ it('should return true for string "true"', () => {
37
+ assert.equal(AdaptFrameworkUtils.toBoolean('true'), true)
38
+ })
39
+
40
+ it('should return false for boolean false', () => {
41
+ assert.equal(AdaptFrameworkUtils.toBoolean(false), false)
42
+ })
43
+
44
+ it('should return false for string "false"', () => {
45
+ assert.equal(AdaptFrameworkUtils.toBoolean('false'), false)
46
+ })
47
+
48
+ it('should return undefined for undefined', () => {
49
+ assert.equal(AdaptFrameworkUtils.toBoolean(undefined), undefined)
50
+ })
51
+
52
+ it('should return false for null', () => {
53
+ assert.equal(AdaptFrameworkUtils.toBoolean(null), false)
54
+ })
55
+
56
+ it('should return false for 0', () => {
57
+ assert.equal(AdaptFrameworkUtils.toBoolean(0), false)
58
+ })
59
+
60
+ it('should return false for empty string', () => {
61
+ assert.equal(AdaptFrameworkUtils.toBoolean(''), false)
62
+ })
63
+ })
64
+
65
+ describe('#getPluginUpdateStatus()', () => {
66
+ it('should return "INVALID" for an invalid import version', () => {
67
+ assert.equal(AdaptFrameworkUtils.getPluginUpdateStatus(['1.0.0', 'not-valid'], false, false), 'INVALID')
68
+ })
69
+
70
+ it('should return "INSTALLED" when no installed version exists', () => {
71
+ assert.equal(AdaptFrameworkUtils.getPluginUpdateStatus([undefined, '1.0.0'], false, false), 'INSTALLED')
72
+ })
73
+
74
+ it('should return "OLDER" when import version is older', () => {
75
+ assert.equal(AdaptFrameworkUtils.getPluginUpdateStatus(['2.0.0', '1.0.0'], false, false), 'OLDER')
76
+ })
77
+
78
+ it('should return "NO_CHANGE" when versions are equal', () => {
79
+ assert.equal(AdaptFrameworkUtils.getPluginUpdateStatus(['1.0.0', '1.0.0'], false, false), 'NO_CHANGE')
80
+ })
81
+
82
+ it('should return "UPDATE_BLOCKED" when import is newer but updates not enabled and not local', () => {
83
+ assert.equal(AdaptFrameworkUtils.getPluginUpdateStatus(['1.0.0', '2.0.0'], false, false), 'UPDATE_BLOCKED')
84
+ })
85
+
86
+ it('should return "UPDATED" when import is newer and updates are enabled', () => {
87
+ assert.equal(AdaptFrameworkUtils.getPluginUpdateStatus(['1.0.0', '2.0.0'], false, true), 'UPDATED')
88
+ })
89
+
90
+ it('should return "UPDATED" when import is newer and is a local install', () => {
91
+ assert.equal(AdaptFrameworkUtils.getPluginUpdateStatus(['1.0.0', '2.0.0'], true, false), 'UPDATED')
92
+ })
93
+ })
94
+
95
+ describe('#getImportContentCounts()', () => {
96
+ it('should count single items by _type', () => {
97
+ const content = {
98
+ course: { _type: 'course' },
99
+ config: { _type: 'config' }
100
+ }
101
+ const result = AdaptFrameworkUtils.getImportContentCounts(content)
102
+ assert.deepEqual(result, { course: 1, config: 1 })
103
+ })
104
+
105
+ it('should count arrays of items by _type', () => {
106
+ const content = {
107
+ course: { _type: 'course' },
108
+ contentObjects: {
109
+ co1: { _type: 'page' },
110
+ co2: { _type: 'page' },
111
+ co3: { _type: 'menu' }
112
+ }
113
+ }
114
+ const result = AdaptFrameworkUtils.getImportContentCounts(content)
115
+ assert.deepEqual(result, { course: 1, page: 2, menu: 1 })
116
+ })
117
+
118
+ it('should return empty object for empty content', () => {
119
+ assert.deepEqual(AdaptFrameworkUtils.getImportContentCounts({}), {})
120
+ })
121
+ })
122
+
123
+ describe('#readJson()', () => {
124
+ const testFile = path.join(__dirname, 'data', 'test-read.json')
125
+
126
+ before(async () => {
127
+ await fs.mkdir(path.join(__dirname, 'data'), { recursive: true })
128
+ await fs.writeFile(testFile, JSON.stringify({ key: 'value' }))
129
+ })
130
+
131
+ after(async () => {
132
+ await fs.rm(testFile, { force: true })
133
+ })
134
+
135
+ it('should read and parse a JSON file', async () => {
136
+ const result = await AdaptFrameworkUtils.readJson(testFile)
137
+ assert.deepEqual(result, { key: 'value' })
138
+ })
139
+
140
+ it('should throw for a non-existent file', async () => {
141
+ await assert.rejects(
142
+ AdaptFrameworkUtils.readJson('/nonexistent/file.json'),
143
+ { code: 'ENOENT' }
144
+ )
145
+ })
146
+ })
147
+
148
+ describe('#writeJson()', () => {
149
+ const testFile = path.join(__dirname, 'data', 'test-write.json')
150
+
151
+ after(async () => {
152
+ await fs.rm(testFile, { force: true })
153
+ })
154
+
155
+ it('should write formatted JSON to a file', async () => {
156
+ const data = { hello: 'world', num: 42 }
157
+ await AdaptFrameworkUtils.writeJson(testFile, data)
158
+ const content = await fs.readFile(testFile, 'utf8')
159
+ assert.deepEqual(JSON.parse(content), data)
160
+ assert.ok(content.includes('\n'), 'should be formatted with indentation')
161
+ })
162
+ })
163
+ })
@@ -0,0 +1,394 @@
1
+ import { describe, it, mock } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import ConfigTransform from '../lib/migrations/config.js'
5
+ import GraphicSrcTransform from '../lib/migrations/graphic-src.js'
6
+ import NavOrderTransform from '../lib/migrations/nav-order.js'
7
+ import ParentIdTransform from '../lib/migrations/parent-id.js'
8
+ import RemoveUndef from '../lib/migrations/remove-undef.js'
9
+ import StartPage from '../lib/migrations/start-page.js'
10
+ import ThemeUndef from '../lib/migrations/theme-undef.js'
11
+
12
+ describe('Migrations', () => {
13
+ describe('ConfigTransform', () => {
14
+ it('should convert numeric ARIA levels to strings', async () => {
15
+ const data = {
16
+ _type: 'config',
17
+ _accessibility: {
18
+ _ariaLevels: {
19
+ _menu: 1,
20
+ _menuItem: 2,
21
+ _page: 3,
22
+ _article: 4,
23
+ _block: 5,
24
+ _component: 6,
25
+ _componentItem: 7,
26
+ _notify: 8
27
+ }
28
+ }
29
+ }
30
+ await ConfigTransform(data)
31
+ assert.equal(data._accessibility._ariaLevels._menu, '1')
32
+ assert.equal(data._accessibility._ariaLevels._menuItem, '2')
33
+ assert.equal(data._accessibility._ariaLevels._page, '3')
34
+ assert.equal(data._accessibility._ariaLevels._article, '4')
35
+ assert.equal(data._accessibility._ariaLevels._block, '5')
36
+ assert.equal(data._accessibility._ariaLevels._component, '6')
37
+ assert.equal(data._accessibility._ariaLevels._componentItem, '7')
38
+ assert.equal(data._accessibility._ariaLevels._notify, '8')
39
+ })
40
+
41
+ it('should not modify already-string values', async () => {
42
+ const data = {
43
+ _type: 'config',
44
+ _accessibility: { _ariaLevels: { _menu: '1' } }
45
+ }
46
+ await ConfigTransform(data)
47
+ assert.equal(data._accessibility._ariaLevels._menu, '1')
48
+ })
49
+
50
+ it('should skip non-config types', async () => {
51
+ const data = {
52
+ _type: 'course',
53
+ _accessibility: { _ariaLevels: { _menu: 1 } }
54
+ }
55
+ await ConfigTransform(data)
56
+ assert.equal(data._accessibility._ariaLevels._menu, 1)
57
+ })
58
+
59
+ it('should skip config without _ariaLevels', async () => {
60
+ const data = { _type: 'config', _accessibility: {} }
61
+ await ConfigTransform(data)
62
+ assert.deepEqual(data._accessibility, {})
63
+ })
64
+
65
+ it('should not convert falsy values (0)', async () => {
66
+ const data = {
67
+ _type: 'config',
68
+ _accessibility: { _ariaLevels: { _menu: 0 } }
69
+ }
70
+ await ConfigTransform(data)
71
+ assert.equal(data._accessibility._ariaLevels._menu, 0)
72
+ })
73
+ })
74
+
75
+ describe('GraphicSrcTransform', () => {
76
+ it('should copy src to large and small for graphic component', async () => {
77
+ const data = {
78
+ _component: 'adapt-contrib-graphic',
79
+ _graphic: { src: 'image.png' }
80
+ }
81
+ await GraphicSrcTransform(data)
82
+ assert.equal(data._graphic.large, 'image.png')
83
+ assert.equal(data._graphic.small, 'image.png')
84
+ })
85
+
86
+ it('should copy src to large and small for hotgraphic component', async () => {
87
+ const data = {
88
+ _component: 'adapt-contrib-hotgraphic',
89
+ _graphic: { src: 'hot.png' },
90
+ _items: [
91
+ { _graphic: { src: 'item1.png' } },
92
+ { _graphic: { src: 'item2.png' } }
93
+ ]
94
+ }
95
+ await GraphicSrcTransform(data)
96
+ assert.equal(data._graphic.large, 'hot.png')
97
+ assert.equal(data._graphic.small, 'hot.png')
98
+ assert.equal(data._items[0]._graphic.large, 'item1.png')
99
+ assert.equal(data._items[0]._graphic.small, 'item1.png')
100
+ assert.equal(data._items[1]._graphic.large, 'item2.png')
101
+ assert.equal(data._items[1]._graphic.small, 'item2.png')
102
+ })
103
+
104
+ it('should skip non-graphic components', async () => {
105
+ const data = {
106
+ _component: 'adapt-contrib-text',
107
+ _graphic: { src: 'image.png' }
108
+ }
109
+ await GraphicSrcTransform(data)
110
+ assert.equal(data._graphic.large, undefined)
111
+ })
112
+
113
+ it('should not overwrite if src is absent', async () => {
114
+ const data = {
115
+ _component: 'adapt-contrib-graphic',
116
+ _graphic: { large: 'existing.png' }
117
+ }
118
+ await GraphicSrcTransform(data)
119
+ assert.equal(data._graphic.large, 'existing.png')
120
+ })
121
+
122
+ it('should handle graphic without _items', async () => {
123
+ const data = {
124
+ _component: 'adapt-contrib-graphic',
125
+ _graphic: { src: 'only.png' }
126
+ }
127
+ await GraphicSrcTransform(data)
128
+ assert.equal(data._graphic.large, 'only.png')
129
+ assert.equal(data._graphic.small, 'only.png')
130
+ })
131
+ })
132
+
133
+ describe('NavOrderTransform', () => {
134
+ it('should convert string _navOrder to number', async () => {
135
+ const data = {
136
+ _type: 'course',
137
+ _globals: {
138
+ _extensions: {
139
+ _trickle: { _navOrder: '100' },
140
+ _resources: { _navOrder: '200' }
141
+ }
142
+ }
143
+ }
144
+ await NavOrderTransform(data)
145
+ assert.equal(data._globals._extensions._trickle._navOrder, 100)
146
+ assert.equal(data._globals._extensions._resources._navOrder, 200)
147
+ })
148
+
149
+ it('should keep numeric _navOrder as-is', async () => {
150
+ const data = {
151
+ _type: 'course',
152
+ _globals: { _extensions: { _trickle: { _navOrder: 50 } } }
153
+ }
154
+ await NavOrderTransform(data)
155
+ assert.equal(data._globals._extensions._trickle._navOrder, 50)
156
+ })
157
+
158
+ it('should skip non-course types', async () => {
159
+ const data = {
160
+ _type: 'config',
161
+ _globals: { _extensions: { _trickle: { _navOrder: '100' } } }
162
+ }
163
+ await NavOrderTransform(data)
164
+ assert.equal(data._globals._extensions._trickle._navOrder, '100')
165
+ })
166
+
167
+ it('should skip extensions without _navOrder', async () => {
168
+ const data = {
169
+ _type: 'course',
170
+ _globals: { _extensions: { _trickle: { other: 'value' } } }
171
+ }
172
+ await NavOrderTransform(data)
173
+ assert.equal(data._globals._extensions._trickle._navOrder, undefined)
174
+ })
175
+ })
176
+
177
+ describe('ParentIdTransform', () => {
178
+ it('should remap _parentId using idMap', async () => {
179
+ const data = { _parentId: 'old-id' }
180
+ const importer = { idMap: { 'old-id': 'new-id' } }
181
+ await ParentIdTransform(data, importer)
182
+ assert.equal(data._parentId, 'new-id')
183
+ })
184
+
185
+ it('should set _parentId to undefined when not in idMap', async () => {
186
+ const data = { _parentId: 'unknown' }
187
+ const importer = { idMap: {} }
188
+ await ParentIdTransform(data, importer)
189
+ assert.equal(data._parentId, undefined)
190
+ })
191
+
192
+ it('should skip when _parentId is falsy', async () => {
193
+ const data = { _parentId: undefined }
194
+ const importer = { idMap: { undefined: 'should-not-set' } }
195
+ await ParentIdTransform(data, importer)
196
+ assert.equal(data._parentId, undefined)
197
+ })
198
+
199
+ it('should skip when _parentId is not present', async () => {
200
+ const data = { _type: 'course' }
201
+ const importer = { idMap: {} }
202
+ await ParentIdTransform(data, importer)
203
+ assert.equal(data._parentId, undefined)
204
+ })
205
+ })
206
+
207
+ describe('RemoveUndef', () => {
208
+ it('should remove null properties', async () => {
209
+ const data = { a: 1, b: null, c: 'test' }
210
+ await RemoveUndef(data)
211
+ assert.equal(data.a, 1)
212
+ assert.equal(data.c, 'test')
213
+ assert.equal('b' in data, false)
214
+ })
215
+
216
+ it('should recursively remove nulls in nested objects', async () => {
217
+ const data = { a: { b: null, c: { d: null, e: 1 } } }
218
+ await RemoveUndef(data)
219
+ assert.equal('b' in data.a, false)
220
+ assert.equal('d' in data.a.c, false)
221
+ assert.equal(data.a.c.e, 1)
222
+ })
223
+
224
+ it('should not recurse into arrays', async () => {
225
+ const data = { arr: [null, 1, null] }
226
+ await RemoveUndef(data)
227
+ assert.deepEqual(data.arr, [null, 1, null])
228
+ })
229
+
230
+ it('should preserve non-null falsy values', async () => {
231
+ const data = { a: 0, b: false, c: '', d: undefined }
232
+ await RemoveUndef(data)
233
+ assert.equal(data.a, 0)
234
+ assert.equal(data.b, false)
235
+ assert.equal(data.c, '')
236
+ assert.equal(data.d, undefined)
237
+ })
238
+
239
+ it('should handle empty object', async () => {
240
+ const data = {}
241
+ await RemoveUndef(data)
242
+ assert.deepEqual(data, {})
243
+ })
244
+
245
+ it('should handle deeply nested nulls', async () => {
246
+ const data = { a: { b: { c: { d: null } } } }
247
+ await RemoveUndef(data)
248
+ assert.equal('d' in data.a.b.c, false)
249
+ })
250
+ })
251
+
252
+ describe('StartPage', () => {
253
+ it('should assign _friendlyId to content objects from _startIds', async () => {
254
+ const data = {
255
+ _type: 'course',
256
+ _start: {
257
+ _startIds: [
258
+ { _id: 'page1' },
259
+ { _id: 'page2' }
260
+ ]
261
+ }
262
+ }
263
+ const importer = {
264
+ contentJson: {
265
+ contentObjects: {
266
+ page1: { _id: 'page1' },
267
+ page2: { _id: 'page2' }
268
+ }
269
+ },
270
+ framework: { log: mock.fn() }
271
+ }
272
+ await StartPage(data, importer)
273
+ assert.equal(importer.contentJson.contentObjects.page1._friendlyId, 'start_page_1')
274
+ assert.equal(importer.contentJson.contentObjects.page2._friendlyId, 'start_page_2')
275
+ })
276
+
277
+ it('should preserve existing _friendlyId', async () => {
278
+ const data = {
279
+ _type: 'course',
280
+ _start: {
281
+ _startIds: [{ _id: 'page1' }]
282
+ }
283
+ }
284
+ const importer = {
285
+ contentJson: {
286
+ contentObjects: {
287
+ page1: { _id: 'page1', _friendlyId: 'my-custom-id' }
288
+ }
289
+ },
290
+ framework: { log: mock.fn() }
291
+ }
292
+ await StartPage(data, importer)
293
+ assert.equal(importer.contentJson.contentObjects.page1._friendlyId, 'my-custom-id')
294
+ assert.equal(data._start._startIds[0]._id, 'my-custom-id')
295
+ })
296
+
297
+ it('should log warning for missing content object', async () => {
298
+ const logFn = mock.fn()
299
+ const data = {
300
+ _type: 'course',
301
+ _start: {
302
+ _startIds: [{ _id: 'missing' }]
303
+ }
304
+ }
305
+ const importer = {
306
+ contentJson: { contentObjects: {} },
307
+ framework: { log: logFn }
308
+ }
309
+ await StartPage(data, importer)
310
+ assert.equal(logFn.mock.calls.length, 1)
311
+ assert.equal(logFn.mock.calls[0].arguments[0], 'warn')
312
+ })
313
+
314
+ it('should skip non-course types', async () => {
315
+ const data = { _type: 'config', _start: { _startIds: [{ _id: 'p1' }] } }
316
+ const importer = { contentJson: { contentObjects: {} } }
317
+ await StartPage(data, importer)
318
+ })
319
+
320
+ it('should skip when _start is undefined', async () => {
321
+ const data = { _type: 'course' }
322
+ await StartPage(data, {})
323
+ })
324
+
325
+ it('should update _startIds[i]._id to match _friendlyId', async () => {
326
+ const data = {
327
+ _type: 'course',
328
+ _start: { _startIds: [{ _id: 'page1' }] }
329
+ }
330
+ const importer = {
331
+ contentJson: {
332
+ contentObjects: { page1: { _id: 'page1' } }
333
+ },
334
+ framework: { log: mock.fn() }
335
+ }
336
+ await StartPage(data, importer)
337
+ assert.equal(data._start._startIds[0]._id, 'start_page_1')
338
+ })
339
+ })
340
+
341
+ describe('ThemeUndef', () => {
342
+ it('should set _theme from used plugins when undefined', async () => {
343
+ const data = { _type: 'config' }
344
+ const importer = {
345
+ usedContentPlugins: {
346
+ 'adapt-contrib-vanilla': { name: 'adapt-contrib-vanilla', type: 'theme' },
347
+ 'adapt-contrib-text': { name: 'adapt-contrib-text', type: 'component' }
348
+ }
349
+ }
350
+ await ThemeUndef(data, importer)
351
+ assert.equal(data._theme, 'adapt-contrib-vanilla')
352
+ })
353
+
354
+ it('should not override existing _theme', async () => {
355
+ const data = { _type: 'config', _theme: 'my-theme' }
356
+ const importer = {
357
+ usedContentPlugins: {
358
+ 'adapt-contrib-vanilla': { name: 'adapt-contrib-vanilla', type: 'theme' }
359
+ }
360
+ }
361
+ await ThemeUndef(data, importer)
362
+ assert.equal(data._theme, 'my-theme')
363
+ })
364
+
365
+ it('should skip non-config types', async () => {
366
+ const data = { _type: 'course' }
367
+ const importer = {
368
+ usedContentPlugins: {
369
+ 'adapt-contrib-vanilla': { name: 'adapt-contrib-vanilla', type: 'theme' }
370
+ }
371
+ }
372
+ await ThemeUndef(data, importer)
373
+ assert.equal(data._theme, undefined)
374
+ })
375
+
376
+ it('should set _theme to undefined when no theme plugin found', async () => {
377
+ const data = { _type: 'config' }
378
+ const importer = {
379
+ usedContentPlugins: {
380
+ 'adapt-contrib-text': { name: 'adapt-contrib-text', type: 'component' }
381
+ }
382
+ }
383
+ await ThemeUndef(data, importer)
384
+ assert.equal(data._theme, undefined)
385
+ })
386
+
387
+ it('should handle empty usedContentPlugins', async () => {
388
+ const data = { _type: 'config' }
389
+ const importer = { usedContentPlugins: {} }
390
+ await ThemeUndef(data, importer)
391
+ assert.equal(data._theme, undefined)
392
+ })
393
+ })
394
+ })