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