tessera-learn 0.0.3 → 0.0.4

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.
@@ -32,8 +32,10 @@ const KNOWN_CONFIG_FIELDS = new Set([
32
32
  ]);
33
33
 
34
34
  const VALID_NAV_MODES = ['free', 'sequential'];
35
- const VALID_COMPLETION_MODES = ['quiz', 'percentage'];
35
+ const VALID_COMPLETION_MODES = ['quiz', 'percentage', 'manual'];
36
36
  const VALID_EXPORT_STANDARDS = ['web', 'scorm12', 'scorm2004', 'cmi5'];
37
+ const VALID_MANUAL_TRIGGERS = ['page'];
38
+ const VALID_REQUIRE_SUCCESS_STATUS = ['passed', 'failed'];
37
39
 
38
40
  // ---------- Main ----------
39
41
 
@@ -75,7 +77,12 @@ export function validateProject(projectRoot: string): ValidationResult {
75
77
  interface ParsedConfig {
76
78
  title?: string;
77
79
  navigation?: { mode?: string };
78
- completion?: { mode?: string; percentageThreshold?: number };
80
+ completion?: {
81
+ mode?: string;
82
+ percentageThreshold?: number;
83
+ trigger?: string;
84
+ requireSuccessStatus?: string;
85
+ };
79
86
  scoring?: { passingScore?: number };
80
87
  export?: { standard?: string };
81
88
  [key: string]: unknown;
@@ -126,7 +133,31 @@ function parseConfig(
126
133
  if (config.completion?.mode !== undefined) {
127
134
  if (!VALID_COMPLETION_MODES.includes(config.completion.mode)) {
128
135
  errors.push(
129
- `course.config.js: "completion.mode" must be "quiz" or "percentage", got "${config.completion.mode}"`
136
+ `course.config.js: "completion.mode" must be "quiz", "percentage", or "manual", got "${config.completion.mode}"`
137
+ );
138
+ }
139
+ }
140
+
141
+ if (config.completion?.trigger !== undefined) {
142
+ if (config.completion.mode !== 'manual') {
143
+ warnings.push(
144
+ `course.config.js: "completion.trigger" is ignored unless completion.mode is "manual"`
145
+ );
146
+ } else if (!VALID_MANUAL_TRIGGERS.includes(config.completion.trigger)) {
147
+ errors.push(
148
+ `course.config.js: "completion.trigger" must be "page" or omitted, got "${config.completion.trigger}"`
149
+ );
150
+ }
151
+ }
152
+
153
+ if (config.completion?.requireSuccessStatus !== undefined) {
154
+ if (config.completion.mode !== 'manual') {
155
+ warnings.push(
156
+ `course.config.js: "completion.requireSuccessStatus" is ignored unless completion.mode is "manual"`
157
+ );
158
+ } else if (!VALID_REQUIRE_SUCCESS_STATUS.includes(config.completion.requireSuccessStatus)) {
159
+ errors.push(
160
+ `course.config.js: "completion.requireSuccessStatus" must be "passed" or "failed" (omit for "unknown"), got "${config.completion.requireSuccessStatus}"`
130
161
  );
131
162
  }
132
163
  }
@@ -452,10 +483,19 @@ function validateSingleXAPIEntry(
452
483
 
453
484
  // ---------- Pages Validation ----------
454
485
 
486
+ interface PageInfo {
487
+ fileRel: string;
488
+ navIndex: number;
489
+ hasGradedQuiz: boolean;
490
+ hasQuiz: boolean;
491
+ completesOnView: boolean;
492
+ }
493
+
455
494
  interface PagesValidationResult extends ValidationResult {
456
495
  totalPages: number;
457
496
  totalQuizzes: number;
458
497
  hasGradedQuiz: boolean;
498
+ pages: PageInfo[];
459
499
  }
460
500
 
461
501
  function validatePages(
@@ -465,6 +505,7 @@ function validatePages(
465
505
  ): PagesValidationResult {
466
506
  const errors: string[] = [];
467
507
  const warnings: string[] = [];
508
+ const pages: PageInfo[] = [];
468
509
  let totalPages = 0;
469
510
  let totalQuizzes = 0;
470
511
  let hasGradedQuiz = false;
@@ -475,7 +516,7 @@ function validatePages(
475
516
  errors.push(
476
517
  'No pages found. Create at least one section with a lesson and page in pages/'
477
518
  );
478
- return { errors, warnings, totalPages: 0, totalQuizzes: 0, hasGradedQuiz: false };
519
+ return { errors, warnings, totalPages: 0, totalQuizzes: 0, hasGradedQuiz: false, pages };
479
520
  }
480
521
 
481
522
  const topLevelEntries = readdirSync(pagesDir);
@@ -503,7 +544,7 @@ function validatePages(
503
544
  errors.push(
504
545
  'No pages found. Create at least one section with a lesson and page in pages/'
505
546
  );
506
- return { errors, warnings, totalPages: 0, totalQuizzes: 0, hasGradedQuiz: false };
547
+ return { errors, warnings, totalPages: 0, totalQuizzes: 0, hasGradedQuiz: false, pages };
507
548
  }
508
549
 
509
550
  for (const sectionName of sectionDirs) {
@@ -545,16 +586,28 @@ function validatePages(
545
586
  const content = readSourceFileCached(filePath);
546
587
 
547
588
  const pageConfig = validatePageConfig(content, fileRel, errors);
589
+ const navIndex = totalPages;
548
590
  totalPages++;
549
591
 
592
+ let pageHasGradedQuiz = false;
550
593
  if (pageConfig?.quiz) {
551
594
  totalQuizzes++;
552
595
  validateQuizConfig(pageConfig.quiz, fileRel, errors);
553
596
  if ((pageConfig.quiz as { graded?: unknown }).graded === true) {
554
597
  hasGradedQuiz = true;
598
+ pageHasGradedQuiz = true;
555
599
  }
556
600
  }
557
601
 
602
+ const completesOnView = validateCompletesOn(pageConfig, fileRel, errors);
603
+ pages.push({
604
+ fileRel,
605
+ navIndex,
606
+ hasGradedQuiz: pageHasGradedQuiz,
607
+ hasQuiz: !!pageConfig?.quiz,
608
+ completesOnView,
609
+ });
610
+
558
611
  validateAssetRefs(content, fileRel, assetsDir, warnings, assetExistsCache);
559
612
  }
560
613
 
@@ -615,8 +668,10 @@ function validatePages(
615
668
  const content = readSourceFileCached(filePath);
616
669
 
617
670
  const pageConfig = validatePageConfig(content, fileRel, errors);
671
+ const navIndex = totalPages;
618
672
  totalPages++;
619
673
 
674
+ let pageHasGradedQuiz = false;
620
675
  if (pageConfig?.quiz) {
621
676
  totalQuizzes++;
622
677
 
@@ -625,9 +680,19 @@ function validatePages(
625
680
 
626
681
  if ((pageConfig.quiz as { graded?: unknown }).graded === true) {
627
682
  hasGradedQuiz = true;
683
+ pageHasGradedQuiz = true;
628
684
  }
629
685
  }
630
686
 
687
+ const completesOnView = validateCompletesOn(pageConfig, fileRel, errors);
688
+ pages.push({
689
+ fileRel,
690
+ navIndex,
691
+ hasGradedQuiz: pageHasGradedQuiz,
692
+ hasQuiz: !!pageConfig?.quiz,
693
+ completesOnView,
694
+ });
695
+
631
696
  // Check $assets references
632
697
  validateAssetRefs(content, fileRel, assetsDir, warnings, assetExistsCache);
633
698
  }
@@ -640,7 +705,7 @@ function validatePages(
640
705
  );
641
706
  }
642
707
 
643
- return { errors, warnings, totalPages, totalQuizzes, hasGradedQuiz };
708
+ return { errors, warnings, totalPages, totalQuizzes, hasGradedQuiz, pages };
644
709
  }
645
710
 
646
711
  // ---------- _meta.js Validation ----------
@@ -681,7 +746,7 @@ function validatePageConfig(
681
746
  content: string,
682
747
  fileRel: string,
683
748
  errors: string[]
684
- ): { title?: string; quiz?: unknown } | null {
749
+ ): { title?: string; quiz?: unknown; completesOn?: unknown } | null {
685
750
  const result = parsePageConfigFromSource(content);
686
751
  if (result.kind === 'ok') return result.value;
687
752
  if (result.kind === 'invalid') {
@@ -692,6 +757,19 @@ function validatePageConfig(
692
757
  return null;
693
758
  }
694
759
 
760
+ function validateCompletesOn(
761
+ pageConfig: { completesOn?: unknown } | null,
762
+ fileRel: string,
763
+ errors: string[]
764
+ ): boolean {
765
+ if (!pageConfig || pageConfig.completesOn === undefined) return false;
766
+ if (pageConfig.completesOn === 'view') return true;
767
+ errors.push(
768
+ `${fileRel}: pageConfig.completesOn must be "view", got ${JSON.stringify(pageConfig.completesOn)}`
769
+ );
770
+ return false;
771
+ }
772
+
695
773
  // ---------- Quiz Config Validation ----------
696
774
 
697
775
  function validateQuizConfig(quiz: unknown, fileRel: string, errors: string[]): void {
@@ -766,6 +844,58 @@ function crossValidate(
766
844
  );
767
845
  }
768
846
 
847
+ const isManual = config.completion?.mode === 'manual';
848
+ const completesOnPages = pageResults.pages.filter((p) => p.completesOnView);
849
+
850
+ if (isManual && config.completion?.trigger === 'page' && completesOnPages.length === 0) {
851
+ errors.push(
852
+ 'completion.mode is "manual" with trigger: "page", but no page declares pageConfig.completesOn: "view". ' +
853
+ 'Either add a completesOn page or remove the trigger field to drop the static check.'
854
+ );
855
+ }
856
+
857
+ if (isManual) {
858
+ for (const page of pageResults.pages) {
859
+ if (page.hasGradedQuiz) {
860
+ warnings.push(
861
+ `${page.fileRel}: quiz.graded is true under completion.mode: "manual". ` +
862
+ 'The score will be reported to the LMS for transcripts, but it will not drive ' +
863
+ 'completion or success status — `markComplete()` / completesOn does. If that\'s ' +
864
+ 'not what you want, set graded: false or change completion.mode.'
865
+ );
866
+ }
867
+ }
868
+ }
869
+
870
+ if (isManual && config.completion?.percentageThreshold !== undefined) {
871
+ warnings.push(
872
+ 'course.config.js: "completion.percentageThreshold" is ignored under completion.mode: "manual"'
873
+ );
874
+ }
875
+ if (!isManual) {
876
+ for (const page of completesOnPages) {
877
+ warnings.push(
878
+ `${page.fileRel}: pageConfig.completesOn is ignored — completion.mode is "${config.completion?.mode ?? 'percentage'}"`
879
+ );
880
+ }
881
+ }
882
+ for (const page of pageResults.pages) {
883
+ if (page.completesOnView && page.hasQuiz) {
884
+ warnings.push(
885
+ `${page.fileRel}: completion fires on view, before the quiz can be answered — likely a mistake`
886
+ );
887
+ }
888
+ }
889
+
890
+ if (isManual) {
891
+ const firstPage = pageResults.pages.find((p) => p.navIndex === 0);
892
+ if (firstPage?.completesOnView) {
893
+ warnings.push(
894
+ `${firstPage.fileRel}: pageConfig.completesOn: "view" is on the first page — the course will complete immediately on launch, before the learner sees any other content.`
895
+ );
896
+ }
897
+ }
898
+
769
899
  // SCORM 1.2 + high page count warning
770
900
  if (config.export?.standard === 'scorm12') {
771
901
  // Estimate worst-case suspend_data size when all pages are visited, all
@@ -102,8 +102,13 @@
102
102
  if (gen !== loadGeneration) return; // stale
103
103
  PageComponent = mod.default;
104
104
  pageLoading = false;
105
- // Mark visited and recalculate
106
105
  progress.markVisited(index);
106
+ if (
107
+ manifest.pages[index].completesOn === 'view' &&
108
+ config.completion.mode === 'manual'
109
+ ) {
110
+ progress.markCompleteManually();
111
+ }
107
112
  progress.recalculateCompletion(manifest, config);
108
113
  progress.recalculateSuccess(manifest, config);
109
114
  }).catch(err => {
@@ -211,6 +216,7 @@
211
216
  s,
212
217
  gs: [...progress.gradedStandalonePages],
213
218
  u: { ...userState },
219
+ ...(progress.manuallyCompleted ? { m: 1 } : {}),
214
220
  };
215
221
  }
216
222
 
@@ -246,6 +252,10 @@
246
252
  }
247
253
  // Restore duration
248
254
  duration = new DurationTracker(saved.d || 0);
255
+ // Must come before recalc so manual-mode branches see the latch.
256
+ if (saved.m === 1) {
257
+ progress.markCompleteManually();
258
+ }
249
259
  // Recalculate derived state
250
260
  progress.recalculateCompletion(manifest, config);
251
261
  progress.recalculateSuccess(manifest, config);
@@ -303,7 +313,10 @@
303
313
 
304
314
  untrack(() => {
305
315
  adapter.setScore(Math.round(average));
306
- adapter.setSuccessStatus(average >= config.scoring.passingScore ? 'passed' : 'failed');
316
+ // Under manual mode, success is owned by requireSuccessStatus.
317
+ if (config.completion.mode !== 'manual') {
318
+ adapter.setSuccessStatus(average >= config.scoring.passingScore ? 'passed' : 'failed');
319
+ }
307
320
  adapter.setDuration(duration.sessionSeconds);
308
321
  adapter.commit();
309
322
  });
@@ -322,8 +335,21 @@
322
335
  });
323
336
  });
324
337
 
338
+ let prevSuccessStatus = $state('unknown');
339
+ $effect(() => {
340
+ const status = progress.successStatus;
341
+ if (!persistenceReady) return;
342
+ if (status === prevSuccessStatus) return;
343
+ prevSuccessStatus = status;
344
+ untrack(() => {
345
+ adapter.setSuccessStatus(status);
346
+ adapter.commit();
347
+ });
348
+ });
349
+
325
350
  // ---- Exit / Terminate lifecycle ----
326
351
  let terminated = false;
352
+ let manualWatchdog = null;
327
353
 
328
354
  function handleExit() {
329
355
  if (terminated) return;
@@ -360,10 +386,23 @@
360
386
  pageLoading = false;
361
387
  return;
362
388
  }
389
+
390
+ // cmi5 §8: an LMS-supplied masteryScore is the authoritative pass
391
+ // threshold for this launch and overrides the manifest. Mutate the
392
+ // imported config object once before any UI reads it so every
393
+ // downstream consumer (recalculateSuccess, navigation gating, Quiz
394
+ // page context) sees the same effective value.
395
+ const lmsMastery = adapter.getMasteryScore?.();
396
+ if (typeof lmsMastery === 'number') {
397
+ config.scoring.passingScore = lmsMastery * 100;
398
+ pageContext.passingScore = lmsMastery * 100;
399
+ }
400
+
363
401
  const saved = adapter.getState();
364
402
  if (saved) {
365
403
  restoreState(saved);
366
404
  prevCompletionStatus = progress.completionStatus;
405
+ prevSuccessStatus = progress.successStatus;
367
406
  }
368
407
  persistenceReady = true;
369
408
 
@@ -390,6 +429,27 @@
390
429
  window.addEventListener('beforeunload', handleExit);
391
430
  const appEl = document.getElementById('tessera-app');
392
431
  appEl?.addEventListener('tessera-quiz-complete', handleQuizComplete);
432
+
433
+ // Dev-only watchdog for `completion.mode: "manual"` without an opt-in
434
+ // trigger check — catches the hook never being called or no completesOn
435
+ // page being reachable.
436
+ if (
437
+ import.meta.env?.DEV &&
438
+ config.completion.mode === 'manual' &&
439
+ config.completion.trigger === undefined &&
440
+ progress.completionStatus === 'incomplete'
441
+ ) {
442
+ manualWatchdog = window.setTimeout(() => {
443
+ if (progress.completionStatus === 'incomplete') {
444
+ console.warn(
445
+ '[tessera] completion.mode is "manual" but the course has not completed after 60s. ' +
446
+ 'No page declared `pageConfig.completesOn: "view"` was reached, and no component called ' +
447
+ '`useCompletion().markComplete()`. This is a misconfiguration; set `completion.trigger: "page"` ' +
448
+ 'in course.config.js to fail the build instead of waiting at runtime.'
449
+ );
450
+ }
451
+ }, 60_000);
452
+ }
393
453
  });
394
454
 
395
455
  onDestroy(() => {
@@ -397,6 +457,10 @@
397
457
  window.removeEventListener('beforeunload', handleExit);
398
458
  const appEl = document.getElementById('tessera-app');
399
459
  appEl?.removeEventListener('tessera-quiz-complete', handleQuizComplete);
460
+ if (manualWatchdog !== null) {
461
+ clearTimeout(manualWatchdog);
462
+ manualWatchdog = null;
463
+ }
400
464
  // Clear the global slot so a stale client from a previous mount
401
465
  // can't leak into a fresh one (matters for tests that re-mount).
402
466
  registerXAPIClient(null);
@@ -17,10 +17,29 @@ const VERBS = {
17
17
  failed: 'http://adlnet.gov/expapi/verbs/failed',
18
18
  suspended: 'http://adlnet.gov/expapi/verbs/suspended',
19
19
  terminated: 'http://adlnet.gov/expapi/verbs/terminated',
20
+ satisfied: 'https://w3id.org/xapi/adl/verbs/satisfied',
20
21
  } as const;
21
22
 
22
23
  const CMI_INTERACTION_TYPE = 'http://adlnet.gov/expapi/activities/cmi.interaction';
23
24
 
25
+ const CMI5_MASTERYSCORE_EXT =
26
+ 'https://w3id.org/xapi/cmi5/context/extensions/masteryscore';
27
+
28
+ export type CMI5MoveOn =
29
+ | 'Passed'
30
+ | 'Completed'
31
+ | 'CompletedAndPassed'
32
+ | 'CompletedOrPassed'
33
+ | 'NotApplicable';
34
+
35
+ const VALID_MOVE_ON: ReadonlySet<CMI5MoveOn> = new Set([
36
+ 'Passed',
37
+ 'Completed',
38
+ 'CompletedAndPassed',
39
+ 'CompletedOrPassed',
40
+ 'NotApplicable',
41
+ ]);
42
+
24
43
  /**
25
44
  * CMI5 persistence adapter using xAPI.
26
45
  *
@@ -50,8 +69,16 @@ export class CMI5Adapter implements PersistenceAdapter {
50
69
  #completedSent = false;
51
70
  #completionStatus: 'incomplete' | 'complete' = 'incomplete';
52
71
  #successSent = false;
72
+ #passed = false;
53
73
  #terminated = false;
54
74
 
75
+ // cmi5 §8 launch params. masteryScore (when present) overrides the
76
+ // course's manifest passingScore for this launch — the LMS is the
77
+ // authority. moveOn drives the optional Satisfied statement (§9.5.3).
78
+ #masteryScore: number | null = null;
79
+ #moveOn: CMI5MoveOn = 'NotApplicable';
80
+ #satisfiedSent = false;
81
+
55
82
  async init(): Promise<void> {
56
83
  const params = new URLSearchParams(window.location.search);
57
84
  const fetchUrl = params.get('fetch');
@@ -63,6 +90,29 @@ export class CMI5Adapter implements PersistenceAdapter {
63
90
  this.#registration = reg ? reg : undefined;
64
91
  this.#activityId = params.get('activityId') || '';
65
92
 
93
+ const rawMastery = params.get('masteryScore');
94
+ if (rawMastery !== null && rawMastery !== '') {
95
+ const m = Number(rawMastery);
96
+ if (Number.isFinite(m) && m >= 0 && m <= 1) {
97
+ this.#masteryScore = m;
98
+ } else {
99
+ console.warn(
100
+ `Tessera cmi5: launch parameter 'masteryScore' is not a decimal in [0,1] (got "${rawMastery}"); ignoring.`
101
+ );
102
+ }
103
+ }
104
+
105
+ const rawMoveOn = params.get('moveOn');
106
+ if (rawMoveOn !== null && rawMoveOn !== '') {
107
+ if (VALID_MOVE_ON.has(rawMoveOn as CMI5MoveOn)) {
108
+ this.#moveOn = rawMoveOn as CMI5MoveOn;
109
+ } else {
110
+ console.warn(
111
+ `Tessera cmi5: launch parameter 'moveOn' is not a recognized value (got "${rawMoveOn}"); defaulting to NotApplicable.`
112
+ );
113
+ }
114
+ }
115
+
66
116
  // Malformed actor JSON is a launch-time failure: an empty {} actor
67
117
  // would fail every Identified-Agent check downstream and produce
68
118
  // confusing 400s on every send. Fail loud here instead.
@@ -156,6 +206,21 @@ export class CMI5Adapter implements PersistenceAdapter {
156
206
  return this.#publisher;
157
207
  }
158
208
 
209
+ /**
210
+ * LMS-supplied masteryScore from the cmi5 launch URL (a decimal in
211
+ * [0, 1]), or null when omitted. When present, the runtime should treat
212
+ * it as the authoritative pass threshold for this session, overriding
213
+ * `course.config.js scoring.passingScore`.
214
+ */
215
+ getMasteryScore(): number | null {
216
+ return this.#masteryScore;
217
+ }
218
+
219
+ /** LMS-supplied moveOn criterion (defaults to "NotApplicable"). */
220
+ getMoveOn(): CMI5MoveOn {
221
+ return this.#moveOn;
222
+ }
223
+
159
224
  getState(): SavedState | null {
160
225
  return this.#state;
161
226
  }
@@ -197,15 +262,18 @@ export class CMI5Adapter implements PersistenceAdapter {
197
262
  .sendStatement({
198
263
  verb: { id: VERBS.completed, display: { 'en-US': 'completed' } },
199
264
  result,
265
+ context: this.#masteryContext(),
200
266
  })
201
267
  .catch((err) => {
202
268
  console.warn('Tessera cmi5: failed to send Completed statement', err);
203
269
  });
270
+ this.#maybeSendSatisfied();
204
271
  }
205
272
 
206
273
  setSuccessStatus(status: 'passed' | 'failed' | 'unknown'): void {
207
274
  if (status === 'unknown' || this.#successSent || !this.#publisher) return;
208
275
  this.#successSent = true;
276
+ this.#passed = status === 'passed';
209
277
 
210
278
  const verb = status === 'passed' ? VERBS.passed : VERBS.failed;
211
279
  const verbName = status === 'passed' ? 'passed' : 'failed';
@@ -220,10 +288,12 @@ export class CMI5Adapter implements PersistenceAdapter {
220
288
  .sendStatement({
221
289
  verb: { id: verb, display: { 'en-US': verbName } },
222
290
  result,
291
+ context: this.#masteryContext(),
223
292
  })
224
293
  .catch((err) => {
225
294
  console.warn(`Tessera cmi5: failed to send ${verbName} statement`, err);
226
295
  });
296
+ this.#maybeSendSatisfied();
227
297
  }
228
298
 
229
299
  setDuration(seconds: number): void {
@@ -305,6 +375,56 @@ export class CMI5Adapter implements PersistenceAdapter {
305
375
 
306
376
  // ---- Private helpers ----
307
377
 
378
+ /**
379
+ * Build a context object carrying the cmi5 masteryscore extension when
380
+ * the LMS provided one. Returns undefined otherwise so the publisher
381
+ * doesn't add an empty `context.extensions` block.
382
+ */
383
+ #masteryContext(): Record<string, unknown> | undefined {
384
+ if (this.#masteryScore === null) return undefined;
385
+ return {
386
+ extensions: { [CMI5_MASTERYSCORE_EXT]: this.#masteryScore },
387
+ };
388
+ }
389
+
390
+ /**
391
+ * cmi5 §9.5.3: when the moveOn criterion has been met, the AU MAY send
392
+ * a Satisfied statement so LMSes that don't compute moveOn themselves
393
+ * still see satisfaction. NotApplicable disables emission entirely.
394
+ */
395
+ #maybeSendSatisfied(): void {
396
+ if (this.#satisfiedSent || !this.#publisher) return;
397
+ if (this.#moveOn === 'NotApplicable') return;
398
+
399
+ let satisfied = false;
400
+ switch (this.#moveOn) {
401
+ case 'Passed':
402
+ satisfied = this.#passed;
403
+ break;
404
+ case 'Completed':
405
+ satisfied = this.#completedSent;
406
+ break;
407
+ case 'CompletedAndPassed':
408
+ satisfied = this.#completedSent && this.#passed;
409
+ break;
410
+ case 'CompletedOrPassed':
411
+ satisfied = this.#completedSent || this.#passed;
412
+ break;
413
+ }
414
+ if (!satisfied) return;
415
+
416
+ this.#satisfiedSent = true;
417
+ this.#publisher
418
+ .sendStatement({
419
+ verb: { id: VERBS.satisfied, display: { 'en-US': 'satisfied' } },
420
+ result: { duration: formatISO8601Duration(this.#durationSeconds) },
421
+ context: this.#masteryContext(),
422
+ })
423
+ .catch((err) => {
424
+ console.warn('Tessera cmi5: failed to send Satisfied statement', err);
425
+ });
426
+ }
427
+
308
428
  #buildStateUrl(): string {
309
429
  const agentJson = JSON.stringify(this.#actor);
310
430
  const params = new URLSearchParams({
@@ -9,6 +9,16 @@ export interface ScormDialect<TApi> {
9
9
  sessionTimeKey: string;
10
10
  /** Format `seconds` for the session-time field — HHMMSS for 1.2, ISO8601 for 2004. */
11
11
  formatDuration(seconds: number): string;
12
+ /**
13
+ * Per-spec maximum byte length for `cmi.suspend_data` (SCORM 1.2 RTE
14
+ * §3.4.5.2 = 4096; SCORM 2004 4E §4.2 = 64000). Used by `saveState` to
15
+ * warn once when the serialized payload would be silently truncated by
16
+ * the LMS. Treated as "characters" since SCORM data-model lengths are
17
+ * specified in characters and Tessera stores ASCII-safe JSON.
18
+ */
19
+ suspendDataLimit: number;
20
+ /** Human label for the limit warning, e.g. "SCORM 1.2 (4096 chars)". */
21
+ suspendDataLimitLabel: string;
12
22
  /** Per-interaction-row field config passed to `buildScormInteractionFields`. */
13
23
  interactionFields: {
14
24
  responseField: 'student_response' | 'learner_response';
@@ -34,6 +44,7 @@ export abstract class BaseScormAdapter<TApi> implements PersistenceAdapter {
34
44
  protected readonly queue = new WriteQueue();
35
45
  #state: SavedState | null = null;
36
46
  #terminated = false;
47
+ #suspendOverflowWarned = false;
37
48
  protected interactionCount = 0;
38
49
 
39
50
  constructor(api: TApi, dialect: ScormDialect<TApi>) {
@@ -84,6 +95,19 @@ export abstract class BaseScormAdapter<TApi> implements PersistenceAdapter {
84
95
  saveState(state: SavedState): void {
85
96
  this.#state = state;
86
97
  const json = JSON.stringify(state);
98
+ if (
99
+ !this.#suspendOverflowWarned &&
100
+ json.length > this.dialect.suspendDataLimit
101
+ ) {
102
+ this.#suspendOverflowWarned = true;
103
+ console.warn(
104
+ `Tessera: cmi.suspend_data is ${json.length} chars, over the ` +
105
+ `${this.dialect.suspendDataLimitLabel} limit. The LMS will likely ` +
106
+ `truncate it and the next resume will lose state. Reduce ` +
107
+ `usePersistence() payloads or switch export.standard to a ` +
108
+ `larger-limit standard (scorm2004/cmi5).`
109
+ );
110
+ }
87
111
  this.queue.enqueue(() =>
88
112
  this.dialect.setValue(this.api, 'cmi.suspend_data', json)
89
113
  );
@@ -19,6 +19,8 @@ export interface SCORM12API {
19
19
  const SCORM12_DIALECT: ScormDialect<SCORM12API> = {
20
20
  sessionTimeKey: 'cmi.core.session_time',
21
21
  formatDuration: formatHHMMSS,
22
+ suspendDataLimit: 4096,
23
+ suspendDataLimitLabel: 'SCORM 1.2 cmi.suspend_data 4096-char',
22
24
  interactionFields: {
23
25
  responseField: 'student_response',
24
26
  timestampField: 'time',
@@ -18,6 +18,8 @@ export interface SCORM2004API {
18
18
  const SCORM2004_DIALECT: ScormDialect<SCORM2004API> = {
19
19
  sessionTimeKey: 'cmi.session_time',
20
20
  formatDuration: formatISO8601Duration,
21
+ suspendDataLimit: 64000,
22
+ suspendDataLimitLabel: 'SCORM 2004 4E cmi.suspend_data 64000-char',
21
23
  interactionFields: {
22
24
  responseField: 'learner_response',
23
25
  timestampField: 'timestamp',
@@ -191,6 +191,45 @@ export function useProgress() {
191
191
  };
192
192
  }
193
193
 
194
+ // One dev warning per session, regardless of caller count.
195
+ let warnedNonManualCompletion = false;
196
+
197
+ /** Test-only: reset the once-per-session warning latch. */
198
+ export function __resetUseCompletionWarning(): void {
199
+ warnedNonManualCompletion = false;
200
+ }
201
+
202
+ /**
203
+ * Trigger course completion from any component, and reactively read the
204
+ * current completion status. Active under `completion.mode: "manual"`; a
205
+ * no-op (with a one-shot dev warning) under any other mode.
206
+ */
207
+ export function useCompletion(): {
208
+ markComplete(): void;
209
+ readonly completionStatus: 'incomplete' | 'complete';
210
+ } {
211
+ const { progress, manifest, config } = requireNavContext('useCompletion()');
212
+ return {
213
+ markComplete() {
214
+ if (config.completion.mode !== 'manual') {
215
+ if (import.meta.env?.DEV && !warnedNonManualCompletion) {
216
+ warnedNonManualCompletion = true;
217
+ console.warn(
218
+ "Tessera: useCompletion().markComplete() ignored — completion.mode is not 'manual'. " +
219
+ '(This warning is shown once per session.)'
220
+ );
221
+ }
222
+ return;
223
+ }
224
+ progress.markCompleteManually();
225
+ progress.recalculateSuccess(manifest, config);
226
+ },
227
+ get completionStatus() {
228
+ return progress.completionStatus;
229
+ },
230
+ };
231
+ }
232
+
194
233
  /**
195
234
  * Scoped persistence — save and restore per-widget state that survives reload.
196
235
  * Routes to whichever adapter the course is running under (localStorage, SCORM
@@ -53,4 +53,6 @@ export interface SavedState {
53
53
  s?: Record<string, Record<string, number>>;
54
54
  /** Graded standalone page indices — pages with at least one graded standalone question */
55
55
  gs?: number[];
56
+ /** Manual completion latch. 1 if the learner triggered manual completion. Absent otherwise. */
57
+ m?: 1;
56
58
  }