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