diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/Editor.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/Editor.vue index 174e989a49..aa113da68e 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/Editor.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/Editor.vue @@ -248,7 +248,7 @@ import ClickableRegion from '../../components/ClickableRegion/index.vue'; import ShuffledResponsePool from '../../components/ShuffledResponsePool/index.vue'; import EditableChipList from '../../components/EditableChipList/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'; @@ -537,21 +537,25 @@ () => new Set( flatten(state.value.pairs) - .filter(choice => hasRichTextContent(choice.content)) - .map(choice => richTextComparisonKey(choice.content)), + .map(choice => choiceKey(choice.content)) + .filter(Boolean), ), ); - // One option per association the learner can make: the emitted XML folds - // choices holding the same answer into a single option whose match-max is - // the number of occurrences walked here. - const poolChoices = computed(() => - [...flatten(state.value.pairs), ...state.value.distractors].map(choice => ({ - content: choice.content, - isCorrect: - props.showAnswers && pairedTexts.value.has(richTextComparisonKey(choice.content)), - })), - ); + // Kolibri's pool shows each choice of the interaction once. + const poolChoices = computed(() => { + const keys = new Set(); + const keyById = new Map(); + const choices = []; + for (const { id, content } of [...flatten(state.value.pairs), ...state.value.distractors]) { + const key = choiceKey(content); + if (key ? keys.has(key) : keyById.get(id) === '') continue; + if (key) keys.add(key); + if (!keyById.has(id)) keyById.set(id, key); + choices.push({ content, isCorrect: props.showAnswers && pairedTexts.value.has(key) }); + } + return choices; + }); return { EditorImageProcessor, diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/Editor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/Editor.spec.js index fc5e8dfdb9..c85198d4c9 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/Editor.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/Editor.spec.js @@ -11,6 +11,7 @@ import { mockInteractionBlockWithDecl as blockWithDecl, } from '../../../utils/testingFixtures'; import { QuestionType } from '../../../constants'; +import { parseXML } from '../../../serialization/xml'; import { qtiEditorStrings as tr } from '../../../qtiEditorStrings'; jest.mock('shared/views/TipTapEditor/TipTapEditor/TipTapEditor'); @@ -60,6 +61,37 @@ const SHARED_CHOICE_DECL_XML = ` `; +const PAIRED_DISTRACTOR_XML = ` + Match each character to his adversary. + Antonio + Prospero + Antonio +`; + +const BLANK_CHOICES_XML = ` + Match each character to his adversary. + + Prospero + Capulet + +`; + +const THRICE_SHARED_CHOICE_XML = ` + Match each character to his adversary. + Antonio + Prospero + Capulet + Montague +`; + +const THRICE_SHARED_CHOICE_DECL_XML = ` + + choice_aaa11111 choice_bbb22222 + choice_aaa11111 choice_ccc33333 + choice_aaa11111 choice_ddd44444 + +`; + // The mock TipTapEditor renders a only for the card currently open. const openTextarea = () => screen.queryAllByRole('textbox').find(el => el.tagName === 'TEXTAREA'); @@ -376,13 +408,49 @@ describe('AssociateEditor', () => { } }); - it('repeats an option in the pool once per association it allows', () => { + it('lists an option shared by several associations once in the pool', () => { renderEditor({ ...viewProps, interaction: blockWithDecl(SHARED_CHOICE_XML, SHARED_CHOICE_DECL_XML), }); - expect(screen.getAllByText('Antonio')).toHaveLength(2); - expect(screen.getAllByText('Prospero')).toHaveLength(1); + const pool = screen.getByRole('list', { name: tr.$tr('responsePoolLabel') }); + expect(within(pool).getAllByText('Antonio')).toHaveLength(1); + expect(within(pool).getAllByText('Prospero')).toHaveLength(1); + }); + + it('lists a distractor of the same content as a paired option once in the pool', () => { + renderEditor({ + ...viewProps, + interaction: blockWithDecl(PAIRED_DISTRACTOR_XML, ONE_PAIR_DECL_XML), + }); + const pool = screen.getByRole('list', { name: tr.$tr('responsePoolLabel') }); + expect(within(pool).getAllByText('Antonio')).toHaveLength(1); + }); + + it('lists a blank option once per identifier in the pool', () => { + renderEditor({ + ...viewProps, + interaction: blockWithDecl(BLANK_CHOICES_XML, SHARED_CHOICE_DECL_XML), + }); + const pool = screen.getByRole('list', { name: tr.$tr('responsePoolLabel') }); + expect(within(pool).getAllByRole('listitem')).toHaveLength(4); + }); + + it('lists as many pool options as the saved XML holds once shared options are cleared', async () => { + const user = userEvent.setup(); + const { emitted, updateProps } = renderEditor({ + interaction: blockWithDecl(THRICE_SHARED_CHOICE_XML, THRICE_SHARED_CHOICE_DECL_XML), + }); + for (const number of [2, 3]) { + await user.click(button(editPairItem(number, 1))); + await user.clear(openTextarea()); + } + await updateProps({ mode: 'view' }); + const savedChoices = parseXML(latestBodyXml(emitted)).querySelectorAll( + 'qti-simple-associable-choice', + ); + const pool = screen.getByRole('list', { name: tr.$tr('responsePoolLabel') }); + expect(within(pool).getAllByRole('listitem')).toHaveLength(savedChoices.length); }); it('keeps the pool order across a round trip through edit mode', async () => { 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/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 only for the card currently open. const openTextareas = () => screen.queryAllByRole('textbox').filter(el => el.tagName === 'TEXTAREA'); @@ -808,14 +838,39 @@ describe('MatchEditor', () => { const renderView = (props = {}) => renderEditor({ mode: 'view', ...props }); const pool = () => screen.getByRole('list', { name: tr.$tr('responsePoolLabel') }); - it('repeats an answer in the pool once per row it serves, and each distractor once', () => { + it('lists an answer shared by several rows once in the pool, and each distractor once', () => { renderView({ interaction: blockWithDecl(SHARED_ANSWER_XML, SHARED_ANSWER_DECL_XML), }); - expect(within(pool()).getAllByText('Mammal')).toHaveLength(2); + expect(within(pool()).getAllByText('Mammal')).toHaveLength(1); expect(within(pool()).getAllByText('Reptile')).toHaveLength(1); }); + it('lists a distractor apart from an answer of the same content', () => { + renderView({ + interaction: blockWithDecl(ANSWER_AS_DISTRACTOR_XML, ONE_ROW_DECL_XML), + }); + expect(within(pool()).getAllByText('Mammal')).toHaveLength(2); + }); + + it('paints the one pool chip of an answer shared by several rows green', () => { + const palette = themePalette(); + renderView({ + interaction: blockWithDecl(SHARED_ANSWER_XML, SHARED_ANSWER_DECL_XML), + showAnswers: true, + }); + const chips = within(pool()).getAllByText('Mammal'); + expect(chips).toHaveLength(1); + expect(chips[0].closest('li')).toHaveStyle({ borderColor: palette.green.v_600 }); + }); + + it('lists each blank answer as its own pool chip', () => { + renderView({ + interaction: blockWithDecl(TWO_BLANK_ANSWERS_XML, TWO_BLANK_ANSWERS_DECL_XML), + }); + expect(within(pool()).getAllByRole('listitem')).toHaveLength(2); + }); + it('lists every row prompt and no answer when answers are hidden', () => { renderView(); for (const content of ['Dog', 'Eagle', 'Frog']) { 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) : ''; +}
Match each character to his adversary.
Match each animal to its class.