diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useTextEntryInteraction.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useTextEntryInteraction.spec.js index 7d46014754..aa6a475187 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useTextEntryInteraction.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useTextEntryInteraction.spec.js @@ -3,11 +3,15 @@ import { useTextEntryInteraction } from '../useTextEntryInteraction'; import { QuestionType, ValidationError } from '../../constants'; function makeNumericBlock(answerValues = ['12']) { - const values = answerValues.map(v => `${v}`).join(''); - const cardinality = answerValues.length > 1 ? 'multiple' : 'single'; + const correct = answerValues.length ? `${answerValues[0]}` : ''; + const mapping = answerValues.length + ? `${answerValues + .map(v => ``) + .join('')}` + : ''; const bodyXml = `

What is 3 \xd7 4?

`; - const declaration = `${values}`; + const declaration = `${correct}${mapping}`; return { bodyXml, responseDeclarations: [declaration] }; } diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Descriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Descriptor.js index 934189eea7..d07b7c820a 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Descriptor.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Descriptor.js @@ -74,23 +74,15 @@ class TextEntryInteractionDescriptor extends InteractionDescriptor { /** * Returns the response declaration schema for the given question type. - * Cardinality is derived from answer count for NUMERIC and TEXT_ENTRY so it - * stays in sync as answers are added or removed. + * Always single-cardinality: accepted answers are scored by a + * , not by a multiple-cardinality response. * * @param {string} questionType - * @param {TextEntryState|null} [state] * @returns {{ baseType: string, cardinality: string }} */ - getResponseDeclarationSchema(questionType, state = null) { - if (questionType === QuestionType.FREE_RESPONSE) { - return { baseType: BaseType.STRING, cardinality: Cardinality.SINGLE }; - } - const answerCount = state?.answers?.length ?? 0; - const cardinality = answerCount > 1 ? Cardinality.MULTIPLE : Cardinality.SINGLE; - if (questionType === QuestionType.TEXT_ENTRY) { - return { baseType: BaseType.STRING, cardinality }; - } - return { baseType: BaseType.FLOAT, cardinality }; + getResponseDeclarationSchema(questionType) { + const baseType = questionType === QuestionType.NUMERIC ? BaseType.FLOAT : BaseType.STRING; + return { baseType, cardinality: Cardinality.SINGLE }; } /** @@ -111,7 +103,7 @@ class TextEntryInteractionDescriptor extends InteractionDescriptor { return buildTextEntryInteractionXML( state, questionType, - this.getResponseDeclarationSchema(questionType, state), + this.getResponseDeclarationSchema(questionType), ); } 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 d2399eadd7..2a9d4e7673 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 @@ -44,6 +44,19 @@ const TEXT_ENTRY_DECLARATION_WITH_MAPPING = ` `.trim(); +const SINGLE_TEXT_DECLARATION_WITH_MAPPING = ` + + + Madrid + + + + + + + +`.trim(); + const TEXT_ENTRY_DECLARATION_WITHOUT_MAPPING = ` @@ -178,10 +191,44 @@ describe('_extractAnswers', () => { expect(errorSpy).not.toHaveBeenCalled(); }); - it('falls back to caseSensitive false when there is no mapping', () => { + it('reads a single-cardinality text mapping in mapping order', () => { + const result = _extractAnswers([SINGLE_TEXT_DECLARATION_WITH_MAPPING]); + expect(result.map(a => [a.value, a.caseSensitive])).toEqual([ + ['Madrid', true], + ['Paris', false], + ['Rome', false], + ]); + }); + + it('falls back to correct-response values when the mapping has no entries', () => { + const declXml = ` + + Paris + + + `.trim(); + expect(_extractAnswers([declXml]).map(a => [a.value, a.caseSensitive])).toEqual([ + ['Paris', true], + ]); + }); + + it('reads text answers as case-sensitive when there is no mapping', () => { const result = _extractAnswers([TEXT_ENTRY_DECLARATION_WITHOUT_MAPPING]); expect(result).toHaveLength(1); - expect(result[0].caseSensitive).toBe(false); + expect(result[0].caseSensitive).toBe(true); + }); + + it.each([ + ['string', 'a', 'b', ['a', 'b']], + ['float', '1', '2', ['1', '2']], + ])('%s: keeps correct values missing from the mapping', (baseType, first, second, expected) => { + const declXml = ` + + ${first}${second} + + + `.trim(); + expect(_extractAnswers([declXml]).map(a => a.value)).toEqual(expected); }); it('reports numeric answers as never case-sensitive', () => { @@ -249,6 +296,27 @@ describe('_extractAnswers', () => { }, ); + // The editor's own shape (correct response = first map-key) keeps repeats; + // any other dedupes map-keys by value against the answers already read. + it.each([ + [['2'], ['2', '2'], ['2', '2']], + [['2'], ['2', '2.0'], ['2', '2.0']], + [['5'], ['5.0', '5'], ['5']], + [['1'], ['1.0', '1'], ['1']], + [['1'], ['1', '1.0'], ['1', '1.0']], + [[], ['1', '1'], ['1']], + ])('reads correct %j with map-keys %j as %j', (correct, mapKeys, expected) => { + const declXml = ` + + ${correct.length ? `${correct.map(v => `${v}`).join('')}` : ''} + + ${mapKeys.map(key => ``).join('')} + + + `; + expect(_extractAnswers([declXml]).map(a => a.value)).toEqual(expected); + }); + it('reads mapped-value as a leading number, as Mapping.fromXML does', () => { const declXml = ` @@ -350,8 +418,7 @@ describe('parseTextEntryInteraction', () => { describe('buildTextEntryInteractionXML', () => { const FREE_SCHEMA = { baseType: BaseType.STRING, cardinality: Cardinality.SINGLE }; const NUMERIC_SINGLE_SCHEMA = { baseType: BaseType.FLOAT, cardinality: Cardinality.SINGLE }; - const NUMERIC_MULTI_SCHEMA = { baseType: BaseType.FLOAT, cardinality: Cardinality.MULTIPLE }; - const TEXT_ENTRY_MULTI_SCHEMA = { baseType: BaseType.STRING, cardinality: Cardinality.MULTIPLE }; + const TEXT_ENTRY_SCHEMA = { baseType: BaseType.STRING, cardinality: Cardinality.SINGLE }; describe('bodyXml', () => { it('produces a well-formed ', () => { @@ -456,40 +523,6 @@ describe('buildTextEntryInteractionXML', () => { expect(responseDeclarations[0]).toContain('base-type="float"'); }); - it('numeric with 2+ answers gets cardinality="multiple"', () => { - const { responseDeclarations } = buildTextEntryInteractionXML( - { - prompt: '', - answers: [ - { id: 'a1', value: '0.5' }, - { id: 'a2', value: '1.5' }, - ], - expectedLength: 0, - }, - QuestionType.NUMERIC, - NUMERIC_MULTI_SCHEMA, - ); - expect(responseDeclarations[0]).toContain('cardinality="multiple"'); - }); - - it('numeric includes with each answer value', () => { - const { responseDeclarations } = buildTextEntryInteractionXML( - { - prompt: '', - answers: [ - { id: 'a1', value: '0.5' }, - { id: 'a2', value: '1.5' }, - ], - expectedLength: 0, - }, - QuestionType.NUMERIC, - NUMERIC_MULTI_SCHEMA, - ); - expect(responseDeclarations[0]).toContain('qti-correct-response'); - expect(responseDeclarations[0]).toContain('>0.5<'); - expect(responseDeclarations[0]).toContain('>1.5<'); - }); - it('numeric with 0 answers omits (empty element is invalid per XSD)', () => { const { responseDeclarations } = buildTextEntryInteractionXML( { prompt: '', answers: [], expectedLength: 0 }, @@ -512,7 +545,7 @@ describe('buildTextEntryInteractionXML', () => { function buildDeclaration( state, questionType = QuestionType.TEXT_ENTRY, - schema = TEXT_ENTRY_MULTI_SCHEMA, + schema = TEXT_ENTRY_SCHEMA, ) { const { responseDeclarations } = buildTextEntryInteractionXML(state, questionType, schema); const [decl] = responseDeclarations; @@ -534,20 +567,21 @@ describe('buildTextEntryInteractionXML', () => { expect(entries.map(e => e.getAttribute('case-sensitive'))).toEqual([null, 'true']); }); - it('emits no mapping for numeric answers', () => { + it('keeps a blank numeric answer row as a blank map entry', () => { const { doc } = buildDeclaration( { prompt: '', answers: [ - { id: 'a1', value: '0.5' }, - { id: 'a2', value: '1.5' }, + { id: 'a1', value: '1' }, + { id: 'a2', value: '' }, ], expectedLength: 0, }, QuestionType.NUMERIC, - NUMERIC_MULTI_SCHEMA, + NUMERIC_SINGLE_SCHEMA, ); - expect(doc.querySelector('qti-mapping')).toBeNull(); + const entries = [...doc.querySelectorAll('qti-map-entry')]; + expect(entries.map(e => e.getAttribute('map-key'))).toEqual(['1', '']); }); it('emits no mapping for free response', () => { @@ -604,7 +638,7 @@ describe('buildTextEntryInteractionXML', () => { expect(parsed.expectedLength).toBe(DEFAULT_EXPECTED_LENGTH); }); - it('multi-answer numeric: round-trip preserves all values', () => { + it('multi-answer numeric: round-trip preserves all values in order', () => { const original = { prompt: '

Q

', answers: [ @@ -616,7 +650,7 @@ describe('buildTextEntryInteractionXML', () => { const { bodyXml, responseDeclarations } = buildTextEntryInteractionXML( original, QuestionType.NUMERIC, - NUMERIC_MULTI_SCHEMA, + NUMERIC_SINGLE_SCHEMA, ); const parsed = parseTextEntryInteraction(bodyXml, responseDeclarations); expect(parsed.answers.map(a => a.value)).toEqual(['0.5', '1.5']); @@ -701,12 +735,15 @@ describe('buildTextEntryInteractionXML', () => { const { bodyXml, responseDeclarations } = buildTextEntryInteractionXML( original, QuestionType.TEXT_ENTRY, - TEXT_ENTRY_MULTI_SCHEMA, + TEXT_ENTRY_SCHEMA, ); const parsed = parseTextEntryInteraction(bodyXml, responseDeclarations); - const byValue = Object.fromEntries(parsed.answers.map(a => [a.value, a.caseSensitive])); - expect(byValue).toEqual({ Paris: false, Madrid: true, Rome: true }); + expect(parsed.answers.map(a => [a.value, a.caseSensitive])).toEqual([ + ['Paris', false], + ['Madrid', true], + ['Rome', true], + ]); }); }); }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js index 25bcf89cde..3b1b54352a 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js @@ -94,33 +94,43 @@ function extractNumericAnswers(declarationEl) { const values = [...declarationEl.querySelectorAll(':scope > qti-correct-response qti-value')].map( el => el.textContent.trim(), ); - // Full-credit map-keys are answers too, as on the string path. One equal in value to an - // answer already read (`5.0` for `5`) is that answer, so it is not added again. + // Full-credit map-keys are answers too, as on the string path. + const mapKeys = [...declarationEl.querySelectorAll(':scope > qti-mapping qti-map-entry')] + .filter(entry => parseFloat(entry.getAttribute('mapped-value')) >= 1) + .map(entry => (entry.getAttribute('map-key') ?? '').trim()); + const toAnswers = answerValues => + answerValues.map(value => ({ + id: generateRandomSlug('answer'), + value, + caseSensitive: false, + })); + // The editor writes its first answer as the correct response and maps every answer, + // so its map-keys are the answers as authored, repeats included. + if (values.length === 1 && values[0] === mapKeys[0]) { + return toAnswers(mapKeys); + } + // Otherwise a map-key equal in value to an answer already read (`5.0` for `5`) is that + // answer, so it is not added again. const keys = new Set(values.map(value => parseXsdDouble(value) ?? value)); - for (const entry of declarationEl.querySelectorAll(':scope > qti-mapping qti-map-entry')) { - const value = (entry.getAttribute('map-key') ?? '').trim(); + for (const value of mapKeys) { const key = parseXsdDouble(value) ?? value; - if (parseFloat(entry.getAttribute('mapped-value')) >= 1 && !keys.has(key)) { + if (!keys.has(key)) { keys.add(key); values.push(value); } } - return values.map(value => ({ - id: generateRandomSlug('answer'), - value, - caseSensitive: false, - })); + return toAnswers(values); } /** - * Extract correct answer values from the response declaration string. - * Returns an array of `{ id, value, caseSensitive }` objects, or [] when no - * correct response is declared (i.e. free-response items). + * Extract accepted answers from the response declaration string. + * Returns an array of `{ id, value, caseSensitive }` objects, or [] when the + * declaration has none (i.e. free-response items). * * Supports both float (numeric) and string (textEntry) base-types. * Answers are the correct response values plus any full-credit `map-key`s. - * For string base-types `caseSensitive` comes from the declaration's - * , matched by `map-key`; it is always false for float. + * For string base-types `caseSensitive` comes from the matching map entry, and + * is true when there is no ; it is always false for float. * * @param {string[]} responseDeclarations * @returns {{ id: string, value: string, caseSensitive: boolean }[]} @@ -164,9 +174,9 @@ export function _extractAnswers(responseDeclarations) { return [...values].map(value => ({ id: generateRandomSlug('answer'), value, - // An answer with no matching qti-map-entry — including every answer in an - // item authored before mappings were written — takes the XSD default, false. - caseSensitive: caseSensitivity.get(value) ?? false, + // With no map entries, match_correct compares exactly; an answer missing from a + // mapping takes the XSD default, false. + caseSensitive: caseSensitivity.get(value) ?? !mapEntries.length, })); } catch (err) { // eslint-disable-next-line no-console @@ -264,29 +274,15 @@ export function buildTextEntryInteractionXML(state, questionType, declarationSch // CorrectResponse before Mapping: getXML emits children in capability insertion // order, and the schema requires to precede . if (questionType !== QuestionType.FREE_RESPONSE && answers.length !== 0) { - new CorrectResponse( - answers.map(a => a.value), - declaration, - ); - - // is the spec's home for per-answer case sensitivity (string-only). - // mapped-value is schema-required but unused: the editor does not score responses. - if (baseType === BaseType.STRING) { - new Mapping( - { - defaultValue: 0, - lowerBound: null, - upperBound: null, - entries: answers.map(a => ({ - // Trimmed to match how _extractAnswers reads text back. - mapKey: a.value.trim(), - mappedValue: 1, - caseSensitive: Boolean(a.caseSensitive), - })), - }, - declaration, - ); - } + const isString = baseType === BaseType.STRING; + const entries = answers.map(a => ({ + // Text keys are trimmed to match how _extractAnswers reads text back. + mapKey: isString ? a.value.trim() : a.value, + mappedValue: 1, + caseSensitive: isString && Boolean(a.caseSensitive), + })); + new CorrectResponse([answers[0].value], declaration); + new Mapping({ defaultValue: 0, lowerBound: null, upperBound: null, entries }, declaration); } const declarationXml = serializer.serializeToString(declaration.getXML()); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/textEntryItem.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/textEntryItem.spec.js new file mode 100644 index 0000000000..9bb5dfcff3 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/textEntryItem.spec.js @@ -0,0 +1,199 @@ +// jest-dom's matchers reject the XML nodes this serialization produces. +/* eslint-disable jest-dom/prefer-to-have-attribute, jest-dom/prefer-to-have-text-content */ +import { assembleItemXml } from '../assembleItem'; +import { parseItem } from '../parseItem'; +import { parseXML } from '../xml'; +import { textEntryInteractionDescriptor as descriptor } from '../../interactions/textEntry/Descriptor'; +import { QuestionType } from '../../constants'; + +const answer = (value, caseSensitive = false) => ({ + id: `answer_${value}`, + value, + caseSensitive, +}); + +const assemble = ({ bodyXml, responseDeclarations }) => + assembleItemXml({ + identifier: 'item_1', + title: 'Question', + language: 'en', + bodyXml, + responseDeclarations, + }); + +const build = (questionType, answers) => + assemble(descriptor.buildXML({ prompt: '

Q

', answers, expectedLength: 50 }, questionType)); + +const read = xml => { + const [{ bodyXml, responseDeclarations }] = parseItem(xml).interactions; + return descriptor.parse(bodyXml, responseDeclarations); +}; + +const template = xml => + parseXML(xml).querySelector('qti-response-processing').getAttribute('template'); + +const summarize = ({ answers }) => + answers.map(({ value, caseSensitive }) => [value, caseSensitive]); + +describe('a text entry item', () => { + describe('response processing', () => { + it.each([ + [QuestionType.NUMERIC, [answer('1')]], + [QuestionType.TEXT_ENTRY, [answer('a')]], + [QuestionType.NUMERIC, [answer('1'), answer('2'), answer('3')]], + [QuestionType.TEXT_ENTRY, [answer('a'), answer('b'), answer('c')]], + ])('%s with %j is scored by map_response', (questionType, answers) => { + expect(template(build(questionType, answers))).toContain('rptemplates/map_response'); + }); + + it.each([QuestionType.NUMERIC, QuestionType.TEXT_ENTRY])( + 'declares %s as single cardinality however many answers it has', + questionType => { + const doc = parseXML(build(questionType, [answer('1'), answer('2')])); + expect(doc.querySelector('qti-response-declaration').getAttribute('cardinality')).toBe( + 'single', + ); + }, + ); + }); + + it.each([ + [QuestionType.NUMERIC, [answer('1')]], + [QuestionType.TEXT_ENTRY, [answer('a'), answer('b')]], + ])('scores %s answers %j 1 each and anything else 0', (questionType, answers) => { + const doc = parseXML(build(questionType, answers)); + expect(doc.querySelector('qti-mapping').getAttribute('default-value')).toBe('0'); + const entries = [...doc.querySelectorAll('qti-map-entry')]; + expect(entries.map(e => e.getAttribute('mapped-value'))).toEqual(answers.map(() => '1')); + }); + + describe('round trip', () => { + it.each([ + [QuestionType.NUMERIC, [answer('1')]], + [QuestionType.NUMERIC, [answer('1'), answer('2'), answer('3')]], + [QuestionType.NUMERIC, [answer('2'), answer('2'), answer('2.0')]], + [QuestionType.TEXT_ENTRY, [answer('a')]], + [QuestionType.TEXT_ENTRY, [answer('a', true)]], + [QuestionType.TEXT_ENTRY, [answer('a', true), answer('b'), answer('c', true)]], + ])('%s keeps answers %j in order with their flags', (questionType, answers) => { + const once = read(build(questionType, answers)); + const twice = read(build(questionType, once.answers)); + expect(summarize(once)).toEqual(summarize({ answers })); + expect(summarize(twice)).toEqual(summarize(once)); + }); + }); + + it.each([ + [QuestionType.NUMERIC, [answer('1'), answer('2'), answer('3')]], + [QuestionType.TEXT_ENTRY, [answer('a'), answer('b'), answer('c')]], + ])('holds only the first %s answer in the correct response', (questionType, answers) => { + const doc = parseXML(build(questionType, answers)); + const values = [...doc.querySelectorAll('qti-correct-response qti-value')]; + expect(values.map(v => v.textContent)).toEqual([answers[0].value]); + }); + + it('keeps Numeric answers as authored', () => { + const doc = parseXML(build(QuestionType.NUMERIC, [answer('1.0'), answer('.5'), answer('1e3')])); + const entries = [...doc.querySelectorAll('qti-map-entry')]; + expect(entries.map(e => e.getAttribute('map-key'))).toEqual(['1.0', '.5', '1e3']); + expect(doc.querySelector('qti-correct-response qti-value').textContent).toBe('1.0'); + }); + + it('writes the Text correct response as typed and its map key trimmed', () => { + const doc = parseXML(build(QuestionType.TEXT_ENTRY, [answer(' Paris ')])); + expect(doc.querySelector('qti-correct-response qti-value').textContent).toBe(' Paris '); + expect(doc.querySelector('qti-map-entry').getAttribute('map-key')).toBe('Paris'); + }); + + it('writes the Numeric answer as typed', () => { + const doc = parseXML(build(QuestionType.NUMERIC, [answer(' 1 ')])); + expect(doc.querySelector('qti-correct-response qti-value').textContent).toBe(' 1 '); + expect(doc.querySelector('qti-map-entry').getAttribute('map-key')).toBe(' 1 '); + }); + + it('never marks a Numeric map entry case-sensitive', () => { + const doc = parseXML(build(QuestionType.NUMERIC, [answer('1', true)])); + expect(doc.querySelector('qti-map-entry').hasAttribute('case-sensitive')).toBe(false); + }); + + it('keeps invalid and repeated Numeric answers as authored', () => { + const xml = build(QuestionType.NUMERIC, [ + answer('2'), + answer('1e'), + answer('0x10'), + answer('2.0'), + ]); + const entries = [...parseXML(xml).querySelectorAll('qti-map-entry')]; + expect(entries.map(e => e.getAttribute('map-key'))).toEqual(['2', '1e', '0x10', '2.0']); + }); + + const item = declaration => + '' + + declaration + + '

Q

' + + '
'; + + describe('saved with cardinality="multiple"', () => { + const NUMERIC = item( + '' + + '123' + + '', + ); + const TEXT = item( + '' + + 'abc' + + '' + + '' + + '' + + '' + + '', + ); + + it('opens a Numeric item with every accepted answer', () => { + expect(summarize(read(NUMERIC))).toEqual([ + ['1', false], + ['2', false], + ['3', false], + ]); + }); + + it('opens a Text item with every accepted answer and its case flag', () => { + expect(summarize(read(TEXT))).toEqual([ + ['a', true], + ['b', false], + ['c', false], + ]); + }); + + it.each([ + [QuestionType.NUMERIC, NUMERIC], + [QuestionType.TEXT_ENTRY, TEXT], + ])('rewrites a %s item as single cardinality scored by map_response', (questionType, xml) => { + const rewritten = assemble( + descriptor.buildXML({ ...read(xml), expectedLength: 50 }, questionType), + ); + const doc = parseXML(rewritten); + expect(doc.querySelector('qti-response-declaration').getAttribute('cardinality')).toBe( + 'single', + ); + expect(doc.querySelectorAll('qti-map-entry')).toHaveLength(3); + expect(template(rewritten)).toContain('rptemplates/map_response'); + }); + }); + + it('opens a Text item without a mapping as case-sensitive and rewrites it with one', () => { + const TEXT_WITHOUT_MAPPING = item( + '' + + 'a' + + '', + ); + const state = read(TEXT_WITHOUT_MAPPING); + expect(summarize(state)).toEqual([['a', true]]); + const rewritten = assemble( + descriptor.buildXML({ ...state, expectedLength: 50 }, QuestionType.TEXT_ENTRY), + ); + expect(template(rewritten)).toContain('rptemplates/map_response'); + const entries = [...parseXML(rewritten).querySelectorAll('qti-map-entry')]; + expect(entries.map(e => e.getAttribute('case-sensitive'))).toEqual(['true']); + }); +});