Skip to content

Tell QTI answers apart by their formulas and images - #6302

Merged
AlexVelezLl merged 2 commits into
learningequality:unstablefrom
rtibblesbot:issue-6301-3fcad5
Oct 15, 2026
Merged

AlexVelezLl merged 2 commits into
learningequality:unstablefrom
rtibblesbot:issue-6301-3fcad5

Conversation

@rtibblesbot

@rtibblesbot rtibblesbot commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Answers compare on text plus each formula, image, superscript, subscript and line break, so they are no longer flagged or merged as duplicates.
  • Bold, image size and image alignment still count as duplicates; Enter, Shift+Enter and blank lines are one line break; spaces compare as the editor shows them.

References

Fixes #6301. Reported in: #6297 (review comment).

Reviewer guidance

Questions:

  • An image keys on src and alt, not size or alignment. Should alt count, or src alone?
  • Should x² solve and solve x² differ, as now?

Manual QA

QA team: answers now differ by formulas, images, scripts and line breaks. Match and Associate are most at risk, because a save can merge answers.

Requires: one image file.

  1. Match shared answers
    • Give rows 1 and 2 Big cat, a formula and the image.
    • Give row 3 12 Enter 3. Give row 4 1 Enter 23.
    • Give rows 5 and 6 the image. In row 6, click More, then Align right.
    • Click Close, then Edit. Each row keeps its answer. Rows 1 and 2 share one answer, as do rows 5 and 6.
  2. Choice images
    • Give two options the same image. Make one smaller with Resize image. Align the other with More, then Align right. Both show "Duplicate answer options are not allowed".
    • Add Text Enter image, and Text Enter Enter image. Both show the error.
    • Click Finish, then Exit anyway. The channel tree marks this exercise incomplete. Open it again. The same options show the error.
  3. Choice scripts and line breaks
    • Add options x with Superscript 2, x with Subscript 2, x2, and bold x2. Only x2 and bold x2 show the error.
    • Add a b, a Shift+Enter b, a Enter b, and a Enter Enter b. Only the three multi-line options show the error. Is that right?
    • Add 12 Enter 3 and 1 Enter 23. Neither shows the error.
  4. Associate pairs
    • Give pair 2 one text and formula twice. "Answers within a pair cannot be the same" shows.

Evidence

Multiple choice line breaks and images

Step Screenshot
12⏎3 and 1⏎23 not flagged; every other pair flagged 12/3 and 1/23 not flagged; every other pair flagged
Reopened: same options flagged Reopened: same options flagged
"QA6302 S1 MC flags" shows the incomplete icon QA6302 S1 MC flags shows the incomplete icon

Match line breaks and aligned image

match-author-save-reopen.webm

Ordering

s2-ordering-duplicates.webm

Multiple choice images

s3-image-duplicates-resize-reopen.webm

Associate

s5-associate-duplicates-reopen.webm
More captures (29)
Step Screenshot
Scripts before save Scripts before save
Scripts reopened Scripts reopened
Finish Finish
Collapsed Collapsed
Breaks Breaks
Cleared Cleared
Ordering Ordering
Ordering reopened Ordering reopened
Image Image
Resized Resized
Alt Alt
New alt New alt
Reopened Reopened
Resized reopened Resized reopened
Match Match
Pool Pool
Row Row
Match reopened Match reopened
Associate Associate
Associate reopened Associate reopened
Icons Icons
Before reload: a b and a b not flagged; image option flagged Before reload: a b and a nbsp b not flagged; image option flagged
Reopened: same flags Reopened: same flags
After reload: "QA6302 S7 img" shows the incomplete icon After reload: QA6302 S7 img shows the incomplete icon
New multiple choice question New multiple choice question
Tree after reopen: incomplete icon unchanged Tree after reopen: incomplete icon unchanged
Match rows before close Match rows before close
Match closed preview Match closed preview
Match reopened: each row keeps its answer Match reopened: each row keeps its answer

AI usage

Used Claude Code to write the fix and its tests from the issue. Verified with the QTIEditor and TipTapEditor Jest suites and pre-commit.

Committed separately: no commit on this branch introduced the lines these changes touch, so they are a new commit rather than folded into the work they amend:

  • contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/comparisonKey.js
  • contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/comparisonKey.spec.js

Deviations from the issue spec

Criterion Why not
Markup-only differences compare equal Line breaks count, so match keeps 12⏎3 and 1⏎23 apart (reviewer request).

Written by rtibblesbot, an LLM-based coding agent.

@rtibblesbot

🟡 Waiting for feedback

Last updated: 2026-10-14 22:03 UTC

@rtibblesbot
rtibblesbot marked this pull request as ready for review October 7, 2026 21:23
@rtibblesbot
rtibblesbot requested review from a team and leq-bot October 7, 2026 21:23

@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.

Listing all block elements like that is a smell; we could probably do better using ProseMirror's capabilities!

if (!html.includes('<')) return html.trim();
const body = QTISanitizer.textBody(html);
const text = body.textContent.trim();
if (!text) return normaliseMarkup(html);

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.

suggestion: mediaToken reads an image the way TipTap stores it, so a re-rendered image keys the same as the stored one. That only applies beside text. With no text, the raw markup is still the key, and TipTap changes it on every image I tried. Running the spec's own rewrap helper:

Stored After TipTap Same key?
<img src="a.png"/> <img style="text-align: left;" src="a.png"> no
<p><img alt="a cat" src="a.png"/></p> <p></p><img style="text-align: left;" src="a.png" alt="a cat"> no
<p><img src="a.png" title="t"/></p> <p></p><img style="text-align: left;" src="a.png"> no

So when a stored image-only option sits beside the same image newly inserted in TipTap, the duplicate isn't flagged, and match/associate won't share them as one choice. This behaviour predates the PR. But the PR now pins in two rules that disagree: an image with a title is the same image beside text (line 113) and a different one alone (line 125).

Could a fragment with no text key on its media tokens, i.e. the same querySelectorAll(EMBEDDED_MEDIA) pass joined without the text? Then a media-only fragment uses the same identity rule, and the rewrap table could include image-only cases.

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.

Fixed in d1d64fe: an image-only fragment now keys on its Image node attrs, the same as beside text.

  • The rewrap table now has your three image-only cases, and they key the same.
  • The image identity tests run both beside text and alone (describe.each), so title, class, size style and permanentsrc follow one rule in both.
  • Searched QTIEditor for other duplicate checks on raw content: none. Every duplicate check goes through this key. HintsSection compares hint.content === only to skip a no-op update.

* Elements whose surrounding whitespace is layout, not a word gap: TipTap drops it, and
* markup read back out of stored XML arrives pretty-printed with it.
*/
const BLOCK_ELEMENTS =

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.

suggestion: BLOCK_ELEMENTS, the whitespace regexes, mediaToken's image attribute list and the NUL delimiters all copy by hand how TipTap parses content. The "after TipTap rewraps it" tests check that the copy matches today, but nothing keeps it in sync when the editor gains a node or an attribute.

Could the key come from TipTap's schema instead? Parse the fragment with ProseMirror's DOMParser and walk the doc:

import { getSchema } from '@tiptap/core';
import { DOMParser as PMDOMParser } from '@tiptap/pm/model';

const parser = PMDOMParser.fromSchema(getSchema(EDITOR_EXTENSIONS)); // once, at import

export function richTextComparisonKey(html) {
  const { body } = new DOMParser().parseFromString(html ?? '', 'text/html');
  const parts = [];
  parser.parse(body).descendants(node => {
    if (node.isText) parts.push(node.text);
    else if (node.type.name === 'math') parts.push(['latex', node.attrs.latex]);
    else if (node.type.name === 'image') parts.push(['img', node.attrs]);
  });
  return JSON.stringify(parts);
}
  • The schema already knows what's a block and keeps code-block whitespace, so BLOCK_ELEMENTS, dropBlockIndentation and the whitespace regexes go away.
  • node.attrs is exactly what the Image node keeps, so there's no hand-picked attribute list.
  • The key is structured JSON, so typed text can't pass for a formula and the NUL trick isn't needed.
  • It matches TipTap's re-render because it runs the same parser. That also covers the image-only case in my other comment, so normaliseMarkup can go too.
  • No editor instance is created. The schema and parser are built once. Each call is one DOM parse, which the current code already does, plus two linear walks. Don't use generateJSON here: it rebuilds the schema on every call.

Since the key depends on the editor's schema, I think it belongs in TipTapEditor/TipTapEditor/utils/, with the extension list exported from useEditor.js so the editor and the key share one source. It should use the full schema, not the inline-only one, so inline and block content of the same text key the same. The QTI call sites only change their import. TipTap can't import from QTI, so it parses the fragment itself. ProseMirror's parser already skips script and style, so the new QTISanitizer.textBody isn't needed and that change can be reverted.

One behaviour change: ProseMirror collapses runs of spaces, so a b and a b would become duplicates. They render the same to a learner, so I'd count that as a fix, but it reverses the "keeps runs of whitespace in text alone" test.

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 d1d64fe, as you sketched:

  • richTextComparisonKey moved to TipTapEditor/TipTapEditor/utils/comparisonKey.js. It parses with the full schema from editorExtensions(), now exported from useEditor.js, which builds its own editors from it.
  • BLOCK_ELEMENTS, the whitespace regexes, normaliseMarkup, the NUL delimiters and QTISanitizer.textBody are gone. The QTI call sites only change their import.
  • Images key on node.attrs minus permanentSrc. It records where an upload is saved, and keying on it would split a new upload from the same image once saved.
  • One parse was not enough: <p>see <img> here</p> keeps the space before here, but TipTap drops it when it reads its own output. The key serializes the first parse and parses again, so it reads what TipTap saves.
  • Adjacent text nodes are joined, so <strong>Par</strong>is still keys as Paris. Non-breaking spaces at the edges are still trimmed.
  • The "keeps runs of whitespace" test is now "collapses runs of whitespace in text".
  • Two associate validation tests asserted the literal key "Lysander". They now compare against richTextComparisonKey(...), as the match tests already did.
  • MathML and inline SVG are not in the schema, so they now key as their text, which is what TipTap shows and saves.

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.

194ea5a: MathML and inline SVG now key on their markup where they stand, not as their text; sub/superscript text keys apart from plain text, and a non-breaking space keys as a space.

@rtibblesbot

Copy link
Copy Markdown
Contributor Author

Outside this PR's scope — tracked in:


Written by rtibblesbot, an LLM-based coding agent.

// Not where an upload is saved, nor the size ImageNodeView writes when it loads.
parts.push(['img', omit(node.attrs, ['permanentSrc', 'width', 'height'])]);
lineStart = false;
} else if (node.isTextblock || node.type.name === 'hardBreak') {

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.

blocking: Breaks add nothing to the key, so text on either side of them is joined. I checked: <p>12</p><p>3</p> and <p>1</p><p>23</p> both key to ["123"], and <p>a<br>b</p> keys the same as <p>ab</p>. buildMatchInteractionXML (match/parse.js:156-164) merges answers by this key. If row 1's answer is 12⏎3 and row 2's is 1⏎23, saving replaces row 2's answer with row 1's, and no validation error appears because Match allows a shared answer. That's the same silent replacement #6301 is fixing, just triggered by a break instead of a formula.

You can keep Shift+Enter and Enter equal to each other (as the PR description intends) by ending the current text and pushing a break marker such as ['br'] here, instead of only setting lineStart. Skip the marker at the very start and end, so trailing empty paragraphs still drop out. The reads text split over %s as the same text on one line test (comparisonKey.spec.js:121) would then expect a key that differs from <p>ab</p>.

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.

Fixed in c4c3f89: a paragraph or hard break now adds one ["br"], none at the start or end.

  • <p>12</p><p>3</p> and <p>1</p><p>23</p> key apart; Shift+Enter and Enter still key the same.
  • Runs of empty paragraphs between lines read as one break.
  • Searched for merges by this key: match/parse.js and associate/parse.js, both fixed by the key. Added your case to the Match round-trip test.


it.each([
['alt text', '<img src="a.png" alt="a cat"/>', '<img src="a.png" alt="a dog"/>'],
['alignment', '<img src="a.png"/>', '<img src="a.png" data-text-align="center"/>'],

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.

suggestion: On the reviewer-guidance question: the key ignores bold and image size because a learner sees the same answer either way. Alignment works the same way: the same image centered or left-aligned is still the same answer to a learner, so keeping textAlign in the key seems inconsistent. I'd also leave it out, along with permanentSrc/width/height. alt is less clear-cut, since screen-reader users hear it, so keeping it seems fine.

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 c4c3f89: textAlign dropped, so an image keys on src and alt. permanentSrc/width/height were already left out.

@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 more thing!

// The resolver does not read package `exports` subpaths.
// eslint-disable-next-line import/no-unresolved
import { DOMParser as PMDOMParser } from '@tiptap/pm/model';
import { editorExtensions } from '../composables/useEditor';

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.

nitpick: Importing editorExtensions from the useEditor composable means headless validation (validateItem → descriptors → this module) also loads @tiptap/vue-2's Editor and the markdown serializer and paste transform, none of which building a schema needs. Moving editorExtensions (and INLINE_ONLY_STARTER_KIT) into its own module, e.g. TipTapEditor/extensions/index.js, would keep those out of the validation import graph. Image and Math would still bring in their node views, so this only narrows the imports, not removes TipTap.

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 2dfc4a2: editorExtensions and INLINE_ONLY_STARTER_KIT now live in TipTapEditor/extensions/index.js. comparisonKey.js was the only importer outside the composable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lone

- Answers differing only in a formula, image, SVG, MathML or sub/superscript no longer merge in match/associate or flag as duplicates.
- The key parses with the editor's schema, so it reads content as TipTap renders it.
- Whitespace at line edges keys as nothing; a non-breaking space inside text keys apart from a space.

Closes learningequality#6301

Co-Authored-By: Claude Opus 5.5 <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.

Code changes look solid, tests provide good coverage, and manual QA checks out. LGTM!

@AlexVelezLl
AlexVelezLl merged commit dfa8d3e into learningequality:unstable Oct 15, 2026
13 checks passed
@rtibblesbot

Copy link
Copy Markdown
Contributor Author

Quick check on rtibblesbot's work on this PR: react 👍 helpful · 😕 mixed · 👎 not helpful. Reply with anything specific.

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.

[QTI] Answers that differ only in a formula or image are treated as the same answer

2 participants