diff --git a/AGENTS.md b/AGENTS.md index 2fa4f30..1b00c58 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -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 @@ -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 diff --git a/README.md b/README.md index 89b41bf..bfdc27f 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/apps/demo/scripts/sweep-content-types.mjs b/apps/demo/scripts/sweep-content-types.mjs index e8397f0..32e4bc7 100644 --- a/apps/demo/scripts/sweep-content-types.mjs +++ b/apps/demo/scripts/sweep-content-types.mjs @@ -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) @@ -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} |` @@ -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` |', '|---|---|---|---|---|', diff --git a/apps/demo/sweep/H5P.GameMap.json b/apps/demo/sweep/H5P.GameMap.json index 09f5050..11b36fc 100644 --- a/apps/demo/sweep/H5P.GameMap.json +++ b/apps/demo/sweep/H5P.GameMap.json @@ -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": { diff --git a/apps/demo/sweep/H5P.InteractiveBook.json b/apps/demo/sweep/H5P.InteractiveBook.json index 3401abe..5ffe2d3 100644 --- a/apps/demo/sweep/H5P.InteractiveBook.json +++ b/apps/demo/sweep/H5P.InteractiveBook.json @@ -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": "" }, diff --git a/docs/cmi5.md b/docs/cmi5.md index f54526d..c17f8dd 100644 --- a/docs/cmi5.md +++ b/docs/cmi5.md @@ -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`. diff --git a/packages/player/scripts/build-fixtures.mjs b/packages/player/scripts/build-fixtures.mjs index 849388c..e6ea611 100644 --- a/packages/player/scripts/build-fixtures.mjs +++ b/packages/player/scripts/build-fixtures.mjs @@ -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) => { @@ -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.' } } }) ) ) diff --git a/packages/player/tests/browser/finished.test.ts b/packages/player/tests/browser/finished.test.ts index 7a92080..cff8198 100644 --- a/packages/player/tests/browser/finished.test.ts +++ b/packages/player/tests/browser/finished.test.ts @@ -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 } } context?: { contextActivities?: { parent?: unknown } } result?: { score?: { raw: number; max: number } } } @@ -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(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) diff --git a/packages/player/tests/fixtures/src/basic/H5P.OfflineTest-1.0/offline-test.js b/packages/player/tests/fixtures/src/basic/H5P.OfflineTest-1.0/offline-test.js index 20fe055..a1b8fc9 100644 --- a/packages/player/tests/fixtures/src/basic/H5P.OfflineTest-1.0/offline-test.js +++ b/packages/player/tests/fixtures/src/basic/H5P.OfflineTest-1.0/offline-test.js @@ -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 = $('
'); + $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( diff --git a/packages/runtime/src/finished.ts b/packages/runtime/src/finished.ts index 87d80e2..aa5ac60 100644 --- a/packages/runtime/src/finished.ts +++ b/packages/runtime/src/finished.ts @@ -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 } } 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) } diff --git a/packages/runtime/tests/finished.test.ts b/packages/runtime/tests/finished.test.ts index 70aff41..d087ab2 100644 --- a/packages/runtime/tests/finished.test.ts +++ b/packages/runtime/tests/finished.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from 'vitest' -import { isResult } from '../src/finished' +import { isResult, SUB_CONTENT_EXTENSION } from '../src/finished' const verb = (name: string) => ({ id: `http://adlnet.gov/expapi/verbs/${name}` }) const parent = { contextActivities: { parent: [{ id: 'https://example.org/h5p/1' }] } } -describe("the content's result, as H5P's core decides it", () => { +describe("the content's result", () => { it('is a completed or an answered statement from the content itself', () => { expect(isResult({ verb: verb('completed') })).toBe(true) expect(isResult({ verb: verb('answered'), result: { score: { raw: 0, max: 1 } } })).toBe(true) @@ -17,6 +17,23 @@ describe("the content's result, as H5P's core decides it", () => { expect(isResult({ verb: verb('completed'), context: { contextActivities: { parent: [] } } })).toBe(false) }) + it('is not one from sub-content created without a parent', () => { + // Interactive Book's chapters and Game Map's exercises: no parent, the core's mark on the object. + const chapter = { + id: 'https://example.org/h5p/1?subContentId=9d3c1f0a', + definition: { extensions: { 'http://h5p.org/x-api/h5p-local-content-id': 1, [SUB_CONTENT_EXTENSION]: '9d3c1f0a' } } + } + expect(isResult({ verb: verb('completed'), object: chapter })).toBe(false) + expect(isResult({ verb: verb('answered'), object: chapter, context: {} })).toBe(false) + }) + + it('takes an object without the mark as the content itself', () => { + const content = { id: 'https://example.org/h5p/1', definition: { extensions: { 'http://h5p.org/x-api/h5p-local-content-id': 1 } } } + expect(isResult({ verb: verb('completed'), object: content })).toBe(true) + expect(isResult({ verb: verb('answered'), object: { definition: { extensions: { [SUB_CONTENT_EXTENSION]: '' } } } })).toBe(true) + expect(isResult({ verb: verb('answered'), object: { definition: {} } })).toBe(true) + }) + it('takes a context without a parent as the content itself', () => { expect(isResult({ verb: verb('answered'), context: { contextActivities: { category: [{ id: 'x' }] } } })).toBe(true) expect(isResult({ verb: verb('completed'), context: {} })).toBe(true)