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>
This commit is contained in:
@@ -292,6 +292,9 @@ console.log('\n== file manager (Dateien) ==');
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log('\n== rooms ==');
|
console.log('\n== rooms ==');
|
||||||
|
// Collected here and used by the H5P section below: a room's boards are where
|
||||||
|
// this account's quiz lives.
|
||||||
|
const roomBoardIds = [];
|
||||||
// Rooms ("Räume") are a separate space from courses. An account in none is
|
// Rooms ("Räume") are a separate space from courses. An account in none is
|
||||||
// normal — and is exactly the state that hid this whole feature — so the check
|
// normal — and is exactly the state that hid this whole feature — so the check
|
||||||
// is that the tools answer sensibly either way, not that rooms exist.
|
// is that the tools answer sensibly either way, not that rooms exist.
|
||||||
@@ -302,6 +305,7 @@ console.log('\n== rooms ==');
|
|||||||
if (roomId) {
|
if (roomId) {
|
||||||
const room = await call('get_room', { roomId });
|
const room = await call('get_room', { roomId });
|
||||||
check('get_room opens a room', !room.isError && /Room id:/.test(room.text), roomId);
|
check('get_room opens a room', !room.isError && /Room id:/.test(room.text), roomId);
|
||||||
|
roomBoardIds.push(...[...room.text.matchAll(/\(`([0-9a-f]{24})`\)/g)].map((m) => m[1]).slice(0, 8));
|
||||||
const roomBoardId = room.text.match(/### Boards[\s\S]*?\(`([0-9a-f]{24})`\)/)?.[1];
|
const roomBoardId = room.text.match(/### Boards[\s\S]*?\(`([0-9a-f]{24})`\)/)?.[1];
|
||||||
if (roomBoardId) {
|
if (roomBoardId) {
|
||||||
const board = await call('get_board', { boardId: roomBoardId });
|
const board = await call('get_board', { boardId: roomBoardId });
|
||||||
@@ -314,6 +318,47 @@ console.log('\n== rooms ==');
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('\n== H5P exercises ==');
|
||||||
|
// A quiz is an H5P element on a board, and Schulcloud has nothing else like it.
|
||||||
|
// Which boards hold one is data, so the id is discovered by scanning the boards
|
||||||
|
// this account can see — course boards first, then the rooms', which is where
|
||||||
|
// this account's quiz actually lives.
|
||||||
|
{
|
||||||
|
let contentId;
|
||||||
|
let foundOn;
|
||||||
|
const boardsToScan = [...boardIds, ...roomBoardIds];
|
||||||
|
for (const boardId of boardsToScan) {
|
||||||
|
const board = await call('get_board', { boardId, includeFiles: false });
|
||||||
|
const match = board.text.match(/content `([0-9a-f]{24})` — all of it with get_h5p/);
|
||||||
|
if (match) {
|
||||||
|
contentId = match[1];
|
||||||
|
foundOn = boardId;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (contentId) {
|
||||||
|
check('get_board names an H5P exercise and its question count', true, `board ${foundOn}, content ${contentId}`);
|
||||||
|
|
||||||
|
const full = await call('get_h5p', { contentId });
|
||||||
|
check(
|
||||||
|
'get_h5p returns every question at once, with the solutions marked',
|
||||||
|
!full.isError && /^### 1\. /m.test(full.text) && /✔/.test(full.text),
|
||||||
|
full.text.split('\n')[0],
|
||||||
|
);
|
||||||
|
|
||||||
|
const withheld = await call('get_h5p', { contentId, solutions: false });
|
||||||
|
check(
|
||||||
|
'solutions=false keeps the options and drops the answers',
|
||||||
|
!withheld.isError && /^### 1\. /m.test(withheld.text) && !/✔/.test(withheld.text) && /Solutions withheld/.test(withheld.text),
|
||||||
|
withheld.text.split('\n')[0],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
check('get_board names an H5P exercise and its question count', true, 'no board here holds one');
|
||||||
|
}
|
||||||
|
const missing = await call('get_h5p', { contentId: '000000000000000000000000' });
|
||||||
|
check('an unknown content id is a tool error naming the id', missing.isError && /404/.test(missing.text), missing.text.split('\n')[0]);
|
||||||
|
}
|
||||||
|
|
||||||
console.log('\n== resources and prompts ==');
|
console.log('\n== resources and prompts ==');
|
||||||
// Courses and rooms are resources a person attaches; the prompts are German
|
// Courses and rooms are resources a person attaches; the prompts are German
|
||||||
// requests picked from a menu. Both reuse the tools' reads, so what is checked
|
// requests picked from a menu. Both reuse the tools' reads, so what is checked
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Config } from '../config.ts';
|
import type { Config } from '../config.ts';
|
||||||
import type { SchulcloudClient } from './client.ts';
|
import type { SchulcloudClient } from './client.ts';
|
||||||
import { fetchPadText } from './etherpad.ts';
|
import { fetchPadText } from './etherpad.ts';
|
||||||
|
import { readH5pContent, type H5pContent } from './h5p.ts';
|
||||||
import { SchulcloudApiError } from './client.ts';
|
import { SchulcloudApiError } from './client.ts';
|
||||||
import type { BoardSkeleton, CardResponse, ContentElement, FileRecord } from './types.ts';
|
import type { BoardSkeleton, CardResponse, ContentElement, FileRecord } from './types.ts';
|
||||||
|
|
||||||
@@ -32,6 +33,8 @@ export interface AssembledElement {
|
|||||||
alternativeText?: string;
|
alternativeText?: string;
|
||||||
/** H5P content id: the handle onto interactive content (quizzes and the like). */
|
/** H5P content id: the handle onto interactive content (quizzes and the like). */
|
||||||
h5pContentId?: string;
|
h5pContentId?: string;
|
||||||
|
/** The exercise behind that id, when it was resolved: every question at once. */
|
||||||
|
h5p?: H5pContent;
|
||||||
/** Which configured tool an externalTool element launches. */
|
/** Which configured tool an externalTool element launches. */
|
||||||
contextExternalToolId?: string;
|
contextExternalToolId?: string;
|
||||||
/** What a deleted element used to be. */
|
/** What a deleted element used to be. */
|
||||||
@@ -66,7 +69,7 @@ export async function assembleBoard(
|
|||||||
client: SchulcloudClient,
|
client: SchulcloudClient,
|
||||||
boardId: string,
|
boardId: string,
|
||||||
schoolId: string,
|
schoolId: string,
|
||||||
options: { resolveFiles?: boolean; resolvePads?: Config } = {},
|
options: { resolveFiles?: boolean; resolvePads?: Config; resolveH5p?: boolean } = {},
|
||||||
): Promise<AssembledBoard> {
|
): Promise<AssembledBoard> {
|
||||||
const resolveFiles = options.resolveFiles ?? true;
|
const resolveFiles = options.resolveFiles ?? true;
|
||||||
|
|
||||||
@@ -92,6 +95,12 @@ export async function assembleBoard(
|
|||||||
await attachPadText(options.resolvePads, assembled);
|
await attachPadText(options.resolvePads, assembled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A quiz is course material too, and the board hands over only a contentId.
|
||||||
|
// One request per H5P element, and only when a board has one.
|
||||||
|
if (options.resolveH5p) {
|
||||||
|
await attachH5pContent(client, assembled);
|
||||||
|
}
|
||||||
|
|
||||||
const fileCount = assembled
|
const fileCount = assembled
|
||||||
.flatMap((column) => column.cards)
|
.flatMap((column) => column.cards)
|
||||||
.flatMap((card) => card.elements)
|
.flatMap((card) => card.elements)
|
||||||
@@ -197,6 +206,29 @@ async function attachPadText(config: Config, columns: AssembledColumn[]): Promis
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fills in the exercise behind each H5P element.
|
||||||
|
*
|
||||||
|
* Silent on failure, like a pad: the element and its content id are still
|
||||||
|
* reported, and get_h5p then says why it could not be read.
|
||||||
|
*/
|
||||||
|
async function attachH5pContent(client: SchulcloudClient, columns: AssembledColumn[]): Promise<void> {
|
||||||
|
const elements = columns
|
||||||
|
.flatMap((column) => column.cards)
|
||||||
|
.flatMap((card) => card.elements)
|
||||||
|
.filter((element) => element.type === 'h5p' && element.h5pContentId);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
elements.map(async (element) => {
|
||||||
|
try {
|
||||||
|
element.h5p = await readH5pContent(client, element.h5pContentId!);
|
||||||
|
} catch {
|
||||||
|
// Left unset: the formatter falls back to naming the content id.
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves file-bearing elements to file records.
|
* Resolves file-bearing elements to file records.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -450,6 +450,21 @@ export class SchulcloudClient {
|
|||||||
return this.getJson<BoardContext>(`/api/v3/boards/${encodeURIComponent(boardId)}/context`);
|
return this.getJson<BoardContext>(`/api/v3/boards/${encodeURIComponent(boardId)}/context`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The content behind an H5P element: a quiz with all of its questions.
|
||||||
|
*
|
||||||
|
* `params` is what the player loads before it renders anything, so one GET
|
||||||
|
* returns the whole exercise — every question, every answer option and which
|
||||||
|
* of them is correct — even though the player then shows one question at a
|
||||||
|
* time. There is nothing to step through and no page to scrape.
|
||||||
|
*
|
||||||
|
* The shape belongs to whichever H5P library the content uses, so it stays
|
||||||
|
* `unknown` here and `core/h5p.ts` interprets it.
|
||||||
|
*/
|
||||||
|
getH5pParams(contentId: string): Promise<unknown> {
|
||||||
|
return this.getJson<unknown>(`/api/v3/h5p-editor/params/${encodeURIComponent(contentId)}`);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Card bodies for the given ids.
|
* Card bodies for the given ids.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Config } from '../config.ts';
|
import type { Config } from '../config.ts';
|
||||||
|
import { h5pSearchText } from './h5p.ts';
|
||||||
import { assembleBoard, type AssembledBoard } from './board.ts';
|
import { assembleBoard, type AssembledBoard } from './board.ts';
|
||||||
import type { SchulcloudClient } from './client.ts';
|
import type { SchulcloudClient } from './client.ts';
|
||||||
import { fetchHomeworkPage } from './homework-page.ts';
|
import { fetchHomeworkPage } from './homework-page.ts';
|
||||||
@@ -320,6 +321,7 @@ async function crawlBoards(
|
|||||||
assembled = await assembleBoard(client, boardId, options.schoolId, {
|
assembled = await assembleBoard(client, boardId, options.schoolId, {
|
||||||
resolveFiles: includeFiles,
|
resolveFiles: includeFiles,
|
||||||
resolvePads: options.config,
|
resolvePads: options.config,
|
||||||
|
resolveH5p: true,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Record rather than swallow: a dropped board used to disappear from the
|
// Record rather than swallow: a dropped board used to disappear from the
|
||||||
@@ -342,6 +344,9 @@ async function crawlBoards(
|
|||||||
// Pad contents are course material like any other; without this they
|
// Pad contents are course material like any other; without this they
|
||||||
// are unsearchable, and a pad is often where the actual group work is.
|
// are unsearchable, and a pad is often where the actual group work is.
|
||||||
if (element.padText) parts.push(element.padText);
|
if (element.padText) parts.push(element.padText);
|
||||||
|
// A quiz's questions are material too: without this, searching for
|
||||||
|
// something a teacher only asked in an H5P exercise finds nothing.
|
||||||
|
if (element.h5p) parts.push(h5pSearchText(element.h5p));
|
||||||
if (element.url) parts.push(element.url);
|
if (element.url) parts.push(element.url);
|
||||||
for (const record of element.files) {
|
for (const record of element.files) {
|
||||||
files.push({
|
files.push({
|
||||||
|
|||||||
305
src/core/h5p.ts
Normal file
305
src/core/h5p.ts
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
/**
|
||||||
|
* Reads H5P content: the quizzes and interactive exercises on a board.
|
||||||
|
*
|
||||||
|
* Schulcloud has no quiz of its own — an exercise is an H5P element, and the
|
||||||
|
* board API hands over nothing but a `contentId`. The player then shows one
|
||||||
|
* question at a time, which makes a quiz look like something that has to be
|
||||||
|
* stepped through or scraped. It is not: `GET /api/v3/h5p-editor/params/{id}`
|
||||||
|
* returns the entire exercise as the JSON the player is fed, so one request
|
||||||
|
* holds every question, every option and which of them is correct.
|
||||||
|
*
|
||||||
|
* What varies is the *shape* of that JSON, because it belongs to whichever H5P
|
||||||
|
* library the teacher used. The common question types are modelled below; for
|
||||||
|
* anything else the text is harvested generically and labelled as such, so an
|
||||||
|
* exercise this parser does not know still arrives readable instead of empty.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { SchulcloudClient } from './client.ts';
|
||||||
|
import { htmlToText } from './text.ts';
|
||||||
|
|
||||||
|
export interface H5pAnswer {
|
||||||
|
text: string;
|
||||||
|
/** Undefined when the library does not say — never guess a solution. */
|
||||||
|
correct?: boolean;
|
||||||
|
tip?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface H5pQuestion {
|
||||||
|
/** The H5P library, e.g. `H5P.MultiChoice 1.16`. */
|
||||||
|
library: string;
|
||||||
|
/** A short human name for the kind of task, for the reader. */
|
||||||
|
kind: string;
|
||||||
|
text: string;
|
||||||
|
answers: H5pAnswer[];
|
||||||
|
/** True when more than one option is meant to be ticked. */
|
||||||
|
multiple?: boolean;
|
||||||
|
/** The task's own text where it is not the question: a cloze, a description. */
|
||||||
|
body?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface H5pContent {
|
||||||
|
contentId: string;
|
||||||
|
title: string;
|
||||||
|
/** The main library of the content itself, e.g. `H5P.QuestionSet`. */
|
||||||
|
library: string;
|
||||||
|
intro?: string;
|
||||||
|
/** Percentage needed to pass, when the content sets one. */
|
||||||
|
passPercentage?: number;
|
||||||
|
questions: H5pQuestion[];
|
||||||
|
/**
|
||||||
|
* Text from parts this parser does not model. Never silently dropped: a
|
||||||
|
* teacher's exercise turning up as "0 questions" is the bug this avoids.
|
||||||
|
*/
|
||||||
|
unmodelled: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetches and parses one H5P content. Throws if it cannot be read or parsed. */
|
||||||
|
export async function readH5pContent(client: SchulcloudClient, contentId: string): Promise<H5pContent> {
|
||||||
|
return parseH5pParams(contentId, await client.getH5pParams(contentId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything in the content as one string, for the search index. */
|
||||||
|
export function h5pSearchText(content: H5pContent): string {
|
||||||
|
const parts = [content.title, content.intro];
|
||||||
|
for (const question of content.questions) {
|
||||||
|
parts.push(question.text, question.body, ...question.answers.map((answer) => answer.text));
|
||||||
|
}
|
||||||
|
parts.push(...content.unmodelled);
|
||||||
|
return parts.filter((part): part is string => Boolean(part?.trim())).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interprets a `params` payload.
|
||||||
|
*
|
||||||
|
* Throws on a payload that is not H5P at all rather than returning an empty
|
||||||
|
* exercise — the difference between "this quiz has no questions" and "the
|
||||||
|
* format changed" matters, and only one of them is worth reporting as content.
|
||||||
|
*/
|
||||||
|
export function parseH5pParams(contentId: string, payload: unknown): H5pContent {
|
||||||
|
const root = asRecord(payload);
|
||||||
|
const metadata = asRecord(root?.h5p);
|
||||||
|
// `params.params` is the content itself; `params.metadata` beside it repeats
|
||||||
|
// the H5P metadata. A single-question content has the same shape.
|
||||||
|
const outer = asRecord(root?.params);
|
||||||
|
const params = asRecord(outer?.params) ?? outer;
|
||||||
|
const library = string(metadata?.mainLibrary) ?? string(root?.library) ?? 'unknown';
|
||||||
|
if (!params) {
|
||||||
|
throw new Error(`H5P content ${contentId} carried no params — the payload shape has changed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = string(metadata?.title)?.trim() || 'H5P content';
|
||||||
|
const content: H5pContent = { contentId, title, library, questions: [], unmodelled: [] };
|
||||||
|
|
||||||
|
const introPage = asRecord(params.introPage);
|
||||||
|
const intro = htmlToText(string(introPage?.introduction) ?? string(params.intro) ?? '').trim();
|
||||||
|
if (intro) content.intro = intro;
|
||||||
|
if (typeof params.passPercentage === 'number') content.passPercentage = params.passPercentage;
|
||||||
|
|
||||||
|
// A QuestionSet holds a list of sub-contents, each with its own library; any
|
||||||
|
// other library *is* the single question.
|
||||||
|
const questions = Array.isArray(params.questions) && params.questions.some((entry) => asRecord(entry)?.library)
|
||||||
|
? params.questions
|
||||||
|
: undefined;
|
||||||
|
if (questions) {
|
||||||
|
for (const entry of questions) {
|
||||||
|
const record = asRecord(entry);
|
||||||
|
const sub = asRecord(record?.params);
|
||||||
|
const subLibrary = string(record?.library) ?? 'unknown';
|
||||||
|
if (sub) content.questions.push(...parseQuestion(subLibrary, sub, content.unmodelled));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
content.questions.push(...parseQuestion(library, params, content.unmodelled));
|
||||||
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One sub-content as questions.
|
||||||
|
*
|
||||||
|
* Returns several for the libraries that bundle them (SingleChoiceSet,
|
||||||
|
* Summary, Blanks) and none for one it cannot read, in which case its text goes
|
||||||
|
* to `unmodelled`.
|
||||||
|
*/
|
||||||
|
function parseQuestion(library: string, params: Record<string, unknown>, unmodelled: string[]): H5pQuestion[] {
|
||||||
|
const name = library.split(/\s+/)[0] ?? library;
|
||||||
|
const question = (text: string, answers: H5pAnswer[], extra: Partial<H5pQuestion> = {}): H5pQuestion => ({
|
||||||
|
library,
|
||||||
|
kind: KIND[name] ?? name.replace(/^H5P\./, ''),
|
||||||
|
text,
|
||||||
|
answers,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
|
||||||
|
switch (name) {
|
||||||
|
case 'H5P.MultiChoice': {
|
||||||
|
const answers = (asArray(params.answers) ?? []).map((entry): H5pAnswer => {
|
||||||
|
const answer = asRecord(entry);
|
||||||
|
const tip = htmlToText(string(asRecord(answer?.tipsAndFeedback)?.tip) ?? '').trim();
|
||||||
|
return {
|
||||||
|
text: htmlToText(string(answer?.text) ?? '').trim(),
|
||||||
|
correct: answer?.correct === true,
|
||||||
|
...(tip ? { tip } : {}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
// `singleAnswer` is what the player uses to choose radio buttons over
|
||||||
|
// checkboxes, and it is the only honest way to say "tick one".
|
||||||
|
const single = asRecord(params.behaviour)?.singleAnswer === true;
|
||||||
|
return [
|
||||||
|
question(htmlToText(string(params.question) ?? '').trim(), answers, {
|
||||||
|
multiple: !single && answers.filter((answer) => answer.correct).length !== 1,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
case 'H5P.TrueFalse': {
|
||||||
|
const l10n = asRecord(params.l10n);
|
||||||
|
const yes = string(l10n?.trueText) ?? 'Wahr';
|
||||||
|
const no = string(l10n?.falseText) ?? 'Falsch';
|
||||||
|
// `correct` is the string "true" or "false", not a boolean.
|
||||||
|
const correct = string(params.correct);
|
||||||
|
return [
|
||||||
|
question(htmlToText(string(params.question) ?? '').trim(), [
|
||||||
|
{ text: yes, correct: correct === 'true' },
|
||||||
|
{ text: no, correct: correct === 'false' },
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
case 'H5P.Blanks': {
|
||||||
|
const description = htmlToText(string(params.text) ?? '').trim();
|
||||||
|
return (asArray(params.questions) ?? []).flatMap((entry) => {
|
||||||
|
const raw = string(entry);
|
||||||
|
if (!raw) return [];
|
||||||
|
const { text, answers } = parseCloze(raw);
|
||||||
|
return [question(text, answers, { body: description || undefined })];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
case 'H5P.DragText':
|
||||||
|
case 'H5P.MarkTheWords': {
|
||||||
|
const raw = string(params.textField) ?? '';
|
||||||
|
const { text, answers } = parseCloze(raw);
|
||||||
|
const description = htmlToText(string(params.taskDescription) ?? '').trim();
|
||||||
|
return [question(description || 'Aufgabe', answers, { body: text })];
|
||||||
|
}
|
||||||
|
case 'H5P.SingleChoiceSet': {
|
||||||
|
return (asArray(params.choices) ?? []).flatMap((entry) => {
|
||||||
|
const choice = asRecord(entry);
|
||||||
|
const texts = (asArray(choice?.answers) ?? []).map((answer) => htmlToText(string(answer) ?? '').trim());
|
||||||
|
// The first option is the correct one; the player shuffles them.
|
||||||
|
const answers = texts.map((text, index): H5pAnswer => ({ text, correct: index === 0 }));
|
||||||
|
return [question(htmlToText(string(choice?.question) ?? '').trim(), answers)];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
case 'H5P.Summary': {
|
||||||
|
return (asArray(params.summaries) ?? []).flatMap((entry) => {
|
||||||
|
const group = asRecord(entry);
|
||||||
|
const texts = (asArray(group?.summary) ?? []).map((item) => htmlToText(string(item) ?? '').trim());
|
||||||
|
const answers = texts.map((text, index): H5pAnswer => ({ text, correct: index === 0 }));
|
||||||
|
return [question(htmlToText(string(params.intro) ?? '').trim() || 'Welche Aussage stimmt?', answers)];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
case 'H5P.Column': {
|
||||||
|
// A column stacks sub-contents; each carries its own library.
|
||||||
|
return (asArray(params.content) ?? []).flatMap((entry) => {
|
||||||
|
const inner = asRecord(asRecord(entry)?.content);
|
||||||
|
const innerParams = asRecord(inner?.params);
|
||||||
|
const innerLibrary = string(inner?.library) ?? 'unknown';
|
||||||
|
return innerParams ? parseQuestion(innerLibrary, innerParams, unmodelled) : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
const harvested = harvest(params);
|
||||||
|
if (harvested.length > 0) unmodelled.push(`${name}: ${harvested.join(' | ')}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Short names for the libraries worth naming; anything else keeps its own. */
|
||||||
|
const KIND: Record<string, string> = {
|
||||||
|
'H5P.MultiChoice': 'multiple choice',
|
||||||
|
'H5P.TrueFalse': 'true/false',
|
||||||
|
'H5P.Blanks': 'fill in the blanks',
|
||||||
|
'H5P.DragText': 'drag the words',
|
||||||
|
'H5P.MarkTheWords': 'mark the words',
|
||||||
|
'H5P.SingleChoiceSet': 'single choice',
|
||||||
|
'H5P.Summary': 'pick the correct statement',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A cloze text: H5P marks the solutions inline as `*answer:tip*`, with
|
||||||
|
* alternatives separated by slashes.
|
||||||
|
*/
|
||||||
|
export function parseCloze(raw: string): { text: string; answers: H5pAnswer[] } {
|
||||||
|
const answers: H5pAnswer[] = [];
|
||||||
|
const text = htmlToText(
|
||||||
|
raw.replace(/\*([^*]+)\*/g, (_, body: string) => {
|
||||||
|
const [solutions, tip] = body.split(':');
|
||||||
|
const alternatives = (solutions ?? '').split('/').map((part) => part.trim()).filter(Boolean);
|
||||||
|
answers.push({
|
||||||
|
text: alternatives.join(' / '),
|
||||||
|
correct: true,
|
||||||
|
...(tip?.trim() ? { tip: tip.trim() } : {}),
|
||||||
|
});
|
||||||
|
return `____ (${answers.length})`;
|
||||||
|
}),
|
||||||
|
).trim();
|
||||||
|
return { text, answers };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every bit of task text in an unmodelled library.
|
||||||
|
*
|
||||||
|
* Deliberately blunt — it cannot know which field is the question — but it
|
||||||
|
* skips the subtrees that hold button labels and display settings, which
|
||||||
|
* otherwise drown the content in "Überprüfen" and "Wiederholen".
|
||||||
|
*/
|
||||||
|
const SKIP_KEYS = new Set([
|
||||||
|
'UI',
|
||||||
|
'l10n',
|
||||||
|
'behaviour',
|
||||||
|
'overallFeedback',
|
||||||
|
'confirmCheck',
|
||||||
|
'confirmRetry',
|
||||||
|
'media',
|
||||||
|
'localization',
|
||||||
|
'a11y',
|
||||||
|
'accessibility',
|
||||||
|
'scoreBarLabel',
|
||||||
|
'texts',
|
||||||
|
'endGame',
|
||||||
|
'override',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function harvest(value: unknown, depth = 0, out: string[] = []): string[] {
|
||||||
|
if (depth > 6 || out.length > 40) return out;
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const text = htmlToText(value).trim();
|
||||||
|
// Two characters of prose, not a colour code or a library version.
|
||||||
|
if (text.length > 2 && /\p{L}{2}/u.test(text) && !out.includes(text)) out.push(text);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const item of value) harvest(item, depth + 1, out);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
const record = asRecord(value);
|
||||||
|
if (record) {
|
||||||
|
for (const [key, item] of Object.entries(record)) {
|
||||||
|
if (SKIP_KEYS.has(key)) continue;
|
||||||
|
harvest(item, depth + 1, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asArray(value: unknown): unknown[] | undefined {
|
||||||
|
return Array.isArray(value) ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function string(value: unknown): string | undefined {
|
||||||
|
return typeof value === 'string' ? value : undefined;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { CrawledBoard, Snapshot } from './crawl.ts';
|
import type { CrawledBoard, Snapshot } from './crawl.ts';
|
||||||
|
import { h5pSearchText } from './h5p.ts';
|
||||||
import { matchesAll, snippet, tokenize } from './text.ts';
|
import { matchesAll, snippet, tokenize } from './text.ts';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,6 +45,8 @@ function matchBoards(
|
|||||||
// Pad contents are searched by the index; without this the live
|
// Pad contents are searched by the index; without this the live
|
||||||
// crawl path would quietly disagree with it.
|
// crawl path would quietly disagree with it.
|
||||||
if (element.padText) parts.push(element.padText);
|
if (element.padText) parts.push(element.padText);
|
||||||
|
// Same corpus as the index, or a live search would disagree with it.
|
||||||
|
if (element.h5p) parts.push(h5pSearchText(element.h5p));
|
||||||
if (element.url) parts.push(element.url);
|
if (element.url) parts.push(element.url);
|
||||||
for (const file of element.files) parts.push(file.name);
|
for (const file of element.files) parts.push(file.name);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { registerResources } from './resources.ts';
|
|||||||
import { registerContentTools } from './tools/content.ts';
|
import { registerContentTools } from './tools/content.ts';
|
||||||
import { registerFileTools } from './tools/files.ts';
|
import { registerFileTools } from './tools/files.ts';
|
||||||
import { registerFilesystemTools } from './tools/filesystem.ts';
|
import { registerFilesystemTools } from './tools/filesystem.ts';
|
||||||
|
import { registerH5pTools } from './tools/h5p.ts';
|
||||||
import { registerOverviewTools } from './tools/overview.ts';
|
import { registerOverviewTools } from './tools/overview.ts';
|
||||||
import { registerRawTool } from './tools/raw.ts';
|
import { registerRawTool } from './tools/raw.ts';
|
||||||
import { registerIndexTools } from './tools/index-tools.ts';
|
import { registerIndexTools } from './tools/index-tools.ts';
|
||||||
@@ -28,6 +29,9 @@ How the content is organised, and the usual path through it:
|
|||||||
text block, link and attached file in one call.
|
text block, link and attached file in one call.
|
||||||
- **Topics / lessons** ("Themen") — the older format. get_lesson.
|
- **Topics / lessons** ("Themen") — the older format. get_lesson.
|
||||||
- **Tasks** ("Aufgaben") — homework. list_tasks across all courses, get_task for one.
|
- **Tasks** ("Aufgaben") — homework. list_tasks across all courses, get_task for one.
|
||||||
|
- **Quizzes and interactive exercises** are H5P elements on a board ("Quiz", "Test", "Übung"). get_board names
|
||||||
|
one and says how many questions it holds; get_h5p returns all of them with the correct options marked — the
|
||||||
|
player steps through one at a time, this does not. search finds their question text too.
|
||||||
- **Files** hang off boards, lessons and tasks. Every listing shows file ids; download_file fetches one and
|
- **Files** hang off boards, lessons and tasks. Every listing shows file ids; download_file fetches one and
|
||||||
extracts its text (PDF, Word, Excel, PowerPoint, OpenDocument) or returns an image inline.
|
extracts its text (PDF, Word, Excel, PowerPoint, OpenDocument) or returns an image inline.
|
||||||
- **The file manager ("Dateien")** is a separate store with a real folder tree, browsed with the fs_* tools:
|
- **The file manager ("Dateien")** is a separate store with a real folder tree, browsed with the fs_* tools:
|
||||||
@@ -72,6 +76,7 @@ export function createServer(config: Config, services?: Services): { server: Mcp
|
|||||||
registerRoomTools(server, context);
|
registerRoomTools(server, context);
|
||||||
registerFileTools(server, context);
|
registerFileTools(server, context);
|
||||||
registerFilesystemTools(server, context);
|
registerFilesystemTools(server, context);
|
||||||
|
registerH5pTools(server, context);
|
||||||
registerSearchTool(server, context);
|
registerSearchTool(server, context);
|
||||||
registerSubmissionTools(server, context);
|
registerSubmissionTools(server, context);
|
||||||
registerIndexTools(server, context);
|
registerIndexTools(server, context);
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export function registerContentTools(server: McpServer, context: ServerContext):
|
|||||||
const board = await assembleBoard(context.client, boardId, schoolId, {
|
const board = await assembleBoard(context.client, boardId, schoolId, {
|
||||||
resolveFiles: includeFiles,
|
resolveFiles: includeFiles,
|
||||||
resolvePads: context.config,
|
resolvePads: context.config,
|
||||||
|
resolveH5p: true,
|
||||||
});
|
});
|
||||||
return text(formatBoard(board, includeFiles));
|
return text(formatBoard(board, includeFiles));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -478,8 +479,21 @@ function formatElement(element: AssembledElement, includeFiles: boolean): string
|
|||||||
case 'h5p': {
|
case 'h5p': {
|
||||||
// Schulcloud has no quiz of its own: interactive exercises are H5P, and
|
// Schulcloud has no quiz of its own: interactive exercises are H5P, and
|
||||||
// this id is the only way to reach the content behind one.
|
// this id is the only way to reach the content behind one.
|
||||||
const content = element.h5pContentId ? ` — H5P content \`${element.h5pContentId}\`` : '';
|
const content = element.h5pContentId ? ` \`${element.h5pContentId}\`` : '';
|
||||||
return `- H5P interactive content \`${element.id}\`${content}`;
|
if (!element.h5p) {
|
||||||
|
return `- H5P interactive content \`${element.id}\`${content ? ` — H5P content${content}` : ''}`;
|
||||||
|
}
|
||||||
|
// Summarised rather than inlined: a question set runs to twenty
|
||||||
|
// questions with their options, which would bury the rest of the board.
|
||||||
|
// get_h5p prints them, and search reaches their text either way.
|
||||||
|
const quiz = element.h5p;
|
||||||
|
const kinds = [...new Set(quiz.questions.map((question) => question.kind))].join(', ');
|
||||||
|
const count = quiz.questions.length;
|
||||||
|
const unread = quiz.unmodelled.length > 0 ? `, ${quiz.unmodelled.length} part(s) this server cannot model` : '';
|
||||||
|
return (
|
||||||
|
`- **H5P exercise: ${quiz.title}** — ${count} question(s)${kinds ? ` (${kinds})` : ''}${unread}, ` +
|
||||||
|
`content${content} — all of it with get_h5p`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
case 'deleted': {
|
case 'deleted': {
|
||||||
// Saying what it was beats "(deleted element)": the title often names
|
// Saying what it was beats "(deleted element)": the title often names
|
||||||
|
|||||||
93
src/mcp/tools/h5p.ts
Normal file
93
src/mcp/tools/h5p.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* The H5P tool: a quiz in full, rather than one question at a time.
|
||||||
|
*
|
||||||
|
* The board tells you an exercise exists and how many questions it holds; this
|
||||||
|
* prints them. Kept out of the board on purpose — twenty questions with their
|
||||||
|
* options would bury everything else on it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { ServerContext } from '../../context.ts';
|
||||||
|
import { heading, joinSections } from '../../core/text.ts';
|
||||||
|
import { readH5pContent, type H5pContent, type H5pQuestion } from '../../core/h5p.ts';
|
||||||
|
import { text, toToolError } from './result.ts';
|
||||||
|
|
||||||
|
const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
|
||||||
|
|
||||||
|
export function registerH5pTools(server: McpServer, context: ServerContext): void {
|
||||||
|
server.registerTool(
|
||||||
|
'get_h5p',
|
||||||
|
{
|
||||||
|
title: 'Get H5P exercise',
|
||||||
|
description:
|
||||||
|
'An interactive exercise in full: every question, every option, and which of them is correct. This ' +
|
||||||
|
'is how to read a quiz ("Quiz", "Test", "Übung") that a teacher built into a board — Schulcloud has ' +
|
||||||
|
'no quiz of its own, so these are H5P elements, and get_board lists them with the content id this ' +
|
||||||
|
'takes. The player shows one question at a time; this returns all of them at once, so there is ' +
|
||||||
|
'nothing to step through. Pass solutions=false to get the questions without the answers, for asking ' +
|
||||||
|
'the user them one by one.',
|
||||||
|
inputSchema: {
|
||||||
|
contentId: z
|
||||||
|
.string()
|
||||||
|
.describe('H5P content id, as get_board prints it for an H5P element (not the element id).'),
|
||||||
|
solutions: z
|
||||||
|
.boolean()
|
||||||
|
.default(true)
|
||||||
|
.describe('Mark the correct options. Turn off to quiz the user without giving the answers away.'),
|
||||||
|
},
|
||||||
|
annotations: READ_ONLY,
|
||||||
|
},
|
||||||
|
async ({ contentId, solutions }) => {
|
||||||
|
try {
|
||||||
|
return text(formatH5p(await readH5pContent(context.client, contentId), solutions));
|
||||||
|
} catch (error) {
|
||||||
|
return toToolError(error, `read H5P content ${contentId}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatH5p(content: H5pContent, solutions: boolean): string {
|
||||||
|
const facts = [
|
||||||
|
`- Content id: \`${content.contentId}\``,
|
||||||
|
`- Type: ${content.library}`,
|
||||||
|
`- Questions: ${content.questions.length}`,
|
||||||
|
content.passPercentage !== undefined ? `- Pass mark: ${content.passPercentage}%` : undefined,
|
||||||
|
solutions ? undefined : '- _Solutions withheld: call again with solutions=true to see them._',
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
return joinSections([
|
||||||
|
heading(2, content.title),
|
||||||
|
facts.join('\n'),
|
||||||
|
content.intro,
|
||||||
|
...content.questions.map((question, index) => formatQuestion(question, index + 1, solutions)),
|
||||||
|
content.questions.length === 0 && content.unmodelled.length === 0
|
||||||
|
? '_This exercise holds no questions this server can read._'
|
||||||
|
: undefined,
|
||||||
|
// Never silently dropped: an exercise type this server does not model
|
||||||
|
// still has its text reported, labelled for what it is.
|
||||||
|
content.unmodelled.length > 0
|
||||||
|
? joinSections([
|
||||||
|
heading(3, 'Parts this server cannot model'),
|
||||||
|
'_Read as plain text, so the structure of these is lost:_',
|
||||||
|
content.unmodelled.map((part) => `- ${part}`).join('\n'),
|
||||||
|
])
|
||||||
|
: undefined,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatQuestion(question: H5pQuestion, number: number, solutions: boolean): string {
|
||||||
|
const hint = question.multiple ? ', several correct' : '';
|
||||||
|
const label = `${number}. ${question.text || '(no question text)'}`;
|
||||||
|
const options = question.answers.map((answer) => {
|
||||||
|
const tip = answer.tip ? ` _(Hinweis: ${answer.tip})_` : '';
|
||||||
|
if (!solutions || answer.correct === undefined) return `- ${answer.text}${tip}`;
|
||||||
|
return answer.correct ? `- **${answer.text}** ✔${tip}` : `- ${answer.text}${tip}`;
|
||||||
|
});
|
||||||
|
return joinSections([
|
||||||
|
heading(3, `${label} _(${question.kind}${hint})_`),
|
||||||
|
question.body,
|
||||||
|
options.length > 0 ? options.join('\n') : '_(no options)_',
|
||||||
|
]);
|
||||||
|
}
|
||||||
217
test/h5p.test.ts
Normal file
217
test/h5p.test.ts
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user