adapt-schemas 2.1.0 → 3.1.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.
package/README.md CHANGED
@@ -15,16 +15,16 @@ import Schemas from 'adapt-schemas'
15
15
 
16
16
  // Create and initialize the library
17
17
  const library = new Schemas()
18
- await library.init()
18
+ library.init()
19
19
 
20
20
  // Load schemas from plugin directories
21
- await library.loadSchemas('**/schema/*.schema.json', {
21
+ library.loadSchemas('**/schema/*.schema.json', {
22
22
  cwd: './plugins',
23
23
  ignore: ['**/node_modules/**']
24
24
  })
25
25
 
26
26
  // Validate data against a schema (defaults are applied automatically)
27
- const validatedData = await library.validate('course', {
27
+ const validatedData = library.validate('course', {
28
28
  title: 'My Course'
29
29
  })
30
30
  ```
@@ -50,20 +50,20 @@ const library = new Schemas({
50
50
  Initializes the library and loads the base schema.
51
51
 
52
52
  ```javascript
53
- await library.init()
53
+ library.init()
54
54
  ```
55
55
 
56
56
  ##### `loadSchemas(patterns, options)`
57
57
  Loads schemas from directories matching glob patterns.
58
58
 
59
59
  ```javascript
60
- await library.loadSchemas('**/schema/*.schema.json', {
60
+ library.loadSchemas('**/schema/*.schema.json', {
61
61
  cwd: './plugins', // Base directory for patterns
62
62
  ignore: ['**/excluded/**'] // Patterns to exclude
63
63
  })
64
64
 
65
65
  // Multiple patterns
66
- await library.loadSchemas([
66
+ library.loadSchemas([
67
67
  'core/**/schema/*.schema.json',
68
68
  'plugins/**/schema/*.schema.json'
69
69
  ], { ignore: ['**/node_modules/**'] })
@@ -73,7 +73,7 @@ await library.loadSchemas([
73
73
  Registers a single schema file.
74
74
 
75
75
  ```javascript
76
- await library.registerSchema('/path/to/schema.json', {
76
+ library.registerSchema('/path/to/schema.json', {
77
77
  replace: false // Replace existing schema with same name
78
78
  })
79
79
  ```
@@ -82,7 +82,7 @@ await library.registerSchema('/path/to/schema.json', {
82
82
  Retrieves and builds a schema by name.
83
83
 
84
84
  ```javascript
85
- const schema = await library.getSchema('course', {
85
+ const schema = library.getSchema('course', {
86
86
  useCache: true, // Use cached build if available
87
87
  compile: true, // Compile the schema
88
88
  applyExtensions: true // Apply $patch extensions
@@ -93,7 +93,7 @@ const schema = await library.getSchema('course', {
93
93
  Returns the built schema object.
94
94
 
95
95
  ```javascript
96
- const schemaObj = await library.getBuiltSchema('course')
96
+ const schemaObj = library.getBuiltSchema('course')
97
97
  console.log(schemaObj.properties)
98
98
  ```
99
99
 
@@ -101,7 +101,7 @@ console.log(schemaObj.properties)
101
101
  Validates data against a named schema.
102
102
 
103
103
  ```javascript
104
- const validated = await library.validate('course', inputData, {
104
+ const validated = library.validate('course', inputData, {
105
105
  useDefaults: true, // Apply schema defaults (default: true)
106
106
  ignoreRequired: false // Ignore required field errors
107
107
  })
@@ -269,7 +269,7 @@ The library throws `SchemaError` with the following codes:
269
269
  import { SchemaError } from 'adapt-schemas'
270
270
 
271
271
  try {
272
- await library.validate('course', data)
272
+ library.validate('course', data)
273
273
  } catch (e) {
274
274
  if (e instanceof SchemaError) {
275
275
  console.log(e.code) // 'VALIDATION_FAILED'
package/lib/Schema.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import _ from 'lodash'
2
2
  import { EventEmitter } from 'events'
3
- import fs from 'fs/promises'
3
+ import fs from 'fs'
4
4
  import SchemaError from './SchemaError.js'
5
5
  import xss from 'xss'
6
6
 
@@ -114,38 +114,38 @@ class Schema extends EventEmitter {
114
114
 
115
115
  /**
116
116
  * Determines whether the current schema build is valid using last modification timestamp
117
- * @returns {Promise<boolean>}
117
+ * @returns {boolean}
118
118
  */
119
- async isBuildValid () {
119
+ isBuildValid () {
120
120
  if (!this.built) return false
121
121
 
122
122
  let schema = this
123
123
  while (schema) {
124
- const { mtimeMs } = await fs.stat(schema.filePath)
124
+ const { mtimeMs } = fs.statSync(schema.filePath)
125
125
  if (mtimeMs > this.lastBuildTime) return false
126
- schema = await schema.getParent()
126
+ schema = schema.getParent()
127
127
  }
128
128
  return true
129
129
  }
130
130
 
131
131
  /**
132
132
  * Returns the parent schema if $merge is defined (or the base schema if a root schema)
133
- * @returns {Promise<Schema|undefined>}
133
+ * @returns {Schema|undefined}
134
134
  */
135
- async getParent () {
135
+ getParent () {
136
136
  if (this.name === BASE_SCHEMA_NAME) return undefined
137
137
 
138
138
  const parentRef = this.raw?.$merge?.source?.$ref ?? BASE_SCHEMA_NAME
139
- return await this.schemaLibrary.getSchema(parentRef)
139
+ return this.schemaLibrary.getSchema(parentRef)
140
140
  }
141
141
 
142
142
  /**
143
143
  * Loads the schema file
144
- * @returns {Promise<Schema>} This instance
144
+ * @returns {Schema} This instance
145
145
  */
146
- async load () {
146
+ load () {
147
147
  try {
148
- const content = await fs.readFile(this.filePath, 'utf-8')
148
+ const content = fs.readFileSync(this.filePath, 'utf-8')
149
149
  this.raw = JSON.parse(content)
150
150
  this.name = this.raw.$anchor
151
151
  } catch (e) {
@@ -177,54 +177,42 @@ class Schema extends EventEmitter {
177
177
  * @param {boolean} options.compile Compile the schema (default: true)
178
178
  * @param {boolean} options.applyExtensions Apply extension schemas (default: true)
179
179
  * @param {function} options.extensionFilter Filter function for extensions
180
- * @returns {Promise<Schema>}
180
+ * @returns {Schema} This instance
181
181
  */
182
- async build (options = {}) {
183
- if (options.useCache !== false && this.enableCache && await this.isBuildValid()) {
182
+ build (options = {}) {
183
+ if (options.useCache !== false && this.enableCache && this.isBuildValid()) {
184
184
  return this
185
185
  }
186
186
 
187
- if (this.isBuilding) {
188
- return new Promise(resolve => this._buildCallbacks.push(() => resolve(this)))
189
- }
190
-
191
- this.isBuilding = true
192
-
193
187
  const { applyExtensions, extensionFilter } = options
194
188
 
195
189
  let built = _.cloneDeep(this.raw)
196
- let parent = await this.getParent()
190
+ let parent = this.getParent()
197
191
 
198
192
  while (parent) {
199
- const parentBuilt = _.cloneDeep((await parent.build({ ...options, compile: false })).built)
193
+ const parentBuilt = _.cloneDeep((parent.build({ ...options, compile: false })).built)
200
194
  built = this.patch(parentBuilt, built, { strict: false })
201
- parent = await parent.getParent()
195
+ parent = parent.getParent()
202
196
  }
203
197
 
204
198
  if (this.extensions.length) {
205
- await Promise.all(this.extensions.map(async s => {
199
+ this.extensions.forEach(s => {
206
200
  const applyPatch = typeof extensionFilter === 'function' ? extensionFilter(s) : applyExtensions !== false
207
- if (applyPatch) {
208
- const extSchema = await this.schemaLibrary.getSchema(s)
209
- this.patch(built, extSchema.raw, { extendAnnotations: false })
210
- }
211
- }))
201
+ if (!applyPatch) return
202
+ const extSchema = this.schemaLibrary.getSchema(s)
203
+ this.patch(built, extSchema.raw, { extendAnnotations: false })
204
+ })
212
205
  }
213
206
 
214
207
  this.built = built
215
208
 
216
209
  if (options.compile !== false) {
217
- this.compiledWithDefaults = await this.validatorWithDefaults.compileAsync(built)
218
- this.compiled = await this.validator.compileAsync(built)
210
+ this.compiledWithDefaults = this.validatorWithDefaults.compile(built)
211
+ this.compiled = this.validator.compile(built)
219
212
  }
220
213
 
221
- this.isBuilding = false
222
214
  this.lastBuildTime = Date.now()
223
215
 
224
- // Notify waiting callbacks
225
- this._buildCallbacks.forEach(cb => cb())
226
- this._buildCallbacks = []
227
-
228
216
  this.emit('built', this)
229
217
  return this
230
218
  }
@@ -284,10 +272,11 @@ class Schema extends EventEmitter {
284
272
  * @param {Object} options
285
273
  * @param {boolean} options.useDefaults Apply schema defaults (default: true)
286
274
  * @param {boolean} options.ignoreRequired Ignore required field errors
275
+ * @param {boolean} options.ignoreErrors Ignore all validation errors (implies ignoreRequired)
287
276
  * @returns {Object} The validated data
288
277
  */
289
278
  validate (dataToValidate, options = {}) {
290
- const opts = _.defaults(options, { useDefaults: true, ignoreRequired: false })
279
+ const opts = _.defaults(options, { useDefaults: true, ignoreRequired: false, ignoreErrors: false })
291
280
  const data = _.cloneDeep(dataToValidate)
292
281
  let compiled = opts.useDefaults ? this.compiledWithDefaults : this.compiled
293
282
 
@@ -299,17 +288,19 @@ class Schema extends EventEmitter {
299
288
 
300
289
  compiled(data)
301
290
 
302
- const errors = compiled.errors && compiled.errors
303
- .filter(e => e.keyword === 'required' ? !opts.ignoreRequired : true)
304
- .map(e => e.instancePath ? `${e.instancePath} ${e.message}` : e.message)
305
- .join(', ')
291
+ if (!opts.ignoreErrors) {
292
+ const errors = compiled.errors && compiled.errors
293
+ .filter(e => e.keyword === 'required' ? !opts.ignoreRequired : true)
294
+ .map(e => e.instancePath ? `${e.instancePath} ${e.message}` : e.message)
295
+ .join(', ')
306
296
 
307
- if (errors?.length) {
308
- throw new SchemaError('VALIDATION_FAILED', `Validation failed for schema: ${this.name} with errors: ${errors}`, {
309
- schemaName: this.name,
310
- errors,
311
- data
312
- })
297
+ if (errors?.length) {
298
+ throw new SchemaError('VALIDATION_FAILED', `Validation failed for schema: ${this.name} with errors: ${errors}`, {
299
+ schemaName: this.name,
300
+ errors,
301
+ data
302
+ })
303
+ }
313
304
  }
314
305
 
315
306
  return data
package/lib/Schemas.js CHANGED
@@ -2,13 +2,13 @@ import _ from 'lodash'
2
2
  import Ajv from 'ajv/dist/2020.js'
3
3
  import { EventEmitter } from 'events'
4
4
  import { fileURLToPath } from 'url'
5
- import { glob } from 'glob'
6
5
  import Keywords from './Keywords.js'
7
6
  import path from 'path'
8
7
  import safeRegex from 'safe-regex'
9
8
  import Schema from './Schema.js'
10
9
  import SchemaError from './SchemaError.js'
11
10
  import XSSDefaults from './XSSDefaults.js'
11
+ import { globSync } from 'fs'
12
12
 
13
13
  const __dirname = path.dirname(fileURLToPath(import.meta.url))
14
14
  const BASE_SCHEMA_PATH = '../schema/base.schema.json'
@@ -61,7 +61,7 @@ class Schemas extends EventEmitter {
61
61
  addUsedSchema: false,
62
62
  allErrors: true,
63
63
  allowUnionTypes: true,
64
- loadSchema: async (uri) => (await this.getSchema(uri, { compile: false })).built,
64
+ loadSchema: (uri) => (this.getSchema(uri, { compile: false })).built,
65
65
  removeAdditional: 'all',
66
66
  strict: false,
67
67
  verbose: true,
@@ -95,12 +95,12 @@ class Schemas extends EventEmitter {
95
95
 
96
96
  /**
97
97
  * Initializes the schema library by loading the base schema
98
- * @returns {Promise<SchemaLibrary>}
98
+ * @returns {Schemas}
99
99
  */
100
- async init () {
100
+ init () {
101
101
  if (this._initialized) return this
102
102
 
103
- await this.resetSchemaRegistry()
103
+ this.resetSchemaRegistry()
104
104
  this._initialized = true
105
105
  this.emit('initialized')
106
106
  return this
@@ -108,11 +108,10 @@ class Schemas extends EventEmitter {
108
108
 
109
109
  /**
110
110
  * Empties the schema registry (with the exception of the base schema)
111
- * @returns {Promise<void>}
112
111
  */
113
- async resetSchemaRegistry () {
112
+ resetSchemaRegistry () {
114
113
  this.schemas = {
115
- base: await this.createSchema(path.resolve(__dirname, BASE_SCHEMA_PATH), { enableCache: true })
114
+ base: this.createSchema(path.resolve(__dirname, BASE_SCHEMA_PATH), { enableCache: true })
116
115
  }
117
116
  this.emit('reset')
118
117
  }
@@ -161,33 +160,36 @@ class Schemas extends EventEmitter {
161
160
  * @param {Object} options Glob options
162
161
  * @param {string[]} options.ignore Patterns to exclude
163
162
  * @param {string} options.cwd Base directory for glob patterns
164
- * @returns {Promise<void>}
165
163
  */
166
- async loadSchemas (patterns, options = {}) {
164
+ loadSchemas (patterns, options = {}) {
167
165
  if (!this._initialized) {
168
- await this.init()
166
+ this.init()
169
167
  }
170
168
 
171
169
  const globOptions = {
172
170
  cwd: options.cwd || process.cwd(),
173
- absolute: true,
174
- ignore: options.ignore || []
171
+ exclude: options.ignore || []
175
172
  }
176
173
 
177
174
  const patternList = Array.isArray(patterns) ? patterns : [patterns]
178
175
  const allFiles = []
179
176
 
180
177
  for (const pattern of patternList) {
181
- const files = await glob(pattern, globOptions)
182
- allFiles.push(...files)
178
+ const files = globSync(pattern, globOptions)
179
+ allFiles.push(...files.map(f => path.resolve(globOptions.cwd, f)))
183
180
  }
184
181
 
185
182
  // Deduplicate files
186
183
  const uniqueFiles = [...new Set(allFiles)]
187
184
 
188
- const results = await Promise.allSettled(
189
- uniqueFiles.map(f => this.registerSchema(f))
190
- )
185
+ const results = uniqueFiles.map(f => {
186
+ try {
187
+ this.registerSchema(f)
188
+ } catch (err) {
189
+ return { status: 'rejected', reason: err }
190
+ }
191
+ return { status: 'fulfilled' }
192
+ })
191
193
 
192
194
  results
193
195
  .filter(r => r.status === 'rejected')
@@ -201,14 +203,14 @@ class Schemas extends EventEmitter {
201
203
  * @param {string} filePath Path to the schema file
202
204
  * @param {Object} options Extra options
203
205
  * @param {boolean} options.replace Replace existing schema with same name
204
- * @returns {Promise<Schema>}
206
+ * @returns {Schema}
205
207
  */
206
- async registerSchema (filePath, options = {}) {
208
+ registerSchema (filePath, options = {}) {
207
209
  if (!_.isString(filePath)) {
208
210
  throw new SchemaError('INVALID_PARAMS', 'filePath must be a string', { params: ['filePath'] })
209
211
  }
210
212
 
211
- const schema = await this.createSchema(filePath, options)
213
+ const schema = this.createSchema(filePath, options)
212
214
 
213
215
  if (this.schemas[schema.name]) {
214
216
  if (options.replace) {
@@ -251,9 +253,9 @@ class Schemas extends EventEmitter {
251
253
  * Creates a new Schema instance
252
254
  * @param {string} filePath Path to the schema file
253
255
  * @param {Object} options Options passed to Schema constructor
254
- * @returns {Promise<Schema>}
256
+ * @returns {Schema}
255
257
  */
256
- async createSchema (filePath, options = {}) {
258
+ createSchema (filePath, options = {}) {
257
259
  const schema = new Schema({
258
260
  enableCache: options.enableCache ?? this.options.enableCache,
259
261
  filePath,
@@ -296,9 +298,9 @@ class Schemas extends EventEmitter {
296
298
  * @param {boolean} options.compile Compile the schema (default: true)
297
299
  * @param {boolean} options.applyExtensions Apply extension schemas (default: true)
298
300
  * @param {function} options.extensionFilter Filter function for extensions
299
- * @returns {Promise<Schema>}
301
+ * @returns {Schema}
300
302
  */
301
- async getSchema (schemaName, options = {}) {
303
+ getSchema (schemaName, options = {}) {
302
304
  const schema = this.schemas[schemaName]
303
305
  if (!schema) {
304
306
  throw new SchemaError('MISSING_SCHEMA', `Schema '${schemaName}' not found`, { schemaName })
@@ -311,10 +313,10 @@ class Schemas extends EventEmitter {
311
313
  * @param {string} schemaName The schema name
312
314
  * @param {Object} data The data to validate
313
315
  * @param {Object} options Validation options
314
- * @returns {Promise<Object>} The validated data with defaults applied
316
+ * @returns {Object} The validated data with defaults applied
315
317
  */
316
- async validate (schemaName, data, options = {}) {
317
- const schema = await this.getSchema(schemaName)
318
+ validate (schemaName, data, options = {}) {
319
+ const schema = this.getSchema(schemaName)
318
320
  return schema.validate(data, options)
319
321
  }
320
322
 
@@ -322,10 +324,10 @@ class Schemas extends EventEmitter {
322
324
  * Returns the built schema object
323
325
  * @param {string} schemaName The schema name
324
326
  * @param {Object} options Build options
325
- * @returns {Promise<Object>} The built schema object
327
+ * @returns {Object} The built schema object
326
328
  */
327
- async getBuiltSchema (schemaName, options = {}) {
328
- const schema = await this.getSchema(schemaName)
329
+ getBuiltSchema (schemaName, options = {}) {
330
+ const schema = this.getSchema(schemaName)
329
331
  return schema.built
330
332
  }
331
333
 
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "type": "git",
5
5
  "url": "https://github.com/cgkineo/adapt-schemas"
6
6
  },
7
- "version": "2.1.0",
7
+ "version": "3.1.0",
8
8
  "description": "Standalone JSON Schema library for the Adapt framework",
9
9
  "type": "module",
10
10
  "main": "index.js",
package/test.js CHANGED
@@ -165,6 +165,63 @@ async function setupTestSchemas () {
165
165
  }
166
166
  }
167
167
 
168
+ // Create a config extension that mimics adapt-contrib-languagePicker
169
+ const languagePickerConfigSchema = {
170
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
171
+ $anchor: 'languagePicker-config',
172
+ $patch: {
173
+ source: { $ref: 'config' },
174
+ with: {
175
+ properties: {
176
+ _languagePicker: {
177
+ type: 'object',
178
+ default: {},
179
+ properties: {
180
+ _isEnabled: { type: 'boolean', default: false },
181
+ _showOnCourseLoad: { type: 'boolean', default: true },
182
+ _languagePickerIconClass: { type: 'string', default: 'icon-language-2' },
183
+ _restoreStateOnLanguageChange: { type: 'boolean', default: false },
184
+ _classes: { type: 'string', default: '' },
185
+ _display: {
186
+ type: 'object',
187
+ // deliberately no default: {} — tests Case 3
188
+ properties: {
189
+ _iconClass: { type: 'string', default: 'icon-default' },
190
+ _position: { type: 'string', default: 'left' }
191
+ }
192
+ },
193
+ _languages: {
194
+ type: 'array',
195
+ items: {
196
+ type: 'object',
197
+ properties: {
198
+ _language: { type: 'string', default: '' },
199
+ _direction: { type: 'string', default: 'ltr' },
200
+ _isDisabled: { type: 'boolean', default: false },
201
+ displayName: { type: 'string', default: '' },
202
+ _buttons: {
203
+ type: 'object',
204
+ default: {},
205
+ properties: {
206
+ yes: { type: 'string', default: 'Yes' },
207
+ no: { type: 'string', default: 'No' }
208
+ }
209
+ }
210
+ }
211
+ }
212
+ }
213
+ }
214
+ }
215
+ }
216
+ }
217
+ }
218
+ }
219
+
220
+ await fs.writeFile(
221
+ path.join(testSchemaDir, 'languagePicker-config.schema.json'),
222
+ JSON.stringify(languagePickerConfigSchema, null, 2)
223
+ )
224
+
168
225
  // Create a patch schema that extends course
169
226
  const coursePatchSchema = {
170
227
  $schema: 'https://json-schema.org/draft/2020-12/schema',
@@ -413,6 +470,110 @@ async function runTests () {
413
470
  }
414
471
  console.log('')
415
472
 
473
+ // Test 14: ignoreErrors applies defaults without throwing on invalid data
474
+ console.log('Test 14: ignoreErrors applies defaults without throwing')
475
+ const invalidData = await library.validate('course', {
476
+ title: 12345 // wrong type — would normally throw
477
+ }, { ignoreErrors: true })
478
+ const defaultApplied = invalidData.description === ''
479
+ const invalidPreserved = invalidData.title === 12345
480
+ console.log(` ✓ Default description applied despite type error: ${defaultApplied}`)
481
+ console.log(` ✓ Invalid title value preserved: ${invalidPreserved}`)
482
+ if (!defaultApplied || !invalidPreserved) {
483
+ throw new Error('ignoreErrors did not apply defaults or preserve existing data')
484
+ }
485
+
486
+ // Verify it would throw without ignoreErrors
487
+ try {
488
+ await library.validate('course', { title: 12345 })
489
+ throw new Error('Should have thrown without ignoreErrors')
490
+ } catch (e) {
491
+ if (e.code !== 'VALIDATION_FAILED') throw e
492
+ console.log(' ✓ Same data throws without ignoreErrors: true')
493
+ }
494
+ console.log('')
495
+
496
+ // Test 15: ignoreErrors with missing required fields
497
+ console.log('Test 15: ignoreErrors ignores required field errors')
498
+ const missingRequired = await library.validate('course', {
499
+ description: 'No title provided'
500
+ }, { useDefaults: false, ignoreErrors: true })
501
+ const descPreserved = missingRequired.description === 'No title provided'
502
+ console.log(` ✓ Data returned despite missing required "title": ${descPreserved}`)
503
+ if (!descPreserved) {
504
+ throw new Error('ignoreErrors did not return data with missing required fields')
505
+ }
506
+ console.log('')
507
+
508
+ // Test 16: Issue #30 Case 1 — Partially populated object missing sibling defaults
509
+ console.log('Test 16: Issue #30 Case 1 — Partially populated plugin config')
510
+ const partialConfig = await library.validate('config', {
511
+ _languagePicker: {
512
+ _isEnabled: true,
513
+ _languages: [
514
+ {
515
+ _language: 'en',
516
+ _direction: 'ltr',
517
+ displayName: 'English'
518
+ }
519
+ ]
520
+ }
521
+ }, { ignoreErrors: true })
522
+ const lp = partialConfig._languagePicker
523
+ const case1Checks = [
524
+ ['_isEnabled preserved', lp._isEnabled === true],
525
+ ['_showOnCourseLoad defaulted to true', lp._showOnCourseLoad === true],
526
+ ['_languagePickerIconClass defaulted', lp._languagePickerIconClass === 'icon-language-2'],
527
+ ['_restoreStateOnLanguageChange defaulted', lp._restoreStateOnLanguageChange === false],
528
+ ['_classes defaulted to empty string', lp._classes === ''],
529
+ ['_languages[0]._isDisabled defaulted', lp._languages[0]._isDisabled === false],
530
+ ['_languages[0]._buttons defaulted', lp._languages[0]._buttons?.yes === 'Yes'],
531
+ ['_languages[0]._buttons.no defaulted', lp._languages[0]._buttons?.no === 'No']
532
+ ]
533
+ case1Checks.forEach(([desc, ok]) => console.log(` ${ok ? '✓' : '✗'} ${desc}: ${ok}`))
534
+ const case1Failed = case1Checks.filter(([, ok]) => !ok)
535
+ if (case1Failed.length) {
536
+ throw new Error(`Case 1 failures: ${case1Failed.map(([d]) => d).join(', ')}`)
537
+ }
538
+ console.log('')
539
+
540
+ // Test 17: Issue #30 Case 2 — Plugin object entirely absent
541
+ console.log('Test 17: Issue #30 Case 2 — Plugin object entirely absent')
542
+ const emptyConfig = await library.validate('config', {}, { ignoreErrors: true })
543
+ const lp2 = emptyConfig._languagePicker
544
+ const case2Checks = [
545
+ ['_languagePicker created from default: {}', lp2 !== undefined],
546
+ ['_isEnabled defaulted to false', lp2?._isEnabled === false],
547
+ ['_showOnCourseLoad defaulted to true', lp2?._showOnCourseLoad === true],
548
+ ['_languagePickerIconClass defaulted', lp2?._languagePickerIconClass === 'icon-language-2']
549
+ ]
550
+ case2Checks.forEach(([desc, ok]) => console.log(` ${ok ? '✓' : '✗'} ${desc}: ${ok}`))
551
+ const case2Failed = case2Checks.filter(([, ok]) => !ok)
552
+ if (case2Failed.length) {
553
+ throw new Error(`Case 2 failures: ${case2Failed.map(([d]) => d).join(', ')}`)
554
+ }
555
+ console.log('')
556
+
557
+ // Test 18: Issue #30 Case 3 — Nested object without default: {}
558
+ console.log('Test 18: Issue #30 Case 3 — Nested object without default: {}')
559
+ const noNestedDefault = await library.validate('config', {
560
+ _languagePicker: { _isEnabled: true }
561
+ }, { ignoreErrors: true })
562
+ const lp3 = noNestedDefault._languagePicker
563
+ // _display has no default: {} so it should NOT be created by AJV
564
+ const displayMissing = lp3._display === undefined
565
+ console.log(` ✓ _display not created (no default: {}): ${displayMissing}`)
566
+ if (!displayMissing) {
567
+ console.log(` Note: _display was unexpectedly created: ${JSON.stringify(lp3._display)}`)
568
+ }
569
+ // But sibling defaults should still apply
570
+ const siblingsApplied = lp3._showOnCourseLoad === true && lp3._languagePickerIconClass === 'icon-language-2'
571
+ console.log(` ✓ Sibling defaults still applied: ${siblingsApplied}`)
572
+ if (!siblingsApplied) {
573
+ throw new Error('Sibling defaults not applied when nested object has no default')
574
+ }
575
+ console.log('')
576
+
416
577
  console.log('=== All tests passed! ===')
417
578
  } finally {
418
579
  if (!hasSpecifiedPath) {