From f016590a198f625081d422db1edaffbed75eb57d Mon Sep 17 00:00:00 2001 From: rtibblesbot Date: Tue, 6 Oct 2026 15:21:39 -0700 Subject: [PATCH] fix: stop reading the QTI value NULL as null `coerceValue` mirrored Kolibri's assumption that the string `NULL` is QTI NULL, so a Text entry answer of `NULL` reopened empty. Fixes #6272 Co-Authored-By: Claude Opus 5.5 --- .../QTIEditor/__tests__/validateItem.spec.js | 22 +++++++++++++++++++ .../textEntry/__tests__/parse.spec.js | 2 +- .../serialization/qti/QTIDeclaration.js | 4 ++-- .../qti/__tests__/QTIDeclaration.spec.js | 6 ++++- 4 files changed, 30 insertions(+), 4 deletions(-) diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/__tests__/validateItem.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/__tests__/validateItem.spec.js index a3c397f44b..1bedf94a2b 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/__tests__/validateItem.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/__tests__/validateItem.spec.js @@ -1,6 +1,7 @@ import { validateItemShape, validateQtiItem } from '../validateItem'; import { QuestionType, ValidationError } from '../constants'; import { assembleItemXml } from '../serialization/assembleItem'; +import { parseItem } from '../serialization/parseItem'; import { textEntryInteractionDescriptor } from '../interactions/textEntry/Descriptor'; import { validateTextEntryInteraction } from '../interactions/textEntry/validation'; import { @@ -174,6 +175,27 @@ describe('validateQtiItem', () => { }); }); + describe('text entry answers', () => { + it.each([false, true])('reopens and accepts NULL with caseSensitive %s', caseSensitive => { + const { bodyXml, responseDeclarations } = textEntryInteractionDescriptor.buildXML( + { + prompt: '

Which SQL keyword marks a missing value?

', + answers: [{ id: 'a0', value: 'NULL', caseSensitive }], + expectedLength: 0, + }, + QuestionType.TEXT_ENTRY, + ); + const xml = assembleItemXml({ identifier: 'item', title: '', bodyXml, responseDeclarations }); + const [interaction] = parseItem(xml).interactions; + + expect(validateQtiItem(xml)).toEqual([]); + expect( + textEntryInteractionDescriptor.parse(interaction.bodyXml, interaction.responseDeclarations) + .answers, + ).toEqual([{ id: expect.any(String), value: 'NULL', caseSensitive }]); + }); + }); + it('reports unparseable XML', () => { expect(validateQtiItem('')).toEqual([ { code: ValidationError.PARSE_ERROR }, diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/parse.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/parse.spec.js index 2a9d4e7673..25be823101 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/parse.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/parse.spec.js @@ -344,7 +344,7 @@ describe('_extractAnswers', () => { expect(errorSpy).toHaveBeenCalled(); }); - it.each(['1,234', '1.2.3', '1e400', 'Infinity', '0x10', '', 'NULL'])( + it.each(['1,234', '1.2.3', '1e400', 'Infinity', '0x10', ''])( 'reads answers past default value %j, which fromXML accepts', defaultValue => { const declXml = ` diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/QTIDeclaration.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/QTIDeclaration.js index c681a9ee37..9f9fbe0016 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/QTIDeclaration.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/QTIDeclaration.js @@ -202,7 +202,7 @@ export class QTIDeclaration { * * Follows the Kolibri QTI implementation — each base-type maps to a * specific JS primitive or 2-element array (point/pair/directedPair). - * Returns null for empty, null, undefined, or 'NULL' inputs. + * Returns null for empty, null, or undefined inputs. * * @param {string|null|undefined} raw - Raw text from a element * @param {string|null} baseType - One of BaseType.* constants @@ -211,7 +211,7 @@ export class QTIDeclaration { */ static coerceValue(raw, baseType) { // QTI treats empty / null as NULL — see qti-is-null spec - if (raw === null || raw === undefined || raw === '' || raw === 'NULL') { + if (raw === null || raw === undefined || raw === '') { return null; } diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/QTIDeclaration.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/QTIDeclaration.spec.js index 2b95a31991..e3c7b87c41 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/QTIDeclaration.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/QTIDeclaration.spec.js @@ -322,9 +322,13 @@ describe('QTIDeclaration full XML output (QTI compatibility)', () => { describe('QTIDeclaration.coerceValue', () => { describe('null / empty handling', () => { - it.each([null, undefined, '', 'NULL'])('returns null for %p', raw => { + it.each([null, undefined, ''])('returns null for %p', raw => { expect(QTIDeclaration.coerceValue(raw, 'identifier')).toBeNull(); }); + + it('keeps the string "NULL"', () => { + expect(QTIDeclaration.coerceValue('NULL', 'string')).toBe('NULL'); + }); }); describe('boolean', () => {