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.
- package/AGENTS.md +110 -4
- package/dist/plugin/index.js +88 -11
- package/dist/plugin/index.js.map +1 -1
- package/package.json +1 -1
- 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/AGENTS.md
CHANGED
|
@@ -88,7 +88,7 @@ Pages listed in `pages` come first in listed order; any unlisted `.svelte` files
|
|
|
88
88
|
There are five:
|
|
89
89
|
|
|
90
90
|
1. **Built-in components**: `Callout`, `Image`, `MultipleChoice`, `FillInTheBlank`, `Matching`, `Sorting`, etc., from `tessera-learn`. Use, compose, or skip.
|
|
91
|
-
2. **Hooks**: `useQuestion`, `useQuiz`, `useNavigation`, `useProgress`, `usePersistence`. The stable contract between custom widgets and the runtime. Anything the built-ins do, you can do.
|
|
91
|
+
2. **Hooks**: `useQuestion`, `useQuiz`, `useNavigation`, `useProgress`, `useCompletion`, `usePersistence`. The stable contract between custom widgets and the runtime. Anything the built-ins do, you can do.
|
|
92
92
|
3. **Custom layout**: drop `layout.svelte` at the project root to replace the default chrome.
|
|
93
93
|
4. **Custom quiz shell**: drop `quiz.svelte` at the project root to replace the built-in quiz UI for every page that has `pageConfig.quiz`. Authors call `useQuiz()` for state and dispatch; question widgets continue to register through `useQuestion`.
|
|
94
94
|
5. **Custom xAPI**: `useXAPI()` returns a publisher for emitting your own xAPI verbs to one or more LRSes. See [Custom xAPI statements](#custom-xapi-statements).
|
|
@@ -404,6 +404,95 @@ Standalone questions are not graded by default. To grade one (e.g., a required r
|
|
|
404
404
|
|
|
405
405
|
---
|
|
406
406
|
|
|
407
|
+
## Manual completion
|
|
408
|
+
|
|
409
|
+
`completion.mode: "manual"` is for courses where the author — not a quiz score or a page-visit ratio — owns the moment of completion. Two examples:
|
|
410
|
+
|
|
411
|
+
- A short policy briefing where reading the final page **is** the proof of completion.
|
|
412
|
+
- A compliance "click to acknowledge" button at the end of a module.
|
|
413
|
+
|
|
414
|
+
Under manual mode, **both** triggers below are always active. First-to-fire wins; subsequent calls are idempotent.
|
|
415
|
+
|
|
416
|
+
### Trigger A: page frontmatter
|
|
417
|
+
|
|
418
|
+
Declare `completesOn: "view"` on any page. Completion fires the moment that page renders.
|
|
419
|
+
|
|
420
|
+
```svelte
|
|
421
|
+
<!-- pages/05-summary/finale.svelte -->
|
|
422
|
+
<script module>
|
|
423
|
+
export const pageConfig = {
|
|
424
|
+
title: "You're done",
|
|
425
|
+
completesOn: "view",
|
|
426
|
+
};
|
|
427
|
+
</script>
|
|
428
|
+
|
|
429
|
+
<h1>Thanks for completing the briefing.</h1>
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
`completesOn` accepts the literal string `"view"` (only value in v1). The page is marked visited and completion fires in the same effect — the LMS sees one `setCompletionStatus("complete")` immediately after the page renders.
|
|
433
|
+
|
|
434
|
+
### Trigger B: runtime hook
|
|
435
|
+
|
|
436
|
+
```svelte
|
|
437
|
+
<script>
|
|
438
|
+
import { useCompletion } from 'tessera-learn';
|
|
439
|
+
|
|
440
|
+
const { markComplete, completionStatus } = useCompletion();
|
|
441
|
+
</script>
|
|
442
|
+
|
|
443
|
+
<button
|
|
444
|
+
onclick={() => markComplete()}
|
|
445
|
+
disabled={completionStatus === 'complete'}
|
|
446
|
+
>
|
|
447
|
+
I acknowledge
|
|
448
|
+
</button>
|
|
449
|
+
|
|
450
|
+
{#if completionStatus === 'complete'}
|
|
451
|
+
<p>Recorded. You may now close this window.</p>
|
|
452
|
+
{/if}
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
Composes cleanly with custom widgets, modal close handlers, video-ended events, timer expirations, etc. Calling `markComplete()` outside `completion.mode: "manual"` is a no-op with a one-shot dev warning per session — safe to leave in shared components.
|
|
456
|
+
|
|
457
|
+
### `completion.trigger` (build-time check)
|
|
458
|
+
|
|
459
|
+
Optional. Set to `"page"` to fail the build when no page declares `completesOn: "view"`. Useful when the page-view path is load-bearing and a typo should fail the build, not the launch. Both triggers still work either way; the field only adds a static check.
|
|
460
|
+
|
|
461
|
+
```js
|
|
462
|
+
completion: { mode: "manual", trigger: "page" }
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
When omitted, the dev runtime warns once after 60 s if completion has not fired — a safety net that covers both "no `completesOn` page exists" and "the hook is never called" cases.
|
|
466
|
+
|
|
467
|
+
### Success status
|
|
468
|
+
|
|
469
|
+
By default `successStatus` stays `"unknown"` under manual — the LMS sees completion without a pass/fail verdict. If you want completion **and** an automatic pass (typical for "acknowledge" flows):
|
|
470
|
+
|
|
471
|
+
```js
|
|
472
|
+
completion: { mode: "manual", requireSuccessStatus: "passed" } // or "failed"
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
| Adapter | What the LMS sees on `markComplete()` (no `requireSuccessStatus`) |
|
|
476
|
+
| -------------- | ------------------------------------------------------------------ |
|
|
477
|
+
| SCORM 1.2 | `cmi.core.lesson_status = "completed"` |
|
|
478
|
+
| SCORM 2004 4th | `cmi.completion_status = "completed"`, `cmi.success_status = "unknown"` |
|
|
479
|
+
| cmi5 | **Completed** statement (no Passed / Failed) |
|
|
480
|
+
| web | `localStorage` only |
|
|
481
|
+
|
|
482
|
+
With `requireSuccessStatus: "passed"`, SCORM 1.2 writes `lesson_status = "passed"`, SCORM 2004 writes `success_status = "passed"`, and cmi5 emits a **Passed** statement alongside **Completed**.
|
|
483
|
+
|
|
484
|
+
### Quizzes under manual mode
|
|
485
|
+
|
|
486
|
+
A graded quiz under `mode: "manual"` reports its score to the LMS gradebook but does **not** drive completion or success — `markComplete()` / `completesOn` does. The build emits a warning to make this explicit. Set `graded: false` (or remove the quiz) if that's not what you want.
|
|
487
|
+
|
|
488
|
+
### Non-goals
|
|
489
|
+
|
|
490
|
+
- Combining manual + quiz/percentage rules ("complete when X **and** quiz passed"). Use a `useCompletion()` call inside a custom `$effect` if you need conditional logic.
|
|
491
|
+
- Per-learner conditional completion expressed in config — same answer: do it in a component with `useCompletion()`.
|
|
492
|
+
- Marking a course **incomplete** after it has been completed. Completion is monotonic in every spec we target. The runtime ignores re-marks.
|
|
493
|
+
|
|
494
|
+
---
|
|
495
|
+
|
|
407
496
|
## Assets
|
|
408
497
|
|
|
409
498
|
Drop files into `assets/`. Reference them with `$assets/` in component props:
|
|
@@ -486,12 +575,14 @@ export default {
|
|
|
486
575
|
},
|
|
487
576
|
|
|
488
577
|
completion: {
|
|
489
|
-
mode: "percentage", // "percentage"
|
|
578
|
+
mode: "percentage", // "percentage" | "quiz" | "manual"
|
|
490
579
|
percentageThreshold: 100, // 0–100 (percentage mode)
|
|
580
|
+
// trigger: "page", // (manual only) opt into build-time check
|
|
581
|
+
// requireSuccessStatus: "passed", // (manual only) "passed" | "failed"
|
|
491
582
|
},
|
|
492
583
|
|
|
493
584
|
scoring: {
|
|
494
|
-
passingScore: 70,
|
|
585
|
+
passingScore: 70, // optional under "manual" (defaults to 0)
|
|
495
586
|
},
|
|
496
587
|
|
|
497
588
|
export: {
|
|
@@ -504,6 +595,7 @@ export default {
|
|
|
504
595
|
- `navigation.mode: "sequential"` → pages unlock one at a time as each is completed.
|
|
505
596
|
- `completion.mode: "percentage"` → course completes when `visitedPages / totalPages * 100 >= percentageThreshold`.
|
|
506
597
|
- `completion.mode: "quiz"` → course completes when graded quiz average >= `scoring.passingScore`.
|
|
598
|
+
- `completion.mode: "manual"` → course completes when an author-declared trigger fires: a page declares `pageConfig.completesOn: "view"`, or any component calls `useCompletion().markComplete()`. First-to-fire wins. `scoring.passingScore` is optional (defaults to 0). See [Manual completion](#manual-completion).
|
|
507
599
|
|
|
508
600
|
### Minimum config
|
|
509
601
|
|
|
@@ -568,7 +660,7 @@ The Vite plugin runs project validation on every dev start and build (manifest s
|
|
|
568
660
|
|
|
569
661
|
## Hooks Reference
|
|
570
662
|
|
|
571
|
-
|
|
663
|
+
Six hooks plus one helper make up the stable contract between widgets and the runtime.
|
|
572
664
|
|
|
573
665
|
```js
|
|
574
666
|
import {
|
|
@@ -576,6 +668,7 @@ import {
|
|
|
576
668
|
useQuiz,
|
|
577
669
|
useNavigation,
|
|
578
670
|
useProgress,
|
|
671
|
+
useCompletion,
|
|
579
672
|
usePersistence,
|
|
580
673
|
isCorrect,
|
|
581
674
|
} from 'tessera-learn';
|
|
@@ -704,6 +797,18 @@ function useProgress(): {
|
|
|
704
797
|
};
|
|
705
798
|
```
|
|
706
799
|
|
|
800
|
+
### `useCompletion`
|
|
801
|
+
|
|
802
|
+
Trigger course completion from any component, and reactively read the current completion status. Active under `completion.mode: "manual"`; in any other mode `markComplete()` is a no-op with a one-shot dev warning. See [Manual completion](#manual-completion).
|
|
803
|
+
|
|
804
|
+
```ts
|
|
805
|
+
function useCompletion(): {
|
|
806
|
+
/** Idempotent — only the first call per session has an effect. */
|
|
807
|
+
markComplete(): void;
|
|
808
|
+
readonly completionStatus: 'incomplete' | 'complete';
|
|
809
|
+
};
|
|
810
|
+
```
|
|
811
|
+
|
|
707
812
|
### `usePersistence<T>(key)`
|
|
708
813
|
|
|
709
814
|
Per-widget persistent state. Survives reload on every adapter: `localStorage` for web, SCORM `cmi.suspend_data` for SCORM 1.2/2004, xAPI State API for cmi5. Reads sync; writes batched by the adapter. JSON-serializable values only.
|
|
@@ -882,6 +987,7 @@ The runtime translates author intent (page visits, quiz scores, completion, pers
|
|
|
882
987
|
| State persisted (page visited, bookmark moved, chunk revealed, `usePersistence` write, etc.) | `LMSSetValue("cmi.suspend_data", json)` (microtask-coalesced) | `SetValue("cmi.suspend_data", json)` (microtask-coalesced) | State API `PUT` `tessera-state` document, chained on the publisher queue |
|
|
883
988
|
| Graded quiz scored | `LMSSetValue("cmi.core.score.raw"\|"min"\|"max", …)` then `LMSSetValue("cmi.core.lesson_status", "passed"\|"failed")` | `SetValue("cmi.score.raw"\|"min"\|"max"\|"scaled", …)` then `SetValue("cmi.success_status", "passed"\|"failed")` | **Passed** or **Failed** statement, with `result.score.scaled` and `result.duration` (one-shot per session) |
|
|
884
989
|
| Course completion changes | Funneled into `cmi.core.lesson_status` (only one field exists) | `SetValue("cmi.completion_status", "completed"\|"incomplete")` | **Completed** statement with `result.completion = true`, `result.duration`, `result.score?` (one-shot per session) |
|
|
990
|
+
| Author marks complete (`completion.mode: "manual"`) | `cmi.core.lesson_status = "completed"` (or `"passed"`/`"failed"` if `requireSuccessStatus` set) | `cmi.completion_status = "completed"`; `cmi.success_status = "unknown"` (or `"passed"`/`"failed"` if `requireSuccessStatus` set) | **Completed** statement; **Passed**/**Failed** if `requireSuccessStatus` set |
|
|
885
991
|
| Question answered (graded or standalone, inside or outside a quiz) | `cmi.interactions.{n}.id` / `student_response` / `result` / `time` / `type` (n continues from prior `_count`) | `cmi.interactions.{n}.id` / `learner_response` / `result` / `timestamp` / `type` (n continues from prior `_count`) | **Answered** statement; object `${activityId}#${questionId}`, definition `cmi.interaction` + `interactionType`, `result.response`, `result.success` |
|
|
886
992
|
| Resume after reload | Read `cmi.suspend_data` on init; manifest is rebuilt from code, not LMS | Read `cmi.suspend_data` on init | State API `GET` `tessera-state`; lifecycle replays from where the prior session left off |
|
|
887
993
|
| Author exit / unload | `LMSSetValue("cmi.core.exit", "suspend"\|"")`, `LMSCommit("")`, `LMSFinish("")` (queue drained synchronously) | `SetValue("cmi.exit", "suspend"\|"normal"\|...)`, `Commit("")`, `Terminate("")` (queue drained synchronously) | If course not yet **Completed**, send **Suspended**; then **Terminated** (always last on the wire, cmi5 §9.3.6) |
|
package/dist/plugin/index.js
CHANGED
|
@@ -207,7 +207,8 @@ function generateManifest(pagesDir) {
|
|
|
207
207
|
title: pageConfig.title || titleCase(pageSlug),
|
|
208
208
|
slug: pageSlug,
|
|
209
209
|
importPath: relativePath,
|
|
210
|
-
quiz: pageConfig.quiz || null
|
|
210
|
+
quiz: pageConfig.quiz || null,
|
|
211
|
+
...pageConfig.completesOn === "view" ? { completesOn: "view" } : {}
|
|
211
212
|
};
|
|
212
213
|
lesson.pages.push(page);
|
|
213
214
|
flatPages.push(page);
|
|
@@ -305,13 +306,19 @@ const KNOWN_CONFIG_FIELDS = new Set([
|
|
|
305
306
|
"xapi"
|
|
306
307
|
]);
|
|
307
308
|
const VALID_NAV_MODES = ["free", "sequential"];
|
|
308
|
-
const VALID_COMPLETION_MODES = [
|
|
309
|
+
const VALID_COMPLETION_MODES = [
|
|
310
|
+
"quiz",
|
|
311
|
+
"percentage",
|
|
312
|
+
"manual"
|
|
313
|
+
];
|
|
309
314
|
const VALID_EXPORT_STANDARDS = [
|
|
310
315
|
"web",
|
|
311
316
|
"scorm12",
|
|
312
317
|
"scorm2004",
|
|
313
318
|
"cmi5"
|
|
314
319
|
];
|
|
320
|
+
const VALID_MANUAL_TRIGGERS = ["page"];
|
|
321
|
+
const VALID_REQUIRE_SUCCESS_STATUS = ["passed", "failed"];
|
|
315
322
|
/**
|
|
316
323
|
* Validate a Tessera project at the given root.
|
|
317
324
|
* Returns errors (block build) and warnings (informational).
|
|
@@ -355,7 +362,15 @@ function parseConfig(configPath, errors, warnings) {
|
|
|
355
362
|
if (!VALID_NAV_MODES.includes(config.navigation.mode)) errors.push(`course.config.js: "navigation.mode" must be "free" or "sequential", got "${config.navigation.mode}"`);
|
|
356
363
|
}
|
|
357
364
|
if (config.completion?.mode !== void 0) {
|
|
358
|
-
if (!VALID_COMPLETION_MODES.includes(config.completion.mode)) errors.push(`course.config.js: "completion.mode" must be "quiz" or "
|
|
365
|
+
if (!VALID_COMPLETION_MODES.includes(config.completion.mode)) errors.push(`course.config.js: "completion.mode" must be "quiz", "percentage", or "manual", got "${config.completion.mode}"`);
|
|
366
|
+
}
|
|
367
|
+
if (config.completion?.trigger !== void 0) {
|
|
368
|
+
if (config.completion.mode !== "manual") warnings.push(`course.config.js: "completion.trigger" is ignored unless completion.mode is "manual"`);
|
|
369
|
+
else if (!VALID_MANUAL_TRIGGERS.includes(config.completion.trigger)) errors.push(`course.config.js: "completion.trigger" must be "page" or omitted, got "${config.completion.trigger}"`);
|
|
370
|
+
}
|
|
371
|
+
if (config.completion?.requireSuccessStatus !== void 0) {
|
|
372
|
+
if (config.completion.mode !== "manual") warnings.push(`course.config.js: "completion.requireSuccessStatus" is ignored unless completion.mode is "manual"`);
|
|
373
|
+
else if (!VALID_REQUIRE_SUCCESS_STATUS.includes(config.completion.requireSuccessStatus)) errors.push(`course.config.js: "completion.requireSuccessStatus" must be "passed" or "failed" (omit for "unknown"), got "${config.completion.requireSuccessStatus}"`);
|
|
359
374
|
}
|
|
360
375
|
if (config.export?.standard !== void 0) {
|
|
361
376
|
if (!VALID_EXPORT_STANDARDS.includes(config.export.standard)) errors.push(`course.config.js: "export.standard" must be "web", "scorm12", "scorm2004", or "cmi5", got "${config.export.standard}"`);
|
|
@@ -490,6 +505,7 @@ function validateSingleXAPIEntry(entry, label, standard, errors, warnings) {
|
|
|
490
505
|
function validatePages(pagesDir, assetsDir, projectRoot) {
|
|
491
506
|
const errors = [];
|
|
492
507
|
const warnings = [];
|
|
508
|
+
const pages = [];
|
|
493
509
|
let totalPages = 0;
|
|
494
510
|
let totalQuizzes = 0;
|
|
495
511
|
let hasGradedQuiz = false;
|
|
@@ -501,7 +517,8 @@ function validatePages(pagesDir, assetsDir, projectRoot) {
|
|
|
501
517
|
warnings,
|
|
502
518
|
totalPages: 0,
|
|
503
519
|
totalQuizzes: 0,
|
|
504
|
-
hasGradedQuiz: false
|
|
520
|
+
hasGradedQuiz: false,
|
|
521
|
+
pages
|
|
505
522
|
};
|
|
506
523
|
}
|
|
507
524
|
const topLevelEntries = readdirSync(pagesDir);
|
|
@@ -522,7 +539,8 @@ function validatePages(pagesDir, assetsDir, projectRoot) {
|
|
|
522
539
|
warnings,
|
|
523
540
|
totalPages: 0,
|
|
524
541
|
totalQuizzes: 0,
|
|
525
|
-
hasGradedQuiz: false
|
|
542
|
+
hasGradedQuiz: false,
|
|
543
|
+
pages
|
|
526
544
|
};
|
|
527
545
|
}
|
|
528
546
|
for (const sectionName of sectionDirs) {
|
|
@@ -546,12 +564,25 @@ function validatePages(pagesDir, assetsDir, projectRoot) {
|
|
|
546
564
|
const fileRel = relative(projectRoot, filePath);
|
|
547
565
|
const content = readSourceFileCached(filePath);
|
|
548
566
|
const pageConfig = validatePageConfig(content, fileRel, errors);
|
|
567
|
+
const navIndex = totalPages;
|
|
549
568
|
totalPages++;
|
|
569
|
+
let pageHasGradedQuiz = false;
|
|
550
570
|
if (pageConfig?.quiz) {
|
|
551
571
|
totalQuizzes++;
|
|
552
572
|
validateQuizConfig(pageConfig.quiz, fileRel, errors);
|
|
553
|
-
if (pageConfig.quiz.graded === true)
|
|
573
|
+
if (pageConfig.quiz.graded === true) {
|
|
574
|
+
hasGradedQuiz = true;
|
|
575
|
+
pageHasGradedQuiz = true;
|
|
576
|
+
}
|
|
554
577
|
}
|
|
578
|
+
const completesOnView = validateCompletesOn(pageConfig, fileRel, errors);
|
|
579
|
+
pages.push({
|
|
580
|
+
fileRel,
|
|
581
|
+
navIndex,
|
|
582
|
+
hasGradedQuiz: pageHasGradedQuiz,
|
|
583
|
+
hasQuiz: !!pageConfig?.quiz,
|
|
584
|
+
completesOnView
|
|
585
|
+
});
|
|
555
586
|
validateAssetRefs(content, fileRel, assetsDir, warnings, assetExistsCache);
|
|
556
587
|
}
|
|
557
588
|
const lessonDirs = sectionEntries.filter((name) => {
|
|
@@ -581,12 +612,25 @@ function validatePages(pagesDir, assetsDir, projectRoot) {
|
|
|
581
612
|
const fileRel = relative(projectRoot, filePath);
|
|
582
613
|
const content = readSourceFileCached(filePath);
|
|
583
614
|
const pageConfig = validatePageConfig(content, fileRel, errors);
|
|
615
|
+
const navIndex = totalPages;
|
|
584
616
|
totalPages++;
|
|
617
|
+
let pageHasGradedQuiz = false;
|
|
585
618
|
if (pageConfig?.quiz) {
|
|
586
619
|
totalQuizzes++;
|
|
587
620
|
validateQuizConfig(pageConfig.quiz, fileRel, errors);
|
|
588
|
-
if (pageConfig.quiz.graded === true)
|
|
621
|
+
if (pageConfig.quiz.graded === true) {
|
|
622
|
+
hasGradedQuiz = true;
|
|
623
|
+
pageHasGradedQuiz = true;
|
|
624
|
+
}
|
|
589
625
|
}
|
|
626
|
+
const completesOnView = validateCompletesOn(pageConfig, fileRel, errors);
|
|
627
|
+
pages.push({
|
|
628
|
+
fileRel,
|
|
629
|
+
navIndex,
|
|
630
|
+
hasGradedQuiz: pageHasGradedQuiz,
|
|
631
|
+
hasQuiz: !!pageConfig?.quiz,
|
|
632
|
+
completesOnView
|
|
633
|
+
});
|
|
590
634
|
validateAssetRefs(content, fileRel, assetsDir, warnings, assetExistsCache);
|
|
591
635
|
}
|
|
592
636
|
}
|
|
@@ -597,7 +641,8 @@ function validatePages(pagesDir, assetsDir, projectRoot) {
|
|
|
597
641
|
warnings,
|
|
598
642
|
totalPages,
|
|
599
643
|
totalQuizzes,
|
|
600
|
-
hasGradedQuiz
|
|
644
|
+
hasGradedQuiz,
|
|
645
|
+
pages
|
|
601
646
|
};
|
|
602
647
|
}
|
|
603
648
|
function validateMetaFile(metaPath, parentRel, errors) {
|
|
@@ -624,6 +669,12 @@ function validatePageConfig(content, fileRel, errors) {
|
|
|
624
669
|
if (result.kind === "invalid") errors.push(`${fileRel}: pageConfig must be a static object literal (no variables, function calls, or computed values)`);
|
|
625
670
|
return null;
|
|
626
671
|
}
|
|
672
|
+
function validateCompletesOn(pageConfig, fileRel, errors) {
|
|
673
|
+
if (!pageConfig || pageConfig.completesOn === void 0) return false;
|
|
674
|
+
if (pageConfig.completesOn === "view") return true;
|
|
675
|
+
errors.push(`${fileRel}: pageConfig.completesOn must be "view", got ${JSON.stringify(pageConfig.completesOn)}`);
|
|
676
|
+
return false;
|
|
677
|
+
}
|
|
627
678
|
function validateQuizConfig(quiz, fileRel, errors) {
|
|
628
679
|
if (!quiz || typeof quiz !== "object") return;
|
|
629
680
|
const cfg = quiz;
|
|
@@ -655,6 +706,19 @@ function validateAssetRefs(content, fileRel, assetsDir, warnings, existsCache) {
|
|
|
655
706
|
}
|
|
656
707
|
function crossValidate(config, pageResults, errors, warnings) {
|
|
657
708
|
if (config.completion?.mode === "quiz" && !pageResults.hasGradedQuiz) errors.push("completion.mode is \"quiz\" but no pages have quiz config with graded: true");
|
|
709
|
+
const isManual = config.completion?.mode === "manual";
|
|
710
|
+
const completesOnPages = pageResults.pages.filter((p) => p.completesOnView);
|
|
711
|
+
if (isManual && config.completion?.trigger === "page" && completesOnPages.length === 0) errors.push("completion.mode is \"manual\" with trigger: \"page\", but no page declares pageConfig.completesOn: \"view\". Either add a completesOn page or remove the trigger field to drop the static check.");
|
|
712
|
+
if (isManual) {
|
|
713
|
+
for (const page of pageResults.pages) if (page.hasGradedQuiz) warnings.push(`${page.fileRel}: quiz.graded is true under completion.mode: "manual". The score will be reported to the LMS for transcripts, but it will not drive completion or success status — \`markComplete()\` / completesOn does. If that's not what you want, set graded: false or change completion.mode.`);
|
|
714
|
+
}
|
|
715
|
+
if (isManual && config.completion?.percentageThreshold !== void 0) warnings.push("course.config.js: \"completion.percentageThreshold\" is ignored under completion.mode: \"manual\"");
|
|
716
|
+
if (!isManual) for (const page of completesOnPages) warnings.push(`${page.fileRel}: pageConfig.completesOn is ignored — completion.mode is "${config.completion?.mode ?? "percentage"}"`);
|
|
717
|
+
for (const page of pageResults.pages) if (page.completesOnView && page.hasQuiz) warnings.push(`${page.fileRel}: completion fires on view, before the quiz can be answered — likely a mistake`);
|
|
718
|
+
if (isManual) {
|
|
719
|
+
const firstPage = pageResults.pages.find((p) => p.navIndex === 0);
|
|
720
|
+
if (firstPage?.completesOnView) warnings.push(`${firstPage.fileRel}: pageConfig.completesOn: "view" is on the first page — the course will complete immediately on launch, before the learner sees any other content.`);
|
|
721
|
+
}
|
|
658
722
|
if (config.export?.standard === "scorm12") {
|
|
659
723
|
let visitedChars = 0;
|
|
660
724
|
for (let i = 0; i < pageResults.totalPages; i++) visitedChars += String(i).length + 1;
|
|
@@ -1049,6 +1113,19 @@ mount(App, {
|
|
|
1049
1113
|
}
|
|
1050
1114
|
const VIRTUAL_CONFIG_ID = "virtual:tessera-config";
|
|
1051
1115
|
const RESOLVED_CONFIG_ID = "\0" + VIRTUAL_CONFIG_ID;
|
|
1116
|
+
function completionDefaults(mode) {
|
|
1117
|
+
if (mode === "manual") return {
|
|
1118
|
+
completion: { mode: "manual" },
|
|
1119
|
+
passingScore: 0
|
|
1120
|
+
};
|
|
1121
|
+
return {
|
|
1122
|
+
completion: {
|
|
1123
|
+
mode: "percentage",
|
|
1124
|
+
percentageThreshold: 100
|
|
1125
|
+
},
|
|
1126
|
+
passingScore: 70
|
|
1127
|
+
};
|
|
1128
|
+
}
|
|
1052
1129
|
function tesseraConfigPlugin() {
|
|
1053
1130
|
let projectRoot;
|
|
1054
1131
|
return {
|
|
@@ -1078,6 +1155,7 @@ function tesseraConfigPlugin() {
|
|
|
1078
1155
|
userConfig = JSON5.parse(objectStr);
|
|
1079
1156
|
} catch {}
|
|
1080
1157
|
}
|
|
1158
|
+
const { completion, passingScore } = completionDefaults(userConfig.completion?.mode);
|
|
1081
1159
|
const merged = {
|
|
1082
1160
|
title: userConfig.title || "Untitled Course",
|
|
1083
1161
|
...userConfig,
|
|
@@ -1086,12 +1164,11 @@ function tesseraConfigPlugin() {
|
|
|
1086
1164
|
...userConfig.navigation
|
|
1087
1165
|
},
|
|
1088
1166
|
completion: {
|
|
1089
|
-
|
|
1090
|
-
percentageThreshold: 100,
|
|
1167
|
+
...completion,
|
|
1091
1168
|
...userConfig.completion
|
|
1092
1169
|
},
|
|
1093
1170
|
scoring: {
|
|
1094
|
-
passingScore
|
|
1171
|
+
passingScore,
|
|
1095
1172
|
...userConfig.scoring
|
|
1096
1173
|
},
|
|
1097
1174
|
export: {
|