Files
Schulcloud-MCP/test/h5p.test.ts
MechaCat02 a0cef532c6 Read the quizzes behind H5P elements
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>
2026-09-17 21:39:19 +02:00

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/);
});
});