diff --git a/contentcuration/contentcuration/frontend/channelEdit/__tests__/utils.spec.js b/contentcuration/contentcuration/frontend/channelEdit/__tests__/utils.spec.js index 1c19d7fadb..5678516418 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/__tests__/utils.spec.js +++ b/contentcuration/contentcuration/frontend/channelEdit/__tests__/utils.spec.js @@ -1,9 +1,5 @@ import each from 'jest-each'; import { - floatOrIntRegex, - getCorrectAnswersIndices, - mapCorrectAnswers, - updateAnswersToQuestionType, isImportedContent, importedChannelLink, secondsToHms, @@ -13,7 +9,7 @@ import { import router from '../router'; import { RouteNames } from '../constants'; import { MasteryModelsNames } from 'shared/leUtils/MasteryModels'; -import { AssessmentItemTypes, CompletionCriteriaModels } from 'shared/constants'; +import { CompletionCriteriaModels } from 'shared/constants'; describe('channelEdit utils', () => { describe('imported content', () => { @@ -47,416 +43,6 @@ describe('channelEdit utils', () => { expect(importedChannelLink(notImportedContent, router)).toBe(null); }); }); - describe('getCorrectAnswersIndices', () => { - let questionKind; - - describe('for a single selection question', () => { - beforeEach(() => { - questionKind = AssessmentItemTypes.SINGLE_SELECTION; - }); - - it('returns null if there is no correct answer', () => { - expect( - getCorrectAnswersIndices(questionKind, [ - { answer: 'Answer 1', correct: false }, - { answer: 'Answer 2', correct: false }, - ]), - ).toBeNull(); - }); - - it('returns a correct answer index', () => { - expect( - getCorrectAnswersIndices(questionKind, [ - { answer: 'Answer 1', correct: false }, - { answer: 'Answer 2', correct: true }, - ]), - ).toBe(1); - }); - }); - - describe('for a true/false question', () => { - beforeEach(() => { - questionKind = AssessmentItemTypes.TRUE_FALSE; - }); - - it('returns null if there is no correct answer', () => { - expect( - getCorrectAnswersIndices(questionKind, [ - { answer: 'True', correct: false }, - { answer: 'False', correct: false }, - ]), - ).toBeNull(); - }); - - it('returns a correct answer index', () => { - expect( - getCorrectAnswersIndices(questionKind, [ - { answer: 'True', correct: false }, - { answer: 'False', correct: true }, - ]), - ).toBe(1); - }); - }); - - describe('for a multiple selection question', () => { - beforeEach(() => { - questionKind = AssessmentItemTypes.MULTIPLE_SELECTION; - }); - - it('returns an empty array if there is no correct answer', () => { - expect( - getCorrectAnswersIndices(questionKind, [ - { answer: 'Answer 1', correct: false }, - { answer: 'Answer 2', correct: false }, - { answer: 'Answer 3', correct: false }, - ]), - ).toEqual([]); - }); - - it('returns an array of correct answer indices', () => { - expect( - getCorrectAnswersIndices(questionKind, [ - { answer: 'Answer 1', correct: true }, - { answer: 'Answer 2', correct: false }, - { answer: 'Answer 3', correct: true }, - ]), - ).toEqual([0, 2]); - }); - }); - - describe('for an input question', () => { - beforeEach(() => { - questionKind = AssessmentItemTypes.INPUT_QUESTION; - }); - - it('returns an empty array if there is no correct answer', () => { - expect( - getCorrectAnswersIndices(questionKind, [ - { answer: 'Answer 1', correct: false }, - { answer: 'Answer 2', correct: false }, - { answer: 'Answer 3', correct: false }, - ]), - ).toEqual([]); - }); - - it('returns an array of correct answer indices', () => { - expect( - getCorrectAnswersIndices(questionKind, [ - { answer: 'Answer 1', correct: true }, - { answer: 'Answer 2', correct: true }, - { answer: 'Answer 3', correct: true }, - ]), - ).toEqual([0, 1, 2]); - }); - }); - }); - - describe('mapCorrectAnswers', () => { - describe('for a single correct answer index', () => { - it('returns updated answers', () => { - expect( - mapCorrectAnswers( - [ - { answer: 'Answer 1', correct: true }, - { answer: 'Answer 2', correct: false }, - { answer: 'Answer 3', correct: true }, - ], - 1, - ), - ).toEqual([ - { answer: 'Answer 1', correct: false }, - { answer: 'Answer 2', correct: true }, - { answer: 'Answer 3', correct: false }, - ]); - }); - }); - - describe('for an array of correct answers indices', () => { - it('returns updated answers', () => { - expect( - mapCorrectAnswers( - [ - { answer: 'Answer 1', correct: true }, - { answer: 'Answer 2', correct: false }, - { answer: 'Answer 3', correct: true }, - ], - [1, 2], - ), - ).toEqual([ - { answer: 'Answer 1', correct: false }, - { answer: 'Answer 2', correct: true }, - { answer: 'Answer 3', correct: true }, - ]); - }); - }); - }); - - describe('updateAnswersToQuestionType', () => { - let answers; - - describe('when converting originally empty answers to true/false', () => { - it('returns true/false answers', () => { - expect(updateAnswersToQuestionType(AssessmentItemTypes.TRUE_FALSE, [])).toEqual([ - { answer: 'True', correct: true, order: 1 }, - { answer: 'False', correct: false, order: 2 }, - ]); - }); - }); - - describe('for originally single selection answers', () => { - beforeEach(() => { - answers = [ - { answer: 'Mayonnaise (I mean you can, but...)', correct: false, order: 1 }, - { answer: 'Peanut butter', correct: true, order: 2 }, - { answer: 'Jelly', correct: false, order: 3 }, - ]; - }); - - describe('conversion to single selection', () => { - it('returns the same answers', () => { - expect( - updateAnswersToQuestionType(AssessmentItemTypes.SINGLE_SELECTION, answers), - ).toEqual(answers); - }); - }); - - describe('conversion to multiple selection', () => { - it('returns the same answers', () => { - expect( - updateAnswersToQuestionType(AssessmentItemTypes.MULTIPLE_SELECTION, answers), - ).toEqual(answers); - }); - }); - - describe('conversion to input question', () => { - beforeEach(() => { - answers = [ - { answer: '1500', correct: false, order: 1 }, - { answer: '1500.00', correct: false, order: 2 }, - { answer: '-1500.00', correct: true, order: 3 }, - { answer: '1500 with alphabetical', correct: false, order: 4 }, - { answer: '$1500.00', correct: false, order: 5 }, - ]; - }); - - it('makes all answers correct and removes any answers with non-numeric characters', () => { - expect(updateAnswersToQuestionType(AssessmentItemTypes.INPUT_QUESTION, answers)).toEqual([ - { answer: '1500', correct: true, order: 1 }, - { answer: '1500.00', correct: true, order: 2 }, - { answer: '-1500.00', correct: true, order: 3 }, - ]); - }); - }); - - describe('conversion to true/false', () => { - it('returns true/false answers only', () => { - expect(updateAnswersToQuestionType(AssessmentItemTypes.TRUE_FALSE, answers)).toEqual([ - { answer: 'True', correct: true, order: 1 }, - { answer: 'False', correct: false, order: 2 }, - ]); - }); - }); - }); - - describe('for originally input question', () => { - beforeEach(() => { - answers = [ - { answer: '8', correct: true, order: 1 }, - { answer: '8.0', correct: true, order: 2 }, - { answer: '-400.19090', correct: true, order: 3 }, - { answer: '-140140104', correct: true, order: 4 }, - ]; - }); - - describe('conversion to input question', () => { - it('returns the same answers', () => { - expect(updateAnswersToQuestionType(AssessmentItemTypes.INPUT_QUESTION, answers)).toEqual( - answers, - ); - }); - }); - - describe('conversion to multiple selection', () => { - it('returns the same answers', () => { - expect( - updateAnswersToQuestionType(AssessmentItemTypes.MULTIPLE_SELECTION, answers), - ).toEqual(answers); - }); - }); - - describe('conversion to single selection', () => { - it('keeps only first answer as correct', () => { - expect( - updateAnswersToQuestionType(AssessmentItemTypes.SINGLE_SELECTION, answers), - ).toEqual([ - { answer: '8', correct: true, order: 1 }, - { answer: '8.0', correct: false, order: 2 }, - { answer: '-400.19090', correct: false, order: 3 }, - { answer: '-140140104', correct: false, order: 4 }, - ]); - }); - }); - - describe('conversion to true/false', () => { - it('returns true/false answers only', () => { - expect(updateAnswersToQuestionType(AssessmentItemTypes.TRUE_FALSE, answers)).toEqual([ - { answer: 'True', correct: true, order: 1 }, - { answer: 'False', correct: false, order: 2 }, - ]); - }); - }); - }); - - describe('for originally true/false question', () => { - beforeEach(() => { - answers = [ - { answer: 'True', correct: false, order: 1 }, - { answer: 'False', correct: true, order: 2 }, - ]; - }); - - describe('conversion to true/false question', () => { - it('returns the same answers', () => { - expect( - updateAnswersToQuestionType(AssessmentItemTypes.SINGLE_SELECTION, answers), - ).toEqual(answers); - }); - }); - - describe('conversion to multiple selection', () => { - it('returns the same answers', () => { - expect( - updateAnswersToQuestionType(AssessmentItemTypes.MULTIPLE_SELECTION, answers), - ).toEqual(answers); - }); - }); - - describe('conversion to single selection', () => { - it('returns the same answers', () => { - expect( - updateAnswersToQuestionType(AssessmentItemTypes.SINGLE_SELECTION, answers), - ).toEqual(answers); - }); - }); - - describe('conversion to input question', () => { - it('remove all answers', () => { - expect(updateAnswersToQuestionType(AssessmentItemTypes.INPUT_QUESTION, answers)).toEqual( - [], - ); - }); - }); - }); - - describe('for originally multiple selection answers', () => { - describe('conversion to multiple selection', () => { - it('returns the same answers', () => { - expect( - updateAnswersToQuestionType(AssessmentItemTypes.SINGLE_SELECTION, answers), - ).toEqual(answers); - }); - }); - - describe('conversion to single selection', () => { - describe('if there are some correct answers', () => { - beforeEach(() => { - answers = [ - { answer: 'Mayonnaise (I mean you can, but...)', correct: false, order: 1 }, - { answer: 'Peanut butter', correct: true, order: 2 }, - { answer: 'Jelly', correct: true, order: 3 }, - ]; - }); - - it('keeps only first correct answer', () => { - expect( - updateAnswersToQuestionType(AssessmentItemTypes.SINGLE_SELECTION, answers), - ).toEqual([ - { answer: 'Mayonnaise (I mean you can, but...)', correct: false, order: 1 }, - { answer: 'Peanut butter', correct: true, order: 2 }, - { answer: 'Jelly', correct: false, order: 3 }, - ]); - }); - }); - - describe('if there is no correct answer', () => { - beforeEach(() => { - answers = [ - { answer: 'Mayonnaise (I mean you can, but...)', correct: false, order: 1 }, - { answer: 'Peanut butter', correct: false, order: 2 }, - { answer: 'Jelly', correct: false, order: 3 }, - ]; - }); - - it('makes a first answer correct', () => { - expect( - updateAnswersToQuestionType(AssessmentItemTypes.SINGLE_SELECTION, answers), - ).toEqual([ - { answer: 'Mayonnaise (I mean you can, but...)', correct: true, order: 1 }, - { answer: 'Peanut butter', correct: false, order: 2 }, - { answer: 'Jelly', correct: false, order: 3 }, - ]); - }); - }); - }); - - describe('conversion to input question', () => { - beforeEach(() => { - answers = [ - { answer: '1500', correct: false, order: 1 }, - { answer: '1500 00', correct: false, order: 2 }, - { answer: '1500 with alphabetical', correct: false, order: 3 }, - ]; - }); - - it('makes all answers correct and removes any answers with non-numeric characters', () => { - expect(updateAnswersToQuestionType(AssessmentItemTypes.INPUT_QUESTION, answers)).toEqual([ - { answer: '1500', correct: true, order: 1 }, - ]); - }); - }); - - describe('conversion to true/false', () => { - beforeEach(() => { - answers = [ - { answer: 'Mayonnaise (I mean you can, but...)', correct: true, order: 1 }, - { answer: 'Peanut butter', correct: false, order: 2 }, - { answer: 'Jelly', correct: true, order: 3 }, - ]; - }); - - it('returns true/false answers only', () => { - expect(updateAnswersToQuestionType(AssessmentItemTypes.TRUE_FALSE, answers)).toEqual([ - { answer: 'True', correct: true, order: 1 }, - { answer: 'False', correct: false, order: 2 }, - ]); - }); - }); - }); - }); - - // At least we know that these will work - describe('floatOrIntRegex', () => { - it('tests true for valid values', () => { - [ - '1.5', // Float - '-4.5', // Signed Float - '+1', // Signed Int - '10e5', // Exponentiation - '-15.3e5', // Combo - '-12345.67890e98', // Combo 2 - ].forEach(v => expect(floatOrIntRegex.test(v)).toBe(true)); - }); - - it('tests false for invalid values', () => { - [ - 'i * 1.5', // Math - 'one.point.five', // Text - '10 5 0 100', // Spaces - '1.2.3.4', // IP - ].forEach(v => expect(floatOrIntRegex.test(v)).toBe(false)); - }); - }); - describe(`secondsToHms`, () => { it(`converts 0 seconds to '00:00'`, () => { expect(secondsToHms(0)).toBe('00:00'); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/AnswersEditor/AnswersEditor.spec.js b/contentcuration/contentcuration/frontend/channelEdit/components/AnswersEditor/AnswersEditor.spec.js deleted file mode 100644 index 5d07f3296f..0000000000 --- a/contentcuration/contentcuration/frontend/channelEdit/components/AnswersEditor/AnswersEditor.spec.js +++ /dev/null @@ -1,621 +0,0 @@ -import { shallowMount, mount } from '@vue/test-utils'; - -import { AssessmentItemToolbarActions } from '../../constants'; -import AnswersEditor from './AnswersEditor'; -import { AssessmentItemTypes } from 'shared/constants'; -import TipTapEditor from 'shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue'; - -jest.mock('shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue'); - -jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => { - return function useKResponsiveWindow() { - const { ref } = require('vue'); - return { windowIsSmall: ref(false) }; - }; -}); - -const clickNewAnswerBtn = async wrapper => { - await wrapper.findComponent('[data-test="newAnswerBtn"]').trigger('click'); -}; - -const rendersNewAnswerBtn = wrapper => { - return wrapper.findComponent('[data-test="newAnswerBtn"]').exists(); -}; - -const clickAnswer = async (wrapper, answerIdx) => { - await wrapper.findAll('[data-test="answer"]').at(answerIdx).trigger('click'); -}; - -const clickMoveAnswerUp = async (wrapper, answerIdx) => { - await wrapper - .findAllComponents(`[data-test="toolbarIcon-${AssessmentItemToolbarActions.MOVE_ITEM_UP}"]`) - .at(answerIdx) - .trigger('click'); -}; - -const clickMoveAnswerDown = async (wrapper, answerIdx) => { - await wrapper - .findAllComponents(`[data-test="toolbarIcon-${AssessmentItemToolbarActions.MOVE_ITEM_DOWN}"]`) - .at(answerIdx) - .trigger('click'); -}; - -const clickDeleteAnswer = async (wrapper, answerIdx) => { - await wrapper - .findAllComponents(`[data-test="toolbarIcon-${AssessmentItemToolbarActions.DELETE_ITEM}"]`) - .at(answerIdx) - .trigger('click'); -}; - -describe('AnswersEditor', () => { - let wrapper; - - it('smoke test', () => { - const wrapper = shallowMount(AnswersEditor); - - expect(wrapper.exists()).toBe(true); - }); - - it('renders a placeholder when there are no answers', () => { - wrapper = mount(AnswersEditor, { - propsData: { - answers: [], - }, - }); - - expect(wrapper.html()).toContain('Question has no answer options'); - }); - - describe('answers label', () => { - it.each([ - [AssessmentItemTypes.SINGLE_SELECTION, AnswersEditor.$trs.answersLabelSingleChoice], - [AssessmentItemTypes.TRUE_FALSE, AnswersEditor.$trs.answersLabelSingleChoice], - [AssessmentItemTypes.MULTIPLE_SELECTION, AnswersEditor.$trs.answersLabelMultipleChoice], - [AssessmentItemTypes.INPUT_QUESTION, AnswersEditor.$trs.answersLabelNumeric], - ])('renders the correct label for %s questions', (questionKind, expectedLabel) => { - wrapper = shallowMount(AnswersEditor, { - propsData: { - questionKind, - answers: [], - }, - }); - - expect(wrapper.text()).toContain(expectedLabel); - }); - }); - - describe('for a single selection question', () => { - beforeEach(() => { - wrapper = mount(AnswersEditor, { - propsData: { - questionKind: AssessmentItemTypes.SINGLE_SELECTION, - answers: [ - { answer: 'Mayonnaise (I mean you can, but...)', correct: true, order: 1 }, - { answer: 'Peanut butter', correct: false, order: 2 }, - ], - }, - }); - }); - - it('renders answers as radio controls', () => { - const inputs = wrapper.findAll('input'); - - expect(inputs.length).toBe(2); - for (const n in [0, 1]) { - expect(inputs.at(n).attributes()['type']).toBe('radio'); - } - }); - - it('renders only correct answers as checked', () => { - const inputs = wrapper.findAll('input'); - - expect(inputs.at(0).element.checked).toBe(true); - expect(inputs.at(1).element.checked).toBe(false); - }); - - it('marks correct answer rows with the selected visual state', () => { - const answerRows = wrapper.findAll('[data-test="answer"]'); - - // Correct row has both border-color and background-color applied - expect(answerRows.at(0).attributes('style')).toContain('border-color'); - expect(answerRows.at(0).attributes('style')).toContain('background-color'); - // Incorrect row has border-color but no inline background-color (null omits it) - expect(answerRows.at(1).attributes('style')).toContain('border-color'); - expect(answerRows.at(1).attributes('style')).not.toContain('background-color'); - }); - - it('renders all possible answers', () => { - // First answer is open by default (openAnswerIdx=0) — edit mode TipTapEditor - // Second answer is closed — view mode TipTapEditor - const editors = wrapper.findAllComponents(TipTapEditor); - - // Closed answer uses view mode to safely render rich text - const viewEditor = editors.filter(e => e.props('mode') === 'view').at(0); - expect(viewEditor.exists()).toBe(true); - expect(viewEditor.props('value')).toBe('Peanut butter'); - - // Open answer uses edit mode - const editEditor = editors.filter(e => e.props('mode') === 'edit').at(0); - expect(editEditor.exists()).toBe(true); - expect(editEditor.props('value')).toBe('Mayonnaise (I mean you can, but...)'); - }); - - it('renders new answer button', () => { - expect(rendersNewAnswerBtn(wrapper)).toBe(true); - expect(wrapper.findComponent('[data-test="newAnswerBtn"]').text()).toContain( - AnswersEditor.$trs.addOptionBtnLabel, - ); - }); - - describe('on new answer button click', () => { - beforeEach(async () => { - await clickNewAnswerBtn(wrapper); - }); - - it('emits update event with a payload containing all answers + new answer which is wrong by default', () => { - expect(wrapper.emitted().update).toBeTruthy(); - expect(wrapper.emitted().update.length).toBe(1); - expect(wrapper.emitted().update[0][0]).toEqual([ - { answer: 'Mayonnaise (I mean you can, but...)', correct: true, order: 1 }, - { answer: 'Peanut butter', correct: false, order: 2 }, - { answer: '', correct: false, order: 3 }, - ]); - }); - }); - }); - - describe('for a multiple selection question', () => { - beforeEach(() => { - wrapper = mount(AnswersEditor, { - propsData: { - questionKind: AssessmentItemTypes.MULTIPLE_SELECTION, - answers: [ - { answer: 'Mayonnaise (I mean you can, but...)', correct: true, order: 1 }, - { answer: 'Peanut butter', correct: false, order: 2 }, - { answer: 'Jelly', correct: true, order: 3 }, - ], - }, - }); - }); - - it('renders answers as checkboxes', () => { - const inputs = wrapper.findAll('input'); - - expect(inputs.length).toBe(3); - for (const n in [0, 1, 2]) { - expect(inputs.at(n).attributes()['type']).toBe('checkbox'); - } - }); - - it('renders only correct answers as checked', () => { - const inputs = wrapper.findAll('input'); - - expect(inputs.at(0).element.checked).toBe(true); - expect(inputs.at(1).element.checked).toBe(false); - expect(inputs.at(2).element.checked).toBe(true); - }); - - it('renders all possible answers', () => { - // First answer is open by default (openAnswerIdx=0) — edit mode TipTapEditor - // Remaining answers are closed — each gets a view mode TipTapEditor - const editors = wrapper.findAllComponents(TipTapEditor); - - const viewEditors = editors.filter(e => e.props('mode') === 'view'); - expect(viewEditors.length).toBe(2); - expect(viewEditors.at(0).props('value')).toBe('Peanut butter'); - expect(viewEditors.at(1).props('value')).toBe('Jelly'); - - const editEditor = editors.filter(e => e.props('mode') === 'edit').at(0); - expect(editEditor.exists()).toBe(true); - expect(editEditor.props('value')).toBe('Mayonnaise (I mean you can, but...)'); - }); - - it('renders new answer button', () => { - expect(rendersNewAnswerBtn(wrapper)).toBe(true); - expect(wrapper.findComponent('[data-test="newAnswerBtn"]').text()).toContain( - AnswersEditor.$trs.addOptionBtnLabel, - ); - }); - - describe('on new answer button click', () => { - beforeEach(async () => { - await clickNewAnswerBtn(wrapper); - }); - - it('emits update event with a payload containing all answers + new answer which is wrong by default', () => { - expect(wrapper.emitted().update).toBeTruthy(); - expect(wrapper.emitted().update.length).toBe(1); - expect(wrapper.emitted().update[0][0]).toEqual([ - { answer: 'Mayonnaise (I mean you can, but...)', correct: true, order: 1 }, - { answer: 'Peanut butter', correct: false, order: 2 }, - { answer: 'Jelly', correct: true, order: 3 }, - { answer: '', correct: false, order: 4 }, - ]); - }); - }); - }); - - describe('for a true/false question', () => { - beforeEach(() => { - wrapper = mount(AnswersEditor, { - propsData: { - questionKind: AssessmentItemTypes.TRUE_FALSE, - answers: [ - { answer: 'True', correct: false, order: 1 }, - { answer: 'False', correct: true, order: 2 }, - ], - }, - }); - }); - - it('renders answers as radio controls', () => { - const inputs = wrapper.findAll('input'); - - expect(inputs.length).toBe(2); - for (const n in [0, 1]) { - expect(inputs.at(n).attributes()['type']).toBe('radio'); - } - }); - - it('renders only correct answers as checked', () => { - const inputs = wrapper.findAll('input'); - - expect(inputs.at(0).element.checked).toBe(false); - expect(inputs.at(1).element.checked).toBe(true); - }); - - it('does not render new answer button', () => { - expect(rendersNewAnswerBtn(wrapper)).toBe(false); - }); - }); - - describe('for an input question', () => { - beforeEach(() => { - wrapper = mount(AnswersEditor, { - propsData: { - questionKind: AssessmentItemTypes.INPUT_QUESTION, - answers: [ - { answer: '1.5', correct: true, order: 1 }, - { answer: '2', correct: true, order: 2 }, - ], - }, - }); - }); - - it('renders open answer as a number input and closed answer as plain text', () => { - expect(wrapper.find('input[type="number"]').element.value).toBe('1.5'); - - expect(wrapper.html()).toContain('2'); - }); - - it('renders new answer button', () => { - expect(rendersNewAnswerBtn(wrapper)).toBe(true); - expect(wrapper.findComponent('[data-test="newAnswerBtn"]').text()).toContain( - AnswersEditor.$trs.newAnswerBtnLabel, - ); - }); - - describe('on new answer button click', () => { - beforeEach(async () => { - await clickNewAnswerBtn(wrapper); - }); - - it('emits update event with a payload containing all answers + new answer which is correct', () => { - expect(wrapper.emitted().update).toBeTruthy(); - expect(wrapper.emitted().update.length).toBe(1); - expect(wrapper.emitted().update[0][0]).toEqual([ - { answer: '1.5', correct: true, order: 1 }, - { answer: '2', correct: true, order: 2 }, - { answer: '', correct: true, order: 3 }, - ]); - }); - }); - }); - - describe('autofocus on the open answer editor', () => { - beforeEach(() => { - wrapper = mount(AnswersEditor, { - propsData: { - questionKind: AssessmentItemTypes.SINGLE_SELECTION, - answers: [ - { answer: 'Mayonnaise (I mean you can, but...)', correct: true, order: 1 }, - { answer: 'Peanut butter', correct: false, order: 2 }, - ], - openAnswerIdx: 1, - }, - }); - }); - - it('passes autofocus=true to the open (edit-mode) answer editor', () => { - // A single TipTapEditor per answer switches mode reactively. - // The editor for openAnswerIdx has mode='edit' and autofocus=true. - const editors = wrapper.findAllComponents(TipTapEditor); - const editModeEditor = editors.filter(e => e.props('mode') === 'edit').at(0); - expect(editModeEditor.props('autofocus')).toBe(true); - }); - }); - - describe('on an answer click', () => { - beforeEach(async () => { - wrapper = mount(AnswersEditor, { - propsData: { - questionKind: AssessmentItemTypes.SINGLE_SELECTION, - answers: [ - { answer: 'Mayonnaise (I mean you can, but...)', correct: true, order: 1 }, - { answer: 'Peanut butter', correct: false, order: 2 }, - ], - }, - }); - - await clickAnswer(wrapper, 1); - }); - - it('emits open event with a correct answer idx', () => { - expect(wrapper.emitted().open).toBeTruthy(); - expect(wrapper.emitted().open.length).toBe(1); - expect(wrapper.emitted().open[0][0]).toBe(1); - }); - }); - - describe('on new answer button click', () => { - beforeEach(async () => { - wrapper = mount(AnswersEditor, { - propsData: { - questionKind: AssessmentItemTypes.SINGLE_SELECTION, - answers: [ - { answer: 'Mayonnaise (I mean you can, but...)', correct: true, order: 1 }, - { answer: ' ', correct: true, order: 2 }, - { answer: 'Peanut butter', correct: false, order: 3 }, - ], - }, - }); - - await clickNewAnswerBtn(wrapper); - }); - - it('emits update event with a payload containing all answers and one new empty answer', () => { - expect(wrapper.emitted().update).toBeTruthy(); - expect(wrapper.emitted().update.length).toBe(1); - expect(wrapper.emitted().update[0][0]).toEqual([ - { answer: 'Mayonnaise (I mean you can, but...)', correct: true, order: 1 }, - { answer: ' ', correct: true, order: 2 }, - { answer: 'Peanut butter', correct: false, order: 3 }, - { answer: '', correct: false, order: 4 }, - ]); - }); - - it('emits open event with a new answer idx', () => { - expect(wrapper.emitted().open).toBeTruthy(); - expect(wrapper.emitted().open.length).toBe(1); - expect(wrapper.emitted().open[0][0]).toBe(3); - }); - }); - - describe('on answer text update', () => { - beforeEach(async () => { - wrapper = mount(AnswersEditor, { - propsData: { - questionKind: AssessmentItemTypes.SINGLE_SELECTION, - answers: [ - { answer: 'Mayonnaise (I mean you can, but...)', correct: true, order: 1 }, - { answer: 'Peanut butter', correct: false, order: 2 }, - ], - openAnswerIdx: 1, - }, - }); - - const editors = wrapper.findAllComponents(TipTapEditor); - editors.at(1).vm.$emit('update', 'European butter'); - - await wrapper.vm.$nextTick(); - }); - - it('emits update event with a payload containing updated answers', () => { - expect(wrapper.emitted().update).toBeTruthy(); - expect(wrapper.emitted().update.length).toBe(1); - - const emittedAnswers = JSON.parse(JSON.stringify(wrapper.emitted().update[0][0])); - - expect(emittedAnswers).toEqual([ - { answer: 'Mayonnaise (I mean you can, but...)', correct: true, order: 1 }, - { answer: 'European butter', correct: false, order: 2 }, - ]); - }); - }); - - describe('on correct answer change', () => { - beforeEach(async () => { - wrapper = mount(AnswersEditor, { - propsData: { - questionKind: AssessmentItemTypes.SINGLE_SELECTION, - answers: [ - { answer: 'Mayonnaise (I mean you can, but...)', correct: true, order: 1 }, - { answer: 'Peanut butter', correct: false, order: 2 }, - ], - openAnswerIdx: 1, - }, - }); - - await wrapper.vm.$nextTick(); - await wrapper.findAll('.answer-selection input[type="radio"]').at(1).trigger('click'); - }); - - it('emits update event with a payload containing updated answers', () => { - expect(wrapper.emitted().update).toBeTruthy(); - expect(wrapper.emitted().update.length).toBe(1); - expect(wrapper.emitted().update[0][0]).toEqual([ - { answer: 'Mayonnaise (I mean you can, but...)', correct: false, order: 1 }, - { answer: 'Peanut butter', correct: true, order: 2 }, - ]); - }); - }); - - describe('on move answer up click', () => { - beforeEach(() => { - wrapper = mount(AnswersEditor, { - propsData: { - questionKind: AssessmentItemTypes.SINGLE_SELECTION, - answers: [ - { answer: 'Mayonnaise (I mean you can, but...)', correct: true, order: 1 }, - { answer: 'Peanut butter', correct: false, order: 2 }, - ], - }, - }); - }); - - it('emits update event with a payload containing updated and properly ordered answers', async () => { - await clickMoveAnswerUp(wrapper, 1); - - expect(wrapper.emitted().update).toBeTruthy(); - expect(wrapper.emitted().update.length).toBe(1); - expect(wrapper.emitted().update[0][0]).toEqual([ - { answer: 'Peanut butter', correct: false, order: 1 }, - { answer: 'Mayonnaise (I mean you can, but...)', correct: true, order: 2 }, - ]); - }); - - describe('if moved answer was open', () => { - beforeEach(async () => { - await wrapper.setProps({ - openAnswerIdx: 1, - }); - }); - - it('emits open event with updated answer index', async () => { - await clickMoveAnswerUp(wrapper, 1); - - expect(wrapper.emitted().open).toBeTruthy(); - expect(wrapper.emitted().open.length).toBe(1); - expect(wrapper.emitted().open[0][0]).toBe(0); - }); - }); - - describe('if an answer above a moved answer was open', () => { - beforeEach(async () => { - await wrapper.setProps({ - openAnswerIdx: 0, - }); - - await clickMoveAnswerUp(wrapper, 1); - }); - - it('emits open event with updated, originally open, answer index', () => { - expect(wrapper.emitted().open).toBeTruthy(); - expect(wrapper.emitted().open.length).toBe(1); - expect(wrapper.emitted().open[0][0]).toBe(1); - }); - }); - }); - - describe('on move answer down click', () => { - beforeEach(() => { - wrapper = mount(AnswersEditor, { - propsData: { - questionKind: AssessmentItemTypes.SINGLE_SELECTION, - answers: [ - { answer: 'Mayonnaise (I mean you can, but...)', correct: true, order: 1 }, - { answer: 'Peanut butter', correct: false, order: 2 }, - ], - }, - }); - }); - - it('emits update event with a payload containing updated and properly ordered answers', async () => { - await clickMoveAnswerDown(wrapper, 0); - - expect(wrapper.emitted().update).toBeTruthy(); - expect(wrapper.emitted().update.length).toBe(1); - expect(wrapper.emitted().update[0][0]).toEqual([ - { answer: 'Peanut butter', correct: false, order: 1 }, - { answer: 'Mayonnaise (I mean you can, but...)', correct: true, order: 2 }, - ]); - }); - - describe('if moved answer was open', () => { - beforeEach(async () => { - await wrapper.setProps({ - openAnswerIdx: 0, - }); - }); - - it('emits open event with updated answer index', async () => { - await clickMoveAnswerDown(wrapper, 0); - - expect(wrapper.emitted().open).toBeTruthy(); - expect(wrapper.emitted().open.length).toBe(1); - expect(wrapper.emitted().open[0][0]).toBe(1); - }); - }); - - describe('if an answer below a moved answer was open', () => { - beforeEach(async () => { - await wrapper.setProps({ - openAnswerIdx: 1, - }); - - await clickMoveAnswerDown(wrapper, 0); - }); - - it('emits open event with updated, originally open, answer index', () => { - expect(wrapper.emitted().open).toBeTruthy(); - expect(wrapper.emitted().open.length).toBe(1); - expect(wrapper.emitted().open[0][0]).toBe(0); - }); - }); - }); - - describe('on delete answer click', () => { - beforeEach(() => { - wrapper = mount(AnswersEditor, { - propsData: { - questionKind: AssessmentItemTypes.SINGLE_SELECTION, - answers: [ - { answer: 'Mayonnaise (I mean you can, but...)', correct: true, order: 1 }, - { answer: 'Peanut butter', correct: false, order: 2 }, - ], - }, - }); - }); - - it('emits update event with a payload containing updated and properly ordered answers', async () => { - await clickDeleteAnswer(wrapper, 0); - - expect(wrapper.emitted().update).toBeTruthy(); - expect(wrapper.emitted().update.length).toBe(1); - expect(wrapper.emitted().update[0][0]).toEqual([ - { answer: 'Peanut butter', correct: false, order: 1 }, - ]); - }); - - describe('if deleted answer was open', () => { - beforeEach(async () => { - await wrapper.setProps({ - openAnswerIdx: 0, - }); - }); - - it('emits close event', async () => { - await clickDeleteAnswer(wrapper, 0); - - expect(wrapper.emitted().close).toBeTruthy(); - expect(wrapper.emitted().close.length).toBe(1); - }); - }); - - describe('if an answer below a deleted answer was open', () => { - beforeEach(async () => { - await wrapper.setProps({ - openAnswerIdx: 1, - }); - - await clickDeleteAnswer(wrapper, 0); - }); - - it('emits open event with updated, originally open, answer index', () => { - expect(wrapper.emitted().open).toBeTruthy(); - expect(wrapper.emitted().open.length).toBe(1); - expect(wrapper.emitted().open[0][0]).toBe(0); - }); - }); - }); -}); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/AnswersEditor/AnswersEditor.vue b/contentcuration/contentcuration/frontend/channelEdit/components/AnswersEditor/AnswersEditor.vue deleted file mode 100644 index 44ec3df510..0000000000 --- a/contentcuration/contentcuration/frontend/channelEdit/components/AnswersEditor/AnswersEditor.vue +++ /dev/null @@ -1,668 +0,0 @@ - - - - - - - diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentEditor/AssessmentEditor.spec.js b/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentEditor/AssessmentEditor.spec.js deleted file mode 100644 index e908768f04..0000000000 --- a/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentEditor/AssessmentEditor.spec.js +++ /dev/null @@ -1,437 +0,0 @@ -import { shallowMount, mount } from '@vue/test-utils'; - -import { AssessmentItemToolbarActions } from '../../constants'; -import { assessmentItemKey } from '../../utils'; -import AssessmentEditor from './AssessmentEditor'; -import { AssessmentItemTypes, ValidationErrors, DELAYED_VALIDATION } from 'shared/constants'; - -jest.mock('shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue'); - -const NODE_ID = 'node-id'; -const ITEM1 = { - contentnode: NODE_ID, - assessment_id: 'question-1', - question: 'Question 1', - type: AssessmentItemTypes.INPUT_QUESTION, - order: 0, - answers: [ - { answer: 'Mayonnaise (I mean you can, but...)', correct: true, order: 1 }, - { answer: 'Peanut butter', correct: true, order: 2 }, - ], - hints: [], -}; -const ITEM2 = { - contentnode: NODE_ID, - assessment_id: 'question-2', - question: 'Question 2', - type: AssessmentItemTypes.SINGLE_SELECTION, - order: 1, - answers: [ - { answer: 'Mayonnaise (I mean you can, but...)', correct: true, order: 1 }, - { answer: 'Peanut butter', correct: true, order: 2 }, - ], - hints: [ - { hint: "It's not healthy", order: 1 }, - { hint: 'Tasty!', order: 2 }, - ], -}; -const ITEM3 = { - contentnode: NODE_ID, - assessment_id: 'question-3', - question: 'Question 3', - type: AssessmentItemTypes.MULTIPLE_SELECTION, - order: 2, - answers: [ - { answer: 'Mayonnaise (I mean you can, but...)', correct: true, order: 1 }, - { answer: 'Peanut butter', correct: false, order: 2 }, - { answer: 'Jelly', correct: true, order: 3 }, - ], - hints: [], -}; -const ITEM4 = { - contentnode: NODE_ID, - assessment_id: 'question-4', - question: 'Question 4', - type: AssessmentItemTypes.TRUE_FALSE, - order: 3, - answers: [ - { answer: 'True', correct: false, order: 1 }, - { answer: 'False', correct: true, order: 2 }, - ], - hints: [], -}; - -const ITEMS = [ITEM1, ITEM2, ITEM3, ITEM4]; -const ITEMS_VALIDATION = [ - [], - [ValidationErrors.INVALID_NUMBER_OF_CORRECT_ANSWERS], - [ValidationErrors.QUESTION_REQUIRED], -]; - -const checkShowAnswers = async wrapper => { - await wrapper.findComponent('[data-test="showAnswersCheckbox"]').trigger('click'); -}; - -const getItems = wrapper => { - return wrapper.findAllComponents('[data-test="item"]'); -}; - -const isItemOpen = assessmentItemWrapper => { - return assessmentItemWrapper.findComponent('[data-test="editor"]').exists(); -}; - -const isAnswersPreviewVisible = assessmentItemWrapper => { - return assessmentItemWrapper.findComponent('[data-test="item-answers-preview"]').exists(); -}; - -const clickNewQuestionBtn = async wrapper => { - await wrapper.findComponent('[data-test="newQuestionBtn"]').trigger('click'); -}; - -const clickClose = async assessmentItemWrapper => { - await assessmentItemWrapper.findComponent('[data-test="closeBtn"]').trigger('click'); -}; - -const clickDelete = async assessmentItemWrapper => { - await assessmentItemWrapper - .findComponent(`[data-test="toolbarMenuItem-${AssessmentItemToolbarActions.DELETE_ITEM}"]`) - .trigger('click'); -}; - -const clickAddQuestionAbove = async assessmentItemWrapper => { - await assessmentItemWrapper - .findComponent(`[data-test="toolbarMenuItem-${AssessmentItemToolbarActions.ADD_ITEM_ABOVE}"]`) - .trigger('click'); -}; - -const clickAddQuestionBelow = async assessmentItemWrapper => { - await assessmentItemWrapper - .findComponent(`[data-test="toolbarMenuItem-${AssessmentItemToolbarActions.ADD_ITEM_BELOW}"]`) - .trigger('click'); -}; - -const clickMoveUp = async assessmentItemWrapper => { - await assessmentItemWrapper - .findComponent(`[data-test="toolbarIcon-${AssessmentItemToolbarActions.MOVE_ITEM_UP}"]`) - .trigger('click'); -}; - -const clickMoveDown = async assessmentItemWrapper => { - await assessmentItemWrapper - .findComponent(`[data-test="toolbarIcon-${AssessmentItemToolbarActions.MOVE_ITEM_DOWN}"]`) - .trigger('click'); -}; - -describe('AssessmentEditor', () => { - let wrapper; - const listeners = { - deleteItem: jest.fn(), - addItem: jest.fn(), - updateItem: jest.fn(), - updateItems: jest.fn(), - }; - - beforeEach(() => { - wrapper = mount(AssessmentEditor, { - propsData: { - nodeId: NODE_ID, - items: ITEMS, - itemsValidation: ITEMS_VALIDATION, - }, - stubs: { - AssessmentItemEditor: true, - }, - listeners, - }); - }); - - it('smoke test', () => { - const wrapper = shallowMount(AssessmentEditor); - - expect(wrapper.exists()).toBe(true); - }); - - describe('for an exercise with no questions', () => { - let wrapper; - - beforeEach(() => { - wrapper = mount(AssessmentEditor, { - propsData: { - nodeId: NODE_ID, - items: [], - }, - }); - }); - - it('renders placeholder text if exercise has no questions', () => { - expect(wrapper.html()).toContain('Exercise has no questions'); - }); - - it("doesn't render 'Show answers' checkbox", () => { - expect(wrapper.findComponent('[data-test="showAnswersCheckbox"]').exists()).toBe(false); - }); - }); - - it('renders all items', () => { - const items = getItems(wrapper); - - expect(items.length).toBe(4); - - expect(items.at(0).findComponent({ name: 'RichTextEditor' }).props('value')).toBe( - ITEM1.question, - ); - expect(items.at(1).findComponent({ name: 'RichTextEditor' }).props('value')).toBe( - ITEM2.question, - ); - expect(items.at(2).findComponent({ name: 'RichTextEditor' }).props('value')).toBe( - ITEM3.question, - ); - expect(items.at(3).findComponent({ name: 'RichTextEditor' }).props('value')).toBe( - ITEM4.question, - ); - }); - - it('renders items as closed', () => { - const items = getItems(wrapper); - - expect(isItemOpen(items.at(0))).toBe(false); - expect(isItemOpen(items.at(1))).toBe(false); - expect(isItemOpen(items.at(2))).toBe(false); - expect(isItemOpen(items.at(3))).toBe(false); - }); - - it("renders 'Show answers' checkbox", () => { - expect(wrapper.findComponent('[data-test="showAnswersCheckbox"]').exists()).toBe(true); - }); - - it("wraps 'Show answers' checkbox in a page container", () => { - expect(wrapper.find('.show-answers-container').exists()).toBe(true); - }); - - it('renders question card headers', () => { - expect(wrapper.html()).toContain('Question 1 of 4 — Numeric input'); - expect(wrapper.html()).toContain('Question 2 of 4 — Single choice'); - }); - - it("doesn't render answers preview by default", () => { - const items = getItems(wrapper); - - expect(isAnswersPreviewVisible(items.at(0))).toBe(false); - expect(isAnswersPreviewVisible(items.at(1))).toBe(false); - expect(isAnswersPreviewVisible(items.at(2))).toBe(false); - expect(isAnswersPreviewVisible(items.at(3))).toBe(false); - }); - - it('renders answers preview on show answers click', async () => { - await checkShowAnswers(wrapper); - - const items = getItems(wrapper); - - expect(isAnswersPreviewVisible(items.at(0))).toBe(true); - expect(isAnswersPreviewVisible(items.at(1))).toBe(true); - expect(isAnswersPreviewVisible(items.at(2))).toBe(true); - expect(isAnswersPreviewVisible(items.at(3))).toBe(true); - }); - - it('opens an item on item click', async () => { - const items = getItems(wrapper); - await items.at(1).trigger('click'); - const updatedItems = getItems(wrapper); - - expect(isItemOpen(updatedItems.at(0))).toBe(false); - expect(isItemOpen(updatedItems.at(1))).toBe(true); - expect(isItemOpen(updatedItems.at(2))).toBe(false); - expect(isItemOpen(updatedItems.at(3))).toBe(false); - }); - - it('closes an item on close button click', async () => { - // open an item at first - const items = getItems(wrapper); - await items.at(1).trigger('click'); - let updatedItems = getItems(wrapper); - expect(isItemOpen(updatedItems.at(1))).toBe(true); - - // now close it - await clickClose(updatedItems.at(1)); - updatedItems = getItems(wrapper); - expect(isItemOpen(updatedItems.at(1))).toBe(false); - }); - - describe('on "Delete" click', () => { - beforeEach(async () => { - jest.clearAllMocks(); - const items = getItems(wrapper); - await clickDelete(items.at(1)); - }); - - it('emits delete item event with a correct key', () => { - expect(listeners.deleteItem).toHaveBeenCalledWith(ITEM2); - expect(listeners.updateItems).toHaveBeenCalledTimes(1); - }); - - it('emits update item events with updated order of items after the deleted item', () => { - expect(listeners.updateItems).toHaveBeenCalledWith([ - { - ...assessmentItemKey(ITEM1), - order: 0, - }, - { - ...assessmentItemKey(ITEM3), - order: 1, - }, - { - ...assessmentItemKey(ITEM4), - order: 2, - }, - ]); - expect(listeners.updateItems).toHaveBeenCalledTimes(1); - }); - }); - - describe('on "Add question above" click', () => { - beforeEach(async () => { - jest.clearAllMocks(); - const items = getItems(wrapper); - await clickAddQuestionAbove(items.at(1)); - }); - - it('emits add item event with a new item with a correct order', () => { - expect(listeners.addItem).toHaveBeenCalledWith({ - contentnode: NODE_ID, - question: '', - type: AssessmentItemTypes.SINGLE_SELECTION, - answers: [], - hints: [], - order: 1, - [DELAYED_VALIDATION]: true, - }); - }); - - it('emits update item events with updated order of items below the new item', () => { - expect(listeners.updateItems).toHaveBeenCalledWith([ - { - ...assessmentItemKey(ITEM1), - order: 0, - }, - { - ...assessmentItemKey(ITEM2), - order: 2, - }, - { - ...assessmentItemKey(ITEM3), - order: 3, - }, - { - ...assessmentItemKey(ITEM4), - order: 4, - }, - ]); - expect(listeners.updateItems).toHaveBeenCalledTimes(1); - }); - }); - - describe('on "Add question below" click', () => { - beforeEach(async () => { - jest.clearAllMocks(); - const items = getItems(wrapper); - await clickAddQuestionBelow(items.at(1)); - }); - - it('emits add item event with a new item with a correct order', () => { - expect(listeners.addItem).toHaveBeenCalledWith({ - contentnode: NODE_ID, - question: '', - type: AssessmentItemTypes.SINGLE_SELECTION, - answers: [], - hints: [], - order: 2, - [DELAYED_VALIDATION]: true, - }); - expect(listeners.addItem).toHaveBeenCalledTimes(1); - }); - - it('emits update item events with updated order of items below the new item', () => { - expect(listeners.updateItems).toHaveBeenCalledWith([ - { - ...assessmentItemKey(ITEM1), - order: 0, - }, - { - ...assessmentItemKey(ITEM2), - order: 1, - }, - { - ...assessmentItemKey(ITEM3), - order: 3, - }, - { - ...assessmentItemKey(ITEM4), - order: 4, - }, - ]); - expect(listeners.updateItems).toHaveBeenCalledTimes(1); - }); - }); - - describe('on "Move up" click', () => { - beforeEach(async () => { - jest.clearAllMocks(); - const items = getItems(wrapper); - await clickMoveUp(items.at(1)); - }); - - it('emits update item events with updated order of affected items', () => { - expect(listeners.updateItems).toHaveBeenCalledWith([ - { - ...assessmentItemKey(ITEM2), - order: 0, - }, - { - ...assessmentItemKey(ITEM1), - order: 1, - }, - ]); - expect(listeners.updateItems).toHaveBeenCalledTimes(1); - }); - }); - - describe('on "Move down" click', () => { - beforeEach(async () => { - jest.clearAllMocks(); - const items = getItems(wrapper); - await clickMoveDown(items.at(1)); - }); - - it('emits update item events with updated order of affected items', () => { - expect(listeners.updateItems).toHaveBeenCalledWith([ - { - ...assessmentItemKey(ITEM2), - order: 2, - }, - { - ...assessmentItemKey(ITEM3), - order: 1, - }, - ]); - expect(listeners.updateItems).toHaveBeenCalledTimes(1); - }); - }); - - describe('on "Add new question" click', () => { - beforeEach(async () => { - await clickNewQuestionBtn(wrapper); - }); - - it('emits add item event with a new item with a correct order', () => { - expect(listeners.addItem).toHaveBeenCalledWith({ - contentnode: NODE_ID, - question: '', - type: AssessmentItemTypes.SINGLE_SELECTION, - answers: [], - hints: [], - order: 4, - [DELAYED_VALIDATION]: true, - }); - }); - }); -}); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentEditor/AssessmentEditor.vue b/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentEditor/AssessmentEditor.vue deleted file mode 100644 index f6ccb6c2b0..0000000000 --- a/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentEditor/AssessmentEditor.vue +++ /dev/null @@ -1,550 +0,0 @@ - - - - - - - diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentItemEditor/AssessmentItemEditor.spec.js b/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentItemEditor/AssessmentItemEditor.spec.js deleted file mode 100644 index f5d55b88d8..0000000000 --- a/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentItemEditor/AssessmentItemEditor.spec.js +++ /dev/null @@ -1,220 +0,0 @@ -import { render, screen, fireEvent, within, configure } from '@testing-library/vue'; -import userEvent from '@testing-library/user-event'; - -import { factory } from '../../store'; -import { assessmentItemKey } from '../../utils'; -import AssessmentItemEditor from './AssessmentItemEditor'; -import { AssessmentItemTypes, ValidationErrors } from 'shared/constants'; - -jest.mock('shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue'); - -configure({ - testIdAttribute: 'data-test', -}); - -const store = factory(); - -const ITEM = { - contentnode: 'Exercise 2', - assessment_id: 'Question 2', - question: 'Exercise 2 - Question 2', - type: AssessmentItemTypes.SINGLE_SELECTION, - answers: [ - { answer: 'Mayonnaise (I mean you can, but...)', correct: true, order: 1 }, - { answer: 'Peanut butter', correct: false, order: 2 }, - ], - hints: [ - { hint: "It's not healthy", order: 1 }, - { hint: 'Tasty!', order: 2 }, - ], -}; - -const renderComponent = (props = {}) => { - return render(AssessmentItemEditor, { - store, - routes: [], - props: { - nodeId: 'node-id', - item: ITEM, - ...props, - }, - }); -}; - -// Returns the payload of the most recent `update` event. -const lastUpdatePayload = emitted => { - const updates = emitted().update; - return updates[updates.length - 1][0]; -}; - -// Opens the question editor (question starts collapsed in view mode) and returns its textbox. -const openQuestionEditor = async user => { - await user.click(screen.getByTestId('questionText')); - // Both the type dropdown and the answers expose textboxes, so target the question editor's. - return screen.getAllByRole('textbox').find(el => el.tagName === 'TEXTAREA'); -}; - -// Opens the response-type dropdown (by clicking its current value) and picks a new type. -const changeQuestionType = async (user, currentLabel, newLabel) => { - const select = screen.getByTestId('kindSelect'); - await user.click(within(select).getByText(currentLabel)); - await user.click(await screen.findByText(newLabel)); -}; - -describe('AssessmentItemEditor', () => { - it('shows the response type, question, answers, and hints of the item', () => { - renderComponent(); - - expect(screen.getByText('Type')).toBeInTheDocument(); - expect(screen.getByText('Exercise 2 - Question 2')).toBeInTheDocument(); - expect(screen.getByText('Peanut butter')).toBeInTheDocument(); - expect(screen.getByText('Mayonnaise (I mean you can, but...)')).toBeInTheDocument(); - }); - - it('lets the user edit the question and emits the updated question text', async () => { - const user = userEvent.setup(); - const { emitted } = renderComponent(); - - const questionEditor = await openQuestionEditor(user); - await fireEvent.update(questionEditor, 'My new question'); - - expect(lastUpdatePayload(emitted)).toEqual({ - ...assessmentItemKey(ITEM), - question: 'My new question', - }); - }); - - describe('changing the question type', () => { - it('keeps a single correct answer when switching to single choice', async () => { - const item = { - ...ITEM, - type: AssessmentItemTypes.MULTIPLE_SELECTION, - answers: [ - { answer: 'Mayonnaise (I mean you can, but...)', correct: true, order: 1 }, - { answer: 'Peanut butter', correct: true, order: 2 }, - ], - }; - const user = userEvent.setup(); - const { emitted } = renderComponent({ item }); - - await changeQuestionType(user, 'Multiple choice', 'Single choice'); - - expect(lastUpdatePayload(emitted)).toEqual({ - ...assessmentItemKey(item), - type: AssessmentItemTypes.SINGLE_SELECTION, - answers: [ - { answer: 'Mayonnaise (I mean you can, but...)', correct: true, order: 1 }, - { answer: 'Peanut butter', correct: false, order: 2 }, - ], - }); - }); - - it('replaces the answers with True and False when switching to true or false', async () => { - const item = { - ...ITEM, - type: AssessmentItemTypes.SINGLE_SELECTION, - answers: [ - { answer: 'Mayonnaise (I mean you can, but...)', correct: true, order: 1 }, - { answer: 'Peanut butter', correct: false, order: 2 }, - ], - }; - const user = userEvent.setup(); - const { emitted } = renderComponent({ item }); - - await changeQuestionType(user, 'Single choice', 'True/False'); - - expect(lastUpdatePayload(emitted)).toEqual({ - ...assessmentItemKey(item), - type: AssessmentItemTypes.TRUE_FALSE, - answers: [ - { answer: 'True', order: 1, correct: true }, - { answer: 'False', order: 2, correct: false }, - ], - }); - }); - - it('marks every numeric answer as correct when switching to numeric input', async () => { - const item = { - ...ITEM, - type: AssessmentItemTypes.SINGLE_SELECTION, - answers: [ - { answer: '8', correct: true, order: 1 }, - { answer: '8.0', correct: false, order: 2 }, - { answer: '-400.19090', correct: false, order: 3 }, - ], - }; - const user = userEvent.setup(); - const { emitted } = renderComponent({ item }); - - await changeQuestionType(user, 'Single choice', 'Numeric input'); - - expect(lastUpdatePayload(emitted)).toEqual({ - ...assessmentItemKey(item), - type: AssessmentItemTypes.INPUT_QUESTION, - answers: [ - { answer: '8', correct: true, order: 1 }, - { answer: '8.0', correct: true, order: 2 }, - { answer: '-400.19090', correct: true, order: 3 }, - ], - }); - }); - }); - - it('emits the updated answers when the user changes which answer is correct', async () => { - const item = { - ...ITEM, - type: AssessmentItemTypes.SINGLE_SELECTION, - answers: [ - { answer: 'Mayonnaise (I mean you can, but...)', correct: true, order: 1 }, - { answer: 'Peanut butter', correct: false, order: 2 }, - ], - }; - const { emitted } = renderComponent({ item }); - - // Selecting the second answer's correctness control makes it the correct one. - const radios = screen.getAllByRole('radio'); - await fireEvent.click(radios[1]); - - expect(lastUpdatePayload(emitted)).toEqual({ - ...assessmentItemKey(item), - answers: [ - { answer: 'Mayonnaise (I mean you can, but...)', correct: false, order: 1 }, - { answer: 'Peanut butter', correct: true, order: 2 }, - ], - }); - }); - - it('emits the updated hints when the user edits a hint', async () => { - const user = userEvent.setup(); - const item = { - ...ITEM, - hints: [{ hint: 'Hint 1', order: 1 }], - }; - const { emitted } = renderComponent({ item }); - - // Open the collapsible hints section, then open the hint to edit it. - await user.click(screen.getByRole('button', { name: /hints/i })); - const hintCard = screen.getByTestId('hint'); - await user.click(hintCard); - - const hintEditor = within(screen.getByTestId('hint')).getByRole('textbox'); - await fireEvent.update(hintEditor, 'Updated hint'); - - expect(lastUpdatePayload(emitted)).toEqual({ - ...assessmentItemKey(item), - hints: [{ hint: 'Updated hint', order: 1 }], - }); - }); - - it('shows validation messages for an invalid item', () => { - renderComponent({ - errors: [ - ValidationErrors.QUESTION_REQUIRED, - ValidationErrors.INVALID_NUMBER_OF_CORRECT_ANSWERS, - ], - }); - - expect(screen.getByText('Question is required')).toBeInTheDocument(); - expect(screen.getByText('Choose a correct answer')).toBeInTheDocument(); - }); -}); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentItemEditor/AssessmentItemEditor.vue b/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentItemEditor/AssessmentItemEditor.vue deleted file mode 100644 index 79acbfc85a..0000000000 --- a/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentItemEditor/AssessmentItemEditor.vue +++ /dev/null @@ -1,534 +0,0 @@ - - - - - - - diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentItemPreview/AssessmentItemPreview.spec.js b/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentItemPreview/AssessmentItemPreview.spec.js deleted file mode 100644 index 4022762e3d..0000000000 --- a/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentItemPreview/AssessmentItemPreview.spec.js +++ /dev/null @@ -1,105 +0,0 @@ -import { mount } from '@vue/test-utils'; - -import AssessmentItemPreview from './AssessmentItemPreview'; -import { AssessmentItemTypes } from 'shared/constants'; - -jest.mock('shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue'); - -describe('AssessmentItemPreview', () => { - let wrapper; - - beforeEach(() => { - wrapper = mount(AssessmentItemPreview, { - propsData: { - item: { - question: 'Question', - type: AssessmentItemTypes.SINGLE_SELECTION, - answers: [ - { answer: 'Answer 1', correct: false, order: 1 }, - { answer: 'Answer 2', correct: true, order: 2 }, - { answer: 'Answer 3', correct: false, order: 3 }, - ], - hints: [ - { hint: 'Hint 1', order: 1 }, - { hint: 'Hint 2', order: 2 }, - ], - }, - }, - }); - }); - - it('smoke test', () => { - expect(wrapper.exists()).toBe(true); - }); - - it('renders question', () => { - // Find the RichTextEditor for the question and check its value prop. - const questionEditor = wrapper.findComponent({ name: 'RichTextEditor' }); - expect(questionEditor.props('value')).toBe('Question'); - }); - - it("doesn't render answers by default", () => { - expect(wrapper.html()).not.toContain('Answer 1'); - expect(wrapper.html()).not.toContain('Answer 2'); - expect(wrapper.html()).not.toContain('Answer 3'); - }); - - it("doesn't render hints and hints toggle by default", () => { - expect(wrapper.findComponent('[data-test="hintsToggle"]').exists()).toBe(false); - - expect(wrapper.html()).not.toContain('Hint 1'); - expect(wrapper.html()).not.toContain('Hint 2'); - }); - - describe('if detailed true', () => { - beforeEach(async () => { - await wrapper.setProps({ - detailed: true, - }); - }); - - it('renders answers', () => { - const editors = wrapper.findAllComponents({ name: 'RichTextEditor' }); - // We expect 1 for the question + 3 for the answers = 4 total editors. - expect(editors.length).toBe(4); - - expect(editors.at(1).props('value')).toBe('Answer 1'); - expect(editors.at(2).props('value')).toBe('Answer 2'); - expect(editors.at(3).props('value')).toBe('Answer 3'); - }); - - it("doesn't render hints", () => { - expect(wrapper.html()).not.toContain('Hint 1'); - expect(wrapper.html()).not.toContain('Hint 2'); - }); - - it('renders hints toggle', () => { - expect(wrapper.find('[data-test="hintsToggle"]').exists()).toBe(true); - }); - - it('renders hints on hints toggle click', async () => { - await wrapper.find('[data-test="hintsToggle"]').trigger('click'); - - // After clicking, there should be more editors for the hints. - // 1 (question) + 3 (answers) + 2 (hints) = 6 total editors. - const editors = wrapper.findAllComponents({ name: 'RichTextEditor' }); - expect(editors.length).toBe(6); - - expect(editors.at(4).props('value')).toBe('Hint 1'); - expect(editors.at(5).props('value')).toBe('Hint 2'); - }); - }); - - describe('showTypeLabel property', () => { - it('should render type label by default', () => { - expect(wrapper.find('[data-test="type-label"]').exists()).toBe(true); - }); - - it('should hide type label when showTypeLabel is false', async () => { - await wrapper.setProps({ - showTypeLabel: false, - }); - expect(wrapper.find('[data-test="type-label"]').exists()).toBe(false); - }); - }); -}); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentItemPreview/AssessmentItemPreview.vue b/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentItemPreview/AssessmentItemPreview.vue deleted file mode 100644 index 67e3b01b48..0000000000 --- a/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentItemPreview/AssessmentItemPreview.vue +++ /dev/null @@ -1,314 +0,0 @@ - - - - - - - diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentItemToolbar.vue b/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentItemToolbar.vue deleted file mode 100644 index 63c7e354ff..0000000000 --- a/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentItemToolbar.vue +++ /dev/null @@ -1,312 +0,0 @@ - - - - - - - diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentTab/AssessmentTab.vue b/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentTab/AssessmentTab.vue index a8fbc846e2..e2511272c0 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentTab/AssessmentTab.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentTab/AssessmentTab.vue @@ -1,46 +1,26 @@ @@ -48,19 +28,28 @@ + + + diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/HintsEditor/HintsEditor.spec.js b/contentcuration/contentcuration/frontend/channelEdit/components/HintsEditor/HintsEditor.spec.js deleted file mode 100644 index 3efedcb4c8..0000000000 --- a/contentcuration/contentcuration/frontend/channelEdit/components/HintsEditor/HintsEditor.spec.js +++ /dev/null @@ -1,320 +0,0 @@ -import { render, screen, within, configure } from '@testing-library/vue'; -import userEvent from '@testing-library/user-event'; - -import { AssessmentItemToolbarActions } from '../../constants'; -import HintsEditor from './HintsEditor'; - -jest.mock('shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue'); -jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => { - return function useKResponsiveWindow() { - const { ref } = require('vue'); - return { windowIsSmall: ref(false) }; - }; -}); - -configure({ - testIdAttribute: 'data-test', -}); - -const renderComponent = props => { - return render(HintsEditor, { - routes: [], - props: { - hints: [], - ...props, - }, - }); -}; - -const openHintsSection = async user => { - await user.click(screen.getByText(HintsEditor.$trs.hintsLabel)); -}; - -const getHintCards = () => { - return screen.getAllByTestId('hint'); -}; - -const clickToolbarAction = async ({ action, hintIdx, user }) => { - const buttons = screen.getAllByTestId(`toolbarIcon-${action}`); - expect(buttons[hintIdx]).toBeInTheDocument(); - await user.click(buttons[hintIdx]); -}; - -describe('HintsEditor', () => { - it('smoke test', async () => { - const user = userEvent.setup(); - renderComponent(); - await openHintsSection(user); - - expect( - screen.getByRole('button', { name: HintsEditor.$trs.newHintBtnLabel }), - ).toBeInTheDocument(); - }); - - it('shows an empty-state message when a question has no hints', async () => { - const user = userEvent.setup(); - renderComponent({ - hints: [], - }); - await openHintsSection(user); - - expect(screen.getByText(HintsEditor.$trs.noHintsPlaceholder)).toBeInTheDocument(); - }); - - it('shows hints in the same order as the question', async () => { - const user = userEvent.setup(); - renderComponent({ - hints: [ - { hint: 'First hint', order: 1 }, - { hint: 'Second hint', order: 2 }, - ], - }); - await openHintsSection(user); - - const hintCards = getHintCards(); - expect(within(hintCards[0]).getByText('First hint')).toBeInTheDocument(); - expect(within(hintCards[1]).getByText('Second hint')).toBeInTheDocument(); - }); - - it('lets the user update the text of the currently open hint', async () => { - const user = userEvent.setup(); - const { emitted } = renderComponent({ - hints: [ - { hint: 'First hint', order: 1 }, - { hint: 'Second hint', order: 2 }, - ], - openHintIdx: 1, - }); - await openHintsSection(user); - - const hintCards = getHintCards(); - const hintTextField = within(hintCards[1]).getByRole('textbox'); - - await user.clear(hintTextField); - await user.type(hintTextField, 'Updated hint'); - - const updateEvents = emitted().update; - expect(updateEvents[updateEvents.length - 1][0]).toEqual([ - { hint: 'First hint', order: 1 }, - { hint: 'Updated hint', order: 2 }, - ]); - }); - - it('autofocuses the editor of the open hint', async () => { - const user = userEvent.setup(); - renderComponent({ - hints: [ - { hint: 'First hint', order: 1 }, - { hint: 'Second hint', order: 2 }, - ], - openHintIdx: 0, - }); - await openHintsSection(user); - - const hintCards = getHintCards(); - // The open hint renders an editable textbox that should request autofocus. - expect(within(hintCards[0]).getByRole('textbox')).toHaveAttribute('data-autofocus', 'true'); - // Closed hints render in view mode, so they have no editable textbox to focus. - expect(within(hintCards[1]).queryByRole('textbox')).not.toBeInTheDocument(); - }); - - it('adds a new hint and removes existing empty hints when the user clicks New hint', async () => { - const user = userEvent.setup(); - const { emitted } = renderComponent({ - hints: [ - { hint: 'First hint', order: 1 }, - { hint: '', order: 2 }, - { hint: 'Third hint', order: 3 }, - ], - }); - await openHintsSection(user); - - await user.click(screen.getByRole('button', { name: HintsEditor.$trs.newHintBtnLabel })); - - expect(emitted().update).toHaveLength(1); - expect(emitted().update[0][0]).toEqual([ - { hint: 'First hint', order: 1 }, - { hint: 'Third hint', order: 2 }, - { hint: '', order: 3 }, - ]); - expect(emitted().open).toHaveLength(1); - expect(emitted().open[0][0]).toBe(2); - }); - - it('opens a different hint when the user clicks that hint card', async () => { - const user = userEvent.setup(); - const { emitted } = renderComponent({ - hints: [ - { hint: 'First hint', order: 1 }, - { hint: 'Second hint', order: 2 }, - ], - openHintIdx: 0, - }); - await openHintsSection(user); - - const hintCards = getHintCards(); - await user.click(hintCards[1]); - - expect(emitted().open).toHaveLength(1); - expect(emitted().open[0][0]).toBe(1); - }); - - it('moves a hint up and keeps the same hint open after moving', async () => { - const user = userEvent.setup(); - const { emitted } = renderComponent({ - hints: [ - { hint: 'First hint', order: 1 }, - { hint: 'Second hint', order: 2 }, - ], - openHintIdx: 1, - }); - await openHintsSection(user); - - await clickToolbarAction({ - action: AssessmentItemToolbarActions.MOVE_ITEM_UP, - hintIdx: 1, - user, - }); - - expect(emitted().update).toHaveLength(1); - expect(emitted().update[0][0]).toEqual([ - { hint: 'Second hint', order: 1 }, - { hint: 'First hint', order: 2 }, - ]); - expect(emitted().open).toHaveLength(1); - expect(emitted().open[0][0]).toBe(0); - }); - - it('keeps track of the open hint when the user moves the hint below it upward', async () => { - const user = userEvent.setup(); - const { emitted } = renderComponent({ - hints: [ - { hint: 'First hint', order: 1 }, - { hint: 'Second hint', order: 2 }, - ], - openHintIdx: 0, - }); - await openHintsSection(user); - - await clickToolbarAction({ - action: AssessmentItemToolbarActions.MOVE_ITEM_UP, - hintIdx: 1, - user, - }); - - expect(emitted().open).toHaveLength(1); - expect(emitted().open[0][0]).toBe(1); - }); - - it('moves a hint down and keeps the same hint open after moving', async () => { - const user = userEvent.setup(); - const { emitted } = renderComponent({ - hints: [ - { hint: 'First hint', order: 1 }, - { hint: 'Second hint', order: 2 }, - ], - openHintIdx: 0, - }); - await openHintsSection(user); - - await clickToolbarAction({ - action: AssessmentItemToolbarActions.MOVE_ITEM_DOWN, - hintIdx: 0, - user, - }); - - expect(emitted().update).toHaveLength(1); - expect(emitted().update[0][0]).toEqual([ - { hint: 'Second hint', order: 1 }, - { hint: 'First hint', order: 2 }, - ]); - expect(emitted().open).toHaveLength(1); - expect(emitted().open[0][0]).toBe(1); - }); - - it('keeps track of the open hint when the user moves the hint above it downward', async () => { - const user = userEvent.setup(); - const { emitted } = renderComponent({ - hints: [ - { hint: 'First hint', order: 1 }, - { hint: 'Second hint', order: 2 }, - ], - openHintIdx: 1, - }); - await openHintsSection(user); - - await clickToolbarAction({ - action: AssessmentItemToolbarActions.MOVE_ITEM_DOWN, - hintIdx: 0, - user, - }); - - expect(emitted().open).toHaveLength(1); - expect(emitted().open[0][0]).toBe(0); - }); - - it('deletes a hint and closes the editor when that hint was open', async () => { - const user = userEvent.setup(); - const { emitted } = renderComponent({ - hints: [ - { hint: 'First hint', order: 1 }, - { hint: 'Second hint', order: 2 }, - ], - openHintIdx: 0, - }); - await openHintsSection(user); - - await clickToolbarAction({ - action: AssessmentItemToolbarActions.DELETE_ITEM, - hintIdx: 0, - user, - }); - - expect(emitted().update).toHaveLength(1); - expect(emitted().update[0][0]).toEqual([{ hint: 'Second hint', order: 1 }]); - expect(emitted().close).toHaveLength(1); - }); - - it('keeps track of the open hint when the user deletes a hint above it', async () => { - const user = userEvent.setup(); - const { emitted } = renderComponent({ - hints: [ - { hint: 'First hint', order: 1 }, - { hint: 'Second hint', order: 2 }, - ], - openHintIdx: 1, - }); - await openHintsSection(user); - - await clickToolbarAction({ - action: AssessmentItemToolbarActions.DELETE_ITEM, - hintIdx: 0, - user, - }); - - expect(emitted().open).toHaveLength(1); - expect(emitted().open[0][0]).toBe(0); - }); - - it('toggles the hints section open and closed when clicking the header button', async () => { - const user = userEvent.setup(); - renderComponent({ - hints: [{ hint: 'First hint', order: 1 }], - }); - - // The header button acts as an accordion trigger with correct initial attributes - const headerButton = screen.getByRole('button', { name: HintsEditor.$trs.hintsLabel }); - expect(headerButton).toHaveAttribute('aria-expanded', 'false'); - expect(screen.queryByTestId('hint')).not.toBeInTheDocument(); - - // Click to open the section - await user.click(headerButton); - expect(headerButton).toHaveAttribute('aria-expanded', 'true'); - expect(screen.getByTestId('hint')).toBeInTheDocument(); - - // Click to close the section - await user.click(headerButton); - expect(headerButton).toHaveAttribute('aria-expanded', 'false'); - expect(screen.queryByTestId('hint')).not.toBeInTheDocument(); - }); -}); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/HintsEditor/HintsEditor.vue b/contentcuration/contentcuration/frontend/channelEdit/components/HintsEditor/HintsEditor.vue deleted file mode 100644 index 2acdb20eba..0000000000 --- a/contentcuration/contentcuration/frontend/channelEdit/components/HintsEditor/HintsEditor.vue +++ /dev/null @@ -1,534 +0,0 @@ - - - - - - - diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/ResourcePanel.vue b/contentcuration/contentcuration/frontend/channelEdit/components/ResourcePanel.vue index e8616b3c1e..662f8b7e5c 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/ResourcePanel.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/ResourcePanel.vue @@ -138,31 +138,17 @@ {{ $tr('questionCount', { value: assessmentItems.length }) }} - - - - -
- {{ index + 1 }} -
-
- - - -
-
- -
+ :key="item.assessment_id" + :item="item" + :index="index" + :total="assessmentItems.length" + mode="view" + :allowFreeResponse="allowFreeResponse" + :showAnswers="showAnswers" + class="question-preview" + /> @@ -507,8 +493,12 @@ import camelCase from 'lodash/camelCase'; import { isImportedContent, importedChannelLink, getCompletionCriteriaLabels } from '../utils'; import FilePreview from '../views/files/FilePreview'; - import { ContentLevels, Categories, AccessibilityCategories } from '../../shared/constants'; - import AssessmentItemPreview from './AssessmentItemPreview/AssessmentItemPreview'; + import { + ContentLevels, + Categories, + AccessibilityCategories, + ContentModalities, + } from '../../shared/constants'; import ContentNodeValidator from './ContentNodeValidator'; import { @@ -520,6 +510,7 @@ getNodeMasteryModelMErrors, getNodeMasteryModelNErrors, } from 'shared/utils/validation'; + import QTIItemEditor from 'shared/views/QTIEditor/components/QTIItemEditor/index'; import ContentNodeLearningActivityIcon from 'shared/views/ContentNodeLearningActivityIcon'; import LoadingText from 'shared/views/LoadingText'; import DetailsRow from 'shared/views/details/DetailsRow'; @@ -544,7 +535,7 @@ DetailsRow, FilePreview, ExpandableList, - AssessmentItemPreview, + QTIItemEditor, Checkbox, ContentNodeValidator, Banner, @@ -626,6 +617,10 @@ assessmentItems() { return this.getAssessmentItems(this.nodeId); }, + // Free-response questions cannot be scored, so they only count as complete on a survey. + allowFreeResponse() { + return this.node?.extra_fields?.options?.modality === ContentModalities.SURVEY; + }, fileSize() { return this.contentNodesTotalSize([this.nodeId]); }, @@ -918,6 +913,10 @@ padding: 0; } + .question-preview { + margin-bottom: 8px; + } + .preview-error { padding: 24% 0; diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue index 759f27e821..527f598fc3 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue @@ -215,7 +215,6 @@ import BottomBar from 'shared/views/BottomBar'; import FileDropzone from 'shared/views/files/FileDropzone'; import { isNodeComplete } from 'shared/utils/validation'; - import { DELAYED_VALIDATION } from 'shared/constants'; const CHECK_STORAGE_INTERVAL = 10000; @@ -272,6 +271,8 @@ }, computed: { ...mapGetters('contentNode', ['getContentNode', 'getContentNodeIsValid']), + // Read through `vm` in the route guard below, which the lint rule cannot see. + // eslint-disable-next-line vue/no-unused-properties ...mapGetters('assessmentItem', ['getAssessmentItems']), // eslint-disable-next-line vue/no-unused-properties ...mapGetters('currentChannel', ['currentChannel', 'canEdit']), @@ -445,7 +446,7 @@ 'createContentNode', ]), ...mapActions('file', ['loadFiles', 'updateFile']), - ...mapActions('assessmentItem', ['loadAssessmentItems', 'updateAssessmentItems']), + ...mapActions('assessmentItem', ['loadAssessmentItems']), /* eslint-enable vue/no-unused-properties */ ...mapMutations('contentNode', { enableValidation: 'ENABLE_VALIDATION_ON_NODES' }), closeModal(changed = false) { @@ -488,11 +489,6 @@ this.selected = this.nodeIds; this.$nextTick(() => { this.enableValidation(this.nodeIds); - const assessmentItems = this.getAssessmentItems(this.nodeIds); - assessmentItems.forEach(item => - item.question ? (item[DELAYED_VALIDATION] = false) : '', - ); - this.updateAssessmentItems(assessmentItems); // reaches into Details Tab to run save of diffTracker // before the validation pop up is executed diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditView.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditView.vue index 398e2c2031..a56b779bed 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditView.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditView.vue @@ -281,8 +281,7 @@ }, areAssessmentItemsValid() { return ( - !this.oneSelected || - this.getAssessmentItemsAreValid({ contentNodeId: this.nodeIds[0], ignoreDelayed: true }) + !this.oneSelected || this.getAssessmentItemsAreValid({ contentNodeId: this.nodeIds[0] }) ); }, areFilesValid() { diff --git a/contentcuration/contentcuration/frontend/channelEdit/composables/__tests__/useAssessmentItems.spec.js b/contentcuration/contentcuration/frontend/channelEdit/composables/__tests__/useAssessmentItems.spec.js new file mode 100644 index 0000000000..deed36749e --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/composables/__tests__/useAssessmentItems.spec.js @@ -0,0 +1,171 @@ +import { Store } from 'vuex'; +import VueRouter from 'vue-router'; +import { render } from '@testing-library/vue'; +import useAssessmentItems from '../useAssessmentItems'; +import { AssessmentItemTypes, ContentModalities } from 'shared/constants'; + +const NODE_ID = 'node-1'; + +const item = (assessment_id, order, raw_data = `${assessment_id}`) => ({ + assessment_id, + contentnode: NODE_ID, + type: AssessmentItemTypes.QTI, + order, + raw_data, +}); + +/** + * Renders a component that does nothing but run the composable, and returns it alongside + * the actions the composable dispatched, in the order it dispatched them. + */ +function setup(storedItems, { modality = null } = {}) { + const dispatched = []; + const record = name => (context, payload) => dispatched.push([name, payload]); + + const store = new Store({ + modules: { + contentNode: { + namespaced: true, + getters: { + getContentNode: () => () => ({ extra_fields: { options: { modality } } }), + }, + }, + assessmentItem: { + namespaced: true, + getters: { + getAssessmentItems: () => () => storedItems, + getInvalidAssessmentItemsCount: () => () => 0, + }, + actions: { + updateAssessmentItems: record('updateAssessmentItems'), + updateAssessmentItem: record('updateAssessmentItem'), + addAssessmentItem: record('addAssessmentItem'), + deleteAssessmentItem: record('deleteAssessmentItem'), + }, + }, + }, + }); + + let composable; + render( + { + template: '
', + setup() { + composable = useAssessmentItems(NODE_ID); + }, + }, + { store, routes: new VueRouter() }, + ); + + return { composable, dispatched }; +} + +describe('useAssessmentItems', () => { + describe('allowFreeResponse', () => { + it('accepts a question with no correct answer on a survey', () => { + const { composable } = setup([], { modality: ContentModalities.SURVEY }); + + expect(composable.allowFreeResponse.value).toBe(true); + }); + + it('does not accept one on an exercise, whose questions are scored', () => { + const { composable } = setup([]); + + expect(composable.allowFreeResponse.value).toBe(false); + }); + }); + + it('dispatches nothing when the list is unchanged', async () => { + const items = [item('a', 0), item('b', 1)]; + const { composable, dispatched } = setup(items); + + await composable.applyUpdate([...items]); + + expect(dispatched).toEqual([]); + }); + + it('updates only the question whose content changed', async () => { + const { composable, dispatched } = setup([item('a', 0), item('b', 1)]); + + await composable.applyUpdate([item('a', 0), item('b', 1, 'edited')]); + + expect(dispatched).toEqual([ + [ + 'updateAssessmentItem', + { contentnode: NODE_ID, assessment_id: 'b', raw_data: 'edited' }, + ], + ]); + }); + + it('adds a new question with its position as order', async () => { + const { composable, dispatched } = setup([item('a', 0)]); + const added = { + assessment_id: 'new', + type: AssessmentItemTypes.QTI, + raw_data: 'new', + }; + + await composable.applyUpdate([item('a', 0), added]); + + expect(dispatched).toEqual([ + ['addAssessmentItem', { contentnode: NODE_ID, ...added, order: 1 }], + ]); + }); + + it('does not delay validation of a new question, so it counts as incomplete at once', async () => { + // The card it renders reports being incomplete immediately, so the tab icon and the + // "N incomplete questions" banner have to agree rather than staying quiet until reload. + const { composable, dispatched } = setup([item('a', 0)]); + const added = { assessment_id: 'new', type: AssessmentItemTypes.QTI, raw_data: '' }; + + await composable.applyUpdate([item('a', 0), added]); + + const [, payload] = dispatched[0]; + expect(Object.getOwnPropertySymbols(payload)).toEqual([]); + }); + + it('reorders the questions that moved before adding a new one between them', async () => { + const { composable, dispatched } = setup([item('a', 0), item('b', 1)]); + const added = { + assessment_id: 'new', + type: AssessmentItemTypes.QTI, + raw_data: 'new', + }; + + await composable.applyUpdate([item('a', 0), added, item('b', 1)]); + + expect(dispatched.map(([name]) => name)).toEqual([ + 'updateAssessmentItems', + 'addAssessmentItem', + ]); + expect(dispatched[0][1]).toEqual([{ contentnode: NODE_ID, assessment_id: 'b', order: 2 }]); + expect(dispatched[1][1].order).toBe(1); + }); + + it('reorders the remaining questions before deleting one', async () => { + const { composable, dispatched } = setup([item('a', 0), item('b', 1), item('c', 2)]); + + await composable.applyUpdate([item('a', 0), item('c', 2)]); + + expect(dispatched).toEqual([ + ['updateAssessmentItems', [{ contentnode: NODE_ID, assessment_id: 'c', order: 1 }]], + ['deleteAssessmentItem', { contentnode: NODE_ID, assessment_id: 'b' }], + ]); + }); + + it('reorders swapped questions', async () => { + const { composable, dispatched } = setup([item('a', 0), item('b', 1)]); + + await composable.applyUpdate([item('b', 1), item('a', 0)]); + + expect(dispatched).toEqual([ + [ + 'updateAssessmentItems', + [ + { contentnode: NODE_ID, assessment_id: 'b', order: 0 }, + { contentnode: NODE_ID, assessment_id: 'a', order: 1 }, + ], + ], + ]); + }); +}); diff --git a/contentcuration/contentcuration/frontend/channelEdit/composables/useAssessmentItems.js b/contentcuration/contentcuration/frontend/channelEdit/composables/useAssessmentItems.js new file mode 100644 index 0000000000..983ca88b45 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/composables/useAssessmentItems.js @@ -0,0 +1,112 @@ +import { computed, unref } from 'vue'; +import useStore from 'shared/composables/useStore'; +import { ContentModalities } from 'shared/constants'; + +/** + * Work out what changed between the list Studio holds and the list the editor produced. + * + * The QTI editor is a controlled list component: it hands back the whole array and knows + * nothing about how questions are stored. Studio, on the other hand, syncs one change + * record per assessment item, so the array has to be translated back into per-item writes. + * + * Position in the array is the question's order, and `raw_data` is the only field the + * editor ever rewrites. + * + * @param {Array} prevItems - The items currently in the store + * @param {Array} nextItems - The items the editor emitted + * @returns {{ orders: Array, added: Array, updated: Array, deleted: Array }} + */ +function diffAssessmentItems(prevItems, nextItems) { + const prevById = new Map(prevItems.map(item => [item.assessment_id, item])); + const nextIds = new Set(nextItems.map(item => item.assessment_id)); + + const orders = []; + const added = []; + const updated = []; + const deleted = prevItems.filter(item => !nextIds.has(item.assessment_id)); + + nextItems.forEach((item, order) => { + const previous = prevById.get(item.assessment_id); + + if (!previous) { + added.push({ ...item, order }); + return; + } + if (previous.order !== order) { + orders.push({ assessment_id: item.assessment_id, order }); + } + if (previous.raw_data !== item.raw_data) { + updated.push({ assessment_id: item.assessment_id, raw_data: item.raw_data }); + } + }); + + return { orders, added, updated, deleted }; +} + +/** + * Everything the questions tab needs about one content node's assessment items: the + * ordered list to render, how many of them are incomplete, and a way to save an edited + * list back through the change-sync layer. + * + * @param {string|import('vue').Ref} nodeId + */ +export default function useAssessmentItems(nodeId) { + const store = useStore(); + + const assessmentItems = computed(() => + store.getters['assessmentItem/getAssessmentItems'](unref(nodeId)), + ); + + /** + * A question with no correct answer cannot be scored, so it only counts as complete on a + * survey. The editor takes this as a plain flag; the modality lives out here. + */ + const allowFreeResponse = computed( + () => + store.getters['contentNode/getContentNode'](unref(nodeId))?.extra_fields?.options + ?.modality === ContentModalities.SURVEY, + ); + + const invalidItemsCount = computed(() => + store.getters['assessmentItem/getInvalidAssessmentItemsCount']({ + contentNodeId: unref(nodeId), + }), + ); + + /** + * Persist an edited list of items. + * + * Reordering runs first so that added and removed questions never leave two items + * claiming the same position, even briefly. + * + * @param {Array} nextItems - The full ordered list emitted by the editor + */ + async function applyUpdate(nextItems) { + const contentnode = unref(nodeId); + const { orders, added, updated, deleted } = diffAssessmentItems( + assessmentItems.value, + nextItems, + ); + + if (orders.length) { + await store.dispatch( + 'assessmentItem/updateAssessmentItems', + orders.map(order => ({ contentnode, ...order })), + ); + } + for (const item of added) { + await store.dispatch('assessmentItem/addAssessmentItem', { contentnode, ...item }); + } + for (const item of updated) { + await store.dispatch('assessmentItem/updateAssessmentItem', { contentnode, ...item }); + } + for (const item of deleted) { + await store.dispatch('assessmentItem/deleteAssessmentItem', { + contentnode, + assessment_id: item.assessment_id, + }); + } + } + + return { assessmentItems, invalidItemsCount, allowFreeResponse, applyUpdate }; +} diff --git a/contentcuration/contentcuration/frontend/channelEdit/constants.js b/contentcuration/contentcuration/frontend/channelEdit/constants.js index 8932058ecf..cb34622eaf 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/constants.js +++ b/contentcuration/contentcuration/frontend/channelEdit/constants.js @@ -1,5 +1,3 @@ -import { AssessmentItemTypes } from 'shared/constants'; - export const RouteNames = { TREE_ROOT_VIEW: 'TREE_ROOT_VIEW', TREE_VIEW: 'TREE_VIEW', @@ -32,24 +30,6 @@ export const ChannelEditPageErrors = Object.freeze({ CHANNEL_DELETED: 'CHANNEL_EDIT_ERROR_CHANNEL_DELETED', }); -export const AssessmentItemToolbarActions = { - EDIT_ITEM: 'EDIT_ITEM', - MOVE_ITEM_UP: 'MOVE_ITEM_UP', - MOVE_ITEM_DOWN: 'MOVE_ITEM_DOWN', - DELETE_ITEM: 'DELETE_ITEM', - ADD_ITEM_ABOVE: 'ADD_ITEM_ABOVE', - ADD_ITEM_BELOW: 'ADD_ITEM_BELOW', -}; - -export const AssessmentItemTypeLabels = { - [AssessmentItemTypes.SINGLE_SELECTION]: 'questionTypeSingleSelection', - [AssessmentItemTypes.MULTIPLE_SELECTION]: 'questionTypeMultipleSelection', - [AssessmentItemTypes.TRUE_FALSE]: 'questionTypeTrueFalse', - [AssessmentItemTypes.INPUT_QUESTION]: 'questionTypeInput', - [AssessmentItemTypes.PERSEUS_QUESTION]: 'questionTypePerseus', - [AssessmentItemTypes.FREE_RESPONSE]: 'questionTypeFreeResponse', -}; - export const TabNames = { DETAILS: 'details', PREVIEW: 'preview', diff --git a/contentcuration/contentcuration/frontend/channelEdit/translator.js b/contentcuration/contentcuration/frontend/channelEdit/translator.js index 17d9c684af..6493330e64 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/translator.js +++ b/contentcuration/contentcuration/frontend/channelEdit/translator.js @@ -3,19 +3,6 @@ import { createTranslator } from 'shared/i18n'; const NAMESPACE = 'channelEditVue'; const MESSAGES = { - true: 'True', - false: 'False', - questionTypeSingleSelection: 'Single choice', - questionTypeMultipleSelection: 'Multiple choice', - questionTypeTrueFalse: 'True/False', - questionTypeInput: 'Numeric input', - questionTypePerseus: 'Perseus', - questionTypeFreeResponse: 'Free response', - errorQuestionRequired: 'Question is required', - errorInvalidQuestionType: 'Invalid question type', - errorMissingAnswer: 'Choose a correct answer', - errorChooseAtLeastOneCorrectAnswer: 'Choose at least one correct answer', - errorProvideAtLeastOneCorrectAnswer: 'Provide at least one correct answer', selectionCount: '{topicCount, plural, =0 {} one {# folder, } other {# folders, }}{resourceCount, plural, one {# resource} other {# resources}}', }; diff --git a/contentcuration/contentcuration/frontend/channelEdit/utils.js b/contentcuration/contentcuration/frontend/channelEdit/utils.js index 0d734e558f..9c4f5d081a 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/utils.js +++ b/contentcuration/contentcuration/frontend/channelEdit/utils.js @@ -1,4 +1,3 @@ -import translator from './translator'; import { RouteNames } from './constants'; import { ContentKindsNames } from 'shared/leUtils/ContentKinds'; import { MasteryModelsNames } from 'shared/leUtils/MasteryModels'; @@ -6,139 +5,12 @@ import { metadataStrings } from 'shared/strings/metadataStrings'; import { constantStrings } from 'shared/mixins'; import { ContentModalities, - AssessmentItemTypes, CompletionCriteriaModels, SHORT_LONG_ACTIVITY_MIDPOINT, defaultCompletionCriteriaModels, defaultCompletionCriteriaThresholds, } from 'shared/constants'; -/** - * Get correct answer index/indices out of an array of answer objects. - * @param {String} questionType single/multiple selection, true/false, input question - * @param {Array} answers An array of answer objects { answer: ..., correct: ..., ...} - * @returns {Number|null|Array} Returns a correct answer index or null for single selection - * or true/false question. Returns an array of correct answers indices for multiple selection - * or input question. - */ -export function getCorrectAnswersIndices(questionType, answers) { - if (!questionType || !answers || !answers.length) { - return null; - } - - if ( - questionType === AssessmentItemTypes.SINGLE_SELECTION || - questionType === AssessmentItemTypes.TRUE_FALSE - ) { - const idx = answers.findIndex(answer => answer.correct); - return idx === -1 ? null : idx; - } - - return answers - .map((answer, idx) => { - return answer.correct ? idx : undefined; - }) - .filter(idx => idx !== undefined); -} - -/** - * Updates `correct` fields of answers based on index/indexes stored in `correctAnswersIndices`. - * @param {Array} answers An array of answer objects { answer: ..., correct: ..., ...} - * @param {Number|null|Array} correctAnswersIndices A correct answer index or an array - * of correct answers indexes. - * @returns {Array} An array of answer objects with updated `correct` fields. - */ -export function mapCorrectAnswers(answers, correctAnswersIndices) { - if (!answers || !answers.length) { - return null; - } - - return answers.map((answer, idx) => { - const isAnswerCorrect = - correctAnswersIndices === idx || - (Array.isArray(correctAnswersIndices) && correctAnswersIndices.includes(idx)); - - return { - ...answer, - correct: isAnswerCorrect, - }; - }); -} - -// RegEx to test for signed floats or ints. Also allows the letter e -// to comply with what Chrome permits in their type="number" fields -export const floatOrIntRegex = /^(?=.)([+-]?([0-9e]*)(\.([0-9e]+))?)$/; - -/** - * Update answers to correspond to a question type: - * - multiple selection: No answers updates needed. - * - input question: Make all answers correct and remove non-numerics altogether - * - true/false: Remove answers in favour of new true/false values. - * - single selection: Keep first correct choice only if there is any. - * Otherwise mark first choice as correct. - * @param {String} newQuestionType single/multiple selection, true/false, input question - * @param {Array} answers An array of answer objects. - * @returns {Array} An array of updated answer objects. - */ -export function updateAnswersToQuestionType(questionType, answers) { - const NEW_TRUE_FALSE_ANSWERS = [ - { answer: translator.$tr('true'), correct: true, order: 1 }, - { answer: translator.$tr('false'), correct: false, order: 2 }, - ]; - - if (!answers || !answers.length) { - if (questionType === AssessmentItemTypes.TRUE_FALSE) { - return NEW_TRUE_FALSE_ANSWERS; - } else { - return []; - } - } - - if (questionType === AssessmentItemTypes.FREE_RESPONSE) { - return []; - } - - const answersCopy = JSON.parse(JSON.stringify(answers)); - - switch (questionType) { - case AssessmentItemTypes.MULTIPLE_SELECTION: - return answersCopy; - - case AssessmentItemTypes.INPUT_QUESTION: - return answersCopy.reduce((obj, answer) => { - // If there is anything other than a number in the answer - // we'll just skip it - removing non-numeric answers - if (floatOrIntRegex.test(answer.answer) === false) { - return obj; - } - - // Otherwise, set the answer to correct and push it to our obj - answer.correct = true; - obj.push(answer); - return obj; - }, []); - - case AssessmentItemTypes.TRUE_FALSE: - return NEW_TRUE_FALSE_ANSWERS; - - case AssessmentItemTypes.SINGLE_SELECTION: { - let firstCorrectAnswerIdx = answers.findIndex(answer => answer.correct === true); - if (firstCorrectAnswerIdx === -1) { - firstCorrectAnswerIdx = 0; - } - - const newAnswers = answersCopy.map(answer => { - answer.correct = false; - return answer; - }); - - newAnswers[firstCorrectAnswerIdx].correct = true; - - return newAnswers; - } - } -} - export function isImportedContent(node) { return Boolean( node && node.original_source_node_id && node.node_id !== node.original_source_node_id, @@ -162,14 +34,6 @@ export function importedChannelLink(node, router) { } } -// AssessmentItems are referenced by `[contentnode, assessment_id]` -export function assessmentItemKey(assessmentItem) { - return { - contentnode: assessmentItem.contentnode, - assessment_id: assessmentItem.assessment_id, - }; -} - /** * Converts a value in seconds to a human-readable format. * If the value is greater than or equal to one hour, the format will be hh:mm:ss. diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/assessmentItem/__tests__/getters.spec.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/assessmentItem/__tests__/getters.spec.js index 3155fdbc00..7222701222 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/assessmentItem/__tests__/getters.spec.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/assessmentItem/__tests__/getters.spec.js @@ -5,7 +5,22 @@ import { getInvalidAssessmentItemsCount, getAssessmentItemsAreValid, } from '../getters'; -import { AssessmentItemTypes, DELAYED_VALIDATION, ValidationErrors } from 'shared/constants'; +import { AssessmentItemTypes, ContentModalities } from 'shared/constants'; +import { ValidationError } from 'shared/views/QTIEditor/constants'; +import { + VALID_CHOICE_ITEM_DOCUMENT, + CHOICE_ITEM_DOCUMENT_NO_PROMPT, + CHOICE_ITEM_DOCUMENT_NO_CORRECT_ANSWER, + FREE_RESPONSE_ITEM_DOCUMENT, +} from 'shared/views/QTIEditor/utils/testingFixtures'; + +const item = (assessment_id, contentnode, raw_data, extra = {}) => ({ + assessment_id, + contentnode, + type: AssessmentItemTypes.QTI, + raw_data, + ...extra, +}); describe('assessmentItem getters', () => { let state; @@ -15,72 +30,44 @@ describe('assessmentItem getters', () => { state = { assessmentItemsMap: { 'content-node-id-1': { - 'assessment-id-1': { - assessment_id: 'assessment-id-1', - contentnode: 'content-node-id-1', - type: AssessmentItemTypes.SINGLE_SELECTION, - question: '1+1=?', - answers: [ - { - answer: '2', - correct: false, - order: 1, - }, - { - answer: '11', - correct: true, - order: 2, - }, - ], - }, + 'assessment-id-1': item( + 'assessment-id-1', + 'content-node-id-1', + VALID_CHOICE_ITEM_DOCUMENT, + ), }, 'content-node-id-2': { - 'assessment-id-2': { - assessment_id: 'assessment-id-2', - contentnode: 'content-node-id-2', - type: AssessmentItemTypes.SINGLE_SELECTION, - question: '', - answers: [], - order: 1, - }, - 'assessment-id-3': { - assessment_id: 'assessment-id-3', - contentnode: 'content-node-id-2', - [DELAYED_VALIDATION]: true, - type: AssessmentItemTypes.SINGLE_SELECTION, - question: 'What color are minions?', - answers: [ - { - answer: 'Blue', - correct: false, - order: 1, - }, - { - answer: 'Yellow', - correct: false, - order: 2, - }, - ], - order: 2, - }, + 'assessment-id-2': item( + 'assessment-id-2', + 'content-node-id-2', + CHOICE_ITEM_DOCUMENT_NO_PROMPT, + { order: 1 }, + ), + 'assessment-id-3': item( + 'assessment-id-3', + 'content-node-id-2', + CHOICE_ITEM_DOCUMENT_NO_CORRECT_ANSWER, + { order: 2 }, + ), }, 'content-node-id-3': { - 'assessment-id-4': { - assessment_id: 'assessment-id-4', - contentnode: 'content-node-id-3', - [DELAYED_VALIDATION]: true, - type: AssessmentItemTypes.SINGLE_SELECTION, - question: '', - answers: [], - }, - 'assessment-id-5': { - assessment_id: 'assessment-id-5', - contentnode: 'content-node-id-3', - [DELAYED_VALIDATION]: true, - type: AssessmentItemTypes.SINGLE_SELECTION, - question: '', - answers: [], - }, + 'assessment-id-4': item( + 'assessment-id-4', + 'content-node-id-3', + CHOICE_ITEM_DOCUMENT_NO_PROMPT, + ), + 'assessment-id-5': item( + 'assessment-id-5', + 'content-node-id-3', + CHOICE_ITEM_DOCUMENT_NO_PROMPT, + ), + }, + 'content-node-id-survey': { + 'assessment-id-6': item( + 'assessment-id-6', + 'content-node-id-survey', + FREE_RESPONSE_ITEM_DOCUMENT, + ), }, }, }; @@ -89,45 +76,26 @@ describe('assessmentItem getters', () => { 'contentNode/getContentNode': id => ({ id, kind: 'exercise', + extra_fields: + id === 'content-node-id-survey' + ? { options: { modality: ContentModalities.SURVEY } } + : {}, }), }; }); + const errorsFor = (contentNodeId, options = {}) => + getAssessmentItemsErrors(state, {}, {}, rootGetters)({ contentNodeId, ...options }); + describe('getAssessmentItems', () => { it('returns an empty array if a content node not found', () => { expect(getAssessmentItems(state)('content-node-id-4')).toEqual([]); }); it('returns an array of assessment items belonging to a content node', () => { - expect(getAssessmentItems(state)('content-node-id-2')).toEqual([ - { - assessment_id: 'assessment-id-2', - contentnode: 'content-node-id-2', - type: AssessmentItemTypes.SINGLE_SELECTION, - question: '', - answers: [], - order: 1, - }, - { - assessment_id: 'assessment-id-3', - contentnode: 'content-node-id-2', - [DELAYED_VALIDATION]: true, - type: AssessmentItemTypes.SINGLE_SELECTION, - question: 'What color are minions?', - answers: [ - { - answer: 'Blue', - correct: false, - order: 1, - }, - { - answer: 'Yellow', - correct: false, - order: 2, - }, - ], - order: 2, - }, + expect(getAssessmentItems(state)('content-node-id-2').map(i => i.assessment_id)).toEqual([ + 'assessment-id-2', + 'assessment-id-3', ]); }); }); @@ -144,38 +112,24 @@ describe('assessmentItem getters', () => { describe('getAssessmentItemsErrors', () => { it('returns validation codes corresponding to invalid assessment items of a content node', () => { - expect( - getAssessmentItemsErrors( - state, - {}, - {}, - rootGetters, - )({ contentNodeId: 'content-node-id-2' }), - ).toEqual({ - 'assessment-id-2': [ - ValidationErrors.QUESTION_REQUIRED, - ValidationErrors.INVALID_NUMBER_OF_CORRECT_ANSWERS, - ], - 'assessment-id-3': [ValidationErrors.INVALID_NUMBER_OF_CORRECT_ANSWERS], + expect(errorsFor('content-node-id-2')).toEqual({ + 'assessment-id-2': [{ code: ValidationError.PROMPT_REQUIRED }], + 'assessment-id-3': [{ code: ValidationError.NO_CORRECT_ANSWER }], }); }); - it("doesn't include invalid nodes errors that are new if `ignoreDelayed` set to true", () => { - expect( - getAssessmentItemsErrors( - state, - {}, - {}, - rootGetters, - )({ contentNodeId: 'content-node-id-2', ignoreDelayed: true }), - ).toEqual({ - 'assessment-id-2': [ - ValidationErrors.QUESTION_REQUIRED, - ValidationErrors.INVALID_NUMBER_OF_CORRECT_ANSWERS, - ], - 'assessment-id-3': [], + it('rejects a free-response question on a node that is not a survey', () => { + state.assessmentItemsMap['content-node-id-1']['assessment-id-1'].raw_data = + FREE_RESPONSE_ITEM_DOCUMENT; + + expect(errorsFor('content-node-id-1')['assessment-id-1']).toContainEqual({ + code: ValidationError.FREE_RESPONSE_NOT_ALLOWED, }); }); + + it('accepts a free-response question on a survey', () => { + expect(errorsFor('content-node-id-survey')).toEqual({ 'assessment-id-6': [] }); + }); }); describe('getInvalidAssessmentItemsCount', () => { @@ -190,17 +144,18 @@ describe('assessmentItem getters', () => { ).toBe(2); }); - it("doesn't count invalid nodes that are new if `ignoreDelayed` set to true", () => { + it('counts an item the author has only just added like any other', () => { + state.assessmentItemsMap['content-node-id-3'] = { + 'assessment-id-7': item('assessment-id-7', 'content-node-id-3', ''), + }; + expect( getInvalidAssessmentItemsCount( state, {}, {}, rootGetters, - )({ - contentNodeId: 'content-node-id-2', - ignoreDelayed: true, - }), + )({ contentNodeId: 'content-node-id-3' }), ).toBe(1); }); }); @@ -228,18 +183,15 @@ describe('assessmentItem getters', () => { ).toBe(false); }); - it('returns true if all assessment items are not valid and marked as new if `ignoreDelayed` set to true', () => { + it('returns false when every assessment item of a content node is invalid', () => { expect( getAssessmentItemsAreValid( state, {}, {}, rootGetters, - )({ - contentNodeId: 'content-node-id-4', - ignoreDelayed: true, - }), - ).toBe(true); + )({ contentNodeId: 'content-node-id-3' }), + ).toBe(false); }); }); }); diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/assessmentItem/__tests__/mutations.spec.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/assessmentItem/__tests__/mutations.spec.js index 07c5ab28cb..77786a6082 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/assessmentItem/__tests__/mutations.spec.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/assessmentItem/__tests__/mutations.spec.js @@ -1,6 +1,14 @@ import { UPDATE_ASSESSMENTITEM, DELETE_ASSESSMENTITEM } from '../mutations'; import { AssessmentItemTypes } from 'shared/constants'; +const item = (assessment_id, contentnode, extra = {}) => ({ + assessment_id, + contentnode, + type: AssessmentItemTypes.QTI, + raw_data: `${assessment_id}`, + ...extra, +}); + describe('assessmentItem mutations', () => { let state; @@ -8,223 +16,67 @@ describe('assessmentItem mutations', () => { state = { assessmentItemsMap: { 'content-node-id-1': { - 'assessment-id-1': { - assessment_id: 'assessment-id-1', - contentnode: 'content-node-id-1', - type: AssessmentItemTypes.SINGLE_SELECTION, - question: '1+1=?', - answers: [ - { - answer: '2', - correct: false, - order: 1, - }, - { - answer: '11', - correct: true, - order: 2, - }, - ], - hints: [], - }, + 'assessment-id-1': item('assessment-id-1', 'content-node-id-1'), }, 'content-node-id-2': { - 'assessment-id-2': { - assessment_id: 'assessment-id-2', - contentnode: 'content-node-id-2', - type: AssessmentItemTypes.SINGLE_SELECTION, - question: '', - answers: [], - hints: [], - }, - 'assessment-id-3': { - assessment_id: 'assessment-id-3', - contentnode: 'content-node-id-2', - type: AssessmentItemTypes.SINGLE_SELECTION, - question: 'What color are minions?', - answers: [ - { - answer: 'Blue', - correct: false, - order: 1, - }, - { - answer: 'Yellow', - correct: true, - order: 2, - }, - ], - hints: [], - }, + 'assessment-id-2': item('assessment-id-2', 'content-node-id-2'), }, }, }; }); describe('UPDATE_ASSESSMENTITEM', () => { - it('adds a new assessment item, parses and sorts answers and hints', () => { - UPDATE_ASSESSMENTITEM(state, { - assessment_id: 'assessment-id-4', - contentnode: 'content-node-id-1', - type: AssessmentItemTypes.SINGLE_SELECTION, - question: 'Question', - answers: JSON.stringify([ - { - answer: 'Answer 2', - correct: false, - order: 2, - }, - { - answer: 'Answer 1', - correct: true, - order: 1, - }, - ]), - hints: JSON.stringify([ - { - answer: 'Hint 2', - order: 2, - }, - { - answer: 'Hint 1', - order: 1, - }, - ]), + it('throws if the item cannot be identified', () => { + expect(() => UPDATE_ASSESSMENTITEM(state, { contentnode: 'content-node-id-1' })).toThrow( + ReferenceError, + ); + expect(() => UPDATE_ASSESSMENTITEM(state, { assessment_id: 'assessment-id-9' })).toThrow( + ReferenceError, + ); + }); + + it('adds an assessment item to a content node that has some already', () => { + const newItem = item('assessment-id-3', 'content-node-id-1'); + + UPDATE_ASSESSMENTITEM(state, newItem); + + expect(state.assessmentItemsMap['content-node-id-1']).toEqual({ + 'assessment-id-1': item('assessment-id-1', 'content-node-id-1'), + 'assessment-id-3': newItem, }); + }); - expect(state.assessmentItemsMap['content-node-id-1']['assessment-id-4']).toEqual({ - assessment_id: 'assessment-id-4', - contentnode: 'content-node-id-1', - type: AssessmentItemTypes.SINGLE_SELECTION, - question: 'Question', - answers: [ - { - answer: 'Answer 1', - correct: true, - order: 1, - }, - { - answer: 'Answer 2', - correct: false, - order: 2, - }, - ], - hints: [ - { - answer: 'Hint 1', - order: 1, - }, - { - answer: 'Hint 2', - order: 2, - }, - ], + it('adds an assessment item to a content node with none yet', () => { + const newItem = item('assessment-id-4', 'content-node-id-3'); + + UPDATE_ASSESSMENTITEM(state, newItem); + + expect(state.assessmentItemsMap['content-node-id-3']).toEqual({ + 'assessment-id-4': newItem, }); }); - it('updates an assessment item, parses and sorts answers and hints', () => { + it('merges the given fields into an existing assessment item', () => { UPDATE_ASSESSMENTITEM(state, { - assessment_id: 'assessment-id-3', - contentnode: 'content-node-id-2', - type: AssessmentItemTypes.SINGLE_SELECTION, - question: 'What color are minions?', - answers: JSON.stringify([ - { - answer: 'Blue', - correct: false, - order: 3, - }, - { - answer: 'Yellow', - correct: true, - order: 1, - }, - { - answer: 'Red', - correct: false, - order: 2, - }, - ]), - hints: JSON.stringify([ - { - answer: 'Not red', - order: 2, - }, - { - answer: 'Not blue', - order: 1, - }, - ]), + assessment_id: 'assessment-id-1', + contentnode: 'content-node-id-1', + raw_data: 'edited', }); - expect(state.assessmentItemsMap['content-node-id-2']['assessment-id-3']).toEqual({ - assessment_id: 'assessment-id-3', - contentnode: 'content-node-id-2', - type: AssessmentItemTypes.SINGLE_SELECTION, - question: 'What color are minions?', - answers: [ - { - answer: 'Yellow', - correct: true, - order: 1, - }, - { - answer: 'Red', - correct: false, - order: 2, - }, - { - answer: 'Blue', - correct: false, - order: 3, - }, - ], - hints: [ - { - answer: 'Not blue', - order: 1, - }, - { - answer: 'Not red', - order: 2, - }, - ], - }); + expect(state.assessmentItemsMap['content-node-id-1']['assessment-id-1']).toEqual( + item('assessment-id-1', 'content-node-id-1', { raw_data: 'edited' }), + ); }); }); describe('DELETE_ASSESSMENTITEM', () => { it('removes an assessment item', () => { DELETE_ASSESSMENTITEM(state, { - assessment_id: 'assessment-id-3', - contentnode: 'content-node-id-2', - type: AssessmentItemTypes.SINGLE_SELECTION, - question: 'What color are minions?', - answers: [ - { - answer: 'Blue', - correct: false, - order: 1, - }, - { - answer: 'Yellow', - correct: true, - order: 2, - }, - ], - hints: [], + assessment_id: 'assessment-id-1', + contentnode: 'content-node-id-1', }); - expect(state.assessmentItemsMap['content-node-id-2']).toEqual({ - 'assessment-id-2': { - assessment_id: 'assessment-id-2', - contentnode: 'content-node-id-2', - type: AssessmentItemTypes.SINGLE_SELECTION, - question: '', - answers: [], - hints: [], - }, - }); + expect(state.assessmentItemsMap['content-node-id-1']).toEqual({}); }); }); }); diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/assessmentItem/actions.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/assessmentItem/actions.js index e26d2b8763..07a0fdc9d6 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/assessmentItem/actions.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/assessmentItem/actions.js @@ -50,18 +50,12 @@ export function loadAssessmentItems(context, params = {}) { } export function addAssessmentItem(context, assessmentItem) { - // API accepts answers and hints as strings - const stringifiedAssessmentItem = { - ...assessmentItem, - answers: JSON.stringify(assessmentItem.answers || []), - hints: JSON.stringify(assessmentItem.hints || []), - }; - + // Questions are authored as QTI, whose content lives in raw_data. return db.transaction( 'rw', [TABLE_NAMES.CONTENTNODE, TABLE_NAMES.ASSESSMENTITEM, TABLE_NAMES.CHANGES_TABLE], () => { - return AssessmentItem.add(stringifiedAssessmentItem).then(([contentnode, assessment_id]) => { + return AssessmentItem.add(assessmentItem).then(([contentnode, assessment_id]) => { context.commit('UPDATE_ASSESSMENTITEM', { ...assessmentItem, contentnode, @@ -91,19 +85,9 @@ export function updateAssessmentItems(context, assessmentItems) { () => { return Promise.all( assessmentItems.map(assessmentItem => { - // API accepts answers and hints as strings - const stringifiedAssessmentItem = { - ...assessmentItem, - }; - if (assessmentItem.answers) { - stringifiedAssessmentItem.answers = JSON.stringify(assessmentItem.answers); - } - if (assessmentItem.hints) { - stringifiedAssessmentItem.hints = JSON.stringify(assessmentItem.hints); - } return AssessmentItem.update( [assessmentItem.contentnode, assessmentItem.assessment_id], - stringifiedAssessmentItem, + assessmentItem, ).then(() => { updateNodeComplete(assessmentItem.contentnode, context); }); diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/assessmentItem/getters.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/assessmentItem/getters.js index 5c454113f7..63b74e0641 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/assessmentItem/getters.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/assessmentItem/getters.js @@ -1,4 +1,4 @@ -import { AssessmentItemTypes, ContentModalities, DELAYED_VALIDATION } from 'shared/constants'; +import { ContentModalities } from 'shared/constants'; import { getAssessmentItemErrors } from 'shared/utils/validation'; /** * Get assessment items of a node. @@ -25,10 +25,9 @@ export function getAssessmentItemsCount(state) { /** * Get a map of assessment items errors where keys are assessment ids. - * Consider new assessment items as valid if `ignoreDelayed` is true. */ export function getAssessmentItemsErrors(state, getters, rootState, rootGetters) { - return function ({ contentNodeId, ignoreDelayed = false }) { + return function ({ contentNodeId }) { const assessmentItemsErrors = {}; const contentNode = rootGetters['contentNode/getContentNode'](contentNodeId); @@ -37,20 +36,15 @@ export function getAssessmentItemsErrors(state, getters, rootState, rootGetters) if (!state.assessmentItemsMap || !state.assessmentItemsMap[contentNodeId]) { return assessmentItemsErrors; } + // Free-response questions cannot be scored, so they only make sense on a survey. + const allowFreeResponse = modality === ContentModalities.SURVEY; + Object.keys(state.assessmentItemsMap[contentNodeId]).forEach(assessmentItemId => { const assessmentItem = state.assessmentItemsMap[contentNodeId][assessmentItemId]; - const freeResponseInvalid = - modality !== ContentModalities.SURVEY && - assessmentItem.type === AssessmentItemTypes.FREE_RESPONSE; - if (ignoreDelayed && assessmentItem[DELAYED_VALIDATION]) { - assessmentItemsErrors[assessmentItemId] = []; - } else { - assessmentItemsErrors[assessmentItemId] = getAssessmentItemErrors( - assessmentItem, - freeResponseInvalid, - ); - } + assessmentItemsErrors[assessmentItemId] = getAssessmentItemErrors(assessmentItem, { + allowFreeResponse, + }); }); return assessmentItemsErrors; }; @@ -58,20 +52,16 @@ export function getAssessmentItemsErrors(state, getters, rootState, rootGetters) /** * Get total number of invalid assessment items of a node. - * Consider new assessment items as valid if `ignoreDelayed` is true. */ export function getInvalidAssessmentItemsCount(state, getters, rootState, rootGetters) { - return function ({ contentNodeId, ignoreDelayed = false }) { + return function ({ contentNodeId }) { let count = 0; const assessmentItemsErrors = getAssessmentItemsErrors( state, getters, rootState, rootGetters, - )({ - contentNodeId, - ignoreDelayed, - }); + )({ contentNodeId }); for (const assessmentItemId in assessmentItemsErrors) { if (assessmentItemsErrors[assessmentItemId].length) { @@ -85,17 +75,12 @@ export function getInvalidAssessmentItemsCount(state, getters, rootState, rootGe /** * Are all assessment items of a node valid? - * Consider new assessment items as valid if `ignoreDelayed` is true. */ export function getAssessmentItemsAreValid(state, getters, rootState, rootGetters) { - return function ({ contentNodeId, ignoreDelayed = false }) { + return function ({ contentNodeId }) { return ( - getInvalidAssessmentItemsCount( - state, - getters, - rootState, - rootGetters, - )({ contentNodeId, ignoreDelayed }) === 0 + getInvalidAssessmentItemsCount(state, getters, rootState, rootGetters)({ contentNodeId }) === + 0 ); }; } diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/assessmentItem/mutations.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/assessmentItem/mutations.js index 3962c00a3b..10a325b39f 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/assessmentItem/mutations.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/assessmentItem/mutations.js @@ -10,30 +10,6 @@ export function UPDATE_ASSESSMENTITEM(state, assessmentItem) { throw ReferenceError('contentnode must be defined to update an assessment item'); } - // data can come from API that returns answers and hints as string - let answers, hints; - if (typeof assessmentItem.answers === 'string') { - answers = JSON.parse(assessmentItem.answers); - } else { - answers = assessmentItem.answers ? assessmentItem.answers : null; - } - - if (answers) { - answers.sort((answer1, answer2) => (answer1.order > answer2.order ? 1 : -1)); - assessmentItem.answers = answers; - } - - if (typeof assessmentItem.hints === 'string') { - hints = JSON.parse(assessmentItem.hints); - } else { - hints = assessmentItem.hints ? assessmentItem.hints : null; - } - - if (hints) { - hints.sort((hint1, hint2) => (hint1.order > hint2.order ? 1 : -1)); - assessmentItem.hints = hints; - } - set( state.assessmentItemsMap, assessmentItem.contentnode, diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/contentNode/getters.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/contentNode/getters.js index 3e31ce6ed3..6c754cf4cb 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/contentNode/getters.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/contentNode/getters.js @@ -144,14 +144,7 @@ export function getContentNodeIsValid(state, getters, rootState, rootGetters) { (contentNode[NEW_OBJECT] || (getContentNodeDetailsAreValid(state)(contentNodeId) && getContentNodeFilesAreValid(state, getters, rootState, rootGetters)(contentNodeId) && - rootGetters['assessmentItem/getAssessmentItemsAreValid']({ - contentNodeId, - // Because this is called after items have been created, - // and it is not used within a form to run field validations, - // it's okay to set this to false. This also accounts for - // any async delays with the node creation - ignoreDelayed: false, - }))) + rootGetters['assessmentItem/getAssessmentItemsAreValid']({ contentNodeId }))) ); }; } diff --git a/contentcuration/contentcuration/frontend/shared/constants.js b/contentcuration/contentcuration/frontend/shared/constants.js index d08a6d803c..909f79d99e 100644 --- a/contentcuration/contentcuration/frontend/shared/constants.js +++ b/contentcuration/contentcuration/frontend/shared/constants.js @@ -52,10 +52,6 @@ export const NOVALUE = Symbol('No value default'); // that they have not yet been committed to our IndexedDB layer. export const NEW_OBJECT = Symbol('New object'); -// This symbol is used as a key on new objects used to denote when -// validation should be delayed -export const DELAYED_VALIDATION = Symbol('Delayed validation'); - export const kindToIconMap = { audio: 'headset', channel: 'apps', @@ -153,12 +149,8 @@ export const ErrorTypes = Object.freeze({ // should correspond to backend types export const AssessmentItemTypes = { - SINGLE_SELECTION: 'single_selection', - MULTIPLE_SELECTION: 'multiple_selection', - TRUE_FALSE: 'true_false', - INPUT_QUESTION: 'input_question', + QTI: 'QTI', PERSEUS_QUESTION: 'perseus_question', - FREE_RESPONSE: 'free_response', }; export const ValidationErrors = { @@ -174,10 +166,6 @@ export const ValidationErrors = { MASTERY_MODEL_N_REQUIRED: 'MASTERY_MODEL_N_REQUIRED', MASTERY_MODEL_N_WHOLE_NUMBER: 'MASTERY_MODEL_N_WHOLE_NUMBER', MASTERY_MODEL_N_GT_ZERO: 'MASTERY_MODEL_N_GT_ZERO', - QUESTION_REQUIRED: 'QUESTION_REQUIRED', - INVALID_NUMBER_OF_CORRECT_ANSWERS: 'INVALID_NUMBER_OF_CORRECT_ANSWERS', - INVALID_COMPLETION_TYPE_FOR_FREE_RESPONSE_QUESTION: - 'INVALID_COMPLETION_TYPE_FOR_FREE_RESPONSE_QUESTION', NO_VALID_PRIMARY_FILES: 'NO_VALID_PRIMARY_FILES', INVALID_COMPLETION_CRITERIA_MODEL: 'INVALID_COMPLETION_CRITERIA_MODEL', COMPLETION_REQUIRED: 'COMPLETION_REQUIRED', diff --git a/contentcuration/contentcuration/frontend/shared/data/resources.js b/contentcuration/contentcuration/frontend/shared/data/resources.js index 2a458cd2a3..0f4388bcbc 100644 --- a/contentcuration/contentcuration/frontend/shared/data/resources.js +++ b/contentcuration/contentcuration/frontend/shared/data/resources.js @@ -47,7 +47,7 @@ import { import urls from 'shared/urls'; import { currentLanguage } from 'shared/i18n'; import client, { paramsSerializer } from 'shared/client'; -import { DELAYED_VALIDATION, fileErrors, NEW_OBJECT } from 'shared/constants'; +import { fileErrors, NEW_OBJECT } from 'shared/constants'; import { ContentKindsNames } from 'shared/leUtils/ContentKinds'; import { getMergedMapFields } from 'shared/utils/helpers'; @@ -606,8 +606,7 @@ class IndexedDBResource { } /** - * Method to remove the NEW_OBJECT and DELAYED_VALIDATION symbols - * property so we don't commit it to IndexedDB + * Method to remove the NEW_OBJECT symbol property so we don't commit it to IndexedDB * @param {Object} obj * @return {Object} */ @@ -616,7 +615,6 @@ class IndexedDBResource { ...obj, }; delete out[NEW_OBJECT]; - delete out[DELAYED_VALIDATION]; return out; } diff --git a/contentcuration/contentcuration/frontend/shared/utils/helpers.js b/contentcuration/contentcuration/frontend/shared/utils/helpers.js index 2545b91c85..8e1c4c0053 100644 --- a/contentcuration/contentcuration/frontend/shared/utils/helpers.js +++ b/contentcuration/contentcuration/frontend/shared/utils/helpers.js @@ -19,49 +19,6 @@ function safeParseInt(str) { const EXTENDED_SLOT = '__extendedSlot'; -/** - * Insert an item into an array before another item. - * @param {Array} arr - * @param {Number} idx An index of an item before which - * a new item will be inserted. - * @param {*} item A new item to be inserted into an array. - */ -export function insertBefore(arr, idx, item) { - const newArr = JSON.parse(JSON.stringify(arr)); - const insertAt = Math.max(0, idx); - newArr.splice(insertAt, 0, item); - - return newArr; -} - -/** - * Insert an item into an array after another item. - * @param {Array} arr - * @param {Number} idx An index of an item after which - * a new item will be inserted. - * @param {*} item A new item to be inserted into an array. - */ -export function insertAfter(arr, idx, item) { - const newArr = JSON.parse(JSON.stringify(arr)); - const insertAt = Math.min(arr.length, idx + 1); - newArr.splice(insertAt, 0, item); - - return newArr; -} - -/** - * Swap two elements of an array - * @param {Array} arr - * @param {Number} idx1 - * @param {Number} idx2 - */ -export function swapElements(arr, idx1, idx2) { - const newArr = JSON.parse(JSON.stringify(arr)); - [newArr[idx1], newArr[idx2]] = [newArr[idx2], newArr[idx1]]; - - return newArr; -} - /** * Chunks an array of `things`, calling `callback` with `chunkSize` amount of items, * expecting callback to return `Promise` that when resolved will allow next chunk to be processed. diff --git a/contentcuration/contentcuration/frontend/shared/utils/helpers.spec.js b/contentcuration/contentcuration/frontend/shared/utils/helpers.spec.js index 5d01b4026a..d16a1399e7 100644 --- a/contentcuration/contentcuration/frontend/shared/utils/helpers.spec.js +++ b/contentcuration/contentcuration/frontend/shared/utils/helpers.spec.js @@ -2,40 +2,7 @@ import Vue from 'vue'; import { mount } from '@vue/test-utils'; -import each from 'jest-each'; - -import { insertBefore, insertAfter, swapElements, extendSlot } from './helpers'; - -describe('insertBefore', () => { - each([ - [[], 0, 'pink', ['pink']], - [['blue', 'yellow', 'violet'], -1, 'pink', ['pink', 'blue', 'yellow', 'violet']], - [['blue', 'yellow', 'violet'], 0, 'pink', ['pink', 'blue', 'yellow', 'violet']], - [['blue', 'yellow', 'violet'], 1, 'pink', ['blue', 'pink', 'yellow', 'violet']], - ]).it('inserts a new item before another item', (arr, idx, item, expected) => { - expect(insertBefore(arr, idx, item)).toEqual(expected); - }); -}); - -describe('insertAfter', () => { - each([ - [[], 2, 'pink', ['pink']], - [['blue', 'yellow', 'violet'], 3, 'pink', ['blue', 'yellow', 'violet', 'pink']], - [['blue', 'yellow', 'violet'], 2, 'pink', ['blue', 'yellow', 'violet', 'pink']], - [['blue', 'yellow', 'violet'], 1, 'pink', ['blue', 'yellow', 'pink', 'violet']], - ]).it('inserts a new item after another item', (arr, idx, item, expected) => { - expect(insertAfter(arr, idx, item)).toEqual(expected); - }); -}); - -describe('swapElements', () => { - each([ - [['blue', 'yellow', 'violet'], 0, 0, ['blue', 'yellow', 'violet']], - [['blue', 'yellow', 'violet'], 0, 2, ['violet', 'yellow', 'blue']], - ]).it('swaps two elements', (arr, idx1, idx2, expected) => { - expect(swapElements(arr, idx1, idx2)).toEqual(expected); - }); -}); +import { extendSlot } from './helpers'; describe('extendSlot', () => { // Component that implements extendSlot functionality diff --git a/contentcuration/contentcuration/frontend/shared/utils/validation.js b/contentcuration/contentcuration/frontend/shared/utils/validation.js index e576d678e5..5675841d52 100644 --- a/contentcuration/contentcuration/frontend/shared/utils/validation.js +++ b/contentcuration/contentcuration/frontend/shared/utils/validation.js @@ -2,6 +2,7 @@ import get from 'lodash/get'; import CompletionCriteriaModels from 'kolibri-constants/CompletionCriteria'; import translator from '../translator'; import { AssessmentItemTypes, ValidationErrors, ContentModalities } from '../constants'; +import { validateQtiItem } from 'shared/views/QTIEditor/validateItem'; import Licenses from 'shared/leUtils/Licenses'; import { MasteryModelsNames } from 'shared/leUtils/MasteryModels'; import { ContentKindsNames } from 'shared/leUtils/ContentKinds'; @@ -90,10 +91,7 @@ export function isNodeComplete({ nodeDetails, assessmentItems, files }) { return false; } - const isInvalid = assessmentItem => { - const sanitizedAssessmentItem = sanitizeAssessmentItem(assessmentItem, true); - return getAssessmentItemErrors(sanitizedAssessmentItem).length; - }; + const isInvalid = assessmentItem => getAssessmentItemErrors(assessmentItem).length; if (assessmentItems.some(isInvalid)) { if (process.env.NODE_ENV !== 'production' && process.env.NODE_ENV !== 'test') { // eslint-disable-next-line no-console @@ -432,151 +430,50 @@ export function getNodeFilesErrors(files) { } /** - * Sanitize assesment item answers - * - trim answers - * - (optional) remove empty answers - * @param {Array} answers Assessment item answers - * @param {Boolean} removeEmpty Remove all empty answers? - * @returns {Array} Cleaned answers - */ -export function sanitizeAssessmentItemAnswers(answers, removeEmpty = false) { - if (!answers || !answers.length) { - return []; - } - - let sanitizedAnswers = answers.map(answer => { - let answerText = answer.answer; - if (typeof answerText !== 'number') { - answerText = answerText ? answerText.trim() : ''; - } - - return { - ...answer, - answer: answerText, - }; - }); - - if (removeEmpty) { - sanitizedAnswers = sanitizedAnswers.filter(answer => answer.answer.length > 0); - } - - sanitizedAnswers = sanitizedAnswers.map((answer, answerIdx) => { - return { - ...answer, - order: answerIdx + 1, - }; - }); - - return sanitizedAnswers; -} - -/** - * Sanitize assesment item hints - * - trim hints - * - (optional) remove empty hints - * @param {Array} hints Assessment item hints - * @param {Boolean} removeEmpty Remove all empty hints? - * @returns {Array} Cleaned hints - */ -export function sanitizeAssessmentItemHints(hints, removeEmpty = false) { - if (!hints || !hints.length) { - return []; - } - - let sanitizedHints = hints.map(hint => { - const hintText = hint.hint ? hint.hint.trim() : ''; - - return { - ...hint, - hint: hintText, - }; - }); - - if (removeEmpty) { - sanitizedHints = sanitizedHints.filter(hint => hint.hint.length > 0); - } - - sanitizedHints = sanitizedHints.map((hint, hintIdx) => { - return { - ...hint, - order: hintIdx + 1, - }; - }); - - return sanitizedHints; -} - -/** - * Sanitize an assesment item - * - trim question text - * - sanitize answers and hints - * @param {Array} assessmentItem An assessment item - * @param {Boolean} removeEmpty Remove empty answers and hints? - * @returns {Array} Cleaned assessment item + * The last verdict reached for an item, so that the several places asking whether a node is + * complete — the incomplete-questions banner, the tab icon, the node list, the preview — + * parse each question's XML once between edits rather than once each. + * + * Keyed by the item, so an entry is collected along with the item it describes and there is + * no cache to keep or invalidate by hand. The XML it was read from is kept alongside the + * verdict so that an item edited in place is not answered from a stale reading. + * + * @type {WeakMap} */ -export function sanitizeAssessmentItem(assessmentItem, removeEmpty = false) { - const question = assessmentItem.question ? assessmentItem.question.trim() : ''; - const answers = assessmentItem.answers - ? sanitizeAssessmentItemAnswers(assessmentItem.answers, removeEmpty) - : []; - const hints = assessmentItem.hints - ? sanitizeAssessmentItemHints(assessmentItem.hints, removeEmpty) - : []; - - return { - ...assessmentItem, - question, - answers, - hints, - }; -} +const errorsByAssessmentItem = new WeakMap(); /** * Validate an assessment item. + * + * Questions are authored and stored as QTI, so the QTI editor owns what makes one valid; + * this reads its verdict without rendering anything. Perseus questions come from other + * tools and are not validated here. + * * @param {Object} assessmentItem An assessment item. - * @returns {Array} An array of error codes. + * @param {Object} [options] + * @param {Boolean} [options.allowFreeResponse] Whether free-response questions are + * permitted — they are only meaningful on surveys. + * @returns {Array} An array of errors. */ -export function getAssessmentItemErrors(assessmentItem, freeResponseInvalid = false) { - const errors = []; - - // Don't validate perseus questions +export function getAssessmentItemErrors(assessmentItem, { allowFreeResponse = true } = {}) { if (assessmentItem.type === AssessmentItemTypes.PERSEUS_QUESTION) { - return errors; - } - // Convert answers to string to handle numeric responses - const hasOneCorrectAnswer = - assessmentItem.answers && - assessmentItem.answers.filter( - answer => answer.answer && String(answer.answer).trim() && answer.correct === true, - ).length === 1; - const hasAtLeatOneCorrectAnswer = - assessmentItem.answers && - assessmentItem.answers.filter( - answer => answer.answer && String(answer.answer).trim() && answer.correct === true, - ).length > 0; - - if (!assessmentItem.question || !assessmentItem.question.trim()) { - errors.push(ValidationErrors.QUESTION_REQUIRED); - } - if (freeResponseInvalid) { - errors.push(ValidationErrors.INVALID_COMPLETION_TYPE_FOR_FREE_RESPONSE_QUESTION); + return []; } - switch (assessmentItem.type) { - case AssessmentItemTypes.MULTIPLE_SELECTION: - case AssessmentItemTypes.INPUT_QUESTION: - if (!hasAtLeatOneCorrectAnswer) { - errors.push(ValidationErrors.INVALID_NUMBER_OF_CORRECT_ANSWERS); - } - break; - - case AssessmentItemTypes.TRUE_FALSE: - case AssessmentItemTypes.SINGLE_SELECTION: - if (!hasOneCorrectAnswer) { - errors.push(ValidationErrors.INVALID_NUMBER_OF_CORRECT_ANSWERS); - } - break; + const cached = errorsByAssessmentItem.get(assessmentItem); + if ( + cached && + cached.rawData === assessmentItem.raw_data && + cached.allowFreeResponse === allowFreeResponse + ) { + return cached.errors; } + const errors = validateQtiItem(assessmentItem.raw_data, { allowFreeResponse }); + errorsByAssessmentItem.set(assessmentItem, { + rawData: assessmentItem.raw_data, + allowFreeResponse, + errors, + }); return errors; } diff --git a/contentcuration/contentcuration/frontend/shared/utils/validation.spec.js b/contentcuration/contentcuration/frontend/shared/utils/validation.spec.js index 11e2fe367f..05b0dadf27 100644 --- a/contentcuration/contentcuration/frontend/shared/utils/validation.spec.js +++ b/contentcuration/contentcuration/frontend/shared/utils/validation.spec.js @@ -14,12 +14,16 @@ import { isNodeComplete, getNodeDetailsErrors, getNodeFilesErrors, - sanitizeAssessmentItemAnswers, - sanitizeAssessmentItemHints, - sanitizeAssessmentItem, getAssessmentItemErrors, getNodeLearningActivityErrors, } from './validation'; +import { ValidationError } from 'shared/views/QTIEditor/constants'; +import { + VALID_CHOICE_ITEM_DOCUMENT, + CHOICE_ITEM_DOCUMENT_NO_PROMPT, + CHOICE_ITEM_DOCUMENT_NO_CORRECT_ANSWER, + FREE_RESPONSE_ITEM_DOCUMENT, +} from 'shared/views/QTIEditor/utils/testingFixtures'; import { MasteryModelsNames } from 'shared/leUtils/MasteryModels'; import { ContentKindsNames } from 'shared/leUtils/ContentKinds'; @@ -403,12 +407,8 @@ describe('channelEdit utils', () => { }; assessmentItems = [ { - question: 'Question', - type: AssessmentItemTypes.SINGLE_SELECTION, - answers: [ - { answer: 'Mayonnaise (I mean you can, but...)', correct: true, order: 1 }, - { answer: 'Peanut butter', correct: false, order: 2 }, - ], + type: AssessmentItemTypes.QTI, + raw_data: VALID_CHOICE_ITEM_DOCUMENT, }, ]; }); @@ -436,17 +436,13 @@ describe('channelEdit utils', () => { it('returns false if there is at least one invalid assessment item', () => { const invalidAssessmentItem = { - question: 'A question with missing answers', - type: AssessmentItemTypes.SINGLE_SELECTION, - answers: [], + type: AssessmentItemTypes.QTI, + raw_data: CHOICE_ITEM_DOCUMENT_NO_CORRECT_ANSWER, }; expect( isNodeComplete({ nodeDetails, - assessmentItems: { - ...assessmentItems, - invalidAssessmentItem, - }, + assessmentItems: [...assessmentItems, invalidAssessmentItem], }), ).toBe(false); }); @@ -808,348 +804,69 @@ describe('channelEdit utils', () => { }); }); - describe('sanitizeAssessmentItemAnswers', () => { - it('trims answers', () => { - const answers = [ - { answer: '', order: 1, correct: true }, - { answer: ' 3 ', order: 2, correct: false }, - { answer: ' ', order: 3, correct: true }, - ]; - - expect(sanitizeAssessmentItemAnswers(answers)).toEqual([ - { answer: '', order: 1, correct: true }, - { answer: '3', order: 2, correct: false }, - { answer: '', order: 3, correct: true }, - ]); - }); - - it('removes all empty answers and reorders remaining answers if removeEmpty true', () => { - const answers = [ - { answer: '', order: 1, correct: true }, - { answer: ' 3 ', order: 2, correct: false }, - { answer: ' ', order: 3, correct: true }, - ]; - - expect(sanitizeAssessmentItemAnswers(answers, true)).toEqual([ - { answer: '3', order: 1, correct: false }, - ]); - }); - }); - - describe('sanitizeAssessmentItemHints', () => { - it('trims hints', () => { - const hints = [ - { hint: '', order: 1 }, - { hint: ' Hint 1 ', order: 2 }, - { hint: ' ', order: 3 }, - ]; - - expect(sanitizeAssessmentItemHints(hints)).toEqual([ - { hint: '', order: 1 }, - { hint: 'Hint 1', order: 2 }, - { hint: '', order: 3 }, - ]); - }); - - it('removes all empty hints and reorders remaining hints if removeEmpty true', () => { - const hints = [ - { hint: '', order: 1 }, - { hint: ' Hint 1 ', order: 2 }, - { hint: ' ', order: 3 }, - ]; - - expect(sanitizeAssessmentItemHints(hints, true)).toEqual([{ hint: 'Hint 1', order: 1 }]); - }); - }); - - describe('sanitizeAssessmentItem', () => { - it('trims question, hints and answers', () => { + describe('getAssessmentItemErrors', () => { + it('reports no errors for a complete question', () => { const assessmentItem = { - order: 1, - question: ' Question text ', - answers: [ - { answer: ' Answer 1', order: 1, correct: false }, - { answer: '', order: 2, correct: true }, - { answer: 'Answer 3 ', order: 3, correct: true }, - ], - hints: [ - { hint: ' ', order: 1 }, - { hint: '', order: 2 }, - { hint: ' Hint 3', order: 3 }, - ], + type: AssessmentItemTypes.QTI, + raw_data: VALID_CHOICE_ITEM_DOCUMENT, }; - expect(sanitizeAssessmentItem(assessmentItem)).toEqual({ - order: 1, - question: 'Question text', - answers: [ - { answer: 'Answer 1', order: 1, correct: false }, - { answer: '', order: 2, correct: true }, - { answer: 'Answer 3', order: 3, correct: true }, - ], - hints: [ - { hint: '', order: 1 }, - { hint: '', order: 2 }, - { hint: 'Hint 3', order: 3 }, - ], - }); + expect(getAssessmentItemErrors(assessmentItem)).toEqual([]); }); - it('removes all empty hints and answers if removeEmpty true', () => { + it('reports the errors of the question it holds', () => { const assessmentItem = { - order: 1, - question: ' Question text ', - answers: [ - { answer: ' Answer 1', order: 1, correct: false }, - { answer: '', order: 2, correct: true }, - { answer: 'Answer 3 ', order: 3, correct: true }, - ], - hints: [ - { hint: ' ', order: 1 }, - { hint: '', order: 2 }, - { hint: ' Hint 3', order: 3 }, - ], + type: AssessmentItemTypes.QTI, + raw_data: CHOICE_ITEM_DOCUMENT_NO_PROMPT, }; - expect(sanitizeAssessmentItem(assessmentItem, true)).toEqual({ - order: 1, - question: 'Question text', - answers: [ - { answer: 'Answer 1', order: 1, correct: false }, - { answer: 'Answer 3', order: 2, correct: true }, - ], - hints: [{ hint: 'Hint 3', order: 1 }], - }); - }); - }); - - describe('getAssessmentItemErrors', () => { - describe('when question text is missing', () => { - it('returns negative validation results', () => { - const assessmentItem = { - question: '', - answers: [{ answer: 'Answer', correct: true, order: 1 }], - }; - - expect(getAssessmentItemErrors(assessmentItem)).toEqual([ - ValidationErrors.QUESTION_REQUIRED, - ]); - }); - }); - - describe('for single selection with no answers', () => { - it('returns negative validation results', () => { - const assessmentItem = { - question: 'Question', - type: AssessmentItemTypes.SINGLE_SELECTION, - answers: [], - }; - - expect(getAssessmentItemErrors(assessmentItem)).toEqual([ - ValidationErrors.INVALID_NUMBER_OF_CORRECT_ANSWERS, - ]); - }); - }); - - describe('for single selection with no correct answer', () => { - it('returns negative validation results', () => { - const assessmentItem = { - question: 'Question', - type: AssessmentItemTypes.SINGLE_SELECTION, - answers: [{ answer: 'Answer', correct: false, order: 1 }], - }; - - expect(getAssessmentItemErrors(assessmentItem)).toEqual([ - ValidationErrors.INVALID_NUMBER_OF_CORRECT_ANSWERS, - ]); - }); - }); - - describe('for single selection with more correct answers', () => { - it('returns negative validation results', () => { - const assessmentItem = { - question: 'Question', - type: AssessmentItemTypes.SINGLE_SELECTION, - answers: [ - { answer: 'Answer 1', correct: true, order: 1 }, - { answer: 'Answer 2', correct: true, order: 2 }, - ], - }; - - expect(getAssessmentItemErrors(assessmentItem)).toEqual([ - ValidationErrors.INVALID_NUMBER_OF_CORRECT_ANSWERS, - ]); - }); + expect(getAssessmentItemErrors(assessmentItem).map(error => error.code)).toContain( + ValidationError.PROMPT_REQUIRED, + ); }); - describe('for single selection with one correct answer', () => { - it('returns positive validation results', () => { - const assessmentItem = { - question: 'Question', - type: AssessmentItemTypes.SINGLE_SELECTION, - answers: [ - { answer: 'Answer 1', correct: false, order: 1 }, - { answer: 'Answer 2', correct: true, order: 2 }, - ], - }; - - expect(getAssessmentItemErrors(assessmentItem)).toEqual([]); - }); - }); - - describe('for multiple selection with no answers', () => { - it('returns negative validation results', () => { - const assessmentItem = { - question: 'Question', - type: AssessmentItemTypes.MULTIPLE_SELECTION, - answers: [], - }; - - expect(getAssessmentItemErrors(assessmentItem)).toEqual([ - ValidationErrors.INVALID_NUMBER_OF_CORRECT_ANSWERS, - ]); - }); - }); - - describe('for multiple selection with no correct answer', () => { - it('returns negative validation results', () => { - const assessmentItem = { - question: 'Question', - type: AssessmentItemTypes.MULTIPLE_SELECTION, - answers: [ - { answer: 'Answer 1', correct: false, order: 1 }, - { answer: 'Answer 2', correct: false, order: 2 }, - ], - }; - - expect(getAssessmentItemErrors(assessmentItem)).toEqual([ - ValidationErrors.INVALID_NUMBER_OF_CORRECT_ANSWERS, - ]); - }); - }); - - describe('for multiple selection with at least one correct answer', () => { - it('returns positive validation results', () => { - const assessmentItem = { - question: 'Question', - type: AssessmentItemTypes.MULTIPLE_SELECTION, - answers: [ - { answer: 'Answer 1', correct: true, order: 1 }, - { answer: 'Answer 2', correct: false, order: 2 }, - ], - }; - - expect(getAssessmentItemErrors(assessmentItem)).toEqual([]); - }); - }); - - describe('for input question with no answers', () => { - it('returns negative validation results', () => { - const assessmentItem = { - question: 'Question', - type: AssessmentItemTypes.INPUT_QUESTION, - answers: [], - }; - - expect(getAssessmentItemErrors(assessmentItem)).toEqual([ - ValidationErrors.INVALID_NUMBER_OF_CORRECT_ANSWERS, - ]); - }); - }); - - describe('for input question with no correct answer', () => { - it('returns negative validation results', () => { - const assessmentItem = { - question: 'Question', - type: AssessmentItemTypes.INPUT_QUESTION, - answers: [ - { answer: 'Answer 1', correct: false, order: 1 }, - { answer: 'Answer 2', correct: false, order: 2 }, - ], - }; + it('reports no errors for a Perseus question, which is authored elsewhere', () => { + const assessmentItem = { + type: AssessmentItemTypes.PERSEUS_QUESTION, + raw_data: 'not qti at all', + }; - expect(getAssessmentItemErrors(assessmentItem)).toEqual([ - ValidationErrors.INVALID_NUMBER_OF_CORRECT_ANSWERS, - ]); - }); + expect(getAssessmentItemErrors(assessmentItem)).toEqual([]); }); - describe('for input question with at least one correct answer', () => { - it('returns positive validation results', () => { - const assessmentItem = { - question: 'Question', - type: AssessmentItemTypes.INPUT_QUESTION, - answers: [ - { answer: 'Answer 1', correct: true, order: 1 }, - { answer: 'Answer 2', correct: true, order: 2 }, - ], - }; - - expect(getAssessmentItemErrors(assessmentItem)).toEqual([]); - }); - }); - - describe('for true/false with no answers', () => { - it('returns negative validation results', () => { - const assessmentItem = { - question: 'Question', - type: AssessmentItemTypes.TRUE_FALSE, - answers: [], - }; + it('reports the same errors when asked about the same question again', () => { + const assessmentItem = { + type: AssessmentItemTypes.QTI, + raw_data: CHOICE_ITEM_DOCUMENT_NO_PROMPT, + }; - expect(getAssessmentItemErrors(assessmentItem)).toEqual([ - ValidationErrors.INVALID_NUMBER_OF_CORRECT_ANSWERS, - ]); - }); + expect(getAssessmentItemErrors(assessmentItem)).toEqual( + getAssessmentItemErrors(assessmentItem), + ); }); - describe('for true/false with no correct answer', () => { - it('returns negative validation results', () => { - const assessmentItem = { - question: 'Question', - type: AssessmentItemTypes.TRUE_FALSE, - answers: [ - { answer: 'True', correct: false, order: 1 }, - { answer: 'False', correct: false, order: 2 }, - ], - }; - - expect(getAssessmentItemErrors(assessmentItem)).toEqual([ - ValidationErrors.INVALID_NUMBER_OF_CORRECT_ANSWERS, - ]); - }); - }); + it('reports the errors of the question as it is now, not as it was', () => { + const assessmentItem = { + type: AssessmentItemTypes.QTI, + raw_data: CHOICE_ITEM_DOCUMENT_NO_PROMPT, + }; + getAssessmentItemErrors(assessmentItem); - describe('for true/false with more correct answers', () => { - it('returns negative validation results', () => { - const assessmentItem = { - question: 'Question', - type: AssessmentItemTypes.TRUE_FALSE, - answers: [ - { answer: 'True', correct: true, order: 1 }, - { answer: 'False', correct: true, order: 2 }, - ], - }; + assessmentItem.raw_data = VALID_CHOICE_ITEM_DOCUMENT; - expect(getAssessmentItemErrors(assessmentItem)).toEqual([ - ValidationErrors.INVALID_NUMBER_OF_CORRECT_ANSWERS, - ]); - }); + expect(getAssessmentItemErrors(assessmentItem)).toEqual([]); }); - describe('for true/false with one correct answer', () => { - it('returns positive validation results', () => { - const assessmentItem = { - question: 'Question', - type: AssessmentItemTypes.TRUE_FALSE, - answers: [ - { answer: 'True', correct: false, order: 1 }, - { answer: 'False', correct: true, order: 2 }, - ], - }; + it('reports a free-response question differently depending on whether it is allowed', () => { + const assessmentItem = { + type: AssessmentItemTypes.QTI, + raw_data: FREE_RESPONSE_ITEM_DOCUMENT, + }; - expect(getAssessmentItemErrors(assessmentItem)).toEqual([]); - }); + expect(getAssessmentItemErrors(assessmentItem, { allowFreeResponse: true })).toEqual([]); + expect( + getAssessmentItemErrors(assessmentItem, { allowFreeResponse: false }).map(e => e.code), + ).toContain(ValidationError.FREE_RESPONSE_NOT_ALLOWED); }); }); }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/__tests__/validateItem.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/__tests__/validateItem.spec.js new file mode 100644 index 0000000000..01bf33420e --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/__tests__/validateItem.spec.js @@ -0,0 +1,45 @@ +import { validateQtiItem } from '../validateItem'; +import { ValidationError } from '../constants'; +import { + VALID_CHOICE_ITEM_DOCUMENT, + CHOICE_ITEM_DOCUMENT_NO_PROMPT, + CHOICE_ITEM_DOCUMENT_NO_CORRECT_ANSWER, + NO_INTERACTION_ITEM_DOCUMENT, +} from '../utils/testingFixtures'; + +const codesOf = errors => errors.map(error => error.code); + +describe('validateQtiItem', () => { + it('returns no errors for a complete item', () => { + expect(validateQtiItem(VALID_CHOICE_ITEM_DOCUMENT)).toEqual([]); + }); + + it('reports a missing prompt', () => { + expect(codesOf(validateQtiItem(CHOICE_ITEM_DOCUMENT_NO_PROMPT))).toContain( + ValidationError.PROMPT_REQUIRED, + ); + }); + + it('reports a missing correct answer', () => { + expect(codesOf(validateQtiItem(CHOICE_ITEM_DOCUMENT_NO_CORRECT_ANSWER))).toContain( + ValidationError.NO_CORRECT_ANSWER, + ); + }); + + it('reports an item whose body holds no interaction', () => { + expect(validateQtiItem(NO_INTERACTION_ITEM_DOCUMENT)).toEqual([ + { code: ValidationError.NO_INTERACTION }, + ]); + }); + + it('reports an item with no raw data at all', () => { + expect(validateQtiItem('')).toEqual([{ code: ValidationError.NO_INTERACTION }]); + expect(validateQtiItem(undefined)).toEqual([{ code: ValidationError.NO_INTERACTION }]); + }); + + it('reports unparseable XML', () => { + expect(validateQtiItem('')).toEqual([ + { code: ValidationError.PARSE_ERROR }, + ]); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/index.vue index 060b191f58..c7fc3492e9 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/index.vue @@ -16,7 +16,7 @@ /> editors[descriptor.value.type]); + return { descriptor, + editorComponent, questionType, parseError, onUpdateQuestionType, 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 8a9d19fe02..aecaa776d4 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 @@ -1,8 +1,16 @@ import { render, screen, fireEvent } from '@testing-library/vue'; +import { nextTick } from 'vue'; import VueRouter from 'vue-router'; import QTIItemEditor from '../index.vue'; import { qtiEditorStrings } from '../../../qtiEditorStrings'; import { AssessmentItemTypes } from '../../../constants'; +import { + VALID_CHOICE_ITEM_DOCUMENT, + CHOICE_ITEM_DOCUMENT_NO_CORRECT_ANSWER, + ORDERING_ITEM_DOCUMENT_NO_PROMPT, + FREE_RESPONSE_ITEM_DOCUMENT, + NO_INTERACTION_ITEM_DOCUMENT, +} from '../../../utils/testingFixtures'; jest.mock('shared/views/TipTapEditor/TipTapEditor/TipTapEditor'); jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => { @@ -13,7 +21,12 @@ jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => { }; }); -const { closeBtnLabel$, questionContentPlaceholder$ } = qtiEditorStrings; +const { + closeBtnLabel$, + questionContentPlaceholder$, + unsupportedItemMessage$, + incompleteItemIndicatorLabel$, +} = qtiEditorStrings; const defaultProps = { item: { @@ -77,6 +90,132 @@ describe('QTIItemEditor', () => { }); }); + describe('items this editor cannot edit', () => { + test('shows a read-only message for an item authored elsewhere', () => { + renderComponent({ + item: { assessment_id: 'perseus-item', type: 'perseus_question', raw_data: '{}' }, + }); + expect(screen.getByText(unsupportedItemMessage$())).toBeInTheDocument(); + }); + + test('shows a read-only message when the item XML cannot be read', () => { + renderComponent({ + item: { + assessment_id: 'broken-item', + type: AssessmentItemTypes.QTI, + raw_data: '', + }, + }); + expect(screen.getByText(unsupportedItemMessage$())).toBeInTheDocument(); + }); + }); + + describe('incomplete indicator', () => { + const renderAndValidate = async raw_data => { + renderComponent({ + item: { assessment_id: 'item-id', type: AssessmentItemTypes.QTI, raw_data }, + }); + await nextTick(); + }; + + test('is shown for a question missing something the author has to supply', async () => { + await renderAndValidate(CHOICE_ITEM_DOCUMENT_NO_CORRECT_ANSWER); + expect(screen.getByText(incompleteItemIndicatorLabel$())).toBeInTheDocument(); + }); + + test('is not shown for a complete question', async () => { + await renderAndValidate(VALID_CHOICE_ITEM_DOCUMENT); + expect(screen.queryByText(incompleteItemIndicatorLabel$())).not.toBeInTheDocument(); + }); + + // The card reads the item's XML rather than errors an interaction editor reports, so an + // interaction that reports nothing is covered like any other. + test('is shown for an incomplete question of any interaction type', async () => { + await renderAndValidate(ORDERING_ITEM_DOCUMENT_NO_PROMPT); + expect(screen.getByText(incompleteItemIndicatorLabel$())).toBeInTheDocument(); + }); + + test('is shown for an item with no interaction at all', async () => { + await renderAndValidate(NO_INTERACTION_ITEM_DOCUMENT); + expect(screen.getByText(incompleteItemIndicatorLabel$())).toBeInTheDocument(); + }); + + test('is shown for a free-response question where those are not accepted', async () => { + renderComponent({ + allowFreeResponse: false, + item: { + assessment_id: 'item-id', + type: AssessmentItemTypes.QTI, + raw_data: FREE_RESPONSE_ITEM_DOCUMENT, + }, + }); + await nextTick(); + + expect(screen.getByText(incompleteItemIndicatorLabel$())).toBeInTheDocument(); + }); + + test('is not shown for a free-response question where those are accepted', async () => { + await renderAndValidate(FREE_RESPONSE_ITEM_DOCUMENT); + expect(screen.queryByText(incompleteItemIndicatorLabel$())).not.toBeInTheDocument(); + }); + test('is not shown for a question this editor cannot read', async () => { + renderComponent({ + item: { + assessment_id: 'item-id', + type: AssessmentItemTypes.PERSEUS_QUESTION, + raw_data: '{"not":"qti"}', + }, + }); + await nextTick(); + + expect(screen.queryByText(incompleteItemIndicatorLabel$())).not.toBeInTheDocument(); + }); + }); + + describe('reporting content changes', () => { + const renderWithContent = mode => + renderComponent({ + mode, + item: { + assessment_id: 'item-id', + type: AssessmentItemTypes.QTI, + raw_data: VALID_CHOICE_ITEM_DOCUMENT, + }, + }); + + test('a card that is only being viewed reports nothing', async () => { + // A closed card re-assembles its XML too; reporting that would rewrite every + // question in the list just for being on screen. + const { emitted } = renderWithContent('view'); + await nextTick(); + + expect(emitted()['update:rawData']).toBeUndefined(); + }); + + test('a change made while editing is still reported once the card closes', async () => { + // Closing sets the parent's active item to none, which re-renders this card as a + // viewed one before the watcher for the change runs. The change was still authored. + const { emitted, updateProps } = renderWithContent('edit'); + // Deliberately not awaited: the change and the close land in the same flush, which is + // what happens when a click closes the card the author was just typing in. + fireEvent.click(screen.getByRole('button', { name: /add choice/i })); + await updateProps({ mode: 'view' }); + await nextTick(); + + expect(emitted()['update:rawData']).toBeDefined(); + }); + + test('the card being edited reports the new XML when the author changes it', async () => { + const { emitted } = renderWithContent('edit'); + // The fixture starts with two choices. + await fireEvent.click(screen.getByRole('button', { name: /add choice/i })); + await nextTick(); + + const reported = emitted()['update:rawData'].pop()[0]; + expect(reported.match(/ { 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 40711ad6f9..60555fe5c2 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue @@ -22,13 +22,32 @@
+ + + {{ incompleteItemIndicatorLabel$() }} +
+

+ {{ unsupportedItemMessage$() }} +

props.item.type !== AssessmentItemTypes.QTI || Boolean(parseError.value), + ); + // Seed the editor refs from the parsed interactions (first interaction only). if (interactions.value.length > 0) { currentBodyXml.value = interactions.value[0].bodyXml; @@ -144,8 +174,19 @@ }), ); + /** + * Whether the change the watcher below is about to report came from an edit in this + * card. Recorded as the change happens rather than read from `mode` when the watcher + * flushes: closing the card sets the parent's active item to none, and that re-render + * lands first, so a change made just before the close would look like it came from a + * card nobody was editing. + */ + let editedHere = false; + // Emit only when the assembled XML actually changes after initial mount. watch(rawData, newVal => { + if (!editedHere) return; + editedHere = false; if (process.env.NODE_ENV === 'development') { // eslint-disable-next-line no-console console.log('[QTIItemEditor] assembled XML:\n', newVal); @@ -154,18 +195,41 @@ }); function onUpdateInteraction({ bodyXml, responseDeclarations }) { + editedHere = props.mode === 'edit'; currentBodyXml.value = bodyXml; currentResponseDeclarations.value = responseDeclarations; } + /** + * Whether the question is missing something an author still has to supply. + * + * Read from the item's own XML rather than from errors the interaction editor + * reports, so that it covers what is wrong with the item as a whole — no interaction + * at all, or a free-response question where those are not accepted — and so that + * every interaction is included without having to report anything. It follows the + * assembled XML, so it keeps up with the question being edited. + * + * A question this editor cannot read is shown as read-only instead, and reporting it + * as incomplete would ask the author to fix something they cannot reach. + */ + const isIncomplete = computed( + () => + !isUnsupported.value && + validateQtiItem(rawData.value, { allowFreeResponse: props.allowFreeResponse }).length > 0, + ); + return { currentQuestionType, interactions, currentInteraction, + isUnsupported, + isIncomplete, questionNumberLabel, questionNumberAndTypeLabel, closeBtnLabel$, questionContentPlaceholder$, + incompleteItemIndicatorLabel$, + unsupportedItemMessage$, onUpdateInteraction, }; }, @@ -200,6 +264,14 @@ type: Boolean, default: false, }, + /** + * Whether a question with no correct answer counts as complete. Only a survey + * accepts those, so a consumer that scores its questions passes false. + */ + allowFreeResponse: { + type: Boolean, + default: true, + }, }, emits: ['close', 'update:rawData'], @@ -235,6 +307,15 @@ align-items: center; } + .incomplete-indicator { + display: flex; + gap: 4px; + align-items: center; + font-size: 14px; + font-weight: 600; + white-space: nowrap; + } + .question-card-body { min-width: 0; padding: 10px var(--question-card-horizontal-padding) 16px; diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useInteraction.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useInteraction.spec.js index 57dcd875de..4b768951fe 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useInteraction.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useInteraction.spec.js @@ -1,14 +1,6 @@ import { ref, nextTick } from 'vue'; import { useInteraction } from '../useInteraction'; -jest.mock('lodash/debounce', () => { - return jest.fn(fn => { - const mocked = jest.fn((...args) => fn(...args)); - mocked.cancel = jest.fn(); - return mocked; - }); -}); - function makeDescriptor({ parseReturn = {}, buildReturn = null, validateReturn = [] } = {}) { return { parse: jest.fn(() => parseReturn), diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useTextEntryInteraction.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useTextEntryInteraction.spec.js index 9da18c9334..3cb643866e 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useTextEntryInteraction.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useTextEntryInteraction.spec.js @@ -36,12 +36,17 @@ describe('useTextEntryInteraction', () => { expect(state.value.answers[0].value).toBe('12'); }); - it('starts with empty errors', () => { + it('starts with no errors when the parsed state is already valid', () => { const { errors } = setupNumeric(); - // errors populates asynchronously via debounced watcher; - // immediately after setup it is still empty. + expect(errors.value).toEqual([]); }); + + it('reports errors for an invalid parsed state without waiting', () => { + const { errors } = setupNumeric([]); + + expect(errors.value.map(e => e.code)).toContain(ValidationError.NO_CORRECT_ANSWER); + }); }); describe('addAnswer()', () => { diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useChoiceInteraction.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useChoiceInteraction.js index d030f693d2..4b30e20ed3 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useChoiceInteraction.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useChoiceInteraction.js @@ -1,7 +1,7 @@ import { computed, readonly } from 'vue'; import { QuestionType } from '../constants'; import { generateRandomSlug } from '../utils/generateRandomSlug'; -import { choiceInteractionDescriptor } from '../interactions/choice/ChoiceInteractionDescriptor'; +import { choiceInteractionDescriptor } from '../interactions/choice/Descriptor'; import { useInteraction } from './useInteraction'; /** diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteraction.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteraction.js index f9c9d64691..316ffe0b9f 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteraction.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteraction.js @@ -1,5 +1,4 @@ -import { ref, computed, watch, onUnmounted } from 'vue'; -import debounce from 'lodash/debounce'; +import { ref, computed, watch } from 'vue'; /** * Base composable for all interaction editors. @@ -8,12 +7,11 @@ import debounce from 'lodash/debounce'; * interaction plugin must go through. Individual interaction composables * (e.g. useChoiceInteraction) call this and add mutation methods on top. * - * Validation runs immediately when called explicitly (e.g. when closing a - * panel), but is debounced when triggered by state changes so that errors - * only appear after the user pauses typing (400 ms), avoiding noisy - * inline error flicker on every keystroke. + * Validation runs on every state or questionType change, so errors always describe the + * state the editor is showing. runValidation is exposed for explicit triggers, such as + * closing a panel. * - * @param {import('../interactions/defineInteraction').InteractionDescriptor} descriptor + * @param {import('../interactions/InteractionDescriptor').InteractionDescriptor} descriptor * @param {{ bodyXml: string, responseDeclarations: string[] }} interactionBlock * @param {import('vue').Ref} questionType * @returns {{ @@ -43,21 +41,12 @@ export function useInteraction(descriptor, interactionBlock, questionType) { const errors = ref([]); - /** Immediately validates and updates errors. Use this for explicit triggers (e.g. close). */ + /** Validates and updates errors. Exposed for explicit triggers (e.g. close). */ function runValidation() { errors.value = descriptor.validate(state.value, questionType.value); } - /** - * Debounced version used by the state watcher — waits 400 ms after the user - * stops typing before showing inline errors. - */ - const debouncedValidation = debounce(runValidation, 400); - - // Cancel any pending debounce when the component is torn down. - onUnmounted(() => debouncedValidation.cancel()); - - watch([state, questionType], debouncedValidation, { deep: true, immediate: true }); + watch([state, questionType], runValidation, { deep: true, immediate: true }); return { state, bodyXml, responseDeclarations, errors, runValidation }; } diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteractionDescriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteractionDescriptor.js index 76b40f311b..2bba518481 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteractionDescriptor.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteractionDescriptor.js @@ -1,6 +1,6 @@ import { computed, ref } from 'vue'; -import { parseXML } from '../serialization/parseItem'; import { descriptors, registry, DEFAULT_INTERACTION } from '../interactions/index'; +import { resolveDescriptor } from '../interactions/resolveDescriptor'; import { qtiEditorStrings } from '../qtiEditorStrings'; const { errorParsingQuestion$ } = qtiEditorStrings; @@ -14,48 +14,20 @@ const { errorParsingQuestion$ } = qtiEditorStrings; */ export default function useInteractionDescriptor(interactionRef) { /** - * Parses bodyXml and returns the matching descriptor, resolved - * question type, and any parse error without touching reactive state. - */ - function inferFromXml(xml, declarations) { - if (!xml) { - return { descriptor: registry[DEFAULT_INTERACTION], questionType: null, error: null }; - } - try { - const doc = parseXML(xml); - const interactionEl = doc.documentElement; - const desc = descriptors.find(d => d.matches(interactionEl)) ?? registry[DEFAULT_INTERACTION]; - return { - descriptor: desc, - questionType: desc.getQuestionType(interactionEl, declarations) ?? null, - error: null, - }; - } catch (e) { - // eslint-disable-next-line no-console - console.error('[QTI] Failed to parse interaction XML:', e.message); - return { - descriptor: registry[DEFAULT_INTERACTION], - questionType: null, - error: errorParsingQuestion$(), - }; - } - } - - /** - * Parse the initial XML synchronously during component setup. + * Resolve the initial XML synchronously during component setup. * * This ensures `questionType` is immediately available for downstream components * on first render, avoiding prop validation warnings that would occur if * initialization was deferred to a lifecycle hook. */ - const initial = inferFromXml( + const initial = resolveDescriptor( interactionRef.value?.bodyXml, interactionRef.value?.responseDeclarations, ); /** Writable ref driven by UI selections after initial parse. */ const questionType = ref(initial.questionType); - const parseError = ref(initial.error); + const parseError = ref(initial.error ? errorParsingQuestion$() : null); /** * Derived from questionType so the descriptor updates when the user switches diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useOrderingInteraction.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useOrderingInteraction.js index 9590207c3b..d38a0fa1a2 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useOrderingInteraction.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useOrderingInteraction.js @@ -1,6 +1,6 @@ import { readonly } from 'vue'; import { generateRandomSlug } from '../utils/generateRandomSlug'; -import { orderingInteractionDescriptor } from '../interactions/ordering/OrderingInteractionDescriptor'; +import { orderingInteractionDescriptor } from '../interactions/ordering/Descriptor'; import { useInteraction } from './useInteraction'; /** diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useTextEntryInteraction.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useTextEntryInteraction.js index e607fc206a..43d9546312 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useTextEntryInteraction.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useTextEntryInteraction.js @@ -1,6 +1,6 @@ import { readonly } from 'vue'; import { generateRandomSlug } from '../utils/generateRandomSlug'; -import { textEntryInteractionDescriptor } from '../interactions/textEntry/TextEntryInteractionDescriptor'; +import { textEntryInteractionDescriptor } from '../interactions/textEntry/Descriptor'; import { useInteraction } from './useInteraction'; /** diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js index 3fa2bcd6a4..1ebd3f003e 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js @@ -34,14 +34,14 @@ export const Orientation = Object.freeze({ * 2. QuestionType -> The type editors will select per assessment item. * It's different from AssessmentItemType because we will extend this for all * new question types without confusing it with values stored in the database - * (all of these will be assessment item type: "qti"). Value is related to how + * (all of these will be assessment item type: "QTI"). Value is related to how * Studio presents different question options to users in the UI. * * 3. InteractionType (QtiInteraction) -> The actual interactions defined by QTI, * and the ones that dictate how to parse and what descriptor we will use. * Each QTI interaction can have multiple related question types (e.g., choice * can be singleSelect or multiSelect), but all of them will have assessment - * item type "qti". + * item type "QTI". */ /** @@ -66,7 +66,8 @@ export const QTI_INTERACTION_TAGS = Object.freeze(Object.values(QtiInteraction)) * by the broader Studio assessment system, not by this editor. */ export const AssessmentItemTypes = Object.freeze({ - QTI: 'qti', + // Matches the value the API stores and returns (le_utils exercises.QTI). + QTI: 'QTI', }); /** @@ -89,6 +90,10 @@ export const QuestionType = Object.freeze({ * this set in their own validate.js module. */ export const ValidationError = Object.freeze({ + // Item-level codes, produced by validateItem.js rather than an interaction + PARSE_ERROR: 'PARSE_ERROR', + NO_INTERACTION: 'NO_INTERACTION', + FREE_RESPONSE_NOT_ALLOWED: 'FREE_RESPONSE_NOT_ALLOWED', PROMPT_REQUIRED: 'PROMPT_REQUIRED', NO_CORRECT_ANSWER: 'NO_CORRECT_ANSWER', TOO_MANY_CORRECT_ANSWERS: 'TOO_MANY_CORRECT_ANSWERS', @@ -102,10 +107,7 @@ export const ValidationError = Object.freeze({ export const RESPONSE_IDENTIFIER = 'RESPONSE'; -/** - * Set of QTI interaction tag names that have `placement: 'inline'`. - * Used by parseItem to decide whether to serialize the full `` - * (inline) or just the interaction element (block). - * Kept here to avoid a circular dependency with the descriptor registry. - */ -export const INLINE_INTERACTION_TAGS = new Set([QtiInteraction.TEXT_ENTRY]); +export const Placement = Object.freeze({ + BLOCK: 'block', + INLINE: 'inline', +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/index.vue index cc9b2af339..f6ab305f08 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/index.vue @@ -26,6 +26,7 @@ :index="idx" :total="items.length" :mode="activeId === item.assessment_id ? 'edit' : 'view'" + :allowFreeResponse="allowFreeResponse" :showAnswers="showAnswers" data-testid="item" @close="closeItem" @@ -66,6 +67,7 @@ import QTIItemEditor from './components/QTIItemEditor/index'; import CollapsibleToolbar from './components/CollapsibleToolbar/index.vue'; import useQTIEditorActions from './useQTIEditorActions'; + import { createBlankItemXml } from './serialization/createBlankItem'; // Custom uuid4 function to match our dashless uuids on the server side function uuid4() { @@ -77,6 +79,7 @@ return { assessment_id: uuid4(), type: AssessmentItemTypes.QTI, + raw_data: createBlankItemXml(), }; } @@ -100,6 +103,9 @@ const showAnswers = ref(false); function openItem(id) { + const item = props.assessments.find(i => i.assessment_id === id); + // Items authored elsewhere (e.g. Perseus) are read-only here. + if (!item || item.type !== AssessmentItemTypes.QTI) return; activeId.value = id; } @@ -196,6 +202,14 @@ type: Array, default: () => [], }, + /** + * Whether a question with no correct answer counts as complete. Only a survey + * accepts those, so a consumer that scores its questions passes false. + */ + allowFreeResponse: { + type: Boolean, + default: true, + }, }, emits: ['update'], diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/InteractionDescriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/InteractionDescriptor.js new file mode 100644 index 0000000000..8a2c603ad7 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/InteractionDescriptor.js @@ -0,0 +1,82 @@ +/** + * Base class for every interaction descriptor. + * + * A descriptor owns everything about one QTI interaction except how it looks: recognising + * its element, resolving which question type an element represents, parsing XML to state, + * building XML back, and validating that state. The Vue editor is deliberately not part of + * it, so that headless parse/validation does not import any .vue components. + * + * The contract is checked as the descriptor is constructed, which happens at import time + * for the module singletons, so an incomplete descriptor fails while its author is looking + * at it rather than when an author opens a question. + */ + +import { Placement } from '../constants'; + +/** + * Methods a subclass has to implement. `matches` and `getTypeOptions` are not listed + * because this class provides usable defaults for them. + */ +const REQUIRED_METHODS = [ + 'getQuestionType', + 'getResponseDeclarationSchema', + 'parse', + 'buildXML', + 'validate', +]; + +export class InteractionDescriptor { + /** + * @param {object} options + * @param {string} options.type - The interaction's XML tag name, e.g. 'qti-choice-interaction' + * @param {string[]} options.questionTypes - QuestionType values this interaction can author + * @param {string} [options.placement] - Placement.BLOCK (default) or Placement.INLINE. + * Inline interactions are handed the whole item body to parse, since their prompt lives + * in the body around them rather than in a `` child. + */ + constructor({ type, questionTypes, placement = Placement.BLOCK } = {}) { + const name = this.constructor.name; + + if (!type) { + throw new Error(`${name}: type is required`); + } + if (!Array.isArray(questionTypes) || !questionTypes.length) { + throw new Error(`${name}: questionTypes must list at least one question type`); + } + + if (!Object.values(Placement).includes(placement)) { + throw new Error(`${name}: placement must be one of ${Object.values(Placement).join(', ')}`); + } + + const missing = REQUIRED_METHODS.filter(method => typeof this[method] !== 'function'); + if (missing.length) { + throw new Error(`${name}: missing required method(s) ${missing.join(', ')}`); + } + + this.type = type; + this.questionTypes = questionTypes; + this.placement = placement; + } + + /** + * Whether this descriptor handles the given interaction element. Defaults to matching the + * element whose tag name is this interaction's type; interactions that can appear nested + * in the item body (inline ones) override this. + * + * @param {Element} el + * @returns {boolean} + */ + matches(el) { + return el.tagName.toLowerCase() === this.type; + } + + /** + * Options this interaction contributes to the question type selector. An interaction that + * authors are not meant to pick directly contributes none. + * + * @returns {Array<{ value: string, label: string, description: string }>} + */ + getTypeOptions() { + return []; + } +} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/InteractionDescriptor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/InteractionDescriptor.spec.js new file mode 100644 index 0000000000..0144f108f8 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/InteractionDescriptor.spec.js @@ -0,0 +1,102 @@ +import { InteractionDescriptor } from '../InteractionDescriptor'; +import { Placement, QtiInteraction, QuestionType } from '../../constants'; + +const IMPLEMENTED = { + getQuestionType: () => QuestionType.SINGLE_SELECT, + getResponseDeclarationSchema: () => ({}), + parse: () => ({}), + buildXML: () => ({ bodyXml: '', responseDeclarations: [] }), + validate: () => [], +}; + +/** Builds a subclass implementing everything except the listed methods. */ +function makeDescriptorClass({ omit = [], options } = {}) { + class TestDescriptor extends InteractionDescriptor { + constructor() { + super( + options ?? { + type: QtiInteraction.CHOICE, + questionTypes: [QuestionType.SINGLE_SELECT], + }, + ); + } + } + for (const [name, fn] of Object.entries(IMPLEMENTED)) { + if (!omit.includes(name)) { + TestDescriptor.prototype[name] = fn; + } + } + return TestDescriptor; +} + +describe('InteractionDescriptor', () => { + it('constructs when the subclass implements the contract', () => { + const Descriptor = makeDescriptorClass(); + const descriptor = new Descriptor(); + + expect(descriptor.type).toBe(QtiInteraction.CHOICE); + expect(descriptor.questionTypes).toEqual([QuestionType.SINGLE_SELECT]); + }); + + it('names every method the subclass failed to implement', () => { + const Descriptor = makeDescriptorClass({ omit: ['parse', 'validate'] }); + + expect(() => new Descriptor()).toThrow(/missing required method\(s\) parse, validate/); + }); + + it('requires a type', () => { + const Descriptor = makeDescriptorClass({ + options: { questionTypes: [QuestionType.SINGLE_SELECT] }, + }); + + expect(() => new Descriptor()).toThrow(/type is required/); + }); + + it('places an interaction in the body as a block unless told otherwise', () => { + expect(new (makeDescriptorClass())().placement).toBe(Placement.BLOCK); + + const Inline = makeDescriptorClass({ + options: { + type: QtiInteraction.TEXT_ENTRY, + questionTypes: [QuestionType.TEXT_ENTRY], + placement: Placement.INLINE, + }, + }); + expect(new Inline().placement).toBe(Placement.INLINE); + }); + + it('rejects a placement it does not know', () => { + const Descriptor = makeDescriptorClass({ + options: { + type: QtiInteraction.CHOICE, + questionTypes: [QuestionType.SINGLE_SELECT], + placement: 'floating', + }, + }); + + expect(() => new Descriptor()).toThrow(/placement must be one of/); + }); + + it('requires at least one question type', () => { + const Descriptor = makeDescriptorClass({ + options: { type: QtiInteraction.CHOICE, questionTypes: [] }, + }); + + expect(() => new Descriptor()).toThrow(/at least one question type/); + }); + + describe('defaults', () => { + it('matches the element whose tag name is the interaction type', () => { + const descriptor = new (makeDescriptorClass())(); + const matching = { tagName: 'QTI-CHOICE-INTERACTION' }; + const other = { tagName: 'QTI-ORDER-INTERACTION' }; + + expect(descriptor.matches(matching)).toBe(true); + expect(descriptor.matches(other)).toBe(false); + }); + + it('contributes no question type options', () => { + expect(new (makeDescriptorClass())().getTypeOptions()).toEqual([]); + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/defineInteraction.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/defineInteraction.spec.js deleted file mode 100644 index f56266a565..0000000000 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/defineInteraction.spec.js +++ /dev/null @@ -1,72 +0,0 @@ -import defineInteraction from '../defineInteraction'; - -// A minimal valid descriptor with all required keys except editorComponent, -// which is now always supplied as the second argument to defineInteraction. -const makeValidDescriptor = (overrides = {}) => ({ - type: 'test', - placement: 'block', - questionTypes: [], - convertsFrom: [], - matches: () => false, - getQuestionType: () => null, - getResponseDeclarationSchema: () => ({ baseType: 'string', cardinality: 'single' }), - parse: () => ({}), - buildXML: () => ({ bodyXml: '', responseDeclarations: [] }), - validate: () => [], - ...overrides, -}); - -const STUB_COMPONENT = {}; - -describe('defineInteraction', () => { - it('returns the descriptor unchanged when all required keys are present', () => { - const descriptor = makeValidDescriptor(); - expect(defineInteraction(descriptor, STUB_COMPONENT)).toBe(descriptor); - }); - - it('attaches the editorComponent from the second argument onto the descriptor', () => { - const descriptor = makeValidDescriptor(); - const component = { name: 'MyEditor' }; - defineInteraction(descriptor, component); - expect(descriptor.editorComponent).toBe(component); - }); - - const REQUIRED_DESCRIPTOR_KEYS = [ - 'type', - 'placement', - 'questionTypes', - 'convertsFrom', - 'matches', - 'getQuestionType', - 'getResponseDeclarationSchema', - 'parse', - 'buildXML', - 'validate', - ]; - - it.each(REQUIRED_DESCRIPTOR_KEYS)('throws when the required key "%s" is missing', key => { - const descriptor = makeValidDescriptor(); - delete descriptor[key]; - expect(() => defineInteraction(descriptor, STUB_COMPONENT)).toThrow( - new RegExp(`missing required key "${key}"`, 'i'), - ); - }); - - it('throws when editorComponent is not passed as the second argument', () => { - const descriptor = makeValidDescriptor(); - expect(() => defineInteraction(descriptor)).toThrow(/missing required key "editorComponent"/i); - }); - - it('includes the descriptor type in the error message when type is present', () => { - const descriptor = makeValidDescriptor({ type: 'myPlugin' }); - delete descriptor.buildXML; // delete a different key to trigger the error - expect(() => defineInteraction(descriptor, STUB_COMPONENT)).toThrow(/myPlugin/); - }); - - it('uses "(unknown)" in the error message when type is also missing', () => { - const descriptor = makeValidDescriptor(); - delete descriptor.type; - delete descriptor.buildXML; - expect(() => defineInteraction(descriptor, STUB_COMPONENT)).toThrow(/\(unknown\)/); - }); -}); 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 new file mode 100644 index 0000000000..1f753c67cd --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/registry.spec.js @@ -0,0 +1,48 @@ +import { descriptors, editors, registry, DEFAULT_INTERACTION } from '../index'; +import { isInlineInteraction } from '../descriptors'; +import { Placement } from '../../constants'; + +/** + * An interaction is registered in two places: its descriptor in `descriptors.js` and its + * editor in `index.js`. That split keeps the editors out of the parse/validate import + * graph, at the cost of two lists that have to agree — so these assert they do. A new + * interaction that only got half-registered fails here rather than at the moment an author + * opens the question. + */ +describe('interaction registry', () => { + it('registers an editor for every descriptor', () => { + const missing = descriptors.filter(d => !editors[d.type]).map(d => d.type); + expect(missing).toEqual([]); + }); + + it('registers a descriptor for every editor', () => { + const orphans = Object.keys(editors).filter(type => !registry[type]); + expect(orphans).toEqual([]); + }); + + it('holds the same number of descriptors and editors', () => { + expect(Object.keys(editors)).toHaveLength(descriptors.length); + }); + + it('keys the registry by every descriptor type', () => { + expect(Object.keys(registry).sort()).toEqual(descriptors.map(d => d.type).sort()); + }); + + it('has a descriptor for the fallback interaction', () => { + expect(registry[DEFAULT_INTERACTION]).toBeDefined(); + }); + + describe('isInlineInteraction', () => { + it('reports the placement each descriptor declares', () => { + for (const descriptor of descriptors) { + expect(isInlineInteraction(descriptor.type)).toBe( + descriptor.placement === Placement.INLINE, + ); + } + }); + + it('reports an interaction with no descriptor as not inline', () => { + expect(isInlineInteraction('qti-match-interaction')).toBe(false); + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionDescriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/Descriptor.js similarity index 86% rename from contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionDescriptor.js rename to contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/Descriptor.js index 26b04163f6..354d0bbc5a 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionDescriptor.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/Descriptor.js @@ -1,17 +1,18 @@ import { QtiInteraction, QuestionType, BaseType, Cardinality } from '../../constants'; -import { parseXML } from '../../serialization/parseItem'; +import { parseXML } from '../../serialization/xml'; +import { InteractionDescriptor } from '../InteractionDescriptor'; import { parseChoiceInteraction, buildChoiceInteractionXML } from './parse'; import { validateChoiceInteraction } from './validation'; /** * Owns all choice-specific interaction logic: schema, parse, buildXML, and validate. */ -export class ChoiceInteractionDescriptor { - constructor({ editorComponent = null } = {}) { - this.type = QtiInteraction.CHOICE; - this.placement = 'block'; - this.questionTypes = [QuestionType.SINGLE_SELECT, QuestionType.MULTI_SELECT]; - this.editorComponent = editorComponent; +export class ChoiceInteractionDescriptor extends InteractionDescriptor { + constructor() { + super({ + type: QtiInteraction.CHOICE, + questionTypes: [QuestionType.SINGLE_SELECT, QuestionType.MULTI_SELECT], + }); this.convertsFrom = []; } @@ -30,11 +31,6 @@ export class ChoiceInteractionDescriptor { ]; } - /** @param {Element} el */ - matches(el) { - return el.tagName.toLowerCase() === QtiInteraction.CHOICE; - } - /** * Reads cardinality from the response declaration to determine question type. * diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionEditor.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/Editor.vue similarity index 100% rename from contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionEditor.vue rename to contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/Editor.vue diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/ChoiceInteractionDescriptor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/Descriptor.spec.js similarity index 96% rename from contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/ChoiceInteractionDescriptor.spec.js rename to contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/Descriptor.spec.js index bd321d465d..60dc3a9bca 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/ChoiceInteractionDescriptor.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/Descriptor.spec.js @@ -1,4 +1,4 @@ -import { ChoiceInteractionDescriptor } from '../ChoiceInteractionDescriptor'; +import { ChoiceInteractionDescriptor } from '../Descriptor'; import { BaseType, Cardinality, QtiInteraction, QuestionType } from '../../../constants'; describe('ChoiceInteractionDescriptor', () => { diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/ChoiceInteractionEditor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/Editor.spec.js similarity index 95% rename from contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/ChoiceInteractionEditor.spec.js rename to contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/Editor.spec.js index 7cf59d9378..5a85fbafee 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/ChoiceInteractionEditor.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/Editor.spec.js @@ -1,7 +1,7 @@ import { render, screen, fireEvent, within } from '@testing-library/vue'; import { nextTick } from 'vue'; import VueRouter from 'vue-router'; -import ChoiceInteractionEditor from '../ChoiceInteractionEditor.vue'; +import ChoiceInteractionEditor from '../Editor.vue'; import { CHOICE_SINGLE_SELECT_XML, @@ -282,46 +282,49 @@ describe('ChoiceInteractionEditor', () => { }); describe('validation', () => { - it('does not show errors before any field is touched', () => { + it('reports what is missing as soon as it renders', () => { + // Validation is not debounced, so errors describe the state on screen from the start: + // this fixture has no declaration, so no choice is marked correct. renderEditor({ interaction: block(CHOICE_SINGLE_SELECT_XML), questionType: QuestionType.SINGLE_SELECT, }); + + expect(screen.getByText(tr.errorNoCorrectAnswer$())).toBeInTheDocument(); + }); + + it('shows no errors for a question that is already complete', () => { + renderEditor({ + interaction: blockWithDecl(CHOICE_SINGLE_SELECT_XML, SINGLE_DECL), + questionType: QuestionType.SINGLE_SELECT, + }); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); }); it('shows global errors (no correct choice) after a structural mutation', async () => { - jest.useFakeTimers(); // Add a choice so we have 2+ choices — then the only error is no correct choice. renderEditor({ interaction: block(CHOICE_SINGLE_SELECT_XML), questionType: QuestionType.SINGLE_SELECT, }); - // Clicking Add choice mutates state → debounced validate fires. + // Clicking Add choice mutates state, which validates straight away. await fireEvent.click(screen.getByRole('button', { name: /add choice/i })); - // Flush Vue watcher queue. - await nextTick(); - // Advance past the 400ms debounce, then flush the resulting DOM update. - jest.advanceTimersByTime(400); await nextTick(); - jest.useRealTimers(); + // NO_CORRECT_ANSWER (and potentially others) should be shown after validation runs. expect(screen.getAllByRole('alert').length).toBeGreaterThan(0); }); - it('shows no-correct-choice error after toggling and running validation', async () => { - jest.useFakeTimers(); + it('shows the empty-choice error as soon as a choice is added', async () => { renderEditor({ interaction: blockWithDecl(CHOICE_SINGLE_SELECT_XML, SINGLE_DECL), questionType: QuestionType.SINGLE_SELECT, }); - // Trigger validation via add-choice which mutates state → debounced validate fires. await fireEvent.click(screen.getByRole('button', { name: /add choice/i })); await nextTick(); - jest.advanceTimersByTime(400); - await nextTick(); - jest.useRealTimers(); - // Validate fires; errors should appear (e.g. empty choice content). + + expect(screen.getByText(tr.errorEmptyChoiceContent$())).toBeInTheDocument(); }); }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/parse.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/parse.spec.js index 53f5f286bf..0ba74d36ed 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/parse.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/parse.spec.js @@ -2,7 +2,7 @@ // The eslint-dom matchers reject XML nodes produced by DOMParser(..., 'text/xml'). // Native DOM APIs (getAttribute, textContent) work correctly on XML elements. -import { choiceInteractionDescriptor } from '../ChoiceInteractionDescriptor'; +import { choiceInteractionDescriptor } from '../Descriptor'; import { CHOICE_SINGLE_SELECT_XML, diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/validate.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/validation.spec.js similarity index 98% rename from contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/validate.spec.js rename to contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/validation.spec.js index b333a3bee3..bcf9119cc5 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/validate.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/validation.spec.js @@ -1,4 +1,4 @@ -import { choiceInteractionDescriptor } from '../ChoiceInteractionDescriptor'; +import { choiceInteractionDescriptor } from '../Descriptor'; import { ValidationError, QuestionType, Orientation } from '../../../constants'; diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/index.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/index.js deleted file mode 100644 index 966cc2dd7e..0000000000 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/index.js +++ /dev/null @@ -1,5 +0,0 @@ -import defineInteraction from '../defineInteraction'; -import ChoiceInteractionEditor from './ChoiceInteractionEditor.vue'; -import { choiceInteractionDescriptor } from './ChoiceInteractionDescriptor'; - -export default defineInteraction(choiceInteractionDescriptor, ChoiceInteractionEditor); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/parse.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/parse.js index d9c83d4872..4293ef5d40 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/parse.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/parse.js @@ -1,5 +1,5 @@ import { QTIDeclaration } from '../../serialization/qti/QTIDeclaration'; -import { getPromptHTML, parseXML } from '../../serialization/parseItem'; +import { getPromptHTML, parseXML } from '../../serialization/xml'; import { buildXmlNode } from '../../serialization/assembleItem'; import CorrectResponse from '../../serialization/qti/declarations/correctResponse'; import { generateRandomSlug } from '../../utils/generateRandomSlug'; diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/defineInteraction.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/defineInteraction.js deleted file mode 100644 index f08d273877..0000000000 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/defineInteraction.js +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Required keys every interaction descriptor must provide. - * Validated at import time so missing fields surface immediately during development. - */ -const REQUIRED_KEYS = [ - 'type', - 'placement', - 'questionTypes', - 'editorComponent', - 'convertsFrom', - 'matches', - 'getQuestionType', - 'getResponseDeclarationSchema', - 'parse', - 'buildXML', - 'validate', -]; - -/** - * Validates that a descriptor has every required key and returns it unchanged. - * Throws at call-time (i.e. module import time) if any key is absent. - * - * Pass the Vue editor component as the second argument to attach it to the - * descriptor here rather than mutating the descriptor after construction. - * - * @template {object} T - * @param {T} descriptor - The interaction descriptor to validate - * @param {object} editorComponent - The Vue component that edits this interaction - * @returns {T} The same descriptor, with editorComponent attached - * @throws {Error} If any required key is missing from the descriptor - */ -export default function defineInteraction(descriptor, editorComponent) { - // Attach editorComponent before validation so the required-key check can - // confirm it is present even when the descriptor class does not set it. - descriptor.editorComponent = editorComponent; - - for (const key of REQUIRED_KEYS) { - // Use a truthiness check for editorComponent (a Vue component object) so - // that passing `undefined` as the second argument is caught as missing. - const isMissing = key === 'editorComponent' ? !descriptor[key] : !(key in descriptor); - if (isMissing) { - const name = descriptor.type ?? '(unknown)'; - throw new Error(`defineInteraction: missing required key "${key}" on descriptor "${name}"`); - } - } - return descriptor; -} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/descriptors.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/descriptors.js new file mode 100644 index 0000000000..4e639ca3c7 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/descriptors.js @@ -0,0 +1,62 @@ +import { Placement, QtiInteraction } from '../constants'; +import { choiceInteractionDescriptor } from './choice/Descriptor'; +import { textEntryInteractionDescriptor } from './textEntry/Descriptor'; +import { orderingInteractionDescriptor } from './ordering/Descriptor'; + +/** + * Every interaction's descriptor: matching, parsing, building and validating XML. + * + * This module imports `Descriptor.js` files only, never an `Editor.vue`, so anything that + * just reads or writes QTI — validateItem.js, which Studio calls to decide whether a node + * is complete without rendering anything — can import it without pulling the editors and + * TipTap into its bundle. The editors are registered in `./index` instead. + * + * Registering a new interaction means adding its descriptor here and its editor there; the + * two lists are asserted to agree in __tests__/registry.spec.js. + */ + +/** + * The default interaction type used as fallback when no descriptor matches + * the interaction element found in the XML body. + */ +export const DEFAULT_INTERACTION = QtiInteraction.CHOICE; + +/** + * Ordered list of all registered interaction descriptors. + * Searched in order; the first whose `matches(el)` returns true wins. + */ +export const descriptors = [ + choiceInteractionDescriptor, + textEntryInteractionDescriptor, + orderingInteractionDescriptor, +]; + +/** + * Registry map keyed by descriptor.type for O(1) direct lookup. + * Built from the descriptors array — do not populate manually. + * + * @type {Object.} + */ +export const registry = Object.fromEntries(descriptors.map(d => [d.type, d])); + +/** + * Find the interaction descriptor that supports a given question type. + * + * @param {string} questionType + * @returns {import('./InteractionDescriptor').InteractionDescriptor|undefined} + */ +export function getDescriptorForQuestionType(questionType) { + return descriptors.find(d => d.questionTypes.includes(questionType)); +} + +/** + * Whether an interaction is authored inline, and so needs the whole item body to parse + * rather than its own element. Read off the descriptor's placement, so declaring it there + * is all a new inline interaction has to do. + * + * @param {string} tagName - The interaction's XML tag name, lower-cased + * @returns {boolean} + */ +export function isInlineInteraction(tagName) { + return registry[tagName]?.placement === Placement.INLINE; +} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js index 107a549a6b..190615f5cb 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js @@ -1,34 +1,25 @@ import { QtiInteraction } from '../constants'; -import choiceDescriptor from './choice/index'; -import textEntryDescriptor from './textEntry/index'; -import orderingDescriptor from './ordering/index'; +import ChoiceEditor from './choice/Editor.vue'; +import TextEntryEditor from './textEntry/Editor.vue'; +import OrderingEditor from './ordering/Editor.vue'; /** - * The default interaction type used as fallback when no descriptor matches - * the interaction element found in the XML body. - */ -export const DEFAULT_INTERACTION = QtiInteraction.CHOICE; - -/** - * Ordered list of all registered interaction descriptors. - * Searched in order; the first whose `matches(el)` returns true wins. - */ -export const descriptors = [choiceDescriptor, textEntryDescriptor, orderingDescriptor]; - -/** - * Registry map keyed by descriptor.type for O(1) direct lookup. - * Built from the descriptors array — do not populate manually. + * Entry point for the editor tree: the descriptors, plus the Vue component that edits each + * interaction. * - * @type {Object.} + * The editors live here rather than on the descriptors themselves so that `./descriptors` + * stays free of `.vue` files — see the note there. Import this module when something is + * going to be rendered, and `./descriptors` when it is not. */ -export const registry = Object.fromEntries(descriptors.map(d => [d.type, d])); +export const editors = Object.freeze({ + [QtiInteraction.CHOICE]: ChoiceEditor, + [QtiInteraction.TEXT_ENTRY]: TextEntryEditor, + [QtiInteraction.ORDER]: OrderingEditor, +}); -/** - * Find the interaction descriptor that supports a given question type. - * - * @param {string} questionType - * @returns {import('./defineInteraction').InteractionDescriptor|undefined} - */ -export function getDescriptorForQuestionType(questionType) { - return descriptors.find(d => d.questionTypes.includes(questionType)); -} +export { + DEFAULT_INTERACTION, + descriptors, + registry, + getDescriptorForQuestionType, +} from './descriptors'; diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/OrderingInteractionDescriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/Descriptor.js similarity index 81% rename from contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/OrderingInteractionDescriptor.js rename to contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/Descriptor.js index d6f281780d..58bce294de 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/OrderingInteractionDescriptor.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/Descriptor.js @@ -1,16 +1,17 @@ import { QtiInteraction, QuestionType, BaseType, Cardinality } from '../../constants'; +import { InteractionDescriptor } from '../InteractionDescriptor'; import { parseOrderingInteraction, buildOrderingInteractionXML } from './parse'; -import { validateOrderingInteraction } from './validate'; +import { validateOrderingInteraction } from './validation'; /** * Owns all ordering-specific interaction logic: schema, parse, buildXML, and validate. */ -export class OrderingInteractionDescriptor { - constructor({ editorComponent = null } = {}) { - this.type = QtiInteraction.ORDER; - this.placement = 'block'; - this.questionTypes = [QuestionType.ORDERING]; - this.editorComponent = editorComponent; +export class OrderingInteractionDescriptor extends InteractionDescriptor { + constructor() { + super({ + type: QtiInteraction.ORDER, + questionTypes: [QuestionType.ORDERING], + }); this.convertsFrom = []; } @@ -24,11 +25,6 @@ export class OrderingInteractionDescriptor { ]; } - /** @param {Element} el */ - matches(el) { - return el.tagName.toLowerCase() === QtiInteraction.ORDER; - } - /** * Ordering always has exactly one question type. * diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/OrderingInteractionEditor.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/Editor.vue similarity index 100% rename from contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/OrderingInteractionEditor.vue rename to contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/Editor.vue diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/OrderingInteractionEditor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/Editor.spec.js similarity index 93% rename from contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/OrderingInteractionEditor.spec.js rename to contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/Editor.spec.js index 6e082e94cf..09f4114eca 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/OrderingInteractionEditor.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/Editor.spec.js @@ -1,7 +1,7 @@ import { render, screen, fireEvent } from '@testing-library/vue'; import { nextTick } from 'vue'; import VueRouter from 'vue-router'; -import OrderingInteractionEditor from '../OrderingInteractionEditor.vue'; +import OrderingEditor from '../Editor.vue'; import { ORDERING_XML, @@ -22,12 +22,12 @@ jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => { }); const renderEditor = (props = {}) => - render(OrderingInteractionEditor, { + render(OrderingEditor, { props: { mode: 'edit', ...props }, routes: new VueRouter(), }); -describe('OrderingInteractionEditor', () => { +describe('OrderingEditor', () => { describe('edit mode rendering', () => { it('renders the prompt text from the XML', () => { renderEditor({ @@ -214,7 +214,7 @@ describe('OrderingInteractionEditor', () => { }); describe('validation', () => { - it('does not show errors before any field is touched', () => { + it('shows no errors for a question that is already complete', () => { renderEditor({ interaction: blockWithDecl(ORDERING_XML, ORDERING_DECL_XML), questionType: QuestionType.ORDERING, @@ -222,19 +222,15 @@ describe('OrderingInteractionEditor', () => { expect(screen.queryByRole('alert')).not.toBeInTheDocument(); }); - it('shows errors after runValidation is triggered by state mutation', async () => { - jest.useFakeTimers(); + it('reports what is missing as soon as the state changes', async () => { renderEditor({ interaction: block(''), questionType: QuestionType.ORDERING, }); await fireEvent.click(screen.getByRole('button', { name: tr.$tr('addItemBtn') })); await nextTick(); - jest.advanceTimersByTime(400); - await nextTick(); - jest.useRealTimers(); - // Prompt is empty → should show prompt required error - expect(screen.getAllByRole('alert').length).toBeGreaterThan(0); + + expect(screen.getByText(tr.errorPromptRequired$())).toBeInTheDocument(); }); }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/parse.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/parse.spec.js index 0cafc5b252..4fc6d09766 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/parse.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/parse.spec.js @@ -1,7 +1,7 @@ /* eslint-disable jest-dom/prefer-to-have-attribute, jest-dom/prefer-to-have-text-content */ // The eslint-dom matchers reject XML nodes produced by DOMParser(..., 'text/xml'). -import { orderingInteractionDescriptor } from '../OrderingInteractionDescriptor'; +import { orderingInteractionDescriptor } from '../Descriptor'; import { ORDERING_XML, ORDERING_DECL_XML } from '../../../utils/testingFixtures'; import { QuestionType, Orientation } from '../../../constants'; diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/validate.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/validation.spec.js similarity index 98% rename from contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/validate.spec.js rename to contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/validation.spec.js index f480cd7fcd..2222e8afd1 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/validate.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/validation.spec.js @@ -1,4 +1,4 @@ -import { validateOrderingInteraction } from '../validate'; +import { validateOrderingInteraction } from '../validation'; import { ValidationError, Orientation } from '../../../constants'; function makeItem(overrides = {}) { diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/index.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/index.js deleted file mode 100644 index 2a16ab7fcc..0000000000 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/index.js +++ /dev/null @@ -1,5 +0,0 @@ -import defineInteraction from '../defineInteraction'; -import OrderingInteractionEditor from './OrderingInteractionEditor.vue'; -import { orderingInteractionDescriptor } from './OrderingInteractionDescriptor'; - -export default defineInteraction(orderingInteractionDescriptor, OrderingInteractionEditor); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/parse.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/parse.js index 7b30cc8472..aab176caf0 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/parse.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/parse.js @@ -1,5 +1,5 @@ import { QTIDeclaration } from '../../serialization/qti/QTIDeclaration'; -import { getPromptHTML, parseXML } from '../../serialization/parseItem'; +import { getPromptHTML, parseXML } from '../../serialization/xml'; import { buildXmlNode } from '../../serialization/assembleItem'; import CorrectResponse from '../../serialization/qti/declarations/correctResponse'; import { generateRandomSlug } from '../../utils/generateRandomSlug'; diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/validate.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/validation.js similarity index 100% rename from contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/validate.js rename to contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/validation.js diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/resolveDescriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/resolveDescriptor.js new file mode 100644 index 0000000000..c02f778346 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/resolveDescriptor.js @@ -0,0 +1,42 @@ +import { parseXML } from '../serialization/xml'; +import { ValidationError } from '../constants'; +import { descriptors, registry, DEFAULT_INTERACTION } from './descriptors'; + +/** + * Resolve the interaction descriptor and question type for a single interaction block. + * + * Pure and component-free, so both the editor (via useInteractionDescriptor) and the + * headless validator (validateItem.js) can share one resolution path. + * + * @param {string} bodyXml - Serialized interaction element (or item body, for inline + * interactions) + * @param {string[]} [responseDeclarations] + * @returns {{ + * descriptor: object, + * questionType: string|null, + * error: string|null, + * }} `error` is a ValidationError code; callers own how it is presented. + */ +export function resolveDescriptor(bodyXml, responseDeclarations) { + if (!bodyXml) { + return { descriptor: registry[DEFAULT_INTERACTION], questionType: null, error: null }; + } + try { + const interactionEl = parseXML(bodyXml).documentElement; + const descriptor = + descriptors.find(d => d.matches(interactionEl)) ?? registry[DEFAULT_INTERACTION]; + return { + descriptor, + questionType: descriptor.getQuestionType(interactionEl, responseDeclarations) ?? null, + error: null, + }; + } catch (e) { + // eslint-disable-next-line no-console + console.error('[QTI] Failed to parse interaction XML:', e.message); + return { + descriptor: registry[DEFAULT_INTERACTION], + questionType: null, + error: ValidationError.PARSE_ERROR, + }; + } +} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/TextEntryInteractionDescriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Descriptor.js similarity index 85% rename from contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/TextEntryInteractionDescriptor.js rename to contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Descriptor.js index 9972eb691f..934189eea7 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/TextEntryInteractionDescriptor.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Descriptor.js @@ -1,25 +1,22 @@ -import { QtiInteraction, QuestionType, BaseType, Cardinality } from '../../constants'; -import { parseXML } from '../../serialization/parseItem'; +import { QtiInteraction, QuestionType, BaseType, Cardinality, Placement } from '../../constants'; +import { parseXML } from '../../serialization/xml'; +import { InteractionDescriptor } from '../InteractionDescriptor'; import { parseTextEntryInteraction, buildTextEntryInteractionXML } from './parse'; import { validateTextEntryInteraction } from './validation'; /** * Owns all text-entry-specific interaction logic: schema, parse, buildXML, validate. * - * placement: 'inline' — signals to parseItem that the whole - * should be passed as bodyXml rather than just the interaction element, so - * parse() can recover the prompt from body siblings. + * Inline placement means parse() is handed the whole rather than just the + * interaction element, so it can recover the prompt from the body siblings. */ -class TextEntryInteractionDescriptor { +class TextEntryInteractionDescriptor extends InteractionDescriptor { constructor() { - this.type = QtiInteraction.TEXT_ENTRY; - this.placement = 'inline'; - this.questionTypes = [ - QuestionType.NUMERIC, - QuestionType.TEXT_ENTRY, - QuestionType.FREE_RESPONSE, - ]; - this.editorComponent = null; + super({ + type: QtiInteraction.TEXT_ENTRY, + questionTypes: [QuestionType.NUMERIC, QuestionType.TEXT_ENTRY, QuestionType.FREE_RESPONSE], + placement: Placement.INLINE, + }); this.convertsFrom = []; } diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/TextEntryEditor.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Editor.vue similarity index 100% rename from contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/TextEntryEditor.vue rename to contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Editor.vue diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/TextEntryEditor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/Editor.spec.js similarity index 95% rename from contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/TextEntryEditor.spec.js rename to contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/Editor.spec.js index a804d84e81..e060c07846 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/TextEntryEditor.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/Editor.spec.js @@ -1,7 +1,7 @@ import { render, screen, fireEvent } from '@testing-library/vue'; import { nextTick } from 'vue'; import VueRouter from 'vue-router'; -import TextEntryEditor from '../TextEntryEditor.vue'; +import TextEntryEditor from '../Editor.vue'; import { TEXT_ENTRY_BODY_XML, @@ -126,10 +126,6 @@ describe('TextEntryEditor — numeric', () => { }); describe('validation', () => { - afterEach(() => { - jest.useRealTimers(); - }); - it('does not show errors before any field is touched', () => { renderEditor({ interaction: blockWithDecl(TEXT_ENTRY_BODY_XML, NUMERIC_DECL), @@ -139,7 +135,6 @@ describe('TextEntryEditor — numeric', () => { }); it('shows an error after typing a non-numeric value and blurring', async () => { - jest.useFakeTimers(); renderEditor({ interaction: blockWithDecl(TEXT_ENTRY_BODY_XML, NUMERIC_DECL), questionType: QuestionType.NUMERIC, @@ -147,22 +142,19 @@ describe('TextEntryEditor — numeric', () => { const input = answerInputs()[0]; await fireEvent.input(input, { target: { value: 'not-a-number' } }); await fireEvent.blur(input); - jest.useRealTimers(); await nextTick(); + expect(screen.getByRole('alert')).toBeInTheDocument(); }); - it('shows validation errors after a state mutation and debounce', async () => { - jest.useFakeTimers(); + it('shows validation errors as soon as the state changes', async () => { renderEditor({ interaction: block(TEXT_ENTRY_BODY_XML), questionType: QuestionType.NUMERIC, }); await fireEvent.click(screen.getByRole('button', { name: tr.$tr('addAnswerBtn') })); await nextTick(); - jest.advanceTimersByTime(400); - jest.useRealTimers(); - await nextTick(); + expect(screen.getAllByRole('alert').length).toBeGreaterThan(0); }); }); @@ -260,7 +252,10 @@ describe('TextEntryEditor — accessibility', () => { describe('TextEntryEditor — graceful fallback', () => { it('does not crash with empty bodyXml for numeric', () => { renderEditor({ interaction: block(''), questionType: QuestionType.NUMERIC }); - expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + + // An empty interaction is incomplete, and validation is not debounced, so it says so + // right away rather than rendering nothing. + expect(screen.getByText(tr.errorPromptRequired$())).toBeInTheDocument(); }); it('does not crash with empty bodyXml for freeResponse', () => { diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/parse.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/parse.spec.js index 9d308bfe41..f507069708 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/parse.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/parse.spec.js @@ -243,6 +243,28 @@ describe('buildTextEntryInteractionXML', () => { expect(doc.querySelector('qti-item-body')).not.toBeNull(); }); + it('leaves no xhtml namespace on the prompt markup', () => { + // The prompt comes from the HTML parser; an explicit xmlns on it makes the whole + // item fail schema validation on the server. + const { bodyXml } = buildTextEntryInteractionXML( + { prompt: '

What is H2O?

', answers: [], expectedLength: 0 }, + QuestionType.FREE_RESPONSE, + FREE_SCHEMA, + ); + expect(bodyXml).not.toContain('http://www.w3.org/1999/xhtml'); + }); + + it('keeps the prompt before the interaction', () => { + const { bodyXml } = buildTextEntryInteractionXML( + { prompt: '

Question

', answers: [], expectedLength: 0 }, + QuestionType.FREE_RESPONSE, + FREE_SCHEMA, + ); + expect(bodyXml.indexOf('Question')).toBeLessThan( + bodyXml.indexOf('qti-text-entry-interaction'), + ); + }); + it('contains a element', () => { const { bodyXml } = buildTextEntryInteractionXML( { prompt: '', answers: [], expectedLength: 0 }, diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/index.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/index.js deleted file mode 100644 index d587280220..0000000000 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/index.js +++ /dev/null @@ -1,5 +0,0 @@ -import defineInteraction from '../defineInteraction'; -import TextEntryEditor from './TextEntryEditor.vue'; -import { textEntryInteractionDescriptor } from './TextEntryInteractionDescriptor'; - -export default defineInteraction(textEntryInteractionDescriptor, TextEntryEditor); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js index 921bd47091..d42669475d 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js @@ -1,5 +1,5 @@ import { QTIDeclaration } from '../../serialization/qti/QTIDeclaration'; -import { parseXML } from '../../serialization/parseItem'; +import { parseXML } from '../../serialization/xml'; import { buildXmlNode } from '../../serialization/assembleItem'; import CorrectResponse from '../../serialization/qti/declarations/correctResponse'; import Mapping from '../../serialization/qti/declarations/mapping'; @@ -199,15 +199,10 @@ export function buildTextEntryInteractionXML(state, questionType, declarationSch children: [interactionEl], }); - // Build body children: prompt HTML nodes (if any) followed by the interaction paragraph. - const bodyChildren = []; - if (prompt) { - const promptDoc = parseXML(`${prompt}`, 'text/html'); - bodyChildren.push(...promptDoc.body.childNodes); - } - bodyChildren.push(interactionParagraph); - - const bodyEl = buildXmlNode({ tag: 'qti-item-body', children: bodyChildren }); + // The prompt is authored HTML, so it goes in through innerHTML: buildXmlNode parses it + // and adopts the result into the item's namespace. + const bodyEl = buildXmlNode({ tag: 'qti-item-body', innerHTML: prompt || '' }); + bodyEl.appendChild(interactionParagraph); const bodyXml = serializer.serializeToString(bodyEl); // Build the response declaration. diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js index 2100820126..119685454a 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js @@ -29,6 +29,15 @@ export const qtiEditorStrings = createTranslator('QTIEditorStrings', { message: 'Show answers', context: 'Checkbox label to toggle displaying answers/previews', }, + incompleteItemIndicatorLabel: { + message: 'Incomplete', + context: 'Shown in a question card header when the question is missing something', + }, + unsupportedItemMessage: { + message: 'This question cannot be edited here', + context: + 'Shown in place of the editor for questions authored elsewhere, or whose content could not be read', + }, singleSelectLabel: { message: 'Single Choice', context: 'Display name for a single-select question type', diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/assembleItem.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/assembleItem.spec.js index 47192f2ddc..555ea86f03 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/assembleItem.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/assembleItem.spec.js @@ -142,6 +142,38 @@ describe('assembleItem', () => { buildXmlNode({ tag: 'qti-simple-choice', children: ['x'], innerHTML: '

y

' }), ).toThrow('mutually exclusive'); }); + + it('leaves no xhtml namespace on the markup it appends', () => { + // The QTI schema expects inline content in the namespace the item root declares, so + // an explicit xmlns from the HTML parser makes the whole item invalid on the server. + const node = buildXmlNode({ + tag: 'qti-simple-choice', + innerHTML: '

Lima

', + }); + expect(new XMLSerializer().serializeToString(node)).toBe( + '

Lima

', + ); + }); + + it('drops an xhtml namespace already carried by stored content', () => { + const node = buildXmlNode({ + tag: 'qti-simple-choice', + innerHTML: '

Lima

', + }); + expect(new XMLSerializer().serializeToString(node)).toBe( + '

Lima

', + ); + }); + + it('keeps a foreign namespace, which QTI expects declared', () => { + const node = buildXmlNode({ + tag: 'qti-prompt', + innerHTML: 'x', + }); + expect(new XMLSerializer().serializeToString(node)).toContain( + '', + ); + }); }); describe('innerHTML — HTML5 void elements (TipTap regression)', () => { diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/createBlankItem.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/createBlankItem.spec.js new file mode 100644 index 0000000000..f1c2200213 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/createBlankItem.spec.js @@ -0,0 +1,32 @@ +import { createBlankItemXml, DEFAULT_ITEM_TITLE } from '../createBlankItem'; +import { parseItem } from '../parseItem'; +import { parseXML } from '../xml'; +import { QtiInteraction } from '../../constants'; +import { validateQtiItem } from '../../validateItem'; + +describe('createBlankItemXml', () => { + it('produces an item holding exactly one default interaction', () => { + const { interactions } = parseItem(createBlankItemXml()); + + expect(interactions).toHaveLength(1); + expect(parseXML(interactions[0].bodyXml).documentElement.tagName.toLowerCase()).toBe( + QtiInteraction.CHOICE, + ); + }); + + it('stamps a unique identifier and the default title', () => { + const first = parseItem(createBlankItemXml()); + const second = parseItem(createBlankItemXml()); + + expect(first.title).toBe(DEFAULT_ITEM_TITLE); + // The identifier is an XML NCName: a letter or underscore, then name characters. + expect(first.identifier).toMatch(/^[A-Za-z_][\w.-]*$/); + expect(first.identifier).not.toBe(second.identifier); + }); + + it('is renderable but not yet complete, so the author has something to fill in', () => { + // A blank item must parse into an interaction — otherwise the editor has nothing to + // render — while still reporting as invalid until the author fills it in. + expect(validateQtiItem(createBlankItemXml()).length).toBeGreaterThan(0); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/parseItem.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/parseItem.spec.js index 48b8941460..711b63bb2f 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/parseItem.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/parseItem.spec.js @@ -1,5 +1,5 @@ /* eslint-disable jest-dom/prefer-to-have-attribute, jest-dom/prefer-to-have-text-content */ -import { parseXML, parseItem } from '../parseItem'; +import { parseItem } from '../parseItem'; import { VALID_CHOICE_ITEM_DOCUMENT, TWO_INTERACTIONS_DOCUMENT } from '../../utils/testingFixtures'; // Fixtures @@ -15,46 +15,6 @@ const ITEM_NO_INTERACTIONS = ` `; -// parseXML -describe('parseXML', () => { - it('parses valid XML into a Document', () => { - const doc = parseXML(VALID_CHOICE_ITEM_DOCUMENT); - expect(doc).toBeInstanceOf(Document); - expect(doc.querySelector('qti-assessment-item')).not.toBeNull(); - }); - - it('throws for malformed XML', () => { - expect(() => parseXML(' { - // An extra closing tag causes a parsererror in jsdom - expect(() => parseXML('')).toThrow(/QTI XML parse error/i); - }); - - it('parses valid XML when text/xml is passed explicitly', () => { - const doc = parseXML(VALID_CHOICE_ITEM_DOCUMENT, 'text/xml'); - expect(doc.querySelector('qti-assessment-item')).not.toBeNull(); - }); - - it('parses HTML leniently into a Document when text/html is passed', () => { - const doc = parseXML('bold', 'text/html'); - expect(doc).toBeInstanceOf(Document); - // doc.body is a DOMParser-realm node, not a testing-library node, so - // toHaveTextContent rejects it; assert on textContent directly. - // eslint-disable-next-line jest-dom/prefer-to-have-text-content - expect(doc.body.textContent).toBe('bold'); - }); - - it('does not throw for malformed HTML', () => { - expect(() => parseXML(' { - expect(() => parseXML('x', 'text/html')).not.toThrow(); - }); -}); - // parseItem — meta extraction describe('parseItem — meta', () => { it('returns an object with the top-level item attributes', () => { diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/xml.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/xml.spec.js new file mode 100644 index 0000000000..7d08c3f7f5 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/xml.spec.js @@ -0,0 +1,58 @@ +import { parseXML, getPromptHTML } from '../xml'; +import { VALID_CHOICE_ITEM_DOCUMENT } from '../../utils/testingFixtures'; + +// parseXML +describe('parseXML', () => { + it('parses valid XML into a Document', () => { + const doc = parseXML(VALID_CHOICE_ITEM_DOCUMENT); + expect(doc).toBeInstanceOf(Document); + expect(doc.querySelector('qti-assessment-item')).not.toBeNull(); + }); + + it('throws for malformed XML', () => { + expect(() => parseXML(' { + // An extra closing tag causes a parsererror in jsdom + expect(() => parseXML('')).toThrow(/QTI XML parse error/i); + }); + + it('parses valid XML when text/xml is passed explicitly', () => { + const doc = parseXML(VALID_CHOICE_ITEM_DOCUMENT, 'text/xml'); + expect(doc.querySelector('qti-assessment-item')).not.toBeNull(); + }); + + it('parses HTML leniently into a Document when text/html is passed', () => { + const doc = parseXML('bold', 'text/html'); + expect(doc).toBeInstanceOf(Document); + // doc.body is a DOMParser-realm node, not a testing-library node, so + // toHaveTextContent rejects it; assert on textContent directly. + // eslint-disable-next-line jest-dom/prefer-to-have-text-content + expect(doc.body.textContent).toBe('bold'); + }); + + it('does not throw for malformed HTML', () => { + expect(() => parseXML(' { + expect(() => parseXML('x', 'text/html')).not.toThrow(); + }); +}); + +describe('getPromptHTML', () => { + it('returns the prompt markup of an interaction', () => { + const el = parseXML( + 'Pick one', + ).documentElement; + + expect(getPromptHTML(el)).toBe('Pick one'); + }); + + it('returns an empty string when the interaction has no prompt', () => { + const el = parseXML('').documentElement; + + expect(getPromptHTML(el)).toBe(''); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/assembleItem.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/assembleItem.js index 48295a8889..68d7386376 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/assembleItem.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/assembleItem.js @@ -8,11 +8,56 @@ * (e.g. XMLSerializer.serializeToString). */ -import { parseXML } from './parseItem'; +import { parseXML } from './xml'; const xmlDoc = new DOMParser().parseFromString('', 'text/xml'); const serializer = new XMLSerializer(); +const XHTML_NS = 'http://www.w3.org/1999/xhtml'; + +/** + * Re-create a node parsed from HTML inside the XML document. + * + * The HTML parser puts elements in the XHTML namespace, and XMLSerializer then writes + * that out as an explicit `xmlns` on every element it produces — `

`. + * The QTI schema rejects that: inline content belongs to the QTI namespace the item root + * declares, so these elements have to be namespace-less in order to inherit it. Foreign + * subtrees (MathML, SVG) keep their own namespace, which QTI does expect declared. + * + * @param {Node} node + * @returns {Node|null} null for node types that carry no content (comments, etc.) + */ +function adoptHtmlNode(node) { + if (node.nodeType === Node.TEXT_NODE) { + return xmlDoc.createTextNode(node.nodeValue); + } + if (node.nodeType !== Node.ELEMENT_NODE) { + return null; + } + + const namespace = node.namespaceURI; + const el = + !namespace || namespace === XHTML_NS + ? xmlDoc.createElement(node.localName) + : xmlDoc.createElementNS(namespace, node.tagName); + + for (const attr of node.attributes) { + // A literal xmlns attribute would re-introduce the namespace we just dropped. + if (attr.name !== 'xmlns') { + el.setAttribute(attr.name, attr.value); + } + } + + for (const child of node.childNodes) { + const adopted = adoptHtmlNode(child); + if (adopted) { + el.appendChild(adopted); + } + } + + return el; +} + /** * Build an XML element node. * @@ -42,7 +87,10 @@ export function buildXmlNode({ tag, attrs = {}, children, innerHTML }) { if (innerHTML !== undefined) { const htmlDoc = parseXML(`${innerHTML}`, 'text/html'); for (const child of [...htmlDoc.body.childNodes]) { - el.appendChild(xmlDoc.importNode(child, true)); + const adopted = adoptHtmlNode(child); + if (adopted) { + el.appendChild(adopted); + } } } else { for (const child of children ?? []) { @@ -100,9 +148,8 @@ export function assembleItemXml({ identifier, title, language, bodyXml, response tag: 'qti-assessment-item', attrs: { xmlns: 'http://www.imsglobal.org/xsd/imsqtiasi_v3p0', - // TODO: We will need to properly generate the identifier and title - // on the useQtiItem composable when we integrate the question type selector - // and have the add question button working. + // New items get their identifier and title from createBlankItem.js; these fallbacks + // only cover items assembled from XML that never carried them. identifier: identifier || 'item', title: title || '', adaptive: 'false', diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/createBlankItem.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/createBlankItem.js new file mode 100644 index 0000000000..39f9737825 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/createBlankItem.js @@ -0,0 +1,36 @@ +import { QuestionType } from '../constants'; +import { choiceInteractionDescriptor } from '../interactions/choice/Descriptor'; +import { _defaultState } from '../interactions/choice/parse'; +import { generateRandomSlug } from '../utils/generateRandomSlug'; +import { assembleItemXml } from './assembleItem'; + +/** + * Title stamped on newly created items. Deliberately fixed rather than derived from the + * item's position, which would go stale on the next reorder. + */ +export const DEFAULT_ITEM_TITLE = 'Question'; + +/** + * Build the QTI XML for a brand new, empty assessment item. + * + * A new item cannot start with empty `raw_data`: the editor only renders an interaction + * when one is present in the body, and the server validates every item against the QTI + * schema before storing it. So a new item starts as the default interaction's empty + * state, which the author then fills in. + * + * @returns {string} Full QTI assessment item XML + */ +export function createBlankItemXml() { + const { bodyXml, responseDeclarations } = choiceInteractionDescriptor.buildXML( + _defaultState(), + QuestionType.SINGLE_SELECT, + ); + + return assembleItemXml({ + identifier: generateRandomSlug('item'), + title: DEFAULT_ITEM_TITLE, + language: '', + bodyXml, + responseDeclarations, + }); +} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/parseItem.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/parseItem.js index d0bccb018c..0374dcbfc4 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/parseItem.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/parseItem.js @@ -1,52 +1,8 @@ -import { QTI_INTERACTION_TAGS, INLINE_INTERACTION_TAGS } from '../constants'; +import { QTI_INTERACTION_TAGS } from '../constants'; +import { isInlineInteraction } from '../interactions/descriptors'; +import { parseXML } from './xml'; const serializer = new XMLSerializer(); -const parser = new DOMParser(); - -/** - * Parses a QTI XML or HTML string into a Document. - * - * @param {string} xmlString - Raw QTI XML (or HTML fragment) string - * @param {string} [mimeType='text/xml'] - Parse mode. `'text/xml'` runs the - * `parsererror` check; `'text/html'` parses leniently and never throws. - * @returns {Document} Parsed XML or HTML Document - * @throws {Error} If parsing as `'text/xml'` and the input is malformed or - * contains a parsererror. HTML parsing never throws. - */ -export function parseXML(xmlString, mimeType = 'text/xml') { - let input = xmlString; - if (mimeType === 'text/xml') { - input = xmlString.replace(/ xmlns="[^"]*"/, ''); - } - - const doc = parser.parseFromString(input, mimeType); - - // DOMParser never throws — it signals failure via a node. This - // only applies to XML: the HTML parser recovers silently and never emits one, - // so an HTML document literally containing a must not trip it. - if (mimeType === 'text/xml') { - const error = doc.querySelector('parsererror'); - if (error) { - throw new Error(`QTI XML parse error: ${error.textContent.trim()}`); - } - } - - return doc; -} - -/** - * Extract the inner HTML of the first child of an interaction element. - * Returns an empty string when no prompt element is present. - * Using innerHTML (not textContent) preserves rich inline markup (

, , etc.) - * for round-trip fidelity. - * - * @param {Element} interactionEl - The root element - * @returns {string} - */ -export function getPromptHTML(interactionEl) { - const promptEl = interactionEl.querySelector('qti-prompt'); - return promptEl ? promptEl.innerHTML : ''; -} /** * Parses a raw QTI XML string into the structured ItemModel. @@ -55,9 +11,9 @@ export function getPromptHTML(interactionEl) { * A response declaration belongs to an interaction when the declaration's * `identifier` matches the interaction's `response-identifier` attribute. * - * For descriptors with `placement: 'inline'`, `bodyXml` is the serialized - * `` rather than the interaction element alone, so the - * interaction's parse() function can recover prompt content from body siblings. + * An interaction its descriptor declares as inline gets the serialized + * `` as its `bodyXml` rather than the interaction element alone, + * so its parse() can recover prompt content from body siblings. * * @param {string} rawData - Raw QTI XML string (the full assessment item XML) * @returns {{ @@ -93,7 +49,7 @@ export function parseItem(rawData) { .filter(d => d.getAttribute('identifier') === responseId) .map(d => serializer.serializeToString(d)); - const isInline = INLINE_INTERACTION_TAGS.has(el.tagName.toLowerCase()); + const isInline = isInlineInteraction(el.tagName.toLowerCase()); interactions.push({ bodyXml: isInline ? serializer.serializeToString(body) : serializer.serializeToString(el), diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/QTIDeclaration.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/QTIDeclaration.js index 9941b80309..ac2ef196d1 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/QTIDeclaration.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/QTIDeclaration.js @@ -330,7 +330,11 @@ export class QTIDeclaration { attrs['base-type'] = this.baseType; } - const children = Object.values(this._capabilities).map(cap => cap.getXML()); + // A capability returns null when it has nothing valid to serialize (e.g. a correct + // response with no values); those are dropped rather than emitted empty. + const children = Object.values(this._capabilities) + .map(cap => cap.getXML()) + .filter(Boolean); return buildXmlNode({ tag: this.tag, attrs, children }); } diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/QTISanitizer.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/QTISanitizer.js index 7c50c8b240..97736127db 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/QTISanitizer.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/QTISanitizer.js @@ -4,7 +4,7 @@ * @module serialization/qti/QTISanitizer */ -import { parseXML } from '../parseItem'; +import { parseXML } from '../xml'; // Valid QTI 3.0 base-type values — https://www.imsglobal.org/spec/qti/v3p0/impl/#h.wq4e8lbs4wa9 const VALID_BASE_TYPES = new Set([ diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/declarations/correctResponse.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/declarations/correctResponse.spec.js index 7aa7c250fc..3b9f87fe08 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/declarations/correctResponse.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/declarations/correctResponse.spec.js @@ -110,10 +110,10 @@ describe('CorrectResponse', () => { expect(values).toEqual(['ChoiceA', 'ChoiceC']); }); - it('produces an empty qti-correct-response when values is empty', () => { - expect( - new CorrectResponse([], makeDeclaration()).getXML().querySelectorAll('qti-value').length, - ).toBe(0); + it('produces no element at all when values is empty', () => { + // The schema requires at least one qti-value inside qti-correct-response, so an + // answer-less declaration omits the element instead of emitting an empty one. + expect(new CorrectResponse([], makeDeclaration()).getXML()).toBeNull(); }); it('round-trips: qti-value child carries correct text', () => { diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/testUtils.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/testUtils.js index 0d95a0b659..d1d9b50adb 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/testUtils.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/testUtils.js @@ -1,7 +1,7 @@ /** * Shared XML parse helper for declaration tests. */ -import { parseXML as parseXMLDocument } from '../../parseItem'; +import { parseXML as parseXMLDocument } from '../../xml'; const serializer = new XMLSerializer(); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/correctResponse.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/correctResponse.js index e492ea8c9b..c492e4712d 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/correctResponse.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/correctResponse.js @@ -42,9 +42,16 @@ export default class CorrectResponse { } /** - * @returns {Element} + * `qti-correct-response` is optional but must hold at least one `qti-value` when + * present, so an answer-less declaration omits the element rather than emitting an + * empty one the schema would reject. + * + * @returns {Element|null} */ getXML() { + if (!this._values.length) { + return null; + } return buildXmlNode({ tag: 'qti-correct-response', children: this._declaration diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/defaultValue.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/defaultValue.js index 00432f8e0b..0712c94f42 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/defaultValue.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/defaultValue.js @@ -40,9 +40,15 @@ export default class DefaultValue { } /** - * @returns {Element} + * Like `qti-correct-response`, the element is optional but must hold at least one + * `qti-value`, so an empty one is omitted rather than emitted. + * + * @returns {Element|null} */ getXML() { + if (!this._values.length) { + return null; + } return buildXmlNode({ tag: 'qti-default-value', children: this._declaration diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/xml.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/xml.js new file mode 100644 index 0000000000..a25a948d47 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/xml.js @@ -0,0 +1,50 @@ +/** + * DOM helpers for reading QTI XML. + */ + +const parser = new DOMParser(); + +/** + * Parses a QTI XML or HTML string into a Document. + * + * @param {string} xmlString - Raw QTI XML (or HTML fragment) string + * @param {string} [mimeType='text/xml'] - Parse mode. `'text/xml'` runs the + * `parsererror` check; `'text/html'` parses leniently and never throws. + * @returns {Document} Parsed XML or HTML Document + * @throws {Error} If parsing as `'text/xml'` and the input is malformed or + * contains a parsererror. HTML parsing never throws. + */ +export function parseXML(xmlString, mimeType = 'text/xml') { + let input = xmlString; + if (mimeType === 'text/xml') { + input = xmlString.replace(/ xmlns="[^"]*"/, ''); + } + + const doc = parser.parseFromString(input, mimeType); + + // DOMParser never throws — it signals failure via a node. This + // only applies to XML: the HTML parser recovers silently and never emits one, + // so an HTML document literally containing a must not trip it. + if (mimeType === 'text/xml') { + const error = doc.querySelector('parsererror'); + if (error) { + throw new Error(`QTI XML parse error: ${error.textContent.trim()}`); + } + } + + return doc; +} + +/** + * Extract the inner HTML of the first child of an interaction element. + * Returns an empty string when no prompt element is present. + * Using innerHTML (not textContent) preserves rich inline markup (

, , etc.) + * for round-trip fidelity. + * + * @param {Element} interactionEl - The root element + * @returns {string} + */ +export function getPromptHTML(interactionEl) { + const promptEl = interactionEl.querySelector('qti-prompt'); + return promptEl ? promptEl.innerHTML : ''; +} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/useQTIEditorActions.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/useQTIEditorActions.js index cd3b77c29a..62fbbb40d1 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/useQTIEditorActions.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/useQTIEditorActions.js @@ -1,4 +1,5 @@ import { qtiEditorStrings } from './qtiEditorStrings'; +import { AssessmentItemTypes } from './constants'; /** * Generates the toolbar actions array for a specific QTI item in the list. @@ -32,7 +33,8 @@ export default function useQTIEditorActions({ label: toolbarLabelEdit$(), handler: () => openItem(item.assessment_id), collapsed: false, - disabled: isEditMode, + // Items authored elsewhere (e.g. Perseus) can be moved or removed, but not opened. + disabled: isEditMode || item.type !== AssessmentItemTypes.QTI, }); result.push({ diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/__tests__/math.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/__tests__/math.spec.js new file mode 100644 index 0000000000..346742fa4e --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/__tests__/math.spec.js @@ -0,0 +1,23 @@ +import { floatOrIntRegex } from '../math'; + +describe('floatOrIntRegex', () => { + it('tests true for valid values', () => { + [ + '1.5', // Float + '-4.5', // Signed Float + '+1', // Signed Int + '10e5', // Exponentiation + '-15.3e5', // Combo + '-12345.67890e98', // Combo 2 + ].forEach(v => expect(floatOrIntRegex.test(v)).toBe(true)); + }); + + it('tests false for invalid values', () => { + [ + 'i * 1.5', // Math + 'one.point.five', // Text + '10 5 0 100', // Spaces + '1.2.3.4', // IP + ].forEach(v => expect(floatOrIntRegex.test(v)).toBe(false)); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js index 24c12a77c2..c59c696dfe 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js @@ -91,6 +91,120 @@ export const VALID_CHOICE_ITEM_DOCUMENT = ` `; +export const CHOICE_ITEM_DOCUMENT_NO_PROMPT = ` + + + + choice-a + + + + + + A + B + + +`; + +export const CHOICE_ITEM_DOCUMENT_NO_CORRECT_ANSWER = ` + + + + + + Pick one. + A + B + + +`; + +/** + * An ordering item missing its prompt — used to check that an interaction which reports + * nothing to the card is still reported as incomplete. + */ +export const ORDERING_ITEM_DOCUMENT_NO_PROMPT = ` + + + + order_aaa11111 + order_bbb22222 + + + + + + Mercury + Venus + + +`; + +/** + * A text-entry item whose declaration carries no correct response — an open-ended + * question, which only surveys accept. + */ +export const FREE_RESPONSE_ITEM_DOCUMENT = ` + + + + +

Tell us what you think.

+

+
+`; + +export const NO_INTERACTION_ITEM_DOCUMENT = ` + + +

Just some text.

+
+
`; + export const TWO_INTERACTIONS_DOCUMENT = ` } Empty when the item is valid + */ +export function validateQtiItem(rawData, { allowFreeResponse = true } = {}) { + if (!rawData) { + return [{ code: ValidationError.NO_INTERACTION }]; + } + + let item; + try { + item = parseItem(rawData); + } catch { + return [{ code: ValidationError.PARSE_ERROR }]; + } + + if (!item.interactions.length) { + return [{ code: ValidationError.NO_INTERACTION }]; + } + + const errors = []; + for (const { bodyXml, responseDeclarations } of item.interactions) { + const { descriptor, questionType, error } = resolveDescriptor(bodyXml, responseDeclarations); + if (error) { + errors.push({ code: error }); + continue; + } + if (!allowFreeResponse && questionType === QuestionType.FREE_RESPONSE) { + errors.push({ code: ValidationError.FREE_RESPONSE_NOT_ALLOWED }); + } + const state = descriptor.parse(bodyXml, responseDeclarations); + errors.push(...descriptor.validate(state, questionType)); + } + return errors; +} diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue index 0e114c49ea..5af52542bb 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue @@ -126,6 +126,7 @@ import { useMathHandling } from './composables/useMathHandling'; import FormulasMenu from './components/math/FormulasMenu.vue'; import { preprocessMarkdown } from './utils/markdown'; + import { resolveImageSrcs, toStoredImageSrcs } from './utils/imageSrc'; import MobileTopBar from './components/toolbar/MobileTopBar.vue'; import MobileFormattingBar from './components/toolbar/MobileFormattingBar.vue'; import { getTipTapEditorStrings } from './TipTapEditorStrings'; @@ -195,7 +196,10 @@ const getContent = () => { if (!editor.value || !isReady.value) return ''; - if (props.format === 'html') return editor.value.getHTML(); + // Image srcs are resolved for display on the way in, so they are reduced + // back to their stored form here — leaving this the one place that reads + // content out, whichever form the editor happens to be holding. + if (props.format === 'html') return toStoredImageSrcs(editor.value.getHTML()); if (!editor.value.storage?.markdown) return ''; return editor.value.storage.markdown.getMarkdown(); }; @@ -231,7 +235,7 @@ } const processedContent = - props.format === 'html' ? newValue : preprocessMarkdown(newValue); + props.format === 'html' ? resolveImageSrcs(newValue) : preprocessMarkdown(newValue); if (!editor.value) { initializeEditor(processedContent, props.mode, { diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/imageSrc.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/imageSrc.js new file mode 100644 index 0000000000..38c28c2855 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/imageSrc.js @@ -0,0 +1,71 @@ +// Translates image sources between the two forms they take in HTML content. +// +// Stored content references an image by bare `.` filename, which is +// what publishing rewrites into a package's images/ directory (see the backend's +// utils/assessment/qti/media.py) and what the QTI Img model accepts — it rejects +// absolute paths outright. The browser, though, needs a URL it can load, so the +// filename is resolved to its storage URL on the way into the editor and reduced +// back to the filename on the way out. +// +// The markdown format does the same thing through preprocessMarkdown/paramsToImageMd; +// these are its counterparts for content that is already HTML. +import { storageUrl } from 'shared/vuex/file/utils'; + +// Kept identical to QTI_CHECKSUM_FILENAME_REGEX in media.py, which decides on the +// backend which references publishing is able to resolve. +const CHECKSUM_FILENAME = /^([a-f0-9]{32})\.([0-9a-z]+)$/; + +const IMG_TAG = /]*>/gi; +const SRC_ATTRIBUTE = /\bsrc\s*=\s*(["'])(.*?)\1/i; + +/** + * Rewrite the src of every in an HTML string. + * + * A targeted substitution rather than a parse-and-serialize round trip, so + * everything else about the markup — attribute order, self-closing style, + * whitespace — survives untouched. + * + * @param {string} html + * @param {function(string): string} mapSrc + * @returns {string} + */ +function mapImageSrcs(html, mapSrc) { + if (!html) { + return html; + } + return html.replace(IMG_TAG, tag => + tag.replace(SRC_ATTRIBUTE, (attribute, quote, src) => { + const mapped = mapSrc(src); + return mapped === src ? attribute : `src=${quote}${mapped}${quote}`; + }), + ); +} + +/** + * Turn stored `.` sources into loadable storage URLs. + * + * @param {string} html + * @returns {string} + */ +export function resolveImageSrcs(html) { + return mapImageSrcs(html, src => { + const match = CHECKSUM_FILENAME.exec(src); + return match ? storageUrl(match[1], match[2]) : src; + }); +} + +/** + * Reduce storage URLs back to the `.` filename that gets stored. + * + * Sources that are not a checksum filename — a data URI, a remote image — are left + * as they are. + * + * @param {string} html + * @returns {string} + */ +export function toStoredImageSrcs(html) { + return mapImageSrcs(html, src => { + const filename = src.split('/').pop(); + return CHECKSUM_FILENAME.test(filename) ? filename : src; + }); +} diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/imageSrc.spec.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/imageSrc.spec.js new file mode 100644 index 0000000000..54b949c355 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/imageSrc.spec.js @@ -0,0 +1,64 @@ +import { resolveImageSrcs, toStoredImageSrcs } from '../TipTapEditor/utils/imageSrc'; + +const CHECKSUM = '83ab37e959e03fec7be3e1bf834cb169'; +const FILENAME = `${CHECKSUM}.jpg`; +const STORAGE_URL = `/content/storage/8/3/${FILENAME}`; + +describe('resolveImageSrcs', () => { + it('turns a stored filename into its storage URL', () => { + expect(resolveImageSrcs(`a`)).toBe( + `a`, + ); + }); + + it('keeps the rest of the tag as it was', () => { + expect(resolveImageSrcs(`

text more

`)).toBe( + `

text more

`, + ); + }); + + it('resolves every image in the content', () => { + const html = ``; + expect(resolveImageSrcs(html)).toBe(``); + }); + + it('leaves an already resolved src alone', () => { + expect(resolveImageSrcs(``)).toBe(``); + }); + + it('leaves a src that is not a checksum filename alone', () => { + const html = ''; + expect(resolveImageSrcs(html)).toBe(html); + }); + + it('ignores a src outside an img tag', () => { + const html = ``; + expect(resolveImageSrcs(html)).toBe(html); + }); + + it('returns empty content unchanged', () => { + expect(resolveImageSrcs('')).toBe(''); + }); +}); + +describe('toStoredImageSrcs', () => { + it('reduces a storage URL to the filename that gets stored', () => { + expect(toStoredImageSrcs(`a`)).toBe( + `a`, + ); + }); + + it('leaves an already stored src alone', () => { + expect(toStoredImageSrcs(``)).toBe(``); + }); + + it('leaves a src that is not a checksum filename alone', () => { + const html = ''; + expect(toStoredImageSrcs(html)).toBe(html); + }); + + it('is the inverse of resolveImageSrcs', () => { + const html = `

a

`; + expect(toStoredImageSrcs(resolveImageSrcs(html))).toBe(html); + }); +}); diff --git a/contentcuration/contentcuration/tests/utils/qti/fixtures/single_selection_no_answers.xml b/contentcuration/contentcuration/tests/utils/qti/fixtures/single_selection_no_answers.xml new file mode 100644 index 0000000000..c480798ac6 --- /dev/null +++ b/contentcuration/contentcuration/tests/utils/qti/fixtures/single_selection_no_answers.xml @@ -0,0 +1,9 @@ + + + + +
+

What is 2+2?

+
+
+
diff --git a/contentcuration/contentcuration/tests/utils/qti/test_convert.py b/contentcuration/contentcuration/tests/utils/qti/test_convert.py index 63d5ec32e2..c5f5e7c649 100644 --- a/contentcuration/contentcuration/tests/utils/qti/test_convert.py +++ b/contentcuration/contentcuration/tests/utils/qti/test_convert.py @@ -117,6 +117,71 @@ def test_true_false(self): ) self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid) + def test_single_selection_no_answers(self): + item = _make_item( + type=exercises.SINGLE_SELECTION, + question="What is 2+2?", + answers=[], + randomize=True, + assessment_id="abcdef1234567890abcdef1234567890", + ) + + result = convert_legacy_assessment_item_to_qti(item) + + self.assertEqual(result.identifier, "Kq83vEjRWeJCrze8SNFZ4kA") + self.assertEqual( + _normalize_xml(_load_fixture("single_selection_no_answers.xml")), + _normalize_xml(result.xml), + ) + self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid) + + def test_choice_types_with_no_answers_omit_the_interaction(self): + # The guard covers the choice types as a group; test_single_selection_no_answers + # pins SINGLE_SELECTION against the fixture. + for question_type in (exercises.MULTIPLE_SELECTION, "true_false"): + with self.subTest(question_type=question_type): + item = _make_item( + type=question_type, + question="What is 2+2?", + answers=[], + assessment_id="abcdef1234567890abcdef1234567890", + ) + + result = convert_legacy_assessment_item_to_qti(item) + + self.assertNotIn("qti-choice-interaction", result.xml) + self.assertNotIn("qti-response-declaration", result.xml) + self.assertNotIn("qti-response-processing", result.xml) + self.assertIn("

What is 2+2?

", result.xml) + self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid) + + def test_choice_type_with_no_answers_and_no_question(self): + item = _make_item( + type=exercises.MULTIPLE_SELECTION, + question="", + answers=[], + assessment_id="abcdef1234567890abcdef1234567890", + ) + + result = convert_legacy_assessment_item_to_qti(item) + + self.assertIn("

", result.xml) + self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid) + + def test_choice_type_with_no_answers_and_block_maths(self): + # Validity is not asserted: rendered MathML carries no namespace, the same + # gap test_free_response_with_maths lives with. + item = _make_item( + type=exercises.SINGLE_SELECTION, + question="$$\\sum_n^sxa^n$$", + answers=[], + assessment_id="abcdef1234567890abcdef1234567890", + ) + + result = convert_legacy_assessment_item_to_qti(item) + + self.assertIn('
', result.xml) + def test_media_reference_survives(self): item = _make_item( type=exercises.SINGLE_SELECTION, diff --git a/contentcuration/contentcuration/tests/utils/qti/test_validation.py b/contentcuration/contentcuration/tests/utils/qti/test_validation.py index c18ccb277d..467cafedf4 100644 --- a/contentcuration/contentcuration/tests/utils/qti/test_validation.py +++ b/contentcuration/contentcuration/tests/utils/qti/test_validation.py @@ -152,6 +152,32 @@ def test_rejects_item_with_unknown_root_element(self): self.assertTrue(result.errors) +# Mirrors what the QTI editor emits for a brand new question, before the author has +# written anything — see createBlankItem.js. Every "New question" click sends this to the +# sync endpoint, which validates it, so the two have to stay in lockstep. +BLANK_EDITOR_ITEM = ( + '\n' + '' + '' + "" + '' + '' + "" + "" + "" +) + + +class BlankEditorItemTests(unittest.TestCase): + def test_accepts_blank_item_from_editor(self): + result = validate_qti_item(BLANK_EDITOR_ITEM) + self.assertTrue(result.is_valid) + self.assertEqual(result.errors, []) + + class SchemaReuseTests(unittest.TestCase): def test_schema_compiled_once_across_multiple_validate_calls(self): _compiled_schema.cache_clear() diff --git a/contentcuration/contentcuration/tests/utils/test_markdown.py b/contentcuration/contentcuration/tests/utils/test_markdown.py index 0088d4a09e..655f44dbf5 100644 --- a/contentcuration/contentcuration/tests/utils/test_markdown.py +++ b/contentcuration/contentcuration/tests/utils/test_markdown.py @@ -267,3 +267,75 @@ def _assert_conversion(self, markdown_text: str, expected: str): roundtrip_result.replace("\n", "").strip(), expected.replace("\n", "").strip(), ) + + +class SizedImageTests(unittest.TestCase): + """Perseus images, whose size and alignment suffixes are not valid CommonMark.""" + + def test_size_suffix_becomes_width_and_height(self): + self.assertEqual( + render_markdown("![Test](83ab37e959e03fec7be3e1bf834cb169.jpg =550x364)"), + '

Test

\n', + ) + + def test_image_without_alt_text(self): + self.assertEqual( + render_markdown("![](cs.png =12x34)"), + '

\n', + ) + + def test_align_suffix_is_consumed_but_dropped(self): + # Consumed so the image parses at all; dropped because QTI's Img has no + # attribute to carry it. + self.assertEqual( + render_markdown("![a](cs.png align=center)"), + '

a

\n', + ) + + def test_size_and_align_together(self): + self.assertEqual( + render_markdown("![a](cs.png =12x34 align=right)"), + '

a

\n', + ) + + def test_fractional_size_is_rounded(self): + self.assertEqual( + render_markdown("![a](cs.png =229.5x287.2)"), + '

a

\n', + ) + + def test_src_is_reduced_to_the_bare_filename(self): + self.assertEqual( + render_markdown("![a](images/cs.png =12x34)"), + '

a

\n', + ) + + def test_image_keeps_its_surrounding_text(self): + self.assertEqual( + render_markdown("before ![a](cs.png =1x2) after"), + '

before a after

\n', + ) + + def test_alt_text_is_escaped(self): + self.assertEqual( + render_markdown('![