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.
Files changed (79) hide show
  1. package/README.md +1 -0
  2. package/dist/audit-BBJpQGqb.js +204 -0
  3. package/dist/audit-BBJpQGqb.js.map +1 -0
  4. package/dist/plugin/a11y-cli.d.ts +1 -0
  5. package/dist/plugin/a11y-cli.js +36 -0
  6. package/dist/plugin/a11y-cli.js.map +1 -0
  7. package/dist/plugin/cli.js +6 -3
  8. package/dist/plugin/cli.js.map +1 -1
  9. package/dist/plugin/index.d.ts +16 -1
  10. package/dist/plugin/index.d.ts.map +1 -1
  11. package/dist/plugin/index.js +171 -140
  12. package/dist/plugin/index.js.map +1 -1
  13. package/dist/{validation-BxWAMMnJ.js → validation-B-xTvM9B.js} +417 -81
  14. package/dist/validation-B-xTvM9B.js.map +1 -0
  15. package/package.json +17 -2
  16. package/src/components/Accordion.svelte +3 -1
  17. package/src/components/AccordionItem.svelte +1 -5
  18. package/src/components/Audio.svelte +22 -5
  19. package/src/components/Callout.svelte +5 -1
  20. package/src/components/Carousel.svelte +24 -8
  21. package/src/components/DefaultLayout.svelte +41 -12
  22. package/src/components/FillInTheBlank.svelte +75 -103
  23. package/src/components/Image.svelte +14 -10
  24. package/src/components/LockedBanner.svelte +5 -5
  25. package/src/components/Matching.svelte +48 -19
  26. package/src/components/MediaTracks.svelte +21 -0
  27. package/src/components/MultipleChoice.svelte +81 -102
  28. package/src/components/Quiz.svelte +63 -21
  29. package/src/components/ResultIcon.svelte +20 -4
  30. package/src/components/RevealModal.svelte +25 -22
  31. package/src/components/Sorting.svelte +61 -26
  32. package/src/components/Transcript.svelte +37 -0
  33. package/src/components/Video.svelte +25 -20
  34. package/src/components/util.ts +4 -1
  35. package/src/components/video-embed.ts +25 -0
  36. package/src/index.ts +2 -7
  37. package/src/plugin/a11y/audit.ts +299 -0
  38. package/src/plugin/a11y/contrast.ts +67 -0
  39. package/src/plugin/a11y-cli.ts +35 -0
  40. package/src/plugin/cli.ts +6 -8
  41. package/src/plugin/export.ts +60 -50
  42. package/src/plugin/index.ts +244 -101
  43. package/src/plugin/layout.ts +6 -51
  44. package/src/plugin/manifest.ts +90 -24
  45. package/src/plugin/override-plugin.ts +68 -0
  46. package/src/plugin/quiz.ts +9 -54
  47. package/src/plugin/validation.ts +768 -183
  48. package/src/runtime/App.svelte +128 -64
  49. package/src/runtime/LoadingBar.svelte +12 -3
  50. package/src/runtime/Sidebar.svelte +24 -8
  51. package/src/runtime/access.ts +15 -3
  52. package/src/runtime/adapters/cmi5.ts +68 -116
  53. package/src/runtime/adapters/format.ts +67 -0
  54. package/src/runtime/adapters/index.ts +45 -34
  55. package/src/runtime/adapters/retry.ts +25 -84
  56. package/src/runtime/adapters/scorm-base.ts +19 -15
  57. package/src/runtime/adapters/scorm12.ts +8 -9
  58. package/src/runtime/adapters/scorm2004.ts +22 -30
  59. package/src/runtime/adapters/web.ts +1 -1
  60. package/src/runtime/hooks.svelte.ts +152 -328
  61. package/src/runtime/interaction-format.ts +30 -12
  62. package/src/runtime/interaction.ts +44 -11
  63. package/src/runtime/navigation.svelte.ts +29 -40
  64. package/src/runtime/persistence.ts +2 -2
  65. package/src/runtime/progress.svelte.ts +22 -9
  66. package/src/runtime/quiz-engine.svelte.ts +361 -0
  67. package/src/runtime/quiz-policy.ts +28 -179
  68. package/src/runtime/types.ts +24 -2
  69. package/src/runtime/xapi/agent-rules.ts +11 -3
  70. package/src/runtime/xapi/client.ts +5 -5
  71. package/src/runtime/xapi/derive-actor.ts +2 -2
  72. package/src/runtime/xapi/publisher.ts +33 -40
  73. package/src/runtime/xapi/setup.ts +18 -15
  74. package/src/runtime/xapi/validation.ts +15 -6
  75. package/src/virtual.d.ts +4 -1
  76. package/styles/base.css +32 -11
  77. package/styles/layout.css +39 -18
  78. package/styles/theme.css +15 -3
  79. 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() { return q.id; },
133
- get submitted() { return q.submitted; },
134
- get correct() { return q.correct; },
135
- get answer() { return q.answer; },
136
- get feedbackVisible() { return q.feedbackVisible; },
137
- get locked() { return q.locked; },
138
- get isLockedCorrect() { return q.isLockedCorrect; },
139
- get render() { return q.render; },
140
- setAnswer(a: unknown) { q.setAnswer(a); },
141
- commit() { q.commit(); },
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() { opts.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) { q.setRender(render); },
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(navCtx.manifest.totalPages, navCtx.config);
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() { return opts.id; },
215
- get submitted() { return submitted; },
216
- get correct() { return correct; },
217
- get answer() { return currentAnswer; },
218
- get feedbackVisible() { return submitted; },
219
- get locked() { return submitted; },
220
- get isLockedCorrect() { return submitted && correct === true && retryCount >= maxRetries; },
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) { currentAnswer = a; },
253
+ setAnswer(a: unknown) {
254
+ currentAnswer = a;
255
+ },
223
256
  commit,
224
257
  submit,
225
258
  reset,
226
259
  retry,
227
- get canRetry() { return retryCount < maxRetries; },
228
- get retryCount() { return retryCount; },
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() { return manifest.pages[nav.currentPageIndex]; },
238
- get currentPageIndex() { return nav.currentPageIndex; },
239
- get pages() { return manifest.pages; },
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) { nav.goToPage(index); },
245
- next() { nav.goNext(); },
246
- prev() { nav.goPrev(); },
247
- get canGoNext() { return nav.canGoNext; },
248
- get canGoPrev() { return nav.canGoPrev; },
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() { return progress.visitedPages; },
260
- get quizScores() { return progress.quizScores; },
261
- get chunkProgress() { return progress.chunkProgress; },
262
- get completionStatus() { return progress.completionStatus; },
263
- get successStatus() { return progress.successStatus; },
264
- markVisited(pageIndex: number) { progress.markVisited(pageIndex); },
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, manifest, config } = requireNavContext('useCompletion()');
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>(key: string): {
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 { return (store.get(key) as T | null) ?? null; },
310
- set(value: T) { store.set(key, value); },
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: { element: () => HTMLElement | null }): UseQuizHandle {
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
- function startReview(): void {
596
- if (!submitted) return;
597
- reviewing = true;
598
- }
599
-
600
- function exitReview(): void {
601
- reviewing = false;
602
- }
603
-
604
- function retry(): void {
605
- if (!canRetry) return;
606
- const results: QuizQuestionResult[] = [];
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, { registerQuestion });
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(internalQuestions.length));
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
- const handle: UseQuizInternalHandle = {
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
  }