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 @@ -19,6 +19,7 @@
<QTIEditor
:assessments="assessmentItems"
:allowFreeResponse="allowFreeResponse"
:language="language"
@update="applyUpdate"
/>
</div>
Expand All @@ -42,14 +43,21 @@
setup(props) {
const { nodeId } = toRefs(props);
const { windowIsSmall } = useKResponsiveWindow();
const { assessmentItems, invalidItemsCount, allowFreeResponse, applyUpdate } =
const { assessmentItems, invalidItemsCount, allowFreeResponse, language, applyUpdate } =
useAssessmentItems(nodeId);

const bannerStyle = computed(() =>
windowIsSmall.value ? {} : { maxWidth: '1200px', margin: '0 auto' },
);

return { assessmentItems, invalidItemsCount, allowFreeResponse, applyUpdate, bannerStyle };
return {
assessmentItems,
invalidItemsCount,
allowFreeResponse,
language,
applyUpdate,
bannerStyle,
};
},
props: {
nodeId: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@
:total="assessmentItems.length"
mode="view"
:allowFreeResponse="allowFreeResponse"
:language="language"
:showAnswers="showAnswers"
class="question-preview"
/>
Expand Down Expand Up @@ -499,6 +500,7 @@
AccessibilityCategories,
ContentModalities,
} from '../../shared/constants';
import { exerciseLanguage } from '../composables/useAssessmentItems';
import ContentNodeValidator from './ContentNodeValidator';

import {
Expand Down Expand Up @@ -570,6 +572,7 @@
]),
...mapGetters('file', ['getContentNodeFiles', 'contentNodesTotalSize']),
...mapGetters('assessmentItem', ['getAssessmentItems', 'getInvalidAssessmentItemsCount']),
...mapGetters('channel', ['getChannel']),
node() {
return this.getContentNode(this.nodeId);
},
Expand Down Expand Up @@ -620,6 +623,10 @@
allowFreeResponse() {
return this.node?.extra_fields?.options?.modality === ContentModalities.SURVEY;
},
language() {
// The Clipboard previews nodes from other channels.
return exerciseLanguage(this.node, this.getChannel(this.node?.channel_id));
},
fileSize() {
return this.contentNodesTotalSize([this.nodeId]);
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import VueRouter from 'vue-router';
import { render } from '@testing-library/vue';
import useAssessmentItems from '../useAssessmentItems';
import { AssessmentItemTypes, ContentModalities } from 'shared/constants';
import { currentLanguage } from 'shared/i18n';

const NODE_ID = 'node-1';

Expand All @@ -18,7 +19,7 @@ const item = (assessment_id, order, raw_data = `<xml>${assessment_id}</xml>`) =>
* 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 } = {}) {
function setup(storedItems, { modality = null, nodeLanguage = null, channelLanguage = null } = {}) {
const dispatched = [];
const record = name => (context, payload) => dispatched.push([name, payload]);

Expand All @@ -27,7 +28,16 @@ function setup(storedItems, { modality = null } = {}) {
contentNode: {
namespaced: true,
getters: {
getContentNode: () => () => ({ extra_fields: { options: { modality } } }),
getContentNode: () => () => ({
language: nodeLanguage,
extra_fields: { options: { modality } },
}),
},
},
currentChannel: {
namespaced: true,
getters: {
currentChannel: () => ({ language: channelLanguage }),
},
},
assessmentItem: {
Expand Down Expand Up @@ -75,6 +85,33 @@ describe('useAssessmentItems', () => {
});
});

describe('language', () => {
it("is the exercise's own language when it has one", () => {
const { composable } = setup([], { nodeLanguage: 'fr', channelLanguage: 'hi' });

expect(composable.language.value).toBe('fr');
});

it("falls back to the channel's language", () => {
const { composable } = setup([], { channelLanguage: 'hi' });

expect(composable.language.value).toBe('hi');
});

it("falls back to the author's interface language when neither has one", () => {
const { composable } = setup([]);

expect(composable.language.value).toBe(currentLanguage);
});

it('is empty for a language with no number conventions in this browser', () => {
// `qaa` is reserved for private use, so no browser has conventions for it.
const { composable } = setup([], { nodeLanguage: 'qaa', channelLanguage: 'hi' });

expect(composable.language.value).toBe('');
});
});

it('dispatches nothing when the list is unchanged', async () => {
const items = [item('a', 0), item('b', 1)];
const { composable, dispatched } = setup(items);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { computed, unref } from 'vue';
import useStore from 'shared/composables/useStore';
import { ContentModalities } from 'shared/constants';
import { currentLanguage } from 'shared/i18n';

/**
* Work out what changed between the list Studio holds and the list the editor produced.
Expand Down Expand Up @@ -43,6 +44,19 @@ function diffAssessmentItems(prevItems, nextItems) {
return { orders, added, updated, deleted };
}

/**
* The exercise's language, else the channel's, else the author's UI language. Empty when
* this browser has no number conventions for it, as `Intl` would use the browser's own.
*
* @param {object} [node]
* @param {object} [channel]
* @returns {string}
*/
export function exerciseLanguage(node, channel) {
const language = node?.language || channel?.language || currentLanguage;
return Intl.NumberFormat.supportedLocalesOf([language]).length ? language : '';
}

/**
* 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
Expand All @@ -57,13 +71,17 @@ export default function useAssessmentItems(nodeId) {
store.getters['assessmentItem/getAssessmentItems'](unref(nodeId)),
);

const node = computed(() => store.getters['contentNode/getContentNode'](unref(nodeId)));

/**
* Currently free responses are only allowed in surveys
*/
const allowFreeResponse = computed(
() =>
store.getters['contentNode/getContentNode'](unref(nodeId))?.extra_fields?.options
?.modality === ContentModalities.SURVEY,
() => node.value?.extra_fields?.options?.modality === ContentModalities.SURVEY,
);

const language = computed(() =>
exerciseLanguage(node.value, store.getters['currentChannel/currentChannel']),
);

const invalidItemsCount = computed(() =>
Expand Down Expand Up @@ -107,5 +125,5 @@ export default function useAssessmentItems(nodeId) {
}
}

return { assessmentItems, invalidItemsCount, allowFreeResponse, applyUpdate };
return { assessmentItems, invalidItemsCount, allowFreeResponse, language, applyUpdate };
}
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,73 @@ describe('validateQtiItem', () => {
});
});

describe('validateQtiItem on numeric answers typed in a language', () => {
it.each([
['fr', '1,5'],
['fr', '1,234'],
['fr', '1 234,5'],
['fr', '1\u202F234,5'],
['en', '1,234'],
['en', '1,234.5'],
['hi', '12,34,567'],
['hi', '३०'],
['ar', '٣٠'],
['fr', '1.5'],
['ar-EG', '3.5'],
])('reports no error for an answer typed in %s as %s, as the editor does', (language, value) => {
const state = {
prompt: '<p>How much?</p>',
answers: [{ id: 'a1', value }],
expectedLength: 50,
};
const { bodyXml, responseDeclarations } = textEntryInteractionDescriptor.buildXML(
state,
QuestionType.NUMERIC,
{ language },
);
const rawData = assembleItemXml({
identifier: 'item',
title: 'Question',
language,
bodyXml,
responseDeclarations,
});
expect(validateQtiItem(rawData)).toEqual([]);
expect(
textEntryInteractionDescriptor.validate(state, QuestionType.NUMERIC, { language }),
).toEqual([]);
});

it.each([
['en', '1,23'],
['fr', '1,2,3'],
['fr', '1 234.5'],
])('reports an answer typed in %s as %s invalid, as the editor does', (language, value) => {
const state = {
prompt: '<p>How much?</p>',
answers: [{ id: 'a1', value }],
expectedLength: 50,
};
const { bodyXml, responseDeclarations } = textEntryInteractionDescriptor.buildXML(
state,
QuestionType.NUMERIC,
{ language },
);
const rawData = assembleItemXml({
identifier: 'item',
title: 'Question',
language,
bodyXml,
responseDeclarations,
});
const invalid = ValidationError.INVALID_NUMERIC_VALUE;
expect(
codesOf(textEntryInteractionDescriptor.validate(state, QuestionType.NUMERIC, { language })),
).toContain(invalid);
expect(codesOf(validateQtiItem(rawData))).toContain(invalid);
});
});

// What the editor asks about an item it is already showing, which is everything an
// interaction cannot answer for itself.
describe('validateItemShape', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,13 @@

<component
:is="editorComponent"
:key="descriptor.type"
:key="`${descriptor.type}:${language}`"
:questionType="questionType"
:interaction="interaction"
:mode="mode"
:showAnswers="showAnswers"
:teleportTargetId="settingsTargetId"
:language="language"
@update:interaction="onUpdateInteraction"
@update:errors="errors => $emit('update:errors', errors)"
/>
Expand Down Expand Up @@ -125,6 +126,14 @@
type: Boolean,
default: true,
},
/**
* The exercise's language, which numeric answers are read and shown in. Empty means
* they are read and shown as stored.
*/
language: {
type: String,
default: '',
},
},

emits: ['update:questionType', 'update:interaction', 'update:errors'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
CHOICE_ITEM_DOCUMENT_NO_CORRECT_ANSWER,
ORDERING_ITEM_DOCUMENT_NO_PROMPT,
FREE_RESPONSE_ITEM_DOCUMENT,
NUMERIC_ITEM_DOCUMENT,
NO_INTERACTION_ITEM_DOCUMENT,
CHOICE_ITEM_DOCUMENT_WITH_HINTS,
VALID_ASSOCIATE_ITEM_DOCUMENT,
Expand Down Expand Up @@ -219,6 +220,29 @@ describe('QTIItemEditor', () => {
});
});

test('shows a numeric answer in the language it is given', async () => {
renderComponent({
item: { ...defaultProps.item, raw_data: NUMERIC_ITEM_DOCUMENT },
showAnswers: true,
language: 'fr',
});
expect(await screen.findByDisplayValue('1234,5')).toBeInTheDocument();
});

test('reads a numeric answer in the language it is given after a change', async () => {
const { emitted, updateProps } = renderComponent({
item: { ...defaultProps.item, raw_data: NUMERIC_ITEM_DOCUMENT },
mode: 'edit',
language: 'fr',
});
await screen.findByDisplayValue('1234,5');
await updateProps({ language: 'en' });
const input = await screen.findByDisplayValue('1234.5');
await fireEvent.input(input, { target: { value: '1,234' } });

expect(emitted()['update:rawData'].at(-1)[0]).toContain('<qti-value>1234</qti-value>');
});

describe('items this editor cannot edit', () => {
test('shows a read-only message for an item authored elsewhere', () => {
renderComponent({
Expand Down Expand Up @@ -315,6 +339,24 @@ describe('QTIItemEditor', () => {
expect(screen.getByText(incompleteItemIndicatorLabel$())).toBeInTheDocument();
});

// Publishing reads the stored XML, where these are not xsd:double.
test.each([
['fr', '1,5'],
['fr', '1 234'],
['de', '1.234,5'],
['en', '1,234'],
])('is shown in %s for a stored numeric answer %s', async (language, stored) => {
renderComponent({
item: {
...defaultProps.item,
raw_data: NUMERIC_ITEM_DOCUMENT.replace('1234.5', stored),
},
language,
});
await nextTick();
expect(screen.getByText(incompleteItemIndicatorLabel$())).toBeInTheDocument();
});

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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
:mode="mode"
:showAnswers="showAnswers"
:allowFreeResponse="allowFreeResponse"
:language="language"
@update:questionType="type => (currentQuestionType = type)"
@update:interaction="onUpdateInteraction"
@update:errors="onUpdateErrors"
Expand Down Expand Up @@ -398,6 +399,14 @@
type: Boolean,
default: true,
},
/**
* The exercise's language, which numeric answers are read and shown in. Empty means
* they are read and shown as stored.
*/
language: {
type: String,
default: '',
},
},

emits: ['open', 'close', 'update:rawData'],
Expand Down
Loading
Loading