tessera-learn 0.0.9 → 0.0.11
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/dist/plugin/cli.js +5 -3
- package/dist/plugin/cli.js.map +1 -1
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +215 -124
- package/dist/plugin/index.js.map +1 -1
- package/dist/{validation-BxWAMMnJ.js → validation-D9DXlqNP.js} +77 -65
- package/dist/validation-D9DXlqNP.js.map +1 -0
- package/package.json +9 -6
- package/src/components/Audio.svelte +5 -2
- package/src/components/DefaultLayout.svelte +2 -0
- package/src/components/FillInTheBlank.svelte +60 -98
- package/src/components/Image.svelte +3 -8
- package/src/components/LockedBanner.svelte +3 -4
- package/src/components/MultipleChoice.svelte +53 -94
- package/src/components/Quiz.svelte +2 -1
- package/src/components/Video.svelte +4 -2
- package/src/components/util.ts +1 -0
- package/src/plugin/cli.ts +2 -7
- package/src/plugin/export.ts +23 -41
- package/src/plugin/index.ts +197 -56
- package/src/plugin/layout.ts +6 -51
- package/src/plugin/manifest.ts +31 -5
- package/src/plugin/override-plugin.ts +68 -0
- package/src/plugin/quiz.ts +9 -54
- package/src/plugin/validation.ts +38 -67
- package/src/runtime/App.svelte +48 -36
- package/src/runtime/LoadingBar.svelte +47 -0
- package/src/runtime/Sidebar.svelte +2 -0
- package/src/runtime/adapters/cmi5.ts +13 -83
- package/src/runtime/adapters/format.ts +67 -0
- package/src/runtime/adapters/index.ts +28 -29
- package/src/runtime/adapters/retry.ts +0 -64
- package/src/runtime/adapters/scorm12.ts +1 -1
- package/src/runtime/adapters/scorm2004.ts +11 -16
- package/src/runtime/hooks.svelte.ts +14 -16
- package/src/runtime/navigation.svelte.ts +51 -45
- package/src/runtime/progress.svelte.ts +25 -10
- package/src/runtime/quiz-policy.ts +21 -178
- package/src/runtime/xapi/agent-rules.ts +7 -2
- package/src/runtime/xapi/publisher.ts +1 -11
- package/src/runtime/xapi/validation.ts +1 -1
- package/src/virtual.d.ts +13 -0
- package/styles/layout.css +34 -24
- package/dist/validation-BxWAMMnJ.js.map +0 -1
- package/src/runtime/LoadingSkeleton.svelte +0 -26
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Manifest } from '../plugin/manifest.js';
|
|
2
2
|
import type { CourseConfig } from './types.js';
|
|
3
3
|
import { ProgressState } from './progress.svelte.js';
|
|
4
|
+
import { resolveAccess } from './access.js';
|
|
4
5
|
|
|
5
6
|
export function isPageComplete(
|
|
6
7
|
index: number,
|
|
@@ -22,10 +23,13 @@ export function isPageComplete(
|
|
|
22
23
|
return (progress.quizScores.get(index) ?? 0) >= config.scoring.passingScore;
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
export type PageModuleMap = Record<string, () => Promise<unknown>>;
|
|
27
|
+
|
|
25
28
|
export class NavigationState {
|
|
26
29
|
manifest = $state<Manifest>(null!);
|
|
27
30
|
#progress: ProgressState;
|
|
28
31
|
#config: CourseConfig;
|
|
32
|
+
#pageModules: PageModuleMap | null = null;
|
|
29
33
|
currentPageIndex = $state(0);
|
|
30
34
|
|
|
31
35
|
canGoPrev = $derived(this.currentPageIndex > 0);
|
|
@@ -36,11 +40,24 @@ export class NavigationState {
|
|
|
36
40
|
return !this.isPageLocked(next);
|
|
37
41
|
});
|
|
38
42
|
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
#
|
|
43
|
+
// Memo cache so the derived can return a stable Set reference when
|
|
44
|
+
// membership is unchanged (two Sets with identical contents are not `===`).
|
|
45
|
+
// Must NOT be `$state` — that would make this a reactive-state mutation
|
|
46
|
+
// from inside a derived.
|
|
47
|
+
#prevLockedSet: Set<number> | null = null;
|
|
48
|
+
#lockedSet = $derived.by<Set<number>>(() => {
|
|
49
|
+
const next = this.#computeLockedSet();
|
|
50
|
+
const prev = this.#prevLockedSet;
|
|
51
|
+
if (prev && prev.size === next.size) {
|
|
52
|
+
let same = true;
|
|
53
|
+
for (const i of next) {
|
|
54
|
+
if (!prev.has(i)) { same = false; break; }
|
|
55
|
+
}
|
|
56
|
+
if (same) return prev;
|
|
57
|
+
}
|
|
58
|
+
this.#prevLockedSet = next;
|
|
59
|
+
return next;
|
|
60
|
+
});
|
|
44
61
|
|
|
45
62
|
constructor(manifest: Manifest, progress: ProgressState, config: CourseConfig) {
|
|
46
63
|
this.manifest = manifest;
|
|
@@ -48,6 +65,23 @@ export class NavigationState {
|
|
|
48
65
|
this.#config = config;
|
|
49
66
|
}
|
|
50
67
|
|
|
68
|
+
setPageModules(modules: PageModuleMap) {
|
|
69
|
+
this.#pageModules = modules;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Warm the browser module cache for a page chunk. Idempotent — repeated
|
|
74
|
+
* calls for the same index hit the existing cache. Bails on locked pages
|
|
75
|
+
* so callers don't need to guard.
|
|
76
|
+
*/
|
|
77
|
+
prefetch(index: number) {
|
|
78
|
+
if (!this.#pageModules) return;
|
|
79
|
+
if (index < 0 || index >= this.manifest.totalPages) return;
|
|
80
|
+
if (this.isPageLocked(index)) return;
|
|
81
|
+
const page = this.manifest.pages[index];
|
|
82
|
+
this.#pageModules[page.importPath]?.();
|
|
83
|
+
}
|
|
84
|
+
|
|
51
85
|
goToPage(index: number) {
|
|
52
86
|
if (index < 0 || index >= this.manifest.totalPages) return;
|
|
53
87
|
if (this.isPageLocked(index)) return;
|
|
@@ -66,50 +100,22 @@ export class NavigationState {
|
|
|
66
100
|
return this.#lockedSet.has(index);
|
|
67
101
|
}
|
|
68
102
|
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
// per-page evaluation since their semantics are arbitrary — but it still
|
|
73
|
-
// runs once per state change rather than once per page per render.
|
|
103
|
+
// Resolve the access predicate once (custom canAccess, or the free /
|
|
104
|
+
// sequential preset) and evaluate it per page. Runs once per state change
|
|
105
|
+
// — the presets are the single source of truth for the gating rules.
|
|
74
106
|
#computeLockedSet(): Set<number> {
|
|
75
107
|
const total = this.manifest.totalPages;
|
|
76
108
|
const locked = new Set<number>();
|
|
77
|
-
|
|
78
|
-
if (this.#config.navigation.canAccess) {
|
|
79
|
-
const fn = this.#config.navigation.canAccess;
|
|
80
|
-
for (let i = 0; i < total; i++) {
|
|
81
|
-
if (!fn({
|
|
82
|
-
pageIndex: i,
|
|
83
|
-
page: this.manifest.pages[i],
|
|
84
|
-
manifest: this.manifest,
|
|
85
|
-
progress: this.#progress,
|
|
86
|
-
config: this.#config,
|
|
87
|
-
})) {
|
|
88
|
-
locked.add(i);
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
return locked;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
if (this.#config.navigation.mode === 'sequential') {
|
|
95
|
-
// Once any page is incomplete, every later page is locked.
|
|
96
|
-
for (let i = 1; i < total; i++) {
|
|
97
|
-
if (!isPageComplete(i - 1, this.manifest, this.#progress, this.#config)) {
|
|
98
|
-
for (let k = i; k < total; k++) locked.add(k);
|
|
99
|
-
return locked;
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
return locked;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// Free mode: a page is locked iff its most-recent gating quiz is unmet.
|
|
106
|
-
let lastGatingUnmet = false;
|
|
109
|
+
const access = resolveAccess(this.#config);
|
|
107
110
|
for (let i = 0; i < total; i++) {
|
|
108
|
-
if (
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
111
|
+
if (!access({
|
|
112
|
+
pageIndex: i,
|
|
113
|
+
page: this.manifest.pages[i],
|
|
114
|
+
manifest: this.manifest,
|
|
115
|
+
progress: this.#progress,
|
|
116
|
+
config: this.#config,
|
|
117
|
+
})) {
|
|
118
|
+
locked.add(i);
|
|
113
119
|
}
|
|
114
120
|
}
|
|
115
121
|
return locked;
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
|
2
|
-
import type { Manifest } from '../plugin/manifest.js';
|
|
3
2
|
import type { CourseConfig } from './types.js';
|
|
4
3
|
|
|
5
4
|
export class ProgressState {
|
|
5
|
+
#quizGradedIndices: ReadonlySet<number>;
|
|
6
|
+
|
|
7
|
+
constructor(quizGradedIndices: ReadonlySet<number>) {
|
|
8
|
+
this.#quizGradedIndices = quizGradedIndices;
|
|
9
|
+
}
|
|
10
|
+
|
|
6
11
|
visitedPages = $state(new SvelteSet<number>());
|
|
7
12
|
quizScores = $state(new SvelteMap<number, number>());
|
|
8
13
|
/**
|
|
@@ -114,17 +119,17 @@ export class ProgressState {
|
|
|
114
119
|
return sum / pageMap.size;
|
|
115
120
|
}
|
|
116
121
|
|
|
117
|
-
recalculateCompletion(
|
|
122
|
+
recalculateCompletion(totalPages: number, config: CourseConfig) {
|
|
118
123
|
if (this.#manuallyCompleted) return;
|
|
119
124
|
if (config.completion.mode === 'manual') return;
|
|
120
125
|
if (config.completion.mode === 'percentage') {
|
|
121
126
|
const threshold = config.completion.percentageThreshold ?? 100;
|
|
122
|
-
const percent =
|
|
123
|
-
? (this.visitedPages.size /
|
|
127
|
+
const percent = totalPages > 0
|
|
128
|
+
? (this.visitedPages.size / totalPages) * 100
|
|
124
129
|
: 0;
|
|
125
130
|
this.completionStatus = percent >= threshold ? 'complete' : 'incomplete';
|
|
126
131
|
} else if (config.completion.mode === 'quiz') {
|
|
127
|
-
const { indices } = this.#gradedPages(
|
|
132
|
+
const { indices } = this.#gradedPages();
|
|
128
133
|
if (indices.length === 0) {
|
|
129
134
|
this.completionStatus = 'incomplete';
|
|
130
135
|
return;
|
|
@@ -134,7 +139,7 @@ export class ProgressState {
|
|
|
134
139
|
}
|
|
135
140
|
}
|
|
136
141
|
|
|
137
|
-
recalculateSuccess(
|
|
142
|
+
recalculateSuccess(config: CourseConfig) {
|
|
138
143
|
if (config.completion.mode === 'manual') {
|
|
139
144
|
const want = config.completion.requireSuccessStatus;
|
|
140
145
|
// Stay 'unknown' until manual mark fires, so a learner who never
|
|
@@ -143,7 +148,7 @@ export class ProgressState {
|
|
|
143
148
|
return;
|
|
144
149
|
}
|
|
145
150
|
|
|
146
|
-
const { indices, attempted } = this.#gradedPages(
|
|
151
|
+
const { indices, attempted } = this.#gradedPages();
|
|
147
152
|
|
|
148
153
|
if (indices.length === 0) {
|
|
149
154
|
this.successStatus = 'unknown';
|
|
@@ -158,14 +163,24 @@ export class ProgressState {
|
|
|
158
163
|
this.successStatus = average >= config.scoring.passingScore ? 'passed' : 'failed';
|
|
159
164
|
}
|
|
160
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Effective graded score for LMS reporting — same union and averaging as
|
|
168
|
+
* recalculateSuccess, so score and success status can't disagree.
|
|
169
|
+
*/
|
|
170
|
+
gradedScore(): { average: number; attempted: boolean } {
|
|
171
|
+
const { indices, attempted } = this.#gradedPages();
|
|
172
|
+
return { average: this.#gradedAverage(indices), attempted };
|
|
173
|
+
}
|
|
174
|
+
|
|
161
175
|
/**
|
|
162
176
|
* Union of pages that contribute to graded scoring: pageConfig graded quizzes
|
|
163
177
|
* plus pages with at least one graded standalone question (deduped).
|
|
164
178
|
* `attempted` is true if any of those pages has a recorded score.
|
|
165
179
|
*/
|
|
166
|
-
#gradedPages(
|
|
167
|
-
const
|
|
168
|
-
const
|
|
180
|
+
#gradedPages(): { indices: number[]; attempted: boolean } {
|
|
181
|
+
const merged = new Set(this.#quizGradedIndices);
|
|
182
|
+
for (const i of this.gradedStandalonePages) merged.add(i);
|
|
183
|
+
const indices = [...merged];
|
|
169
184
|
const attempted = indices.some(i => this.#hasScore(i));
|
|
170
185
|
return { indices, attempted };
|
|
171
186
|
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Quiz config desugaring. Authors
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* Quiz config desugaring. Authors pick feedback / retry behavior with string
|
|
3
|
+
* enums in `pageConfig.quiz`; this module normalizes them into predicates so
|
|
4
|
+
* `useQuiz` only ever interacts with the predicate API. Config is extracted
|
|
5
|
+
* from source as a static object literal (JSON5), so only the enum forms are
|
|
6
|
+
* representable — there are no function-valued options.
|
|
6
7
|
*/
|
|
7
8
|
import type { Interaction } from './interaction.js';
|
|
9
|
+
import type { QuizConfig } from './types.js';
|
|
8
10
|
|
|
9
11
|
export interface QuizQuestionResult {
|
|
10
12
|
/** The original interaction reported for the question. */
|
|
@@ -15,12 +17,7 @@ export interface QuizQuestionResult {
|
|
|
15
17
|
weight: number;
|
|
16
18
|
}
|
|
17
19
|
|
|
18
|
-
/**
|
|
19
|
-
* State the feedback predicate is given so it can decide independently of
|
|
20
|
-
* the string-enum branches inside `useQuiz`. The predicate is the single
|
|
21
|
-
* source of truth — the enums (`'immediate'` / `'review'`) desugar into
|
|
22
|
-
* predicates over this same state.
|
|
23
|
-
*/
|
|
20
|
+
/** State the feedback predicate decides over. */
|
|
24
21
|
export interface FeedbackVisibilityState {
|
|
25
22
|
/** Index of the question being asked about. */
|
|
26
23
|
questionIndex: number;
|
|
@@ -30,11 +27,7 @@ export interface FeedbackVisibilityState {
|
|
|
30
27
|
reviewing: boolean;
|
|
31
28
|
/** Has the question been answered (the shell called `setAnswer`)? */
|
|
32
29
|
hasAnswered: boolean;
|
|
33
|
-
/**
|
|
34
|
-
* Has the shell explicitly revealed feedback for this question via
|
|
35
|
-
* `revealFeedback(index)`? Lets `'immediate'` flows distinguish "answered
|
|
36
|
-
* but not yet revealed" from "Check Answer button pressed."
|
|
37
|
-
*/
|
|
30
|
+
/** Has the shell revealed feedback for this question via `revealFeedback`? */
|
|
38
31
|
revealed: boolean;
|
|
39
32
|
/** Number of times `submit()` has fired for this quiz instance. */
|
|
40
33
|
attemptCount: number;
|
|
@@ -42,102 +35,29 @@ export interface FeedbackVisibilityState {
|
|
|
42
35
|
|
|
43
36
|
export type FeedbackModePredicate = (state: FeedbackVisibilityState) => boolean;
|
|
44
37
|
export type RetryStrategyPredicate = (results: QuizQuestionResult[]) => Set<number>;
|
|
45
|
-
export type CanSubmitPredicate = (answeredCount: number, totalCount: number) => boolean;
|
|
46
|
-
export type ScorePredicate = (results: QuizQuestionResult[]) => number;
|
|
47
|
-
|
|
48
|
-
export interface QuizPolicyConfig {
|
|
49
|
-
/**
|
|
50
|
-
* When feedback for a question should render:
|
|
51
|
-
* - `'immediate'` — after the shell calls `revealFeedback(q)` for the question.
|
|
52
|
-
* - `'review'` (default) — only while the quiz is in review mode.
|
|
53
|
-
* - `'never'` — feedback never renders, no Review button.
|
|
54
|
-
* - predicate `(state) => boolean` — full control over visibility.
|
|
55
|
-
*
|
|
56
|
-
* Predicates receive a `FeedbackVisibilityState` so they can decide
|
|
57
|
-
* independently of the enum branches — the enums themselves desugar to
|
|
58
|
-
* predicates over the same state.
|
|
59
|
-
*/
|
|
60
|
-
feedbackMode?: 'immediate' | 'review' | 'never' | FeedbackModePredicate;
|
|
61
|
-
/**
|
|
62
|
-
* On retry, clear every answer (`'full'`), preserve correct answers
|
|
63
|
-
* (`'incorrect-only'`), or pass a custom predicate that takes the previous
|
|
64
|
-
* attempt's results and returns the set of question indices to keep locked.
|
|
65
|
-
*/
|
|
66
|
-
retryMode?: 'full' | 'incorrect-only' | RetryStrategyPredicate;
|
|
67
|
-
/**
|
|
68
|
-
* Custom gate for the Submit button. Defaults to "every registered
|
|
69
|
-
* question has an answer". Predicates take (answered, total).
|
|
70
|
-
*/
|
|
71
|
-
canSubmit?: CanSubmitPredicate;
|
|
72
|
-
/**
|
|
73
|
-
* Custom score formula. Defaults to weighted-correct percentage —
|
|
74
|
-
* `Σ(weight × correct) / Σ(weight) × 100`. Authors must return a value in
|
|
75
|
-
* 0–100; values outside that range warn in dev mode.
|
|
76
|
-
*/
|
|
77
|
-
score?: ScorePredicate;
|
|
78
|
-
}
|
|
79
38
|
|
|
80
39
|
/**
|
|
81
|
-
* Resolve the configured feedback policy into
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
* - `'
|
|
86
|
-
* the question, OR while the quiz is in review mode.
|
|
87
|
-
* - `'review'` (default) — visible only while the quiz is in review mode.
|
|
88
|
-
* - `'never'` — never visible. `useQuiz` short-circuits before calling here.
|
|
89
|
-
*
|
|
90
|
-
* Predicates receive the full visibility state so they can encode any policy
|
|
91
|
-
* — e.g. "only after first wrong attempt": `(s) => s.attemptCount > 0 && s.submitted`.
|
|
40
|
+
* Resolve the configured feedback policy into the "should this question's
|
|
41
|
+
* feedback be visible now?" predicate.
|
|
42
|
+
* - `'immediate'` — visible after the shell calls `revealFeedback(q)`, or in review.
|
|
43
|
+
* - `'review'` (default) — visible only while reviewing.
|
|
44
|
+
* - `'never'` — never visible (`useQuiz` short-circuits before calling here).
|
|
92
45
|
*/
|
|
93
|
-
export function resolveFeedbackMode(cfg:
|
|
46
|
+
export function resolveFeedbackMode(cfg: QuizConfig | undefined | null): FeedbackModePredicate {
|
|
94
47
|
const mode = cfg?.feedbackMode;
|
|
95
|
-
if (
|
|
96
|
-
if (mode === '
|
|
97
|
-
return (s) => s.revealed || s.reviewing;
|
|
98
|
-
}
|
|
99
|
-
if (mode === 'never') {
|
|
100
|
-
return () => false;
|
|
101
|
-
}
|
|
102
|
-
// Default + 'review'
|
|
48
|
+
if (mode === 'immediate') return (s) => s.revealed || s.reviewing;
|
|
49
|
+
if (mode === 'never') return () => false;
|
|
103
50
|
return (s) => s.reviewing;
|
|
104
51
|
}
|
|
105
52
|
|
|
106
|
-
function isDevMode(): boolean {
|
|
107
|
-
return import.meta.env?.DEV === true;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
53
|
/**
|
|
111
|
-
* Resolve the
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
* - `'full'` (default) — reset everything.
|
|
54
|
+
* Resolve the retry strategy into a predicate returning the set of question
|
|
55
|
+
* indices to lock as "already correct" on the next attempt.
|
|
115
56
|
* - `'incorrect-only'` — keep questions the learner got right.
|
|
116
|
-
* -
|
|
117
|
-
*
|
|
118
|
-
* Author predicates are wrapped: a non-Set return turns into "lock nothing"
|
|
119
|
-
* in production and throws in dev so the bug stays local. An author returning
|
|
120
|
-
* `[0, 1]` instead of `new Set([0, 1])` would otherwise silently no-op the
|
|
121
|
-
* lock and quietly break `'incorrect-only'`-style retries.
|
|
57
|
+
* - `'full'` (default) — reset everything.
|
|
122
58
|
*/
|
|
123
|
-
export function resolveRetryStrategy(cfg:
|
|
124
|
-
|
|
125
|
-
if (typeof mode === 'function') {
|
|
126
|
-
return (results) => {
|
|
127
|
-
const raw = mode(results);
|
|
128
|
-
if (!(raw instanceof Set)) {
|
|
129
|
-
if (isDevMode()) {
|
|
130
|
-
throw new TypeError(
|
|
131
|
-
`[tessera] quiz retryMode predicate returned ${Object.prototype.toString.call(raw)}; ` +
|
|
132
|
-
`expected a Set<number> of question indices to lock.`
|
|
133
|
-
);
|
|
134
|
-
}
|
|
135
|
-
return new Set<number>();
|
|
136
|
-
}
|
|
137
|
-
return raw;
|
|
138
|
-
};
|
|
139
|
-
}
|
|
140
|
-
if (mode === 'incorrect-only') {
|
|
59
|
+
export function resolveRetryStrategy(cfg: QuizConfig | undefined | null): RetryStrategyPredicate {
|
|
60
|
+
if (cfg?.retryMode === 'incorrect-only') {
|
|
141
61
|
return (results) => {
|
|
142
62
|
const locked = new Set<number>();
|
|
143
63
|
results.forEach((r, i) => {
|
|
@@ -146,82 +66,5 @@ export function resolveRetryStrategy(cfg: QuizPolicyConfig | undefined | null):
|
|
|
146
66
|
return locked;
|
|
147
67
|
};
|
|
148
68
|
}
|
|
149
|
-
// Default 'full': clear every answer.
|
|
150
69
|
return () => new Set<number>();
|
|
151
70
|
}
|
|
152
|
-
|
|
153
|
-
/**
|
|
154
|
-
* Resolve the Submit gate. Default — all answered.
|
|
155
|
-
*
|
|
156
|
-
* Author predicates are wrapped: a non-boolean return is coerced with `!!` in
|
|
157
|
-
* production and throws in dev. Authors returning `answered` (a number) would
|
|
158
|
-
* otherwise enable Submit on `0` answered ↔ disable on a count that happens
|
|
159
|
-
* to equal `NaN` — silently wrong gates either way.
|
|
160
|
-
*/
|
|
161
|
-
export function resolveCanSubmit(cfg: QuizPolicyConfig | undefined | null): CanSubmitPredicate {
|
|
162
|
-
if (typeof cfg?.canSubmit === 'function') {
|
|
163
|
-
const fn = cfg.canSubmit;
|
|
164
|
-
return (answered, total) => {
|
|
165
|
-
const raw = fn(answered, total);
|
|
166
|
-
if (typeof raw !== 'boolean') {
|
|
167
|
-
if (isDevMode()) {
|
|
168
|
-
throw new TypeError(
|
|
169
|
-
`[tessera] quiz canSubmit predicate returned ${typeof raw}; expected a boolean.`
|
|
170
|
-
);
|
|
171
|
-
}
|
|
172
|
-
return !!raw;
|
|
173
|
-
}
|
|
174
|
-
return raw;
|
|
175
|
-
};
|
|
176
|
-
}
|
|
177
|
-
return (answered, total) => total > 0 && answered >= total;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* Resolve the score formula. Default — weighted-correct percentage. With all
|
|
182
|
-
* weights = 1 (the default for every existing course), the output equals the
|
|
183
|
-
* pre-Phase-5 unweighted formula.
|
|
184
|
-
*/
|
|
185
|
-
export function resolveScore(cfg: QuizPolicyConfig | undefined | null): ScorePredicate {
|
|
186
|
-
if (typeof cfg?.score === 'function') {
|
|
187
|
-
return (results) => {
|
|
188
|
-
const raw = cfg.score!(results);
|
|
189
|
-
const isDev = isDevMode();
|
|
190
|
-
if (typeof raw !== 'number' || !Number.isFinite(raw)) {
|
|
191
|
-
// NaN/Infinity/non-number can't ride through to setScore(...) — the LMS
|
|
192
|
-
// either rejects the cmi write or rolls it up to nonsense. Throw in dev
|
|
193
|
-
// so the bug stays local; clamp to 0 in prod so a runaway predicate
|
|
194
|
-
// can't crash the learner's session.
|
|
195
|
-
if (isDev) {
|
|
196
|
-
throw new TypeError(
|
|
197
|
-
`[tessera] quiz score predicate returned ${String(raw)}; expected a finite number in 0–100.`
|
|
198
|
-
);
|
|
199
|
-
}
|
|
200
|
-
return 0;
|
|
201
|
-
}
|
|
202
|
-
if (raw < 0 || raw > 100) {
|
|
203
|
-
if (isDev) {
|
|
204
|
-
// eslint-disable-next-line no-console
|
|
205
|
-
console.warn(
|
|
206
|
-
`[tessera] quiz score predicate returned ${raw}; expected a finite number in 0–100. ` +
|
|
207
|
-
`Clamping to range — LMSes reject out-of-range cmi.score.raw values.`
|
|
208
|
-
);
|
|
209
|
-
}
|
|
210
|
-
return Math.max(0, Math.min(100, raw));
|
|
211
|
-
}
|
|
212
|
-
return raw;
|
|
213
|
-
};
|
|
214
|
-
}
|
|
215
|
-
return (results) => {
|
|
216
|
-
if (results.length === 0) return 0;
|
|
217
|
-
let weighted = 0;
|
|
218
|
-
let totalWeight = 0;
|
|
219
|
-
for (const r of results) {
|
|
220
|
-
const w = r.weight > 0 ? r.weight : 1;
|
|
221
|
-
totalWeight += w;
|
|
222
|
-
if (r.correct) weighted += w;
|
|
223
|
-
}
|
|
224
|
-
if (totalWeight === 0) return 0;
|
|
225
|
-
return Math.round((weighted / totalWeight) * 100);
|
|
226
|
-
};
|
|
227
|
-
}
|
|
@@ -7,6 +7,11 @@
|
|
|
7
7
|
* Keeping the rules in one place prevents the two callsites from drifting.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
/** Join a field label with a validator suffix: `.foo` chains, others get `: `. */
|
|
11
|
+
export function joinFieldError(label: string, suffix: string): string {
|
|
12
|
+
return suffix.startsWith('.') ? `${label}${suffix}` : `${label}: ${suffix}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
10
15
|
/**
|
|
11
16
|
* Validate that a candidate is an Identified Agent per xAPI 1.0.3.
|
|
12
17
|
* Returns null on success or a human-readable error suffix on failure.
|
|
@@ -81,10 +86,10 @@ export function validateAgent(actor: unknown): string | null {
|
|
|
81
86
|
*/
|
|
82
87
|
export function validateAuthCredential(auth: string): string | null {
|
|
83
88
|
if (typeof auth !== 'string' || !auth) {
|
|
84
|
-
return '
|
|
89
|
+
return 'must be a non-empty string';
|
|
85
90
|
}
|
|
86
91
|
if (/^basic\s/i.test(auth)) {
|
|
87
|
-
return "
|
|
92
|
+
return "must be the Basic credential value only, not the full header. Drop the 'Basic ' prefix.";
|
|
88
93
|
}
|
|
89
94
|
if (/^bearer\s/i.test(auth)) {
|
|
90
95
|
return 'Bearer/OAuth credentials are not supported in v1. Use Basic auth, or wrap your token-exchange in an auth function that returns a Basic credential.';
|
|
@@ -12,8 +12,8 @@ import {
|
|
|
12
12
|
validatePartialStatement,
|
|
13
13
|
validateAgent,
|
|
14
14
|
validateAuthCredential,
|
|
15
|
+
joinFieldError,
|
|
15
16
|
XAPIConfigError,
|
|
16
|
-
XAPIStatementError,
|
|
17
17
|
} from './validation.js';
|
|
18
18
|
import { RETRY_ATTEMPTS, backoffMs } from '../adapters/retry.js';
|
|
19
19
|
|
|
@@ -21,16 +21,6 @@ import { RETRY_ATTEMPTS, backoffMs } from '../adapters/retry.js';
|
|
|
21
21
|
const CMI5_SESSIONID_EXT =
|
|
22
22
|
'https://w3id.org/xapi/cmi5/context/extensions/sessionid';
|
|
23
23
|
|
|
24
|
-
/**
|
|
25
|
-
* Combine a field label (e.g. `xapi.actor`) with the prefix-friendly suffix
|
|
26
|
-
* returned by `validateAgent`. Sub-field suffixes start with `.` and chain
|
|
27
|
-
* directly (`xapi.actor.mbox …`); top-level messages get a `: ` separator
|
|
28
|
-
* (`xapi.actor: must be an object`).
|
|
29
|
-
*/
|
|
30
|
-
function joinFieldError(label: string, suffix: string): string {
|
|
31
|
-
return suffix.startsWith('.') ? `${label}${suffix}` : `${label}: ${suffix}`;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
24
|
export interface XAPIPublisherOptions {
|
|
35
25
|
/** Resolved http(s) endpoint URL. The 'lms' sentinel is a config-layer concept and never reaches the publisher. */
|
|
36
26
|
endpoint: string;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { PartialStatement } from './types.js';
|
|
2
|
-
export { validateAgent, validateAuthCredential } from './agent-rules.js';
|
|
2
|
+
export { validateAgent, validateAuthCredential, joinFieldError } from './agent-rules.js';
|
|
3
3
|
|
|
4
4
|
/** Thrown for runtime-validation failures (auth/actor resolver misuse). */
|
|
5
5
|
export class XAPIConfigError extends Error {
|
package/src/virtual.d.ts
CHANGED
|
@@ -4,6 +4,19 @@ declare module 'virtual:tessera-layout' {
|
|
|
4
4
|
export default layout;
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
+
declare module 'virtual:tessera-adapter' {
|
|
8
|
+
import type { PersistenceAdapter } from 'tessera-learn/runtime/persistence.js';
|
|
9
|
+
import type { CourseConfig } from 'tessera-learn/runtime/types.js';
|
|
10
|
+
export function createAdapter(config: CourseConfig): PersistenceAdapter;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
declare module 'virtual:tessera-xapi-setup' {
|
|
14
|
+
import type { CourseConfig } from 'tessera-learn/runtime/types.js';
|
|
15
|
+
import type { PersistenceAdapter } from 'tessera-learn/runtime/persistence.js';
|
|
16
|
+
import type { XAPIClient } from 'tessera-learn/runtime/xapi/client.js';
|
|
17
|
+
export function buildXAPIClient(config: CourseConfig, adapter: PersistenceAdapter): Promise<XAPIClient | null>;
|
|
18
|
+
}
|
|
19
|
+
|
|
7
20
|
interface ImportMetaEnv {
|
|
8
21
|
readonly DEV: boolean;
|
|
9
22
|
readonly PROD: boolean;
|
package/styles/layout.css
CHANGED
|
@@ -281,38 +281,48 @@
|
|
|
281
281
|
z-index: 999;
|
|
282
282
|
}
|
|
283
283
|
|
|
284
|
-
/* ---- Loading
|
|
285
|
-
.tessera-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
284
|
+
/* ---- Loading Bar ---- */
|
|
285
|
+
.tessera-loading-bar {
|
|
286
|
+
position: fixed;
|
|
287
|
+
top: 0;
|
|
288
|
+
left: 0;
|
|
289
|
+
right: 0;
|
|
290
|
+
height: 2px;
|
|
291
|
+
z-index: 2000;
|
|
292
|
+
pointer-events: none;
|
|
293
|
+
background-color: transparent;
|
|
290
294
|
}
|
|
291
295
|
|
|
292
|
-
.tessera-
|
|
293
|
-
height:
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
296
|
+
.tessera-loading-bar-fill {
|
|
297
|
+
height: 100%;
|
|
298
|
+
width: 0;
|
|
299
|
+
background-color: var(--tessera-primary);
|
|
300
|
+
box-shadow: 0 0 8px var(--tessera-primary);
|
|
301
|
+
transition: width 12s cubic-bezier(0.1, 0.7, 0.1, 1);
|
|
297
302
|
}
|
|
298
303
|
|
|
299
|
-
.tessera-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
.tessera-skeleton-line:nth-child(4) { width: 78%; }
|
|
303
|
-
.tessera-skeleton-line:nth-child(5) { width: 85%; }
|
|
304
|
-
.tessera-skeleton-line:nth-child(6) { width: 60%; }
|
|
304
|
+
.tessera-loading-bar.appear .tessera-loading-bar-fill {
|
|
305
|
+
width: 90%;
|
|
306
|
+
}
|
|
305
307
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
308
|
+
.tessera-loading-bar.complete .tessera-loading-bar-fill {
|
|
309
|
+
width: 100%;
|
|
310
|
+
transition: width 180ms ease-out;
|
|
309
311
|
}
|
|
310
312
|
|
|
311
|
-
.tessera-
|
|
312
|
-
|
|
313
|
-
|
|
313
|
+
.tessera-loading-bar-message {
|
|
314
|
+
position: fixed;
|
|
315
|
+
top: var(--tessera-spacing-md);
|
|
316
|
+
left: 50%;
|
|
317
|
+
transform: translateX(-50%);
|
|
318
|
+
z-index: 2000;
|
|
319
|
+
margin: 0;
|
|
320
|
+
padding: 4px 12px;
|
|
321
|
+
font-size: 0.8125rem;
|
|
314
322
|
color: var(--tessera-text-light);
|
|
315
|
-
|
|
323
|
+
background-color: var(--tessera-bg-secondary);
|
|
324
|
+
border: 1px solid var(--tessera-border);
|
|
325
|
+
border-radius: 999px;
|
|
316
326
|
}
|
|
317
327
|
|
|
318
328
|
/* ---- Buttons ---- */
|