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 @@ -28,36 +28,8 @@
<script>

import { ref, defineComponent } from 'vue';
import { CHOICE_ITEM_XML, MULTI_CHOICE_ITEM_XML } from './qtiDemoData';
import { INITIAL_ASSESSMENTS } from './qtiDemoData';
import QTIEditor from 'shared/views/QTIEditor/index';
import { AssessmentItemTypes } from 'shared/views/QTIEditor/constants';

/**
* Hardcoded items covering different states:
* - item-1: has raw_data (real QTI XML) → exercises the full load path
* - item-2: no raw_data → shows placeholder (blank new item state)
* - item-3: no raw_data → shows placeholder
*/
const INITIAL_ASSESSMENTS = [
{
assessment_id: 'demo-item-1',
type: AssessmentItemTypes.QTI,
raw_data: CHOICE_ITEM_XML,
},
{
assessment_id: 'demo-item-2',
type: AssessmentItemTypes.QTI,
raw_data: MULTI_CHOICE_ITEM_XML,
},
{
assessment_id: 'demo-item-3',
type: AssessmentItemTypes.QTI,
},
{
assessment_id: 'demo-item-4',
type: AssessmentItemTypes.QTI,
},
];

export default defineComponent({
name: 'QTIDemoPage',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,103 @@ export const MULTI_CHOICE_ITEM_XML = `<?xml version="1.0" encoding="UTF-8"?>
</qti-item-body>
</qti-assessment-item>`;

/**
* Demo item 3: numeric text-entry — student types an acceptable number.
*/
export const NUMERIC_ITEM_XML = `<?xml version="1.0" encoding="UTF-8"?>
<qti-assessment-item
xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0"
identifier="item-numeric"
title="Speed of light"
adaptive="false"
time-dependent="false"
xml:lang="en"
>
<qti-response-declaration
identifier="RESPONSE"
cardinality="multiple"
base-type="float"
>
<qti-correct-response>
<qti-value>299792458</qti-value>
<qti-value>3e8</qti-value>
</qti-correct-response>
</qti-response-declaration>

<qti-item-body>
<div>
<div><p>What is the speed of light in m/s? (enter one of the accepted values)</p></div>
<p><qti-text-entry-interaction response-identifier="RESPONSE"/></p>
</div>
</qti-item-body>
</qti-assessment-item>`;

/**
* Demo item 4: textEntry — student types a string answer (case-sensitive option shown).
*/
export const TEXT_ENTRY_ITEM_XML = `<?xml version="1.0" encoding="UTF-8"?>
<qti-assessment-item
xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0"
identifier="item-text-entry"
title="Chemical symbol for water"
adaptive="false"
time-dependent="false"
xml:lang="en"
>
<qti-response-declaration
identifier="RESPONSE"
cardinality="single"
base-type="string"
>
<qti-correct-response>
<qti-value case-sensitive="true">H2O</qti-value>
<qti-value>h2o</qti-value>
<qti-value>H2o</qti-value>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nitpick: This demo item loads with a validation error: h2o and H2o are both case-insensitive, so validateTextEntryInteraction normalizes both to h2o|false and this third row renders DUPLICATE_ANSWER_CONTENT as soon as the debounced validator fires. Dropping H2o (or marking it case-sensitive) gives a clean demo.

</qti-correct-response>
</qti-response-declaration>

<qti-item-body>
<div>
<div><p>What is the chemical symbol for water?</p></div>
<p><qti-text-entry-interaction response-identifier="RESPONSE" expected-length="10"/></p>
</div>
</qti-item-body>
</qti-assessment-item>`;

/**
* Demo item 5: freeResponse — open-ended, no correct answer.
*/
export const FREE_RESPONSE_ITEM_XML = `<?xml version="1.0" encoding="UTF-8"?>
<qti-assessment-item
xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0"
identifier="item-free-response"
title="Describe photosynthesis"
adaptive="false"
time-dependent="false"
xml:lang="en"
>
<qti-response-declaration
identifier="RESPONSE"
cardinality="single"
base-type="string"
/>

<qti-item-body>
<div>
<div><p>Describe the process of photosynthesis in your own words.</p></div>
<p><qti-text-entry-interaction response-identifier="RESPONSE" expected-length="50"/></p>
</div>
</qti-item-body>
</qti-assessment-item>`;

/**
* Hardcoded items covering different states:
* - item-1: has raw_data (real QTI XML) → exercises the full load path
* - item-2: no raw_data → shows placeholder (blank new item state)
* - item-3: no raw_data → shows placeholder
* - item-1: single-select choice interaction
* - item-2: multi-select choice interaction
* - item-numeric: numeric text-entry
* - item-text-entry: string text-entry with case-sensitive answers
* - item-free-response: free-response text-entry (no correct answer)
* - item-blank: no raw_data → shows placeholder (blank new item state)
*/
export const INITIAL_ASSESSMENTS = [
{
Expand All @@ -95,7 +187,22 @@ export const INITIAL_ASSESSMENTS = [
raw_data: MULTI_CHOICE_ITEM_XML,
},
{
assessment_id: 'demo-item-3',
assessment_id: 'demo-item-numeric',
type: AssessmentItemTypes.QTI,
raw_data: NUMERIC_ITEM_XML,
},
{
assessment_id: 'demo-item-text-entry',
type: AssessmentItemTypes.QTI,
raw_data: TEXT_ENTRY_ITEM_XML,
},
{
assessment_id: 'demo-item-free-response',
type: AssessmentItemTypes.QTI,
raw_data: FREE_RESPONSE_ITEM_XML,
},
{
assessment_id: 'demo-item-blank',
type: AssessmentItemTypes.QTI,
},
];
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<template>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

praise: Extracting the "Add …" button and reusing it in both editors is the right call at exactly the point the duplication appeared, and it keeps the Figma styling in one place as more interaction editors land.


<KButton
appearance="flat-button"
:appearanceOverrides="buttonAppearanceOverrides"
class="add-list-item-btn"
@click="$emit('click')"
>
<div class="add-list-item-btn-content">
<KIcon
icon="plus"
:color="$themePalette.blue.v_500"
/>
<span>{{ label }}</span>
</div>
</KButton>

</template>


<script>

import { computed } from 'vue';
import { themePalette } from 'kolibri-design-system/lib/styles/theme';

export default {
name: 'AddListItemButton',
setup() {
const palette = themePalette();
const buttonAppearanceOverrides = computed(() => ({
backgroundColor: palette.blue.v_50,
border: `1px dashed ${palette.blue.v_200}`,
color: `${palette.blue.v_500} !important`,
fontSize: '14px',
fontWeight: '600',
textTransform: 'none',
':hover': {
backgroundColor: palette.blue.v_100,
},
}));

return {
buttonAppearanceOverrides,
};
},
props: {
label: {
type: String,
required: true,
},
},
emits: ['click'],
};

</script>


<style scoped>

.add-list-item-btn {
justify-content: center;
width: 100%;
padding: 11px 16px !important;
margin-top: 10px;
line-height: unset !important;
border-radius: 4px !important;
}

.add-list-item-btn-content {
display: flex;
gap: 10px;
align-items: center;
justify-content: center;
}

</style>
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,6 @@ const renderSection = (props = {}) =>
routes: new VueRouter(),
});

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe('InteractionSection', () => {
describe('choice interaction', () => {
it('renders the prompt from the XML via ChoiceInteractionEditor', () => {
Expand All @@ -43,11 +39,9 @@ describe('InteractionSection', () => {
});

describe('parse error handling', () => {
it('gracefully falls back to default interaction state when XML is malformed', () => {
it('shows a parse error when XML is malformed', () => {
renderSection({ interaction: interactionBlock('not-xml<{{') });
// It should render exactly 1 choice fallback element
const inputs = screen.queryAllByRole('radio').concat(screen.queryAllByRole('checkbox'));
expect(inputs).toHaveLength(1);
expect(screen.getByText('This question could not be loaded')).toBeInTheDocument();
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import QTIItemEditor from '../index.vue';
import { qtiEditorStrings } from '../../../qtiEditorStrings';
import { AssessmentItemTypes } from '../../../constants';

jest.mock('shared/views/TipTapEditor/TipTapEditor/TipTapEditor');

const { closeBtnLabel$, questionContentPlaceholder$ } = qtiEditorStrings;

const defaultProps = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,18 +117,18 @@
*/
const currentQuestionType = ref(null);

/**
* Maps each QuestionType to its localized display label.
* Add new entries here as more question types are introduced.
*/
const QUESTION_TYPE_LABELS = {
[QuestionType.SINGLE_SELECT]: () => qtiEditorStrings.singleChoiceLabel$(),
[QuestionType.MULTI_SELECT]: () => qtiEditorStrings.multipleChoiceLabel$(),
};

const interactionTypeLabel = computed(
() => QUESTION_TYPE_LABELS[currentQuestionType.value]?.() ?? unknownTypeLabel$(),
);
const interactionTypeLabel = computed(() => {
const type = currentQuestionType.value;
if (!type) return unknownTypeLabel$();
const QUESTION_TYPE_LABELS = {
[QuestionType.SINGLE_SELECT]: qtiEditorStrings.singleSelectLabel$,
[QuestionType.MULTI_SELECT]: qtiEditorStrings.multiSelectLabel$,
[QuestionType.NUMERIC]: qtiEditorStrings.numericLabel$,
[QuestionType.TEXT_ENTRY]: qtiEditorStrings.textEntryLabel$,
[QuestionType.FREE_RESPONSE]: qtiEditorStrings.freeResponseLabel$,
};
return (QUESTION_TYPE_LABELS[type] ?? unknownTypeLabel$)();
});

const questionNumberAndTypeLabel = computed(() =>
questionNumberAndTypeLabel$({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,11 @@ function makeBlock(choices, questionType = QuestionType.SINGLE_SELECT) {
}

function setup(choices, questionType = QuestionType.SINGLE_SELECT) {
const qt = ref(questionType);
const questionTypeRef = ref(questionType);
const block = makeBlock(choices, questionType);
return { qt, ...useChoiceInteraction(block, qt) };
return { questionTypeRef, ...useChoiceInteraction(block, questionTypeRef) };
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe('useChoiceInteraction', () => {
describe('addChoice()', () => {
it('appends a new choice to the list', () => {
Expand Down Expand Up @@ -118,33 +114,33 @@ describe('useChoiceInteraction', () => {

describe('toggleCorrectChoice()', () => {
it('singleSelect: sets only the target as correct and clears others', () => {
const { state, toggleCorrectChoice, qt } = setup([
const { state, toggleCorrectChoice, questionTypeRef } = setup([
makeAnswer({ id: 'a', correct: true }),
makeAnswer({ id: 'b', correct: false }),
]);
qt.value = QuestionType.SINGLE_SELECT;
questionTypeRef.value = QuestionType.SINGLE_SELECT;
toggleCorrectChoice('b');
expect(state.value.choices.find(a => a.id === 'b').correct).toBe(true);
expect(state.value.choices.find(a => a.id === 'a').correct).toBe(false);
});

it('multiSelect: toggles only the target, leaves others unchanged', () => {
const { state, toggleCorrectChoice, qt } = setup(
const { state, toggleCorrectChoice, questionTypeRef } = setup(
[makeAnswer({ id: 'a', correct: true }), makeAnswer({ id: 'b', correct: false })],
QuestionType.MULTI_SELECT,
);
qt.value = QuestionType.MULTI_SELECT;
questionTypeRef.value = QuestionType.MULTI_SELECT;
toggleCorrectChoice('b');
expect(state.value.choices.find(a => a.id === 'b').correct).toBe(true);
expect(state.value.choices.find(a => a.id === 'a').correct).toBe(true);
});

it('multiSelect: toggles correct off when already correct', () => {
const { state, toggleCorrectChoice, qt } = setup(
const { state, toggleCorrectChoice, questionTypeRef } = setup(
[makeAnswer({ id: 'a', correct: true }), makeAnswer({ id: 'b', correct: true })],
QuestionType.MULTI_SELECT,
);
qt.value = QuestionType.MULTI_SELECT;
questionTypeRef.value = QuestionType.MULTI_SELECT;
toggleCorrectChoice('a');
expect(state.value.choices.find(a => a.id === 'a').correct).toBe(false);
expect(state.value.choices.find(a => a.id === 'b').correct).toBe(true);
Expand Down
Loading
Loading