diff --git a/contentcuration/contentcuration/frontend/channelEdit/pages/qtiDemoData.js b/contentcuration/contentcuration/frontend/channelEdit/pages/qtiDemoData.js index a3c831b10c..009cd3e574 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/pages/qtiDemoData.js +++ b/contentcuration/contentcuration/frontend/channelEdit/pages/qtiDemoData.js @@ -259,6 +259,57 @@ export const ASSOCIATE_ITEM_XML = ` `; +/** + * Demo item 8: match interaction — learner matches animals to their traits. + * Uses cardinality="multiple" and base-type="directedPair". + * Bat has two answers, "Can fly" and "Mammal" are each shared by two rows, and + * "Reptile" is a distractor. + */ +export const MATCH_ITEM_XML = ` + + + + row_bat choice_mammal + row_bat choice_fly + row_eagle choice_bird + row_eagle choice_fly + row_whale choice_mammal + + + + + +

Match each animal with its traits:

+ + Bat + Eagle + Whale + + + Mammal + Can fly + Bird + Reptile + +
+
+
`; + /** * Hardcoded items covering different states: * - item-1: single-select choice interaction @@ -268,6 +319,7 @@ export const ASSOCIATE_ITEM_XML = ` * - 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) + * - item-match: match interaction (animals to traits, with shared answers and a distractor) */ export const INITIAL_ASSESSMENTS = [ { @@ -305,4 +357,9 @@ export const INITIAL_ASSESSMENTS = [ type: AssessmentItemTypes.QTI, raw_data: ASSOCIATE_ITEM_XML, }, + { + assessment_id: 'demo-item-match', + type: AssessmentItemTypes.QTI, + raw_data: MATCH_ITEM_XML, + }, ]; diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/__tests__/validateItem.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/__tests__/validateItem.spec.js index 0b072d7b38..7f9d5204bd 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/__tests__/validateItem.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/__tests__/validateItem.spec.js @@ -5,6 +5,9 @@ import { CHOICE_ITEM_DOCUMENT_NO_PROMPT, CHOICE_ITEM_DOCUMENT_NO_CORRECT_ANSWER, NO_INTERACTION_ITEM_DOCUMENT, + VALID_MATCH_ITEM_DOCUMENT, + MATCH_THREE_SETS_XML, + MATCH_XML, } from '../utils/testingFixtures'; const codesOf = errors => errors.map(error => error.code); @@ -37,6 +40,22 @@ describe('validateQtiItem', () => { expect(validateQtiItem(undefined)).toEqual([{ code: ValidationError.NO_INTERACTION }]); }); + describe('match interaction', () => { + it('returns no errors for a complete match item', () => { + expect(validateQtiItem(VALID_MATCH_ITEM_DOCUMENT)).toEqual([]); + }); + + it('runs the match validator', () => { + const noPrompt = VALID_MATCH_ITEM_DOCUMENT.replace(/.*<\/qti-prompt>/, ''); + expect(codesOf(validateQtiItem(noPrompt))).toContain(ValidationError.PROMPT_REQUIRED); + }); + + it('reports a match interaction without exactly two match sets as unparseable', () => { + const threeSets = VALID_MATCH_ITEM_DOCUMENT.replace(MATCH_XML, MATCH_THREE_SETS_XML); + expect(validateQtiItem(threeSets)).toEqual([{ code: ValidationError.PARSE_ERROR }]); + }); + }); + it('reports unparseable XML', () => { expect(validateQtiItem('')).toEqual([ { code: ValidationError.PARSE_ERROR }, diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ClickableRegion/__tests__/ClickableRegion.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ClickableRegion/__tests__/ClickableRegion.spec.js index e8e5589e38..3af1d2c4f0 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ClickableRegion/__tests__/ClickableRegion.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ClickableRegion/__tests__/ClickableRegion.spec.js @@ -66,4 +66,23 @@ describe('ClickableRegion', () => { expect(emitted().click).toHaveLength(1); }); + + it('focuses its button when asked to', async () => { + render( + { + components: { ClickableRegion }, + template: ` +
+ + +
+ `, + }, + { routes: new VueRouter() }, + ); + + await fireEvent.click(screen.getByRole('button', { name: 'Elsewhere' })); + + expect(screen.getByRole('button', { name: 'Test label' })).toHaveFocus(); + }); }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ClickableRegion/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ClickableRegion/index.vue index 80ff7e1d42..080a4b3bf5 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ClickableRegion/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ClickableRegion/index.vue @@ -6,10 +6,12 @@ -->
+
+ `, + props: { + initialChips: { type: Array, default: () => CHIPS }, + addMode: { type: String, default: 'button' }, + minChips: { type: Number, default: 0 }, + errorMessages: { type: Array, default: () => [] }, + }, + data() { + return { chips: [...this.initialChips], labels: LABELS }; + }, +}; + +const renderHost = (props = {}) => render(Host, { props, routes: new VueRouter() }); + +const list = () => screen.getByRole('list', { name: distractorsLabel$() }); +const chipItems = () => within(list()).queryAllByRole('listitem'); +const chipTexts = () => chipItems().map(item => item.textContent.trim()); +const button = name => screen.getByRole('button', { name }); +const queryButton = name => screen.queryByRole('button', { name }); +const openTextarea = () => screen.queryAllByRole('textbox').find(el => el.tagName === 'TEXTAREA'); +const closeFromElsewhere = user => user.click(button('Elsewhere')); + +describe('EditableChipList', () => { + describe('button mode', () => { + it('lists each chip in the named list', () => { + renderList(); + expect(chipTexts()).toEqual(['Antonio', 'Prospero', 'Capulet']); + }); + + it('opens a chip in place of its edit button, and reports its edits', async () => { + const user = userEvent.setup(); + const { emitted } = renderList(); + await user.click(button(editDistractorLabel$({ number: 2 }))); + expect(emitted().open).toHaveLength(1); + expect(queryButton(editDistractorLabel$({ number: 2 }))).not.toBeInTheDocument(); + expect(within(chipItems()[1]).getByRole('textbox')).toHaveValue('Prospero'); + + await user.type(openTextarea(), '!'); + expect(emitted()['update-chip'].at(-1)).toEqual([1, 'Prospero!']); + }); + + it('asks to remove a chip without also opening it', async () => { + const user = userEvent.setup(); + const { emitted } = renderList(); + await user.click(button(deleteDistractorBtn$({ number: 3 }))); + expect(emitted()['remove-chip']).toEqual([[2]]); + expect(emitted().open).toBeUndefined(); + }); + + it('writes a new chip outside the list, adding nothing until it is saved', async () => { + const user = userEvent.setup(); + renderHost(); + await user.click(button(addDistractorBtn$())); + expect(within(list()).queryByRole('textbox')).not.toBeInTheDocument(); + await user.type(openTextarea(), 'Montague'); + expect(chipTexts()).toHaveLength(3); + + await user.click(button(saveChipBtn$())); + expect(chipTexts().at(-1)).toBe('Montague'); + expect(openTextarea()).toBeUndefined(); + }); + + it('closes an open chip, keeping its edits, when its save button is pressed', async () => { + const user = userEvent.setup(); + renderHost(); + await user.click(button(editDistractorLabel$({ number: 2 }))); + await user.type(openTextarea(), '!'); + await user.click(within(chipItems()[1]).getByRole('button', { name: saveChipBtn$() })); + expect(openTextarea()).toBeUndefined(); + expect(chipTexts()).toEqual(['Antonio', 'Prospero!', 'Capulet']); + }); + + it('adds the written chip when its editor is closed without saving', async () => { + const user = userEvent.setup(); + renderHost(); + await user.click(button(addDistractorBtn$())); + await user.type(openTextarea(), 'Montague'); + await closeFromElsewhere(user); + expect(chipTexts().at(-1)).toBe('Montague'); + expect(openTextarea()).toBeUndefined(); + }); + + it('adds nothing when a new chip is closed blank', async () => { + const user = userEvent.setup(); + renderHost(); + await user.click(button(addDistractorBtn$())); + await user.click(button(saveChipBtn$())); + expect(chipTexts()).toHaveLength(3); + }); + + it('throws a written chip away when its discard button is pressed', async () => { + const user = userEvent.setup(); + const { emitted } = renderList(); + await user.click(button(addDistractorBtn$())); + await user.type(openTextarea(), 'x'); + await user.click(button(deleteDistractorBtn$({ number: 4 }))); + expect(openTextarea()).toBeUndefined(); + expect(emitted()['add-chip']).toBeUndefined(); + expect(emitted().close).toHaveLength(1); + }); + + it('commits the written chip and leaves a fresh, focused editor on a second add press', async () => { + const user = userEvent.setup(); + renderHost(); + await user.click(button(addDistractorBtn$())); + await user.type(openTextarea(), 'Montague'); + await user.click(button(addDistractorBtn$())); + expect(within(list()).getByText('Montague')).toBeInTheDocument(); + expect(openTextarea()).toHaveValue(''); + expect(openTextarea()).toHaveFocus(); + }); + + it('drops a chip left blank when its editor closes', async () => { + const user = userEvent.setup(); + renderHost({ initialChips: [...CHIPS, { id: 'choice_d', content: '' }] }); + await user.click(button(editDistractorLabel$({ number: 4 }))); + await closeFromElsewhere(user); + expect(chipTexts()).toEqual(['Antonio', 'Prospero', 'Capulet']); + }); + + it('opens the chip clicked after a blank one drops out ahead of it', async () => { + const user = userEvent.setup(); + renderHost({ initialChips: [{ id: 'choice_d', content: '' }, ...CHIPS] }); + await user.click(button(editDistractorLabel$({ number: 1 }))); + await user.click(button(editDistractorLabel$({ number: 3 }))); + expect(chipItems()).toHaveLength(3); + expect(within(chipItems()[1]).getByRole('textbox')).toHaveValue('Prospero'); + }); + + it('shows each error message under its own chip only', () => { + renderList({ errorMessages: [null, errorEmptyChoiceContent$(), null] }); + const [first, second, third] = chipItems(); + expect(within(second).getByText(errorEmptyChoiceContent$())).toBeInTheDocument(); + expect(within(first).queryByText(errorEmptyChoiceContent$())).not.toBeInTheDocument(); + expect(within(third).queryByText(errorEmptyChoiceContent$())).not.toBeInTheDocument(); + }); + + it('shows an open chip’s error under its editor', async () => { + const user = userEvent.setup(); + renderHost({ + initialChips: [{ id: 'choice_d', content: '' }], + minChips: 1, + errorMessages: [errorEmptyChoiceContent$()], + }); + await user.click(button(editDistractorLabel$({ number: 1 }))); + expect(within(chipItems()[0]).getByText(errorEmptyChoiceContent$())).toBeInTheDocument(); + }); + + it('shows the placeholder in a blank chip only', () => { + renderList({ + chips: [CHIPS[0], { id: 'choice_d', content: '' }], + placeholder: 'Write here', + }); + const [written, blank] = chipItems(); + expect(within(blank).getByText('Write here')).toBeInTheDocument(); + expect(within(written).queryByText('Write here')).not.toBeInTheDocument(); + }); + }); + + describe('keeping a minimum', () => { + it('disables every remove button once the list is down to its minimum', () => { + renderList({ chips: [CHIPS[0]], minChips: 1 }); + expect(button(deleteDistractorBtn$({ number: 1 }))).toBeDisabled(); + }); + + it('keeps a chip left blank when it is the last one the list must keep', async () => { + const user = userEvent.setup(); + renderHost({ initialChips: [{ id: 'choice_d', content: '' }], minChips: 1 }); + await user.click(button(editDistractorLabel$({ number: 1 }))); + await closeFromElsewhere(user); + expect(chipItems()).toHaveLength(1); + }); + + it('focuses the add control once the list reaches its minimum', async () => { + const user = userEvent.setup(); + renderHost({ initialChips: CHIPS.slice(0, 2), minChips: 1 }); + await user.click(button(deleteDistractorBtn$({ number: 1 }))); + expect(chipTexts()).toEqual(['Prospero']); + expect(button(addDistractorBtn$())).toHaveFocus(); + }); + }); + + describe('focus', () => { + it('focuses the delete button of the chip taking a removed chip’s place', async () => { + const user = userEvent.setup(); + renderHost(); + await user.click(button(deleteDistractorBtn$({ number: 2 }))); + expect(screen.queryByText('Prospero')).not.toBeInTheDocument(); + // Capulet now sits second. + expect(button(deleteDistractorBtn$({ number: 2 }))).toHaveFocus(); + }); + + it('focuses the chip taking a removed chip’s place after an open blank chip drops', async () => { + const user = userEvent.setup(); + renderHost({ + initialChips: [ + { id: 'choice_blank', content: '' }, + { id: 'choice_c', content: 'Capulet' }, + { id: 'choice_m', content: 'Montague' }, + { id: 'choice_l', content: 'Lysander' }, + ], + }); + await user.click(button(editDistractorLabel$({ number: 1 }))); + await user.click(button(deleteDistractorBtn$({ number: 2 }))); + expect(chipTexts()).toEqual(['Montague', 'Lysander']); + expect(button(deleteDistractorBtn$({ number: 1 }))).toHaveFocus(); + }); + + it('focuses the add button when the last chip is removed', async () => { + const user = userEvent.setup(); + renderHost({ initialChips: [CHIPS[0]] }); + await user.click(button(deleteDistractorBtn$({ number: 1 }))); + expect(chipItems()).toHaveLength(0); + expect(button(addDistractorBtn$())).toHaveFocus(); + }); + + it('focuses the add button when the new chip is discarded', async () => { + const user = userEvent.setup(); + renderHost(); + await user.click(button(addDistractorBtn$())); + await user.click(button(deleteDistractorBtn$({ number: 4 }))); + expect(openTextarea()).toBeUndefined(); + expect(button(addDistractorBtn$())).toHaveFocus(); + }); + + it('focuses the add button when the new chip is saved', async () => { + const user = userEvent.setup(); + renderHost(); + await user.click(button(addDistractorBtn$())); + await user.type(openTextarea(), 'Montague'); + await user.click(button(saveChipBtn$())); + expect(button(addDistractorBtn$())).toHaveFocus(); + }); + + it('focuses a saved chip', async () => { + const user = userEvent.setup(); + renderHost(); + await user.click(button(editDistractorLabel$({ number: 2 }))); + await user.click(button(saveChipBtn$())); + expect(button(editDistractorLabel$({ number: 2 }))).toHaveFocus(); + }); + }); + + describe('region mode', () => { + const region = () => button(addDistractorBtn$()); + const queryRegion = () => queryButton(addDistractorBtn$()); + + it('makes the box itself the add control, with no add button', () => { + renderList({ addMode: 'region' }); + expect(region()).toBeInTheDocument(); + // The add button shows its label as text; the region only names itself. + expect(screen.queryByText(addDistractorBtn$())).not.toBeInTheDocument(); + }); + + it('opens a new chip when the empty area of the list is clicked', async () => { + const user = userEvent.setup(); + const { emitted } = renderList({ addMode: 'region' }); + await user.click(list()); + expect(emitted().open).toHaveLength(1); + expect(openTextarea()).toHaveValue(''); + }); + + it('opens the first blank chip rather than a new one when the list is clicked', async () => { + const user = userEvent.setup(); + renderList({ + addMode: 'region', + chips: [CHIPS[0], { id: 'choice_d', content: '' }, { id: 'choice_e', content: '' }], + }); + await user.click(list()); + expect(within(chipItems()[1]).getByRole('textbox')).toHaveValue(''); + expect(chipItems()).toHaveLength(3); + expect(screen.queryAllByRole('textbox').filter(el => el.tagName === 'TEXTAREA')).toHaveLength( + 1, + ); + }); + + it('names the region after the blank chip it opens', () => { + renderList({ addMode: 'region', chips: [CHIPS[0], { id: 'choice_d', content: '' }] }); + expect(queryRegion()).not.toBeInTheDocument(); + // The chip's own button and the region that opens it + expect( + screen.getAllByRole('button', { name: editDistractorLabel$({ number: 2 }) }), + ).toHaveLength(2); + }); + + it('opens a new chip when Enter is pressed on the region', async () => { + const user = userEvent.setup(); + renderList({ addMode: 'region' }); + region().focus(); + await user.keyboard('{Enter}'); + expect(openTextarea()).toHaveValue(''); + }); + + it('opens only the chip when the chip is clicked', async () => { + const user = userEvent.setup(); + renderList({ addMode: 'region' }); + await user.click(button(editDistractorLabel$({ number: 1 }))); + expect(openTextarea()).toHaveValue('Antonio'); + }); + + it('asks only to remove a chip when its delete button is clicked', async () => { + const user = userEvent.setup(); + const { emitted } = renderList({ addMode: 'region' }); + await user.click(button(deleteDistractorBtn$({ number: 1 }))); + expect(emitted()['remove-chip']).toEqual([[0]]); + expect(openTextarea()).toBeUndefined(); + }); + + it('turns the region off while one of its editors is open', async () => { + const user = userEvent.setup(); + renderList({ addMode: 'region' }); + await user.click(list()); + expect(queryRegion()).not.toBeInTheDocument(); + await user.click(list()); + expect(openTextarea()).toHaveValue(''); + }); + + it('opens no new chip on a click inside an open chip', async () => { + const user = userEvent.setup(); + renderList({ addMode: 'region' }); + await user.click(button(editDistractorLabel$({ number: 1 }))); + await user.click(openTextarea()); + expect(openTextarea()).toHaveValue('Antonio'); + }); + + it('focuses the region when the last chip is removed', async () => { + const user = userEvent.setup(); + renderHost({ initialChips: [CHIPS[0]], addMode: 'region' }); + await user.click(button(deleteDistractorBtn$({ number: 1 }))); + expect(chipItems()).toHaveLength(0); + expect(region()).toHaveFocus(); + }); + + it('focuses the region when the new chip is discarded', async () => { + const user = userEvent.setup(); + renderHost({ addMode: 'region' }); + await user.click(list()); + await user.click(button(deleteDistractorBtn$({ number: 4 }))); + expect(openTextarea()).toBeUndefined(); + expect(region()).toHaveFocus(); + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/EditableChipList/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/EditableChipList/index.vue new file mode 100644 index 0000000000..523f80ba23 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/EditableChipList/index.vue @@ -0,0 +1,535 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/__tests__/InteractionSection.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/__tests__/InteractionSection.spec.js index e5f25514dc..360d681956 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/__tests__/InteractionSection.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/__tests__/InteractionSection.spec.js @@ -8,6 +8,7 @@ import { CHOICE_SINGLE_SELECT_XML, CHOICE_MULTI_SELECT_XML, UNKNOWN_INTERACTION_XML, + MATCH_THREE_SETS_XML, mockInteractionBlock as interactionBlock, } from '../../../utils/testingFixtures'; @@ -16,7 +17,7 @@ jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => { const { ref } = require('vue'); return { __esModule: true, - default: () => ({ windowIsSmall: ref(false) }), + default: () => ({ windowIsSmall: ref(false), windowIsLarge: ref(true) }), }; }); @@ -62,36 +63,43 @@ describe('InteractionSection', () => { renderSection({ interaction: interactionBlock('not-xml<{{') }); expect(screen.getByText(tr.$tr('errorParsingQuestion'))).toBeInTheDocument(); }); + + it('shows a parse error for a match interaction without exactly two match sets', () => { + renderSection({ interaction: interactionBlock(MATCH_THREE_SETS_XML) }); + expect(screen.getByText(tr.$tr('errorParsingQuestion'))).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: tr.$tr('addRowBtn') })).not.toBeInTheDocument(); + }); }); - describe('type switching', () => { - it('preserves the prompt but resets choices when switching from choice to text-entry', async () => { - const Wrapper = { - components: { InteractionSection }, - template: ` - - `, - data() { - return { - interactionBlock: { - bodyXml: CHOICE_SINGLE_SELECT_XML, - responseDeclarations: [], - }, - }; - }, - methods: { - onUpdate(val) { - this.interactionBlock = val; - this.$emit('wrapper-update', val); - }, + // Feeds each emitted interaction back in, as the item editor does. + const SwitchableSection = { + components: { InteractionSection }, + template: ` + + `, + data() { + return { + interactionBlock: { + bodyXml: CHOICE_SINGLE_SELECT_XML, + responseDeclarations: [], }, }; + }, + methods: { + onUpdate(val) { + this.interactionBlock = val; + this.$emit('wrapper-update', val); + }, + }, + }; - const { emitted } = render(Wrapper, { + describe('type switching', () => { + it('preserves the prompt but resets choices when switching from choice to text-entry', async () => { + const { emitted } = render(SwitchableSection, { routes: new VueRouter(), }); @@ -114,6 +122,25 @@ describe('InteractionSection', () => { }); }); + describe('switching to match', () => { + it('produces a match item that reopens in the match editor', async () => { + const { emitted, unmount } = render(SwitchableSection, { routes: new VueRouter() }); + await nextTick(); + + await fireEvent.click(screen.getAllByText(tr.$tr('singleSelectLabel'))[0]); + await fireEvent.click(screen.getByText(tr.$tr('matchLabel'))); + await nextTick(); + + const switched = emitted()['wrapper-update'].at(-1)[0]; + expect(switched.bodyXml).toContain('Which planet is closest to the Sun?'); + expect(switched.bodyXml.match(/ { it('falls back silently when the interaction tag is unrecognized', () => { // Should not throw — just renders the fallback component diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js index 1404d9753f..b74b9b405b 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js @@ -13,6 +13,7 @@ import { CHOICE_ITEM_DOCUMENT_WITH_HINTS, NO_INTERACTION_ITEM_WITH_HINTS, VALID_ASSOCIATE_ITEM_DOCUMENT, + VALID_MATCH_ITEM_DOCUMENT, } from '../../../utils/testingFixtures'; jest.mock('shared/views/TipTapEditor/TipTapEditor/TipTapEditor'); @@ -20,7 +21,7 @@ jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => { const { ref } = require('vue'); return { __esModule: true, - default: () => ({ windowIsSmall: ref(false) }), + default: () => ({ windowIsSmall: ref(false), windowIsLarge: ref(true) }), }; }); @@ -31,6 +32,8 @@ const { incompleteItemIndicatorLabel$, hintsLabel$, associateLabel$, + matchLabel$, + questionNumberAndTypeLabel$, unknownTypeLabel$, responsePoolLabel$, } = qtiEditorStrings; @@ -318,6 +321,29 @@ describe('QTIItemEditor', () => { }); }); + describe('match interaction', () => { + const renderMatchItem = () => + renderComponent({ + item: { + assessment_id: 'test-item-id', + type: AssessmentItemTypes.QTI, + raw_data: VALID_MATCH_ITEM_DOCUMENT, + }, + }); + + test('names the match question type rather than falling back to unknown', async () => { + renderMatchItem(); + const heading = questionNumberAndTypeLabel$({ number: 1, total: 5, type: matchLabel$() }); + expect(await screen.findByText(heading)).toBeInTheDocument(); + }); + + test('renders the match editor for the parsed interaction', async () => { + renderMatchItem(); + expect(await screen.findByText(responsePoolLabel$())).toBeInTheDocument(); + expect(screen.getByText('Dog')).toBeInTheDocument(); + }); + }); + describe('toolbarActions slot', () => { test('renders content injected into the toolbarActions slot', () => { renderComponent({}, { toolbarActions: '' }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue index 8c36a87748..df8778b14d 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue @@ -183,6 +183,7 @@ [QuestionType.FREE_RESPONSE]: qtiEditorStrings.freeResponseLabel$, [QuestionType.ORDERING]: qtiEditorStrings.orderingLabel$, [QuestionType.ASSOCIATE]: qtiEditorStrings.associateLabel$, + [QuestionType.MATCH]: qtiEditorStrings.matchLabel$, }; return (QUESTION_TYPE_LABELS[type] ?? unknownTypeLabel$)(); }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QuestionTypeSelector/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QuestionTypeSelector/index.vue index 68d4fcd265..c3b5391705 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QuestionTypeSelector/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QuestionTypeSelector/index.vue @@ -198,7 +198,9 @@ flex-wrap: wrap; gap: 5px; align-items: flex-start; - padding: 10px 20px; + + // The question card body already insets its content. + padding: 10px 0; &.small-screen { flex-direction: column; @@ -223,8 +225,9 @@ gap: 8px; } + // Matches the question label of every interaction editor. .group-label { - font-size: 12px; + font-size: 14px; font-weight: 600; line-height: 1.4; white-space: nowrap; diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ShuffledResponsePool/__tests__/ShuffledResponsePool.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ShuffledResponsePool/__tests__/ShuffledResponsePool.spec.js new file mode 100644 index 0000000000..62c2dff586 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ShuffledResponsePool/__tests__/ShuffledResponsePool.spec.js @@ -0,0 +1,75 @@ +import { render, screen, within } from '@testing-library/vue'; +import VueRouter from 'vue-router'; +import shuffle from 'lodash/shuffle'; +import { themePalette } from 'kolibri-design-system/lib/styles/theme'; +import ShuffledResponsePool from '../index.vue'; +import { qtiEditorStrings } from '../../../qtiEditorStrings'; + +jest.mock('shared/views/TipTapEditor/TipTapEditor/TipTapEditor'); +jest.mock('lodash/shuffle', () => jest.fn(list => [...list].reverse())); + +const { responsePoolLabel$ } = qtiEditorStrings; + +const CHOICES = [ + { content: 'Antonio', isCorrect: true }, + { content: 'Prospero', isCorrect: false }, + { content: 'Capulet', isCorrect: false }, +]; + +const renderPool = (choices = CHOICES) => + render(ShuffledResponsePool, { + props: { choices, label: responsePoolLabel$() }, + routes: new VueRouter(), + }); + +const pool = () => screen.getByRole('list', { name: responsePoolLabel$() }); +const chipTexts = () => + within(pool()) + .getAllByRole('listitem') + .map(item => item.textContent.trim()); +const chip = text => within(pool()).getByText(text).closest('li'); + +describe('ShuffledResponsePool', () => { + beforeEach(() => { + shuffle.mockImplementation(list => [...list].reverse()); + }); + + it('labels the pool heading and the list, with one item per choice', () => { + renderPool(); + expect(screen.getByRole('heading', { name: responsePoolLabel$() })).toBeInTheDocument(); + expect(within(pool()).getAllByRole('listitem')).toHaveLength(3); + }); + + it('renders two choices with the same content as two items', () => { + renderPool([ + { content: 'Antonio', isCorrect: false }, + { content: 'Antonio', isCorrect: false }, + ]); + expect(chipTexts()).toEqual(['Antonio', 'Antonio']); + }); + + it('paints only the correct choice green, wherever the shuffle puts it', () => { + const palette = themePalette(); + renderPool(); + expect(chipTexts()).toEqual(['Capulet', 'Prospero', 'Antonio']); + expect(chip('Antonio')).toHaveStyle({ + borderColor: palette.green.v_600, + backgroundColor: palette.green.v_50, + }); + expect(chip('Prospero')).not.toHaveStyle({ borderColor: palette.green.v_600 }); + expect(chip('Prospero')).not.toHaveStyle({ backgroundColor: palette.green.v_50 }); + }); + + it('keeps its order when only correctness changes', async () => { + const { updateProps } = renderPool(); + shuffle.mockImplementation(list => [...list]); + await updateProps({ choices: CHOICES.map(choice => ({ ...choice, isCorrect: false })) }); + expect(chipTexts()).toEqual(['Capulet', 'Prospero', 'Antonio']); + }); + + it('reshuffles when the contents change', async () => { + const { updateProps } = renderPool(); + await updateProps({ choices: [...CHOICES, { content: 'Montague', isCorrect: false }] }); + expect(chipTexts()).toEqual(['Montague', 'Capulet', 'Prospero', 'Antonio']); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ShuffledResponsePool/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ShuffledResponsePool/index.vue new file mode 100644 index 0000000000..cd2a0b5d9a --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ShuffledResponsePool/index.vue @@ -0,0 +1,145 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useMatchInteraction.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useMatchInteraction.spec.js new file mode 100644 index 0000000000..818352695e --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useMatchInteraction.spec.js @@ -0,0 +1,179 @@ +import { ref } from 'vue'; +import { useMatchInteraction } from '../useMatchInteraction'; +import { MATCH_XML, MATCH_DECL_XML } from '../../utils/testingFixtures'; +import { parseXML } from '../../serialization/xml'; +import { QuestionType } from '../../constants'; + +const GENERATED_CHOICE_ID = /^choice_[a-z0-9]{8}$/; +const GENERATED_ROW_ID = /^row_[a-z0-9]{8}$/; + +const contentsOf = rows => + rows.map(row => [row.content, row.matches.map(choice => choice.content)]); + +const maxAssociations = bodyXml => + parseXML(bodyXml).documentElement.getAttribute('max-associations'); + +describe('useMatchInteraction', () => { + function setup(bodyXml = MATCH_XML, declarationXml = MATCH_DECL_XML) { + const questionType = ref(QuestionType.MATCH); + return useMatchInteraction({ bodyXml, responseDeclarations: [declarationXml] }, questionType); + } + + describe('addRow()', () => { + it('appends a row with a blank prompt and one blank answer', () => { + const { state, addRow } = setup(); + addRow(); + expect(state.value.rows).toHaveLength(4); + const row = state.value.rows[3]; + expect(row.content).toBe(''); + expect(row.id).toMatch(GENERATED_ROW_ID); + expect(row.matches).toHaveLength(1); + expect(row.matches[0].content).toBe(''); + expect(row.matches[0].id).toMatch(GENERATED_CHOICE_ID); + }); + + it('rebuilds bodyXml with the new max-associations', () => { + const { bodyXml, addRow } = setup(); + addRow(); + expect(maxAssociations(bodyXml.value)).toBe('4'); + }); + }); + + describe('removeRow()', () => { + it('drops the row at the given index and keeps the rest in order', () => { + const { state, removeRow } = setup(); + removeRow(1); + expect(contentsOf(state.value.rows)).toEqual([ + ['Dog', ['Mammal']], + ['Frog', ['Amphibian']], + ]); + }); + + it('is a no-op when only one row remains', () => { + const { state, removeRow } = setup(); + removeRow(0); + removeRow(0); + removeRow(0); + expect(contentsOf(state.value.rows)).toEqual([['Frog', ['Amphibian']]]); + }); + + it("drops the row's values from the emitted correct response", () => { + const { responseDeclarations, removeRow } = setup(); + removeRow(0); + expect(responseDeclarations.value[0]).not.toContain('row_dog'); + }); + }); + + describe('setRowContent()', () => { + it('updates only the targeted row prompt', () => { + const { state, setRowContent } = setup(); + setRowContent(1, '

Hawk

'); + expect(contentsOf(state.value.rows)).toEqual([ + ['Dog', ['Mammal']], + ['

Hawk

', ['Bird']], + ['Frog', ['Amphibian']], + ]); + }); + }); + + describe('addMatch()', () => { + it('appends a blank answer with a generated id to that row only', () => { + const { state, addMatch } = setup(); + addMatch(0); + expect(contentsOf(state.value.rows)).toEqual([ + ['Dog', ['Mammal', '']], + ['Eagle', ['Bird']], + ['Frog', ['Amphibian']], + ]); + expect(state.value.rows[0].matches[1].id).toMatch(GENERATED_CHOICE_ID); + }); + + it('appends the given content when the answer is written before it is added', () => { + const { state, addMatch } = setup(); + addMatch(2, '

Vertebrate

'); + expect(state.value.rows[2].matches.map(c => c.content)).toEqual([ + 'Amphibian', + '

Vertebrate

', + ]); + expect(state.value.rows[2].matches[1].id).toMatch(GENERATED_CHOICE_ID); + }); + + it('leaves a previously captured state untouched', () => { + const { state, addMatch } = setup(); + const before = state.value; + addMatch(0, '

Vertebrate

'); + expect(contentsOf(before.rows)).toEqual([ + ['Dog', ['Mammal']], + ['Eagle', ['Bird']], + ['Frog', ['Amphibian']], + ]); + }); + }); + + describe('removeMatch()', () => { + it('drops only the targeted answer', () => { + const { state, addMatch, removeMatch } = setup(); + addMatch(0, '

Vertebrate

'); + addMatch(1, '

Vertebrate

'); + removeMatch(0, 0); + expect(contentsOf(state.value.rows)).toEqual([ + ['Dog', ['

Vertebrate

']], + ['Eagle', ['Bird', '

Vertebrate

']], + ['Frog', ['Amphibian']], + ]); + }); + + it("is a no-op on a row's only answer", () => { + const { state, removeMatch } = setup(); + removeMatch(0, 0); + expect(contentsOf(state.value.rows)[0]).toEqual(['Dog', ['Mammal']]); + }); + }); + + describe('setMatchContent()', () => { + it('updates only the targeted answer', () => { + const { state, addMatch, setMatchContent } = setup(); + addMatch(0, '

Vertebrate

'); + setMatchContent(0, 1, '

Canine

'); + expect(contentsOf(state.value.rows)).toEqual([ + ['Dog', ['Mammal', '

Canine

']], + ['Eagle', ['Bird']], + ['Frog', ['Amphibian']], + ]); + }); + }); + + 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_CHOICE_ID); + }); + + it('appends the given content when the distractor is written before it is added', () => { + const { state, addDistractor } = setup(); + addDistractor('

Fish

'); + expect(state.value.distractors[1].content).toBe('

Fish

'); + expect(state.value.distractors[1].id).toMatch(GENERATED_CHOICE_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, '

Updated

'); + expect(state.value.distractors.map(d => d.content)).toEqual(['Reptile', '

Updated

']); + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useMatchInteraction.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useMatchInteraction.js new file mode 100644 index 0000000000..f38dc0b52f --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useMatchInteraction.js @@ -0,0 +1,104 @@ +import { readonly } from 'vue'; +import { matchInteractionDescriptor } from '../interactions/match/Descriptor'; +import { newChoice } from '../interactions/associate/parse'; +import { newRow } from '../interactions/match/parse'; +import { useInteraction } from './useInteraction'; + +/** + * Composable for the match interaction editor. + * + * @param {{ bodyXml: string, responseDeclarations: string[] }} interactionBlock + * @param {import('vue').Ref} questionType + */ +export function useMatchInteraction(interactionBlock, questionType) { + const base = useInteraction(matchInteractionDescriptor, interactionBlock, questionType); + const { state } = base; + + function updateRow(index, update) { + state.value = { + ...state.value, + rows: state.value.rows.map((row, i) => (i === index ? update(row) : row)), + }; + } + + function addRow() { + state.value = { ...state.value, rows: [...state.value.rows, newRow()] }; + } + + function removeRow(index) { + // A match question is meaningless without a row to match. + if (state.value.rows.length <= 1) return; + state.value = { + ...state.value, + rows: state.value.rows.filter((_, i) => i !== index), + }; + } + + function setRowContent(index, html) { + updateRow(index, row => ({ ...row, content: html })); + } + + function addMatch(rowIndex, content = '') { + updateRow(rowIndex, row => ({ ...row, matches: [...row.matches, newChoice(content)] })); + } + + function removeMatch(rowIndex, index) { + // A row always keeps an answer to match. + if (state.value.rows[rowIndex].matches.length <= 1) return; + updateRow(rowIndex, row => ({ + ...row, + matches: row.matches.filter((_, i) => i !== index), + })); + } + + function setMatchContent(rowIndex, index, html) { + updateRow(rowIndex, row => ({ + ...row, + matches: row.matches.map((choice, i) => + i === index ? { ...choice, content: html } : choice, + ), + })); + } + + function addDistractor(content = '') { + state.value = { + ...state.value, + distractors: [...state.value.distractors, newChoice(content)], + }; + } + + 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), + addRow, + removeRow, + setRowContent, + addMatch, + removeMatch, + setMatchContent, + addDistractor, + removeDistractor, + setDistractorContent, + setPrompt, + }; +} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js index 22572b1467..3a4b941c21 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js @@ -84,6 +84,7 @@ export const QuestionType = Object.freeze({ FREE_RESPONSE: 'freeResponse', ORDERING: 'ordering', ASSOCIATE: 'associate', + MATCH: 'match', }); /** @@ -108,6 +109,11 @@ export const ValidationError = Object.freeze({ TOO_FEW_PAIRS: 'TOO_FEW_PAIRS', DUPLICATE_PAIR_CONTENT: 'DUPLICATE_PAIR_CONTENT', DUPLICATE_DISTRACTOR_CONTENT: 'DUPLICATE_DISTRACTOR_CONTENT', + EMPTY_ROW_CONTENT: 'EMPTY_ROW_CONTENT', + ROW_WITHOUT_MATCH: 'ROW_WITHOUT_MATCH', + TOO_FEW_ROWS: 'TOO_FEW_ROWS', + DUPLICATE_ROW_CONTENT: 'DUPLICATE_ROW_CONTENT', + DUPLICATE_MATCH_CONTENT: 'DUPLICATE_MATCH_CONTENT', }); export const RESPONSE_IDENTIFIER = 'RESPONSE'; diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/registry.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/registry.spec.js index 1f753c67cd..73a745dde6 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/registry.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/registry.spec.js @@ -1,6 +1,6 @@ import { descriptors, editors, registry, DEFAULT_INTERACTION } from '../index'; import { isInlineInteraction } from '../descriptors'; -import { Placement } from '../../constants'; +import { Placement, QtiInteraction } from '../../constants'; /** * An interaction is registered in two places: its descriptor in `descriptors.js` and its @@ -42,7 +42,7 @@ describe('interaction registry', () => { }); it('reports an interaction with no descriptor as not inline', () => { - expect(isInlineInteraction('qti-match-interaction')).toBe(false); + expect(isInlineInteraction(QtiInteraction.EXTENDED_TEXT)).toBe(false); }); }); }); 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 1b7ad2c114..94df62cef0 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/Editor.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/Editor.vue @@ -1,9 +1,6 @@