tessera-learn 0.0.1
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/AGENTS.md +1228 -0
- package/LICENSE +21 -0
- package/README.md +21 -0
- package/dist/plugin/index.d.ts +7 -0
- package/dist/plugin/index.d.ts.map +1 -0
- package/dist/plugin/index.js +1239 -0
- package/dist/plugin/index.js.map +1 -0
- package/package.json +77 -0
- package/src/archiver.d.ts +27 -0
- package/src/components/Accordion.svelte +32 -0
- package/src/components/AccordionItem.svelte +144 -0
- package/src/components/Audio.svelte +38 -0
- package/src/components/Callout.svelte +81 -0
- package/src/components/Carousel.svelte +194 -0
- package/src/components/CarouselSlide.svelte +32 -0
- package/src/components/DefaultLayout.svelte +108 -0
- package/src/components/FillInTheBlank.svelte +345 -0
- package/src/components/Image.svelte +47 -0
- package/src/components/Matching.svelte +513 -0
- package/src/components/MultipleChoice.svelte +363 -0
- package/src/components/Quiz.svelte +569 -0
- package/src/components/RevealModal.svelte +228 -0
- package/src/components/Sorting.svelte +663 -0
- package/src/components/Video.svelte +118 -0
- package/src/components/index.ts +15 -0
- package/src/components/quiz-payload.ts +71 -0
- package/src/components/util.ts +24 -0
- package/src/index.ts +56 -0
- package/src/plugin/export.ts +264 -0
- package/src/plugin/index.ts +464 -0
- package/src/plugin/layout.ts +55 -0
- package/src/plugin/manifest.ts +330 -0
- package/src/plugin/quiz.ts +65 -0
- package/src/plugin/validation.ts +838 -0
- package/src/runtime/App.svelte +435 -0
- package/src/runtime/ErrorPage.svelte +14 -0
- package/src/runtime/LoadingSkeleton.svelte +26 -0
- package/src/runtime/Sidebar.svelte +76 -0
- package/src/runtime/access.ts +55 -0
- package/src/runtime/adapters/cmi5.ts +341 -0
- package/src/runtime/adapters/discovery.ts +38 -0
- package/src/runtime/adapters/index.ts +99 -0
- package/src/runtime/adapters/retry.ts +284 -0
- package/src/runtime/adapters/scorm12.ts +172 -0
- package/src/runtime/adapters/scorm2004.ts +162 -0
- package/src/runtime/adapters/web.ts +62 -0
- package/src/runtime/contexts.ts +76 -0
- package/src/runtime/duration.ts +29 -0
- package/src/runtime/hooks.svelte.ts +543 -0
- package/src/runtime/interaction-format.ts +132 -0
- package/src/runtime/interaction.ts +96 -0
- package/src/runtime/navigation.svelte.ts +117 -0
- package/src/runtime/persistence.ts +56 -0
- package/src/runtime/progress.svelte.ts +168 -0
- package/src/runtime/quiz-policy.ts +227 -0
- package/src/runtime/slugify.ts +17 -0
- package/src/runtime/types.ts +92 -0
- package/src/runtime/xapi/agent-rules.ts +93 -0
- package/src/runtime/xapi/client.ts +133 -0
- package/src/runtime/xapi/derive-actor.ts +90 -0
- package/src/runtime/xapi/publisher.ts +604 -0
- package/src/runtime/xapi/registry.ts +38 -0
- package/src/runtime/xapi/setup.ts +250 -0
- package/src/runtime/xapi/types.ts +106 -0
- package/src/runtime/xapi/uuid.ts +21 -0
- package/src/runtime/xapi/validation.ts +71 -0
- package/src/runtime/xapi/version.ts +23 -0
- package/src/virtual.d.ts +16 -0
- package/styles/base.css +194 -0
- package/styles/layout.css +408 -0
- package/styles/theme.css +36 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed Svelte context keys used by the Tessera runtime. All cross-cutting
|
|
3
|
+
* contexts (set by App.svelte, read by hooks and built-in components) live
|
|
4
|
+
* here so the shape is declared once and consumers don't have to spell out
|
|
5
|
+
* `getContext<...>('tessera-...')` casts.
|
|
6
|
+
*
|
|
7
|
+
* Component-internal contexts (e.g. tessera-quiz, tessera-accordion) stay
|
|
8
|
+
* with their owning component — they are not shared across the runtime.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { getContext } from 'svelte';
|
|
12
|
+
import type { NavigationState } from './navigation.svelte.js';
|
|
13
|
+
import type { ProgressState } from './progress.svelte.js';
|
|
14
|
+
import type { Manifest } from '../plugin/manifest.js';
|
|
15
|
+
import type { CourseConfig, QuizConfig } from './types.js';
|
|
16
|
+
import type { PersistenceAdapter } from './persistence.js';
|
|
17
|
+
|
|
18
|
+
// ---- Keys ----
|
|
19
|
+
|
|
20
|
+
export const TESSERA_NAV = 'tessera-nav' as const;
|
|
21
|
+
export const TESSERA_ADAPTER = 'tessera-adapter' as const;
|
|
22
|
+
export const TESSERA_PAGE = 'tessera-page' as const;
|
|
23
|
+
export const TESSERA_USER_STATE = 'tessera-user-state' as const;
|
|
24
|
+
|
|
25
|
+
// ---- Shapes ----
|
|
26
|
+
|
|
27
|
+
export interface NavContext {
|
|
28
|
+
nav: NavigationState;
|
|
29
|
+
manifest: Manifest;
|
|
30
|
+
progress: ProgressState;
|
|
31
|
+
config: CourseConfig;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface AdapterContext {
|
|
35
|
+
readonly adapter: PersistenceAdapter;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface PageContext {
|
|
39
|
+
quiz: QuizConfig | null;
|
|
40
|
+
passingScore: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface UserStateStore {
|
|
44
|
+
get(key: string): unknown;
|
|
45
|
+
set(key: string, value: unknown): void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ---- Required-getter helpers ----
|
|
49
|
+
|
|
50
|
+
function notInCourse(name: string): never {
|
|
51
|
+
throw new Error(`${name} must be called inside a Tessera course`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function requireNavContext(name: string): NavContext {
|
|
55
|
+
const ctx = getContext<NavContext | undefined>(TESSERA_NAV);
|
|
56
|
+
if (!ctx) notInCourse(name);
|
|
57
|
+
return ctx;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function getNavContext(): NavContext | undefined {
|
|
61
|
+
return getContext<NavContext | undefined>(TESSERA_NAV);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function getAdapterContext(): AdapterContext | undefined {
|
|
65
|
+
return getContext<AdapterContext | undefined>(TESSERA_ADAPTER);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function getPageContext(): PageContext | undefined {
|
|
69
|
+
return getContext<PageContext | undefined>(TESSERA_PAGE);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function requireUserStateStore(name: string): UserStateStore {
|
|
73
|
+
const store = getContext<UserStateStore | undefined>(TESSERA_USER_STATE);
|
|
74
|
+
if (!store) notInCourse(name);
|
|
75
|
+
return store;
|
|
76
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tracks course time-on-task. Two readouts:
|
|
3
|
+
*
|
|
4
|
+
* - `totalSeconds` — cumulative across all sessions (previousSeconds +
|
|
5
|
+
* this session's elapsed). Persisted into `SavedState.d`.
|
|
6
|
+
* - `sessionSeconds` — only this session's elapsed. Fed to SCORM
|
|
7
|
+
* `cmi.(core.)session_time` and to cmi5 statement `result.duration`,
|
|
8
|
+
* both of which are session-scoped: SCORM sums session_time into
|
|
9
|
+
* `cmi.total_time` itself, and cmi5 `duration` on Completed/Passed/
|
|
10
|
+
* Failed/Terminated reflects the AU's launch session.
|
|
11
|
+
*/
|
|
12
|
+
export class DurationTracker {
|
|
13
|
+
#startTime = Date.now();
|
|
14
|
+
#accumulated = 0;
|
|
15
|
+
|
|
16
|
+
constructor(previousSeconds: number = 0) {
|
|
17
|
+
this.#accumulated = previousSeconds;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Cumulative across all sessions. Use for suspend_data persistence. */
|
|
21
|
+
get totalSeconds(): number {
|
|
22
|
+
return this.#accumulated + this.sessionSeconds;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** This session only. Use for SCORM session_time and cmi5 result.duration. */
|
|
26
|
+
get sessionSeconds(): number {
|
|
27
|
+
return Math.floor((Date.now() - this.#startTime) / 1000);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
import { getContext, setContext, onDestroy } from 'svelte';
|
|
2
|
+
import type { Interaction } from './interaction.js';
|
|
3
|
+
import { isCorrect as isCorrectInteraction } from './interaction.js';
|
|
4
|
+
import {
|
|
5
|
+
requireNavContext,
|
|
6
|
+
getNavContext,
|
|
7
|
+
getAdapterContext,
|
|
8
|
+
getPageContext,
|
|
9
|
+
requireUserStateStore,
|
|
10
|
+
} from './contexts.js';
|
|
11
|
+
import { buildQuizInteractions } from '../components/quiz-payload.js';
|
|
12
|
+
import type { QuizContext } from '../components/quiz-payload.js';
|
|
13
|
+
|
|
14
|
+
export interface UseQuestionOptions {
|
|
15
|
+
/** Stable identifier used for LMS interaction reporting. Must be unique on the page. */
|
|
16
|
+
id: string;
|
|
17
|
+
/** Whether this question counts toward course success status. Default false. */
|
|
18
|
+
graded?: boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Optional weight for quiz scoring — only used inside a `<Quiz>` (or `useQuiz`)
|
|
21
|
+
* host that aggregates with the weighted formula `Σ(w·correct)/Σ(w)*100`.
|
|
22
|
+
* Default 1; ignored in standalone mode.
|
|
23
|
+
*/
|
|
24
|
+
weight?: number;
|
|
25
|
+
/** Called on submit — returns the current learner response payload. */
|
|
26
|
+
response: () => Interaction;
|
|
27
|
+
/**
|
|
28
|
+
* Optional score override (0–100). Standalone mode only — per-question scoring
|
|
29
|
+
* inside a `<Quiz>` is the quiz's responsibility.
|
|
30
|
+
*/
|
|
31
|
+
score?: () => number;
|
|
32
|
+
/** Optional reset handler invoked when the learner tries again. */
|
|
33
|
+
reset?: () => void;
|
|
34
|
+
/** Optional Svelte snippet the parent `<Quiz>` renders for this question. Ignored in standalone mode. */
|
|
35
|
+
render?: unknown;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface UseQuestionHandle {
|
|
39
|
+
submit(): void;
|
|
40
|
+
reset(): void;
|
|
41
|
+
readonly submitted: boolean;
|
|
42
|
+
readonly correct: boolean | null;
|
|
43
|
+
readonly mode: 'standalone' | 'quiz';
|
|
44
|
+
/** Index returned by the parent Quiz registration, used for per-question context reads. Undefined in standalone mode. */
|
|
45
|
+
readonly quizIndex: number | undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Register a question widget with the Tessera runtime. Works outside a `<Quiz>`
|
|
50
|
+
* for inline practice, and inside a `<Quiz>` wrapper — the same hook drives both
|
|
51
|
+
* modes. Inside a Quiz the handle's `submit()` is a no-op (the parent Quiz drives
|
|
52
|
+
* submission) and `submitted`/`correct` mirror the quiz's state.
|
|
53
|
+
*/
|
|
54
|
+
export function useQuestion(opts: UseQuestionOptions): UseQuestionHandle {
|
|
55
|
+
const quizCtx = getContext<QuizContext | undefined>('tessera-quiz');
|
|
56
|
+
const navCtx = getNavContext();
|
|
57
|
+
const adapterCtx = getAdapterContext();
|
|
58
|
+
|
|
59
|
+
if (quizCtx?.registerQuestion) {
|
|
60
|
+
const quizIndex = quizCtx.registerQuestion({
|
|
61
|
+
id: opts.id,
|
|
62
|
+
weight: opts.weight,
|
|
63
|
+
checkAnswer: () => isCorrectInteraction(opts.response()) === true,
|
|
64
|
+
reset: opts.reset,
|
|
65
|
+
interaction: () => opts.response(),
|
|
66
|
+
render: opts.render,
|
|
67
|
+
});
|
|
68
|
+
return {
|
|
69
|
+
submit() {},
|
|
70
|
+
reset() { opts.reset?.(); },
|
|
71
|
+
get submitted() { return quizCtx.submitted ?? false; },
|
|
72
|
+
get correct() {
|
|
73
|
+
if (!(quizCtx.submitted ?? false)) return null;
|
|
74
|
+
return isCorrectInteraction(opts.response());
|
|
75
|
+
},
|
|
76
|
+
mode: 'quiz' as const,
|
|
77
|
+
quizIndex,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let submitted = $state(false);
|
|
82
|
+
let correct = $state<boolean | null>(null);
|
|
83
|
+
|
|
84
|
+
function submit() {
|
|
85
|
+
if (submitted) return;
|
|
86
|
+
const response = opts.response();
|
|
87
|
+
correct = isCorrectInteraction(response);
|
|
88
|
+
const score = opts.score
|
|
89
|
+
? opts.score()
|
|
90
|
+
: correct === true
|
|
91
|
+
? 100
|
|
92
|
+
: 0;
|
|
93
|
+
|
|
94
|
+
adapterCtx?.adapter.reportInteraction(opts.id, response, correct);
|
|
95
|
+
if (opts.graded && navCtx) {
|
|
96
|
+
const pageIndex = navCtx.nav.currentPageIndex;
|
|
97
|
+
navCtx.progress.markStandaloneQuestion(pageIndex, opts.id, score, true);
|
|
98
|
+
navCtx.progress.recalculateCompletion(navCtx.manifest, navCtx.config);
|
|
99
|
+
navCtx.progress.recalculateSuccess(navCtx.manifest, navCtx.config);
|
|
100
|
+
} else if (navCtx) {
|
|
101
|
+
const pageIndex = navCtx.nav.currentPageIndex;
|
|
102
|
+
navCtx.progress.markStandaloneQuestion(pageIndex, opts.id, score, false);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
submitted = true;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function reset() {
|
|
109
|
+
submitted = false;
|
|
110
|
+
correct = null;
|
|
111
|
+
opts.reset?.();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
submit,
|
|
116
|
+
reset,
|
|
117
|
+
get submitted() { return submitted; },
|
|
118
|
+
get correct() { return correct; },
|
|
119
|
+
mode: 'standalone' as const,
|
|
120
|
+
quizIndex: undefined,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Access Tessera navigation imperatively — programmatic go-to, next/prev,
|
|
126
|
+
* and the active page.
|
|
127
|
+
*/
|
|
128
|
+
export function useNavigation() {
|
|
129
|
+
const { nav, manifest } = requireNavContext('useNavigation()');
|
|
130
|
+
return {
|
|
131
|
+
get currentPage() { return manifest.pages[nav.currentPageIndex]; },
|
|
132
|
+
get currentPageIndex() { return nav.currentPageIndex; },
|
|
133
|
+
get pages() { return manifest.pages; },
|
|
134
|
+
goTo(slug: string) {
|
|
135
|
+
const index = manifest.pages.findIndex((p) => p.slug === slug);
|
|
136
|
+
if (index >= 0) nav.goToPage(index);
|
|
137
|
+
},
|
|
138
|
+
goToIndex(index: number) { nav.goToPage(index); },
|
|
139
|
+
next() { nav.goNext(); },
|
|
140
|
+
prev() { nav.goPrev(); },
|
|
141
|
+
get canGoNext() { return nav.canGoNext; },
|
|
142
|
+
get canGoPrev() { return nav.canGoPrev; },
|
|
143
|
+
canAccess(slug: string) {
|
|
144
|
+
const index = manifest.pages.findIndex((p) => p.slug === slug);
|
|
145
|
+
return index >= 0 && !nav.isPageLocked(index);
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Access Tessera progress state imperatively.
|
|
152
|
+
*/
|
|
153
|
+
export function useProgress() {
|
|
154
|
+
const { progress } = requireNavContext('useProgress()');
|
|
155
|
+
return {
|
|
156
|
+
get visitedPages() { return progress.visitedPages; },
|
|
157
|
+
get quizScores() { return progress.quizScores; },
|
|
158
|
+
get chunkProgress() { return progress.chunkProgress; },
|
|
159
|
+
get completionStatus() { return progress.completionStatus; },
|
|
160
|
+
get successStatus() { return progress.successStatus; },
|
|
161
|
+
markVisited(pageIndex: number) { progress.markVisited(pageIndex); },
|
|
162
|
+
markChunk(pageIndex: number, chunkIndex: number) {
|
|
163
|
+
progress.markChunk(pageIndex, chunkIndex);
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Scoped persistence — save and restore per-widget state that survives reload.
|
|
170
|
+
* Routes to whichever adapter the course is running under (localStorage, SCORM
|
|
171
|
+
* suspend_data, or xAPI State API).
|
|
172
|
+
*/
|
|
173
|
+
export function usePersistence<T = unknown>(key: string): {
|
|
174
|
+
get(): T | null;
|
|
175
|
+
set(value: T): void;
|
|
176
|
+
} {
|
|
177
|
+
const store = requireUserStateStore('usePersistence()');
|
|
178
|
+
return {
|
|
179
|
+
get(): T | null { return (store.get(key) as T | null) ?? null; },
|
|
180
|
+
set(value: T) { store.set(key, value); },
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ---------- useQuiz ----------
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Per-question registration shape accepted by `useQuiz().registerQuestion`.
|
|
188
|
+
* Mirrors the QuizQuestionApi used by built-in `<Quiz>` plus an optional
|
|
189
|
+
* `weight` for the weighted score formula.
|
|
190
|
+
*/
|
|
191
|
+
export interface UseQuizQuestionApi {
|
|
192
|
+
id: string;
|
|
193
|
+
/** Optional weight for the score rollup. Default 1 — `Σ(w·correct)/Σ(w)*100`. */
|
|
194
|
+
weight?: number;
|
|
195
|
+
checkAnswer: (answer?: unknown) => boolean;
|
|
196
|
+
reset?: () => void;
|
|
197
|
+
/** Optional Svelte snippet the quiz host renders for this question. */
|
|
198
|
+
render?: unknown;
|
|
199
|
+
/** Optional — when present, included in the `tessera-quiz-complete` event payload. */
|
|
200
|
+
interaction?: () => Interaction;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export interface UseQuizQuestionView {
|
|
204
|
+
readonly id: string;
|
|
205
|
+
readonly correct: boolean | null;
|
|
206
|
+
readonly submitted: boolean;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export interface UseQuizHandle {
|
|
210
|
+
readonly state: 'answering' | 'submitted' | 'reviewing';
|
|
211
|
+
readonly questions: UseQuizQuestionView[];
|
|
212
|
+
readonly canSubmit: boolean;
|
|
213
|
+
readonly canRetry: boolean;
|
|
214
|
+
readonly score: number;
|
|
215
|
+
readonly attemptCount: number;
|
|
216
|
+
registerQuestion(api: UseQuizQuestionApi): number;
|
|
217
|
+
setAnswer(index: number, answer: unknown): void;
|
|
218
|
+
getAnswer(index: number): unknown;
|
|
219
|
+
submit(): void;
|
|
220
|
+
startReview(): void;
|
|
221
|
+
exitReview(): void;
|
|
222
|
+
retry(): void;
|
|
223
|
+
revealFeedback(index: number): void;
|
|
224
|
+
feedbackVisible(index: number): boolean;
|
|
225
|
+
setRender(index: number, render: unknown): void;
|
|
226
|
+
getRender(index: number): unknown;
|
|
227
|
+
isLockedCorrect(index: number): boolean;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Dev warning helper for quizzes that unmount with answered questions but no
|
|
232
|
+
* submit() call. Exported so `use-quiz.test.ts` can exercise the warning code
|
|
233
|
+
* path without relying on jsdom's onDestroy timing under vitest.
|
|
234
|
+
*/
|
|
235
|
+
export function __warnUnsubmittedQuiz(stats: {
|
|
236
|
+
questionsCount: number;
|
|
237
|
+
answersCount: number;
|
|
238
|
+
submitCalled: boolean;
|
|
239
|
+
}): void {
|
|
240
|
+
if (stats.submitCalled) return;
|
|
241
|
+
if (stats.answersCount <= 0) return;
|
|
242
|
+
console.warn(
|
|
243
|
+
'[tessera] useQuiz: submit() was never called before unmount, but the learner answered ' +
|
|
244
|
+
`${stats.answersCount} of ${stats.questionsCount} questions. ` +
|
|
245
|
+
'Did your custom quiz shell forget to call handle.submit()?'
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Programmatic quiz orchestration for custom quiz shells. Returns a handle
|
|
251
|
+
* exposing the same state machine `<Quiz>` runs internally — register
|
|
252
|
+
* questions, set answers, submit, review, retry — but with no template
|
|
253
|
+
* baked in, so authors can build any UI on top.
|
|
254
|
+
*
|
|
255
|
+
* Reads quiz config from the `tessera-page` context (set by App.svelte) and
|
|
256
|
+
* publishes a `tessera-quiz` context for `useQuestion` widgets to consume.
|
|
257
|
+
*
|
|
258
|
+
* The host element passed via `opts.element()` is what `tessera-quiz-*` DOM
|
|
259
|
+
* events dispatch from. App.svelte's bridge listens on `#tessera-app` and
|
|
260
|
+
* forwards `tessera-quiz-complete` into the persistence adapter.
|
|
261
|
+
*/
|
|
262
|
+
export function useQuiz(opts: { element: () => HTMLElement | null }): UseQuizHandle {
|
|
263
|
+
const pageCtx = getPageContext();
|
|
264
|
+
if (!pageCtx?.quiz) {
|
|
265
|
+
throw new Error(
|
|
266
|
+
'useQuiz() must be called on a page with a quiz config (export const pageConfig = { quiz: { ... } }).'
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
const quizConfig = pageCtx.quiz;
|
|
270
|
+
const passingScore = pageCtx.passingScore ?? 70;
|
|
271
|
+
|
|
272
|
+
// Dev-mode warning: a second useQuiz on the same page silently overwrites
|
|
273
|
+
// the first quiz's pageIndex-keyed score. We can't prevent it (some pages
|
|
274
|
+
// really do compose multiple quiz hosts in dev experiments) but the
|
|
275
|
+
// multi-quiz writer should know.
|
|
276
|
+
const existing = getContext<unknown>('tessera-quiz');
|
|
277
|
+
if (existing) {
|
|
278
|
+
console.warn(
|
|
279
|
+
'[tessera] useQuiz: a second quiz registered on this page; ' +
|
|
280
|
+
'quiz scores are keyed by pageIndex and the later submit will overwrite the earlier one.'
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const maxAttempts = quizConfig.maxAttempts ?? Infinity;
|
|
285
|
+
const showFeedback = quizConfig.showFeedback ?? true;
|
|
286
|
+
const feedbackMode: 'review' | 'immediate' =
|
|
287
|
+
showFeedback && quizConfig.feedbackMode === 'immediate' ? 'immediate' : 'review';
|
|
288
|
+
const retryMode: 'full' | 'incorrect-only' = quizConfig.retryMode ?? 'full';
|
|
289
|
+
|
|
290
|
+
interface InternalQuestion extends UseQuizQuestionApi {
|
|
291
|
+
weight: number;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
let questions = $state<InternalQuestion[]>([]);
|
|
295
|
+
const answers = new Map<number, unknown>();
|
|
296
|
+
let answersVersion = $state(0);
|
|
297
|
+
let submitted = $state(false);
|
|
298
|
+
let reviewing = $state(false);
|
|
299
|
+
let score = $state(0);
|
|
300
|
+
let attemptCount = $state(0);
|
|
301
|
+
let feedbackShown = $state(new Set<number>());
|
|
302
|
+
let lockedCorrect = $state(new Set<number>());
|
|
303
|
+
let submitCalled = false;
|
|
304
|
+
|
|
305
|
+
const seenIds = new Set<string>();
|
|
306
|
+
|
|
307
|
+
const totalQuestions = $derived(questions.length);
|
|
308
|
+
// Match the built-in <Quiz> rule: every registered question has an entry in
|
|
309
|
+
// `answers`. We track the map via an explicit version counter rather than
|
|
310
|
+
// a reactive Map proxy so $derived reliably re-runs across `set()` calls.
|
|
311
|
+
const allAnswered = $derived(
|
|
312
|
+
(void answersVersion, totalQuestions > 0 && answers.size >= totalQuestions)
|
|
313
|
+
);
|
|
314
|
+
const canSubmit = $derived(!submitted && allAnswered);
|
|
315
|
+
const canRetry = $derived(submitted && attemptCount < maxAttempts);
|
|
316
|
+
const state: 'answering' | 'submitted' | 'reviewing' = $derived(
|
|
317
|
+
reviewing ? 'reviewing' : submitted ? 'submitted' : 'answering'
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
function dispatch(name: string, detail?: unknown): void {
|
|
321
|
+
const el = opts.element();
|
|
322
|
+
if (!el) {
|
|
323
|
+
// Caller-side warning is the submit() path's responsibility; we stay
|
|
324
|
+
// silent here so question-answered pings don't spam logs.
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
el.dispatchEvent(new CustomEvent(name, { detail, bubbles: true }));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function questionView(i: number): UseQuizQuestionView {
|
|
331
|
+
const q = questions[i];
|
|
332
|
+
return {
|
|
333
|
+
get id() { return q.id; },
|
|
334
|
+
get submitted() { return submitted; },
|
|
335
|
+
get correct() {
|
|
336
|
+
if (!submitted) return null;
|
|
337
|
+
const a = answers.has(i) ? answers.get(i) : undefined;
|
|
338
|
+
return q.checkAnswer(a);
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Stable view array — recompute when questions change.
|
|
344
|
+
const questionViews = $derived(questions.map((_q, i) => questionView(i)));
|
|
345
|
+
|
|
346
|
+
function registerQuestion(api: UseQuizQuestionApi): number {
|
|
347
|
+
if (seenIds.has(api.id)) {
|
|
348
|
+
console.warn(
|
|
349
|
+
`[tessera] useQuiz: duplicate question id "${api.id}" — ` +
|
|
350
|
+
'each question id must be unique within a quiz (LMS interaction records key by id).'
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
seenIds.add(api.id);
|
|
354
|
+
const internal: InternalQuestion = {
|
|
355
|
+
...api,
|
|
356
|
+
weight: typeof api.weight === 'number' && api.weight > 0 ? api.weight : 1,
|
|
357
|
+
};
|
|
358
|
+
questions.push(internal);
|
|
359
|
+
return questions.length - 1;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function setAnswer(index: number, answer: unknown): void {
|
|
363
|
+
answers.set(index, answer);
|
|
364
|
+
answersVersion++;
|
|
365
|
+
dispatch('tessera-quiz-question-answered', { index });
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function getAnswer(index: number): unknown {
|
|
369
|
+
void answersVersion; // keep reactive readers (e.g. tests) tracking
|
|
370
|
+
return answers.get(index);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function setRender(index: number, render: unknown): void {
|
|
374
|
+
if (questions[index]) questions[index].render = render;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function getRender(index: number): unknown {
|
|
378
|
+
return questions[index]?.render;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function feedbackVisible(index: number): boolean {
|
|
382
|
+
if (!showFeedback) return false;
|
|
383
|
+
if (feedbackMode === 'immediate' && feedbackShown.has(index)) return true;
|
|
384
|
+
if (submitted && reviewing) return true;
|
|
385
|
+
return false;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function revealFeedback(index: number): void {
|
|
389
|
+
if (!showFeedback) return;
|
|
390
|
+
// Replace the Set so the $state reference changes — `.add()` on a plain
|
|
391
|
+
// Set wouldn't trigger reactive readers.
|
|
392
|
+
const next = new Set(feedbackShown);
|
|
393
|
+
next.add(index);
|
|
394
|
+
feedbackShown = next;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function isLockedCorrect(index: number): boolean {
|
|
398
|
+
return lockedCorrect.has(index);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Weighted rollup: Σ(w·correct)/Σ(w)·100, rounded.
|
|
403
|
+
* Default weight 1 collapses to the unweighted mean — that path is locked
|
|
404
|
+
* by the compliance test.
|
|
405
|
+
*/
|
|
406
|
+
function computeScore(): { rounded: number; correctCount: number } {
|
|
407
|
+
let weighted = 0;
|
|
408
|
+
let totalWeight = 0;
|
|
409
|
+
let correctCount = 0;
|
|
410
|
+
for (let i = 0; i < questions.length; i++) {
|
|
411
|
+
const q = questions[i];
|
|
412
|
+
const a = answers.has(i) ? answers.get(i) : undefined;
|
|
413
|
+
const ok = q.checkAnswer(a);
|
|
414
|
+
totalWeight += q.weight;
|
|
415
|
+
if (ok) {
|
|
416
|
+
weighted += q.weight;
|
|
417
|
+
correctCount++;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
if (totalWeight === 0) return { rounded: 0, correctCount: 0 };
|
|
421
|
+
return { rounded: Math.round((weighted / totalWeight) * 100), correctCount };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function submit(): void {
|
|
425
|
+
submitCalled = true;
|
|
426
|
+
if (submitted) return;
|
|
427
|
+
if (!allAnswered) return;
|
|
428
|
+
const el = opts.element();
|
|
429
|
+
if (!el) {
|
|
430
|
+
console.warn(
|
|
431
|
+
'[tessera] useQuiz: submit() ran but the host element was null — no LMS bridge ' +
|
|
432
|
+
'listener exists, so this score will not be persisted. Make sure your custom ' +
|
|
433
|
+
'quiz shell binds the element it passes to useQuiz({ element: () => ... }).'
|
|
434
|
+
);
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
el.dispatchEvent(new CustomEvent('tessera-quiz-before-submit', { bubbles: true }));
|
|
438
|
+
|
|
439
|
+
const { rounded } = computeScore();
|
|
440
|
+
score = rounded;
|
|
441
|
+
submitted = true;
|
|
442
|
+
attemptCount++;
|
|
443
|
+
|
|
444
|
+
const interactions = buildQuizInteractions(questions, answers);
|
|
445
|
+
void passingScore; // reserved for future custom-shell extensions
|
|
446
|
+
el.dispatchEvent(
|
|
447
|
+
new CustomEvent('tessera-quiz-complete', {
|
|
448
|
+
detail: { score: rounded, interactions },
|
|
449
|
+
bubbles: true,
|
|
450
|
+
})
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function startReview(): void {
|
|
455
|
+
if (!submitted) return;
|
|
456
|
+
reviewing = true;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function exitReview(): void {
|
|
460
|
+
reviewing = false;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function retry(): void {
|
|
464
|
+
if (!canRetry) return;
|
|
465
|
+
if (retryMode === 'incorrect-only') {
|
|
466
|
+
const newLocked = new Set<number>();
|
|
467
|
+
const preserved = new Map<number, unknown>();
|
|
468
|
+
for (let i = 0; i < questions.length; i++) {
|
|
469
|
+
const a = answers.has(i) ? answers.get(i) : undefined;
|
|
470
|
+
if (questions[i].checkAnswer(a)) {
|
|
471
|
+
newLocked.add(i);
|
|
472
|
+
preserved.set(i, a);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
lockedCorrect = newLocked;
|
|
476
|
+
answers.clear();
|
|
477
|
+
for (const [i, a] of preserved) answers.set(i, a);
|
|
478
|
+
for (let i = 0; i < questions.length; i++) {
|
|
479
|
+
if (!newLocked.has(i) && questions[i].reset) questions[i].reset!();
|
|
480
|
+
}
|
|
481
|
+
} else {
|
|
482
|
+
lockedCorrect = new Set();
|
|
483
|
+
answers.clear();
|
|
484
|
+
for (const q of questions) q.reset?.();
|
|
485
|
+
}
|
|
486
|
+
answersVersion++;
|
|
487
|
+
feedbackShown = new Set();
|
|
488
|
+
submitted = false;
|
|
489
|
+
reviewing = false;
|
|
490
|
+
score = 0;
|
|
491
|
+
dispatch('tessera-quiz-retry');
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Publish the same `tessera-quiz` context the built-in <Quiz> sets, so
|
|
495
|
+
// existing useQuestion widgets work inside a custom quiz shell without
|
|
496
|
+
// changes.
|
|
497
|
+
setContext('tessera-quiz', {
|
|
498
|
+
get registerQuestion() { return registerQuestion; },
|
|
499
|
+
get setRender() { return setRender; },
|
|
500
|
+
get setAnswer() { return setAnswer; },
|
|
501
|
+
get getAnswer() { return getAnswer; },
|
|
502
|
+
get submitted() { return submitted; },
|
|
503
|
+
get reviewing() { return reviewing; },
|
|
504
|
+
get showFeedback() { return showFeedback; },
|
|
505
|
+
get feedbackVisible() { return feedbackVisible; },
|
|
506
|
+
get isAnswerLocked() {
|
|
507
|
+
return (i: number) =>
|
|
508
|
+
submitted ||
|
|
509
|
+
lockedCorrect.has(i) ||
|
|
510
|
+
(feedbackMode === 'immediate' && feedbackShown.has(i));
|
|
511
|
+
},
|
|
512
|
+
get isLockedCorrect() { return (i: number) => lockedCorrect.has(i); },
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
onDestroy(() => {
|
|
516
|
+
__warnUnsubmittedQuiz({
|
|
517
|
+
questionsCount: questions.length,
|
|
518
|
+
answersCount: answers.size,
|
|
519
|
+
submitCalled,
|
|
520
|
+
});
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
return {
|
|
524
|
+
get state() { return state; },
|
|
525
|
+
get questions() { return questionViews; },
|
|
526
|
+
get canSubmit() { return canSubmit; },
|
|
527
|
+
get canRetry() { return canRetry; },
|
|
528
|
+
get score() { return score; },
|
|
529
|
+
get attemptCount() { return attemptCount; },
|
|
530
|
+
registerQuestion,
|
|
531
|
+
setAnswer,
|
|
532
|
+
getAnswer,
|
|
533
|
+
submit,
|
|
534
|
+
startReview,
|
|
535
|
+
exitReview,
|
|
536
|
+
retry,
|
|
537
|
+
revealFeedback,
|
|
538
|
+
feedbackVisible,
|
|
539
|
+
setRender,
|
|
540
|
+
getRender,
|
|
541
|
+
isLockedCorrect,
|
|
542
|
+
};
|
|
543
|
+
}
|