Skip to content
Merged
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 @@ -167,9 +167,9 @@
import ClickableRegion from '../ClickableRegion/index.vue';
import ValidationMessage from '../ValidationMessage/index.vue';
import { qtiEditorStrings } from '../../qtiEditorStrings';
import { hasRichTextContent } from '../../utils/richText';
import TipTapEditor from 'shared/views/TipTapEditor/TipTapEditor/TipTapEditor';
import EditorImageProcessor from 'shared/views/TipTapEditor/TipTapEditor/services/imageService';
import { hasRichTextContent } from 'shared/views/TipTapEditor/TipTapEditor/utils/comparisonKey';

// Holds a message line open without text
const NO_BREAK_SPACE = '\u00a0';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { computed, readonly } from 'vue';
import { inlineChoiceInteractionDescriptor } from '../interactions/inlineChoice/Descriptor';
import { getDropdowns } from '../interactions/inlineChoice/parse';
import { hasRichTextContent } from '../utils/richText';
import { useInteraction } from './useInteraction';
import { hasRichTextContent } from 'shared/views/TipTapEditor/TipTapEditor/utils/comparisonKey';

/**
* Composable for the inline choice interaction editor.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -248,9 +248,12 @@
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 { resolveAssociateChoices } from './parse';
import TipTapEditor from 'shared/views/TipTapEditor/TipTapEditor/TipTapEditor';
import { richTextComparisonKey } from 'shared/views/TipTapEditor/TipTapEditor/utils/comparisonKey';
import {
hasRichTextContent,
richTextComparisonKey,
} from 'shared/views/TipTapEditor/TipTapEditor/utils/comparisonKey';
import EditorImageProcessor from 'shared/views/TipTapEditor/TipTapEditor/services/imageService';

/** Which card currently holds the open TipTap editor. */
Expand Down Expand Up @@ -531,27 +534,21 @@
props.mode === 'edit' ? correctPairsLabel$() : correctAnswersLabel$(),
);

// Blank pair members are left out: every blank choice keys the same, so
// keeping them would mark an unwritten option in the pool as correct.
const pairedTexts = computed(
() =>
new Set(
flatten(state.value.pairs)
.filter(choice => hasRichTextContent(choice.content))
.map(choice => richTextComparisonKey(choice.content)),
),
);

// 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(() => {
Comment thread
AlexVelezLl marked this conversation as resolved.
const { pairs, pool } = resolveAssociateChoices(state.value.pairs, state.value.distractors);
// Blank pair members are left out: every blank choice keys the same, so
// keeping them would mark an unwritten option in the pool as correct.
const pairedKeys = new Set(
flatten(pairs)
.map(({ key }) => key)
.filter(Boolean),
);
return pool.map(({ content, key }) => ({
content,
isCorrect: props.showAnswers && pairedKeys.has(key),
}));
});

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,7 +4,6 @@ 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 { RESPONSE_IDENTIFIER } from '../../constants';
import { richTextComparisonKey } from 'shared/views/TipTapEditor/TipTapEditor/utils/comparisonKey';

Expand Down Expand Up @@ -116,28 +115,26 @@ export function parseAssociateInteraction(bodyXml, responseDeclarations) {
}

/**
* Serialize AssociateState → { bodyXml, responseDeclarations }.
*
* @param {AssociateState} state
* @param {string} _questionType - unused (associate has one question type); kept for API parity
* @param {object} declarationSchema - { baseType: string, cardinality: string }
* @returns {{ bodyXml: string, responseDeclarations: string[] }}
* @param {AssociateChoice[][]} pairs
* @param {AssociateChoice[]} distractors
* @returns {{
* pairs: Array<Array<AssociateChoice & { key: string }>>,
* pool: Array<AssociateChoice & { key: string, matchMax: number }>,
* }}
*/
export function buildAssociateInteractionXML(state, _questionType, declarationSchema) {
const { responseIdentifier = RESPONSE_IDENTIFIER, prompt, pairs = [], distractors = [] } = state;

export function resolveAssociateChoices(pairs, distractors) {
const idByContent = new Map();
const contentById = new Map();

// 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 = richTextComparisonKey(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.
if (key && idByContent.has(key)) {
return { id: idByContent.get(key), content };
return { id: idByContent.get(key), content, key };
}

const boundKey = contentById.get(id);
Expand All @@ -147,28 +144,44 @@ export function buildAssociateInteractionXML(state, _questionType, declarationSc
if (key) {
idByContent.set(key, resolvedId);
}
return { id: resolvedId, content };
return { id: resolvedId, content, key };
}

const resolvedPairs = pairs.map(pair => pair.map(resolveChoice));
const resolvedDistractors = distractors.map(resolveChoice);

// Every appearance of a choice is one pairing it may take part in.
const pool = new Map();
for (const { id, content } of [...flatten(resolvedPairs), ...resolvedDistractors]) {
for (const { id, content, key } of [...flatten(resolvedPairs), ...resolvedDistractors]) {
const entry = pool.get(id);
if (entry) {
entry.matchMax += 1;
} else {
pool.set(id, { id, content, matchMax: 1 });
pool.set(id, { id, content, key, matchMax: 1 });
}
}

return { pairs: resolvedPairs, pool: [...pool.values()] };
}

/**
* Serialize AssociateState → { bodyXml, responseDeclarations }.
*
* @param {AssociateState} state
* @param {string} _questionType - unused (associate has one question type); kept for API parity
* @param {object} declarationSchema - { baseType: string, cardinality: string }
* @returns {{ bodyXml: string, responseDeclarations: string[] }}
*/
export function buildAssociateInteractionXML(state, _questionType, declarationSchema) {
const { responseIdentifier = RESPONSE_IDENTIFIER, prompt, pairs = [], distractors = [] } = state;

const { pairs: resolvedPairs, pool } = resolveAssociateChoices(pairs, distractors);

const children = [];
if (prompt) {
children.push(buildXmlNode({ tag: 'qti-prompt', innerHTML: prompt }));
}
for (const { id, content, matchMax } of pool.values()) {
for (const { id, content, matchMax } of pool) {
children.push(
buildXmlNode({
tag: 'qti-simple-associable-choice',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import flatten from 'lodash/flatten';
import { ValidationError } from '../../constants';
import { hasRichTextContent } from '../../utils/richText';
import { duplicateDistractorErrors } from '../../utils/distractors';
import { richTextComparisonKey } from 'shared/views/TipTapEditor/TipTapEditor/utils/comparisonKey';
import {
hasRichTextContent,
richTextComparisonKey,
} from 'shared/views/TipTapEditor/TipTapEditor/utils/comparisonKey';

/**
* Validate AssociateState → ValidationError[].
Expand All @@ -23,13 +25,10 @@ export function validateAssociateInteraction(state) {
errors.push({ code: ValidationError.PROMPT_REQUIRED });
}

// Every rule below asks the same two questions of a choice, and answering
// either one parses its HTML — so each choice is read once, here.
const read = ({ id, content }) => ({
id,
filled: hasRichTextContent(content),
key: richTextComparisonKey(content),
});
const read = ({ id, content }) => {
const key = richTextComparisonKey(content);
return { id, filled: Boolean(key), key };
};
const readPairs = pairs.map(pair => pair.map(read));
const readDistractors = distractors.map(read);
const allChoices = [...flatten(readPairs), ...readDistractors];
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { QuestionType, ValidationError } from '../../constants';
import { hasRichTextContent } from '../../utils/richText';
import { richTextComparisonKey } from 'shared/views/TipTapEditor/TipTapEditor/utils/comparisonKey';
import {
hasRichTextContent,
richTextComparisonKey,
} from 'shared/views/TipTapEditor/TipTapEditor/utils/comparisonKey';

/**
* Validate ChoiceState → ValidationError[].
Expand All @@ -23,12 +25,12 @@ export function validateChoiceInteraction(state, questionType) {
const duplicateIds = new Set();

for (const choice of choices) {
if (!hasRichTextContent(choice.content)) {
const key = richTextComparisonKey(choice.content);
if (!key) {
errors.push({ code: ValidationError.EMPTY_CHOICE_CONTENT, id: choice.id });
continue;
}

const key = richTextComparisonKey(choice.content);
if (firstSeenId.has(key)) {
duplicateIds.add(firstSeenId.get(key));
duplicateIds.add(choice.id);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ import {
wrapInlineRuns,
} from '../../serialization/xml';
import { generateRandomSlug } from '../../utils/generateRandomSlug';
import { 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';
import {
hasRichTextContent,
richTextComparisonKey,
} from 'shared/views/TipTapEditor/TipTapEditor/utils/comparisonKey';

const serializer = new XMLSerializer();

Expand Down Expand Up @@ -185,8 +187,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 = richTextComparisonKey(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
@@ -1,5 +1,4 @@
import { ValidationError } from '../../constants';
import { hasRichTextContent } from '../../utils/richText';
import { duplicateDistractorErrors } from '../../utils/distractors';
import { getGaps } from './parse';
import { richTextComparisonKey } from 'shared/views/TipTapEditor/TipTapEditor/utils/comparisonKey';
Expand All @@ -11,11 +10,10 @@ export function validateGapMatchInteraction(state) {
return [{ code: ValidationError.NO_CORRECT_ANSWER }];
}

const read = (id, content) => ({
id,
filled: hasRichTextContent(content),
key: richTextComparisonKey(content),
});
const read = (id, content) => {
const key = richTextComparisonKey(content);
return { id, filled: Boolean(key), key };
};
const readAnswers = gaps.map(({ id, answer }) => read(id, answer));
const readDistractors = distractors.map(({ id, content }) => read(id, content));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,12 +141,12 @@
import ValidationMessage from '../../components/ValidationMessage/index.vue';
import ClickableRegion from '../../components/ClickableRegion/index.vue';
import AnswerSettings from '../choice/components/AnswerSettings/index.vue';
import { hasRichTextContent } from '../../utils/richText';
import { generateRandomSlug } from '../../utils/generateRandomSlug';
import { useInlineChoicePassage } from './useInlineChoicePassage';
import InlineChoiceOptions from './components/InlineChoiceOptions/index.vue';
import TipTapEditor from 'shared/views/TipTapEditor/TipTapEditor/TipTapEditor';
import EditorImageProcessor from 'shared/views/TipTapEditor/TipTapEditor/services/imageService';
import { hasRichTextContent } from 'shared/views/TipTapEditor/TipTapEditor/utils/comparisonKey';

export default {
name: 'InlineChoiceEditor',
Expand Down
Loading
Loading