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
@@ -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: `<PerseusRendererIndex ref="renderer" />`,
};
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');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -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;
Expand Down Expand Up @@ -340,7 +372,7 @@
this.loadItemData();
},
itemData(newItemData) {
this.setItemData(newItemData);
this.resolveItemData(newItemData);
},
answerState(newState) {
this.resetState(newState);
Expand Down Expand Up @@ -409,7 +441,7 @@
if (this.defaultFile) {
this.loadItemData();
} else if (this.itemData) {
this.setItemData(this.itemData);
this.resolveItemData(this.itemData);
}
this.$emit('startTracking');
});
Expand Down Expand Up @@ -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)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -46,6 +47,7 @@
[OrderInteraction.tag]: OrderInteraction,
[InlineChoiceInteraction.tag]: InlineChoiceInteraction,
[InlineChoice.tag]: InlineChoice,
[CustomInteraction.tag]: CustomInteraction,
});

/** @typedef {import('../utils/qti/values.js').QTIValue} QTIValue */
Expand Down Expand Up @@ -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 = {};
Expand Down
Loading
Loading