Skip to content
Merged
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
34 changes: 22 additions & 12 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,8 @@ packages/runtime/ @missing-elements/h5p-runtime — the H5P core runtime
port of h5p-php-library's H5PContentValidator; bundled into frame-boot.js,
unit-tested in tests/
src/finished.ts which statement is the content's result, the `finished` event: H5P's own
rule; bundled into frame-boot.js, unit-tested in tests/
rule, and the core's mark on sub-content; bundled into frame-boot.js,
unit-tested in tests/
scripts/build.mjs vendors h5p-standalone into dist/ (headers, the rebuilt stylesheet, the fonts,
LICENSE.txt, NOTICE.txt, MANIFEST.json), builds the boot script, writes
dist/l10n.js from l10n/, writes index.js
Expand Down Expand Up @@ -819,7 +820,7 @@ element, and the element acts. That relay is why `frame-document.ts` has a `mess
with registrations tied to one, and 21 CFR Part 11 LMSs keep version history as a requirement. So
that record is the organisation's — LRS, LMS or document control — and a major change served at a
new URL gets a new Activity id under H5P's own scheme, whose id is the content URL.
- **`finished` is the content's result, by H5P's own rule.** The core's `xAPICompletedListener`,
- **`finished` is the content's result: H5P's own rule, and the core's mark on sub-content.** The core's `xAPICompletedListener`,
which the CMS plugins record a result through (`setFinished`), takes a `completed` or `answered`
statement with no `context.contextActivities.parent`: from the content itself, not from a part
inside it. Up to runtime 0.2.1 the boot script posted `finished` on any `completed`, parent or
Expand All @@ -832,21 +833,30 @@ element, and the element acts. That relay is why `frame-document.ts` has a `mess
relays, so an older player gets it with the runtime. A question checked again says `answered`
again, so `finished` can come more than once in a load; the last is the result, as with
`setFinished`. `finished.test.ts` plays `finished.h5p` — the fixture content type with an Answer
button, and a child of itself as a part — and fails on the old rule for the part and for the
answer.
button, a child of itself as a part, and another child run without a parent — and fails on the
first rule for the part and for the answer, and on H5P's rule for the part without a parent.
Measured across the pack on 2026-10-14 with the sweep's first cut, which worked each type
through by hand-written steps (*The content-type sweep* says why those went): 32 of the 55
content types end with a result of their own — `answered` from 19 question types,
`completed` from 13 sets, containers and games, Image Pair and Questionnaire among them — 19 send
none, and 4 cannot be worked through headless. Two create their parts without a parent, so a
part's result arrives as the content's, under H5P's rule and so in the core's `setFinished` as
well: Interactive Book 1.15 creates each chapter with `parent: self`, which in that scope is the
window, so a chapter's `completed` is parentless (twice, for a chapter holding one question); and
Game Map 1.5's exercise in a stage sends its `answered` with no parent. The content's own
`completed` comes after, on finishing — Interactive Book's only with `reporting`, which gives its
summary page the submit button; without it a book's last `finished` is its last chapter's. A host
that takes the last `finished` gets the content's result in both, with `reporting` for a book;
cmi5, which records the first, completes such a unit on its first part (`docs/cmi5.md` says so).
part's result passes H5P's rule, and the core's `setFinished` records it as the content's:
Interactive Book 1.15 creates each chapter with `parent: self`, which in that scope is the window,
so a chapter's `completed` is parentless (twice, for a chapter holding one question); and Game
Map 1.5's exercise in a stage sends its `answered` with no parent. Virtual Tour 0.5 runs its
interactions the same way, read in its code, since the sweep cannot open a scene. Up to runtime
0.2.2 the element did as the core does, and cmi5, which records the first `finished`, completed a
unit holding a book or a map on its first part. So `isResult` also refuses a statement whose
object carries `http://h5p.org/x-api/h5p-subContentId`, which the core's `setObject` writes for
every instance with a `subContentId` and never for the content's own, parent or not; measured the
same day, every chapter and exercise statement carries it, and so does every statement with a
parent that the sweep reached. A part saved without a `subContentId` — content from before H5P's
editor wrote one, or written by hand or by a tool that writes none — carries no mark and still
counts as the content, as under H5P's rule. The departure from
`setFinished` is deliberate: the parent stands in for "from the content itself", and the mark
says it. The content's own `completed` comes after, on finishing — Interactive Book's only with
`reporting`, which gives its summary page the submit button. A content type that reports nothing
of its own never finishes: a book without `reporting`, a Virtual Tour.
- **The saved state never leaves the page, and never meets the Service Worker.** `resume` is
H5P's own save-and-resume — core asks each instance for `getCurrentState()` every `saveFreq`
seconds, three seconds after a `completed` or `progressed` statement and as the document goes
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ Events are `CustomEvent`s:
|---|---|---|
| `ready` | `{ pkgId, source, metadata, revision, libraryBundle }` | The content is visible |
| `xapi` | `{ statement, verb }` | Any xAPI statement; nothing is stored |
| `finished` | `{ statement }` | The content reported its result: a `completed` or `answered` statement from the content itself, not from a part inside it, as H5P's own `setFinished` counts it. It can come more than once, for a question checked again; the last is the result. Interactive Book and Game Map send a part's result this way as well, as on any H5P site; the content's own comes last, for a book only with `reporting` |
| `finished` | `{ statement }` | The content reported its result: a `completed` or `answered` statement from the content itself, not from a part inside it. That is H5P's own `setFinished` rule, except that a part created without a parent counts as a part too, which H5P's rule misses for Interactive Book's chapters and Game Map's exercises. It can come more than once, for a question checked again; the last is the result. Content that reports no result of its own never raises it, and Interactive Book reports one only with `reporting` |
| `userdata` | `{ pkgId, dataType, subContentId, data, revision }` | With `resume`: the content saved its state; `data: null` means delete it |
| `progress` | `{ phase, fraction, loaded, total, entry? }` | `download`, `libraries`, `warm` or `extract`; `fraction` is 0–1, or `null` while the total is unknown |
| `resize` | `{ height }` | The content's height changed |
Expand Down
5 changes: 3 additions & 2 deletions apps/demo/scripts/sweep-content-types.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,7 @@ function pageHtml() {
const describe = (statement) => ({
verb: (statement?.verb?.id ?? '').split('/').pop(),
parent: Boolean(statement?.context?.contextActivities?.parent),
sub: Boolean(statement?.object?.definition?.extensions?.['http://h5p.org/x-api/h5p-subContentId']),
score: statement?.result?.score ? [statement.result.score.raw, statement.result.score.max] : null,
object: statement?.object?.definition?.name?.['en-US'] ?? null,
at: Math.round(performance.now() - record.started)
Expand Down Expand Up @@ -484,7 +485,7 @@ function markdownReport(results) {
}
const rows = [...byType].map(([machineName, plays]) => {
const played = plays.find((play) => play.state === 'ready') ?? plays[0]
const verbs = [...new Set(played.statements.map(({ verb, parent }) => (parent ? `${verb}*` : verb)))].join(', ') || '—'
const verbs = [...new Set(played.statements.map(({ verb, parent, sub }) => (parent || sub ? `${verb}*` : verb)))].join(', ') || '—'
const finished = played.finished.map(describeFinished).join(', ') || '—'
const byMode = (mode) => cell(plays.find((play) => play.mode === mode))
return `| ${machineName} ${played.version} | ${byMode('untrusted')} | ${byMode('trusted')} | ${verbs} | ${finished} |`
Expand All @@ -495,7 +496,7 @@ function markdownReport(results) {
return [
'# Content type sweep',
'',
`${results.filter((result) => result.problems.length === 0).length} of ${results.length} plays passed. Each content type's package is generated from its semantics and \`apps/demo/sweep/\`, played as a content-only export against the pack, and worked through by the generic walk. The statements are what it reached, not a judgement; a verb with \`*\` came from a part of the content (a statement with a parent).`,
`${results.filter((result) => result.problems.length === 0).length} of ${results.length} plays passed. Each content type's package is generated from its semantics and \`apps/demo/sweep/\`, played as a content-only export against the pack, and worked through by the generic walk. The statements are what it reached, not a judgement; a verb with \`*\` came from a part of the content (a statement with a parent, or one H5P marks as sub-content's).`,
'',
'| Content type | Untrusted | Trusted | Statements | `finished` |',
'|---|---|---|---|---|',
Expand Down
2 changes: 1 addition & 1 deletion apps/demo/sweep/H5P.GameMap.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"note": "One stage holding a question, the start stage, on the generated background image. Generation leaves the stage's neighbours unset, which the content type maps over at once, and makes the special stage type a finish stage. The question inside a stage is created without a parent, so its answered counts as the map's result, in H5P's core too; the map's own completed comes on finishing it.",
"note": "One stage holding a question, the start stage, on the generated background image. Generation leaves the stage's neighbours unset, which the content type maps over at once, and makes the special stage type a finish stage. The question inside a stage is created without a parent, so H5P's core takes its answered for the map's result; the element does not, by the sub-content mark on it. The map's own completed comes on finishing it.",
"params": {
"gamemapSteps": {
"backgroundImageSettings": {
Expand Down
2 changes: 1 addition & 1 deletion apps/demo/sweep/H5P.InteractiveBook.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"note": "Two chapters with a question each, and reporting on, which gives the summary page its submit button. Interactive Book 1.15 creates chapters with parent: self, the window, so a chapter's completed has no parent and counts as the book's result, in H5P's core too; the book's own completed, on submitting, comes last.",
"note": "Two chapters with a question each, and reporting on, which gives the summary page its submit button. Interactive Book 1.15 creates chapters with parent: self, the window, so a chapter's completed has no parent, and H5P's core takes it for the book's result; the element does not, by the sub-content mark on it. The book's own completed comes on submitting.",
"attributes": {
"reporting": ""
},
Expand Down
7 changes: 4 additions & 3 deletions docs/cmi5.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,10 @@ address. With none, a top-level window closes and a framed page stays where it i
`max` and `raw`, rides on `passed` or `failed` only. Each goes out once per registration: an
earlier session's `completed` or `passed` is not sent again, nor `failed` after `passed`. If
the LRS cannot be asked, the session counts for itself and reports `registration-unread`.
Nothing is sent in `Browse` or `Review` mode. Interactive Book and Game Map report a part's
result with no parent, as on any H5P site, so a unit holding one completes on the first part
finished — a book's first chapter, a map's first exercise — with that part's score.
Nothing is sent in `Browse` or `Review` mode. A unit holding Interactive Book completes only
when the book's report is submitted, which needs `reporting` on the element; without it the
book reports no result of its own and the unit never completes. Nor does a unit holding a
Virtual Tour, which reports none.
4. **`terminated`** from Exit. A closed tab sends nothing; the LMS records the session as
`abandoned`.

Expand Down
10 changes: 6 additions & 4 deletions packages/player/scripts/build-fixtures.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,10 @@ built.push([
})
])

// 1b. A result and a part: the content type with its Answer button, which reports `answered` as
// a single question does, and a child of itself run as sub-content, whose Complete reports
// `completed` with the parent named. Only the content's own statements are its result.
// 1b. A result and its parts: the content type with its Answer button, which reports `answered`
// as a single question does, a child of itself run as sub-content, whose Complete reports
// `completed` with the parent named, and another run without a parent, as Interactive Book
// runs its chapters. Only the content's own statements are its result.
built.push([
'finished.h5p',
await writeArchive('finished.h5p', async (writer) => {
Expand All @@ -97,7 +98,8 @@ built.push([
JSON.stringify({
message: 'A result, and a part inside it.',
answer: true,
child: { library: 'H5P.OfflineTest 1.0', subContentId: '9d3c1f0a-2b4e-4c6d-8e1f-3a5b7c9d1e2f', params: { message: 'A part.' } }
child: { library: 'H5P.OfflineTest 1.0', subContentId: '9d3c1f0a-2b4e-4c6d-8e1f-3a5b7c9d1e2f', params: { message: 'A part.' } },
looseChild: { library: 'H5P.OfflineTest 1.0', subContentId: '4b8e2d6f-1a3c-4e5f-9b7d-2c4e6a8f0b1d', params: { message: 'A part without a parent.' } }
})
)
)
Expand Down
29 changes: 24 additions & 5 deletions packages/player/tests/browser/finished.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,18 @@ import type { H5PPlayerElement } from '../../src/h5p-offline-player'
import { FIXTURES, frameDocument, play, waitForEvent } from './utils'

/**
* `finished` is the content's result by H5P's own rule, the one the CMS plugins record through
* `setFinished`: a `completed` or `answered` statement from the content itself, never one from a
* part inside it. `finished.h5p` is the fixture content type with an Answer button, which reports
* `answered` the way a single question does, and a child of itself run as sub-content, whose
* Complete reports `completed` with the parent named.
* `finished` is the content's result: a `completed` or `answered` statement from the content
* itself, never one from a part inside it — H5P's own rule, the one the CMS plugins record through
* `setFinished`, and a part the core marks as sub-content without naming a parent besides.
* `finished.h5p` is the fixture content type with an Answer button, which reports `answered` the
* way a single question does, a child of itself run as sub-content, whose Complete reports
* `completed` with the parent named, and another child run without a parent.
*/

type Detail = {
statement: {
verb: { id: string }
object?: { definition?: { extensions?: Record<string, unknown> } }
context?: { contextActivities?: { parent?: unknown } }
result?: { score?: { raw: number; max: number } }
}
Expand Down Expand Up @@ -45,6 +47,23 @@ describe('the finished event', () => {
expect(finished).toEqual([])
})

it("is not raised for a part's completion when the part names no parent", async () => {
// Interactive Book's chapters and Game Map's exercises are run this way; H5P's own rule, the
// parent alone, took their results for the content's.
const player = await play(FIXTURES.finished)
const finished = recorded(player)

const xapi = waitForEvent<Detail>(player, 'xapi')
button(player, '.h5p-offline-test-loose .h5p-offline-test-complete').click()
const part = (await xapi).detail.statement
expect(part.verb.id).toBe(verb('completed'))
expect(part.context?.contextActivities?.parent).toBeUndefined()
expect(part.object?.definition?.extensions?.['http://h5p.org/x-api/h5p-subContentId']).toBe('4b8e2d6f-1a3c-4e5f-9b7d-2c4e6a8f0b1d')

await new Promise((resolve) => setTimeout(resolve, 300))
expect(finished).toEqual([])
})

it("is raised for a single question's answer, each time it is given", async () => {
const player = await play(FIXTURES.finished)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,14 @@ H5P.OfflineTest = (function ($) {
self.child = H5P.newRunnable(self.params.child, self.contentId, $child, true, { parent: self });
}

// Sub-content run without a parent, as Interactive Book 1.15 runs its chapters and Game Map 1.5
// its exercises: its statements name none, and only the core's mark on them says it is a part.
if (self.params.looseChild) {
var $loose = $('<div class="h5p-offline-test-loose"></div>');
$container.append($loose);
self.looseChild = H5P.newRunnable(self.params.looseChild, self.contentId, $loose, true);
}

if (self.params.media && self.params.media.path) {
var source = H5P.getPath(self.params.media.path, self.contentId);
$container.append(
Expand Down
25 changes: 19 additions & 6 deletions packages/runtime/src/finished.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,39 @@
/**
* Whether an xAPI statement is the content's result, which the element raises as `finished`.
* H5P's own rule, `H5P.xAPICompletedListener` in the core, which is what the CMS plugins record
* through `setFinished`: a `completed` or an `answered` statement with no
* `context.contextActivities.parent` — from the content itself, not from a part inside it. A
* Whether an xAPI statement is the content's result, which the element raises as `finished`: a
* `completed` or an `answered` statement from the content itself, not from a part inside it. A
* single question only ever says `answered`; a question set says `answered` for each question,
* each with the set as its parent, and `completed` for itself. Up to runtime 0.2.1 the boot script
* took any `completed`, parent or not, and never `answered`, so a single question never finished
* and a quiz inside a Column finished before the Column did. A question checked again says
* `answered` again, so this can hold for several statements of one load; the last is the result.
*
* H5P's own rule, `H5P.xAPICompletedListener` in the core, which the CMS plugins record through
* `setFinished`, tells a part by `context.contextActivities.parent` alone. Some content types
* create their parts without one: Interactive Book 1.15 its chapters (`{ parent: self }`, the
* window in that scope), Game Map 1.5 its exercises, Virtual Tour 0.5 its interactions. So a part's
* statement is also told by what the core writes on every statement from sub-content and never on
* the content's own: the `h5p-subContentId` extension on its object. Measured on 2026-10-14,
* every statement with a parent across the library pack carries it as well. Up to runtime 0.2.2
* the parent alone decided, and a book's chapter or a map's exercise finished the content.
*/

const RESULT_VERBS = new Set(['http://adlnet.gov/expapi/verbs/completed', 'http://adlnet.gov/expapi/verbs/answered'])

/** The core's mark on a statement from sub-content, written by `XAPIEvent.setObject`. */
export const SUB_CONTENT_EXTENSION = 'http://h5p.org/x-api/h5p-subContentId'

interface StatementLike {
verb?: { id?: unknown }
object?: { definition?: { extensions?: Record<string, unknown> } }
context?: { contextActivities?: { parent?: unknown } }
}

export function isResult(statement: unknown): boolean {
if (!statement || typeof statement !== 'object') return false
const { verb, context } = statement as StatementLike
const { verb, object, context } = statement as StatementLike
// Truthiness, as the core's `getVerifiedStatementValue` check has it: an empty list still names
// a parent.
return typeof verb?.id === 'string' && RESULT_VERBS.has(verb.id) && !context?.contextActivities?.parent
if (context?.contextActivities?.parent) return false
if (object?.definition?.extensions?.[SUB_CONTENT_EXTENSION]) return false
return typeof verb?.id === 'string' && RESULT_VERBS.has(verb.id)
}
Loading
Loading