tessera-learn 0.0.3 → 0.0.5
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 +128 -12
- package/dist/plugin/index.js +91 -13
- package/dist/plugin/index.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +1 -0
- package/src/plugin/export.ts +6 -2
- 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 +441 -44
- 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/runtime/xapi/publisher.ts +24 -5
package/src/plugin/manifest.ts
CHANGED
|
@@ -13,6 +13,7 @@ export interface ManifestPage {
|
|
|
13
13
|
slug: string;
|
|
14
14
|
importPath: string;
|
|
15
15
|
quiz: QuizConfig | null;
|
|
16
|
+
completesOn?: 'view';
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
export interface ManifestLesson {
|
|
@@ -127,7 +128,7 @@ export type PageConfigParseResult =
|
|
|
127
128
|
/** No module script, or no `pageConfig =` export. Treat as "no config". */
|
|
128
129
|
| { kind: 'none' }
|
|
129
130
|
/** Found and successfully parsed. */
|
|
130
|
-
| { kind: 'ok'; value: { title?: string; quiz?: QuizConfig } }
|
|
131
|
+
| { kind: 'ok'; value: { title?: string; quiz?: QuizConfig; completesOn?: 'view' } }
|
|
131
132
|
/** Found but couldn't parse as a static object literal — non-literal RHS or JSON5 failure. */
|
|
132
133
|
| { kind: 'invalid' };
|
|
133
134
|
|
|
@@ -160,7 +161,7 @@ export function parsePageConfigFromSource(content: string): PageConfigParseResul
|
|
|
160
161
|
}
|
|
161
162
|
|
|
162
163
|
/** Extract pageConfig from a .svelte file. Throws on parse failure. */
|
|
163
|
-
export function extractPageConfig(filePath: string): { title?: string; quiz?: QuizConfig } {
|
|
164
|
+
export function extractPageConfig(filePath: string): { title?: string; quiz?: QuizConfig; completesOn?: 'view' } {
|
|
164
165
|
const result = parsePageConfigFromSource(readSourceFileCached(filePath));
|
|
165
166
|
if (result.kind === 'ok') return result.value;
|
|
166
167
|
if (result.kind === 'invalid') {
|
|
@@ -300,7 +301,7 @@ export function generateManifest(pagesDir: string): Manifest {
|
|
|
300
301
|
const filePath = resolve(lessonPath, fileName);
|
|
301
302
|
const pageSlug = deriveSlug(fileName, true);
|
|
302
303
|
|
|
303
|
-
let pageConfig: { title?: string; quiz?: QuizConfig } = {};
|
|
304
|
+
let pageConfig: { title?: string; quiz?: QuizConfig; completesOn?: 'view' } = {};
|
|
304
305
|
try {
|
|
305
306
|
pageConfig = extractPageConfig(filePath);
|
|
306
307
|
} catch (e) {
|
|
@@ -317,6 +318,7 @@ export function generateManifest(pagesDir: string): Manifest {
|
|
|
317
318
|
slug: pageSlug,
|
|
318
319
|
importPath: relativePath,
|
|
319
320
|
quiz: pageConfig.quiz || null,
|
|
321
|
+
...(pageConfig.completesOn === 'view' ? { completesOn: 'view' as const } : {}),
|
|
320
322
|
};
|
|
321
323
|
|
|
322
324
|
lesson.pages.push(page);
|
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);
|