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: '

Wie richtet man Zahlen in Tabellen aus?

', answers: [ { text: '
Linksbündig
', 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: '

Ergänze:

', questions: ['

Die Norm heißt DIN *5008:fünf-null-null-acht*.

'] }, }, ], }), ); 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: '

Welche Schriftgröße?

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