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 438046186b..e5f25514dc 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 @@ -1,15 +1,24 @@ -import { render, screen } from '@testing-library/vue'; +import { render, screen, fireEvent, within } from '@testing-library/vue'; import { nextTick } from 'vue'; import VueRouter from 'vue-router'; import InteractionSection from '../index.vue'; +import { qtiEditorStrings as tr } from '../../../qtiEditorStrings'; import { CHOICE_SINGLE_SELECT_XML, + CHOICE_MULTI_SELECT_XML, UNKNOWN_INTERACTION_XML, mockInteractionBlock as interactionBlock, } from '../../../utils/testingFixtures'; jest.mock('shared/views/TipTapEditor/TipTapEditor/TipTapEditor'); +jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => { + const { ref } = require('vue'); + return { + __esModule: true, + default: () => ({ windowIsSmall: ref(false) }), + }; +}); const renderSection = (props = {}) => render(InteractionSection, { @@ -36,12 +45,72 @@ describe('InteractionSection', () => { expect(screen.getByText('Mercury')).toBeInTheDocument(); expect(screen.getByText('Venus')).toBeInTheDocument(); }); + + it('teleports answer settings into the question type selector header', async () => { + renderSection({ interaction: interactionBlock(CHOICE_MULTI_SELECT_XML) }); + await nextTick(); + const targetDiv = document.querySelector('.answer-settings-group'); + expect(targetDiv).toBeInTheDocument(); + expect( + within(targetDiv).getByRole('checkbox', { name: tr.$tr('shuffleAnswersLabel') }), + ).toBeInTheDocument(); + }); }); describe('parse error handling', () => { it('shows a parse error when XML is malformed', () => { renderSection({ interaction: interactionBlock('not-xml<{{') }); - expect(screen.getByText('This question could not be loaded')).toBeInTheDocument(); + expect(screen.getByText(tr.$tr('errorParsingQuestion'))).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); + }, + }, + }; + + const { emitted } = render(Wrapper, { + routes: new VueRouter(), + }); + + await nextTick(); + + const selectedOption = screen.getAllByText(tr.$tr('singleSelectLabel'))[0]; + await fireEvent.click(selectedOption); + + const textEntryOption = screen.getByText(tr.$tr('textEntryLabel')); + await fireEvent.click(textEntryOption); + + await nextTick(); + + const emits = emitted()['wrapper-update']; + const switchXml = emits.at(-1)[0].bodyXml; + + expect(switchXml).toContain('Which planet is closest to the Sun?'); + expect(switchXml).toContain(' {{ parseError }}

- +
+ + + +
@@ -26,10 +35,17 @@ import { computed, watch } from 'vue'; import useInteractionDescriptor from '../../composables/useInteractionDescriptor'; + import QuestionTypeSelector from '../QuestionTypeSelector/index.vue'; + import { generateRandomSlug } from '../../utils/generateRandomSlug'; + import { descriptors } from '../../interactions'; export default { name: 'InteractionSection', + components: { + QuestionTypeSelector, + }, + setup(props, { emit }) { const interactionRef = computed(() => props.interaction); const { descriptor, questionType, parseError } = useInteractionDescriptor(interactionRef); @@ -42,7 +58,37 @@ { immediate: true }, ); - return { descriptor, questionType, parseError }; + const onUpdateQuestionType = newType => { + const newDescriptor = descriptors.find(d => d.questionTypes.includes(newType)); + if (newDescriptor && newDescriptor !== descriptor.value) { + const oldState = descriptor.value.parse( + props.interaction.bodyXml, + props.interaction.responseDeclarations, + ); + const freshState = newDescriptor.parse('', []); + const withPrompt = { ...freshState, prompt: oldState.prompt ?? '' }; + const newInteraction = newDescriptor.buildXML(withPrompt, newType); + emit('update:interaction', newInteraction); + } + + questionType.value = newType; + emit('update:questionType', newType); + }; + + const onUpdateInteraction = updatedInteraction => { + emit('update:interaction', updatedInteraction); + }; + + const settingsTargetId = generateRandomSlug('answer-settings'); + + return { + descriptor, + questionType, + parseError, + onUpdateQuestionType, + onUpdateInteraction, + settingsTargetId, + }; }, props: { 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 9e4a2fd42c..8a9d19fe02 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 @@ -5,6 +5,13 @@ import { qtiEditorStrings } from '../../../qtiEditorStrings'; import { AssessmentItemTypes } from '../../../constants'; jest.mock('shared/views/TipTapEditor/TipTapEditor/TipTapEditor'); +jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => { + const { ref } = require('vue'); + return { + __esModule: true, + default: () => ({ windowIsSmall: ref(false) }), + }; +}); const { closeBtnLabel$, questionContentPlaceholder$ } = qtiEditorStrings; 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 659f052d2c..a699e6ed16 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue @@ -29,7 +29,7 @@
({ + bodyXml: currentBodyXml.value, + responseDeclarations: currentResponseDeclarations.value, + })); + const questionNumberLabel = computed(() => questionNumberLabel$({ number: props.index + 1, @@ -155,6 +160,7 @@ return { currentQuestionType, interactions, + currentInteraction, questionNumberLabel, questionNumberAndTypeLabel, closeBtnLabel$, diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QuestionTypeSelector/__tests__/QuestionTypeSelector.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QuestionTypeSelector/__tests__/QuestionTypeSelector.spec.js new file mode 100644 index 0000000000..1a1fd991d5 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QuestionTypeSelector/__tests__/QuestionTypeSelector.spec.js @@ -0,0 +1,69 @@ +import { render, screen, fireEvent, within } from '@testing-library/vue'; +import VueRouter from 'vue-router'; +import QuestionTypeSelector from '../index.vue'; +import { QuestionType } from '../../../constants'; +import { qtiEditorStrings as tr } from '../../../qtiEditorStrings'; + +const defaultProps = { + questionType: QuestionType.SINGLE_SELECT, + settingsTargetId: 'test-settings-target', +}; + +const renderHeader = (props = {}) => + render(QuestionTypeSelector, { + props: { ...defaultProps, ...props }, + routes: new VueRouter(), + }); + +describe('QuestionTypeSelector', () => { + it('renders the type meta-label in edit mode', () => { + renderHeader(); + expect(screen.getByText(tr.$tr('typeLabel'))).toBeInTheDocument(); + }); + + it('renders a KSelect with the selected option label (not raw enum)', () => { + renderHeader(); + expect(screen.getByText(tr.$tr('singleSelectLabel'))).toBeInTheDocument(); + expect(screen.queryByText(QuestionType.SINGLE_SELECT)).not.toBeInTheDocument(); + }); + + it('renders the selected type label inside the type group', () => { + renderHeader(); + const group = screen.getByRole('group', { name: tr.$tr('typeLabel') }); + expect(within(group).getByText(tr.$tr('singleSelectLabel'))).toBeInTheDocument(); + }); + + it('opens type info modal when info button clicked', async () => { + renderHeader(); + + const helpButton = screen.getByRole('button', { name: tr.$tr('responseTypeInfoTitle') }); + await fireEvent.click(helpButton); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByText(tr.$tr('singleChoiceDescription'))).toBeInTheDocument(); + expect(screen.getByText(tr.$tr('multipleSelectionDescription'))).toBeInTheDocument(); + }); + + it('closes type info modal when Close button clicked', async () => { + renderHeader(); + + await fireEvent.click(screen.getByRole('button', { name: tr.$tr('responseTypeInfoTitle') })); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + + await fireEvent.click(screen.getByRole('button', { name: tr.$tr('closeBtnLabel') })); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('emits update:questionType when a new type is selected', async () => { + const { emitted } = renderHeader(); + + // Click the currently selected option to open the dropdown + await fireEvent.click(screen.getByText(tr.$tr('singleSelectLabel'))); + + // Click the new option from the dropdown menu + await fireEvent.click(screen.getByText(tr.$tr('multiSelectLabel'))); + + expect(emitted()['update:questionType']).toBeTruthy(); + expect(emitted()['update:questionType'][0]).toEqual([QuestionType.MULTI_SELECT]); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QuestionTypeSelector/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QuestionTypeSelector/index.vue new file mode 100644 index 0000000000..1cb89f6cf5 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QuestionTypeSelector/index.vue @@ -0,0 +1,241 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useChoiceInteraction.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useChoiceInteraction.spec.js index 3c85a35f11..05fa5419f4 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useChoiceInteraction.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useChoiceInteraction.spec.js @@ -174,4 +174,125 @@ describe('useChoiceInteraction', () => { expect(state.value.shuffle).toBe(true); }); }); + + describe('showAnswerCount', () => { + it('defaults to true (maxChoices !== 0 in the fixture)', () => { + const { state } = setup([ + makeAnswer({ id: 'a', correct: true }), + makeAnswer({ id: 'b', correct: false }), + ]); + expect(state.value.showAnswerCount).toBe(true); + }); + + it('setShowAnswerCount(false) updates state.showAnswerCount', () => { + const { state, setShowAnswerCount } = setup([ + makeAnswer({ id: 'a', correct: true }), + makeAnswer({ id: 'b', correct: false }), + ]); + setShowAnswerCount(false); + expect(state.value.showAnswerCount).toBe(false); + }); + }); + + describe('max-choices / min-choices XML output', () => { + it('when showAnswerCount is true, max-choices equals number of correct answers', () => { + const { bodyXml } = setup( + [ + makeAnswer({ id: 'a', correct: true }), + makeAnswer({ id: 'b', correct: true }), + makeAnswer({ id: 'c', correct: false }), + ], + QuestionType.MULTI_SELECT, + ); + + const parser = new DOMParser(); + const doc = parser.parseFromString(bodyXml.value, 'text/xml'); + const interaction = doc.querySelector('qti-choice-interaction'); + + expect(interaction?.getAttribute('max-choices')).toBe('2'); + }); + + it('when showAnswerCount is false, max-choices is 0', () => { + const { bodyXml, setShowAnswerCount } = setup( + [ + makeAnswer({ id: 'a', correct: true }), + makeAnswer({ id: 'b', correct: true }), + makeAnswer({ id: 'c', correct: false }), + ], + QuestionType.MULTI_SELECT, + ); + + setShowAnswerCount(false); + + const parser = new DOMParser(); + const doc = parser.parseFromString(bodyXml.value, 'text/xml'); + const interaction = doc.querySelector('qti-choice-interaction'); + + expect(interaction?.getAttribute('max-choices')).toBe('0'); + }); + + it('updates automatically when correct answers change and showAnswerCount is true', () => { + const { bodyXml, toggleCorrectChoice } = setup( + [ + makeAnswer({ id: 'a', correct: true }), + makeAnswer({ id: 'b', correct: false }), + makeAnswer({ id: 'c', correct: false }), + ], + QuestionType.MULTI_SELECT, + ); + + // Initially 1 correct answer + let parser = new DOMParser(); + let doc = parser.parseFromString(bodyXml.value, 'text/xml'); + let interaction = doc.querySelector('qti-choice-interaction'); + expect(interaction?.getAttribute('max-choices')).toBe('1'); + expect(interaction?.getAttribute('min-choices')).toBe('1'); + + // Toggle second answer correct + toggleCorrectChoice('b'); + + parser = new DOMParser(); + doc = parser.parseFromString(bodyXml.value, 'text/xml'); + interaction = doc.querySelector('qti-choice-interaction'); + expect(interaction?.getAttribute('max-choices')).toBe('2'); + expect(interaction?.getAttribute('min-choices')).toBe('2'); + }); + + it('sets max-choices to 1 and omits min-choices for single select', () => { + const { bodyXml } = setup( + [makeAnswer({ id: 'a', correct: true }), makeAnswer({ id: 'b', correct: false })], + QuestionType.SINGLE_SELECT, + ); + + const parser = new DOMParser(); + const doc = parser.parseFromString(bodyXml.value, 'text/xml'); + const interaction = doc.querySelector('qti-choice-interaction'); + expect(interaction?.getAttribute('max-choices')).toBe('1'); + expect(interaction?.hasAttribute('min-choices')).toBe(false); + }); + }); + + describe('questionType conversion', () => { + it('updates response declaration cardinality to match the new type', () => { + const { questionTypeRef, responseDeclarations } = setup( + [makeAnswer({ id: 'a', correct: true }), makeAnswer({ id: 'b', correct: false })], + QuestionType.SINGLE_SELECT, + ); + + // Verify initial state + let parser = new DOMParser(); + let doc = parser.parseFromString(responseDeclarations.value[0], 'text/xml'); + let declaration = doc.querySelector('qti-response-declaration'); + expect(declaration?.getAttribute('cardinality')).toBe('single'); + + // Change type to multi-select + questionTypeRef.value = QuestionType.MULTI_SELECT; + + // Verify updated state + parser = new DOMParser(); + doc = parser.parseFromString(responseDeclarations.value[0], 'text/xml'); + declaration = doc.querySelector('qti-response-declaration'); + expect(declaration?.getAttribute('cardinality')).toBe('multiple'); + }); + }); }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useChoiceInteraction.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useChoiceInteraction.js index 661f902e7e..d030f693d2 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useChoiceInteraction.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useChoiceInteraction.js @@ -1,4 +1,4 @@ -import { readonly } from 'vue'; +import { computed, readonly } from 'vue'; import { QuestionType } from '../constants'; import { generateRandomSlug } from '../utils/generateRandomSlug'; import { choiceInteractionDescriptor } from '../interactions/choice/ChoiceInteractionDescriptor'; @@ -17,10 +17,7 @@ import { useInteraction } from './useInteraction'; export function useChoiceInteraction(interactionBlock, questionType) { const base = useInteraction(choiceInteractionDescriptor, interactionBlock, questionType); const { state } = base; - - // --------------------------------------------------------------------------- - // Structural mutations - // --------------------------------------------------------------------------- + const isSingleSelect = computed(() => questionType.value === QuestionType.SINGLE_SELECT); function addChoice() { state.value = { @@ -74,10 +71,6 @@ export function useChoiceInteraction(interactionBlock, questionType) { }; } - // --------------------------------------------------------------------------- - // Field mutations - // --------------------------------------------------------------------------- - function setPrompt(html) { state.value = { ...state.value, prompt: html }; } @@ -93,9 +86,14 @@ export function useChoiceInteraction(interactionBlock, questionType) { state.value = { ...state.value, shuffle: val }; } + function setShowAnswerCount(val) { + state.value = { ...state.value, showAnswerCount: val }; + } + return { ...base, state: readonly(state), + isSingleSelect, addChoice, removeChoice, moveChoiceUp, @@ -104,5 +102,6 @@ export function useChoiceInteraction(interactionBlock, questionType) { setPrompt, setChoiceContent, setShuffle, + setShowAnswerCount, }; } diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionDescriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionDescriptor.js index 5aa0e0d4cf..26b04163f6 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionDescriptor.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionDescriptor.js @@ -15,6 +15,21 @@ export class ChoiceInteractionDescriptor { this.convertsFrom = []; } + getTypeOptions(tr) { + return [ + { + value: QuestionType.SINGLE_SELECT, + label: tr.singleSelectLabel$(), + description: tr.singleChoiceDescription$(), + }, + { + value: QuestionType.MULTI_SELECT, + label: tr.multiSelectLabel$(), + description: tr.multipleSelectionDescription$(), + }, + ]; + } + /** @param {Element} el */ matches(el) { return el.tagName.toLowerCase() === QtiInteraction.CHOICE; diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionEditor.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionEditor.vue index b46aa1a058..f07dd56db7 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionEditor.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionEditor.vue @@ -1,6 +1,19 @@