Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { validateItemShape, validateQtiItem } from '../validateItem';
import { QuestionType, 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('<qti-assessment-item><oops>')).toEqual([
{ code: ValidationError.PARSE_ERROR },
]);
});
});

// What the editor asks about an item it is already showing, which is everything an
// interaction cannot answer for itself.
describe('validateItemShape', () => {
it('accepts an item with something to answer', () => {
expect(
validateItemShape({ interactions: [{}], questionTypes: [QuestionType.SINGLE_SELECT] }),
).toEqual([]);
});

it('reports an item with nothing to answer', () => {
expect(validateItemShape({ interactions: [] })).toEqual([
{ code: ValidationError.NO_INTERACTION },
]);
});

it('accepts a free-response question when the consumer allows it', () => {
expect(
validateItemShape({
interactions: [{}],
questionTypes: [QuestionType.FREE_RESPONSE],
allowFreeResponse: true,
}),
).toEqual([]);
});

it('reports a free-response question when the consumer scores its questions', () => {
expect(
validateItemShape({
interactions: [{}],
questionTypes: [QuestionType.FREE_RESPONSE],
allowFreeResponse: false,
}),
).toEqual([{ code: ValidationError.FREE_RESPONSE_NOT_ALLOWED }]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,20 @@
v-if="mode === 'edit'"
:questionType="questionType"
:settingsTargetId="settingsTargetId"
:allowFreeResponse="allowFreeResponse"
@update:questionType="onUpdateQuestionType"
/>

<component
:is="descriptor.editorComponent"
:is="editorComponent"
:key="descriptor.type"
:questionType="questionType"
:interaction="interaction"
:mode="mode"
:showAnswers="showAnswers"
:teleportTargetId="settingsTargetId"
@update:interaction="onUpdateInteraction"
@update:errors="errors => $emit('update:errors', errors)"
/>
</div>
</div>
Expand All @@ -37,7 +39,7 @@
import useInteractionDescriptor from '../../composables/useInteractionDescriptor';
import QuestionTypeSelector from '../QuestionTypeSelector/index.vue';
import { generateRandomSlug } from '../../utils/generateRandomSlug';
import { descriptors } from '../../interactions';
import { descriptors, editors } from '../../interactions';

export default {
name: 'InteractionSection',
Expand Down Expand Up @@ -81,8 +83,11 @@

const settingsTargetId = generateRandomSlug('answer-settings');

const editorComponent = computed(() => editors[descriptor.value.type]);

return {
descriptor,
editorComponent,
questionType,
parseError,
onUpdateQuestionType,
Expand Down Expand Up @@ -112,9 +117,17 @@
type: Boolean,
default: false,
},
/**
* Whether a question with no correct answer is acceptable here. Passed straight to the

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is more of a product question than a technical question -- are we going to support this from the start? and... is there a distinction between a short answer free response (like a survey question) and more of an "essay" type free response in the spec itself, or just... we will be conditionalizing this to only be available for survey questions?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, this is just to avoid dropping support for what we currently have: free-response questions only appear on surveys. And yes, there is a different interaction, <qti-extended-text-interaction>, for long-text question types.

* type selector, which is the only part of an interaction this concerns.
*/
allowFreeResponse: {
type: Boolean,
default: true,
},
},

emits: ['update:questionType', 'update:interaction'],
emits: ['update:questionType', 'update:interaction', 'update:errors'],
};

</script>
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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: {
Expand Down Expand Up @@ -77,6 +90,135 @@ 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: '<qti-assessment-item><oops>',
},
});
expect(screen.getByText(unsupportedItemMessage$())).toBeInTheDocument();
});
});

describe('incomplete indicator', () => {
const renderAndValidate = async raw_data => {
jest.useFakeTimers();
renderComponent({
item: { assessment_id: 'item-id', type: AssessmentItemTypes.QTI, raw_data },
});
await nextTick();
// Validation is debounced inside the interaction editor.
jest.advanceTimersByTime(400);
await nextTick();
jest.useRealTimers();
};

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 () => {
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(/<qti-simple-choice/g)).toHaveLength(3);
});
});

describe('toolbarActions slot', () => {
test('renders content injected into the toolbarActions slot', () => {
renderComponent({}, { toolbarActions: '<button>Edit</button>' });
Expand Down
Loading