From 2e5baf9188b1a90c65a66b772a2306696a83f083 Mon Sep 17 00:00:00 2001 From: rtibblesbot Date: Fri, 23 Oct 2026 11:42:39 +0000 Subject: [PATCH 1/3] Share the choice fold key across the QTI builds Co-Authored-By: Claude Opus 5.5 --- .../views/QTIEditor/interactions/associate/parse.js | 5 ++--- .../views/QTIEditor/interactions/gapMatch/parse.js | 7 +++---- .../shared/views/QTIEditor/interactions/match/parse.js | 5 ++--- .../frontend/shared/views/QTIEditor/utils/richText.js | 9 +++++++++ 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/parse.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/parse.js index 21d782ea8b..678ae30e56 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/parse.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/parse.js @@ -4,9 +4,8 @@ import { QTIDeclaration } from '../../serialization/qti/QTIDeclaration'; import { buildXmlNode, getContentHTML, getPromptHTML, parseXML } from '../../serialization/xml'; import CorrectResponse from '../../serialization/qti/declarations/correctResponse'; import { generateRandomSlug } from '../../utils/generateRandomSlug'; -import { hasRichTextContent } from '../../utils/richText'; +import { choiceKey } from '../../utils/richText'; import { RESPONSE_IDENTIFIER } from '../../constants'; -import { richTextComparisonKey } from 'shared/views/TipTapEditor/TipTapEditor/utils/comparisonKey'; const serializer = new XMLSerializer(); @@ -132,7 +131,7 @@ export function buildAssociateInteractionXML(state, _questionType, declarationSc // Preserves the id a choice already carries unless it is already bound to // different content. function resolveChoice({ id, content }) { - const key = hasRichTextContent(content) ? richTextComparisonKey(content) : ''; + const key = choiceKey(content); // Blank choices are never deduped — a freshly added pair holds two of them, // and collapsing them would leave the pair unable to round-trip. diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/gapMatch/parse.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/gapMatch/parse.js index ad5df9e584..15b74e73f2 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/gapMatch/parse.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/gapMatch/parse.js @@ -10,10 +10,9 @@ import { wrapInlineRuns, } from '../../serialization/xml'; import { generateRandomSlug } from '../../utils/generateRandomSlug'; -import { hasRichTextContent } from '../../utils/richText'; +import { choiceKey, hasRichTextContent } from '../../utils/richText'; import { RESPONSE_IDENTIFIER, SENTINEL_MARKER } from '../../constants'; import { _extractCorrectPairIds } from '../associate/parse'; -import { richTextComparisonKey } from 'shared/views/TipTapEditor/TipTapEditor/utils/comparisonKey'; const serializer = new XMLSerializer(); @@ -185,8 +184,8 @@ export function buildGapMatchInteractionXML(state, _questionType, declarationSch for (const gapEl of gapEls) { const gapId = claimExisting(gapEl.getAttribute('identifier'), 'gap'); const content = getContentHTML(gapEl); - if (hasRichTextContent(content)) { - const key = richTextComparisonKey(content); + const key = choiceKey(content); + if (key) { const ownAnswerId = gapEl.getAttribute(CORRECT_ATTR); const answerId = idByAnswerKey.get(key) || diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/parse.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/parse.js index c498bc8e4a..1b6d8ca0b5 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/parse.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/parse.js @@ -2,10 +2,9 @@ import { QTIDeclaration } from '../../serialization/qti/QTIDeclaration'; import { buildXmlNode, getContentHTML, getPromptHTML, parseXML } from '../../serialization/xml'; import CorrectResponse from '../../serialization/qti/declarations/correctResponse'; import { generateRandomSlug } from '../../utils/generateRandomSlug'; -import { hasRichTextContent } from '../../utils/richText'; +import { choiceKey } from '../../utils/richText'; import { RESPONSE_IDENTIFIER } from '../../constants'; import { _extractCorrectPairIds, newChoice } from '../associate/parse'; -import { richTextComparisonKey } from 'shared/views/TipTapEditor/TipTapEditor/utils/comparisonKey'; const serializer = new XMLSerializer(); @@ -153,7 +152,7 @@ export function buildMatchInteractionXML(state, _questionType, declarationSchema const correctPairs = []; rows.forEach((row, index) => { for (const { id, content } of row.matches) { - const key = hasRichTextContent(content) ? richTextComparisonKey(content) : ''; + const key = choiceKey(content); const resolved = (key && idByAnswerKey.get(key)) || claim(id, 'choice'); if (key) idByAnswerKey.set(key, resolved); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/richText.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/richText.js index 9ead9a85fe..ccd4d2c2c3 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/richText.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/richText.js @@ -14,6 +14,7 @@ import { QTISanitizer } from '../serialization/qti/QTISanitizer'; import { parseXML } from '../serialization/xml'; +import { richTextComparisonKey } from 'shared/views/TipTapEditor/TipTapEditor/utils/comparisonKey'; /** * Markup that is content in its own right, with no text to find. @@ -49,3 +50,11 @@ export function hasRichTextContent(content) { Boolean(parseXML(content ?? '', 'text/html').querySelector(EMBEDDED_MEDIA)) ); } + +/** + * @param {string} content - HTML fragment from a TipTap editor + * @returns {string} the key choices of equal content share; '' for blank content + */ +export function choiceKey(content) { + return hasRichTextContent(content) ? richTextComparisonKey(content) : ''; +} From 9158b3450cfd018330d322d4ae1f101e79e9516c Mon Sep 17 00:00:00 2001 From: rtibblesbot Date: Fri, 23 Oct 2026 11:42:43 +0000 Subject: [PATCH 2/3] List a shared Match answer once in the response pool Co-Authored-By: Claude Opus 5.5 --- .../QTIEditor/interactions/match/Editor.vue | 30 +++++----- .../match/__tests__/Editor.spec.js | 59 ++++++++++++++++++- 2 files changed, 73 insertions(+), 16 deletions(-) diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/Editor.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/Editor.vue index d663179866..c49f5476af 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/Editor.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/Editor.vue @@ -309,7 +309,7 @@ import ClickableRegion from '../../components/ClickableRegion/index.vue'; import EditableChipList from '../../components/EditableChipList/index.vue'; import ShuffledResponsePool from '../../components/ShuffledResponsePool/index.vue'; - import { hasRichTextContent } from '../../utils/richText'; + import { choiceKey, hasRichTextContent } from '../../utils/richText'; import TipTapEditor from 'shared/views/TipTapEditor/TipTapEditor/TipTapEditor'; import { richTextComparisonKey } from 'shared/views/TipTapEditor/TipTapEditor/utils/comparisonKey'; import EditorImageProcessor from 'shared/views/TipTapEditor/TipTapEditor/services/imageService'; @@ -663,19 +663,21 @@ const isOnlyRow = computed(() => state.value.rows.length <= 1); - // One option per association the learner can make: the emitted XML folds - // answers holding the same content into a single option whose match-max is - // the number of occurrences walked here. Blank answers are left unmarked: - // every blank keys the same, so they would mark an unwritten option correct. - const poolChoices = computed(() => [ - ...state.value.rows.flatMap(row => - row.matches.map(choice => ({ - content: choice.content, - isCorrect: props.showAnswers && hasRichTextContent(choice.content), - })), - ), - ...state.value.distractors.map(choice => ({ content: choice.content, isCorrect: false })), - ]); + // Kolibri's pool shows each choice of the response match set once. + const poolChoices = computed(() => { + const answerKeys = new Set(); + const answers = []; + for (const { content } of state.value.rows.flatMap(row => row.matches)) { + const key = choiceKey(content); + if (answerKeys.has(key)) continue; + if (key) answerKeys.add(key); + answers.push({ content, isCorrect: props.showAnswers && Boolean(key) }); + } + return [ + ...answers, + ...state.value.distractors.map(({ content }) => ({ content, isCorrect: false })), + ]; + }); return { EditorImageProcessor, diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/__tests__/Editor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/__tests__/Editor.spec.js index 423d9b3d7d..1ebbe71224 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/__tests__/Editor.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/__tests__/Editor.spec.js @@ -77,6 +77,17 @@ const SHARED_ANSWER_DECL_XML = ` `; +const ANSWER_AS_DISTRACTOR_XML = ` +

Match each animal to its class.

+ + Dog + + + Mammal + Mammal + +
`; + const BLANK_ANSWER_XML = `

Match each animal to its class.

@@ -87,6 +98,25 @@ const BLANK_ANSWER_XML = `
`; +const TWO_BLANK_ANSWERS_XML = ` +

Match each animal to its class.

+ + Dog + Cat + + + + + +
`; + +const TWO_BLANK_ANSWERS_DECL_XML = ` + + row_dog choice_dog + row_cat choice_cat + +`; + // The mock TipTapEditor renders a