diff --git a/kolibri/plugins/perseus_viewer/frontend/__tests__/injectedItemData.spec.js b/kolibri/plugins/perseus_viewer/frontend/__tests__/injectedItemData.spec.js new file mode 100644 index 00000000000..2b871b9bfa7 --- /dev/null +++ b/kolibri/plugins/perseus_viewer/frontend/__tests__/injectedItemData.spec.js @@ -0,0 +1,222 @@ +import { render, screen, within } from '@testing-library/vue'; +// eslint-disable-next-line import-x/named +import useContentViewer, { useContentViewerMock } from 'kolibri/composables/useContentViewer'; +import { init } from '@khanacademy/perseus'; +import perseusFixtures from '../../../qti_viewer/frontend/components/__fixtures__/perseus'; +import PerseusRendererIndex from '../views/PerseusRendererIndex'; + +jest.mock('kolibri/composables/useContentViewer'); + +// PerseusRendererIndex imports Tex.js, which calls urls.static() at module load; +// that needs plugin URL data, absent in tests without a rendered Django template. +jest.mock('kolibri-plugin-data', () => ({ + __esModule: true, + default: { + urls: { + __staticUrl: '/static/', + __zipContentUrl: '/zipcontent/', + __zipContentOrigin: 'http://localhost', + __zipContentPort: '8000', + prefix: '/', + urls: {}, + }, + }, +})); + +// Perseus reads the widget registry when it migrates/renders items, so populate +// it first the same way production does via perseus.init() in the component's +// created hook (mirrors stateSerialization.spec.js:8). +init(); + +const LOCALPATH_TOKEN = '${☣ LOCALPATH}'; + +// A minimal Perseus item that references a packaged image in its content plus a +// gradable radio widget, so checkAnswer() has something to score. A radio widget +// renders as plain HTML choices — unlike input-number, it needs no Perseus +// keypad/MathInput subtree, which does not mount cleanly under jsdom. +function perseusItem(imageRef) { + return { + question: { + content: `![an image](${imageRef})\n\n[[☃ radio 1]]`, + images: {}, + widgets: { + 'radio 1': { + type: 'radio', + graded: true, + options: { + choices: [ + { content: 'First choice', correct: true }, + { content: 'Second choice', correct: false }, + ], + randomize: false, + multipleSelect: false, + countChoices: false, + displayCount: null, + hasNoneOfTheAbove: false, + deselectEnabled: false, + }, + version: { major: 1, minor: 0 }, + }, + }, + }, + answerArea: { + calculator: false, + chi2Table: false, + periodicTable: false, + tTable: false, + zTable: false, + }, + itemDataVersion: { major: 0, minor: 1 }, + hints: [], + }; +} + +// Build an embellished itemData: the raw Perseus JSON as a string plus an +// image-URL map and a source id, the shape the QTI wrapper injects. +function embellishedItemData({ + imageRef = `${LOCALPATH_TOKEN}/perseus/images/a.png`, + packageFiles, + sourceId, +}) { + return { perseusItemString: JSON.stringify(perseusItem(imageRef)), packageFiles, sourceId }; +} + +// Mount PerseusRendererIndex driving itemData/interactive through the +// useContentViewer mock (the component declares no props — everything arrives +// through the composable). Captures the child instance via a parent ref and the +// assessment API the renderer registers in its created hook. +async function mountRenderer(itemData) { + let instance = null; + let registeredApi = null; + useContentViewer.mockImplementation(() => ({ + ...useContentViewerMock({ itemData, interactive: true }), + // After the spread — the mock's default registerAssessmentApi would else win. + registerAssessmentApi: api => { + registeredApi = api; + }, + })); + // The real module wires this up on the exported component; tests mount the + // component directly, so supply a no-op directional-CSS loader. + PerseusRendererIndex.contentModule = { loadDirectionalCSS: () => Promise.resolve() }; + const Harness = { + components: { PerseusRendererIndex }, + mounted() { + instance = this.$refs.renderer; + }, + template: ``, + }; + const utils = render(Harness); + await global.flushPromises(); + await global.flushPromises(); + return { instance: () => instance, api: () => registeredApi, ...utils }; +} + +describe('PerseusRendererIndex embellished itemData', () => { + it('substitutes injected image URLs into the item, dropping LOCALPATH tokens', async () => { + const itemData = embellishedItemData({ + packageFiles: { 'perseus/images/a.png': 'blob:injected-a' }, + sourceId: 'qti-perseus:perseus/q.json', + }); + const { instance } = await mountRenderer(itemData); + + const content = instance().item.question.content; + expect(content).toContain('blob:injected-a'); + expect(content).not.toContain(LOCALPATH_TOKEN); + expect(instance().perseusFileUrl).toBe('qti-perseus:perseus/q.json'); + }); + + it('matches package files forgivingly by basename', async () => { + const itemData = embellishedItemData({ + // Keyed by basename only — the exact zip path lookup misses. + packageFiles: { 'a.png': 'blob:by-basename' }, + sourceId: 'qti-perseus:perseus/basename.json', + }); + const { instance } = await mountRenderer(itemData); + + expect(instance().item.question.content).toContain('blob:by-basename'); + }); + + it('restores LOCALPATH refs and leaks no resolved URLs into checked answer data', async () => { + const itemData = embellishedItemData({ + packageFiles: { 'perseus/images/a.png': 'blob:injected-a' }, + sourceId: 'qti-perseus:perseus/answer.json', + }); + const { api } = await mountRenderer(itemData); + + const result = api().checkAnswer(); + expect(result).not.toBeNull(); + expect(JSON.stringify(result.answerState)).not.toContain('blob:'); + }); +}); + +// Build the embellished itemData a QTI Perseus custom interaction injects: the +// item JSON served verbatim as a string, plus its declared perseus/images/ deps +// as the image-url map. +function embellishedFromFixture(fixture) { + const packageFiles = Object.fromEntries( + Object.entries(fixture.files).filter(([path]) => path !== fixture.perseusPath), + ); + return { + perseusItemString: fixture.files[fixture.perseusPath], + packageFiles, + sourceId: `qti-perseus:${fixture.perseusPath}`, + }; +} + +// Real Perseus items sampled from the Kolibri QA Channel, wrapped as +// Perseus-in-QTI custom interactions (see the qti_viewer perseus fixtures). +describe('PerseusRendererIndex with real QA-channel items', () => { + // Perseus's graphie loader fetches each image's `-data.json` label file; jsdom + // has no `fetch`. Stub it to return valid JSON so the graphie image widget + // mounts without crashing — the assertions here are on the item/answer data + // model, not on the rendered SVG. `parseDataFromJSONP` falls back to + // JSON.parse of the whole body when there is no JSONP wrapper. + let originalFetch; + beforeEach(() => { + originalFetch = global.fetch; + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + text: () => Promise.resolve('{"range":[[0,10],[0,10]],"labels":[]}'), + }), + ); + }); + afterEach(() => { + global.fetch = originalFetch; + }); + + it('resolves web+graphie image refs from the injected map, dropping LOCALPATH', async () => { + const itemData = embellishedFromFixture(perseusFixtures['perseus-classify-triangle']); + const { instance, api } = await mountRenderer(itemData); + + // The graphie background image is substituted out of LOCALPATH space; the + // renderer resolves the .svg/-data.json halves from the map by basename. + const serializedItem = JSON.stringify(instance().item); + expect(serializedItem).toContain('web+graphie:'); + expect(serializedItem).not.toContain('${☣ LOCALPATH}'); + + // Grading round-trips the answer back into LOCALPATH space — no resolved + // (data:) URL persists into the checked answer data. + const result = api().checkAnswer(); + expect(result).not.toBeNull(); + expect(JSON.stringify(result.answerState)).not.toContain('data:'); + }); + + it('renders accessible choice controls matching native interactions', async () => { + const itemData = embellishedFromFixture(perseusFixtures['perseus-square-shape']); + await mountRenderer(itemData); + + // The Perseus subtree exposes its choices as a grouped list of named, + // keyboard-focusable controls — the same affordances a native QTI choice + // interaction provides (an accessible group of operable, labelled choices). + expect(screen.getAllByRole('group').length).toBeGreaterThan(0); + const choices = screen.getAllByRole('listitem'); + expect(choices.length).toBeGreaterThanOrEqual(3); + choices.forEach(choice => { + const control = within(choice).getByRole('button'); + expect(control).toHaveAccessibleName(); + expect(control.getAttribute('tabindex')).not.toBe('-1'); + expect(control.getAttribute('aria-hidden')).not.toBe('true'); + }); + }); +}); diff --git a/kolibri/plugins/perseus_viewer/frontend/views/PerseusRendererIndex.vue b/kolibri/plugins/perseus_viewer/frontend/views/PerseusRendererIndex.vue index 9b2b70e80ff..dbcd8183e96 100644 --- a/kolibri/plugins/perseus_viewer/frontend/views/PerseusRendererIndex.vue +++ b/kolibri/plugins/perseus_viewer/frontend/views/PerseusRendererIndex.vue @@ -107,16 +107,24 @@ */ const globalPerseusFileRegistry = {}; + // Retain a registry entry (creating it empty on first use). Returns true when + // the entry was newly created, so callers can populate its zipFile. + function retainPerseusFile(key) { + if (globalPerseusFileRegistry[key]) { + globalPerseusFileRegistry[key].usageCounter += 1; + return false; + } + globalPerseusFileRegistry[key] = { + zipFile: null, + usageCounter: 1, + imageUrls: {}, + }; + return true; + } + function setUpPerseusFile(defaultFile) { const perseusFileUrl = defaultFile.storage_url; - if (globalPerseusFileRegistry[perseusFileUrl]) { - globalPerseusFileRegistry[perseusFileUrl].usageCounter += 1; - } else { - globalPerseusFileRegistry[perseusFileUrl] = { - zipFile: null, - usageCounter: 1, - imageUrls: {}, - }; + if (retainPerseusFile(perseusFileUrl)) { class JSONMapper extends Mapper { getPaths() { return getImagePaths(this.file.toString()); @@ -141,19 +149,43 @@ if (globalPerseusFileRegistry[perseusFileUrl]) { globalPerseusFileRegistry[perseusFileUrl].usageCounter -= 1; if (globalPerseusFileRegistry[perseusFileUrl].usageCounter === 0) { - globalPerseusFileRegistry[perseusFileUrl].zipFile.close(); + // Injected entries carry no zipFile (their URLs are owned elsewhere). + const { zipFile } = globalPerseusFileRegistry[perseusFileUrl]; + if (zipFile) { + zipFile.close(); + } delete globalPerseusFileRegistry[perseusFileUrl]; } } } + function basename(path) { + return path.slice(path.lastIndexOf('/') + 1); + } + + // Exact match, else fall back to a basename match: Perseus's lookup names may + // differ from packaged file paths only by directory prefix. + function lookupImageUrl(imageUrls, key) { + if (imageUrls[key]) { + return imageUrls[key]; + } + const keyBase = basename(key); + for (const mapKey in imageUrls) { + if (basename(mapKey) === keyBase) { + return imageUrls[mapKey]; + } + } + return; + } + function getImageUrl(key, zipFileUrl = null) { if (zipFileUrl !== null && globalPerseusFileRegistry[zipFileUrl]) { - return globalPerseusFileRegistry[zipFileUrl].imageUrls[key]; + return lookupImageUrl(globalPerseusFileRegistry[zipFileUrl].imageUrls, key); } for (const file in globalPerseusFileRegistry) { - if (globalPerseusFileRegistry[file].imageUrls[key]) { - return globalPerseusFileRegistry[file].imageUrls[key]; + const url = lookupImageUrl(globalPerseusFileRegistry[file].imageUrls, key); + if (url) { + return url; } } return; @@ -340,7 +372,7 @@ this.loadItemData(); }, itemData(newItemData) { - this.setItemData(newItemData); + this.resolveItemData(newItemData); }, answerState(newState) { this.resetState(newState); @@ -409,7 +441,7 @@ if (this.defaultFile) { this.loadItemData(); } else if (this.itemData) { - this.setItemData(this.itemData); + this.resolveItemData(this.itemData); } this.$emit('startTracking'); }); @@ -732,6 +764,26 @@ }); } }, + resolveItemData(itemData) { + // An embellished itemData carries the raw Perseus JSON plus an image-URL + // map, sidestepping setUpPerseusFile: retain an injected registry entry + // (no zipFile — packageFiles' URLs are owned by the QTI zip / sandbox), + // substitute the URLs in, and render through the normal pipeline. Plain + // object/string itemData is unchanged. + if (itemData && itemData.perseusItemString) { + const { perseusItemString, packageFiles, sourceId } = itemData; + cleanUpPerseusFile(this.perseusFileUrl); + retainPerseusFile(sourceId); + this.perseusFileUrl = sourceId; + const substituted = replaceImageUrls(perseusItemString, sourceId, packageFiles); + this.setItemData(JSON.parse(substituted)); + // Supplied synchronously (unlike the async zip path), so clear loading + // here to unblock checkAnswer/resetState. + this.loading = false; + } else { + this.setItemData(itemData); + } + }, setItemData(itemData) { const result = parseAndMigratePerseusItem(itemData); if (isFailure(result)) { diff --git a/kolibri/plugins/qti_viewer/frontend/components/AssessmentItem.vue b/kolibri/plugins/qti_viewer/frontend/components/AssessmentItem.vue index fb9745fdb46..c7f156925b3 100644 --- a/kolibri/plugins/qti_viewer/frontend/components/AssessmentItem.vue +++ b/kolibri/plugins/qti_viewer/frontend/components/AssessmentItem.vue @@ -35,6 +35,7 @@ import OrderInteraction from './interactions/OrderInteraction.vue'; import InlineChoiceInteraction from './interactions/InlineChoiceInteraction.vue'; import InlineChoice from './interactions/InlineChoice.vue'; + import CustomInteraction from './interactions/CustomInteraction.vue'; const $themeTokens = themeTokens(); @@ -46,6 +47,7 @@ [OrderInteraction.tag]: OrderInteraction, [InlineChoiceInteraction.tag]: InlineChoiceInteraction, [InlineChoice.tag]: InlineChoice, + [CustomInteraction.tag]: CustomInteraction, }); /** @typedef {import('../utils/qti/values.js').QTIValue} QTIValue */ @@ -109,7 +111,10 @@ } registerCheckAnswer(() => { - // Run response processing to compute outcome values (e.g., SCORE) + // Run response processing to compute outcome values (e.g., SCORE). + // Interactions that grade themselves (e.g. the Perseus custom + // interaction) keep their RESPONSE record current as the user answers, + // so it is already up to date when processing reads it here. processResponses(); const answerState = {}; diff --git a/kolibri/plugins/qti_viewer/frontend/components/QTISandboxPage.vue b/kolibri/plugins/qti_viewer/frontend/components/QTISandboxPage.vue index 30646ef710a..909006db8c1 100644 --- a/kolibri/plugins/qti_viewer/frontend/components/QTISandboxPage.vue +++ b/kolibri/plugins/qti_viewer/frontend/components/QTISandboxPage.vue @@ -68,7 +68,7 @@ currentResource.value?.href); + + // itemData injects a single item directly, bypassing the package zip. It is + // either the raw item XML string, or an object carrying that XML alongside + // the item's declared package files, so descendant interactions resolve + // assets exactly as they would from a loaded zip — QTIViewer stays the + // single source of truth for the package however the item was supplied. + const itemXml = computed(() => { + const data = itemData.value; + if (!data) { + return null; + } + return typeof data === 'string' ? data : data.xml; + }); + const itemDataFiles = computed(() => { + const data = itemData.value; + return data && typeof data !== 'string' ? data.files : null; + }); + // If itemData is provided, we only support injecting AssessmentItem XML const resourceType = computed(() => - itemData.value ? 'imsqti_item_xmlv3p0' : currentResource.value?.type, + itemXml.value ? 'imsqti_item_xmlv3p0' : currentResource.value?.type, ); const { @@ -69,9 +87,9 @@ } = useQTIResource(resourceUrl); const xmlDoc = computed(() => { - // If itemData is provided, use it directly - if (itemData.value) { - return parseXML(itemData.value); + // If itemData is provided, use its item XML directly + if (itemXml.value) { + return parseXML(itemXml.value); } // Otherwise, use the resource XML document return resourceXmlDoc.value; @@ -97,7 +115,7 @@ ); const loading = computed(() => { - if (itemData.value) { + if (itemXml.value) { return false; } return packageLoading.value || resourceLoading.value || templateLoading.value; @@ -226,7 +244,38 @@ computed(() => interactive.value), ); - provide('qtiPackage', qtiPackage); + // On the itemData path there is no zip to load, so synthesize a + // package/resource from the files carried on itemData, exposing the same + // getFile/files interface a loaded zip provides. This keeps QTIViewer the + // single source of truth for the package whether the item came from a zip + // or was injected directly; descendant interactions resolve assets through + // these regardless. + const itemDataPackage = computed(() => { + const files = itemDataFiles.value; + if (!files) { + return null; + } + return { + getFile(path) { + const content = files[path]; + if (content == null) { + return undefined; + } + return { toString: () => content, toUrl: () => content }; + }, + }; + }); + const itemDataResource = computed(() => + itemDataFiles.value ? { files: Object.keys(itemDataFiles.value) } : null, + ); + provide( + 'qtiPackage', + computed(() => qtiPackage.value || itemDataPackage.value), + ); + provide( + 'qtiResource', + computed(() => currentResource.value || itemDataResource.value), + ); // The public assessment API (checkAnswer + progressive-reveal hints) is // registered above via registerAssessmentApi and re-exposed on the diff --git a/kolibri/plugins/qti_viewer/frontend/components/__fixtures__/items.js b/kolibri/plugins/qti_viewer/frontend/components/__fixtures__/items.js index 23d8094b4b4..a4433c09f8b 100644 --- a/kolibri/plugins/qti_viewer/frontend/components/__fixtures__/items.js +++ b/kolibri/plugins/qti_viewer/frontend/components/__fixtures__/items.js @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:581072841c36d499fa9401c73cb6a9ef87b298d9e86cf76be420c7101eeb80ba -size 864016 +oid sha256:e017bda913c95f5149973acf5cccf1fc712412294dd8b645390db6be99e2a30a +size 864462 diff --git a/kolibri/plugins/qti_viewer/frontend/components/__fixtures__/perseus/index.js b/kolibri/plugins/qti_viewer/frontend/components/__fixtures__/perseus/index.js new file mode 100644 index 00000000000..a17c2888b00 --- /dev/null +++ b/kolibri/plugins/qti_viewer/frontend/components/__fixtures__/perseus/index.js @@ -0,0 +1,183 @@ +/** + * Perseus-in-QTI sandbox fixtures. + * + * Real assessment items sampled from the Kolibri QA Channel + * (channel 95a52b386f2c485cb97dd60901674a98 on + * https://kolibri-dev.learningequality.org, import token `nakav-mafak`), + * embellished into the mixed-package shape Studio's publish counterpart + * (learningequality/studio#6047) produces: a self-grading QTI wrapper item + * whose single `qti-custom-interaction data-type="perseus"` points at the raw + * Perseus JSON, stored verbatim as a package resource alongside its declared + * image dependencies. Assets are inlined as `data:` URLs (mirroring the + * `luggageSignImage` pattern in items.js) so the sandbox stays zip-free. + * + * The Perseus JSON is stored exactly as published, so its image refs keep the + * raw `${☣ LOCALPATH}/images/…` form while the package declares the assets under + * `perseus/images/…`; the renderer's basename-forgiving lookup bridges the two. + * + * Provenance (Kolibri QA Channel nodes): + * - perseus-square-shape: assessment item 3da39cc6… of exercise + * "Nombra figuras (parte 1)" (node 3c2c0889…) — a plain PNG image + a graded + * radio. + * - perseus-classify-triangle: assessment item e1ad6a6b… of exercise + * "Practice quiz - Classify triangles by both sides and angles" (node + * b539fe66…) — a `web+graphie:` background image + a graded multi-select radio. + */ + +// Build the self-grading QTI wrapper around a Perseus custom interaction: a +// schemaless RESPONSE record the renderer writes `correct`/`simpleAnswer`/ +// `answerState` into, and inline response processing that derives the float +// SCORE from the record's `correct` field (no standard template can read a +// record field). Mirrors the Studio contract (#6047). +function perseusWrapperXml(perseusPath) { + return ` + + + + 0 + + + + + + + + + + + + 1 + + + + + 0 + + + + + `; +} + +const perseusFixtures = { + 'perseus-square-shape': { + identifier: 'perseus-square-shape', + title: 'Nombra figuras (parte 1)', + node: '3c2c08898e894133b48b760ba4a65549', + perseusPath: 'perseus/3da39cc6fbe15074b24dea16a0662cd6.json', + xml: perseusWrapperXml('perseus/3da39cc6fbe15074b24dea16a0662cd6.json'), + // Zip-path -> file content. The item JSON is served verbatim as a + // string; each declared image dep is served as an inlined data: URL. + files: { + 'perseus/3da39cc6fbe15074b24dea16a0662cd6.json': + '{"answerArea":{"calculator":false,"options":{"content":"","images":{},"widgets":{}},"type":"multiple"},"hints":[{"content":"La figura tiene $4$ lados de igual longitud.\\n\\nLa figura tiene esquinas cuadradas, como una hoja de papel.","images":{},"widgets":{}},{"content":"![](${☣ LOCALPATH}/images/d5ba1448bb14ba1b5699e540cf789142.png) es un cuadrado.","images":{"${☣ LOCALPATH}/images/d5ba1448bb14ba1b5699e540cf789142.png":{"height":45,"width":45}},"widgets":{}}],"itemDataVersion":{"major":0,"minor":1},"question":{"content":"**¿Qué es esta figura?**\\n\\n![](${☣ LOCALPATH}/images/d5ba1448bb14ba1b5699e540cf789142.png)\\n\\n[[☃ radio 1]] ","images":{"${☣ LOCALPATH}/images/d5ba1448bb14ba1b5699e540cf789142.png":{"height":45,"width":45}},"widgets":{"radio 1":{"graded":true,"options":{"choices":[{"clue":"Los círculos son redondos. Esta figura tiene $4$ lados rectos.","content":"Círculo","correct":false},{"clue":"Los triángulos tienen $3$ lados. Esta figura tiene $4$ lados rectos.","content":"Triángulo","correct":false},{"clue":"¡Sí! Los cuadrados tienen $4$ lados de igual longitud.","content":"Cuadrado","correct":true}],"displayCount":null,"multipleSelect":false,"noneOfTheAbove":false,"onePerLine":true,"randomize":true},"type":"radio","version":{"major":0,"minor":0}}}}}\n', + 'perseus/images/d5ba1448bb14ba1b5699e540cf789142.png': + 'data:image/png;base64,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', + }, + }, + 'perseus-classify-triangle': { + identifier: 'perseus-classify-triangle', + title: 'Practice quiz - Classify triangles by both sides and angles', + node: 'b539fe6645894620993a9c7ad594df87', + perseusPath: 'perseus/e1ad6a6bd16456f1b328071496ec2be6.json', + xml: perseusWrapperXml('perseus/e1ad6a6bd16456f1b328071496ec2be6.json'), + // Zip-path -> file content. The item JSON is served verbatim as a + // string; each declared image dep is served as an inlined data: URL. + files: { + 'perseus/e1ad6a6bd16456f1b328071496ec2be6.json': + '{"answerArea":{"calculator":false,"chi2Table":false,"periodicTable":false,"tTable":false,"zTable":false},"hints":[{"content":"We can classify triangles by their sides and their angles.","images":{},"replace":false,"widgets":{}},{"content":"###Sides\\n\\nAn $\\\\blueD{\\\\text{equilateral}}$ triangle has $\\\\blueD{3\\\\text{ equal sides}}$. \\n\\nAn $\\\\purpleC{\\\\text{isosceles}}$ triangle has _at least_ $\\\\purpleC{2\\\\text{ equal sides}}$.\\n\\nA $\\\\greenD{\\\\text{scalene}}$ triangle has $\\\\greenD{0\\\\text{ equal sides}}$.","images":{},"replace":false,"widgets":{}},{"content":"**How many sides of equal length does $\\\\triangle{ABC}$ have?**\\n\\n$\\\\triangle{ABC}$ has $\\\\greenD{0\\\\text{ equal sides}}$, so it is a $\\\\greenD{\\\\text{scalene}}$ triangle.","images":{},"replace":false,"widgets":{}},{"content":"###Angles\\n\\nAn $\\\\goldE{\\\\text{acute}}$ triangle has $\\\\goldE{3}$ angles that measure $\\\\goldE{\\\\text{less than } 90^\\\\circ}$. \\n\\nA $\\\\tealD{\\\\text{right}}$ triangle has $\\\\tealD{1}$ angle that measures $\\\\tealD{90^\\\\circ}$.\\n\\nAn $\\\\maroonD{\\\\text{obtuse}}$ triangle has $\\\\maroonD{1}$ angle that measures $\\\\maroonD{\\\\text{more than } 90^\\\\circ}$. ","images":{},"replace":false,"widgets":{}},{"content":"**What are the measures of $\\\\triangle{ABC}$\'s angles?**\\n\\n\\n[[☃ image 1]]\\n\\n$\\\\triangle{ABC}$ has $\\\\maroonD{1}$ angle that measures $\\\\maroonD{\\\\text{more than } 90^\\\\circ}$so it is an $\\\\maroonD{\\\\text{obtuse}}$ triangle.","images":{},"replace":false,"widgets":{"image 1":{"alignment":"block","graded":true,"options":{"alt":"A triangle ABC with side lengths of 4 millimeters, 7 millimeters, and 5 millimeters and angle measures of 42 degrees, 104 degrees, and 34 degrees. ","backgroundImage":{"height":210,"url":"web+graphie:${☣ LOCALPATH}/images/b2472d6d9e60f7b7abe54be369595f503015ffe5","width":300},"box":[300,210],"caption":"","labels":[],"range":[[0,10],[0,10]],"static":false,"title":""},"static":false,"type":"image","version":{"major":0,"minor":0}}}},{"content":"$\\\\triangle{ABC}$ is a $\\\\greenD{\\\\text{scalene}}$ triangle and an $\\\\maroonD{\\\\text{obtuse}}$ triangle.","images":{},"replace":false,"widgets":{}}],"itemDataVersion":{"major":0,"minor":1},"question":{"content":"**Classify $\\\\triangle{ABC}$ by its side lengths and by its angles.**\\n\\n[[☃ image 1]]\\n\\n \\n\\n[[☃ radio 1]]\\n\\n","images":{},"widgets":{"image 1":{"alignment":"block","graded":true,"options":{"alt":"A triangle ABC with side lengths of 4 millimeters, 7 millimeters, and 5 millimeters. There is 1 obtuse angle and 2 acute angles.","backgroundImage":{"height":210,"url":"web+graphie:${☣ LOCALPATH}/images/3a88adb9d69b2f455686069c6b178b22ac11f963","width":300},"box":[300,210],"caption":"","labels":[],"range":[[0,10],[0,10]],"static":false,"title":""},"static":false,"type":"image","version":{"major":0,"minor":0}},"radio 1":{"alignment":"default","graded":true,"options":{"choices":[{"content":"Right triangle"},{"content":"Acute triangle"},{"content":"Equilateral triangle","isNoneOfTheAbove":false},{"content":"Scalene triangle","correct":true,"isNoneOfTheAbove":false},{"content":"Obtuse triangle","correct":true,"isNoneOfTheAbove":false},{"content":"Isosceles triangle","isNoneOfTheAbove":false}],"countChoices":true,"deselectEnabled":false,"displayCount":null,"hasNoneOfTheAbove":false,"multipleSelect":true,"randomize":true},"static":false,"type":"radio","version":{"major":1,"minor":0}}}}}\n', + 'perseus/images/3a88adb9d69b2f455686069c6b178b22ac11f963.svg': + 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMDAiIGhlaWdodD0iMjEwIiB2aWV3Qm94PSIwIDAgMzAwIDIxMCI+PGVsbGlwc2UgY3g9IjYwIiBjeT0iMzAiIHJ4PSI0IiByeT0iNCIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1kYXNoYXJyYXk9IjAiLz48ZWxsaXBzZSBjeD0iOTAiIGN5PSIxNTAiIHJ4PSI0IiByeT0iNCIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1kYXNoYXJyYXk9IjAiLz48ZWxsaXBzZSBjeD0iMjQwIiBjeT0iMTUwIiByeD0iNCIgcnk9IjQiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtZGFzaGFycmF5PSIwIi8+PHBhdGggc3Ryb2tlPSIjMDAwIiBkPSJNNjAgMzBsMzAgMTIwaDE1MHoiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWRhc2hhcnJheT0iMCIgZmlsbD0ibm9uZSIvPjwvc3ZnPgo=', + 'perseus/images/3a88adb9d69b2f455686069c6b178b22ac11f963-data.json': + 'data:application/json;base64,c3ZnRGF0YTNhODhhZGI5ZDY5YjJmNDU1Njg2MDY5YzZiMTc4YjIyYWMxMWY5NjMoeyJyYW5nZSI6W1stMiw4XSxbLTEsNl1dLCJsYWJlbHMiOlt7ImNvbnRlbnQiOiI0XFx0ZXh0eyBtbSB9IiwiY29vcmRpbmF0ZXMiOlstMC4xNDY3NjE2NjY3NjM1NTQ3MywyLjgzODMwOTU4MzMwOTExMTRdLCJhbGlnbm1lbnQiOiJjZW50ZXIiLCJ0eXBlc2V0QXNNYXRoIjp0cnVlLCJzdHlsZSI6eyJmaWxsLW9wYWNpdHkiOiIwIiwiY29sb3IiOiJibGFjayJ9fSx7ImNvbnRlbnQiOiI1XFx0ZXh0eyBtbX0iLCJjb29yZGluYXRlcyI6WzMuNSwwLjMzMzMzMzMzMzMzMzMzMzA0XSwiYWxpZ25tZW50IjoiY2VudGVyIiwidHlwZXNldEFzTWF0aCI6dHJ1ZSwic3R5bGUiOnsiZmlsbC1vcGFjaXR5IjoiMCIsImNvbG9yIjoiYmxhY2sifX0seyJjb250ZW50IjoiN1xcdGV4dHsgbW19IiwiY29vcmRpbmF0ZXMiOlszLjM2OTgwMDEzMDgxNjgxOTMsMy41NTQ3MDAxOTYyMjUyMjldLCJhbGlnbm1lbnQiOiJjZW50ZXIiLCJ0eXBlc2V0QXNNYXRoIjp0cnVlLCJzdHlsZSI6eyJmaWxsLW9wYWNpdHkiOiIwIiwiY29sb3IiOiJibGFjayJ9fSx7ImNvbnRlbnQiOiJBIiwiY29vcmRpbmF0ZXMiOlstMC40MTA0MzQxNDI2MTUxNjg3NSw1LjU4MjQwODA2MzgxNzExOV0sImFsaWdubWVudCI6ImNlbnRlciIsInR5cGVzZXRBc01hdGgiOnRydWUsInN0eWxlIjp7ImZpbGwtb3BhY2l0eSI6IjAiLCJjb2xvciI6ImJsYWNrIn19LHsiY29udGVudCI6IkIiLCJjb29yZGluYXRlcyI6WzAuNTYxNDQ2OTA3MTI5MTcxNCwwLjQzODMxMTU0NTY5NTM1ODhdLCJhbGlnbm1lbnQiOiJjZW50ZXIiLCJ0eXBlc2V0QXNNYXRoIjp0cnVlLCJzdHlsZSI6eyJmaWxsLW9wYWNpdHkiOiIwIiwiY29sb3IiOiJibGFjayJ9fSx7ImNvbnRlbnQiOiJDIiwiY29vcmRpbmF0ZXMiOls2LjY4MTkyNDA4MzQzOTg4MywwLjc5MzUzMDAwMDc1MTY4MzddLCJhbGlnbm1lbnQiOiJjZW50ZXIiLCJ0eXBlc2V0QXNNYXRoIjp0cnVlLCJzdHlsZSI6eyJmaWxsLW9wYWNpdHkiOiIwIiwiY29sb3IiOiJibGFjayJ9fV19KTs=', + 'perseus/images/b2472d6d9e60f7b7abe54be369595f503015ffe5.svg': + 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMDAiIGhlaWdodD0iMjEwIiB2aWV3Qm94PSIwIDAgMzAwIDIxMCI+PGVsbGlwc2UgY3g9IjYwIiBjeT0iMzAiIHJ4PSI0IiByeT0iNCIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1kYXNoYXJyYXk9IjAiLz48ZWxsaXBzZSBjeD0iOTAiIGN5PSIxNTAiIHJ4PSI0IiByeT0iNCIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1kYXNoYXJyYXk9IjAiLz48ZWxsaXBzZSBjeD0iMjQwIiBjeT0iMTUwIiByeD0iNCIgcnk9IjQiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtZGFzaGFycmF5PSIwIi8+PHBhdGggc3Ryb2tlPSIjMDAwIiBkPSJNNjAgMzBsMzAgMTIwaDE1MHoiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWRhc2hhcnJheT0iMCIgZmlsbD0ibm9uZSIvPjwvc3ZnPgo=', + 'perseus/images/b2472d6d9e60f7b7abe54be369595f503015ffe5-data.json': + 'data:application/json;base64,c3ZnRGF0YWIyNDcyZDZkOWU2MGY3YjdhYmU1NGJlMzY5NTk1ZjUwMzAxNWZmZTUoeyJyYW5nZSI6W1stMiw4XSxbLTEsNl1dLCJsYWJlbHMiOlt7ImNvbnRlbnQiOiJcXG1hcm9vbkR7NDJeXFxjaXJjfSIsImNvb3JkaW5hdGVzIjpbMC43MzczODM0ODg2MzQxNjQ2LDMuOTUzNjQ5MjA4Njg5NzIzN10sImFsaWdubWVudCI6ImNlbnRlciIsInR5cGVzZXRBc01hdGgiOnRydWUsInN0eWxlIjp7ImZpbGwtb3BhY2l0eSI6IjAiLCJjb2xvciI6ImJsYWNrIn19LHsiY29udGVudCI6IlxcbWFyb29uRHsxMDReXFxjaXJjfSIsImNvb3JkaW5hdGVzIjpbMS41NjUyNTIwNDg0NDgyMjczLDEuNzIzOTYxNDg3MzI0MjU0OF0sImFsaWdubWVudCI6ImNlbnRlciIsInR5cGVzZXRBc01hdGgiOnRydWUsInN0eWxlIjp7ImZpbGwtb3BhY2l0eSI6IjAiLCJjb2xvciI6ImJsYWNrIn19LHsiY29udGVudCI6IlxcbWFyb29uRHszNF5cXGNpcmN9IiwiY29vcmRpbmF0ZXMiOls0LjYyODc0NzA0MTQ5MjI5MiwxLjQxNTE4MTk4OTAwNDA1Nl0sImFsaWdubWVudCI6ImNlbnRlciIsInR5cGVzZXRBc01hdGgiOnRydWUsInN0eWxlIjp7ImZpbGwtb3BhY2l0eSI6IjAiLCJjb2xvciI6ImJsYWNrIn19LHsiY29udGVudCI6IjRcXHRleHR7IG1tIH0iLCJjb29yZGluYXRlcyI6Wy0wLjE0Njc2MTY2Njc2MzU1NDczLDIuODM4MzA5NTgzMzA5MTExNF0sImFsaWdubWVudCI6ImNlbnRlciIsInR5cGVzZXRBc01hdGgiOnRydWUsInN0eWxlIjp7ImZpbGwtb3BhY2l0eSI6IjAiLCJjb2xvciI6ImJsYWNrIn19LHsiY29udGVudCI6IjVcXHRleHR7IG1tfSIsImNvb3JkaW5hdGVzIjpbMy41LDAuMzMzMzMzMzMzMzMzMzMzMDRdLCJhbGlnbm1lbnQiOiJjZW50ZXIiLCJ0eXBlc2V0QXNNYXRoIjp0cnVlLCJzdHlsZSI6eyJmaWxsLW9wYWNpdHkiOiIwIiwiY29sb3IiOiJibGFjayJ9fSx7ImNvbnRlbnQiOiI3XFx0ZXh0eyBtbX0iLCJjb29yZGluYXRlcyI6WzMuMzY5ODAwMTMwODE2ODE5MywzLjU1NDcwMDE5NjIyNTIyOV0sImFsaWdubWVudCI6ImNlbnRlciIsInR5cGVzZXRBc01hdGgiOnRydWUsInN0eWxlIjp7ImZpbGwtb3BhY2l0eSI6IjAiLCJjb2xvciI6ImJsYWNrIn19LHsiY29udGVudCI6IkEiLCJjb29yZGluYXRlcyI6Wy0wLjQxMDQzNDE0MjYxNTE2ODc1LDUuNTgyNDA4MDYzODE3MTE5XSwiYWxpZ25tZW50IjoiY2VudGVyIiwidHlwZXNldEFzTWF0aCI6dHJ1ZSwic3R5bGUiOnsiZmlsbC1vcGFjaXR5IjoiMCIsImNvbG9yIjoiYmxhY2sifX0seyJjb250ZW50IjoiQiIsImNvb3JkaW5hdGVzIjpbMC41NjE0NDY5MDcxMjkxNzE0LDAuNDM4MzExNTQ1Njk1MzU4OF0sImFsaWdubWVudCI6ImNlbnRlciIsInR5cGVzZXRBc01hdGgiOnRydWUsInN0eWxlIjp7ImZpbGwtb3BhY2l0eSI6IjAiLCJjb2xvciI6ImJsYWNrIn19LHsiY29udGVudCI6IkMiLCJjb29yZGluYXRlcyI6WzYuNjgxOTI0MDgzNDM5ODgzLDAuNzkzNTMwMDAwNzUxNjgzN10sImFsaWdubWVudCI6ImNlbnRlciIsInR5cGVzZXRBc01hdGgiOnRydWUsInN0eWxlIjp7ImZpbGwtb3BhY2l0eSI6IjAiLCJjb2xvciI6ImJsYWNrIn19XX0pOw==', + }, + }, +}; + +// A Perseus fixture for a sandbox item id, or null for a native QTI item. +function perseusFixtureFor(itemId) { + return perseusFixtures[itemId] || null; +} + +// Perseus's markdown image rule strips `data:` URLs (simple-markdown's +// `sanitizeUrl` rejects the `data:` protocol as an XSS defence), so a plain +// `![](data:…)` image would render as `src=null` and never load. A real package +// serves each asset as a `blob:` URL (`kolibri-zip`'s `ExtractedFile.toUrl`), +// which passes the sanitiser — so the mock mirrors that, converting the inlined +// `data:` URL into a `blob:` URL exactly as production would. Cached per data URL +// so repeated `getFile().toUrl()` calls and remounts reuse one object URL instead +// of leaking a fresh one each time. +const objectUrlCache = new Map(); +function assetUrl(content) { + if (!content.startsWith('data:')) { + return content; + } + let url = objectUrlCache.get(content); + if (!url) { + const [meta, base64] = content.split(','); + const mime = meta.slice('data:'.length, meta.indexOf(';')); + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + url = URL.createObjectURL(new Blob([bytes], { type: mime })); + objectUrlCache.set(content, url); + } + return url; +} + +/** + * Build a mock QTI package + resource for a Perseus fixture, matching the + * interface descendant interactions consume: `qtiPackage.getFile(path)` yields + * an ExtractedFile-like object (`toString()` for the item JSON, `toUrl()` for an + * asset — a `blob:` URL, as a real package serves), and `qtiResource.files` lists + * the declared dependencies. Returns a `{ qtiPackage: null, qtiResource: null }` + * pair for a null (native) fixture. + * @param {object|null} fixture - A Perseus fixture, or null for a native item. + * @returns {{ qtiPackage: object|null, qtiResource: object|null }} + */ +export function buildPerseusPackage(fixture) { + if (!fixture) { + return { qtiPackage: null, qtiResource: null }; + } + return { + qtiPackage: { + getFile(path) { + if (!(path in fixture.files)) { + return undefined; + } + const content = fixture.files[path]; + return { toString: () => content, toUrl: () => assetUrl(content) }; + }, + }, + // The declared dependencies are exactly the served zip paths, in + // document order (item JSON first, then each perseus/images/ asset). + qtiResource: { files: Object.keys(fixture.files) }, + }; +} + +/** + * The package files for a sandbox item, in the shape QTIViewer's itemData path + * consumes (zip-path -> file content): the item JSON verbatim, and each asset's + * inlined `data:` URL converted to a `blob:` URL (exactly as a real package's + * `ExtractedFile.toUrl` does — Perseus's markdown sanitiser rejects `data:` + * URLs). Returns `null` for a native QTI item, which carries no Perseus package. + * @param {string} itemId - The selected sandbox item's identifier. + * @returns {{ [zipPath: string]: string }|null} + */ +export function perseusPackageFilesFor(itemId) { + const fixture = perseusFixtureFor(itemId); + if (!fixture) { + return null; + } + return Object.fromEntries( + Object.entries(fixture.files).map(([path, content]) => [path, assetUrl(content)]), + ); +} + +export default perseusFixtures; diff --git a/kolibri/plugins/qti_viewer/frontend/components/__fixtures__/structure.js b/kolibri/plugins/qti_viewer/frontend/components/__fixtures__/structure.js index 5c7806fd006..da6c8ab3c58 100644 --- a/kolibri/plugins/qti_viewer/frontend/components/__fixtures__/structure.js +++ b/kolibri/plugins/qti_viewer/frontend/components/__fixtures__/structure.js @@ -677,6 +677,19 @@ export default [ }, ], }, + { + title: 'Perseus (mixed QTI)', + items: [ + { + identifier: 'perseus-square-shape', + title: 'Nombra figuras — plain image', + }, + { + identifier: 'perseus-classify-triangle', + title: 'Classify triangles — graphie', + }, + ], + }, { title: 'Bootcamp Exercises', items: [ diff --git a/kolibri/plugins/qti_viewer/frontend/components/__tests__/QTIViewer.spec.js b/kolibri/plugins/qti_viewer/frontend/components/__tests__/QTIViewer.spec.js index be6a9c18fdb..7c6ce259815 100644 --- a/kolibri/plugins/qti_viewer/frontend/components/__tests__/QTIViewer.spec.js +++ b/kolibri/plugins/qti_viewer/frontend/components/__tests__/QTIViewer.spec.js @@ -1,8 +1,9 @@ -import { render, screen } from '@testing-library/vue'; +import { render, screen, waitFor } from '@testing-library/vue'; import { nextTick } from 'vue'; // eslint-disable-next-line import-x/named import useContentViewer, { useContentViewerMock } from 'kolibri/composables/useContentViewer'; import items from '../__fixtures__/items'; +import perseusFixtures from '../__fixtures__/perseus'; import QTIViewer from '../QTIViewer.vue'; jest.mock('kolibri/composables/useContentViewer'); @@ -122,3 +123,46 @@ describe('QTIViewer hints', () => { expect(screen.queryByRole('list')).not.toBeInTheDocument(); }); }); + +describe('QTIViewer itemData package', () => { + // Capture the embellished itemData the embedded Perseus interaction receives, + // which proves QTIViewer built a working package from the files on itemData + // (getFile served the item JSON and each asset) without any injected package. + function nestedViewerStub(capture) { + return { + name: 'ContentViewer', + props: ['itemData', 'interactive', 'answerState', 'preset'], + created() { + if (this.preset === 'exercise') { + capture.itemData = this.itemData; + } + }, + template: '
', + }; + } + + it('synthesizes a package from object itemData so an embedded interaction resolves assets', async () => { + const fixture = perseusFixtures['perseus-square-shape']; + const assetPath = Object.keys(fixture.files).find(path => path.startsWith('perseus/images/')); + useContentViewer.mockImplementation(() => ({ + ...useContentViewerMock({ + itemData: { xml: fixture.xml, files: fixture.files }, + interactive: true, + }), + registerAssessmentApi: () => {}, + })); + + const capture = {}; + render(QTIViewer, { + // eslint-disable-next-line kolibri/tests-no-stubs + stubs: { ContentViewer: nestedViewerStub(capture) }, + }); + + await waitFor(() => expect(capture.itemData).toBeTruthy()); + + // The item JSON came through the synthesized package verbatim... + expect(capture.itemData.perseusItemString).toBe(fixture.files[fixture.perseusPath]); + // ...and the declared image dependency was served from it too. + expect(capture.itemData.packageFiles[assetPath]).toBe(fixture.files[assetPath]); + }); +}); diff --git a/kolibri/plugins/qti_viewer/frontend/components/__tests__/perseusInQti.spec.js b/kolibri/plugins/qti_viewer/frontend/components/__tests__/perseusInQti.spec.js new file mode 100644 index 00000000000..d3bd05aa054 --- /dev/null +++ b/kolibri/plugins/qti_viewer/frontend/components/__tests__/perseusInQti.spec.js @@ -0,0 +1,151 @@ +/** + * End-to-end grading for a Perseus-in-QTI wrapper item. The nested ContentViewer + * is stubbed so its `checkAnswer()` stands in for the Perseus renderer; as the + * learner answers (the renderer emits an interaction), the custom interaction + * grades that result into the RESPONSE record, so response processing derives + * SCORE from it at check time. + */ +import { computed, nextTick, ref } from 'vue'; +import { render, waitFor } from '@testing-library/vue'; +import AssessmentItem from '../AssessmentItem.vue'; +import { parseXML } from '../../utils/xml.js'; + +jest.mock('../../utils/xml.js', () => ({ + parseXML: xmlString => { + const xmlDoc = new globalThis.DOMParser().parseFromString(xmlString.trim(), 'text/xml'); + const parserError = xmlDoc.querySelector('parsererror'); + if (parserError) { + throw new Error(`XML parsing error: ${parserError.textContent}`); + } + return xmlDoc; + }, +})); + +jest.mock('kolibri-logging', () => ({ + getLogger: () => ({ + warn: jest.fn(), + error: jest.fn(), + }), +})); + +// Wrapper item per the Studio contract: a record RESPONSE, a float SCORE, a +// single Perseus custom interaction, and inline response processing that reads +// the record's `correct` field via qti-field-value. +const WRAPPER_XML = ` + + + + 0 + + + + + + + + + + + + 1 + + + + + 0 + + + + + `; + +// Stub the nested ContentViewer, exposing a checkAnswer() that returns the +// Perseus renderer's result verbatim, so the test drives grading without +// mounting the real Perseus React renderer. It emits an interaction on mount to +// stand in for the learner answering the question, which the custom interaction +// grades into the RESPONSE record. +function contentViewerStub(checkAnswerResult) { + return { + name: 'ContentViewer', + props: ['itemData', 'interactive', 'answerState', 'preset'], + mounted() { + this.$nextTick(() => this.$emit('interaction')); + }, + methods: { + checkAnswer() { + return checkAnswerResult; + }, + }, + template: '
', + }; +} + +async function renderWrapper(checkAnswerResult) { + const xmlDoc = parseXML(WRAPPER_XML); + let checkAnswerFn = null; + + const qtiPackage = { + getFile: jest.fn(path => { + if (path === 'perseus/q.json') { + return Promise.resolve({ toString: () => '{"question":{"content":""}}' }); + } + return Promise.resolve(undefined); + }), + }; + const qtiResource = { files: ['perseus/q.json'] }; + + const { container } = render(AssessmentItem, { + props: { xmlDoc }, + provide: { + qtiPackage: computed(() => qtiPackage), + qtiResource: computed(() => qtiResource), + handlers: { + interaction: jest.fn(), + registerCheckAnswer: fn => { + checkAnswerFn = fn; + }, + }, + QTI_CONTEXT: computed(() => ({ candidateIdentifier: 'test-user' })), + answerState: ref({}), + interactive: computed(() => true), + }, + // eslint-disable-next-line kolibri/tests-no-stubs + stubs: { + ContentViewer: contentViewerStub(checkAnswerResult), + }, + }); + + // Wait for the interaction's async itemData build to render the nested viewer + // (and capture the viewer ref), then for its mount-time interaction to grade + // the stubbed result into the RESPONSE record. + await waitFor(() => + expect(container.querySelector('[data-testid="content-viewer"]')).toBeTruthy(), + ); + await waitFor(() => expect(checkAnswerFn().answerState.RESPONSE?.correct).not.toBeUndefined()); + await nextTick(); + + return { checkAnswer: () => checkAnswerFn() }; +} + +describe('Perseus-in-QTI grading', () => { + it.each` + correct | simpleAnswer | score + ${true} | ${'42'} | ${1} + ${false} | ${''} | ${0} + `( + 'commits correct=$correct to the RESPONSE record so SCORE=$score', + async ({ correct, simpleAnswer, score }) => { + const { checkAnswer } = await renderWrapper({ + correct, + simpleAnswer, + answerState: { userInput: {} }, + }); + + const result = checkAnswer(); + + expect(result.answerState.RESPONSE.correct).toBe(correct); + expect(result.outcomes.SCORE).toBe(score); + }, + ); +}); diff --git a/kolibri/plugins/qti_viewer/frontend/components/interactions/CustomInteraction.vue b/kolibri/plugins/qti_viewer/frontend/components/interactions/CustomInteraction.vue new file mode 100644 index 00000000000..8ef163beba5 --- /dev/null +++ b/kolibri/plugins/qti_viewer/frontend/components/interactions/CustomInteraction.vue @@ -0,0 +1,52 @@ + diff --git a/kolibri/plugins/qti_viewer/frontend/components/interactions/PerseusCustomInteraction.vue b/kolibri/plugins/qti_viewer/frontend/components/interactions/PerseusCustomInteraction.vue new file mode 100644 index 00000000000..c1dd2cd1436 --- /dev/null +++ b/kolibri/plugins/qti_viewer/frontend/components/interactions/PerseusCustomInteraction.vue @@ -0,0 +1,178 @@ + diff --git a/kolibri/plugins/qti_viewer/frontend/components/interactions/__tests__/CustomInteraction.spec.js b/kolibri/plugins/qti_viewer/frontend/components/interactions/__tests__/CustomInteraction.spec.js new file mode 100644 index 00000000000..216c7ecaf91 --- /dev/null +++ b/kolibri/plugins/qti_viewer/frontend/components/interactions/__tests__/CustomInteraction.spec.js @@ -0,0 +1,62 @@ +import { render } from '@testing-library/vue'; +import CustomInteraction from '../CustomInteraction.vue'; + +// CustomInteraction imports the Perseus component directly and renders it via +// `h()`, so it cannot be swapped through Vue's `stubs`; mock the module instead. +// The mock echoes the props it receives as data attributes so the delegation can +// be asserted without mounting the real Perseus renderer. +jest.mock('../PerseusCustomInteraction.vue', () => ({ + name: 'PerseusCustomInteraction', + props: ['dataPerseusPath', 'responseIdentifier'], + render(h) { + return h('div', { + attrs: { + 'data-testid': 'perseus', + 'data-path': this.dataPerseusPath, + 'data-response': this.responseIdentifier, + }, + }); + }, +})); + +describe('CustomInteraction', () => { + it('delegates a perseus custom interaction to the Perseus component', () => { + const { container } = render(CustomInteraction, { + props: { + dataType: 'perseus', + dataPerseusPath: 'perseus/q.json', + responseIdentifier: 'RESPONSE', + }, + }); + + const perseus = container.querySelector('[data-testid="perseus"]'); + expect(perseus).toBeTruthy(); + // Only the attributes the delivery-engine component reads are forwarded. + expect(perseus).toHaveAttribute('data-path', 'perseus/q.json'); + expect(perseus).toHaveAttribute('data-response', 'RESPONSE'); + }); + + it('raises an error for an unsupported custom-interaction data-type', () => { + // The component throws from setup; capture it through a parent errorCaptured + // boundary (returning false halts propagation) and swallow Vue's own error + // log so jest-fail-on-console does not fail on the expected error. + const captured = []; + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const Harness = { + components: { CustomInteraction }, + errorCaptured(error) { + captured.push(error); + return false; + }, + template: ``, + }; + + render(Harness); + errorSpy.mockRestore(); + + expect(captured).toHaveLength(1); + expect(captured[0].message).toContain( + 'Unsupported qti-custom-interaction data-type: unsupported', + ); + }); +}); diff --git a/kolibri/plugins/qti_viewer/frontend/components/interactions/__tests__/PerseusCustomInteraction.spec.js b/kolibri/plugins/qti_viewer/frontend/components/interactions/__tests__/PerseusCustomInteraction.spec.js new file mode 100644 index 00000000000..bebb36c5946 --- /dev/null +++ b/kolibri/plugins/qti_viewer/frontend/components/interactions/__tests__/PerseusCustomInteraction.spec.js @@ -0,0 +1,237 @@ +import { render, waitFor } from '@testing-library/vue'; +import { computed, nextTick } from 'vue'; +import PerseusCustomInteraction from '../PerseusCustomInteraction.vue'; +import perseusFixtures, { buildPerseusPackage } from '../../__fixtures__/perseus'; + +// jsdom has no URL.createObjectURL; the fixture mock's toUrl() creates blob URLs +// from the inlined asset data (as a real package's ExtractedFile.toUrl does). +let objectUrlCounter = 0; +if (!global.URL.createObjectURL) { + global.URL.createObjectURL = () => `blob:mock/${(objectUrlCounter += 1)}`; +} + +// The custom interaction embeds the Perseus renderer through a nested +// ContentViewer. We stub ContentViewer to capture the embellished `itemData` +// it receives, so we can assert the image-url map and item string the +// interaction builds from the manifest dependencies. +function makeCaptureStub(capture) { + return { + name: 'ContentViewer', + props: ['itemData', 'interactive', 'answerState', 'preset'], + created() { + capture.itemData = this.itemData; + capture.preset = this.preset; + capture.interactive = this.interactive; + }, + template: '
', + }; +} + +function renderInteraction(props, { qtiPackage, qtiResource, responses } = {}) { + const capture = {}; + const result = render(PerseusCustomInteraction, { + props, + provide: { + qtiPackage: computed(() => qtiPackage || null), + qtiResource: computed(() => qtiResource || null), + responses: computed(() => responses || {}), + interactive: computed(() => true), + handlers: { interaction: jest.fn() }, + }, + // eslint-disable-next-line kolibri/tests-no-stubs + stubs: { + ContentViewer: makeCaptureStub(capture), + }, + }); + return { ...result, capture }; +} + +describe('PerseusCustomInteraction', () => { + it('builds embellished itemData from manifest deps without parsing the item JSON', async () => { + const perseusJson = '{"question":{"content":"![](${☣ LOCALPATH}/perseus/images/a.png)"}}'; + const qtiPackage = { + getFile: jest.fn(path => { + if (path === 'perseus/q.json') { + return Promise.resolve({ toString: () => perseusJson }); + } + if (path === 'perseus/images/a.png') { + return Promise.resolve({ toUrl: () => 'blob:a' }); + } + return Promise.resolve(undefined); + }), + }; + const qtiResource = { files: ['perseus/q.json', 'perseus/images/a.png'] }; + + const { capture, container } = renderInteraction( + { dataPerseusPath: 'perseus/q.json', responseIdentifier: 'RESPONSE' }, + { qtiPackage, qtiResource }, + ); + + await waitFor(() => + expect(container.querySelector('[data-testid="content-viewer"]')).toBeTruthy(), + ); + + expect(capture.preset).toBe('exercise'); + expect(capture.itemData.perseusItemString).toBe(perseusJson); + expect(capture.itemData.packageFiles).toEqual({ 'perseus/images/a.png': 'blob:a' }); + expect(typeof capture.itemData.sourceId).toBe('string'); + expect(capture.itemData.sourceId.length).toBeGreaterThan(0); + // The item JSON is fetched once (as the item), and each declared image dep + // is fetched as an asset — the item is never re-fetched as an asset. + expect(qtiPackage.getFile).toHaveBeenCalledWith('perseus/q.json'); + expect(qtiPackage.getFile).toHaveBeenCalledWith('perseus/images/a.png'); + expect( + qtiPackage.getFile.mock.calls.filter(([path]) => path === 'perseus/q.json'), + ).toHaveLength(1); + }); + + it('maps graphie .svg/-data.json deps and keeps web+graphie refs unresolved', async () => { + // Drive the real image-url mapping from a genuine graphie fixture sampled + // from the QA channel, rather than a stub, so the .svg/-data.json handling + // is exercised end to end. + const fixture = perseusFixtures['perseus-classify-triangle']; + const { qtiPackage, qtiResource } = buildPerseusPackage(fixture); + + const { capture, container } = renderInteraction( + { dataPerseusPath: fixture.perseusPath, responseIdentifier: 'RESPONSE' }, + { qtiPackage, qtiResource }, + ); + + await waitFor(() => + expect(container.querySelector('[data-testid="content-viewer"]')).toBeTruthy(), + ); + + // Both halves of each graphie asset (the .svg render and its -data.json label + // data) are mapped from the declared perseus/images/ deps to their URLs. + const packageFiles = capture.itemData.packageFiles; + const svgKeys = Object.keys(packageFiles).filter(key => key.endsWith('.svg')); + const dataKeys = Object.keys(packageFiles).filter(key => key.endsWith('-data.json')); + expect(svgKeys.length).toBeGreaterThan(0); + expect(dataKeys.length).toBe(svgKeys.length); + // Each asset is mapped to a blob: URL (as a real package serves it). + svgKeys.forEach(key => expect(packageFiles[key]).toMatch(/^blob:/)); + dataKeys.forEach(key => expect(packageFiles[key]).toMatch(/^blob:/)); + + // The item JSON is passed verbatim: web+graphie refs are preserved and no + // resolved (blob:) URL is baked into the stored item string — the renderer + // resolves them from the separate map at render time. + expect(capture.itemData.perseusItemString).toContain('web+graphie:${☣ LOCALPATH}/images/'); + expect(capture.itemData.perseusItemString).not.toContain('blob:'); + }); + + it('rebuilds the embellished itemData when the item path changes', async () => { + // The component instance is reused when the surrounding QTI item swaps + // (e.g. sandbox navigation between Perseus items). A path captured once in + // setup would leave the previous item rendered; the build must re-run when + // the reactive dataPerseusPath prop changes. + const qtiPackage = { + getFile: jest.fn(path => { + if (path === 'perseus/one.json') { + return Promise.resolve({ toString: () => '{"question":{"content":"one"}}' }); + } + if (path === 'perseus/two.json') { + return Promise.resolve({ toString: () => '{"question":{"content":"two"}}' }); + } + return Promise.resolve(undefined); + }), + }; + const qtiResource = { files: ['perseus/one.json', 'perseus/two.json'] }; + + const { capture, updateProps } = renderInteraction( + { dataPerseusPath: 'perseus/one.json', responseIdentifier: 'RESPONSE' }, + { qtiPackage, qtiResource }, + ); + + await waitFor(() => expect(capture.itemData?.perseusItemString).toContain('one')); + expect(capture.itemData.sourceId).toContain('perseus/one.json'); + + await updateProps({ + dataPerseusPath: 'perseus/two.json', + responseIdentifier: 'RESPONSE', + }); + + await waitFor(() => expect(capture.itemData?.perseusItemString).toContain('two')); + expect(capture.itemData.sourceId).toContain('perseus/two.json'); + }); + + it('grades on interaction and breaks the check-answer feedback loop', async () => { + // Reproduces the graphie stack overflow: the renderer emits an interaction + // while scoring (as a graphie image re-render does), and the interaction + // grades on every interaction. Without the reentrancy guard this recurses — + // interaction → grade → checkAnswer → interaction → … — until it overflows. + const responses = { RESPONSE: { value: null } }; + let checkAnswerCalls = 0; + const loopingViewer = { + name: 'ContentViewer', + props: ['itemData', 'interactive', 'answerState', 'preset'], + mounted() { + // Simulate the learner answering: emit once the viewer ref is set so the + // interaction can grade through it. + this.$nextTick(() => this.$emit('interaction')); + }, + methods: { + checkAnswer() { + checkAnswerCalls += 1; + // Scoring itself emits an interaction, as a graphie re-render does. + this.$emit('interaction'); + return { correct: true, simpleAnswer: '', answerState: { userInput: {} } }; + }, + }, + template: '
', + }; + const fixture = perseusFixtures['perseus-square-shape']; + const { qtiPackage, qtiResource } = buildPerseusPackage(fixture); + + const { container } = render(PerseusCustomInteraction, { + props: { + dataPerseusPath: fixture.perseusPath, + responseIdentifier: 'RESPONSE', + }, + provide: { + qtiPackage: computed(() => qtiPackage), + qtiResource: computed(() => qtiResource), + responses: computed(() => responses), + interactive: computed(() => true), + handlers: { interaction: jest.fn() }, + }, + // eslint-disable-next-line kolibri/tests-no-stubs + stubs: { ContentViewer: loopingViewer }, + }); + + await waitFor(() => + expect(container.querySelector('[data-testid="content-viewer"]')).toBeTruthy(), + ); + + // Let the mounted-emit and its reentrant scoring settle. + await waitFor(() => expect(responses.RESPONSE.value).not.toBeNull()); + await nextTick(); + + // The guard stops the reentrant re-check, so scoring runs exactly once and + // the record is written from that single result. + expect(checkAnswerCalls).toBe(1); + expect(responses.RESPONSE.value).toEqual({ + correct: true, + simpleAnswer: '', + answerState: { userInput: {} }, + }); + }); + + it('renders an empty container instead of spinning forever when the asset build fails', async () => { + // getFile rejecting (e.g. a missing item) must not leave the loader + // spinning indefinitely — the interaction settles to an empty container. + const qtiPackage = { getFile: jest.fn(() => Promise.reject(new Error('missing'))) }; + const qtiResource = { files: ['perseus/q.json'] }; + + const { container } = renderInteraction( + { dataPerseusPath: 'perseus/q.json', responseIdentifier: 'RESPONSE' }, + { qtiPackage, qtiResource }, + ); + + // The loader shows while the build is in flight... + expect(container.querySelector('[role="progressbar"]')).toBeTruthy(); + // ...and once the build settles to failure it is torn down (not left + // spinning forever), with no ContentViewer rendered either. + await waitFor(() => expect(container.querySelector('[role="progressbar"]')).toBeNull()); + expect(container.querySelector('[data-testid="content-viewer"]')).toBeNull(); + }); +}); diff --git a/kolibri/plugins/qti_viewer/frontend/utils/__tests__/xml.spec.js b/kolibri/plugins/qti_viewer/frontend/utils/__tests__/xml.spec.js index 81966c06238..79b4e601910 100644 --- a/kolibri/plugins/qti_viewer/frontend/utils/__tests__/xml.spec.js +++ b/kolibri/plugins/qti_viewer/frontend/utils/__tests__/xml.spec.js @@ -21,7 +21,7 @@ jest.mock('kolibri-zip', () => { if (content === undefined) { return Promise.reject(new Error(`File not found: ${filename}`)); } - return Promise.resolve({ toString: () => content }); + return Promise.resolve({ toString: () => content, toUrl: () => `blob:${filename}` }); } }, }; @@ -62,6 +62,7 @@ describe('loadQTIPackage', () => { identifier: 'item1', type: 'imsqti_item_xmlv3p0', href: 'item1.xml', + files: [], }); expect(result.qtiPackage).toBeDefined(); expect(typeof result.qtiPackage.getResponseProcessingNode).toBe('function'); @@ -100,4 +101,27 @@ describe('loadQTIPackage', () => { // Should return null for unknown URIs expect(await result.qtiPackage.getResponseProcessingNode('nonexistent.xml')).toBeNull(); }); + + it('collects dependency hrefs into resource.files and exposes getFile', async () => { + mockZipFiles = { + 'imsmanifest.xml': manifest(` + + + + + `), + 'perseus/images/a.png': 'PNGDATA', + }; + + const { resourcesMap, qtiPackage } = await loadQTIPackage('fake://url'); + + // The children are collected, in document order, into `files`. + expect(resourcesMap.q1.files).toEqual(['perseus/q1.json', 'perseus/images/a.png']); + + // getFile delegates to the underlying zip and returns the extracted file, + // exposing its `.toUrl()` (blob URL) for asset resolution. + const file = await qtiPackage.getFile('perseus/images/a.png'); + expect(file).toBeDefined(); + expect(file.toUrl()).toBe('blob:perseus/images/a.png'); + }); }); diff --git a/kolibri/plugins/qti_viewer/frontend/utils/xml.js b/kolibri/plugins/qti_viewer/frontend/utils/xml.js index aa7917d7c72..8283f9581eb 100644 --- a/kolibri/plugins/qti_viewer/frontend/utils/xml.js +++ b/kolibri/plugins/qti_viewer/frontend/utils/xml.js @@ -12,6 +12,10 @@ const domParser = new DOMParser(); * zip by its manifest `uri`. Returns the cloned root element on hit, `null` * on miss (missing file, parse error). Results are cached per URI after * the first successful fetch. + * @property {(path: string) => Promise} getFile + * Read an arbitrary file from the package zip by its path, returning the + * extracted file (with `.toString()` / `.toUrl()`), or `undefined` if the + * file is not present in the zip. */ /** @@ -19,6 +23,8 @@ const domParser = new DOMParser(); * @property {string} identifier - The resource's manifest identifier * @property {string} type - MIME-style resource type from the IMS manifest * @property {string} href - Path to the resource inside the package zip + * @property {string[]} files - Hrefs of the resource's declared `` + * dependencies, in document order. */ /** @@ -64,6 +70,9 @@ export async function loadQTIPackage(file) { identifier, type: resource.getAttribute('type'), href: resource.getAttribute('href'), + files: Array.from(resource.querySelectorAll(':scope > file'), fileNode => + fileNode.getAttribute('href'), + ), }; } } @@ -90,6 +99,13 @@ export async function loadQTIPackage(file) { return null; } }, + async getFile(path) { + try { + return await qtiZip.file(path); + } catch { + return undefined; + } + }, }; return { resourcesMap, qtiPackage };