Skip to content
Open
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,7 +7,7 @@ Edits an exercise's assessment items stored as [QTI 3](https://www.imsglobal.org
2. `composables/useQtiItem.js` parses `raw_data` with `serialization/parseItem.js` into interaction blocks (`bodyXml` + `responseDeclarations`), hints and item metadata.
3. `components/InteractionSection` resolves each block's descriptor and question type with `composables/useInteractionDescriptor.js`.
4. The interaction's `Editor.vue` (`interactions/index.js`) edits a plain state object through `composables/useInteraction.js`: `descriptor.parse` → state → `descriptor.buildXML` → `descriptor.validate`.
5. `useQtiItem` rebuilds the full item with `serialization/assembleItem.js`; `QTIItemEditor` emits `update:rawData`.
5. `useQtiItem` rebuilds the full item with `serialization/assembleItem.js`, keeping `xsi:schemaLocation` and the converter's metadata from `raw_data`; `QTIItemEditor` emits `update:rawData`.

`validateItem.js` `validateQtiItem` runs the same parse and validation headless, without the Vue editors.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
CHOICE_ITEM_DOCUMENT_NO_PROMPT,
CHOICE_ITEM_DOCUMENT_NO_CORRECT_ANSWER,
CHOICE_ITEM_DOCUMENT_NO_CORRECT_ANSWER_WITH_STIMULUS,
CHOICE_ITEM_DOCUMENT_WITH_HINTS,
CHOICE_ITEM_DOCUMENT_WITH_STYLESHEET,
NO_INTERACTION_ITEM_DOCUMENT,
INLINE_CHOICE_ITEM_DOCUMENT,
VALID_MATCH_ITEM_DOCUMENT,
Expand Down Expand Up @@ -53,6 +55,14 @@ describe('validateQtiItem', () => {
expect(validateQtiItem(noPrompt, { allowFreeResponse: false })).toEqual([]);
});

it('does not apply editor rules to an item with content an edit would drop', () => {
const noPrompt = xml => xml.replace('<qti-prompt>Pick one.</qti-prompt>', '');
expect(codesOf(validateQtiItem(noPrompt(CHOICE_ITEM_DOCUMENT_WITH_HINTS)))).toContain(
ValidationError.PROMPT_REQUIRED,
);
expect(validateQtiItem(noPrompt(CHOICE_ITEM_DOCUMENT_WITH_STYLESHEET))).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 @@ -14,6 +14,10 @@ import {
FREE_RESPONSE_ITEM_DOCUMENT,
NO_INTERACTION_ITEM_DOCUMENT,
CHOICE_ITEM_DOCUMENT_WITH_HINTS,
CHOICE_ITEM_DOCUMENT_WITH_HINTS_AND_GLOSSARY,
CHOICE_ITEM_DOCUMENT_WITH_MODAL_FEEDBACK,
CHOICE_ITEM_DOCUMENT_WITH_SCHEMA_LOCATION,
CHOICE_ITEM_DOCUMENT_WITH_STYLESHEET,
VALID_ASSOCIATE_ITEM_DOCUMENT,
VALID_MATCH_ITEM_DOCUMENT,
MATCH_THREE_SETS_XML,
Expand All @@ -25,6 +29,7 @@ import {
UNRECOGNIZED_INTERACTION_ITEM_DOCUMENT,
INLINE_CHOICE_ITEM_DOCUMENT,
} from '../../../utils/testingFixtures';
import { XSI_NS } from '../../../serialization/xml';

jest.mock('shared/views/TipTapEditor/TipTapEditor/TipTapEditor');
jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => {
Expand All @@ -47,6 +52,7 @@ const {
questionNumberAndTypeLabel$,
unknownTypeLabel$,
responsePoolLabel$,
deleteHintBtn$,
} = qtiEditorStrings;

const defaultProps = {
Expand Down Expand Up @@ -252,6 +258,13 @@ describe('QTIItemEditor', () => {
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,
'a stylesheet': CHOICE_ITEM_DOCUMENT_WITH_STYLESHEET,
'a stylesheet and no prompt': CHOICE_ITEM_DOCUMENT_WITH_STYLESHEET.replace(
'<qti-prompt>Pick one.</qti-prompt>',
'',
),
'modal feedback': CHOICE_ITEM_DOCUMENT_WITH_MODAL_FEEDBACK,
'a catalog beside the hints': CHOICE_ITEM_DOCUMENT_WITH_HINTS_AND_GLOSSARY,
};
const documents = {
...publishableDocuments,
Expand Down Expand Up @@ -532,6 +545,19 @@ describe('QTIItemEditor', () => {
});
});

test('keeps xsi:schemaLocation through an edit', async () => {
const { emitted } = renderComponent({
item: { ...defaultProps.item, raw_data: CHOICE_ITEM_DOCUMENT_WITH_SCHEMA_LOCATION },
mode: 'edit',
});
await fireEvent.click(screen.getByRole('button', { name: hintsLabel$() }));
await fireEvent.click(screen.getAllByRole('button', { name: deleteHintBtn$() })[0]);
await nextTick();
const [xml] = emitted()['update:rawData'].at(-1);
const root = new DOMParser().parseFromString(xml, 'text/xml').documentElement;
expect(root.getAttributeNS(XSI_NS, 'schemaLocation')).toContain('imsqti_asiv3p0p1_v1p0.xsd');
});

describe('associate interaction', () => {
const renderAssociateItem = () =>
renderComponent({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,7 @@
import { qtiEditorStrings } from '../../qtiEditorStrings';
import { AssessmentItemTypes, QuestionType } from '../../constants';
import useQtiItem from '../../composables/useQtiItem';
import { validateItemShape, validateQtiItem } from '../../validateItem';
import { isSupportedItem } from '../../interactions/resolveDescriptor';
import { isEditableItem, validateItemShape, validateQtiItem } from '../../validateItem';
import InteractionSection from '../InteractionSection/index.vue';
import HintsSection from '../HintsSection/index.vue';
Expand Down Expand Up @@ -159,12 +158,17 @@
/**
* 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, in the body shape its
* builder writes.
* builder writes, and nothing outside the body that an edit would drop.
*/
const isBlank = !props.item.raw_data;
const sourceXml = props.item.raw_data;
const isEditableQti = computed(
() =>
!parseError.value && (isBlank || isSupportedItem(interactions.value, itemBodyXml.value)),
!parseError.value &&
(!sourceXml ||
isEditableItem(
{ interactions: interactions.value, itemBodyXml: itemBodyXml.value },
sourceXml,
)),
);
/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ export default function useQtiItem(rawXml, { bodyXml, responseDeclarations } = {
bodyXml: bodyXml?.value ?? '',
responseDeclarations: responseDeclarations?.value ?? [],
hints: hints.value,
// parseXML throws on XML that failed to parse, which would break this computed.
sourceXml: parseError.value ? null : rawXml,
}),
);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
// Disabled because jest-dom matchers (toHaveAttribute/toHaveTextContent) are designed for
// HTML and do not work reliably on strict XML elements generated by serialization.
/* eslint-disable jest-dom/prefer-to-have-attribute, jest-dom/prefer-to-have-text-content */
import { assembleItemXml } from '../assembleItem.js';
import { assembleItemXml, keepsItemContent } from '../assembleItem.js';
import { parseItem } from '../parseItem.js';
import { buildXmlNode, parseXML } from '../xml.js';
import { normalizeXML } from '../qti/__tests__/testUtils.js';
import { ResponseProcessingTemplate } from '../../constants';
import { MULTI_TEXT_ENTRY_ITEM_DOCUMENT } from '../../utils/testingFixtures';
import {
CHOICE_ITEM_DOCUMENT_WITH_HINTS,
CHOICE_ITEM_DOCUMENT_WITH_SCHEMA_LOCATION,
MULTI_TEXT_ENTRY_ITEM_DOCUMENT,
} from '../../utils/testingFixtures';

const serializer = new XMLSerializer();

Expand Down Expand Up @@ -360,4 +364,206 @@ describe('assembleItemXml', () => {
expect(parseXML(xml).querySelectorAll('qti-response-processing')).toHaveLength(1);
});
});

describe('with the source item', () => {
const sourceXml = CHOICE_ITEM_DOCUMENT_WITH_SCHEMA_LOCATION;
const reassemble = (params = {}) => {
const item = parseItem(sourceXml);
return assembleItemXml({
identifier: item.identifier,
title: item.title,
language: item.language,
bodyXml: item.interactions[0].bodyXml,
responseDeclarations: item.interactions[0].responseDeclarations,
hints: item.hints,
sourceXml,
...params,
});
};

it('keeps no source prefix declaration that its own elements would take', () => {
const xml = reassemble({
sourceXml: sourceXml.replace(
'xmlns:xsi=',
'xmlns:qti="http://www.imsglobal.org/xsd/imsqtiasi_v3p0"\n xmlns:xsi=',
),
});
expect(xml).not.toContain('qti:');
expect(xml).toContain('xsi:schemaLocation');
expect(parseItem(xml).interactions).toHaveLength(1);
});

it('writes its own root attributes over the source ones', () => {
const root = parseXML(reassemble({ identifier: 'renamed', language: '' })).documentElement;
expect(root.getAttribute('identifier')).toBe('renamed');
expect(root.hasAttribute('xml:lang')).toBe(false);
});
});
});

describe('keepsItemContent', () => {
const withRoot = attrs =>
CHOICE_ITEM_DOCUMENT_WITH_HINTS.replace('xml:lang="en"', `xml:lang="en" ${attrs}`);
const SCORE_DECLARATION =
'<qti-outcome-declaration identifier="SCORE" cardinality="single" base-type="float"/>';
const withScoring = (outcomeDeclaration, responseProcessing = '') =>
CHOICE_ITEM_DOCUMENT_WITH_HINTS.replace(
'\n\n <qti-item-body>',
`\n ${outcomeDeclaration}\n <qti-item-body>`,
).replace('\n</qti-assessment-item>', `\n ${responseProcessing}\n</qti-assessment-item>`);
const withDefaultScore = (value, identifier = 'SCORE') =>
withScoring(
`<qti-outcome-declaration identifier="${identifier}" cardinality="single" base-type="float">
<qti-default-value><qti-value>${value}</qti-value></qti-default-value>
</qti-outcome-declaration>`,
);
const withTemplate = attrs =>
withScoring(SCORE_DECLARATION, `<qti-response-processing ${attrs}/>`);
const withHintCard = card =>
CHOICE_ITEM_DOCUMENT_WITH_HINTS.replace(
'<qti-catalog id="kolibri-hints">',
`<qti-catalog id="kolibri-hints">\n ${card}`,
);
const EMPTY_HINT_CARD = '<qti-card support="ext:kolibri-hint"><qti-html-content/></qti-card>';

it.each([
['hints', CHOICE_ITEM_DOCUMENT_WITH_HINTS],
['xsi:schemaLocation', CHOICE_ITEM_DOCUMENT_WITH_SCHEMA_LOCATION],
['converter metadata', withRoot('label="L" tool-name="kolibri" tool-version="0.1"')],
[
'scoring the editor regenerates',
withTemplate(`template="${ResponseProcessingTemplate.MATCH_CORRECT}"`),
],
[
'a standard template with no .xml',
withTemplate('template="https://purl.imsglobal.org/spec/qti/v3p0/rptemplates/match_correct"'),
],
[
'a standard template in its imsglobal.org form',
withTemplate(
'template="http://www.imsglobal.org/question/qti_v3p0/rptemplates/match_correct"',
),
],
[
'a standard template by template-location',
withTemplate(`template-location="${ResponseProcessingTemplate.MATCH_CORRECT}"`),
],
[
'a standard template other than the one the editor writes',
withTemplate(`template="${ResponseProcessingTemplate.MAP_RESPONSE}"`),
],
['a zero default SCORE', withDefaultScore(0)],
['a zero default RAW_SCORE that one response leaves unread', withDefaultScore(0, 'RAW_SCORE')],
[
'math in a hint, in the namespace the converter leaves it',
withHintCard(
'<qti-card support="ext:kolibri-hint"><qti-html-content><p><math display="inline"><mi>x</mi></math></p></qti-html-content></qti-card>',
),
],
[
'a pretty-printed bare-text hint',
withHintCard(`<qti-card support="ext:kolibri-hint">
<qti-html-content>
Try halving it first
</qti-html-content>
</qti-card>`),
],
...[
['a table', '<table><tr><td>a</td></tr></table>'],
['CDATA', '<p><![CDATA[a < b]]></p>'],
['a data attribute', '<p data-Ref="1">a</p>'],
['a div in a paragraph', '<p><div>a</div></p>'],
].map(([name, html]) => [
`${name} in a hint, which the hint editor rewrites`,
withHintCard(
`<qti-card support="ext:kolibri-hint"><qti-html-content>${html}</qti-html-content></qti-card>`,
),
]),
['an empty hint card', withHintCard(EMPTY_HINT_CARD)],
[
'only empty hint cards',
CHOICE_ITEM_DOCUMENT_WITH_HINTS.replace(
/<qti-catalog id="kolibri-hints">[^]*<\/qti-catalog>/,
`<qti-catalog id="kolibri-hints">${EMPTY_HINT_CARD}</qti-catalog>`,
),
],
])('is true for an item with %s', (_, xml) => {
expect(keepsItemContent(xml)).toBe(true);
});

it.each([
[
'hint cards in a catalog the body references by another id',
CHOICE_ITEM_DOCUMENT_WITH_HINTS.replace(
'<qti-catalog id="kolibri-hints">',
'<qti-catalog id="g1">',
).replace('<qti-prompt>', '<qti-prompt><span data-catalog-idref="g1">Term</span>'),
],
[
'a non-hint card in the hint catalog',
withHintCard(
'<qti-card support="glossary-on-screen"><qti-html-content><p>Term</p></qti-html-content></qti-card>',
),
],
[
'a hint card in two languages',
withHintCard(`<qti-card support="ext:kolibri-hint">
<qti-card-entry xml:lang="en"><qti-html-content><p>Hi</p></qti-html-content></qti-card-entry>
<qti-card-entry xml:lang="es"><qti-html-content><p>Hola</p></qti-html-content></qti-card-entry>
</qti-card>`),
],
[
'a hint card pointing at a file',
withHintCard(
'<qti-card support="ext:kolibri-hint"><qti-file-href mime-type="text/html">hint.html</qti-file-href></qti-card>',
),
],
[
'a response declaration no interaction uses',
withScoring(
'<qti-response-declaration identifier="UNUSED" cardinality="single" base-type="identifier"/>',
),
],
['an unknown root attribute', withRoot('foo="bar"')],
[
'adaptive="true"',
CHOICE_ITEM_DOCUMENT_WITH_HINTS.replace('adaptive="false"', 'adaptive="true"'),
],
[
'a language on a hint card',
CHOICE_ITEM_DOCUMENT_WITH_HINTS.replaceAll(
'<qti-card support="ext:kolibri-hint">',
'<qti-card support="ext:kolibri-hint" xml:lang="es">',
),
],
[
'a language on hint content',
withHintCard(
'<qti-card support="ext:kolibri-hint"><qti-html-content xml:lang="es"><p>Hola</p></qti-html-content></qti-card>',
),
],
[
'a score maximum',
withScoring(
'<qti-outcome-declaration identifier="SCORE" cardinality="single" base-type="float" normal-maximum="2"/>',
),
],
['a non-zero default SCORE', withDefaultScore(1)],
['a non-zero default RAW_SCORE', withDefaultScore(1, 'RAW_SCORE')],
[
'response processing the editor does not write',
withScoring(
'',
`<qti-response-processing>
<qti-set-outcome-value identifier="SCORE"><qti-base-value base-type="float">2</qti-base-value></qti-set-outcome-value>
</qti-response-processing>`,
),
],
[
'a template that is not a standard one',
withTemplate('template="https://example.com/rptemplates/match_correct.xml"'),
],
])('is false for an item with %s', (_, xml) => {
expect(keepsItemContent(xml)).toBe(false);
});
});
Loading
Loading