A quiz in Schulcloud is an H5P element, and a board hands over nothing but a
contentId — so a teacher's exercise was until now a line saying one exists.
The player shows a single question at a time, which makes it look like
something to step through or scrape. It is not:
`GET /api/v3/h5p-editor/params/{contentId}` returns the JSON the player is fed,
so one request holds every question, every option and which of them are
correct. (`play/{id}` is the same content plus the player's script lists: 74 kB
against 51 kB for the live quiz. Neither docs-json describes the service.)
get_h5p prints the exercise, and solutions=false keeps the options while
dropping the answers, so it can be used to ask the questions instead of
answering them. get_board names the exercise — title, question count, kinds —
rather than printing a bare id, and the crawl indexes its text, so a phrase
that exists only inside a quiz is now findable. That is the treatment pads
already get, for the same reason: it is course material and nothing else
surfaces it.
What varies is the shape inside `params`, which belongs to whichever H5P
library the teacher used. Modelled: MultiChoice, whose `behaviour.singleAnswer`
is the only honest source for "tick exactly one"; TrueFalse, whose `correct` is
the string "true"; the cloze libraries, which mark solutions inline as
`*answer:tip*`; SingleChoiceSet and Summary, which put the correct option first
and let the player shuffle; and Column. Anything else has its text harvested
and labelled unmodelled — an exercise reported as "0 questions" would be worse
than a clumsy rendering of one. The harvest skips the UI and l10n subtrees, or
a quiz reads as "Überprüfen, Wiederholen, Absenden".
Verified against this account's quiz, an H5P.QuestionSet of 20 MultiChoice
questions on a room's board: 239 tests, smoke 91/91 live-only and 93/93 with
the index.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
218 lines
7.7 KiB
TypeScript
218 lines
7.7 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { describe, it } from 'node:test';
|
|
import { h5pSearchText, parseCloze, parseH5pParams } from '../src/core/h5p.ts';
|
|
import { formatH5p } from '../src/mcp/tools/h5p.ts';
|
|
|
|
/** The shape the live instance returns: metadata, then params.params. */
|
|
function payload(params: unknown, mainLibrary = 'H5P.QuestionSet', title = 'Quiz zur DIN 5008') {
|
|
return { h5p: { title, mainLibrary }, library: `${mainLibrary} 1.20`, params: { metadata: { title }, params } };
|
|
}
|
|
|
|
const MULTI_CHOICE = {
|
|
library: 'H5P.MultiChoice 1.16',
|
|
params: {
|
|
question: '<p>Wie richtet man Zahlen in Tabellen aus?</p>',
|
|
answers: [
|
|
{ text: '<div>Linksbündig</div>', correct: false, tipsAndFeedback: { tip: '' } },
|
|
{ text: 'Rechtsbündig', correct: true, tipsAndFeedback: { tip: 'Denk an die Nachkommastellen' } },
|
|
],
|
|
behaviour: { singleAnswer: true },
|
|
UI: { checkAnswerButton: 'Überprüfen', tryAgainButton: 'Wiederholen' },
|
|
},
|
|
};
|
|
|
|
describe('parseH5pParams: question sets', () => {
|
|
it('reads a question set as the player is fed it', () => {
|
|
const content = parseH5pParams('c1', payload({ passPercentage: 50, questions: [MULTI_CHOICE, MULTI_CHOICE] }));
|
|
assert.equal(content.title, 'Quiz zur DIN 5008');
|
|
assert.equal(content.library, 'H5P.QuestionSet');
|
|
assert.equal(content.passPercentage, 50);
|
|
assert.equal(content.questions.length, 2);
|
|
assert.equal(content.contentId, 'c1');
|
|
});
|
|
|
|
it('strips the HTML a teacher types and keeps which option is correct', () => {
|
|
const [question] = parseH5pParams('c1', payload({ questions: [MULTI_CHOICE] })).questions;
|
|
assert.equal(question?.text, 'Wie richtet man Zahlen in Tabellen aus?');
|
|
assert.equal(question?.kind, 'multiple choice');
|
|
assert.deepEqual(
|
|
question?.answers.map((answer) => [answer.text, answer.correct]),
|
|
[
|
|
['Linksbündig', false],
|
|
['Rechtsbündig', true],
|
|
],
|
|
);
|
|
assert.equal(question?.answers[1]?.tip, 'Denk an die Nachkommastellen');
|
|
// singleAnswer is the player's radio-button flag: tick exactly one.
|
|
assert.equal(question?.multiple, false);
|
|
});
|
|
|
|
it('marks several-correct questions as such', () => {
|
|
const answers = [
|
|
{ text: 'A', correct: true },
|
|
{ text: 'B', correct: true },
|
|
{ text: 'C', correct: false },
|
|
];
|
|
const [question] = parseH5pParams(
|
|
'c1',
|
|
payload({ questions: [{ library: 'H5P.MultiChoice 1.16', params: { question: 'Was gilt?', answers } }] }),
|
|
).questions;
|
|
assert.equal(question?.multiple, true);
|
|
});
|
|
|
|
it('reads a single-question content, where the params are the question', () => {
|
|
const content = parseH5pParams('c2', payload(MULTI_CHOICE.params, 'H5P.MultiChoice', 'Einzelfrage'));
|
|
assert.equal(content.questions.length, 1);
|
|
assert.equal(content.questions[0]?.text, 'Wie richtet man Zahlen in Tabellen aus?');
|
|
});
|
|
|
|
it('throws on a payload that is not H5P, rather than reporting an empty quiz', () => {
|
|
assert.throws(() => parseH5pParams('c3', { nope: true }), /payload shape has changed/);
|
|
assert.throws(() => parseH5pParams('c3', 'not json'), /payload shape has changed/);
|
|
});
|
|
});
|
|
|
|
describe('parseH5pParams: other question types', () => {
|
|
it('reads true/false, whose answer is the string "true"', () => {
|
|
const content = parseH5pParams(
|
|
'c4',
|
|
payload({
|
|
questions: [
|
|
{
|
|
library: 'H5P.TrueFalse 1.8',
|
|
params: { question: 'Seitenzahlen gehören aufs Deckblatt.', correct: 'false', l10n: { trueText: 'Wahr', falseText: 'Falsch' } },
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
assert.equal(content.questions[0]?.kind, 'true/false');
|
|
assert.deepEqual(
|
|
content.questions[0]?.answers.map((answer) => [answer.text, answer.correct]),
|
|
[
|
|
['Wahr', false],
|
|
['Falsch', true],
|
|
],
|
|
);
|
|
});
|
|
|
|
it('reads a cloze, whose solutions are marked inline', () => {
|
|
const content = parseH5pParams(
|
|
'c5',
|
|
payload({
|
|
questions: [
|
|
{
|
|
library: 'H5P.Blanks 1.14',
|
|
params: { text: '<p>Ergänze:</p>', questions: ['<p>Die Norm heißt DIN *5008:fünf-null-null-acht*.</p>'] },
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
const [question] = content.questions;
|
|
assert.equal(question?.kind, 'fill in the blanks');
|
|
assert.equal(question?.text, 'Die Norm heißt DIN ____ (1).');
|
|
assert.deepEqual(question?.answers, [{ text: '5008', correct: true, tip: 'fünf-null-null-acht' }]);
|
|
assert.equal(question?.body, 'Ergänze:');
|
|
});
|
|
|
|
it('reads single choice and summary, where the first option is the correct one', () => {
|
|
const single = parseH5pParams(
|
|
'c6',
|
|
payload({ choices: [{ question: '<p>Welche Schriftgröße?</p>', answers: ['11 pt', '9 pt'] }] }, 'H5P.SingleChoiceSet'),
|
|
);
|
|
assert.deepEqual(
|
|
single.questions[0]?.answers.map((answer) => [answer.text, answer.correct]),
|
|
[
|
|
['11 pt', true],
|
|
['9 pt', false],
|
|
],
|
|
);
|
|
|
|
const summary = parseH5pParams(
|
|
'c7',
|
|
payload({ intro: 'Wähle die richtige Aussage', summaries: [{ summary: ['Stimmt', 'Stimmt nicht'] }] }, 'H5P.Summary'),
|
|
);
|
|
assert.equal(summary.questions[0]?.text, 'Wähle die richtige Aussage');
|
|
assert.equal(summary.questions[0]?.answers[0]?.correct, true);
|
|
});
|
|
|
|
it('walks into a column of sub-contents', () => {
|
|
const content = parseH5pParams(
|
|
'c8',
|
|
payload({ content: [{ content: MULTI_CHOICE }, { content: MULTI_CHOICE }] }, 'H5P.Column'),
|
|
);
|
|
assert.equal(content.questions.length, 2);
|
|
});
|
|
|
|
it('reports the text of a type it cannot model instead of dropping it', () => {
|
|
const content = parseH5pParams(
|
|
'c9',
|
|
payload({
|
|
questions: [
|
|
{
|
|
library: 'H5P.InteractiveVideo 1.27',
|
|
params: {
|
|
interactiveVideo: { summary: { task: { params: { intro: 'Sieh dir das Video zur DIN 5008 an' } } } },
|
|
// Button labels and colours must not drown the content.
|
|
UI: { play: 'Abspielen' },
|
|
l10n: { close: 'Schließen' },
|
|
behaviour: { autoplay: false },
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
assert.equal(content.questions.length, 0);
|
|
assert.equal(content.unmodelled.length, 1);
|
|
assert.match(content.unmodelled[0]!, /^H5P\.InteractiveVideo: /);
|
|
assert.match(content.unmodelled[0]!, /Sieh dir das Video zur DIN 5008 an/);
|
|
assert.doesNotMatch(content.unmodelled[0]!, /Abspielen|Schließen/);
|
|
});
|
|
});
|
|
|
|
describe('parseCloze', () => {
|
|
it('numbers the blanks and keeps alternatives', () => {
|
|
const { text, answers } = parseCloze('Setze *ein/hinein* und *zwei*.');
|
|
assert.equal(text, 'Setze ____ (1) und ____ (2).');
|
|
assert.deepEqual(
|
|
answers.map((answer) => answer.text),
|
|
['ein / hinein', 'zwei'],
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('h5pSearchText', () => {
|
|
it('carries the questions and the options, so search can find them', () => {
|
|
const content = parseH5pParams('c1', payload({ questions: [MULTI_CHOICE] }));
|
|
const text = h5pSearchText(content);
|
|
assert.match(text, /Wie richtet man Zahlen in Tabellen aus\?/);
|
|
assert.match(text, /Rechtsbündig/);
|
|
assert.match(text, /Quiz zur DIN 5008/);
|
|
});
|
|
});
|
|
|
|
describe('formatH5p', () => {
|
|
const content = parseH5pParams('c1', payload({ passPercentage: 50, questions: [MULTI_CHOICE] }));
|
|
|
|
it('marks the solution and names the exercise', () => {
|
|
const out = formatH5p(content, true);
|
|
assert.match(out, /## Quiz zur DIN 5008/);
|
|
assert.match(out, /- Pass mark: 50%/);
|
|
assert.match(out, /\*\*Rechtsbündig\*\* ✔/);
|
|
assert.match(out, /Hinweis: Denk an die Nachkommastellen/);
|
|
});
|
|
|
|
it('withholds the solutions when asked, and says that it did', () => {
|
|
const out = formatH5p(content, false);
|
|
assert.doesNotMatch(out, /✔/);
|
|
assert.doesNotMatch(out, /\*\*Rechtsbündig\*\*/);
|
|
assert.match(out, /Solutions withheld/);
|
|
// The options still have to be there, or there is nothing to ask.
|
|
assert.match(out, /- Rechtsbündig/);
|
|
});
|
|
|
|
it('says so when there is nothing it can read', () => {
|
|
const empty = parseH5pParams('c10', payload({ questions: [] }));
|
|
assert.match(formatH5p(empty, true), /no questions this server can read/);
|
|
});
|
|
});
|