tessera-learn 0.0.2 → 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.
- package/AGENTS.md +110 -4
- package/dist/plugin/index.js +88 -11
- package/dist/plugin/index.js.map +1 -1
- package/package.json +3 -2
- package/src/index.ts +1 -0
- package/src/plugin/index.ts +13 -2
- package/src/plugin/manifest.ts +5 -3
- package/src/plugin/validation.ts +137 -7
- package/src/runtime/App.svelte +66 -2
- package/src/runtime/adapters/cmi5.ts +120 -0
- package/src/runtime/adapters/scorm-base.ts +24 -0
- package/src/runtime/adapters/scorm12.ts +2 -0
- package/src/runtime/adapters/scorm2004.ts +2 -0
- package/src/runtime/hooks.svelte.ts +39 -0
- package/src/runtime/persistence.ts +2 -0
- package/src/runtime/progress.svelte.ts +25 -0
- package/src/runtime/types.ts +23 -4
package/src/plugin/validation.ts
CHANGED
|
@@ -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?: {
|
|
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 "
|
|
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
|
package/src/runtime/App.svelte
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|