diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/parse.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/parse.spec.js index c17aff3f35..edc472626c 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/parse.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/parse.spec.js @@ -526,6 +526,23 @@ describe('buildInlineChoiceInteractionXML', () => { ); }); + it('writes option text trimmed', () => { + const padded = { + prompt: '', + passage: html( + p( + marked('r1', 'c1', [ + { id: 'c1', text: 'b' }, + { id: 'c2', text: ' a b ' }, + ]), + ), + ), + shuffle: false, + }; + const options = [...bodyDoc(padded).querySelectorAll('qti-inline-choice')]; + expect(options.map(o => o.textContent)).toEqual(['b', 'a b']); + }); + it('renames a repeated response identifier, writing one declaration each', () => { // A copy of a dropdown pasted into the passage repeats its response and choice ids. const copied = () => marked('r1', 'c1', [{ id: 'c1', text: 'x' }]); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/parse.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/parse.js index f2dc953828..423f3980e7 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/parse.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/parse.js @@ -288,6 +288,10 @@ export function buildInlineChoiceInteractionXML(state, questionType, declaration for (const el of passageEl.querySelectorAll(DROPDOWN)) { el.removeAttribute(CORRECT_ATTR); el.setAttribute('shuffle', String(Boolean(shuffle))); + // Trimmed here, not in editor state, so typing is untouched. + for (const option of el.querySelectorAll(OPTION)) { + option.textContent = option.textContent.trim(); + } } const promptEl = buildXmlNode({ tag: 'div', innerHTML: prompt || '' }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/Editor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/Editor.spec.js index b59a05df54..3a1bf857fb 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/Editor.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/Editor.spec.js @@ -249,6 +249,21 @@ describe('TextEntryEditor — emits', () => { await fireEvent.click(screen.getByRole('button', { name: tr.$tr('addAnswerBtn') })); expect(emitted()['update:interaction'].length).toBeGreaterThan(before); }); + + it('keeps typed whitespace in the input but emits the answer trimmed', async () => { + const { emitted } = renderEditor({ + interaction: blockWithDecl(TEXT_ENTRY_BODY_XML, STRING_DECL), + questionType: QuestionType.TEXT_ENTRY, + }); + const [input] = screen.getAllByRole('textbox', { name: tr.$tr('answerTextPlaceholder') }); + await fireEvent.input(input, { target: { value: 'Paris ' } }); + await nextTick(); + + expect(input).toHaveValue('Paris '); + const updates = emitted()['update:interaction']; + const [decl] = updates[updates.length - 1][0].responseDeclarations; + expect(decl).toContain('Paris'); + }); }); describe('TextEntryEditor — accessibility', () => { 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 3b1b54352a..280e77f8d6 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js @@ -276,12 +276,12 @@ export function buildTextEntryInteractionXML(state, questionType, declarationSch if (questionType !== QuestionType.FREE_RESPONSE && answers.length !== 0) { 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, + // Trimmed on build, not in state, so typing is untouched; matches _extractAnswers' trim. + mapKey: a.value.trim(), mappedValue: 1, caseSensitive: isString && Boolean(a.caseSensitive), })); - new CorrectResponse([answers[0].value], declaration); + new CorrectResponse([entries[0].mapKey], declaration); new Mapping({ defaultValue: 0, lowerBound: null, upperBound: null, entries }, declaration); } 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 index 9bb5dfcff3..cb057c2cb1 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/textEntryItem.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/textEntryItem.spec.js @@ -99,16 +99,17 @@ describe('a text entry item', () => { 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 Text answers and map keys trimmed', () => { + const doc = parseXML(build(QuestionType.TEXT_ENTRY, [answer(' New York '), answer('Rome ')])); + expect(doc.querySelector('qti-correct-response qti-value').textContent).toBe('New York'); + const entries = [...doc.querySelectorAll('qti-map-entry')]; + expect(entries.map(e => e.getAttribute('map-key'))).toEqual(['New York', 'Rome']); }); - 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('writes the Numeric answer and map key trimmed', () => { + const doc = parseXML(build(QuestionType.NUMERIC, [answer(' 5 ')])); + expect(doc.querySelector('qti-correct-response qti-value').textContent).toBe('5'); + expect(doc.querySelector('qti-map-entry').getAttribute('map-key')).toBe('5'); }); it('never marks a Numeric map entry case-sensitive', () => { diff --git a/contentcuration/contentcuration/tests/utils/qti/test_perseus_derive.py b/contentcuration/contentcuration/tests/utils/qti/test_perseus_derive.py index 68766d4ae4..f91d8dc250 100644 --- a/contentcuration/contentcuration/tests/utils/qti/test_perseus_derive.py +++ b/contentcuration/contentcuration/tests/utils/qti/test_perseus_derive.py @@ -194,6 +194,27 @@ def test_text_input_mapping_keeps_correct_response(map_entries, expected): assert all(a["correct"] for a in answers) +@pytest.mark.parametrize( + "map_key", + [ + pytest.param("42", id="trimmed_key"), + pytest.param(" 42 ", id="padded_key"), + ], +) +def test_text_input_padded_values_dedupe(map_key): + raw_data = _text_item("single", [" 42 "], map_entries=[(map_key, "1.0")]) + answers = json.loads(derive_perseus_item(_Item(raw_data)).answers) + assert [a["answer"] for a in answers] == ["42"] + + +def test_choice_padded_correct_value_matches_identifier(): + raw_data = _choice_item( + "single", [" choice_0 "], [("choice_0", "A"), ("choice_1", "B")] + ) + answers = json.loads(derive_perseus_item(_Item(raw_data)).answers) + assert [a["correct"] for a in answers] == [True, False] + + def test_overflowing_correct_value_is_dropped(): raw_data = _text_item("single", ["42", "1e400"]) assert is_perseus_derivable(raw_data) is True diff --git a/contentcuration/contentcuration/utils/assessment/qti/perseus_derive.py b/contentcuration/contentcuration/utils/assessment/qti/perseus_derive.py index d213b1292e..bab68b4a6d 100644 --- a/contentcuration/contentcuration/utils/assessment/qti/perseus_derive.py +++ b/contentcuration/contentcuration/utils/assessment/qti/perseus_derive.py @@ -121,7 +121,7 @@ def _correct_values(declaration) -> List[str]: if not correct_responses: return [] return [ - value.text or "" + (value.text or "").strip() for value in _children_by_localname(correct_responses[0], "qti-value") ] @@ -134,7 +134,7 @@ def _accepted_values(declaration) -> List[str]: mappings = _children_by_localname(declaration, "qti-mapping") if mappings: values += [ - entry.get("map-key") + (entry.get("map-key") or "").strip() for entry in _children_by_localname(mappings[0], "qti-map-entry") if (_as_float(entry.get("mapped-value")) or 0) >= 1 ] @@ -198,7 +198,7 @@ def _derive_choice(interaction, item_body, declaration): def _perseus_number(value): """A leading "+" and capital "E" are valid xsd:double but not for ``extract_value``.""" - normalised = value.strip().removeprefix("+").lower() + normalised = value.removeprefix("+").lower() return normalised if extract_value(normalised) is not None else value