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>
</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>

<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 @@ -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 @@ -62,7 +62,6 @@

import { computed, ref, watch } from 'vue';
import { qtiEditorStrings } from '../../qtiEditorStrings';
import { QuestionType } from '../../constants';
import useQtiItem from '../../composables/useQtiItem';
import InteractionSection from '../InteractionSection/index.vue';

Expand Down Expand Up @@ -117,18 +116,13 @@
*/
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$();
// Dynamic access to the localized string method (e.g. 'singleSelectLabel$()')
const methodKey = `${type}Label$`;
return qtiEditorStrings[methodKey]?.() ?? unknownTypeLabel$();
});

const questionNumberAndTypeLabel = computed(() =>
questionNumberAndTypeLabel$({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,14 @@ import {
CHOICE_SINGLE_SELECT_XML,
CHOICE_MULTI_SELECT_XML,
UNKNOWN_INTERACTION_XML,
TEXT_ENTRY_BODY_XML,
TEXT_ENTRY_NUMERIC_DECL_XML,
TEXT_ENTRY_STRING_DECL_XML,
TEXT_ENTRY_FREE_DECL_XML,
} from '../../utils/testingFixtures';

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

// ---------------------------------------------------------------------------
// Helper: renders a wrapper component that runs the composable inside setup().
// Because questionType is now set on mount (not as a computed), we must wait
Expand Down Expand Up @@ -103,11 +109,12 @@ describe('useInteractionDescriptor', () => {
});

describe('with malformed XML', () => {
it('returns a non-null parseError', async () => {
it('returns a parse error for malformed XML', async () => {
// inferFromXml uses text/xml which throws a parser error for malformed fragments.
const { result } = renderDescriptor('<unclosed');
await nextTick();
expect(result.parseError.value).not.toBeNull();
expect(typeof result.parseError.value).toBe('string');
expect(result.parseError.value).toMatch(/parse error/);
});

it('still returns a defined fallback descriptor on parse error', async () => {
Expand All @@ -134,4 +141,41 @@ describe('useInteractionDescriptor', () => {
expect(result.questionType.value).toBe(QuestionType.MULTI_SELECT);
});
});

// ---------------------------------------------------------------------------
// Regression: inline placement — bodyXml is a full <qti-item-body>
// Before the fix, documentElement was <qti-item-body> and no descriptor
// matched it, so the code fell back to the choice descriptor for every
// text-entry item, showing them as "Multiple Choice / Single Choice".
// ---------------------------------------------------------------------------
describe('with an inline text-entry interaction (bodyXml is qti-item-body)', () => {
it('resolves the TextEntry descriptor for a numeric declaration', async () => {
const { result } = renderDescriptor(TEXT_ENTRY_BODY_XML, [TEXT_ENTRY_NUMERIC_DECL_XML]);
await nextTick();
expect(result.descriptor.value.type).toBe(QtiInteraction.TEXT_ENTRY);
expect(result.questionType.value).toBe(QuestionType.NUMERIC);
});

it('resolves the TextEntry descriptor for a string + correct-response (textEntry)', async () => {
const { result } = renderDescriptor(TEXT_ENTRY_BODY_XML, [TEXT_ENTRY_STRING_DECL_XML]);
await nextTick();
expect(result.descriptor.value.type).toBe(QtiInteraction.TEXT_ENTRY);
expect(result.questionType.value).toBe(QuestionType.TEXT_ENTRY);
});

it('resolves the TextEntry descriptor for a string with no correct-response (freeResponse)', async () => {
const { result } = renderDescriptor(TEXT_ENTRY_BODY_XML, [TEXT_ENTRY_FREE_DECL_XML]);
await nextTick();
expect(result.descriptor.value.type).toBe(QtiInteraction.TEXT_ENTRY);
expect(result.questionType.value).toBe(QuestionType.FREE_RESPONSE);
});

it('does NOT resolve as choice when bodyXml is a qti-item-body (regression)', async () => {
// This is the exact bug: without the fix every text-entry item was
// treated as a choice interaction because documentElement was <qti-item-body>.
const { result } = renderDescriptor(TEXT_ENTRY_BODY_XML, [TEXT_ENTRY_FREE_DECL_XML]);
await nextTick();
expect(result.descriptor.value.type).not.toBe(QtiInteraction.CHOICE);
});
});
});
Loading
Loading