Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ import {
VALID_CHOICE_ITEM_DOCUMENT,
CHOICE_ITEM_DOCUMENT_NO_PROMPT,
CHOICE_ITEM_DOCUMENT_NO_CORRECT_ANSWER,
CHOICE_ITEM_DOCUMENT_NO_CORRECT_ANSWER_WITH_STIMULUS,
NO_INTERACTION_ITEM_DOCUMENT,
INLINE_CHOICE_ITEM_DOCUMENT,
VALID_MATCH_ITEM_DOCUMENT,
MATCH_THREE_SETS_XML,
MATCH_XML,
MULTI_TEXT_ENTRY_ITEM_DOCUMENT,
TEXT_ENTRY_ITEM_DOCUMENT_SHARED_PARAGRAPH,
MULTI_INTERACTION_ITEM_DOCUMENT,
UNRECOGNIZED_INTERACTION_ITEM_DOCUMENT,
} from '../utils/testingFixtures';
Expand Down Expand Up @@ -41,6 +43,15 @@ describe('validateQtiItem', () => {
expect(validateQtiItem(xml)).toEqual([]);
});

it('does not apply editor rules to an item whose body the editor cannot reproduce', () => {
const noPrompt = TEXT_ENTRY_ITEM_DOCUMENT_SHARED_PARAGRAPH.replace(
'<p>Tell us what you think.</p>',
'',
);
expect(validateQtiItem(CHOICE_ITEM_DOCUMENT_NO_CORRECT_ANSWER_WITH_STIMULUS)).toEqual([]);
expect(validateQtiItem(noPrompt, { allowFreeResponse: false })).toEqual([]);
});

it('reports an item whose body holds no interaction', () => {
expect(validateQtiItem(NO_INTERACTION_ITEM_DOCUMENT)).toEqual([
{ code: ValidationError.NO_INTERACTION },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import { AssessmentItemTypes } from '../../../constants';
import {
VALID_CHOICE_ITEM_DOCUMENT,
CHOICE_ITEM_DOCUMENT_NO_CORRECT_ANSWER,
CHOICE_ITEM_DOCUMENT_NO_CORRECT_ANSWER_WITH_STIMULUS,
ORDERING_ITEM_DOCUMENT_NO_PROMPT,
CHOICE_ITEM_DOCUMENT_WITH_HINTS_AND_STIMULUS,
CHOICE_ITEM_DOCUMENT_WITH_STIMULUS,
FREE_RESPONSE_ITEM_DOCUMENT,
NO_INTERACTION_ITEM_DOCUMENT,
CHOICE_ITEM_DOCUMENT_WITH_HINTS,
Expand All @@ -16,6 +19,8 @@ import {
MATCH_THREE_SETS_XML,
MATCH_XML,
MULTI_TEXT_ENTRY_ITEM_DOCUMENT,
TEXT_ENTRY_ITEM_DOCUMENT_SHARED_PARAGRAPH,
TEXT_ENTRY_ITEM_DOCUMENT_TRAILING_CONTENT,
MULTI_INTERACTION_ITEM_DOCUMENT,
UNRECOGNIZED_INTERACTION_ITEM_DOCUMENT,
INLINE_CHOICE_ITEM_DOCUMENT,
Expand Down Expand Up @@ -238,13 +243,18 @@ describe('QTIItemEditor', () => {
expect(screen.getByText(unsupportedItemMessage$())).toBeInTheDocument();
});

describe('items without exactly one editable interaction', () => {
describe('items the editor cannot rebuild', () => {
const documents = {
'two interactions': MULTI_INTERACTION_ITEM_DOCUMENT,
'an interaction with no descriptor': UNRECOGNIZED_INTERACTION_ITEM_DOCUMENT,
'an interaction with no editor': INLINE_CHOICE_ITEM_DOCUMENT,
'no interaction': NO_INTERACTION_ITEM_DOCUMENT,
'several blanks in one text entry': MULTI_TEXT_ENTRY_ITEM_DOCUMENT,
'a stimulus beside a block interaction': CHOICE_ITEM_DOCUMENT_WITH_STIMULUS,
'a stimulus beside a hinted block interaction':
CHOICE_ITEM_DOCUMENT_WITH_HINTS_AND_STIMULUS,
'text sharing the text entry paragraph': TEXT_ENTRY_ITEM_DOCUMENT_SHARED_PARAGRAPH,
'content after the text entry paragraph': TEXT_ENTRY_ITEM_DOCUMENT_TRAILING_CONTENT,
};
const renderDocument = (raw_data, props = {}) =>
renderComponent({
Expand All @@ -255,7 +265,9 @@ describe('QTIItemEditor', () => {
describe.each(Object.entries(documents))('with %s', (_, raw_data) => {
test('shows the unsupported message', () => {
renderDocument(raw_data, { mode: 'view' });
expect(screen.getByText(unsupportedItemMessage$())).toBeInTheDocument();
expect(screen.getByTestId('unsupportedMessage')).toHaveTextContent(
unsupportedItemMessage$(),
);
});

test('offers no editable controls or hints in edit mode', () => {
Expand All @@ -269,18 +281,16 @@ describe('QTIItemEditor', () => {
expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
expect(screen.queryAllByRole('textbox').filter(isAuthorInput)).toHaveLength(0);
});
});

test('leaves raw_data untouched after the card is opened and closed', async () => {
const { emitted, updateProps } = renderDocument(MULTI_INTERACTION_ITEM_DOCUMENT, {
mode: 'edit',
});
test('leaves raw_data untouched after the card is opened and closed', async () => {
const { emitted, updateProps } = renderDocument(raw_data, { mode: 'edit' });

await fireEvent.click(screen.getByRole('button', { name: closeBtnLabel$() }));
await updateProps({ mode: 'view' });
await nextTick();
await fireEvent.click(screen.getByRole('button', { name: closeBtnLabel$() }));
await updateProps({ mode: 'view' });
await nextTick();

expect(emitted()['update:rawData']).toBeUndefined();
expect(emitted()['update:rawData']).toBeUndefined();
});
});

test('shows the hints of an unsupported item when answers are shown', () => {
Expand Down Expand Up @@ -330,6 +340,11 @@ describe('QTIItemEditor', () => {
expect(screen.getByText(incompleteItemIndicatorLabel$())).toBeInTheDocument();
});

test('is not shown for an item whose body the editor cannot reproduce', async () => {
await renderAndValidate(CHOICE_ITEM_DOCUMENT_NO_CORRECT_ANSWER_WITH_STIMULUS);
expect(screen.queryByTestId('incompleteIndicator')).not.toBeInTheDocument();
});

test('is not shown for a valid item with unsupported interactions', async () => {
await renderAndValidate(MULTI_INTERACTION_ITEM_DOCUMENT);
expect(screen.queryByText(incompleteItemIndicatorLabel$())).not.toBeInTheDocument();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,20 +148,22 @@
// Parse the item XML. rawData is a computed inside useQtiItem that
// re-assembles the full XML whenever identifier/title/language or the
// editor refs change — no need to duplicate assembleItemXml here.
const { interactions, hints, parseError, rawData } = useQtiItem(props.item.raw_data, {
bodyXml: currentBodyXml,
responseDeclarations: currentResponseDeclarations,
});
const { interactions, itemBodyXml, hints, parseError, rawData } = useQtiItem(
props.item.raw_data,
{ bodyXml: currentBodyXml, responseDeclarations: currentResponseDeclarations },
);

const isQti = computed(() => props.item.type === AssessmentItemTypes.QTI);

/**
* Whether this editor can edit the item's XML faithfully: it is readable, and it is
* either blank or holds exactly one interaction this editor knows.
* either blank or holds exactly one interaction this editor knows, in the body shape its
* builder writes.
*/
const isBlank = !props.item.raw_data;
const isEditableQti = computed(
() => !parseError.value && (isBlank || isSupportedItem(interactions.value)),
() =>
!parseError.value && (isBlank || isSupportedItem(interactions.value, itemBodyXml.value)),
);

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { assembleItemXml } from '../serialization/assembleItem';
* identifier: import('vue').Ref<string>,
* title: import('vue').Ref<string>,
* language: import('vue').Ref<string>,
* itemBodyXml: import('vue').Ref<string>,
* interactions: import('vue').Ref<Array<{ bodyXml: string, responseDeclarations: string[] }>>,
* hints: import('vue').Ref<Array<{ id: string, content: string }>>,
* parseError: import('vue').Ref<string | null>,
Expand All @@ -27,6 +28,7 @@ export default function useQtiItem(rawXml, { bodyXml, responseDeclarations } = {
const identifier = ref('');
const title = ref('');
const language = ref('');
const itemBodyXml = ref('');
const interactions = ref([]);
/**
* Hints belong to the item, not to any one interaction, so they live here beside
Expand All @@ -41,6 +43,7 @@ export default function useQtiItem(rawXml, { bodyXml, responseDeclarations } = {
identifier.value = model.identifier;
title.value = model.title;
language.value = model.language;
itemBodyXml.value = model.itemBodyXml;
interactions.value = model.interactions;
hints.value = model.hints;
} catch (e) {
Expand Down Expand Up @@ -68,6 +71,7 @@ export default function useQtiItem(rawXml, { bodyXml, responseDeclarations } = {
identifier,
title,
language,
itemBodyXml,
interactions,
hints,
parseError,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
*/

import { Placement } from '../constants';
import { isContentNode } from '../serialization/xml';

/**
* Methods a subclass has to implement. `matches` and `getTypeOptions` are not listed
Expand Down Expand Up @@ -69,6 +70,18 @@ export class InteractionDescriptor {
return el.tagName.toLowerCase() === this.type;
}

/**
* Whether the item body has the shape this interaction's builder writes, so that editing
* and rebuilding it loses nothing. The default is the block shape: the interaction alone.
*
* @param {Element} bodyEl
* @returns {boolean}
*/
isSupportedBody(bodyEl) {
const content = [...bodyEl.childNodes].filter(isContentNode);
return content.length === 1 && this.matches(content[0]);
}

/**
* Options this interaction contributes to the question type selector. An interaction that
* authors are not meant to pick directly contributes none.
Expand Down
Loading
Loading