tessera-learn 0.0.7 → 0.0.9

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.
@@ -8,8 +8,6 @@ import {
8
8
  getPageContext,
9
9
  requireUserStateStore,
10
10
  } from './contexts.js';
11
- import { buildQuizInteractions } from '../components/quiz-payload.js';
12
- import type { QuizContext } from '../components/quiz-payload.js';
13
11
  import {
14
12
  resolveFeedbackMode,
15
13
  resolveRetryStrategy,
@@ -17,79 +15,136 @@ import {
17
15
  type QuizQuestionResult,
18
16
  } from './quiz-policy.js';
19
17
 
18
+ /**
19
+ * Per-question handle exposed to both the quiz shell (via `useQuiz().questions`)
20
+ * and the question widget (via `useQuestion()`). All state and operations for
21
+ * one question live on this object — no index plumbing.
22
+ */
23
+ export interface Question {
24
+ /** Stable id used as the LMS interaction key. */
25
+ readonly id: string;
26
+ /** True once the quiz containing this question has been submitted. */
27
+ readonly submitted: boolean;
28
+ /** True/false once submitted; null while answering. */
29
+ readonly correct: boolean | null;
30
+ /** Current learner answer, or undefined if not yet answered. */
31
+ readonly answer: unknown;
32
+ /** Whether feedback should currently render for this question. */
33
+ readonly feedbackVisible: boolean;
34
+ /**
35
+ * True when the widget must treat its input as read-only — either because
36
+ * the quiz has been submitted, feedback is showing, or the answer is locked
37
+ * by a retry policy. Widgets should branch on this alone; the engine owns
38
+ * the composition.
39
+ */
40
+ readonly locked: boolean;
41
+ /**
42
+ * Narrow case of `locked`: the answer is preserved as "already correct" by
43
+ * a retry policy (e.g. `retryMode: 'incorrect-only'`). Use this to show an
44
+ * explicit banner; use `locked` to gate input.
45
+ */
46
+ readonly isLockedCorrect: boolean;
47
+ /** Snippet the widget registered with `setRender` (shell calls `{@render q.render()}`). */
48
+ readonly render: unknown;
49
+ /** Record the learner's current answer. Called from the widget on user input. */
50
+ setAnswer(answer: unknown): void;
51
+ /** Signal the answer is final; triggers the per-question LMS write. */
52
+ commit(): void;
53
+ }
54
+
20
55
  export interface UseQuestionOptions {
21
56
  /** Stable identifier used for LMS interaction reporting. Must be unique on the page. */
22
57
  id: string;
23
58
  /** Whether this question counts toward course success status. Default false. */
24
59
  graded?: boolean;
25
60
  /**
26
- * Optional weight for quiz scoring — only used inside a `<Quiz>` (or `useQuiz`)
27
- * host that aggregates with the weighted formula `Σ(w·correct)/Σ(w)*100`.
28
- * Default 1; ignored in standalone mode.
61
+ * Optional weight for quiz scoring — used inside a quiz host that aggregates
62
+ * with the weighted formula `Σ(w·correct)/Σ(w)·100`. Default 1; ignored in
63
+ * standalone mode.
29
64
  */
30
65
  weight?: number;
31
- /** Standalone retry cap. Default `Infinity`. Ignored inside a `<Quiz>`. */
66
+ /** Standalone retry cap. Default `Infinity`. Ignored inside a quiz. */
32
67
  maxRetries?: number;
33
68
  /** Called on submit — returns the current learner response payload. */
34
69
  response: () => Interaction;
35
70
  /**
36
- * Optional score override (0–100). Standalone mode only — per-question scoring
37
- * inside a `<Quiz>` is the quiz's responsibility.
71
+ * Optional score override (0–100). Standalone mode only — per-question
72
+ * scoring inside a quiz is the quiz's responsibility.
38
73
  */
39
74
  score?: () => number;
40
75
  /** Optional reset handler invoked when the learner tries again. */
41
76
  reset?: () => void;
42
- /** Optional Svelte snippet the parent `<Quiz>` renders for this question. Ignored in standalone mode. */
43
- render?: unknown;
44
77
  }
45
78
 
46
- export interface UseQuestionHandle {
79
+ /**
80
+ * Question handle plus standalone-only operations. Inside a quiz, the
81
+ * standalone-only methods are no-ops (the quiz shell drives submission /
82
+ * retry). `mode` reflects which environment the widget mounted into.
83
+ */
84
+ export interface UseQuestionHandle extends Question {
85
+ /** Standalone submit. No-op inside a quiz (the shell drives submission). */
47
86
  submit(): void;
87
+ /** Reset the widget's own state. */
48
88
  reset(): void;
49
- /** Standalone retry. No-op once `maxRetries` is hit or inside a `<Quiz>`. */
89
+ /** Standalone retry. No-op once `maxRetries` is hit or inside a quiz. */
50
90
  retry(): void;
51
- readonly submitted: boolean;
52
- readonly correct: boolean | null;
53
91
  readonly canRetry: boolean;
54
92
  readonly retryCount: number;
55
93
  readonly mode: 'standalone' | 'quiz';
56
- /** Index returned by the parent Quiz registration, used for per-question context reads. Undefined in standalone mode. */
57
- readonly quizIndex: number | undefined;
94
+ /**
95
+ * Register a Svelte snippet for the quiz shell to render at its chosen
96
+ * location. Standalone widgets don't need this — they render their own UI.
97
+ */
98
+ setRender(render: unknown): void;
99
+ }
100
+
101
+ const TESSERA_QUIZ = 'tessera-quiz' as const;
102
+
103
+ interface QuestionInternal extends Question {
104
+ setRender(render: unknown): void;
105
+ }
106
+
107
+ interface QuizContextValue {
108
+ registerQuestion(api: UseQuizQuestionApi): QuestionInternal;
58
109
  }
59
110
 
60
111
  /**
61
- * Register a question widget with the Tessera runtime. Works outside a `<Quiz>`
62
- * for inline practice, and inside a `<Quiz>` wrapper — the same hook drives both
63
- * modes. Inside a Quiz the handle's `submit()` is a no-op (the parent Quiz drives
112
+ * Register a question widget with the Tessera runtime. Works outside a quiz
113
+ * for inline practice, and inside a quiz host — the same hook drives both
114
+ * modes. Inside a quiz, `submit()` is a no-op (the parent quiz drives
64
115
  * submission) and `submitted`/`correct` mirror the quiz's state.
65
116
  */
66
117
  export function useQuestion(opts: UseQuestionOptions): UseQuestionHandle {
67
- const quizCtx = getContext<QuizContext | undefined>('tessera-quiz');
118
+ const quizCtx = getContext<QuizContextValue | undefined>(TESSERA_QUIZ);
68
119
  const navCtx = getNavContext();
69
120
  const adapterCtx = getAdapterContext();
70
121
 
71
- if (quizCtx?.registerQuestion) {
72
- const quizIndex = quizCtx.registerQuestion({
122
+ if (quizCtx) {
123
+ const q = quizCtx.registerQuestion({
73
124
  id: opts.id,
74
125
  weight: opts.weight,
75
126
  checkAnswer: () => isCorrectInteraction(opts.response()) === true,
76
127
  reset: opts.reset,
77
128
  interaction: () => opts.response(),
78
- render: opts.render,
79
129
  });
80
130
  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(); },
81
141
  submit() {},
82
142
  reset() { opts.reset?.(); },
83
143
  retry() {},
84
- get submitted() { return quizCtx.submitted ?? false; },
85
- get correct() {
86
- if (!(quizCtx.submitted ?? false)) return null;
87
- return isCorrectInteraction(opts.response());
88
- },
89
144
  canRetry: false,
90
145
  retryCount: 0,
91
146
  mode: 'quiz' as const,
92
- quizIndex,
147
+ setRender(render: unknown) { q.setRender(render); },
93
148
  };
94
149
  }
95
150
 
@@ -97,10 +152,25 @@ export function useQuestion(opts: UseQuestionOptions): UseQuestionHandle {
97
152
  let submitted = $state(false);
98
153
  let correct = $state<boolean | null>(null);
99
154
  let retryCount = $state(0);
155
+ let currentAnswer = $state<unknown>(undefined);
156
+
157
+ let committed = false;
158
+
159
+ function commit() {
160
+ const response = opts.response();
161
+ if (!response) return;
162
+ committed = true;
163
+ adapterCtx?.adapter.reportInteraction(
164
+ opts.id,
165
+ response,
166
+ isCorrectInteraction(response)
167
+ );
168
+ }
100
169
 
101
170
  function submit() {
102
171
  if (submitted) return;
103
172
  const response = opts.response();
173
+ currentAnswer = response.response;
104
174
  correct = isCorrectInteraction(response);
105
175
  const score = opts.score
106
176
  ? opts.score()
@@ -108,7 +178,10 @@ export function useQuestion(opts: UseQuestionOptions): UseQuestionHandle {
108
178
  ? 100
109
179
  : 0;
110
180
 
111
- adapterCtx?.adapter.reportInteraction(opts.id, response, correct);
181
+ if (!committed) {
182
+ adapterCtx?.adapter.reportInteraction(opts.id, response, correct);
183
+ committed = true;
184
+ }
112
185
  if (opts.graded && navCtx) {
113
186
  const pageIndex = navCtx.nav.currentPageIndex;
114
187
  navCtx.progress.markStandaloneQuestion(pageIndex, opts.id, score, true);
@@ -125,6 +198,8 @@ export function useQuestion(opts: UseQuestionOptions): UseQuestionHandle {
125
198
  function reset() {
126
199
  submitted = false;
127
200
  correct = null;
201
+ currentAnswer = undefined;
202
+ committed = false;
128
203
  opts.reset?.();
129
204
  }
130
205
 
@@ -135,22 +210,26 @@ export function useQuestion(opts: UseQuestionOptions): UseQuestionHandle {
135
210
  }
136
211
 
137
212
  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; },
220
+ render: undefined,
221
+ setAnswer(a: unknown) { currentAnswer = a; },
222
+ commit,
138
223
  submit,
139
224
  reset,
140
225
  retry,
141
- get submitted() { return submitted; },
142
- get correct() { return correct; },
143
226
  get canRetry() { return retryCount < maxRetries; },
144
227
  get retryCount() { return retryCount; },
145
228
  mode: 'standalone' as const,
146
- quizIndex: undefined,
229
+ setRender() {},
147
230
  };
148
231
  }
149
232
 
150
- /**
151
- * Access Tessera navigation imperatively — programmatic go-to, next/prev,
152
- * and the active page.
153
- */
154
233
  export function useNavigation() {
155
234
  const { nav, manifest } = requireNavContext('useNavigation()');
156
235
  return {
@@ -173,9 +252,6 @@ export function useNavigation() {
173
252
  };
174
253
  }
175
254
 
176
- /**
177
- * Access Tessera progress state imperatively.
178
- */
179
255
  export function useProgress() {
180
256
  const { progress } = requireNavContext('useProgress()');
181
257
  return {
@@ -191,19 +267,12 @@ export function useProgress() {
191
267
  };
192
268
  }
193
269
 
194
- // One dev warning per session, regardless of caller count.
195
270
  let warnedNonManualCompletion = false;
196
271
 
197
- /** Test-only: reset the once-per-session warning latch. */
198
272
  export function __resetUseCompletionWarning(): void {
199
273
  warnedNonManualCompletion = false;
200
274
  }
201
275
 
202
- /**
203
- * Trigger course completion from any component, and reactively read the
204
- * current completion status. Active under `completion.mode: "manual"`; a
205
- * no-op (with a one-shot dev warning) under any other mode.
206
- */
207
276
  export function useCompletion(): {
208
277
  markComplete(): void;
209
278
  readonly completionStatus: 'incomplete' | 'complete';
@@ -230,11 +299,6 @@ export function useCompletion(): {
230
299
  };
231
300
  }
232
301
 
233
- /**
234
- * Scoped persistence — save and restore per-widget state that survives reload.
235
- * Routes to whichever adapter the course is running under (localStorage, SCORM
236
- * suspend_data, or xAPI State API).
237
- */
238
302
  export function usePersistence<T = unknown>(key: string): {
239
303
  get(): T | null;
240
304
  set(value: T): void;
@@ -246,57 +310,63 @@ export function usePersistence<T = unknown>(key: string): {
246
310
  };
247
311
  }
248
312
 
249
- // ---------- useQuiz ----------
250
-
251
313
  /**
252
- * Per-question registration shape accepted by `useQuiz().registerQuestion`.
253
- * Mirrors the QuizQuestionApi used by built-in `<Quiz>` plus an optional
254
- * `weight` for the weighted score formula.
314
+ * Internal registration shape `useQuestion` builds this and hands it to the
315
+ * quiz's `registerQuestion`. Not part of the public authoring API.
255
316
  */
256
317
  export interface UseQuizQuestionApi {
257
318
  id: string;
258
- /** Optional weight for the score rollup. Default 1 — `Σ(w·correct)/Σ(w)*100`. */
319
+ /** Optional weight for the score rollup. Default 1 — `Σ(w·correct)/Σ(w)·100`. */
259
320
  weight?: number;
260
321
  checkAnswer: (answer?: unknown) => boolean;
261
322
  reset?: () => void;
262
- /** Optional Svelte snippet the quiz host renders for this question. */
263
- render?: unknown;
264
- /** Optional — when present, included in the `tessera-quiz-complete` event payload. */
323
+ /** Returns the current Interaction payload for LMS reporting. */
265
324
  interaction?: () => Interaction;
266
325
  }
267
326
 
268
- export interface UseQuizQuestionView {
269
- readonly id: string;
270
- readonly correct: boolean | null;
271
- readonly submitted: boolean;
272
- }
273
-
274
327
  export interface UseQuizHandle {
275
328
  readonly state: 'answering' | 'submitted' | 'reviewing';
276
- readonly questions: UseQuizQuestionView[];
329
+ readonly questions: ReadonlyArray<Question>;
277
330
  readonly canSubmit: boolean;
278
331
  readonly canRetry: boolean;
279
332
  readonly score: number;
333
+ /** Resolved passing threshold (config + LMS mastery override). */
334
+ readonly passingScore: number;
280
335
  readonly attemptCount: number;
281
- registerQuestion(api: UseQuizQuestionApi): number;
282
- setAnswer(index: number, answer: unknown): void;
283
- getAnswer(index: number): unknown;
284
336
  submit(): void;
285
337
  startReview(): void;
286
338
  exitReview(): void;
287
339
  retry(): void;
288
- revealFeedback(index: number): void;
289
- feedbackVisible(index: number): boolean;
340
+ /** Reveal feedback for the given question. */
341
+ revealFeedback(q: Question): void;
342
+ }
343
+
344
+ /**
345
+ * Internal test/component seam. The implementation also exposes index-keyed
346
+ * methods on the returned object so unit tests can drive the engine directly
347
+ * and the built-in `<Quiz>` can iterate by index. NOT part of the public API
348
+ * — authors should use `quiz.questions[].setAnswer(...)` etc.
349
+ */
350
+ export interface UseQuizInternalHandle extends UseQuizHandle {
351
+ registerQuestion(api: UseQuizQuestionApi): Question;
352
+ setAnswer(index: number, answer: unknown): void;
353
+ getAnswer(index: number): unknown;
290
354
  setRender(index: number, render: unknown): void;
291
355
  getRender(index: number): unknown;
356
+ feedbackVisible(index: number): boolean;
357
+ revealFeedbackByIndex(index: number): void;
292
358
  isLockedCorrect(index: number): boolean;
293
359
  }
294
360
 
295
- /**
296
- * Dev warning helper for quizzes that unmount with answered questions but no
297
- * submit() call. Exported so `use-quiz.test.ts` can exercise the warning code
298
- * path without relying on jsdom's onDestroy timing under vitest.
299
- */
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
+
300
370
  export function __warnUnsubmittedQuiz(stats: {
301
371
  questionsCount: number;
302
372
  answersCount: number;
@@ -311,12 +381,6 @@ export function __warnUnsubmittedQuiz(stats: {
311
381
  );
312
382
  }
313
383
 
314
- /**
315
- * Dev warning helper for a quiz host that mounts with no questions registered
316
- * through useQuestion(). Such a page has a quiz wrapper but nothing the runtime
317
- * can score or report to the LMS. Exported so tests can exercise the warning
318
- * without depending on jsdom mount timing under vitest.
319
- */
320
384
  export function __warnEmptyQuiz(questionsCount: number): void {
321
385
  if (questionsCount > 0) return;
322
386
  console.warn(
@@ -325,21 +389,9 @@ export function __warnEmptyQuiz(questionsCount: number): void {
325
389
  );
326
390
  }
327
391
 
328
- /**
329
- * Programmatic quiz orchestration for custom quiz shells. Returns a handle
330
- * exposing the same state machine `<Quiz>` runs internally — register
331
- * questions, set answers, submit, review, retry — but with no template
332
- * baked in, so authors can build any UI on top.
333
- *
334
- * Reads quiz config from the `tessera-page` context (set by App.svelte) and
335
- * publishes a `tessera-quiz` context for `useQuestion` widgets to consume.
336
- *
337
- * The host element passed via `opts.element()` is what `tessera-quiz-*` DOM
338
- * events dispatch from. App.svelte's bridge listens on `#tessera-app` and
339
- * forwards `tessera-quiz-complete` into the persistence adapter.
340
- */
341
392
  export function useQuiz(opts: { element: () => HTMLElement | null }): UseQuizHandle {
342
393
  const pageCtx = getPageContext();
394
+ const adapterCtx = getAdapterContext();
343
395
  if (!pageCtx?.quiz) {
344
396
  throw new Error(
345
397
  'useQuiz() must be called on a page with a quiz config (export const pageConfig = { quiz: { ... } }).'
@@ -347,11 +399,9 @@ export function useQuiz(opts: { element: () => HTMLElement | null }): UseQuizHan
347
399
  }
348
400
  const quizConfig = pageCtx.quiz;
349
401
 
350
- // Dev-mode warning: a second useQuiz on the same page silently overwrites
351
- // the first quiz's pageIndex-keyed score. We can't prevent it (some pages
352
- // really do compose multiple quiz hosts in dev experiments) but the
353
- // multi-quiz writer should know.
354
- const existing = getContext<unknown>('tessera-quiz');
402
+ // A second useQuiz on the same page silently overwrites the first quiz's
403
+ // pageIndex-keyed score; warn but don't prevent (some pages compose hosts).
404
+ const existing = getContext<unknown>(TESSERA_QUIZ);
355
405
  if (existing) {
356
406
  console.warn(
357
407
  '[tessera] useQuiz: a second quiz registered on this page; ' +
@@ -360,23 +410,13 @@ export function useQuiz(opts: { element: () => HTMLElement | null }): UseQuizHan
360
410
  }
361
411
 
362
412
  const maxAttempts = quizConfig.maxAttempts ?? Infinity;
363
- const showFeedback = quizConfig.showFeedback ?? true;
364
-
365
413
  const policyCfg = quizConfig as QuizPolicyConfig;
366
414
  const feedbackPredicate = resolveFeedbackMode(policyCfg);
367
415
  const retryPredicate = resolveRetryStrategy(policyCfg);
368
- // Lock the answer once feedback is revealed in 'immediate' mode and under any
369
- // custom predicate (opaque; lock conservatively). 'review' mode is post-submit.
370
- const revealsLockAnswer =
371
- policyCfg.feedbackMode === 'immediate' ||
372
- typeof policyCfg.feedbackMode === 'function';
373
-
374
- interface InternalQuestion extends UseQuizQuestionApi {
375
- weight: number;
376
- }
377
416
 
378
- let questions = $state<InternalQuestion[]>([]);
417
+ let internalQuestions = $state<InternalQuestion[]>([]);
379
418
  const answers = new Map<number, unknown>();
419
+ const reportedAnswers = new Map<number, string>();
380
420
  let answersVersion = $state(0);
381
421
  let submitted = $state(false);
382
422
  let reviewing = $state(false);
@@ -388,10 +428,7 @@ export function useQuiz(opts: { element: () => HTMLElement | null }): UseQuizHan
388
428
 
389
429
  const seenIds = new Set<string>();
390
430
 
391
- const totalQuestions = $derived(questions.length);
392
- // Match the built-in <Quiz> rule: every registered question has an entry in
393
- // `answers`. We track the map via an explicit version counter rather than
394
- // a reactive Map proxy so $derived reliably re-runs across `set()` calls.
431
+ const totalQuestions = $derived(internalQuestions.length);
395
432
  const allAnswered = $derived(
396
433
  (void answersVersion, totalQuestions > 0 && answers.size >= totalQuestions)
397
434
  );
@@ -403,67 +440,44 @@ export function useQuiz(opts: { element: () => HTMLElement | null }): UseQuizHan
403
440
 
404
441
  function dispatch(name: string, detail?: unknown): void {
405
442
  const el = opts.element();
406
- if (!el) {
407
- // Caller-side warning is the submit() path's responsibility; we stay
408
- // silent here so question-answered pings don't spam logs.
409
- return;
410
- }
443
+ if (!el) return;
411
444
  el.dispatchEvent(new CustomEvent(name, { detail, bubbles: true }));
412
445
  }
413
446
 
414
- function questionView(i: number): UseQuizQuestionView {
415
- const q = questions[i];
416
- return {
417
- get id() { return q.id; },
418
- get submitted() { return submitted; },
419
- get correct() {
420
- if (!submitted) return null;
421
- const a = answers.has(i) ? answers.get(i) : undefined;
422
- return q.checkAnswer(a);
423
- },
424
- };
425
- }
426
-
427
- // Stable view array — recompute when questions change.
428
- const questionViews = $derived(questions.map((_q, i) => questionView(i)));
429
-
430
- function registerQuestion(api: UseQuizQuestionApi): number {
431
- if (seenIds.has(api.id)) {
432
- console.warn(
433
- `[tessera] useQuiz: duplicate question id "${api.id}" — ` +
434
- 'each question id must be unique within a quiz (LMS interaction records key by id).'
435
- );
436
- }
437
- seenIds.add(api.id);
438
- const internal: InternalQuestion = {
439
- ...api,
440
- weight: typeof api.weight === 'number' && api.weight > 0 ? api.weight : 1,
441
- };
442
- questions.push(internal);
443
- return questions.length - 1;
444
- }
445
-
446
- function setAnswer(index: number, answer: unknown): void {
447
+ function setAnswerInternal(index: number, answer: unknown): void {
447
448
  answers.set(index, answer);
448
449
  answersVersion++;
449
450
  dispatch('tessera-quiz-question-answered', { index });
450
451
  }
451
452
 
452
- function getAnswer(index: number): unknown {
453
- void answersVersion; // keep reactive readers (e.g. tests) tracking
453
+ function commitInternal(index: number): void {
454
+ if (!adapterCtx) return;
455
+ const q = internalQuestions[index];
456
+ if (!q || typeof q.interaction !== 'function') return;
457
+ const interaction = q.interaction();
458
+ if (!interaction) return;
459
+ const fingerprint = JSON.stringify(interaction);
460
+ if (reportedAnswers.get(index) === fingerprint) return;
461
+ const answer = answers.has(index) ? answers.get(index) : undefined;
462
+ adapterCtx.adapter.reportInteraction(q.id, interaction, q.checkAnswer(answer));
463
+ reportedAnswers.set(index, fingerprint);
464
+ }
465
+
466
+ function getAnswerInternal(index: number): unknown {
467
+ void answersVersion;
454
468
  return answers.get(index);
455
469
  }
456
470
 
457
- function setRender(index: number, render: unknown): void {
458
- if (questions[index]) questions[index].render = render;
471
+ function setRenderInternal(index: number, render: unknown): void {
472
+ if (internalQuestions[index]) internalQuestions[index].render = render;
459
473
  }
460
474
 
461
- function getRender(index: number): unknown {
462
- return questions[index]?.render;
475
+ function getRenderInternal(index: number): unknown {
476
+ return internalQuestions[index]?.render;
463
477
  }
464
478
 
465
- function feedbackVisible(index: number): boolean {
466
- if (!showFeedback) return false;
479
+ function feedbackVisibleInternal(index: number): boolean {
480
+ if (policyCfg.feedbackMode === 'never') return false;
467
481
  return feedbackPredicate({
468
482
  questionIndex: index,
469
483
  submitted,
@@ -474,30 +488,69 @@ export function useQuiz(opts: { element: () => HTMLElement | null }): UseQuizHan
474
488
  });
475
489
  }
476
490
 
477
- function revealFeedback(index: number): void {
478
- if (!showFeedback) return;
479
- // Replace the Set so the $state reference changes — `.add()` on a plain
480
- // Set wouldn't trigger reactive readers.
491
+ function revealFeedbackInternal(index: number): void {
492
+ if (policyCfg.feedbackMode === 'never') return;
481
493
  const next = new Set(feedbackShown);
482
494
  next.add(index);
483
495
  feedbackShown = next;
484
496
  }
485
497
 
486
- function isLockedCorrect(index: number): boolean {
498
+ function isLockedCorrectInternal(index: number): boolean {
487
499
  return lockedCorrect.has(index);
488
500
  }
489
501
 
490
- /**
491
- * Weighted rollup: Σ(w·correct)/Σ(w)·100, rounded.
492
- * Default weight 1 collapses to the unweighted mean — that path is locked
493
- * by the compliance test.
494
- */
502
+ function makeQuestionHandle(i: number): QuestionInternal {
503
+ return {
504
+ get id() { return internalQuestions[i].id; },
505
+ get submitted() { return submitted; },
506
+ get correct() {
507
+ if (!submitted) return null;
508
+ const a = answers.has(i) ? answers.get(i) : undefined;
509
+ return internalQuestions[i].checkAnswer(a);
510
+ },
511
+ get answer() { return getAnswerInternal(i); },
512
+ get feedbackVisible() { return feedbackVisibleInternal(i); },
513
+ get locked() {
514
+ return submitted || feedbackVisibleInternal(i) || isLockedCorrectInternal(i);
515
+ },
516
+ get isLockedCorrect() { return isLockedCorrectInternal(i); },
517
+ get render() { return getRenderInternal(i); },
518
+ setAnswer(a: unknown) { setAnswerInternal(i, a); },
519
+ commit() { commitInternal(i); },
520
+ setRender(r: unknown) { setRenderInternal(i, r); },
521
+ };
522
+ }
523
+
524
+ let questionHandles = $state<QuestionInternal[]>([]);
525
+
526
+ function registerQuestion(api: UseQuizQuestionApi): QuestionInternal {
527
+ if (seenIds.has(api.id)) {
528
+ console.warn(
529
+ `[tessera] useQuiz: duplicate question id "${api.id}" — ` +
530
+ 'each question id must be unique within a quiz (LMS interaction records key by id).'
531
+ );
532
+ }
533
+ seenIds.add(api.id);
534
+ const internal: InternalQuestion = {
535
+ id: api.id,
536
+ weight: typeof api.weight === 'number' && api.weight > 0 ? api.weight : 1,
537
+ checkAnswer: api.checkAnswer,
538
+ reset: api.reset,
539
+ interaction: api.interaction,
540
+ render: undefined,
541
+ };
542
+ internalQuestions.push(internal);
543
+ const handle = makeQuestionHandle(internalQuestions.length - 1);
544
+ questionHandles.push(handle);
545
+ return handle;
546
+ }
547
+
495
548
  function computeScore(): { rounded: number; correctCount: number } {
496
549
  let weighted = 0;
497
550
  let totalWeight = 0;
498
551
  let correctCount = 0;
499
- for (let i = 0; i < questions.length; i++) {
500
- const q = questions[i];
552
+ for (let i = 0; i < internalQuestions.length; i++) {
553
+ const q = internalQuestions[i];
501
554
  const a = answers.has(i) ? answers.get(i) : undefined;
502
555
  const ok = q.checkAnswer(a);
503
556
  totalWeight += q.weight;
@@ -525,15 +578,16 @@ export function useQuiz(opts: { element: () => HTMLElement | null }): UseQuizHan
525
578
  }
526
579
  el.dispatchEvent(new CustomEvent('tessera-quiz-before-submit', { bubbles: true }));
527
580
 
581
+ for (let i = 0; i < internalQuestions.length; i++) commitInternal(i);
582
+
528
583
  const { rounded } = computeScore();
529
584
  score = rounded;
530
585
  submitted = true;
531
586
  attemptCount++;
532
587
 
533
- const interactions = buildQuizInteractions(questions, answers);
534
588
  el.dispatchEvent(
535
589
  new CustomEvent('tessera-quiz-complete', {
536
- detail: { score: rounded, interactions },
590
+ detail: { score: rounded },
537
591
  bubbles: true,
538
592
  })
539
593
  );
@@ -551,12 +605,12 @@ export function useQuiz(opts: { element: () => HTMLElement | null }): UseQuizHan
551
605
  function retry(): void {
552
606
  if (!canRetry) return;
553
607
  const results: QuizQuestionResult[] = [];
554
- for (let i = 0; i < questions.length; i++) {
608
+ for (let i = 0; i < internalQuestions.length; i++) {
555
609
  const a = answers.has(i) ? answers.get(i) : undefined;
556
610
  results.push({
557
- interaction: questions[i].interaction?.() ?? ({} as never),
558
- correct: questions[i].checkAnswer(a),
559
- weight: questions[i].weight,
611
+ interaction: internalQuestions[i].interaction?.() ?? ({} as never),
612
+ correct: internalQuestions[i].checkAnswer(a),
613
+ weight: internalQuestions[i].weight,
560
614
  });
561
615
  }
562
616
  const newLocked = retryPredicate(results);
@@ -566,9 +620,10 @@ export function useQuiz(opts: { element: () => HTMLElement | null }): UseQuizHan
566
620
  }
567
621
  lockedCorrect = newLocked;
568
622
  answers.clear();
623
+ reportedAnswers.clear();
569
624
  for (const [i, a] of preserved) answers.set(i, a);
570
- for (let i = 0; i < questions.length; i++) {
571
- if (!newLocked.has(i) && questions[i].reset) questions[i].reset!();
625
+ for (let i = 0; i < internalQuestions.length; i++) {
626
+ if (!newLocked.has(i) && internalQuestions[i].reset) internalQuestions[i].reset!();
572
627
  }
573
628
  answersVersion++;
574
629
  feedbackShown = new Set();
@@ -578,60 +633,47 @@ export function useQuiz(opts: { element: () => HTMLElement | null }): UseQuizHan
578
633
  dispatch('tessera-quiz-retry');
579
634
  }
580
635
 
581
- // Publish the same `tessera-quiz` context the built-in <Quiz> sets, so
582
- // existing useQuestion widgets work inside a custom quiz shell without
583
- // changes.
584
- setContext('tessera-quiz', {
585
- get registerQuestion() { return registerQuestion; },
586
- get setRender() { return setRender; },
587
- get setAnswer() { return setAnswer; },
588
- get getAnswer() { return getAnswer; },
589
- get submitted() { return submitted; },
590
- get reviewing() { return reviewing; },
591
- get showFeedback() { return showFeedback; },
592
- get feedbackVisible() { return feedbackVisible; },
593
- get isAnswerLocked() {
594
- return (i: number) =>
595
- submitted ||
596
- lockedCorrect.has(i) ||
597
- (revealsLockAnswer && feedbackShown.has(i));
598
- },
599
- get isLockedCorrect() { return (i: number) => lockedCorrect.has(i); },
600
- });
636
+ function revealFeedback(q: Question): void {
637
+ const index = internalQuestions.findIndex((iq) => iq.id === q.id);
638
+ if (index >= 0) revealFeedbackInternal(index);
639
+ }
640
+
641
+ setContext<QuizContextValue>(TESSERA_QUIZ, { registerQuestion });
601
642
 
602
643
  onMount(() => {
603
644
  if (!import.meta.env?.DEV) return;
604
- // Questions register synchronously as child widgets initialise; a tick()
605
- // also covers any effect-driven registration before we check.
606
- void tick().then(() => __warnEmptyQuiz(questions.length));
645
+ void tick().then(() => __warnEmptyQuiz(internalQuestions.length));
607
646
  });
608
647
 
609
648
  onDestroy(() => {
610
649
  __warnUnsubmittedQuiz({
611
- questionsCount: questions.length,
650
+ questionsCount: internalQuestions.length,
612
651
  answersCount: answers.size,
613
652
  submitCalled,
614
653
  });
615
654
  });
616
655
 
617
- return {
656
+ const handle: UseQuizInternalHandle = {
618
657
  get state() { return state; },
619
- get questions() { return questionViews; },
658
+ get questions() { return questionHandles; },
620
659
  get canSubmit() { return canSubmit; },
621
660
  get canRetry() { return canRetry; },
622
661
  get score() { return score; },
662
+ get passingScore() { return pageCtx.passingScore; },
623
663
  get attemptCount() { return attemptCount; },
624
- registerQuestion,
625
- setAnswer,
626
- getAnswer,
627
664
  submit,
628
665
  startReview,
629
666
  exitReview,
630
667
  retry,
631
668
  revealFeedback,
632
- feedbackVisible,
633
- setRender,
634
- getRender,
635
- isLockedCorrect,
669
+ registerQuestion,
670
+ setAnswer: setAnswerInternal,
671
+ getAnswer: getAnswerInternal,
672
+ setRender: setRenderInternal,
673
+ getRender: getRenderInternal,
674
+ feedbackVisible: feedbackVisibleInternal,
675
+ revealFeedbackByIndex: revealFeedbackInternal,
676
+ isLockedCorrect: isLockedCorrectInternal,
636
677
  };
678
+ return handle;
637
679
  }