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
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { getContext, setContext, onDestroy, onMount, tick } from 'svelte';
|
|
2
|
-
import { SvelteSet } from 'svelte/reactivity';
|
|
3
2
|
import type { Interaction } from './interaction.js';
|
|
4
3
|
import { isCorrect as isCorrectInteraction } from './interaction.js';
|
|
5
4
|
import {
|
|
@@ -9,12 +8,7 @@ import {
|
|
|
9
8
|
getPageContext,
|
|
10
9
|
requireUserStateStore,
|
|
11
10
|
} from './contexts.js';
|
|
12
|
-
import {
|
|
13
|
-
resolveFeedbackMode,
|
|
14
|
-
resolveRetryStrategy,
|
|
15
|
-
type QuizPolicyConfig,
|
|
16
|
-
type QuizQuestionResult,
|
|
17
|
-
} from './quiz-policy.js';
|
|
11
|
+
import { QuizEngine } from './quiz-engine.svelte.js';
|
|
18
12
|
|
|
19
13
|
/**
|
|
20
14
|
* Per-question handle exposed to both the quiz shell (via `useQuiz().questions`)
|
|
@@ -101,7 +95,7 @@ export interface UseQuestionHandle extends Question {
|
|
|
101
95
|
|
|
102
96
|
const TESSERA_QUIZ = 'tessera-quiz' as const;
|
|
103
97
|
|
|
104
|
-
interface QuestionInternal extends Question {
|
|
98
|
+
export interface QuestionInternal extends Question {
|
|
105
99
|
setRender(render: unknown): void;
|
|
106
100
|
}
|
|
107
101
|
|
|
@@ -129,23 +123,47 @@ export function useQuestion(opts: UseQuestionOptions): UseQuestionHandle {
|
|
|
129
123
|
interaction: () => opts.response(),
|
|
130
124
|
});
|
|
131
125
|
return {
|
|
132
|
-
get id() {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
get
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
get
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
126
|
+
get id() {
|
|
127
|
+
return q.id;
|
|
128
|
+
},
|
|
129
|
+
get submitted() {
|
|
130
|
+
return q.submitted;
|
|
131
|
+
},
|
|
132
|
+
get correct() {
|
|
133
|
+
return q.correct;
|
|
134
|
+
},
|
|
135
|
+
get answer() {
|
|
136
|
+
return q.answer;
|
|
137
|
+
},
|
|
138
|
+
get feedbackVisible() {
|
|
139
|
+
return q.feedbackVisible;
|
|
140
|
+
},
|
|
141
|
+
get locked() {
|
|
142
|
+
return q.locked;
|
|
143
|
+
},
|
|
144
|
+
get isLockedCorrect() {
|
|
145
|
+
return q.isLockedCorrect;
|
|
146
|
+
},
|
|
147
|
+
get render() {
|
|
148
|
+
return q.render;
|
|
149
|
+
},
|
|
150
|
+
setAnswer(a: unknown) {
|
|
151
|
+
q.setAnswer(a);
|
|
152
|
+
},
|
|
153
|
+
commit() {
|
|
154
|
+
q.commit();
|
|
155
|
+
},
|
|
142
156
|
submit() {},
|
|
143
|
-
reset() {
|
|
157
|
+
reset() {
|
|
158
|
+
opts.reset?.();
|
|
159
|
+
},
|
|
144
160
|
retry() {},
|
|
145
161
|
canRetry: false,
|
|
146
162
|
retryCount: 0,
|
|
147
163
|
mode: 'quiz' as const,
|
|
148
|
-
setRender(render: unknown) {
|
|
164
|
+
setRender(render: unknown) {
|
|
165
|
+
q.setRender(render);
|
|
166
|
+
},
|
|
149
167
|
};
|
|
150
168
|
}
|
|
151
169
|
|
|
@@ -164,7 +182,7 @@ export function useQuestion(opts: UseQuestionOptions): UseQuestionHandle {
|
|
|
164
182
|
adapterCtx?.adapter.reportInteraction(
|
|
165
183
|
opts.id,
|
|
166
184
|
response,
|
|
167
|
-
isCorrectInteraction(response)
|
|
185
|
+
isCorrectInteraction(response),
|
|
168
186
|
);
|
|
169
187
|
}
|
|
170
188
|
|
|
@@ -173,11 +191,7 @@ export function useQuestion(opts: UseQuestionOptions): UseQuestionHandle {
|
|
|
173
191
|
const response = opts.response();
|
|
174
192
|
currentAnswer = response.response;
|
|
175
193
|
correct = isCorrectInteraction(response);
|
|
176
|
-
const score = opts.score
|
|
177
|
-
? opts.score()
|
|
178
|
-
: correct === true
|
|
179
|
-
? 100
|
|
180
|
-
: 0;
|
|
194
|
+
const score = opts.score ? opts.score() : correct === true ? 100 : 0;
|
|
181
195
|
|
|
182
196
|
if (!committed) {
|
|
183
197
|
adapterCtx?.adapter.reportInteraction(opts.id, response, correct);
|
|
@@ -186,7 +200,10 @@ export function useQuestion(opts: UseQuestionOptions): UseQuestionHandle {
|
|
|
186
200
|
if (opts.graded && navCtx) {
|
|
187
201
|
const pageIndex = navCtx.nav.currentPageIndex;
|
|
188
202
|
navCtx.progress.markStandaloneQuestion(pageIndex, opts.id, score, true);
|
|
189
|
-
navCtx.progress.recalculateCompletion(
|
|
203
|
+
navCtx.progress.recalculateCompletion(
|
|
204
|
+
navCtx.manifest.totalPages,
|
|
205
|
+
navCtx.config,
|
|
206
|
+
);
|
|
190
207
|
navCtx.progress.recalculateSuccess(navCtx.config);
|
|
191
208
|
} else if (navCtx) {
|
|
192
209
|
const pageIndex = navCtx.nav.currentPageIndex;
|
|
@@ -211,21 +228,41 @@ export function useQuestion(opts: UseQuestionOptions): UseQuestionHandle {
|
|
|
211
228
|
}
|
|
212
229
|
|
|
213
230
|
return {
|
|
214
|
-
get id() {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
get
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
get
|
|
231
|
+
get id() {
|
|
232
|
+
return opts.id;
|
|
233
|
+
},
|
|
234
|
+
get submitted() {
|
|
235
|
+
return submitted;
|
|
236
|
+
},
|
|
237
|
+
get correct() {
|
|
238
|
+
return correct;
|
|
239
|
+
},
|
|
240
|
+
get answer() {
|
|
241
|
+
return currentAnswer;
|
|
242
|
+
},
|
|
243
|
+
get feedbackVisible() {
|
|
244
|
+
return submitted;
|
|
245
|
+
},
|
|
246
|
+
get locked() {
|
|
247
|
+
return submitted;
|
|
248
|
+
},
|
|
249
|
+
get isLockedCorrect() {
|
|
250
|
+
return submitted && correct === true && retryCount >= maxRetries;
|
|
251
|
+
},
|
|
221
252
|
render: undefined,
|
|
222
|
-
setAnswer(a: unknown) {
|
|
253
|
+
setAnswer(a: unknown) {
|
|
254
|
+
currentAnswer = a;
|
|
255
|
+
},
|
|
223
256
|
commit,
|
|
224
257
|
submit,
|
|
225
258
|
reset,
|
|
226
259
|
retry,
|
|
227
|
-
get canRetry() {
|
|
228
|
-
|
|
260
|
+
get canRetry() {
|
|
261
|
+
return retryCount < maxRetries;
|
|
262
|
+
},
|
|
263
|
+
get retryCount() {
|
|
264
|
+
return retryCount;
|
|
265
|
+
},
|
|
229
266
|
mode: 'standalone' as const,
|
|
230
267
|
setRender() {},
|
|
231
268
|
};
|
|
@@ -234,18 +271,34 @@ export function useQuestion(opts: UseQuestionOptions): UseQuestionHandle {
|
|
|
234
271
|
export function useNavigation() {
|
|
235
272
|
const { nav, manifest } = requireNavContext('useNavigation()');
|
|
236
273
|
return {
|
|
237
|
-
get currentPage() {
|
|
238
|
-
|
|
239
|
-
|
|
274
|
+
get currentPage() {
|
|
275
|
+
return manifest.pages[nav.currentPageIndex];
|
|
276
|
+
},
|
|
277
|
+
get currentPageIndex() {
|
|
278
|
+
return nav.currentPageIndex;
|
|
279
|
+
},
|
|
280
|
+
get pages() {
|
|
281
|
+
return manifest.pages;
|
|
282
|
+
},
|
|
240
283
|
goTo(slug: string) {
|
|
241
284
|
const index = manifest.pages.findIndex((p) => p.slug === slug);
|
|
242
285
|
if (index >= 0) nav.goToPage(index);
|
|
243
286
|
},
|
|
244
|
-
goToIndex(index: number) {
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
287
|
+
goToIndex(index: number) {
|
|
288
|
+
nav.goToPage(index);
|
|
289
|
+
},
|
|
290
|
+
next() {
|
|
291
|
+
nav.goNext();
|
|
292
|
+
},
|
|
293
|
+
prev() {
|
|
294
|
+
nav.goPrev();
|
|
295
|
+
},
|
|
296
|
+
get canGoNext() {
|
|
297
|
+
return nav.canGoNext;
|
|
298
|
+
},
|
|
299
|
+
get canGoPrev() {
|
|
300
|
+
return nav.canGoPrev;
|
|
301
|
+
},
|
|
249
302
|
canAccess(slug: string) {
|
|
250
303
|
const index = manifest.pages.findIndex((p) => p.slug === slug);
|
|
251
304
|
return index >= 0 && !nav.isPageLocked(index);
|
|
@@ -256,12 +309,24 @@ export function useNavigation() {
|
|
|
256
309
|
export function useProgress() {
|
|
257
310
|
const { progress } = requireNavContext('useProgress()');
|
|
258
311
|
return {
|
|
259
|
-
get visitedPages() {
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
get
|
|
263
|
-
|
|
264
|
-
|
|
312
|
+
get visitedPages() {
|
|
313
|
+
return progress.visitedPages;
|
|
314
|
+
},
|
|
315
|
+
get quizScores() {
|
|
316
|
+
return progress.quizScores;
|
|
317
|
+
},
|
|
318
|
+
get chunkProgress() {
|
|
319
|
+
return progress.chunkProgress;
|
|
320
|
+
},
|
|
321
|
+
get completionStatus() {
|
|
322
|
+
return progress.completionStatus;
|
|
323
|
+
},
|
|
324
|
+
get successStatus() {
|
|
325
|
+
return progress.successStatus;
|
|
326
|
+
},
|
|
327
|
+
markVisited(pageIndex: number) {
|
|
328
|
+
progress.markVisited(pageIndex);
|
|
329
|
+
},
|
|
265
330
|
markChunk(pageIndex: number, chunkIndex: number) {
|
|
266
331
|
progress.markChunk(pageIndex, chunkIndex);
|
|
267
332
|
},
|
|
@@ -278,7 +343,7 @@ export function useCompletion(): {
|
|
|
278
343
|
markComplete(): void;
|
|
279
344
|
readonly completionStatus: 'incomplete' | 'complete';
|
|
280
345
|
} {
|
|
281
|
-
const { progress,
|
|
346
|
+
const { progress, config } = requireNavContext('useCompletion()');
|
|
282
347
|
return {
|
|
283
348
|
markComplete() {
|
|
284
349
|
if (config.completion.mode !== 'manual') {
|
|
@@ -286,7 +351,7 @@ export function useCompletion(): {
|
|
|
286
351
|
warnedNonManualCompletion = true;
|
|
287
352
|
console.warn(
|
|
288
353
|
"Tessera: useCompletion().markComplete() ignored — completion.mode is not 'manual'. " +
|
|
289
|
-
'(This warning is shown once per session.)'
|
|
354
|
+
'(This warning is shown once per session.)',
|
|
290
355
|
);
|
|
291
356
|
}
|
|
292
357
|
return;
|
|
@@ -300,14 +365,20 @@ export function useCompletion(): {
|
|
|
300
365
|
};
|
|
301
366
|
}
|
|
302
367
|
|
|
303
|
-
export function usePersistence<T = unknown>(
|
|
368
|
+
export function usePersistence<T = unknown>(
|
|
369
|
+
key: string,
|
|
370
|
+
): {
|
|
304
371
|
get(): T | null;
|
|
305
372
|
set(value: T): void;
|
|
306
373
|
} {
|
|
307
374
|
const store = requireUserStateStore('usePersistence()');
|
|
308
375
|
return {
|
|
309
|
-
get(): T | null {
|
|
310
|
-
|
|
376
|
+
get(): T | null {
|
|
377
|
+
return (store.get(key) as T | null) ?? null;
|
|
378
|
+
},
|
|
379
|
+
set(value: T) {
|
|
380
|
+
store.set(key, value);
|
|
381
|
+
},
|
|
311
382
|
};
|
|
312
383
|
}
|
|
313
384
|
|
|
@@ -359,15 +430,6 @@ export interface UseQuizInternalHandle extends UseQuizHandle {
|
|
|
359
430
|
isLockedCorrect(index: number): boolean;
|
|
360
431
|
}
|
|
361
432
|
|
|
362
|
-
interface InternalQuestion {
|
|
363
|
-
id: string;
|
|
364
|
-
weight: number;
|
|
365
|
-
checkAnswer: (answer?: unknown) => boolean;
|
|
366
|
-
reset?: () => void;
|
|
367
|
-
interaction?: () => Interaction;
|
|
368
|
-
render: unknown;
|
|
369
|
-
}
|
|
370
|
-
|
|
371
433
|
export function __warnUnsubmittedQuiz(stats: {
|
|
372
434
|
questionsCount: number;
|
|
373
435
|
answersCount: number;
|
|
@@ -378,7 +440,7 @@ export function __warnUnsubmittedQuiz(stats: {
|
|
|
378
440
|
console.warn(
|
|
379
441
|
'[tessera] useQuiz: submit() was never called before unmount, but the learner answered ' +
|
|
380
442
|
`${stats.answersCount} of ${stats.questionsCount} questions. ` +
|
|
381
|
-
'Did your custom quiz shell forget to call handle.submit()?'
|
|
443
|
+
'Did your custom quiz shell forget to call handle.submit()?',
|
|
382
444
|
);
|
|
383
445
|
}
|
|
384
446
|
|
|
@@ -386,19 +448,20 @@ export function __warnEmptyQuiz(questionsCount: number): void {
|
|
|
386
448
|
if (questionsCount > 0) return;
|
|
387
449
|
console.warn(
|
|
388
450
|
'[tessera] useQuiz: quiz mounted with no registered questions. Question widgets ' +
|
|
389
|
-
'must call useQuestion() to be scored and reported to the LMS.'
|
|
451
|
+
'must call useQuestion() to be scored and reported to the LMS.',
|
|
390
452
|
);
|
|
391
453
|
}
|
|
392
454
|
|
|
393
|
-
export function useQuiz(opts: {
|
|
455
|
+
export function useQuiz(opts: {
|
|
456
|
+
element: () => HTMLElement | null;
|
|
457
|
+
}): UseQuizHandle {
|
|
394
458
|
const pageCtx = getPageContext();
|
|
395
459
|
const adapterCtx = getAdapterContext();
|
|
396
460
|
if (!pageCtx?.quiz) {
|
|
397
461
|
throw new Error(
|
|
398
|
-
'useQuiz() must be called on a page with a quiz config (export const pageConfig = { quiz: { ... } }).'
|
|
462
|
+
'useQuiz() must be called on a page with a quiz config (export const pageConfig = { quiz: { ... } }).',
|
|
399
463
|
);
|
|
400
464
|
}
|
|
401
|
-
const quizConfig = pageCtx.quiz;
|
|
402
465
|
|
|
403
466
|
// A second useQuiz on the same page silently overwrites the first quiz's
|
|
404
467
|
// pageIndex-keyed score; warn but don't prevent (some pages compose hosts).
|
|
@@ -406,274 +469,35 @@ export function useQuiz(opts: { element: () => HTMLElement | null }): UseQuizHan
|
|
|
406
469
|
if (existing) {
|
|
407
470
|
console.warn(
|
|
408
471
|
'[tessera] useQuiz: a second quiz registered on this page; ' +
|
|
409
|
-
'quiz scores are keyed by pageIndex and the later submit will overwrite the earlier one.'
|
|
410
|
-
);
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
const maxAttempts = quizConfig.maxAttempts ?? Infinity;
|
|
414
|
-
const policyCfg = quizConfig as QuizPolicyConfig;
|
|
415
|
-
const feedbackPredicate = resolveFeedbackMode(policyCfg);
|
|
416
|
-
const retryPredicate = resolveRetryStrategy(policyCfg);
|
|
417
|
-
|
|
418
|
-
let internalQuestions = $state<InternalQuestion[]>([]);
|
|
419
|
-
const answers = new Map<number, unknown>();
|
|
420
|
-
const reportedAnswers = new Map<number, string>();
|
|
421
|
-
let answersVersion = $state(0);
|
|
422
|
-
let submitted = $state(false);
|
|
423
|
-
let reviewing = $state(false);
|
|
424
|
-
let score = $state(0);
|
|
425
|
-
let attemptCount = $state(0);
|
|
426
|
-
const feedbackShown = new SvelteSet<number>();
|
|
427
|
-
const lockedCorrect = new SvelteSet<number>();
|
|
428
|
-
let submitCalled = false;
|
|
429
|
-
|
|
430
|
-
const seenIds = new Set<string>();
|
|
431
|
-
|
|
432
|
-
const totalQuestions = $derived(internalQuestions.length);
|
|
433
|
-
const allAnswered = $derived(
|
|
434
|
-
(void answersVersion, totalQuestions > 0 && answers.size >= totalQuestions)
|
|
435
|
-
);
|
|
436
|
-
const canSubmit = $derived(!submitted && allAnswered);
|
|
437
|
-
const canRetry = $derived(submitted && attemptCount < maxAttempts);
|
|
438
|
-
const state: 'answering' | 'submitted' | 'reviewing' = $derived(
|
|
439
|
-
reviewing ? 'reviewing' : submitted ? 'submitted' : 'answering'
|
|
440
|
-
);
|
|
441
|
-
|
|
442
|
-
function dispatch(name: string, detail?: unknown): void {
|
|
443
|
-
const el = opts.element();
|
|
444
|
-
if (!el) return;
|
|
445
|
-
el.dispatchEvent(new CustomEvent(name, { detail, bubbles: true }));
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
function setAnswerInternal(index: number, answer: unknown): void {
|
|
449
|
-
answers.set(index, answer);
|
|
450
|
-
answersVersion++;
|
|
451
|
-
dispatch('tessera-quiz-question-answered', { index });
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
function commitInternal(index: number): void {
|
|
455
|
-
if (!adapterCtx) return;
|
|
456
|
-
const q = internalQuestions[index];
|
|
457
|
-
if (!q || typeof q.interaction !== 'function') return;
|
|
458
|
-
const interaction = q.interaction();
|
|
459
|
-
if (!interaction) return;
|
|
460
|
-
const fingerprint = JSON.stringify(interaction);
|
|
461
|
-
if (reportedAnswers.get(index) === fingerprint) return;
|
|
462
|
-
const answer = answers.has(index) ? answers.get(index) : undefined;
|
|
463
|
-
adapterCtx.adapter.reportInteraction(q.id, interaction, q.checkAnswer(answer));
|
|
464
|
-
reportedAnswers.set(index, fingerprint);
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
function getAnswerInternal(index: number): unknown {
|
|
468
|
-
void answersVersion;
|
|
469
|
-
return answers.get(index);
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
function setRenderInternal(index: number, render: unknown): void {
|
|
473
|
-
if (internalQuestions[index]) internalQuestions[index].render = render;
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
function getRenderInternal(index: number): unknown {
|
|
477
|
-
return internalQuestions[index]?.render;
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
function feedbackVisibleInternal(index: number): boolean {
|
|
481
|
-
if (policyCfg.feedbackMode === 'never') return false;
|
|
482
|
-
return feedbackPredicate({
|
|
483
|
-
questionIndex: index,
|
|
484
|
-
submitted,
|
|
485
|
-
reviewing,
|
|
486
|
-
hasAnswered: answers.has(index),
|
|
487
|
-
revealed: feedbackShown.has(index),
|
|
488
|
-
attemptCount,
|
|
489
|
-
});
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
function revealFeedbackInternal(index: number): void {
|
|
493
|
-
if (policyCfg.feedbackMode === 'never') return;
|
|
494
|
-
feedbackShown.add(index);
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
function isLockedCorrectInternal(index: number): boolean {
|
|
498
|
-
return lockedCorrect.has(index);
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
function makeQuestionHandle(i: number): QuestionInternal {
|
|
502
|
-
return {
|
|
503
|
-
get id() { return internalQuestions[i].id; },
|
|
504
|
-
get submitted() { return submitted; },
|
|
505
|
-
get correct() {
|
|
506
|
-
if (!submitted) return null;
|
|
507
|
-
const a = answers.has(i) ? answers.get(i) : undefined;
|
|
508
|
-
return internalQuestions[i].checkAnswer(a);
|
|
509
|
-
},
|
|
510
|
-
get answer() { return getAnswerInternal(i); },
|
|
511
|
-
get feedbackVisible() { return feedbackVisibleInternal(i); },
|
|
512
|
-
get locked() {
|
|
513
|
-
return submitted || feedbackVisibleInternal(i) || isLockedCorrectInternal(i);
|
|
514
|
-
},
|
|
515
|
-
get isLockedCorrect() { return isLockedCorrectInternal(i); },
|
|
516
|
-
get render() { return getRenderInternal(i); },
|
|
517
|
-
setAnswer(a: unknown) { setAnswerInternal(i, a); },
|
|
518
|
-
commit() { commitInternal(i); },
|
|
519
|
-
setRender(r: unknown) { setRenderInternal(i, r); },
|
|
520
|
-
};
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
let questionHandles = $state<QuestionInternal[]>([]);
|
|
524
|
-
|
|
525
|
-
function registerQuestion(api: UseQuizQuestionApi): QuestionInternal {
|
|
526
|
-
if (seenIds.has(api.id)) {
|
|
527
|
-
console.warn(
|
|
528
|
-
`[tessera] useQuiz: duplicate question id "${api.id}" — ` +
|
|
529
|
-
'each question id must be unique within a quiz (LMS interaction records key by id).'
|
|
530
|
-
);
|
|
531
|
-
}
|
|
532
|
-
seenIds.add(api.id);
|
|
533
|
-
const internal: InternalQuestion = {
|
|
534
|
-
id: api.id,
|
|
535
|
-
weight: typeof api.weight === 'number' && api.weight > 0 ? api.weight : 1,
|
|
536
|
-
checkAnswer: api.checkAnswer,
|
|
537
|
-
reset: api.reset,
|
|
538
|
-
interaction: api.interaction,
|
|
539
|
-
render: undefined,
|
|
540
|
-
};
|
|
541
|
-
internalQuestions.push(internal);
|
|
542
|
-
const handle = makeQuestionHandle(internalQuestions.length - 1);
|
|
543
|
-
questionHandles.push(handle);
|
|
544
|
-
return handle;
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
function computeScore(): { rounded: number; correctCount: number } {
|
|
548
|
-
let weighted = 0;
|
|
549
|
-
let totalWeight = 0;
|
|
550
|
-
let correctCount = 0;
|
|
551
|
-
for (let i = 0; i < internalQuestions.length; i++) {
|
|
552
|
-
const q = internalQuestions[i];
|
|
553
|
-
const a = answers.has(i) ? answers.get(i) : undefined;
|
|
554
|
-
const ok = q.checkAnswer(a);
|
|
555
|
-
totalWeight += q.weight;
|
|
556
|
-
if (ok) {
|
|
557
|
-
weighted += q.weight;
|
|
558
|
-
correctCount++;
|
|
559
|
-
}
|
|
560
|
-
}
|
|
561
|
-
if (totalWeight === 0) return { rounded: 0, correctCount: 0 };
|
|
562
|
-
return { rounded: Math.round((weighted / totalWeight) * 100), correctCount };
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
function submit(): void {
|
|
566
|
-
submitCalled = true;
|
|
567
|
-
if (submitted) return;
|
|
568
|
-
if (!allAnswered) return;
|
|
569
|
-
const el = opts.element();
|
|
570
|
-
if (!el) {
|
|
571
|
-
console.warn(
|
|
572
|
-
'[tessera] useQuiz: submit() ran but the host element was null — no LMS bridge ' +
|
|
573
|
-
'listener exists, so this score will not be persisted. Make sure your custom ' +
|
|
574
|
-
'quiz shell binds the element it passes to useQuiz({ element: () => ... }).'
|
|
575
|
-
);
|
|
576
|
-
return;
|
|
577
|
-
}
|
|
578
|
-
el.dispatchEvent(new CustomEvent('tessera-quiz-before-submit', { bubbles: true }));
|
|
579
|
-
|
|
580
|
-
for (let i = 0; i < internalQuestions.length; i++) commitInternal(i);
|
|
581
|
-
|
|
582
|
-
const { rounded } = computeScore();
|
|
583
|
-
score = rounded;
|
|
584
|
-
submitted = true;
|
|
585
|
-
attemptCount++;
|
|
586
|
-
|
|
587
|
-
el.dispatchEvent(
|
|
588
|
-
new CustomEvent('tessera-quiz-complete', {
|
|
589
|
-
detail: { score: rounded },
|
|
590
|
-
bubbles: true,
|
|
591
|
-
})
|
|
472
|
+
'quiz scores are keyed by pageIndex and the later submit will overwrite the earlier one.',
|
|
592
473
|
);
|
|
593
474
|
}
|
|
594
475
|
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
for (let i = 0; i < internalQuestions.length; i++) {
|
|
608
|
-
const a = answers.has(i) ? answers.get(i) : undefined;
|
|
609
|
-
results.push({
|
|
610
|
-
interaction: internalQuestions[i].interaction?.() ?? ({} as never),
|
|
611
|
-
correct: internalQuestions[i].checkAnswer(a),
|
|
612
|
-
weight: internalQuestions[i].weight,
|
|
613
|
-
});
|
|
614
|
-
}
|
|
615
|
-
const newLocked = retryPredicate(results);
|
|
616
|
-
const preserved = new Map<number, unknown>();
|
|
617
|
-
for (const i of newLocked) {
|
|
618
|
-
if (answers.has(i)) preserved.set(i, answers.get(i));
|
|
619
|
-
}
|
|
620
|
-
lockedCorrect.clear();
|
|
621
|
-
for (const i of newLocked) lockedCorrect.add(i);
|
|
622
|
-
answers.clear();
|
|
623
|
-
reportedAnswers.clear();
|
|
624
|
-
for (const [i, a] of preserved) answers.set(i, a);
|
|
625
|
-
for (let i = 0; i < internalQuestions.length; i++) {
|
|
626
|
-
if (!newLocked.has(i) && internalQuestions[i].reset) internalQuestions[i].reset!();
|
|
627
|
-
}
|
|
628
|
-
answersVersion++;
|
|
629
|
-
feedbackShown.clear();
|
|
630
|
-
submitted = false;
|
|
631
|
-
reviewing = false;
|
|
632
|
-
score = 0;
|
|
633
|
-
dispatch('tessera-quiz-retry');
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
function revealFeedback(q: Question): void {
|
|
637
|
-
const index = internalQuestions.findIndex((iq) => iq.id === q.id);
|
|
638
|
-
if (index >= 0) revealFeedbackInternal(index);
|
|
639
|
-
}
|
|
476
|
+
const engine = new QuizEngine({
|
|
477
|
+
quizConfig: pageCtx.quiz,
|
|
478
|
+
passingScore: () => pageCtx.passingScore,
|
|
479
|
+
report: (id, interaction, correct) =>
|
|
480
|
+
adapterCtx?.adapter.reportInteraction(id, interaction, correct),
|
|
481
|
+
dispatch: (name, detail) => {
|
|
482
|
+
const el = opts.element();
|
|
483
|
+
if (!el) return false;
|
|
484
|
+
el.dispatchEvent(new CustomEvent(name, { detail, bubbles: true }));
|
|
485
|
+
return true;
|
|
486
|
+
},
|
|
487
|
+
});
|
|
640
488
|
|
|
641
|
-
setContext<QuizContextValue>(TESSERA_QUIZ, {
|
|
489
|
+
setContext<QuizContextValue>(TESSERA_QUIZ, {
|
|
490
|
+
registerQuestion: (api) => engine.registerQuestion(api),
|
|
491
|
+
});
|
|
642
492
|
|
|
643
493
|
onMount(() => {
|
|
644
494
|
if (!import.meta.env?.DEV) return;
|
|
645
|
-
void tick().then(() => __warnEmptyQuiz(
|
|
495
|
+
void tick().then(() => __warnEmptyQuiz(engine.questions.length));
|
|
646
496
|
});
|
|
647
497
|
|
|
648
498
|
onDestroy(() => {
|
|
649
|
-
__warnUnsubmittedQuiz(
|
|
650
|
-
questionsCount: internalQuestions.length,
|
|
651
|
-
answersCount: answers.size,
|
|
652
|
-
submitCalled,
|
|
653
|
-
});
|
|
499
|
+
__warnUnsubmittedQuiz(engine.stats);
|
|
654
500
|
});
|
|
655
501
|
|
|
656
|
-
|
|
657
|
-
get state() { return state; },
|
|
658
|
-
get questions() { return questionHandles; },
|
|
659
|
-
get canSubmit() { return canSubmit; },
|
|
660
|
-
get canRetry() { return canRetry; },
|
|
661
|
-
get score() { return score; },
|
|
662
|
-
get passingScore() { return pageCtx.passingScore; },
|
|
663
|
-
get attemptCount() { return attemptCount; },
|
|
664
|
-
submit,
|
|
665
|
-
startReview,
|
|
666
|
-
exitReview,
|
|
667
|
-
retry,
|
|
668
|
-
revealFeedback,
|
|
669
|
-
registerQuestion,
|
|
670
|
-
setAnswer: setAnswerInternal,
|
|
671
|
-
getAnswer: getAnswerInternal,
|
|
672
|
-
setRender: setRenderInternal,
|
|
673
|
-
getRender: getRenderInternal,
|
|
674
|
-
feedbackVisible: feedbackVisibleInternal,
|
|
675
|
-
revealFeedbackByIndex: revealFeedbackInternal,
|
|
676
|
-
isLockedCorrect: isLockedCorrectInternal,
|
|
677
|
-
};
|
|
678
|
-
return handle;
|
|
502
|
+
return engine;
|
|
679
503
|
}
|