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.
- package/dist/plugin/cli.js +1 -1
- package/dist/plugin/index.js +3 -2
- package/dist/plugin/index.js.map +1 -1
- package/dist/{validation-B4UhCY5y.js → validation-BxWAMMnJ.js} +4 -7
- package/dist/validation-BxWAMMnJ.js.map +1 -0
- package/package.json +1 -1
- package/src/components/FillInTheBlank.svelte +32 -37
- package/src/components/Matching.svelte +35 -68
- package/src/components/MultipleChoice.svelte +25 -38
- package/src/components/Quiz.svelte +22 -26
- package/src/components/Sorting.svelte +40 -42
- package/src/index.ts +1 -0
- package/src/plugin/index.ts +5 -0
- package/src/plugin/validation.ts +7 -2
- package/src/runtime/App.svelte +2 -7
- package/src/runtime/adapters/cmi5.ts +44 -14
- package/src/runtime/hooks.svelte.ts +259 -217
- package/src/runtime/interaction-format.ts +40 -8
- package/src/runtime/interaction.ts +3 -3
- package/src/runtime/persistence.ts +5 -0
- package/src/runtime/quiz-policy.ts +16 -16
- package/src/runtime/types.ts +1 -2
- package/dist/validation-B4UhCY5y.js.map +0 -1
- package/src/components/quiz-payload.ts +0 -71
|
@@ -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 —
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
|
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
|
|
37
|
-
* inside a
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
57
|
-
|
|
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
|
|
62
|
-
* for inline practice, and inside a
|
|
63
|
-
* modes. Inside a
|
|
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<
|
|
118
|
+
const quizCtx = getContext<QuizContextValue | undefined>(TESSERA_QUIZ);
|
|
68
119
|
const navCtx = getNavContext();
|
|
69
120
|
const adapterCtx = getAdapterContext();
|
|
70
121
|
|
|
71
|
-
if (quizCtx
|
|
72
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
253
|
-
*
|
|
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)
|
|
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
|
-
/**
|
|
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:
|
|
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
|
-
|
|
289
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
//
|
|
351
|
-
//
|
|
352
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
453
|
-
|
|
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
|
|
458
|
-
if (
|
|
471
|
+
function setRenderInternal(index: number, render: unknown): void {
|
|
472
|
+
if (internalQuestions[index]) internalQuestions[index].render = render;
|
|
459
473
|
}
|
|
460
474
|
|
|
461
|
-
function
|
|
462
|
-
return
|
|
475
|
+
function getRenderInternal(index: number): unknown {
|
|
476
|
+
return internalQuestions[index]?.render;
|
|
463
477
|
}
|
|
464
478
|
|
|
465
|
-
function
|
|
466
|
-
if (
|
|
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
|
|
478
|
-
if (
|
|
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
|
|
498
|
+
function isLockedCorrectInternal(index: number): boolean {
|
|
487
499
|
return lockedCorrect.has(index);
|
|
488
500
|
}
|
|
489
501
|
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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 <
|
|
500
|
-
const q =
|
|
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
|
|
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 <
|
|
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:
|
|
558
|
-
correct:
|
|
559
|
-
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 <
|
|
571
|
-
if (!newLocked.has(i) &&
|
|
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
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
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
|
-
|
|
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:
|
|
650
|
+
questionsCount: internalQuestions.length,
|
|
612
651
|
answersCount: answers.size,
|
|
613
652
|
submitCalled,
|
|
614
653
|
});
|
|
615
654
|
});
|
|
616
655
|
|
|
617
|
-
|
|
656
|
+
const handle: UseQuizInternalHandle = {
|
|
618
657
|
get state() { return state; },
|
|
619
|
-
get questions() { return
|
|
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
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
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
|
}
|