@@ -28,6 +28,48 @@ const MULTI_NUMERIC_DECLARATION = `
2828 </qti-response-declaration>
2929` . trim ( ) ;
3030
31+ const TEXT_ENTRY_DECLARATION_WITH_MAPPING = `
32+ <qti-response-declaration identifier="RESPONSE" cardinality="multiple" base-type="string">
33+ <qti-correct-response>
34+ <qti-value>Paris</qti-value>
35+ <qti-value>Madrid</qti-value>
36+ </qti-correct-response>
37+ <qti-mapping default-value="0">
38+ <qti-map-entry map-key="Paris" mapped-value="1" case-sensitive="false"/>
39+ <qti-map-entry map-key="Madrid" mapped-value="1" case-sensitive="true"/>
40+ <qti-map-entry map-key="Lisbon" mapped-value="1" case-sensitive="true"/>
41+ </qti-mapping>
42+ </qti-response-declaration>
43+ ` . trim ( ) ;
44+
45+ const TEXT_ENTRY_DECLARATION_WITHOUT_MAPPING = `
46+ <qti-response-declaration identifier="RESPONSE" cardinality="single" base-type="string">
47+ <qti-correct-response>
48+ <qti-value>Paris</qti-value>
49+ </qti-correct-response>
50+ </qti-response-declaration>
51+ ` . trim ( ) ;
52+
53+ const BLANK_VALUE_DECLARATION = `
54+ <qti-response-declaration identifier="RESPONSE" cardinality="single" base-type="string">
55+ <qti-correct-response>
56+ <qti-value></qti-value>
57+ </qti-correct-response>
58+ <qti-mapping default-value="0">
59+ <qti-map-entry map-key="" mapped-value="1" case-sensitive="true"/>
60+ </qti-mapping>
61+ </qti-response-declaration>
62+ ` . trim ( ) ;
63+
64+ /** `identifier` is required by the QTI schema — QTIDeclaration refuses to model this. */
65+ const DECLARATION_WITHOUT_IDENTIFIER = `
66+ <qti-response-declaration cardinality="single" base-type="string">
67+ <qti-correct-response>
68+ <qti-value>Paris</qti-value>
69+ </qti-correct-response>
70+ </qti-response-declaration>
71+ ` . trim ( ) ;
72+
3173/** Build a minimal <qti-item-body> with the given prompt div and the interaction. */
3274function makeBodyXml ( { promptHtml = '' , expectedLength = null } = { } ) {
3375 const interactionAttrs = `response-identifier="RESPONSE"${ expectedLength ? ` expected-length="${ expectedLength } "` : '' } ` ;
@@ -53,6 +95,10 @@ describe('_defaultState', () => {
5395} ) ;
5496
5597describe ( '_extractAnswers' , ( ) => {
98+ afterEach ( ( ) => {
99+ jest . restoreAllMocks ( ) ;
100+ } ) ;
101+
56102 it ( 'returns [] when no declaration is provided' , ( ) => {
57103 expect ( _extractAnswers ( [ ] ) ) . toEqual ( [ ] ) ;
58104 } ) ;
@@ -85,6 +131,42 @@ describe('_extractAnswers', () => {
85131 const result = _extractAnswers ( [ MULTI_NUMERIC_DECLARATION ] ) ;
86132 expect ( result [ 0 ] . id ) . not . toBe ( result [ 1 ] . id ) ;
87133 } ) ;
134+
135+ it ( 'returns [] when the declaration is too malformed to model' , ( ) => {
136+ const errorSpy = jest . spyOn ( console , 'error' ) . mockImplementation ( ( ) => { } ) ;
137+ expect ( _extractAnswers ( [ DECLARATION_WITHOUT_IDENTIFIER ] ) ) . toEqual ( [ ] ) ;
138+ expect ( errorSpy ) . toHaveBeenCalled ( ) ;
139+ } ) ;
140+
141+ describe ( 'mapping-derived case sensitivity' , ( ) => {
142+ it ( 'reads caseSensitive by map-key, silently ignoring unmatched entries' , ( ) => {
143+ const errorSpy = jest . spyOn ( console , 'error' ) . mockImplementation ( ( ) => { } ) ;
144+ const result = _extractAnswers ( [ TEXT_ENTRY_DECLARATION_WITH_MAPPING ] ) ;
145+ // The Lisbon entry has no <qti-value>, so it contributes no answer and no error.
146+ const byValue = Object . fromEntries ( result . map ( a => [ a . value , a . caseSensitive ] ) ) ;
147+ expect ( byValue ) . toEqual ( { Paris : false , Madrid : true } ) ;
148+ expect ( errorSpy ) . not . toHaveBeenCalled ( ) ;
149+ } ) ;
150+
151+ it ( 'falls back to caseSensitive false when there is no mapping' , ( ) => {
152+ const result = _extractAnswers ( [ TEXT_ENTRY_DECLARATION_WITHOUT_MAPPING ] ) ;
153+ expect ( result ) . toHaveLength ( 1 ) ;
154+ expect ( result [ 0 ] . caseSensitive ) . toBe ( false ) ;
155+ } ) ;
156+
157+ it ( 'reports numeric answers as never case-sensitive' , ( ) => {
158+ const result = _extractAnswers ( [ SINGLE_NUMERIC_DECLARATION ] ) ;
159+ expect ( result [ 0 ] . caseSensitive ) . toBe ( false ) ;
160+ } ) ;
161+
162+ it ( 'matches a map entry for a blank answer value' , ( ) => {
163+ // Both the empty map-key and the empty <qti-value> coerce to null (QTI NULL) and
164+ // format back to ''; the entry is case-sensitive="true" so a missed lookup can't
165+ // slip through the false fallback.
166+ const result = _extractAnswers ( [ BLANK_VALUE_DECLARATION ] ) ;
167+ expect ( result ) . toEqual ( [ expect . objectContaining ( { value : '' , caseSensitive : true } ) ] ) ;
168+ } ) ;
169+ } ) ;
88170} ) ;
89171
90172describe ( 'parseTextEntryInteraction' , ( ) => {
@@ -147,6 +229,7 @@ describe('buildTextEntryInteractionXML', () => {
147229 const FREE_SCHEMA = { baseType : BaseType . STRING , cardinality : Cardinality . SINGLE } ;
148230 const NUMERIC_SINGLE_SCHEMA = { baseType : BaseType . FLOAT , cardinality : Cardinality . SINGLE } ;
149231 const NUMERIC_MULTI_SCHEMA = { baseType : BaseType . FLOAT , cardinality : Cardinality . MULTIPLE } ;
232+ const TEXT_ENTRY_MULTI_SCHEMA = { baseType : BaseType . STRING , cardinality : Cardinality . MULTIPLE } ;
150233
151234 describe ( 'bodyXml' , ( ) => {
152235 it ( 'produces a well-formed <qti-item-body>' , ( ) => {
@@ -273,6 +356,72 @@ describe('buildTextEntryInteractionXML', () => {
273356 } ) ;
274357 } ) ;
275358
359+ describe ( 'mapping' , ( ) => {
360+ const CASE_ANSWERS = [
361+ { id : 'a1' , value : 'Paris' , caseSensitive : false } ,
362+ { id : 'a2' , value : 'Madrid' , caseSensitive : true } ,
363+ ] ;
364+
365+ const CASE_STATE = { prompt : '' , answers : CASE_ANSWERS , expectedLength : 0 } ;
366+
367+ /** Build the declaration for the given state and return it as both string and DOM. */
368+ function buildDeclaration (
369+ state ,
370+ questionType = QuestionType . TEXT_ENTRY ,
371+ schema = TEXT_ENTRY_MULTI_SCHEMA ,
372+ ) {
373+ const { responseDeclarations } = buildTextEntryInteractionXML ( state , questionType , schema ) ;
374+ const [ decl ] = responseDeclarations ;
375+ return { decl, doc : new DOMParser ( ) . parseFromString ( decl , 'text/xml' ) } ;
376+ }
377+
378+ it ( 'emits one qti-map-entry per string answer' , ( ) => {
379+ const { doc } = buildDeclaration ( CASE_STATE ) ;
380+ expect ( doc . querySelectorAll ( 'qti-mapping' ) ) . toHaveLength ( 1 ) ;
381+
382+ const entries = [ ...doc . querySelectorAll ( 'qti-map-entry' ) ] ;
383+ expect ( entries . map ( e => e . getAttribute ( 'map-key' ) ) ) . toEqual ( [ 'Paris' , 'Madrid' ] ) ;
384+ expect ( entries . map ( e => e . getAttribute ( 'mapped-value' ) ) ) . toEqual ( [ '1' , '1' ] ) ;
385+ } ) ;
386+
387+ it ( 'writes case-sensitive="true" only for case-sensitive answers' , ( ) => {
388+ const entries = [ ...buildDeclaration ( CASE_STATE ) . doc . querySelectorAll ( 'qti-map-entry' ) ] ;
389+ // null = attribute absent; false is the XSD default and so is left unwritten.
390+ expect ( entries . map ( e => e . getAttribute ( 'case-sensitive' ) ) ) . toEqual ( [ null , 'true' ] ) ;
391+ } ) ;
392+
393+ it ( 'emits no mapping for numeric answers' , ( ) => {
394+ const { doc } = buildDeclaration (
395+ {
396+ prompt : '' ,
397+ answers : [
398+ { id : 'a1' , value : '0.5' } ,
399+ { id : 'a2' , value : '1.5' } ,
400+ ] ,
401+ expectedLength : 0 ,
402+ } ,
403+ QuestionType . NUMERIC ,
404+ NUMERIC_MULTI_SCHEMA ,
405+ ) ;
406+ expect ( doc . querySelector ( 'qti-mapping' ) ) . toBeNull ( ) ;
407+ } ) ;
408+
409+ it ( 'emits no mapping for free response' , ( ) => {
410+ const { doc } = buildDeclaration ( CASE_STATE , QuestionType . FREE_RESPONSE , FREE_SCHEMA ) ;
411+ expect ( doc . querySelector ( 'qti-mapping' ) ) . toBeNull ( ) ;
412+ } ) ;
413+
414+ it ( 'emits no mapping when there are zero answers' , ( ) => {
415+ const { doc } = buildDeclaration ( { ...CASE_STATE , answers : [ ] } ) ;
416+ expect ( doc . querySelector ( 'qti-mapping' ) ) . toBeNull ( ) ;
417+ } ) ;
418+
419+ it ( 'emits qti-mapping after qti-correct-response per the XSD sequence' , ( ) => {
420+ const { decl } = buildDeclaration ( CASE_STATE ) ;
421+ expect ( decl . indexOf ( '<qti-correct-response' ) ) . toBeLessThan ( decl . indexOf ( '<qti-mapping' ) ) ;
422+ } ) ;
423+ } ) ;
424+
276425 describe ( 'round-trip' , ( ) => {
277426 it ( 'numeric: parse → buildXML → parse yields equivalent state' , ( ) => {
278427 const original = {
@@ -328,5 +477,28 @@ describe('buildTextEntryInteractionXML', () => {
328477 const parsed = parseTextEntryInteraction ( bodyXml , responseDeclarations ) ;
329478 expect ( parsed . answers . map ( a => a . value ) ) . toEqual ( [ '0.5' , '1.5' ] ) ;
330479 } ) ;
480+
481+ it ( 'textEntry: round-trip preserves per-answer caseSensitive' , ( ) => {
482+ const original = {
483+ prompt : '<p>Name a capital city.</p>' ,
484+ answers : [
485+ { id : 'a1' , value : 'Paris' , caseSensitive : false } ,
486+ { id : 'a2' , value : 'Madrid' , caseSensitive : true } ,
487+ // Padded: keeps its flag only if map-key is written trimmed. Case-sensitive
488+ // because false is also the no-match fallback, which would hide a miss.
489+ { id : 'a3' , value : ' Rome ' , caseSensitive : true } ,
490+ ] ,
491+ expectedLength : 0 ,
492+ } ;
493+ const { bodyXml, responseDeclarations } = buildTextEntryInteractionXML (
494+ original ,
495+ QuestionType . TEXT_ENTRY ,
496+ TEXT_ENTRY_MULTI_SCHEMA ,
497+ ) ;
498+ const parsed = parseTextEntryInteraction ( bodyXml , responseDeclarations ) ;
499+
500+ const byValue = Object . fromEntries ( parsed . answers . map ( a => [ a . value , a . caseSensitive ] ) ) ;
501+ expect ( byValue ) . toEqual ( { Paris : false , Madrid : true , Rome : true } ) ;
502+ } ) ;
331503 } ) ;
332504} ) ;
0 commit comments