tessera-learn 0.0.10 → 0.0.13
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/README.md +1 -0
- package/dist/audit-BBJpQGqb.js +204 -0
- package/dist/audit-BBJpQGqb.js.map +1 -0
- package/dist/plugin/a11y-cli.d.ts +1 -0
- package/dist/plugin/a11y-cli.js +36 -0
- package/dist/plugin/a11y-cli.js.map +1 -0
- package/dist/plugin/cli.js +6 -3
- package/dist/plugin/cli.js.map +1 -1
- package/dist/plugin/index.d.ts +16 -1
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +171 -140
- package/dist/plugin/index.js.map +1 -1
- package/dist/{validation-BxWAMMnJ.js → validation-B-xTvM9B.js} +417 -81
- package/dist/validation-B-xTvM9B.js.map +1 -0
- package/package.json +17 -2
- package/src/components/Accordion.svelte +3 -1
- package/src/components/AccordionItem.svelte +1 -5
- package/src/components/Audio.svelte +22 -5
- package/src/components/Callout.svelte +5 -1
- package/src/components/Carousel.svelte +24 -8
- package/src/components/DefaultLayout.svelte +41 -12
- package/src/components/FillInTheBlank.svelte +75 -103
- package/src/components/Image.svelte +14 -10
- package/src/components/LockedBanner.svelte +5 -5
- package/src/components/Matching.svelte +48 -19
- package/src/components/MediaTracks.svelte +21 -0
- package/src/components/MultipleChoice.svelte +81 -102
- package/src/components/Quiz.svelte +63 -21
- package/src/components/ResultIcon.svelte +20 -4
- package/src/components/RevealModal.svelte +25 -22
- package/src/components/Sorting.svelte +61 -26
- package/src/components/Transcript.svelte +37 -0
- package/src/components/Video.svelte +25 -20
- package/src/components/util.ts +4 -1
- package/src/components/video-embed.ts +25 -0
- package/src/index.ts +2 -7
- package/src/plugin/a11y/audit.ts +299 -0
- package/src/plugin/a11y/contrast.ts +67 -0
- package/src/plugin/a11y-cli.ts +35 -0
- package/src/plugin/cli.ts +6 -8
- package/src/plugin/export.ts +60 -50
- package/src/plugin/index.ts +244 -101
- package/src/plugin/layout.ts +6 -51
- package/src/plugin/manifest.ts +90 -24
- package/src/plugin/override-plugin.ts +68 -0
- package/src/plugin/quiz.ts +9 -54
- package/src/plugin/validation.ts +768 -183
- package/src/runtime/App.svelte +128 -64
- package/src/runtime/LoadingBar.svelte +12 -3
- package/src/runtime/Sidebar.svelte +24 -8
- package/src/runtime/access.ts +15 -3
- package/src/runtime/adapters/cmi5.ts +68 -116
- package/src/runtime/adapters/format.ts +67 -0
- package/src/runtime/adapters/index.ts +45 -34
- package/src/runtime/adapters/retry.ts +25 -84
- package/src/runtime/adapters/scorm-base.ts +19 -15
- package/src/runtime/adapters/scorm12.ts +8 -9
- package/src/runtime/adapters/scorm2004.ts +22 -30
- package/src/runtime/adapters/web.ts +1 -1
- package/src/runtime/hooks.svelte.ts +152 -328
- package/src/runtime/interaction-format.ts +30 -12
- package/src/runtime/interaction.ts +44 -11
- package/src/runtime/navigation.svelte.ts +29 -40
- package/src/runtime/persistence.ts +2 -2
- package/src/runtime/progress.svelte.ts +22 -9
- package/src/runtime/quiz-engine.svelte.ts +361 -0
- package/src/runtime/quiz-policy.ts +28 -179
- package/src/runtime/types.ts +24 -2
- package/src/runtime/xapi/agent-rules.ts +11 -3
- package/src/runtime/xapi/client.ts +5 -5
- package/src/runtime/xapi/derive-actor.ts +2 -2
- package/src/runtime/xapi/publisher.ts +33 -40
- package/src/runtime/xapi/setup.ts +18 -15
- package/src/runtime/xapi/validation.ts +15 -6
- package/src/virtual.d.ts +4 -1
- package/styles/base.css +32 -11
- package/styles/layout.css +39 -18
- package/styles/theme.css +15 -3
- package/dist/validation-BxWAMMnJ.js.map +0 -1
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import { SvelteSet } from 'svelte/reactivity';
|
|
2
|
+
import type { Interaction } from './interaction.js';
|
|
3
|
+
import type { QuizConfig } from './types.js';
|
|
4
|
+
import {
|
|
5
|
+
resolveFeedbackMode,
|
|
6
|
+
resolveRetryStrategy,
|
|
7
|
+
type QuizQuestionResult,
|
|
8
|
+
type FeedbackModePredicate,
|
|
9
|
+
type RetryStrategyPredicate,
|
|
10
|
+
} from './quiz-policy.js';
|
|
11
|
+
import type {
|
|
12
|
+
UseQuizInternalHandle,
|
|
13
|
+
UseQuizQuestionApi,
|
|
14
|
+
QuestionInternal,
|
|
15
|
+
Question,
|
|
16
|
+
} from './hooks.svelte.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Dependencies injected into {@link QuizEngine} so the engine itself stays
|
|
20
|
+
* framework- and DOM-free. The Svelte wrapper (`useQuiz`) provides the two
|
|
21
|
+
* callbacks that bridge to the host element and the LMS adapter.
|
|
22
|
+
*/
|
|
23
|
+
export interface QuizEngineDeps {
|
|
24
|
+
quizConfig: QuizConfig;
|
|
25
|
+
/**
|
|
26
|
+
* Live accessor for the resolved passing threshold (config + LMS mastery
|
|
27
|
+
* override). Read on each access rather than captured, because the cmi5/SCORM
|
|
28
|
+
* mastery override mutates `pageContext.passingScore` after `useQuiz()` may
|
|
29
|
+
* already have run.
|
|
30
|
+
*/
|
|
31
|
+
passingScore: () => number;
|
|
32
|
+
/** Wraps `adapterCtx.adapter.reportInteraction`; a no-op when there is no adapter. */
|
|
33
|
+
report: (
|
|
34
|
+
id: string,
|
|
35
|
+
interaction: Interaction,
|
|
36
|
+
correct: boolean | null,
|
|
37
|
+
) => void;
|
|
38
|
+
/**
|
|
39
|
+
* Wraps the host-element `CustomEvent` dispatch. Returns `false` when the host
|
|
40
|
+
* element is null (the engine treats that as "no LMS bridge listener").
|
|
41
|
+
*/
|
|
42
|
+
dispatch: (name: string, detail?: unknown) => boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface InternalQuestion {
|
|
46
|
+
id: string;
|
|
47
|
+
weight: number;
|
|
48
|
+
checkAnswer: (answer?: unknown) => boolean;
|
|
49
|
+
reset?: () => void;
|
|
50
|
+
interaction?: () => Interaction;
|
|
51
|
+
render: unknown;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The quiz engine: all reactive state, scoring, retry/feedback policy and the
|
|
56
|
+
* register/submit/retry lifecycle that used to live inside the `useQuiz`
|
|
57
|
+
* closure. Directly instantiable (and unit-testable) because the only two
|
|
58
|
+
* side-effecting touchpoints — DOM events and LMS reporting — are injected.
|
|
59
|
+
*/
|
|
60
|
+
export class QuizEngine implements UseQuizInternalHandle {
|
|
61
|
+
#deps: QuizEngineDeps;
|
|
62
|
+
#feedbackPredicate: FeedbackModePredicate;
|
|
63
|
+
#retryPredicate: RetryStrategyPredicate;
|
|
64
|
+
#maxAttempts: number;
|
|
65
|
+
|
|
66
|
+
#internalQuestions = $state<InternalQuestion[]>([]);
|
|
67
|
+
#questionHandles = $state<QuestionInternal[]>([]);
|
|
68
|
+
#answers = new Map<number, unknown>();
|
|
69
|
+
#reportedAnswers = new Map<number, string>();
|
|
70
|
+
#answersVersion = $state(0);
|
|
71
|
+
#submitted = $state(false);
|
|
72
|
+
#reviewing = $state(false);
|
|
73
|
+
#score = $state(0);
|
|
74
|
+
#attemptCount = $state(0);
|
|
75
|
+
#submitCalled = false; // plain field, not $state — only the wrapper's onDestroy reads it
|
|
76
|
+
#feedbackShown = new SvelteSet<number>();
|
|
77
|
+
#lockedCorrect = new SvelteSet<number>();
|
|
78
|
+
#seenIds = new Set<string>();
|
|
79
|
+
|
|
80
|
+
constructor(deps: QuizEngineDeps) {
|
|
81
|
+
this.#deps = deps;
|
|
82
|
+
this.#maxAttempts = deps.quizConfig.maxAttempts ?? Infinity;
|
|
83
|
+
this.#feedbackPredicate = resolveFeedbackMode(deps.quizConfig);
|
|
84
|
+
this.#retryPredicate = resolveRetryStrategy(deps.quizConfig);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Derived values are plain getters over $state, mirroring ProgressState (which
|
|
88
|
+
// has no $derived). Getters recompute on read with or without a tracking
|
|
89
|
+
// effect, so engine-construction tests need no $effect.root.
|
|
90
|
+
get #totalQuestions(): number {
|
|
91
|
+
return this.#internalQuestions.length;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
get #allAnswered(): boolean {
|
|
95
|
+
void this.#answersVersion;
|
|
96
|
+
return (
|
|
97
|
+
this.#totalQuestions > 0 && this.#answers.size >= this.#totalQuestions
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
get state(): 'answering' | 'submitted' | 'reviewing' {
|
|
102
|
+
return this.#reviewing
|
|
103
|
+
? 'reviewing'
|
|
104
|
+
: this.#submitted
|
|
105
|
+
? 'submitted'
|
|
106
|
+
: 'answering';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
get questions(): ReadonlyArray<Question> {
|
|
110
|
+
return this.#questionHandles;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
get canSubmit(): boolean {
|
|
114
|
+
return !this.#submitted && this.#allAnswered;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
get canRetry(): boolean {
|
|
118
|
+
return this.#submitted && this.#attemptCount < this.#maxAttempts;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
get score(): number {
|
|
122
|
+
return this.#score;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
get passingScore(): number {
|
|
126
|
+
return this.#deps.passingScore();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
get attemptCount(): number {
|
|
130
|
+
return this.#attemptCount;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Dev-warning inputs the wrapper reads in onDestroy. */
|
|
134
|
+
get stats(): {
|
|
135
|
+
questionsCount: number;
|
|
136
|
+
answersCount: number;
|
|
137
|
+
submitCalled: boolean;
|
|
138
|
+
} {
|
|
139
|
+
return {
|
|
140
|
+
questionsCount: this.#internalQuestions.length,
|
|
141
|
+
answersCount: this.#answers.size,
|
|
142
|
+
submitCalled: this.#submitCalled,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
registerQuestion(api: UseQuizQuestionApi): QuestionInternal {
|
|
147
|
+
if (this.#seenIds.has(api.id)) {
|
|
148
|
+
console.warn(
|
|
149
|
+
`[tessera] useQuiz: duplicate question id "${api.id}" — ` +
|
|
150
|
+
'each question id must be unique within a quiz (LMS interaction records key by id).',
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
this.#seenIds.add(api.id);
|
|
154
|
+
const internal: InternalQuestion = {
|
|
155
|
+
id: api.id,
|
|
156
|
+
weight: typeof api.weight === 'number' && api.weight > 0 ? api.weight : 1,
|
|
157
|
+
checkAnswer: api.checkAnswer,
|
|
158
|
+
reset: api.reset,
|
|
159
|
+
interaction: api.interaction,
|
|
160
|
+
render: undefined,
|
|
161
|
+
};
|
|
162
|
+
this.#internalQuestions.push(internal);
|
|
163
|
+
const handle = this.#makeQuestionHandle(this.#internalQuestions.length - 1);
|
|
164
|
+
this.#questionHandles.push(handle);
|
|
165
|
+
return handle;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
setAnswer(index: number, answer: unknown): void {
|
|
169
|
+
this.#answers.set(index, answer);
|
|
170
|
+
this.#answersVersion++;
|
|
171
|
+
this.#deps.dispatch('tessera-quiz-question-answered', { index });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
getAnswer(index: number): unknown {
|
|
175
|
+
void this.#answersVersion;
|
|
176
|
+
return this.#answers.get(index);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
setRender(index: number, render: unknown): void {
|
|
180
|
+
if (this.#internalQuestions[index])
|
|
181
|
+
this.#internalQuestions[index].render = render;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
getRender(index: number): unknown {
|
|
185
|
+
return this.#internalQuestions[index]?.render;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
feedbackVisible(index: number): boolean {
|
|
189
|
+
if (this.#deps.quizConfig.feedbackMode === 'never') return false;
|
|
190
|
+
return this.#feedbackPredicate({
|
|
191
|
+
questionIndex: index,
|
|
192
|
+
submitted: this.#submitted,
|
|
193
|
+
reviewing: this.#reviewing,
|
|
194
|
+
hasAnswered: this.#answers.has(index),
|
|
195
|
+
revealed: this.#feedbackShown.has(index),
|
|
196
|
+
attemptCount: this.#attemptCount,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
revealFeedbackByIndex(index: number): void {
|
|
201
|
+
if (this.#deps.quizConfig.feedbackMode === 'never') return;
|
|
202
|
+
this.#feedbackShown.add(index);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
isLockedCorrect(index: number): boolean {
|
|
206
|
+
return this.#lockedCorrect.has(index);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
revealFeedback(q: Question): void {
|
|
210
|
+
const index = this.#internalQuestions.findIndex((iq) => iq.id === q.id);
|
|
211
|
+
if (index >= 0) this.revealFeedbackByIndex(index);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
submit(): void {
|
|
215
|
+
this.#submitCalled = true;
|
|
216
|
+
if (this.#submitted) return;
|
|
217
|
+
if (!this.#allAnswered) return;
|
|
218
|
+
// Combined null-host guard + before-submit dispatch: dispatch() returns false
|
|
219
|
+
// when the host element is null, which is the silent-LMS-dropout case.
|
|
220
|
+
if (!this.#deps.dispatch('tessera-quiz-before-submit')) {
|
|
221
|
+
console.warn(
|
|
222
|
+
'[tessera] useQuiz: submit() ran but the host element was null — no LMS bridge ' +
|
|
223
|
+
'listener exists, so this score will not be persisted. Make sure your custom ' +
|
|
224
|
+
'quiz shell binds the element it passes to useQuiz({ element: () => ... }).',
|
|
225
|
+
);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
for (let i = 0; i < this.#internalQuestions.length; i++) this.#commit(i);
|
|
230
|
+
|
|
231
|
+
const { rounded } = this.#computeScore();
|
|
232
|
+
this.#score = rounded;
|
|
233
|
+
this.#submitted = true;
|
|
234
|
+
this.#attemptCount++;
|
|
235
|
+
|
|
236
|
+
this.#deps.dispatch('tessera-quiz-complete', { score: rounded });
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
startReview(): void {
|
|
240
|
+
if (!this.#submitted) return;
|
|
241
|
+
this.#reviewing = true;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
exitReview(): void {
|
|
245
|
+
this.#reviewing = false;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
retry(): void {
|
|
249
|
+
if (!this.canRetry) return;
|
|
250
|
+
const results: QuizQuestionResult[] = [];
|
|
251
|
+
for (let i = 0; i < this.#internalQuestions.length; i++) {
|
|
252
|
+
const a = this.#answers.has(i) ? this.#answers.get(i) : undefined;
|
|
253
|
+
results.push({
|
|
254
|
+
interaction:
|
|
255
|
+
this.#internalQuestions[i].interaction?.() ?? ({} as never),
|
|
256
|
+
correct: this.#internalQuestions[i].checkAnswer(a),
|
|
257
|
+
weight: this.#internalQuestions[i].weight,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
const newLocked = this.#retryPredicate(results);
|
|
261
|
+
const preserved = new Map<number, unknown>();
|
|
262
|
+
for (const i of newLocked) {
|
|
263
|
+
if (this.#answers.has(i)) preserved.set(i, this.#answers.get(i));
|
|
264
|
+
}
|
|
265
|
+
this.#lockedCorrect.clear();
|
|
266
|
+
for (const i of newLocked) this.#lockedCorrect.add(i);
|
|
267
|
+
this.#answers.clear();
|
|
268
|
+
this.#reportedAnswers.clear();
|
|
269
|
+
for (const [i, a] of preserved) this.#answers.set(i, a);
|
|
270
|
+
for (let i = 0; i < this.#internalQuestions.length; i++) {
|
|
271
|
+
if (!newLocked.has(i) && this.#internalQuestions[i].reset)
|
|
272
|
+
this.#internalQuestions[i].reset!();
|
|
273
|
+
}
|
|
274
|
+
this.#answersVersion++;
|
|
275
|
+
this.#feedbackShown.clear();
|
|
276
|
+
this.#submitted = false;
|
|
277
|
+
this.#reviewing = false;
|
|
278
|
+
this.#score = 0;
|
|
279
|
+
this.#deps.dispatch('tessera-quiz-retry');
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
#commit(index: number): void {
|
|
283
|
+
const q = this.#internalQuestions[index];
|
|
284
|
+
if (!q || typeof q.interaction !== 'function') return;
|
|
285
|
+
const interaction = q.interaction();
|
|
286
|
+
if (!interaction) return;
|
|
287
|
+
const fingerprint = JSON.stringify(interaction);
|
|
288
|
+
if (this.#reportedAnswers.get(index) === fingerprint) return;
|
|
289
|
+
const answer = this.#answers.has(index)
|
|
290
|
+
? this.#answers.get(index)
|
|
291
|
+
: undefined;
|
|
292
|
+
this.#deps.report(q.id, interaction, q.checkAnswer(answer));
|
|
293
|
+
this.#reportedAnswers.set(index, fingerprint);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
#computeScore(): { rounded: number; correctCount: number } {
|
|
297
|
+
let weighted = 0;
|
|
298
|
+
let totalWeight = 0;
|
|
299
|
+
let correctCount = 0;
|
|
300
|
+
for (let i = 0; i < this.#internalQuestions.length; i++) {
|
|
301
|
+
const q = this.#internalQuestions[i];
|
|
302
|
+
const a = this.#answers.has(i) ? this.#answers.get(i) : undefined;
|
|
303
|
+
const ok = q.checkAnswer(a);
|
|
304
|
+
totalWeight += q.weight;
|
|
305
|
+
if (ok) {
|
|
306
|
+
weighted += q.weight;
|
|
307
|
+
correctCount++;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
if (totalWeight === 0) return { rounded: 0, correctCount: 0 };
|
|
311
|
+
return {
|
|
312
|
+
rounded: Math.round((weighted / totalWeight) * 100),
|
|
313
|
+
correctCount,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
#makeQuestionHandle(i: number): QuestionInternal {
|
|
318
|
+
const engine = this;
|
|
319
|
+
return {
|
|
320
|
+
get id() {
|
|
321
|
+
return engine.#internalQuestions[i].id;
|
|
322
|
+
},
|
|
323
|
+
get submitted() {
|
|
324
|
+
return engine.#submitted;
|
|
325
|
+
},
|
|
326
|
+
get correct() {
|
|
327
|
+
if (!engine.#submitted) return null;
|
|
328
|
+
const a = engine.#answers.has(i) ? engine.#answers.get(i) : undefined;
|
|
329
|
+
return engine.#internalQuestions[i].checkAnswer(a);
|
|
330
|
+
},
|
|
331
|
+
get answer() {
|
|
332
|
+
return engine.getAnswer(i);
|
|
333
|
+
},
|
|
334
|
+
get feedbackVisible() {
|
|
335
|
+
return engine.feedbackVisible(i);
|
|
336
|
+
},
|
|
337
|
+
get locked() {
|
|
338
|
+
return (
|
|
339
|
+
engine.#submitted ||
|
|
340
|
+
engine.feedbackVisible(i) ||
|
|
341
|
+
engine.isLockedCorrect(i)
|
|
342
|
+
);
|
|
343
|
+
},
|
|
344
|
+
get isLockedCorrect() {
|
|
345
|
+
return engine.isLockedCorrect(i);
|
|
346
|
+
},
|
|
347
|
+
get render() {
|
|
348
|
+
return engine.getRender(i);
|
|
349
|
+
},
|
|
350
|
+
setAnswer(a: unknown) {
|
|
351
|
+
engine.setAnswer(i, a);
|
|
352
|
+
},
|
|
353
|
+
commit() {
|
|
354
|
+
engine.#commit(i);
|
|
355
|
+
},
|
|
356
|
+
setRender(r: unknown) {
|
|
357
|
+
engine.setRender(i, r);
|
|
358
|
+
},
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
}
|
|
@@ -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,114 +27,43 @@ 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;
|
|
41
34
|
}
|
|
42
35
|
|
|
43
36
|
export type FeedbackModePredicate = (state: FeedbackVisibilityState) => boolean;
|
|
44
|
-
export type RetryStrategyPredicate = (
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
}
|
|
37
|
+
export type RetryStrategyPredicate = (
|
|
38
|
+
results: QuizQuestionResult[],
|
|
39
|
+
) => Set<number>;
|
|
79
40
|
|
|
80
41
|
/**
|
|
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`.
|
|
42
|
+
* Resolve the configured feedback policy into the "should this question's
|
|
43
|
+
* feedback be visible now?" predicate.
|
|
44
|
+
* - `'immediate'` — visible after the shell calls `revealFeedback(q)`, or in review.
|
|
45
|
+
* - `'review'` (default) — visible only while reviewing.
|
|
46
|
+
* - `'never'` — never visible (`useQuiz` short-circuits before calling here).
|
|
92
47
|
*/
|
|
93
|
-
export function resolveFeedbackMode(
|
|
48
|
+
export function resolveFeedbackMode(
|
|
49
|
+
cfg: QuizConfig | undefined | null,
|
|
50
|
+
): FeedbackModePredicate {
|
|
94
51
|
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'
|
|
52
|
+
if (mode === 'immediate') return (s) => s.revealed || s.reviewing;
|
|
53
|
+
if (mode === 'never') return () => false;
|
|
103
54
|
return (s) => s.reviewing;
|
|
104
55
|
}
|
|
105
56
|
|
|
106
|
-
function isDevMode(): boolean {
|
|
107
|
-
return import.meta.env?.DEV === true;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
57
|
/**
|
|
111
|
-
* Resolve the
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
* - `'full'` (default) — reset everything.
|
|
58
|
+
* Resolve the retry strategy into a predicate returning the set of question
|
|
59
|
+
* indices to lock as "already correct" on the next attempt.
|
|
115
60
|
* - `'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.
|
|
61
|
+
* - `'full'` (default) — reset everything.
|
|
122
62
|
*/
|
|
123
|
-
export function resolveRetryStrategy(
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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') {
|
|
63
|
+
export function resolveRetryStrategy(
|
|
64
|
+
cfg: QuizConfig | undefined | null,
|
|
65
|
+
): RetryStrategyPredicate {
|
|
66
|
+
if (cfg?.retryMode === 'incorrect-only') {
|
|
141
67
|
return (results) => {
|
|
142
68
|
const locked = new Set<number>();
|
|
143
69
|
results.forEach((r, i) => {
|
|
@@ -146,82 +72,5 @@ export function resolveRetryStrategy(cfg: QuizPolicyConfig | undefined | null):
|
|
|
146
72
|
return locked;
|
|
147
73
|
};
|
|
148
74
|
}
|
|
149
|
-
// Default 'full': clear every answer.
|
|
150
75
|
return () => new Set<number>();
|
|
151
76
|
}
|
|
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
|
-
}
|
package/src/runtime/types.ts
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import type { AccessFn } from './access.js';
|
|
2
2
|
import type { XAPIAgent } from './xapi/types.js';
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Quiz enum domains as runtime tuples. The unions below derive from these, and
|
|
6
|
+
* the build-time validator imports them too — so the accepted value set has a
|
|
7
|
+
* single source and can't drift between the types and the validator.
|
|
8
|
+
*/
|
|
9
|
+
export const FEEDBACK_MODES = ['review', 'immediate', 'never'] as const;
|
|
10
|
+
export const RETRY_MODES = ['full', 'incorrect-only'] as const;
|
|
11
|
+
|
|
4
12
|
/**
|
|
5
13
|
* Per-page quiz configuration. Single source of truth — the build plugin
|
|
6
14
|
* extracts this from `pageConfig.quiz` and embeds it in the manifest;
|
|
@@ -10,8 +18,8 @@ export interface QuizConfig {
|
|
|
10
18
|
graded?: boolean;
|
|
11
19
|
gatesProgress?: boolean;
|
|
12
20
|
maxAttempts?: number;
|
|
13
|
-
feedbackMode?:
|
|
14
|
-
retryMode?:
|
|
21
|
+
feedbackMode?: (typeof FEEDBACK_MODES)[number];
|
|
22
|
+
retryMode?: (typeof RETRY_MODES)[number];
|
|
15
23
|
}
|
|
16
24
|
|
|
17
25
|
export interface CourseConfig {
|
|
@@ -19,6 +27,10 @@ export interface CourseConfig {
|
|
|
19
27
|
description?: string;
|
|
20
28
|
author?: string;
|
|
21
29
|
version?: string;
|
|
30
|
+
/** BCP-47 language tag for <html lang>. Defaults to 'en'. WCAG 3.1.1. */
|
|
31
|
+
language?: string;
|
|
32
|
+
/** Accessibility checker configuration. */
|
|
33
|
+
a11y?: A11yConfig;
|
|
22
34
|
branding?: {
|
|
23
35
|
logo?: string;
|
|
24
36
|
primaryColor?: string;
|
|
@@ -45,6 +57,16 @@ export interface CourseConfig {
|
|
|
45
57
|
xapi?: XAPIConfig | XAPIConfig[];
|
|
46
58
|
}
|
|
47
59
|
|
|
60
|
+
/** Accessibility checker configuration. */
|
|
61
|
+
export interface A11yConfig {
|
|
62
|
+
/** Build-gate severity for promotable Tier-1 rules + Tier-1a warnings. */
|
|
63
|
+
level?: 'warn' | 'error';
|
|
64
|
+
/** axe ruleset tags for the Tier-2 runtime auditor. */
|
|
65
|
+
standard?: 'wcag2a' | 'wcag2aa' | 'wcag21aa';
|
|
66
|
+
/** Per-rule escape hatch matched literally against each diagnostic's ID. */
|
|
67
|
+
ignore?: string[];
|
|
68
|
+
}
|
|
69
|
+
|
|
48
70
|
export interface ManualCompletion {
|
|
49
71
|
mode: 'manual';
|
|
50
72
|
/**
|
|
@@ -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.
|
|
@@ -40,7 +45,10 @@ export function validateAgent(actor: unknown): string | null {
|
|
|
40
45
|
}
|
|
41
46
|
}
|
|
42
47
|
if (a.mbox_sha1sum !== undefined) {
|
|
43
|
-
if (
|
|
48
|
+
if (
|
|
49
|
+
typeof a.mbox_sha1sum !== 'string' ||
|
|
50
|
+
!/^[0-9a-f]{40}$/i.test(a.mbox_sha1sum)
|
|
51
|
+
) {
|
|
44
52
|
return '.mbox_sha1sum must be a 40-character hex string';
|
|
45
53
|
}
|
|
46
54
|
}
|
|
@@ -81,10 +89,10 @@ export function validateAgent(actor: unknown): string | null {
|
|
|
81
89
|
*/
|
|
82
90
|
export function validateAuthCredential(auth: string): string | null {
|
|
83
91
|
if (typeof auth !== 'string' || !auth) {
|
|
84
|
-
return '
|
|
92
|
+
return 'must be a non-empty string';
|
|
85
93
|
}
|
|
86
94
|
if (/^basic\s/i.test(auth)) {
|
|
87
|
-
return "
|
|
95
|
+
return "must be the Basic credential value only, not the full header. Drop the 'Basic ' prefix.";
|
|
88
96
|
}
|
|
89
97
|
if (/^bearer\s/i.test(auth)) {
|
|
90
98
|
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.';
|