Skip to content

Round-trip text-entry case sensitivity through qti-mapping - #6057

Merged
AlexVelezLl merged 8 commits into
learningequality:unstablefrom
rtibblesbot:issue-6055-a45a64
Jul 31, 2026
Merged

Round-trip text-entry case sensitivity through qti-mapping#6057
AlexVelezLl merged 8 commits into
learningequality:unstablefrom
rtibblesbot:issue-6055-a45a64

Conversation

@rtibblesbot

@rtibblesbot rtibblesbot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Summary

Text-entry answers marked case-insensitive in the editor lost that flag on save: it was written nowhere in the XML, and on load it was read back from a case-sensitive attribute on <qti-value> that the QTI schema does not define. Per-answer case sensitivity is now serialized as a <qti-mapping> with one <qti-map-entry> per string answer, and parsed back from it by map-key.

Deviation from one acceptance criterion

The issue's second criterion says qti-map-entry entries default to true and the attribute must be written explicitly when false. That is inverted from the schema, so this PR does the opposite: case-sensitive is written only when the answer is case-sensitive.

  • The QTI 3.0 XSD declares MapEntryDType/@case-sensitive as use="optional" default="false"contentcuration/contentcuration/utils/assessment/qti/schema/xsd/imsqti_itemv3p0p1_v1p0.xsd:32764.
  • Studio's backend model already agrees — utils/assessment/qti/assessment_item.py:149, case_sensitive: bool = False.

Emitting the attribute only for true keeps the written value and the schema default in agreement, so a consumer that applies XSD attribute defaults reads back what was authored. It also means an answer with no matching map entry falls back to case-insensitive, which is the editor's own new-answer default (useTextEntryInteraction.js:30) — items authored before this change load exactly as they did. Say the word and I'll flip it to the criterion as written.

Every other criterion is implemented as specified; coverage table in #6055 (comment). The checkboxes in the issue body are still unticked because this account cannot edit another user's issue (UpdateIssue is denied) — that table is the record.

References

Fixes #6055. Text-entry interaction: #6051.

Reviewer guidance

  • interactions/textEntry/parse.js:255CorrectResponse is constructed before Mapping because QTIDeclaration.getXML emits capabilities in insertion order and the XSD fixes that sequence; check nothing in the capability registry can re-order them.
  • interactions/textEntry/parse.js:83 — reading the mapping degrades to a warning plus the attribute default when QTIDeclaration.fromXML rejects a declaration, rather than dropping the answers the way the enclosing catch does; confirm that is the wanted behaviour for a malformed declaration.
  • serialization/qti/declarations/mapping.js:64 — the parse/serialize default flipped to false to match the XSD, which changes Mapping for every consumer, not just text entry. Text entry is currently the only one.

AI usage

I used Claude Code to implement this from a plan I reviewed and approved, test-driven, and to update the QTI demo item's XML to the new form. Verified with the QTI editor Jest suite and pre-commit.


@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly

How was this generated?
  • Ran pre-flight CI checks (lint, format, tests) and verified all pass
  • Rebased onto the target branch and resolved any conflicts
  • Reorganized commit history into clean, logical commits
  • Audited the diff to ensure only issue-relevant files are changed
  • Built PR body from the repository's PR template with evidence blocks
@rtibblesbot

🟡 Waiting for feedback

Last updated: 2026-07-31 14:40 UTC

rtibblesbot and others added 5 commits July 30, 2026 16:29
Mapping.fromXML treated only the literal string 'false' as false, so a
spec-valid case-sensitive="0" parsed as true.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Per-answer case sensitivity was written nowhere on save and read from a
case-sensitive attribute on qti-value that is not part of the QTI schema.
Serialize a qti-mapping alongside qti-correct-response for string base-type
answers, and derive caseSensitive from it on parse, matching by map-key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The QTI 3.0 XSD declares MapEntryDType/@CaSe-SeNsItIvE as an optional
xs:boolean with default="false", and Studio's own backend model agrees
(assessment_item.py MapEntry.case_sensitive = False). Both the parser and
serializer assumed the default was true, so a case-sensitive answer was
written with the attribute omitted — which any consumer applying XSD
attribute defaults, including Studio's publish-time validation path, reads
back as case-insensitive.

Write case-sensitive="true" for case-sensitive entries and omit it
otherwise; treat an absent attribute as false on parse. Answers with no
matching map entry now fall back to false, which also matches the editor's
new-answer default and leaves items authored before this change parsing
exactly as they did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
With case-sensitive defaulting to false, the padded '  Rome  ' answer
asserted nothing: a map-key that failed to match would fall back to
false and the expectation would still hold. Marking it case-sensitive
makes the assertion fail if the serializer stops trimming map-key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Use optional chaining on the (null for float) lookup map instead of
re-testing isString, so caseSensitive is always a boolean without
relying on && short-circuit ordering.

The map-key trim is already proven end-to-end by the padded answer in
the round-trip test; drop the granular serializer test covering the
same path and say why the round-trip answer is padded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The two mapping fixtures differed only in the xs:boolean lexical form and
fed two tests with identical assertions. Parameterize the test over both
forms with inline XML and drop the single-use fixture.
Comments across the case-sensitivity path restated what the adjacent code
already showed. Cut them back to the non-obvious parts: the QTI NULL
coercion of an empty map-key, the XSD default for case-sensitive, and the
child-ordering constraint on getXML.

Alongside that, drop the buildCaseDeclaration wrapper in favour of default
arguments on buildDeclaration, and read the case-sensitive attribute
through a single lexical-form check.

@AlexVelezLl AlexVelezLl left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just one little thing now that we are there!

* @param {Element} declEl - The <qti-response-declaration> element
* @returns {Map<string, boolean>}
*/
function caseSensitivityByValue(declEl) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of adding a new method, could you put this into _extractAnswers and refactor _extractAnswers to also use the QTIDeclaration.fromXML parser and get the correct responses from the declaration class?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in a54f46bcaseSensitivityByValue is gone and _extractAnswers now does a single QTIDeclaration.fromXML parse, reading both the correct responses and the mapping off the declaration. Values come from declaration.correctResponse (base-type coerced) and are matched to map keys via declaration.formatValue on both sides, so the empty-value/NULL case lines up without any special casing.

One behaviour change worth flagging: a declaration too malformed for QTIDeclaration to model — e.g. missing the required identifier — previously degraded to raw DOM reads and kept the answers. With a single parser there is nothing to degrade to, so it now returns [] and logs, same as an unsupported base-type already did. Test updated to match.

_extractAnswers parsed <qti-correct-response> by hand while a separate
helper built the case-sensitivity lookup via QTIDeclaration. Fold both
into one parse: the declaration class now supplies the correct responses
as well as the mapping.

A declaration too malformed for QTIDeclaration to model (e.g. a missing
identifier) now yields no answers rather than falling back to raw DOM
reads, matching how an unsupported base-type is already handled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@AlexVelezLl AlexVelezLl left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm!

@AlexVelezLl
AlexVelezLl merged commit 0412db3 into learningequality:unstable Jul 31, 2026
22 checks passed
@rtibblesbot
rtibblesbot deleted the issue-6055-a45a64 branch July 31, 2026 14:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Text-entry interaction: parse and serialize case-sensitivity via qti-mapping

2 participants