Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -60,6 +61,37 @@ const SHARED_CHOICE_DECL_XML = `<qti-response-declaration identifier="RESPONSE"
</qti-correct-response>
</qti-response-declaration>`;

const PAIRED_DISTRACTOR_XML = `<qti-associate-interaction response-identifier="RESPONSE">
<qti-prompt><p>Match each character to his adversary.</p></qti-prompt>
<qti-simple-associable-choice identifier="choice_aaa11111" match-max="1">Antonio</qti-simple-associable-choice>
<qti-simple-associable-choice identifier="choice_bbb22222" match-max="1">Prospero</qti-simple-associable-choice>
<qti-simple-associable-choice identifier="choice_ddd44444" match-max="1">Antonio</qti-simple-associable-choice>
</qti-associate-interaction>`;

const BLANK_CHOICES_XML = `<qti-associate-interaction response-identifier="RESPONSE">
<qti-prompt><p>Match each character to his adversary.</p></qti-prompt>
<qti-simple-associable-choice identifier="choice_aaa11111" match-max="2"></qti-simple-associable-choice>
<qti-simple-associable-choice identifier="choice_bbb22222" match-max="1">Prospero</qti-simple-associable-choice>
<qti-simple-associable-choice identifier="choice_ccc33333" match-max="1">Capulet</qti-simple-associable-choice>
<qti-simple-associable-choice identifier="choice_ddd44444" match-max="1"></qti-simple-associable-choice>
</qti-associate-interaction>`;

const THRICE_SHARED_CHOICE_XML = `<qti-associate-interaction response-identifier="RESPONSE">
<qti-prompt><p>Match each character to his adversary.</p></qti-prompt>
<qti-simple-associable-choice identifier="choice_aaa11111" match-max="3">Antonio</qti-simple-associable-choice>
<qti-simple-associable-choice identifier="choice_bbb22222" match-max="1">Prospero</qti-simple-associable-choice>
<qti-simple-associable-choice identifier="choice_ccc33333" match-max="1">Capulet</qti-simple-associable-choice>
<qti-simple-associable-choice identifier="choice_ddd44444" match-max="1">Montague</qti-simple-associable-choice>
</qti-associate-interaction>`;

const THRICE_SHARED_CHOICE_DECL_XML = `<qti-response-declaration identifier="RESPONSE" cardinality="multiple" base-type="pair">
<qti-correct-response>
<qti-value>choice_aaa11111 choice_bbb22222</qti-value>
<qti-value>choice_aaa11111 choice_ccc33333</qti-value>
<qti-value>choice_aaa11111 choice_ddd44444</qti-value>
</qti-correct-response>
</qti-response-declaration>`;

// The mock TipTapEditor renders a <textarea> only for the card currently open.
const openTextarea = () => screen.queryAllByRole('textbox').find(el => el.tagName === 'TEXTAREA');

Expand Down Expand Up @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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) ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,17 @@ const SHARED_ANSWER_DECL_XML = `<qti-response-declaration identifier="RESPONSE"
</qti-correct-response>
</qti-response-declaration>`;

const ANSWER_AS_DISTRACTOR_XML = `<qti-match-interaction response-identifier="RESPONSE">
<qti-prompt><p>Match each animal to its class.</p></qti-prompt>
<qti-simple-match-set>
<qti-simple-associable-choice identifier="row_dog" match-max="1">Dog</qti-simple-associable-choice>
</qti-simple-match-set>
<qti-simple-match-set>
<qti-simple-associable-choice identifier="choice_mammal" match-max="1">Mammal</qti-simple-associable-choice>
<qti-simple-associable-choice identifier="choice_mammal_2" match-max="1">Mammal</qti-simple-associable-choice>
</qti-simple-match-set>
</qti-match-interaction>`;

const BLANK_ANSWER_XML = `<qti-match-interaction response-identifier="RESPONSE">
<qti-prompt><p>Match each animal to its class.</p></qti-prompt>
<qti-simple-match-set>
Expand All @@ -87,6 +98,25 @@ const BLANK_ANSWER_XML = `<qti-match-interaction response-identifier="RESPONSE">
</qti-simple-match-set>
</qti-match-interaction>`;

const TWO_BLANK_ANSWERS_XML = `<qti-match-interaction response-identifier="RESPONSE">
<qti-prompt><p>Match each animal to its class.</p></qti-prompt>
<qti-simple-match-set>
<qti-simple-associable-choice identifier="row_dog" match-max="1">Dog</qti-simple-associable-choice>
<qti-simple-associable-choice identifier="row_cat" match-max="1">Cat</qti-simple-associable-choice>
</qti-simple-match-set>
<qti-simple-match-set>
<qti-simple-associable-choice identifier="choice_dog" match-max="1"></qti-simple-associable-choice>
<qti-simple-associable-choice identifier="choice_cat" match-max="1"></qti-simple-associable-choice>
</qti-simple-match-set>
</qti-match-interaction>`;

const TWO_BLANK_ANSWERS_DECL_XML = `<qti-response-declaration identifier="RESPONSE" cardinality="multiple" base-type="directedPair">
<qti-correct-response>
<qti-value>row_dog choice_dog</qti-value>
<qti-value>row_cat choice_cat</qti-value>
</qti-correct-response>
</qti-response-declaration>`;

// The mock TipTapEditor renders a <textarea> only for the card currently open.
const openTextareas = () =>
screen.queryAllByRole('textbox').filter(el => el.tagName === 'TEXTAREA');
Expand Down Expand Up @@ -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']) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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) : '';
}
Loading