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 @@ -215,6 +215,50 @@ export const ORDERING_ITEM_XML = `<?xml version="1.0" encoding="UTF-8"?>
</qti-item-body>
</qti-assessment-item>`;

/**
* Demo item 7: associate interaction — learner connects countries to capitals.
* Uses cardinality="multiple" and base-type="pair" per QTI 3.0 §3.2.13.
*/
export const ASSOCIATE_ITEM_XML = `<?xml version="1.0" encoding="UTF-8"?>
<qti-assessment-item
xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0"
identifier="item-associate"
title="Match each country with its capital city"
adaptive="false"
time-dependent="false"
xml:lang="en"
>
<qti-response-declaration
identifier="RESPONSE"
cardinality="multiple"
base-type="pair"
>
<qti-correct-response>
<qti-value>choice_kenya choice_nairobi</qti-value>
<qti-value>choice_japan choice_tokyo</qti-value>
<qti-value>choice_brazil choice_brasilia</qti-value>
</qti-correct-response>
</qti-response-declaration>

<qti-item-body>
<qti-associate-interaction
response-identifier="RESPONSE"
shuffle="true"
max-associations="3"
>
<qti-prompt><p>Match each country with its capital city:</p></qti-prompt>
<qti-simple-associable-choice identifier="choice_kenya" match-max="1">Kenya</qti-simple-associable-choice>
<qti-simple-associable-choice identifier="choice_nairobi" match-max="1">Nairobi</qti-simple-associable-choice>
<qti-simple-associable-choice identifier="choice_japan" match-max="1">Japan</qti-simple-associable-choice>
<qti-simple-associable-choice identifier="choice_tokyo" match-max="1">Tokyo</qti-simple-associable-choice>
<qti-simple-associable-choice identifier="choice_brazil" match-max="1">Brazil</qti-simple-associable-choice>
<qti-simple-associable-choice identifier="choice_brasilia" match-max="1">Brasília</qti-simple-associable-choice>
<qti-simple-associable-choice identifier="choice_mombasa" match-max="1">Mombasa</qti-simple-associable-choice>
<qti-simple-associable-choice identifier="choice_osaka" match-max="1">Osaka</qti-simple-associable-choice>
</qti-associate-interaction>
</qti-item-body>
</qti-assessment-item>`;

/**
* Hardcoded items covering different states:
* - item-1: single-select choice interaction
Expand All @@ -223,6 +267,7 @@ export const ORDERING_ITEM_XML = `<?xml version="1.0" encoding="UTF-8"?>
* - item-text-entry: string text-entry with case-sensitive answers
* - item-free-response: free-response text-entry (no correct answer)
* - item-ordering: ordering interaction (planets by distance from the Sun)
* - item-associate: associate interaction (countries to capitals, with distractors)
*/
export const INITIAL_ASSESSMENTS = [
{
Expand Down Expand Up @@ -255,4 +300,9 @@ export const INITIAL_ASSESSMENTS = [
type: AssessmentItemTypes.QTI,
raw_data: ORDERING_ITEM_XML,
},
{
assessment_id: 'demo-item-associate',
type: AssessmentItemTypes.QTI,
raw_data: ASSOCIATE_ITEM_XML,
},
];
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import VueRouter from 'vue-router';
import QTIItemEditor from '../index.vue';
import { qtiEditorStrings } from '../../../qtiEditorStrings';
import { AssessmentItemTypes } from '../../../constants';
import { VALID_ASSOCIATE_ITEM_DOCUMENT } from '../../../utils/testingFixtures';

jest.mock('shared/views/TipTapEditor/TipTapEditor/TipTapEditor');
jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => {
Expand All @@ -13,7 +14,13 @@ jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => {
};
});

const { closeBtnLabel$, questionContentPlaceholder$ } = qtiEditorStrings;
const {
closeBtnLabel$,
questionContentPlaceholder$,
associateLabel$,
unknownTypeLabel$,
responsePoolLabel$,
} = qtiEditorStrings;

const defaultProps = {
item: {
Expand Down Expand Up @@ -77,6 +84,29 @@ describe('QTIItemEditor', () => {
});
});

describe('associate interaction', () => {
const renderAssociateItem = () =>
renderComponent({
item: {
assessment_id: 'test-item-id',
type: AssessmentItemTypes.QTI,
raw_data: VALID_ASSOCIATE_ITEM_DOCUMENT,
},
});

test('names the associate question type rather than falling back to unknown', async () => {
renderAssociateItem();
expect(await screen.findByText(new RegExp(associateLabel$()))).toBeInTheDocument();
expect(screen.queryByText(new RegExp(unknownTypeLabel$()))).not.toBeInTheDocument();
});

test('renders the associate editor for the parsed interaction', async () => {
renderAssociateItem();
expect(await screen.findByText(responsePoolLabel$())).toBeInTheDocument();
expect(screen.getByText('Antonio')).toBeInTheDocument();
});
});

describe('toolbarActions slot', () => {
test('renders content injected into the toolbarActions slot', () => {
renderComponent({}, { toolbarActions: '<button>Edit</button>' });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@
[QuestionType.TEXT_ENTRY]: qtiEditorStrings.textEntryLabel$,
[QuestionType.FREE_RESPONSE]: qtiEditorStrings.freeResponseLabel$,
[QuestionType.ORDERING]: qtiEditorStrings.orderingLabel$,
[QuestionType.ASSOCIATE]: qtiEditorStrings.associateLabel$,
};
return (QUESTION_TYPE_LABELS[type] ?? unknownTypeLabel$)();
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { ref } from 'vue';
import { useAssociateInteraction } from '../useAssociateInteraction';
import { ASSOCIATE_XML, ASSOCIATE_DECL_XML } from '../../utils/testingFixtures';
import { QuestionType, ValidationError } from '../../constants';

const GENERATED_ID = /^choice_[a-zA-Z0-9]{8}$/;

const contentsOf = pairs => pairs.map(pair => pair.map(choice => choice.content));

describe('useAssociateInteraction', () => {
function setup(bodyXml = ASSOCIATE_XML, declarationXml = ASSOCIATE_DECL_XML) {
const questionType = ref(QuestionType.ASSOCIATE);
return useAssociateInteraction(
{ bodyXml, responseDeclarations: [declarationXml] },
questionType,
);
}

describe('initial state', () => {
it('parses pairs and distractors from the fixture XML', () => {
const { state } = setup();
expect(contentsOf(state.value.pairs)).toEqual([
['Antonio', 'Prospero'],
['Capulet', 'Montague'],
]);
expect(state.value.distractors.map(d => d.content)).toEqual(['Lysander']);
});
});

describe('addPair()', () => {
it('appends a pair of two blank choices with distinct generated ids', () => {
const { state, addPair } = setup();
addPair();
expect(state.value.pairs).toHaveLength(3);
const [first, second] = state.value.pairs[2];
expect(first.content).toBe('');
expect(second.content).toBe('');
expect(first.id).toMatch(GENERATED_ID);
expect(second.id).toMatch(GENERATED_ID);
expect(first.id).not.toBe(second.id);
});

it('leaves the existing pairs untouched', () => {
const { state, addPair } = setup();
addPair();
expect(contentsOf(state.value.pairs.slice(0, 2))).toEqual([
['Antonio', 'Prospero'],
['Capulet', 'Montague'],
]);
});

it('rebuilds bodyXml so the mutation reaches the emitted interaction', () => {
const { bodyXml, addPair } = setup();
const before = bodyXml.value;
addPair();
expect(bodyXml.value).not.toBe(before);
});
});

describe('removePair()', () => {
it('drops the pair at the given index and keeps the rest in order', () => {
const { state, removePair } = setup();
removePair(0);
expect(contentsOf(state.value.pairs)).toEqual([['Capulet', 'Montague']]);
});
});

describe('setPair()', () => {
it('replaces only the pair at the given index', () => {
const { state, setPair } = setup();
const [first, second] = state.value.pairs[0];
setPair(0, [{ ...first, content: '<p>Updated</p>' }, second]);
expect(contentsOf(state.value.pairs)).toEqual([
['<p>Updated</p>', 'Prospero'],
['Capulet', 'Montague'],
]);
});
});

describe('addDistractor()', () => {
it('appends one blank choice with a generated id', () => {
const { state, addDistractor } = setup();
addDistractor();
expect(state.value.distractors).toHaveLength(2);
expect(state.value.distractors[1].content).toBe('');
expect(state.value.distractors[1].id).toMatch(GENERATED_ID);
});
});

describe('removeDistractor()', () => {
it('drops the distractor at the given index', () => {
const { state, removeDistractor } = setup();
removeDistractor(0);
expect(state.value.distractors).toEqual([]);
});
});

describe('setDistractorContent()', () => {
it('updates only the targeted distractor', () => {
const { state, addDistractor, setDistractorContent } = setup();
addDistractor();
setDistractorContent(1, '<p>Updated</p>');
expect(state.value.distractors.map(d => d.content)).toEqual(['Lysander', '<p>Updated</p>']);
});
});

describe('runValidation()', () => {
it('populates errors for an invalid state', () => {
const { setPrompt, runValidation, errors } = setup();
setPrompt('');
runValidation();
expect(errors.value.map(e => e.code)).toContain(ValidationError.PROMPT_REQUIRED);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { readonly } from 'vue';
import { generateRandomSlug } from '../utils/generateRandomSlug';
import { associateInteractionDescriptor } from '../interactions/associate/AssociateInteractionDescriptor';
import { useInteraction } from './useInteraction';

const blankChoice = () => ({ id: generateRandomSlug('choice'), content: '' });

/**
* Composable for the associate interaction editor.
*
* @param {{ bodyXml: string, responseDeclarations: string[] }} interactionBlock
* @param {import('vue').Ref<string|null>} questionType
*/
export function useAssociateInteraction(interactionBlock, questionType) {
const base = useInteraction(associateInteractionDescriptor, interactionBlock, questionType);
const { state } = base;

function addPair() {
state.value = { ...state.value, pairs: [...state.value.pairs, [blankChoice(), blankChoice()]] };
}

function removePair(index) {
state.value = {
...state.value,
pairs: state.value.pairs.filter((_, i) => i !== index),
};
}

function setPair(index, newPair) {
state.value = {
...state.value,
pairs: state.value.pairs.map((pair, i) => (i === index ? newPair : pair)),
};
}

function addDistractor() {
state.value = { ...state.value, distractors: [...state.value.distractors, blankChoice()] };
}

function removeDistractor(index) {
state.value = {
...state.value,
distractors: state.value.distractors.filter((_, i) => i !== index),
};
}

function setDistractorContent(index, html) {
state.value = {
...state.value,
distractors: state.value.distractors.map((choice, i) =>
i === index ? { ...choice, content: html } : choice,
),
};
}

function setPrompt(html) {
state.value = { ...state.value, prompt: html };
}

return {
...base,
state: readonly(state),
addPair,
removePair,
setPair,
addDistractor,
removeDistractor,
setDistractorContent,
setPrompt,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export const QtiInteraction = Object.freeze({
CHOICE: 'qti-choice-interaction',
ORDER: 'qti-order-interaction',
MATCH: 'qti-match-interaction',
ASSOCIATE: 'qti-associate-interaction',
TEXT_ENTRY: 'qti-text-entry-interaction',
EXTENDED_TEXT: 'qti-extended-text-interaction',
});
Expand Down Expand Up @@ -81,6 +82,7 @@ export const QuestionType = Object.freeze({
TEXT_ENTRY: 'textEntry',
FREE_RESPONSE: 'freeResponse',
ORDERING: 'ordering',
ASSOCIATE: 'associate',
});

/**
Expand All @@ -98,6 +100,8 @@ export const ValidationError = Object.freeze({
EMPTY_ANSWER_CONTENT: 'EMPTY_ANSWER_CONTENT',
DUPLICATE_ANSWER_CONTENT: 'DUPLICATE_ANSWER_CONTENT',
TOO_FEW_CHOICES: 'TOO_FEW_CHOICES',
TOO_FEW_PAIRS: 'TOO_FEW_PAIRS',
DUPLICATE_PAIR_CONTENT: 'DUPLICATE_PAIR_CONTENT',
});

export const RESPONSE_IDENTIFIER = 'RESPONSE';
Expand Down
Loading
Loading