tessera-learn 0.0.10 → 0.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +1 -0
  2. package/dist/audit-BBJpQGqb.js +204 -0
  3. package/dist/audit-BBJpQGqb.js.map +1 -0
  4. package/dist/plugin/a11y-cli.d.ts +1 -0
  5. package/dist/plugin/a11y-cli.js +36 -0
  6. package/dist/plugin/a11y-cli.js.map +1 -0
  7. package/dist/plugin/cli.js +6 -3
  8. package/dist/plugin/cli.js.map +1 -1
  9. package/dist/plugin/index.d.ts +16 -1
  10. package/dist/plugin/index.d.ts.map +1 -1
  11. package/dist/plugin/index.js +171 -140
  12. package/dist/plugin/index.js.map +1 -1
  13. package/dist/{validation-BxWAMMnJ.js → validation-B-xTvM9B.js} +417 -81
  14. package/dist/validation-B-xTvM9B.js.map +1 -0
  15. package/package.json +17 -2
  16. package/src/components/Accordion.svelte +3 -1
  17. package/src/components/AccordionItem.svelte +1 -5
  18. package/src/components/Audio.svelte +22 -5
  19. package/src/components/Callout.svelte +5 -1
  20. package/src/components/Carousel.svelte +24 -8
  21. package/src/components/DefaultLayout.svelte +41 -12
  22. package/src/components/FillInTheBlank.svelte +75 -103
  23. package/src/components/Image.svelte +14 -10
  24. package/src/components/LockedBanner.svelte +5 -5
  25. package/src/components/Matching.svelte +48 -19
  26. package/src/components/MediaTracks.svelte +21 -0
  27. package/src/components/MultipleChoice.svelte +81 -102
  28. package/src/components/Quiz.svelte +63 -21
  29. package/src/components/ResultIcon.svelte +20 -4
  30. package/src/components/RevealModal.svelte +25 -22
  31. package/src/components/Sorting.svelte +61 -26
  32. package/src/components/Transcript.svelte +37 -0
  33. package/src/components/Video.svelte +25 -20
  34. package/src/components/util.ts +4 -1
  35. package/src/components/video-embed.ts +25 -0
  36. package/src/index.ts +2 -7
  37. package/src/plugin/a11y/audit.ts +299 -0
  38. package/src/plugin/a11y/contrast.ts +67 -0
  39. package/src/plugin/a11y-cli.ts +35 -0
  40. package/src/plugin/cli.ts +6 -8
  41. package/src/plugin/export.ts +60 -50
  42. package/src/plugin/index.ts +244 -101
  43. package/src/plugin/layout.ts +6 -51
  44. package/src/plugin/manifest.ts +90 -24
  45. package/src/plugin/override-plugin.ts +68 -0
  46. package/src/plugin/quiz.ts +9 -54
  47. package/src/plugin/validation.ts +768 -183
  48. package/src/runtime/App.svelte +128 -64
  49. package/src/runtime/LoadingBar.svelte +12 -3
  50. package/src/runtime/Sidebar.svelte +24 -8
  51. package/src/runtime/access.ts +15 -3
  52. package/src/runtime/adapters/cmi5.ts +68 -116
  53. package/src/runtime/adapters/format.ts +67 -0
  54. package/src/runtime/adapters/index.ts +45 -34
  55. package/src/runtime/adapters/retry.ts +25 -84
  56. package/src/runtime/adapters/scorm-base.ts +19 -15
  57. package/src/runtime/adapters/scorm12.ts +8 -9
  58. package/src/runtime/adapters/scorm2004.ts +22 -30
  59. package/src/runtime/adapters/web.ts +1 -1
  60. package/src/runtime/hooks.svelte.ts +152 -328
  61. package/src/runtime/interaction-format.ts +30 -12
  62. package/src/runtime/interaction.ts +44 -11
  63. package/src/runtime/navigation.svelte.ts +29 -40
  64. package/src/runtime/persistence.ts +2 -2
  65. package/src/runtime/progress.svelte.ts +22 -9
  66. package/src/runtime/quiz-engine.svelte.ts +361 -0
  67. package/src/runtime/quiz-policy.ts +28 -179
  68. package/src/runtime/types.ts +24 -2
  69. package/src/runtime/xapi/agent-rules.ts +11 -3
  70. package/src/runtime/xapi/client.ts +5 -5
  71. package/src/runtime/xapi/derive-actor.ts +2 -2
  72. package/src/runtime/xapi/publisher.ts +33 -40
  73. package/src/runtime/xapi/setup.ts +18 -15
  74. package/src/runtime/xapi/validation.ts +15 -6
  75. package/src/virtual.d.ts +4 -1
  76. package/styles/base.css +32 -11
  77. package/styles/layout.css +39 -18
  78. package/styles/theme.css +15 -3
  79. package/dist/validation-BxWAMMnJ.js.map +0 -1
@@ -5,7 +5,7 @@ import {
5
5
  formatCorrectPattern,
6
6
  XAPI_INTERACTION_FORMAT,
7
7
  } from '../interaction-format.js';
8
- import { formatISO8601Duration } from './retry.js';
8
+ import { formatISO8601Duration } from './format.js';
9
9
  import { XAPIPublisher } from '../xapi/publisher.js';
10
10
  import { X_API_VERSION } from '../xapi/version.js';
11
11
  import type { XAPIAgent } from '../xapi/types.js';
@@ -26,7 +26,8 @@ const VERBS = {
26
26
  // registration state when Terminated lands without Completed.
27
27
  } as const;
28
28
 
29
- const CMI_INTERACTION_TYPE = 'http://adlnet.gov/expapi/activities/cmi.interaction';
29
+ const CMI_INTERACTION_TYPE =
30
+ 'http://adlnet.gov/expapi/activities/cmi.interaction';
30
31
 
31
32
  const CMI5_MASTERYSCORE_EXT =
32
33
  'https://w3id.org/xapi/cmi5/context/extensions/masteryscore';
@@ -36,26 +37,10 @@ const CMI5_MASTERYSCORE_EXT =
36
37
  // "failed" MUST additionally carry the "moveOn" Category. Without these, an
37
38
  // LRS will accept the statement as an arbitrary xAPI verb but won't roll it
38
39
  // up into cmi5 lifecycle state — the LMS never sees the AU as completed.
39
- const CMI5_CATEGORY_CMI5 =
40
- 'https://w3id.org/xapi/cmi5/context/categories/cmi5';
40
+ const CMI5_CATEGORY_CMI5 = 'https://w3id.org/xapi/cmi5/context/categories/cmi5';
41
41
  const CMI5_CATEGORY_MOVEON =
42
42
  'https://w3id.org/xapi/cmi5/context/categories/moveon';
43
43
 
44
- export type CMI5MoveOn =
45
- | 'Passed'
46
- | 'Completed'
47
- | 'CompletedAndPassed'
48
- | 'CompletedOrPassed'
49
- | 'NotApplicable';
50
-
51
- const VALID_MOVE_ON: ReadonlySet<CMI5MoveOn> = new Set([
52
- 'Passed',
53
- 'Completed',
54
- 'CompletedAndPassed',
55
- 'CompletedOrPassed',
56
- 'NotApplicable',
57
- ]);
58
-
59
44
  /** cmi5 §10.2.2 — launch mode dictates which Defined Statements the AU may emit. */
60
45
  export type CMI5LaunchMode = 'Normal' | 'Browse' | 'Review';
61
46
  const VALID_LAUNCH_MODE: ReadonlySet<CMI5LaunchMode> = new Set([
@@ -87,31 +72,24 @@ interface CMI5LaunchData {
87
72
  };
88
73
  launchMode?: CMI5LaunchMode;
89
74
  launchMethod?: 'OwnWindow' | 'AnyWindow';
90
- launchParameters?: string;
91
75
  returnURL?: string;
92
76
  masteryScore?: number;
93
- moveOn?: CMI5MoveOn;
94
77
  entitlementKey?: Record<string, string>;
95
78
  [k: string]: unknown;
96
79
  }
97
80
 
98
- /** cmi5 §11.1 Learner Preferences Agent Profile document. */
99
- interface CMI5LearnerPreferences {
100
- languagePreference?: string;
101
- audioPreference?: 'on' | 'off';
102
- [k: string]: unknown;
103
- }
104
-
105
81
  /** `.then` handler that warns on LRS non-2xx. Publisher resolves successfully on 4xx/5xx (failure is in the outcome), so `.catch` alone misses them. */
106
82
  function warnOnLRSReject(
107
- verbName: string
108
- ): (res: { destinations?: Array<{ ok?: boolean; status?: number; error?: Error }> }) => void {
83
+ verbName: string,
84
+ ): (res: {
85
+ destinations?: Array<{ ok?: boolean; status?: number; error?: Error }>;
86
+ }) => void {
109
87
  return (res) => {
110
88
  const dest = res.destinations?.[0];
111
89
  if (dest && !dest.ok) {
112
90
  console.warn(
113
91
  `Tessera cmi5: ${verbName} statement rejected by LRS (${dest.status ?? 'network error'})`,
114
- dest.error
92
+ dest.error,
115
93
  );
116
94
  }
117
95
  };
@@ -148,10 +126,8 @@ export class CMI5Adapter implements PersistenceAdapter {
148
126
  #terminated = false;
149
127
 
150
128
  // cmi5 §8 launch params. masteryScore (when present) overrides the
151
- // course's manifest passingScore for this launch — the LMS is the
152
- // authority. moveOn drives the optional Satisfied statement (§9.5.3).
129
+ // course's manifest passingScore for this launch — the LMS is the authority.
153
130
  #masteryScore: number | null = null;
154
- #moveOn: CMI5MoveOn = 'NotApplicable';
155
131
 
156
132
  // cmi5 §10 LMS.LaunchData. `contextTemplate` is the AU's base context
157
133
  // (§9.6.2) — Publisher Activity and session id live there, and strict
@@ -161,10 +137,6 @@ export class CMI5Adapter implements PersistenceAdapter {
161
137
  #launchMode: CMI5LaunchMode = 'Normal';
162
138
  /** cmi5 §10.2.6 — AU redirects here on `exit()`. */
163
139
  #returnURL: string | undefined;
164
- /** cmi5 §10.2.3 — opaque per-launch content config string. */
165
- #launchParameters: string | undefined;
166
- /** cmi5 §11.1 Learner Preferences. */
167
- #learnerPreferences: CMI5LearnerPreferences | null = null;
168
140
 
169
141
  async init(): Promise<void> {
170
142
  const params = new URLSearchParams(window.location.search);
@@ -184,18 +156,7 @@ export class CMI5Adapter implements PersistenceAdapter {
184
156
  this.#masteryScore = m;
185
157
  } else {
186
158
  console.warn(
187
- `Tessera cmi5: launch parameter 'masteryScore' is not a decimal in [0,1] (got "${rawMastery}"); ignoring.`
188
- );
189
- }
190
- }
191
-
192
- const rawMoveOn = params.get('moveOn');
193
- if (rawMoveOn !== null && rawMoveOn !== '') {
194
- if (VALID_MOVE_ON.has(rawMoveOn as CMI5MoveOn)) {
195
- this.#moveOn = rawMoveOn as CMI5MoveOn;
196
- } else {
197
- console.warn(
198
- `Tessera cmi5: launch parameter 'moveOn' is not a recognized value (got "${rawMoveOn}"); defaulting to NotApplicable.`
159
+ `Tessera cmi5: launch parameter 'masteryScore' is not a decimal in [0,1] (got "${rawMastery}"); ignoring.`,
199
160
  );
200
161
  }
201
162
  }
@@ -212,7 +173,8 @@ export class CMI5Adapter implements PersistenceAdapter {
212
173
  this.#actor = parsed as XAPIAgent;
213
174
  } catch (err) {
214
175
  throw new Error(
215
- `Tessera cmi5: launch parameter 'actor' is malformed (${err instanceof Error ? err.message : String(err)}). The LMS did not send a valid Identified Agent JSON.`
176
+ `Tessera cmi5: launch parameter 'actor' is malformed (${err instanceof Error ? err.message : String(err)}). The LMS did not send a valid Identified Agent JSON.`,
177
+ { cause: err },
216
178
  );
217
179
  }
218
180
 
@@ -221,7 +183,7 @@ export class CMI5Adapter implements PersistenceAdapter {
221
183
  // Fail loud at launch instead of dribbling errors per statement.
222
184
  if (!fetchUrl) {
223
185
  throw new Error(
224
- "Tessera cmi5: launch parameter 'fetch' is missing. Cannot acquire LMS auth token."
186
+ "Tessera cmi5: launch parameter 'fetch' is missing. Cannot acquire LMS auth token.",
225
187
  );
226
188
  }
227
189
  let resp: Response;
@@ -229,12 +191,13 @@ export class CMI5Adapter implements PersistenceAdapter {
229
191
  resp = await fetch(fetchUrl, { method: 'POST' });
230
192
  } catch (err) {
231
193
  throw new Error(
232
- `Tessera cmi5: fetch token request failed (${err instanceof Error ? err.message : String(err)}). The cmi5 launch fetch URL is single-use; reload from the LMS to retry.`
194
+ `Tessera cmi5: fetch token request failed (${err instanceof Error ? err.message : String(err)}). The cmi5 launch fetch URL is single-use; reload from the LMS to retry.`,
195
+ { cause: err },
233
196
  );
234
197
  }
235
198
  if (!resp.ok) {
236
199
  throw new Error(
237
- `Tessera cmi5: fetch token request returned ${resp.status}. The cmi5 launch fetch URL is single-use; reload from the LMS to retry.`
200
+ `Tessera cmi5: fetch token request returned ${resp.status}. The cmi5 launch fetch URL is single-use; reload from the LMS to retry.`,
238
201
  );
239
202
  }
240
203
  const text = (await resp.text()).trim();
@@ -255,15 +218,21 @@ export class CMI5Adapter implements PersistenceAdapter {
255
218
  if (typeof obj['auth-token'] === 'string') {
256
219
  token = (obj['auth-token'] as string).trim();
257
220
  } else {
258
- const code = typeof obj['error-code'] === 'string' ? obj['error-code'] : undefined;
259
- const errText = typeof obj['error-text'] === 'string' ? obj['error-text'] : undefined;
221
+ const code =
222
+ typeof obj['error-code'] === 'string'
223
+ ? obj['error-code']
224
+ : undefined;
225
+ const errText =
226
+ typeof obj['error-text'] === 'string'
227
+ ? obj['error-text']
228
+ : undefined;
260
229
  const detail =
261
230
  code !== undefined || errText !== undefined
262
231
  ? ` (error-code=${code ?? 'unknown'}${errText ? `: ${errText}` : ''})`
263
232
  : '';
264
233
  throw new Error(
265
234
  `Tessera cmi5: fetch URL returned a JSON response without an 'auth-token' field${detail}. ` +
266
- 'The cmi5 fetch URL is single-use (§8.2.3.1); reload from the LMS to obtain a fresh launch.'
235
+ 'The cmi5 fetch URL is single-use (§8.2.3.1); reload from the LMS to obtain a fresh launch.',
267
236
  );
268
237
  }
269
238
  }
@@ -274,15 +243,15 @@ export class CMI5Adapter implements PersistenceAdapter {
274
243
  this.#authToken = token;
275
244
  if (!this.#authToken) {
276
245
  throw new Error(
277
- 'Tessera cmi5: fetch token request returned an empty token. Expected a JSON body of the form {"auth-token": "..."}.'
246
+ 'Tessera cmi5: fetch token request returned an empty token. Expected a JSON body of the form {"auth-token": "..."}.',
278
247
  );
279
248
  }
280
249
 
281
250
  // cmi5 §10 — LaunchData is the only spec-defined channel for the
282
251
  // session id (§9.6.3.1) and Publisher Activity (§9.6.2.3) the LRS
283
- // validates against, plus launchMode/returnURL/launchParameters/
284
- // masteryScore/moveOn (§10.2). LaunchData values override the URL
285
- // masteryScore parsed earlier (§10.2.4 makes it authoritative).
252
+ // validates against, plus launchMode/returnURL/masteryScore (§10.2).
253
+ // LaunchData values override the URL masteryScore parsed earlier
254
+ // (§10.2.4 makes it authoritative).
286
255
  this.#launchData = await this.#fetchLaunchData();
287
256
  const tmpl = this.#launchData?.contextTemplate ?? {};
288
257
  let sessionId: string | undefined;
@@ -303,9 +272,6 @@ export class CMI5Adapter implements PersistenceAdapter {
303
272
  ) {
304
273
  this.#returnURL = this.#launchData.returnURL;
305
274
  }
306
- if (typeof this.#launchData.launchParameters === 'string') {
307
- this.#launchParameters = this.#launchData.launchParameters;
308
- }
309
275
  if (
310
276
  typeof this.#launchData.masteryScore === 'number' &&
311
277
  Number.isFinite(this.#launchData.masteryScore) &&
@@ -314,18 +280,12 @@ export class CMI5Adapter implements PersistenceAdapter {
314
280
  ) {
315
281
  this.#masteryScore = this.#launchData.masteryScore;
316
282
  }
317
- if (
318
- typeof this.#launchData.moveOn === 'string' &&
319
- VALID_MOVE_ON.has(this.#launchData.moveOn)
320
- ) {
321
- this.#moveOn = this.#launchData.moveOn;
322
- }
323
283
  }
324
284
 
325
285
  // cmi5 §11 — fetch the Agent Profile BEFORE Initialized. Strict
326
286
  // LRSes track the GET and reject Initialized otherwise. A 404 here
327
287
  // is legitimate (no prefs set); the GET itself is what's required.
328
- this.#learnerPreferences = await this.#fetchLearnerPreferences();
288
+ await this.#fetchLearnerPreferences();
329
289
 
330
290
  this.#publisher = new XAPIPublisher({
331
291
  endpoint: this.#endpoint,
@@ -361,12 +321,12 @@ export class CMI5Adapter implements PersistenceAdapter {
361
321
  this.#state = await resp.json();
362
322
  } else if (resp.status !== 404) {
363
323
  console.warn(
364
- `Tessera cmi5: State API GET returned ${resp.status}; resume disabled for this launch.`
324
+ `Tessera cmi5: State API GET returned ${resp.status}; resume disabled for this launch.`,
365
325
  );
366
326
  }
367
327
  } catch (err) {
368
328
  console.warn(
369
- `Tessera cmi5: State API GET failed (${err instanceof Error ? err.message : String(err)}); resume disabled for this launch.`
329
+ `Tessera cmi5: State API GET failed (${err instanceof Error ? err.message : String(err)}); resume disabled for this launch.`,
370
330
  );
371
331
  this.#state = null;
372
332
  }
@@ -391,31 +351,11 @@ export class CMI5Adapter implements PersistenceAdapter {
391
351
  return this.#masteryScore;
392
352
  }
393
353
 
394
- /** LMS-supplied moveOn criterion (defaults to "NotApplicable"). */
395
- getMoveOn(): CMI5MoveOn {
396
- return this.#moveOn;
397
- }
398
-
399
354
  /** cmi5 §10.2.2 — "Normal" is the only mode where progress-bearing Defined Statements are permitted. */
400
355
  getLaunchMode(): CMI5LaunchMode {
401
356
  return this.#launchMode;
402
357
  }
403
358
 
404
- /** cmi5 §10.2.6 — URL the AU navigates to on `exit()`. Returns undefined when the LMS didn't supply one. */
405
- getReturnURL(): string | undefined {
406
- return this.#returnURL;
407
- }
408
-
409
- /** cmi5 §10.2.3 — opaque per-launch content-config string. */
410
- getLaunchParameters(): string | undefined {
411
- return this.#launchParameters;
412
- }
413
-
414
- /** cmi5 §11.1 Learner Preferences. Null when the LMS didn't publish one. */
415
- getLearnerPreferences(): CMI5LearnerPreferences | null {
416
- return this.#learnerPreferences;
417
- }
418
-
419
359
  getState(): SavedState | null {
420
360
  return this.#state;
421
361
  }
@@ -425,7 +365,7 @@ export class CMI5Adapter implements PersistenceAdapter {
425
365
  if (!this.#publisher) return;
426
366
  // Chain the State PUT onto the publisher's queue so it lands before
427
367
  // Terminated. We can't use sendStatement here (different URL/verb).
428
- this.#publisher.chainTask(async () => {
368
+ void this.#publisher.chainTask(async () => {
429
369
  try {
430
370
  const resp = await this.#xapiFetch(this.#buildStateUrl(), {
431
371
  method: 'PUT',
@@ -434,7 +374,7 @@ export class CMI5Adapter implements PersistenceAdapter {
434
374
  });
435
375
  if (!resp.ok) {
436
376
  console.warn(
437
- `Tessera cmi5: State API PUT returned ${resp.status}; learner progress did not persist.`
377
+ `Tessera cmi5: State API PUT returned ${resp.status}; learner progress did not persist.`,
438
378
  );
439
379
  }
440
380
  } catch (err) {
@@ -455,7 +395,7 @@ export class CMI5Adapter implements PersistenceAdapter {
455
395
 
456
396
  seedLifecycle(
457
397
  completion: 'incomplete' | 'complete',
458
- success: 'unknown' | 'passed' | 'failed'
398
+ success: 'unknown' | 'passed' | 'failed',
459
399
  ): void {
460
400
  if (completion === 'complete') this.#completedEmitted = true;
461
401
  if (success === 'passed' || success === 'failed') {
@@ -464,7 +404,8 @@ export class CMI5Adapter implements PersistenceAdapter {
464
404
  }
465
405
 
466
406
  setCompletionStatus(status: 'incomplete' | 'complete'): void {
467
- if (status !== 'complete' || this.#completedEmitted || !this.#publisher) return;
407
+ if (status !== 'complete' || this.#completedEmitted || !this.#publisher)
408
+ return;
468
409
  // cmi5 §10.2.2 — Browse/Review launches MUST NOT emit Completed.
469
410
  if (this.#launchMode !== 'Normal') return;
470
411
  this.#completedEmitted = true;
@@ -505,14 +446,16 @@ export class CMI5Adapter implements PersistenceAdapter {
505
446
  // The author asserted the verb, so on contradiction we keep the
506
447
  // verb and drop the score (and warn).
507
448
  if (this.#masteryScore !== null) {
508
- const violatesPassed = status === 'passed' && scaled < this.#masteryScore;
509
- const violatesFailed = status === 'failed' && scaled >= this.#masteryScore;
449
+ const violatesPassed =
450
+ status === 'passed' && scaled < this.#masteryScore;
451
+ const violatesFailed =
452
+ status === 'failed' && scaled >= this.#masteryScore;
510
453
  if (violatesPassed || violatesFailed) {
511
454
  console.warn(
512
455
  `Tessera cmi5: refusing to attach scaled score ${scaled.toFixed(3)} to ` +
513
456
  `${status === 'passed' ? 'Passed' : 'Failed'} (masteryScore=${this.#masteryScore}); ` +
514
457
  `per cmi5 §9.3.${status === 'passed' ? '4' : '5'} the score would contradict the verb. ` +
515
- `Statement will be sent without a score.`
458
+ `Statement will be sent without a score.`,
516
459
  );
517
460
  } else {
518
461
  result.score = { scaled };
@@ -545,7 +488,7 @@ export class CMI5Adapter implements PersistenceAdapter {
545
488
  reportInteraction(
546
489
  questionId: string,
547
490
  interaction: Interaction,
548
- correct: boolean | null
491
+ correct: boolean | null,
549
492
  ): void {
550
493
  if (!this.#publisher) return;
551
494
  const response = formatResponse(interaction, XAPI_INTERACTION_FORMAT);
@@ -637,10 +580,13 @@ export class CMI5Adapter implements PersistenceAdapter {
637
580
  * for Passed/Failed (§9.6.3.2).
638
581
  */
639
582
  #cmi5Context(
640
- opts: { moveOn?: boolean; mastery?: boolean } = {}
583
+ opts: { moveOn?: boolean; mastery?: boolean } = {},
641
584
  ): Record<string, unknown> {
642
585
  const tmpl = this.#launchData?.contextTemplate ?? {};
643
- const tmplActivities = (tmpl.contextActivities ?? {}) as Record<string, unknown>;
586
+ const tmplActivities = (tmpl.contextActivities ?? {}) as Record<
587
+ string,
588
+ unknown
589
+ >;
644
590
 
645
591
  // Concat-dedupe category to preserve any template-supplied entries
646
592
  // (§10.2.1 forbids overwriting them).
@@ -652,8 +598,14 @@ export class CMI5Adapter implements PersistenceAdapter {
652
598
  category.push({ id, objectType: 'Activity' });
653
599
  }
654
600
  };
655
- const templateCategory = Array.isArray((tmplActivities as { category?: unknown }).category)
656
- ? ((tmplActivities as { category: Array<{ id: string; objectType?: string }> }).category)
601
+ const templateCategory = Array.isArray(
602
+ (tmplActivities as { category?: unknown }).category,
603
+ )
604
+ ? (
605
+ tmplActivities as {
606
+ category: Array<{ id: string; objectType?: string }>;
607
+ }
608
+ ).category
657
609
  : [];
658
610
  for (const c of templateCategory) {
659
611
  if (c && typeof c.id === 'string') push(c.id);
@@ -715,35 +667,35 @@ export class CMI5Adapter implements PersistenceAdapter {
715
667
  console.warn(
716
668
  `Tessera cmi5: LMS.LaunchData State GET returned ${resp.status}; ` +
717
669
  'cmi5 Defined Statements may be rejected by strict LRSes ' +
718
- '(missing Publisher Activity / session id).'
670
+ '(missing Publisher Activity / session id).',
719
671
  );
720
672
  } catch (err) {
721
673
  console.warn(
722
- `Tessera cmi5: LMS.LaunchData State GET failed (${err instanceof Error ? err.message : String(err)}).`
674
+ `Tessera cmi5: LMS.LaunchData State GET failed (${err instanceof Error ? err.message : String(err)}).`,
723
675
  );
724
676
  }
725
677
  return null;
726
678
  }
727
679
 
728
- /** GET cmi5 §11.1 Learner Preferences. 404 is normal (no prefs set). */
729
- async #fetchLearnerPreferences(): Promise<CMI5LearnerPreferences | null> {
680
+ /**
681
+ * GET cmi5 §11.1 Learner Preferences. The GET itself is the §11
682
+ * obligation (it must precede Initialized); the response body is not
683
+ * consumed. 404 is normal (no prefs set).
684
+ */
685
+ async #fetchLearnerPreferences(): Promise<void> {
730
686
  try {
731
687
  const url = this.#buildAgentProfileUrl(CMI5_LEARNER_PREFS_PROFILE_ID);
732
688
  const resp = await this.#xapiFetch(url, { method: 'GET' });
733
- if (resp.ok) {
734
- return (await resp.json()) as CMI5LearnerPreferences;
735
- }
736
- if (resp.status !== 404) {
689
+ if (!resp.ok && resp.status !== 404) {
737
690
  console.warn(
738
- `Tessera cmi5: Agent Profile GET (cmi5LearnerPreferences) returned ${resp.status}.`
691
+ `Tessera cmi5: Agent Profile GET (cmi5LearnerPreferences) returned ${resp.status}.`,
739
692
  );
740
693
  }
741
694
  } catch (err) {
742
695
  console.warn(
743
- `Tessera cmi5: Agent Profile GET (cmi5LearnerPreferences) failed (${err instanceof Error ? err.message : String(err)}).`
696
+ `Tessera cmi5: Agent Profile GET (cmi5LearnerPreferences) failed (${err instanceof Error ? err.message : String(err)}).`,
744
697
  );
745
698
  }
746
- return null;
747
699
  }
748
700
 
749
701
  async #xapiFetch(url: string, options: RequestInit = {}): Promise<Response> {
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Time / number formatters for SCORM & cmi5 data-model writes.
3
+ */
4
+
5
+ /**
6
+ * Format integer seconds as SCORM 1.2 `CMITimespan` (HHHH:MM:SS.SS).
7
+ *
8
+ * `DurationTracker.sessionSeconds` always feeds integer seconds via
9
+ * `Math.floor`, so the centisecond field is always `.00`. The format
10
+ * still includes it because `CMITimespan` is defined that way and some
11
+ * older LMS importers reject the bare HHHH:MM:SS form.
12
+ */
13
+ export function formatHHMMSS(totalSeconds: number): string {
14
+ const whole = Math.floor(totalSeconds);
15
+ const hours = Math.floor(whole / 3600);
16
+ const minutes = Math.floor((whole % 3600) / 60);
17
+ const seconds = whole % 60;
18
+ const hh = String(hours).padStart(4, '0');
19
+ const mm = String(minutes).padStart(2, '0');
20
+ const ss = String(seconds).padStart(2, '0');
21
+ return `${hh}:${mm}:${ss}.00`;
22
+ }
23
+
24
+ /**
25
+ * SCORM 2004 4E §4.2/§4.3 define CMIDecimal-like elements as real(10,7) —
26
+ * `String(1/3)` exceeds that and trips SCORM Cloud with error 406. Rounds,
27
+ * then trims trailing zeros (no padded "0.8500000" forms).
28
+ */
29
+ export function formatReal107(value: number): string {
30
+ if (!Number.isFinite(value)) return '0';
31
+ const rounded = Math.round(value * 1e7) / 1e7;
32
+ return rounded
33
+ .toFixed(7)
34
+ .replace(/(\.\d*?)0+$/, '$1')
35
+ .replace(/\.$/, '');
36
+ }
37
+
38
+ /**
39
+ * SCORM 2004 4E §3.3.10.1 references ISO 8601 §5.3.3 — local date+time, no
40
+ * zone designator. Strict validators reject `Z`, `±hh:mm`, and fractional
41
+ * seconds with error 406. UTC components are used so writes don't drift
42
+ * across local-TZ flips even though the format is zone-free.
43
+ */
44
+ export function formatISO8601Timestamp(date: Date): string {
45
+ const yyyy = date.getUTCFullYear();
46
+ const mm = String(date.getUTCMonth() + 1).padStart(2, '0');
47
+ const dd = String(date.getUTCDate()).padStart(2, '0');
48
+ const hh = String(date.getUTCHours()).padStart(2, '0');
49
+ const mi = String(date.getUTCMinutes()).padStart(2, '0');
50
+ const ss = String(date.getUTCSeconds()).padStart(2, '0');
51
+ return `${yyyy}-${mm}-${dd}T${hh}:${mi}:${ss}`;
52
+ }
53
+
54
+ /**
55
+ * Format seconds as ISO 8601 duration: PT1H30M45S
56
+ */
57
+ export function formatISO8601Duration(totalSeconds: number): string {
58
+ const hours = Math.floor(totalSeconds / 3600);
59
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
60
+ const seconds = totalSeconds % 60;
61
+
62
+ let result = 'PT';
63
+ if (hours > 0) result += `${hours}H`;
64
+ if (minutes > 0) result += `${minutes}M`;
65
+ if (seconds > 0 || result === 'PT') result += `${seconds}S`;
66
+ return result;
67
+ }
@@ -20,7 +20,7 @@ export class LMSAdapterError extends Error {
20
20
  }
21
21
 
22
22
  function missingApiError(
23
- standard: 'scorm12' | 'scorm2004' | 'cmi5'
23
+ standard: 'scorm12' | 'scorm2004' | 'cmi5',
24
24
  ): LMSAdapterError {
25
25
  const label =
26
26
  standard === 'scorm12'
@@ -36,7 +36,7 @@ function missingApiError(
36
36
  standard,
37
37
  `Tessera: this course is configured for ${label} but ${detail} ` +
38
38
  `The course must be launched from an LMS that provides the ${label} runtime. ` +
39
- `If you are testing locally, run \`npm run dev\` instead, or set export.standard to "web".`
39
+ `If you are testing locally, run \`npm run dev\` instead, or set export.standard to "web".`,
40
40
  );
41
41
  }
42
42
 
@@ -60,40 +60,51 @@ export interface CreateAdapterOptions {
60
60
  * In dev mode, missing APIs warn and fall back to `WebAdapter` so authors
61
61
  * can still iterate locally.
62
62
  */
63
+ type LMSStandard = 'scorm12' | 'scorm2004' | 'cmi5';
64
+
65
+ /** Per-standard LMS detection. `detect` returns an adapter when the LMS runtime is reachable, else null. */
66
+ const LMS_ADAPTERS: Record<
67
+ LMSStandard,
68
+ { detect: () => PersistenceAdapter | null; label: string }
69
+ > = {
70
+ scorm12: {
71
+ detect: () => {
72
+ const api = findSCORM12API();
73
+ return api ? new SCORM12Adapter(api) : null;
74
+ },
75
+ label: 'SCORM 1.2 API',
76
+ },
77
+ scorm2004: {
78
+ detect: () => {
79
+ const api = findSCORM2004API();
80
+ return api ? new SCORM2004Adapter(api) : null;
81
+ },
82
+ label: 'SCORM 2004 API',
83
+ },
84
+ cmi5: {
85
+ detect: () => (hasCMI5LaunchParams() ? new CMI5Adapter() : null),
86
+ label: 'cmi5 launch parameters',
87
+ },
88
+ };
89
+
63
90
  export function createAdapter(
64
91
  config: CourseConfig,
65
- options: CreateAdapterOptions = {}
92
+ options: CreateAdapterOptions = {},
66
93
  ): PersistenceAdapter {
67
- const allowFallback =
68
- options.allowFallback ?? import.meta.env?.DEV === true;
69
- switch (config.export?.standard) {
70
- case 'scorm12': {
71
- const api = findSCORM12API();
72
- if (api) return new SCORM12Adapter(api);
73
- if (!allowFallback) throw missingApiError('scorm12');
74
- console.warn(
75
- 'Tessera (dev): SCORM 1.2 API not found — falling back to localStorage'
76
- );
77
- return new WebAdapter(config);
78
- }
79
- case 'scorm2004': {
80
- const api = findSCORM2004API();
81
- if (api) return new SCORM2004Adapter(api);
82
- if (!allowFallback) throw missingApiError('scorm2004');
83
- console.warn(
84
- 'Tessera (dev): SCORM 2004 API not found — falling back to localStorage'
85
- );
86
- return new WebAdapter(config);
87
- }
88
- case 'cmi5': {
89
- if (hasCMI5LaunchParams()) return new CMI5Adapter();
90
- if (!allowFallback) throw missingApiError('cmi5');
91
- console.warn(
92
- 'Tessera (dev): cmi5 launch parameters not found — falling back to localStorage'
93
- );
94
- return new WebAdapter(config);
95
- }
96
- default:
97
- return new WebAdapter(config);
94
+ const allowFallback = options.allowFallback ?? import.meta.env?.DEV === true;
95
+ const standard = config.export?.standard;
96
+ if (
97
+ standard === 'scorm12' ||
98
+ standard === 'scorm2004' ||
99
+ standard === 'cmi5'
100
+ ) {
101
+ const entry = LMS_ADAPTERS[standard];
102
+ const adapter = entry.detect();
103
+ if (adapter) return adapter;
104
+ if (!allowFallback) throw missingApiError(standard);
105
+ console.warn(
106
+ `Tessera (dev): ${entry.label} not found — falling back to localStorage`,
107
+ );
98
108
  }
109
+ return new WebAdapter(config);
99
110
  }