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 @@ -5,10 +5,13 @@ import {
CHOICE_ITEM_DOCUMENT_NO_PROMPT,
CHOICE_ITEM_DOCUMENT_NO_CORRECT_ANSWER,
NO_INTERACTION_ITEM_DOCUMENT,
INLINE_CHOICE_ITEM_DOCUMENT,
VALID_MATCH_ITEM_DOCUMENT,
MATCH_THREE_SETS_XML,
MATCH_XML,
MULTI_TEXT_ENTRY_ITEM_DOCUMENT,
MULTI_INTERACTION_ITEM_DOCUMENT,
UNRECOGNIZED_INTERACTION_ITEM_DOCUMENT,
} from '../utils/testingFixtures';

const codesOf = errors => errors.map(error => error.code);
Expand All @@ -30,11 +33,9 @@ describe('validateQtiItem', () => {
);
});

it('validates an item with several inline interactions as one question', () => {
// Every inline interaction is handed the whole body, so validating one per interaction
// would report the same problem once for each of them.
it('does not apply editor rules to an item with several text entries', () => {
const xml = MULTI_TEXT_ENTRY_ITEM_DOCUMENT.replace(/>Sun</, '><').replace(/>Moon</, '><');
expect(codesOf(validateQtiItem(xml))).toEqual([ValidationError.EMPTY_ANSWER_CONTENT]);
expect(validateQtiItem(xml)).toEqual([]);
});

it('reports an item whose body holds no interaction', () => {
Expand All @@ -43,6 +44,33 @@ describe('validateQtiItem', () => {
]);
});

it('reports nothing for an item with an interaction with no descriptor, which is shown read-only', () => {
expect(validateQtiItem(UNRECOGNIZED_INTERACTION_ITEM_DOCUMENT)).toEqual([]);
expect(
validateQtiItem(UNRECOGNIZED_INTERACTION_ITEM_DOCUMENT, { allowFreeResponse: false }),
).toEqual([]);
});

it('reports a three-set match interaction inside a multi-interaction item as unparseable', () => {
const xml = MULTI_INTERACTION_ITEM_DOCUMENT.replace(
/<qti-choice-interaction response-identifier="RESP2"[\s\S]*?<\/qti-choice-interaction>/,
MATCH_THREE_SETS_XML,
);
expect(codesOf(validateQtiItem(xml))).toContain(ValidationError.PARSE_ERROR);
});

it('does not apply the editor rules to a multi-interaction item', () => {
const xml = MULTI_INTERACTION_ITEM_DOCUMENT.replace(
/<qti-prompt>First question<\/qti-prompt>/,
'',
);
expect(validateQtiItem(xml, { allowFreeResponse: false })).toEqual([]);
});

it('does not apply the editor rules to an interaction with no editor', () => {
expect(validateQtiItem(INLINE_CHOICE_ITEM_DOCUMENT)).toEqual([]);
});

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 }]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ const renderSection = (props = {}) =>
routes: new VueRouter(),
});

// jest_config/setup.js adds a hidden csrf input to the document.
const isAuthorInput = el => el.name !== 'csrfmiddlewaretoken';

describe('InteractionSection', () => {
describe('choice interaction', () => {
it('renders the prompt from the XML via ChoiceInteractionEditor', () => {
Expand Down Expand Up @@ -142,11 +145,11 @@ describe('InteractionSection', () => {
});

describe('unknown interaction type', () => {
it('falls back silently when the interaction tag is unrecognized', () => {
// Should not throw — just renders the fallback component
expect(() =>
renderSection({ interaction: interactionBlock(UNKNOWN_INTERACTION_XML) }),
).not.toThrow();
it('renders nothing editable', () => {
renderSection({ interaction: interactionBlock(UNKNOWN_INTERACTION_XML), mode: 'edit' });
expect(screen.queryByRole('button')).not.toBeInTheDocument();
expect(screen.queryAllByRole('textbox').filter(isAuthorInput)).toHaveLength(0);
expect(screen.queryByRole('radio')).not.toBeInTheDocument();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
>
{{ parseError }}
</p>
<div v-else>
<div v-else-if="descriptor">
<QuestionTypeSelector
v-if="mode === 'edit'"
:questionType="questionType"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,14 @@ import {
FREE_RESPONSE_ITEM_DOCUMENT,
NO_INTERACTION_ITEM_DOCUMENT,
CHOICE_ITEM_DOCUMENT_WITH_HINTS,
NO_INTERACTION_ITEM_WITH_HINTS,
VALID_ASSOCIATE_ITEM_DOCUMENT,
VALID_MATCH_ITEM_DOCUMENT,
MATCH_THREE_SETS_XML,
MATCH_XML,
MULTI_TEXT_ENTRY_ITEM_DOCUMENT,
MULTI_INTERACTION_ITEM_DOCUMENT,
UNRECOGNIZED_INTERACTION_ITEM_DOCUMENT,
INLINE_CHOICE_ITEM_DOCUMENT,
} from '../../../utils/testingFixtures';

jest.mock('shared/views/TipTapEditor/TipTapEditor/TipTapEditor');
Expand Down Expand Up @@ -68,6 +72,9 @@ afterAll(() => {
delete Element.prototype.scrollIntoView;
});

// jest_config/setup.js adds a hidden csrf input to the document.
const isAuthorInput = el => el.name !== 'csrfmiddlewaretoken';

describe('QTIItemEditor', () => {
beforeEach(() => scrollIntoView.mockClear());
afterEach(() => jest.restoreAllMocks());
Expand Down Expand Up @@ -230,6 +237,57 @@ describe('QTIItemEditor', () => {
});
expect(screen.getByText(unsupportedItemMessage$())).toBeInTheDocument();
});

describe('items without exactly one editable interaction', () => {
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,
};
const renderDocument = (raw_data, props = {}) =>
renderComponent({
item: { assessment_id: 'item-id', type: AssessmentItemTypes.QTI, raw_data },
...props,
});

describe.each(Object.entries(documents))('with %s', (_, raw_data) => {
test('shows the unsupported message', () => {
renderDocument(raw_data, { mode: 'view' });
expect(screen.getByText(unsupportedItemMessage$())).toBeInTheDocument();
});

test('offers no editable controls or hints in edit mode', () => {
renderDocument(raw_data, { mode: 'edit' });
expect(screen.queryByText(hintsLabel$())).not.toBeInTheDocument();
const buttons = screen.getAllByRole('button');
expect(buttons).toHaveLength(1);
expect(buttons[0]).toHaveAccessibleName(closeBtnLabel$());
expect(screen.queryByRole('radio')).not.toBeInTheDocument();
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument();
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',
});

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

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

test('shows the hints of an unsupported item when answers are shown', () => {
renderDocument(MULTI_INTERACTION_ITEM_DOCUMENT, { mode: 'view', showAnswers: true });
expect(screen.getByRole('button', { name: hintsLabel$() })).toBeInTheDocument();
});
});
});

describe('incomplete indicator', () => {
Expand Down Expand Up @@ -257,11 +315,26 @@ describe('QTIItemEditor', () => {
expect(screen.getByText(incompleteItemIndicatorLabel$())).toBeInTheDocument();
});

test('is shown for an item with no interaction at all', async () => {
test('is shown for an unsupported item with no interaction, which blocks publishing', async () => {
await renderAndValidate(NO_INTERACTION_ITEM_DOCUMENT);
expect(screen.getByText(incompleteItemIndicatorLabel$())).toBeInTheDocument();
});

test('is shown for an item whose XML cannot be parsed', async () => {
await renderAndValidate('<not-xml');
expect(screen.getByText(incompleteItemIndicatorLabel$())).toBeInTheDocument();
});

test('is shown for a match interaction that cannot be read', async () => {
await renderAndValidate(VALID_MATCH_ITEM_DOCUMENT.replace(MATCH_XML, MATCH_THREE_SETS_XML));
expect(screen.getByText(incompleteItemIndicatorLabel$())).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();
});

test('is shown for a free-response question where those are not accepted', async () => {
renderComponent({
allowFreeResponse: false,
Expand Down Expand Up @@ -403,23 +476,6 @@ describe('QTIItemEditor', () => {
expect(screen.getByText(hintsLabel$())).toBeInTheDocument();
});

test('keeps the body of a question that has no interaction when a hint changes', async () => {
// Nothing mounts an interaction editor here, so the editor holds no body of its own.
// Assembling from that empty state would replace the question's text with an empty
// <qti-item-body/> — a hint edit silently deleting the question.
const { emitted } = renderComponent({
item: { ...defaultProps.item, raw_data: NO_INTERACTION_ITEM_WITH_HINTS },
mode: 'edit',
});
await fireEvent.click(screen.getByRole('button', { name: hintsLabel$() }));
await fireEvent.click(screen.getAllByRole('button', { name: 'Delete hint' })[0]);
await nextTick();

const [xml] = emitted()['update:rawData'].at(-1);
expect(xml).toContain('What is the capital of France?');
expect(xml).not.toContain('<qti-item-body/>');
});

test('reports the item XML when a hint changes', async () => {
const { emitted } = renderComponent({
item: { ...defaultProps.item, raw_data: CHOICE_ITEM_DOCUMENT_WITH_HINTS },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@

<!-- .stop: expanding or collapsing the hints must not open the card -->
<HintsSection
v-if="hasHints && (mode === 'edit' || showAnswers)"
v-if="showHints"
:hints="hints"
:mode="mode"
@update:hints="onUpdateHints"
Expand Down Expand Up @@ -115,7 +115,8 @@
import { qtiEditorStrings } from '../../qtiEditorStrings';
import { AssessmentItemTypes, QuestionType } from '../../constants';
import useQtiItem from '../../composables/useQtiItem';
import { validateItemShape } from '../../validateItem';
import { validateItemShape, validateQtiItem } from '../../validateItem';
import { isSupportedItem } from '../../interactions/resolveDescriptor';
import InteractionSection from '../InteractionSection/index.vue';
import HintsSection from '../HintsSection/index.vue';

Expand Down Expand Up @@ -147,33 +148,34 @@
// 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, itemBodyXml, hints, parseError, rawData } = useQtiItem(
props.item.raw_data,
{
bodyXml: currentBodyXml,
responseDeclarations: currentResponseDeclarations,
},
);
const { interactions, hints, parseError, rawData } = useQtiItem(props.item.raw_data, {

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.

If we don't use the itemBodyXml anymore, let's also drop it from the useQtiItem composable.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Dropped itemBodyXml from useQtiItem (state, assignment, JSDoc). parseItem still returns it; convertedItem.spec reads it from there. No other consumers.

bodyXml: currentBodyXml,
responseDeclarations: currentResponseDeclarations,
});

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

/**
* Items authored outside this editor (e.g. Perseus questions) and items whose XML
* cannot be read are shown as read-only cards.
* 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.
*/
const isUnsupported = computed(
() => props.item.type !== AssessmentItemTypes.QTI || Boolean(parseError.value),
const isBlank = !props.item.raw_data;
const isEditableQti = computed(
() => !parseError.value && (isBlank || isSupportedItem(interactions.value)),
);

/**
* Items authored outside this editor (e.g. Perseus questions) and QTI it cannot edit
* faithfully are shown as read-only cards.
*/
const isUnsupported = computed(() => !isQti.value || !isEditableQti.value);

/*
* Seed the editor refs from the parsed item's first block, which for an inline
* passage holds every declaration.
*
* The body is seeded even when there is no interaction to edit. Such an item still has
* content — its own text, and any interaction this editor has no descriptor for — and
* anything else the author can change, a hint, reassembles the whole item. Leaving the
* body unseeded would write an empty <qti-item-body/> over that text.
*/
currentBodyXml.value = interactions.value[0]?.bodyXml ?? itemBodyXml.value;
if (interactions.value.length > 0) {
currentBodyXml.value = interactions.value[0].bodyXml;
currentResponseDeclarations.value = interactions.value[0].responseDeclarations;
}

Expand Down Expand Up @@ -262,6 +264,10 @@
*/
const hasHints = hints.value.length > 0;

const showHints = computed(
() => hasHints && (props.mode === 'edit' ? !isUnsupported.value : props.showAnswers),
);

function onUpdateHints(newHints) {
editedHere = props.mode === 'edit';
hints.value = newHints;
Expand Down Expand Up @@ -315,7 +321,13 @@
*/
const isIncomplete = computed(() => {
if (isUnsupported.value) {
return false;
// Unreadable or empty QTI can't be fixed here and blocks publishing; other
// unsupported items are publishable.
return (
isQti.value &&
validateQtiItem(props.item.raw_data, { allowFreeResponse: props.allowFreeResponse })
.length > 0
);
}
const itemErrors = validateItemShape({
interactions: interactions.value,
Expand Down Expand Up @@ -343,7 +355,7 @@
onUpdateInteraction,
onUpdateErrors,
hints,
hasHints,
showHints,
onUpdateHints,
};
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,38 +62,20 @@ describe('useInteractionDescriptor', () => {
});

describe('with an unrecognized interaction type', () => {
it('falls back to the default descriptor without a parse error', async () => {
it('resolves no descriptor and no parse error', async () => {
const { result } = renderDescriptor(UNKNOWN_INTERACTION_XML);
await nextTick();
expect(result.descriptor.value).toBeNull();
expect(result.parseError.value).toBeNull();
});

it('still returns a defined fallback descriptor', async () => {
const { result } = renderDescriptor(UNKNOWN_INTERACTION_XML);
await nextTick();
expect(result.descriptor.value).toBeDefined();
expect(typeof result.descriptor.value.matches).toBe('function');
});
});

describe('with a null or empty bodyXmlRef', () => {
it('returns the default descriptor when bodyXmlRef is null', async () => {
const { result } = renderDescriptor(null);
await nextTick();
expect(result.descriptor.value).toBeDefined();
expect(result.parseError.value).toBeNull();
});

it('returns null questionType when bodyXmlRef is null', async () => {
const { result } = renderDescriptor(null);
it.each([null, ''])('resolves no descriptor for %p', async xml => {
const { result } = renderDescriptor(xml);
await nextTick();
expect(result.descriptor.value).toBeNull();
expect(result.questionType.value).toBeNull();
});

it('returns the default descriptor when bodyXmlRef is an empty string', async () => {
const { result } = renderDescriptor('');
await nextTick();
expect(result.descriptor.value).toBeDefined();
expect(result.parseError.value).toBeNull();
});
});
Expand All @@ -106,10 +88,10 @@ describe('useInteractionDescriptor', () => {
expect(result.parseError.value).toBe('This question could not be loaded');
});

it('still returns a defined fallback descriptor on parse error', async () => {
it('resolves no descriptor on parse error', async () => {
const { result } = renderDescriptor('<bad xml!!{');
await nextTick();
expect(result.descriptor.value).toBeDefined();
expect(result.descriptor.value).toBeNull();
});
});

Expand Down
Loading
Loading