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.
Files changed (75) 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 +2 -1
  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 +85 -10
  12. package/dist/plugin/index.js.map +1 -1
  13. package/dist/{validation-D9DXlqNP.js → validation-B-xTvM9B.js} +342 -18
  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 +17 -3
  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 +16 -6
  23. package/src/components/Image.svelte +12 -3
  24. package/src/components/LockedBanner.svelte +2 -1
  25. package/src/components/Matching.svelte +48 -19
  26. package/src/components/MediaTracks.svelte +21 -0
  27. package/src/components/MultipleChoice.svelte +33 -13
  28. package/src/components/Quiz.svelte +61 -20
  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 +21 -18
  34. package/src/components/util.ts +3 -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 +4 -1
  41. package/src/plugin/export.ts +42 -14
  42. package/src/plugin/index.ts +216 -44
  43. package/src/plugin/manifest.ts +62 -22
  44. package/src/plugin/validation.ts +736 -122
  45. package/src/runtime/App.svelte +119 -48
  46. package/src/runtime/LoadingBar.svelte +12 -3
  47. package/src/runtime/Sidebar.svelte +24 -8
  48. package/src/runtime/access.ts +15 -3
  49. package/src/runtime/adapters/cmi5.ts +55 -33
  50. package/src/runtime/adapters/index.ts +22 -10
  51. package/src/runtime/adapters/retry.ts +25 -20
  52. package/src/runtime/adapters/scorm-base.ts +19 -15
  53. package/src/runtime/adapters/scorm12.ts +7 -8
  54. package/src/runtime/adapters/scorm2004.ts +11 -14
  55. package/src/runtime/adapters/web.ts +1 -1
  56. package/src/runtime/hooks.svelte.ts +152 -326
  57. package/src/runtime/interaction-format.ts +30 -12
  58. package/src/runtime/interaction.ts +44 -11
  59. package/src/runtime/navigation.svelte.ts +27 -11
  60. package/src/runtime/persistence.ts +2 -2
  61. package/src/runtime/progress.svelte.ts +13 -9
  62. package/src/runtime/quiz-engine.svelte.ts +361 -0
  63. package/src/runtime/quiz-policy.ts +9 -3
  64. package/src/runtime/types.ts +24 -2
  65. package/src/runtime/xapi/agent-rules.ts +4 -1
  66. package/src/runtime/xapi/client.ts +5 -5
  67. package/src/runtime/xapi/derive-actor.ts +2 -2
  68. package/src/runtime/xapi/publisher.ts +32 -29
  69. package/src/runtime/xapi/setup.ts +18 -15
  70. package/src/runtime/xapi/validation.ts +15 -6
  71. package/src/virtual.d.ts +4 -1
  72. package/styles/base.css +32 -11
  73. package/styles/layout.css +39 -18
  74. package/styles/theme.css +15 -3
  75. 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() { return q.id; },
132
- get submitted() { return q.submitted; },
133
- get correct() { return q.correct; },
134
- get answer() { return q.answer; },
135
- get feedbackVisible() { return q.feedbackVisible; },
136
- get locked() { return q.locked; },
137
- get isLockedCorrect() { return q.isLockedCorrect; },
138
- get render() { return q.render; },
139
- setAnswer(a: unknown) { q.setAnswer(a); },
140
- 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
+ },
141
156
  submit() {},
142
- reset() { opts.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) { q.setRender(render); },
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(navCtx.manifest.totalPages, navCtx.config);
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() { return opts.id; },
214
- get submitted() { return submitted; },
215
- get correct() { return correct; },
216
- get answer() { return currentAnswer; },
217
- get feedbackVisible() { return submitted; },
218
- get locked() { return submitted; },
219
- 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
+ },
220
252
  render: undefined,
221
- setAnswer(a: unknown) { currentAnswer = a; },
253
+ setAnswer(a: unknown) {
254
+ currentAnswer = a;
255
+ },
222
256
  commit,
223
257
  submit,
224
258
  reset,
225
259
  retry,
226
- get canRetry() { return retryCount < maxRetries; },
227
- get retryCount() { return retryCount; },
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() { return manifest.pages[nav.currentPageIndex]; },
237
- get currentPageIndex() { return nav.currentPageIndex; },
238
- 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
+ },
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) { nav.goToPage(index); },
244
- next() { nav.goNext(); },
245
- prev() { nav.goPrev(); },
246
- get canGoNext() { return nav.canGoNext; },
247
- 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
+ },
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() { return progress.visitedPages; },
259
- get quizScores() { return progress.quizScores; },
260
- get chunkProgress() { return progress.chunkProgress; },
261
- get completionStatus() { return progress.completionStatus; },
262
- get successStatus() { return progress.successStatus; },
263
- 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
+ },
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, manifest, config } = requireNavContext('useCompletion()');
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>(key: string): {
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 { return (store.get(key) as T | null) ?? null; },
309
- 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
+ },
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: { element: () => HTMLElement | null }): UseQuizHandle {
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
- function startReview(): void {
594
- if (!submitted) return;
595
- reviewing = true;
596
- }
597
-
598
- function exitReview(): void {
599
- reviewing = false;
600
- }
601
-
602
- function retry(): void {
603
- if (!canRetry) return;
604
- const results: QuizQuestionResult[] = [];
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, { registerQuestion });
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(internalQuestions.length));
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
- const handle: UseQuizInternalHandle = {
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
  }