tessera-learn 0.0.11 → 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 +2 -1
- 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 +85 -10
- package/dist/plugin/index.js.map +1 -1
- package/dist/{validation-D9DXlqNP.js → validation-B-xTvM9B.js} +342 -18
- 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 +17 -3
- 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 +16 -6
- package/src/components/Image.svelte +12 -3
- package/src/components/LockedBanner.svelte +2 -1
- package/src/components/Matching.svelte +48 -19
- package/src/components/MediaTracks.svelte +21 -0
- package/src/components/MultipleChoice.svelte +33 -13
- package/src/components/Quiz.svelte +61 -20
- 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 +21 -18
- package/src/components/util.ts +3 -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 +4 -1
- package/src/plugin/export.ts +42 -14
- package/src/plugin/index.ts +216 -44
- package/src/plugin/manifest.ts +62 -22
- package/src/plugin/validation.ts +736 -122
- package/src/runtime/App.svelte +119 -48
- 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 +55 -33
- package/src/runtime/adapters/index.ts +22 -10
- package/src/runtime/adapters/retry.ts +25 -20
- package/src/runtime/adapters/scorm-base.ts +19 -15
- package/src/runtime/adapters/scorm12.ts +7 -8
- package/src/runtime/adapters/scorm2004.ts +11 -14
- package/src/runtime/adapters/web.ts +1 -1
- package/src/runtime/hooks.svelte.ts +152 -326
- package/src/runtime/interaction-format.ts +30 -12
- package/src/runtime/interaction.ts +44 -11
- package/src/runtime/navigation.svelte.ts +27 -11
- package/src/runtime/persistence.ts +2 -2
- package/src/runtime/progress.svelte.ts +13 -9
- package/src/runtime/quiz-engine.svelte.ts +361 -0
- package/src/runtime/quiz-policy.ts +9 -3
- package/src/runtime/types.ts +24 -2
- package/src/runtime/xapi/agent-rules.ts +4 -1
- package/src/runtime/xapi/client.ts +5 -5
- package/src/runtime/xapi/derive-actor.ts +2 -2
- package/src/runtime/xapi/publisher.ts +32 -29
- 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-D9DXlqNP.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
|
+
}
|
|
@@ -34,7 +34,9 @@ export interface FeedbackVisibilityState {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
export type FeedbackModePredicate = (state: FeedbackVisibilityState) => boolean;
|
|
37
|
-
export type RetryStrategyPredicate = (
|
|
37
|
+
export type RetryStrategyPredicate = (
|
|
38
|
+
results: QuizQuestionResult[],
|
|
39
|
+
) => Set<number>;
|
|
38
40
|
|
|
39
41
|
/**
|
|
40
42
|
* Resolve the configured feedback policy into the "should this question's
|
|
@@ -43,7 +45,9 @@ export type RetryStrategyPredicate = (results: QuizQuestionResult[]) => Set<numb
|
|
|
43
45
|
* - `'review'` (default) — visible only while reviewing.
|
|
44
46
|
* - `'never'` — never visible (`useQuiz` short-circuits before calling here).
|
|
45
47
|
*/
|
|
46
|
-
export function resolveFeedbackMode(
|
|
48
|
+
export function resolveFeedbackMode(
|
|
49
|
+
cfg: QuizConfig | undefined | null,
|
|
50
|
+
): FeedbackModePredicate {
|
|
47
51
|
const mode = cfg?.feedbackMode;
|
|
48
52
|
if (mode === 'immediate') return (s) => s.revealed || s.reviewing;
|
|
49
53
|
if (mode === 'never') return () => false;
|
|
@@ -56,7 +60,9 @@ export function resolveFeedbackMode(cfg: QuizConfig | undefined | null): Feedbac
|
|
|
56
60
|
* - `'incorrect-only'` — keep questions the learner got right.
|
|
57
61
|
* - `'full'` (default) — reset everything.
|
|
58
62
|
*/
|
|
59
|
-
export function resolveRetryStrategy(
|
|
63
|
+
export function resolveRetryStrategy(
|
|
64
|
+
cfg: QuizConfig | undefined | null,
|
|
65
|
+
): RetryStrategyPredicate {
|
|
60
66
|
if (cfg?.retryMode === 'incorrect-only') {
|
|
61
67
|
return (results) => {
|
|
62
68
|
const locked = new Set<number>();
|
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
|
/**
|
|
@@ -45,7 +45,10 @@ export function validateAgent(actor: unknown): string | null {
|
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
47
|
if (a.mbox_sha1sum !== undefined) {
|
|
48
|
-
if (
|
|
48
|
+
if (
|
|
49
|
+
typeof a.mbox_sha1sum !== 'string' ||
|
|
50
|
+
!/^[0-9a-f]{40}$/i.test(a.mbox_sha1sum)
|
|
51
|
+
) {
|
|
49
52
|
return '.mbox_sha1sum must be a 40-character hex string';
|
|
50
53
|
}
|
|
51
54
|
}
|
|
@@ -43,7 +43,7 @@ export class XAPIClient {
|
|
|
43
43
|
*/
|
|
44
44
|
sendStatement(
|
|
45
45
|
partial: PartialStatement,
|
|
46
|
-
options?: SendStatementOptions
|
|
46
|
+
options?: SendStatementOptions,
|
|
47
47
|
): Promise<SendStatementResult> {
|
|
48
48
|
try {
|
|
49
49
|
validatePartialStatement(partial);
|
|
@@ -59,8 +59,8 @@ export class XAPIClient {
|
|
|
59
59
|
if (this.#publishers.every(blocked)) {
|
|
60
60
|
return Promise.reject(
|
|
61
61
|
new XAPIConfigError(
|
|
62
|
-
'XAPIClient.sendStatement: page is unloading; author statements queued during unload are dropped to keep Terminated last (cmi5 §9.3.6).'
|
|
63
|
-
)
|
|
62
|
+
'XAPIClient.sendStatement: page is unloading; author statements queued during unload are dropped to keep Terminated last (cmi5 §9.3.6).',
|
|
63
|
+
),
|
|
64
64
|
);
|
|
65
65
|
}
|
|
66
66
|
const id = uuidv4();
|
|
@@ -80,9 +80,9 @@ export class XAPIClient {
|
|
|
80
80
|
endpoint: pub.getEndpoint(),
|
|
81
81
|
ok: false,
|
|
82
82
|
error: new XAPIConfigError(
|
|
83
|
-
'destination skipped: cmi5 publisher is unloading; statement dropped to keep Terminated last (cmi5 §9.3.6).'
|
|
83
|
+
'destination skipped: cmi5 publisher is unloading; statement dropped to keep Terminated last (cmi5 §9.3.6).',
|
|
84
84
|
),
|
|
85
|
-
})
|
|
85
|
+
}),
|
|
86
86
|
);
|
|
87
87
|
continue;
|
|
88
88
|
}
|
|
@@ -39,7 +39,7 @@ export function defaultAccountHomePage(activityId: string): string | null {
|
|
|
39
39
|
export function synthesizeSCORM12Actor(
|
|
40
40
|
api: SCORM12API,
|
|
41
41
|
activityId: string,
|
|
42
|
-
actorAccountHomePage?: string
|
|
42
|
+
actorAccountHomePage?: string,
|
|
43
43
|
): XAPIAgent | null {
|
|
44
44
|
let id = '';
|
|
45
45
|
let name = '';
|
|
@@ -68,7 +68,7 @@ export function synthesizeSCORM12Actor(
|
|
|
68
68
|
export function synthesizeSCORM2004Actor(
|
|
69
69
|
api: SCORM2004API,
|
|
70
70
|
activityId: string,
|
|
71
|
-
actorAccountHomePage?: string
|
|
71
|
+
actorAccountHomePage?: string,
|
|
72
72
|
): XAPIAgent | null {
|
|
73
73
|
let id = '';
|
|
74
74
|
let name = '';
|