tessera-learn 0.0.5 → 0.0.7

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.
@@ -1,34 +1,33 @@
1
1
  import type { PersistenceAdapter, SavedState } from '../persistence.js';
2
2
  import type { Interaction } from '../interaction.js';
3
- import { buildScormInteractionFields } from '../interaction-format.js';
4
- import { WriteQueue, callSync, withRetry } from './retry.js';
3
+ import {
4
+ buildScormInteractionFields,
5
+ type InteractionFormat,
6
+ } from '../interaction-format.js';
7
+ import { WriteQueue, callSyncOrWarn, withRetry } from './retry.js';
8
+ import type { LMSErrorReporter } from './retry.js';
5
9
 
6
- /** Per-version differences shared between SCORM 1.2 and SCORM 2004 adapters. */
10
+ /**
11
+ * Per-version differences shared between SCORM 1.2 and SCORM 2004 adapters.
12
+ *
13
+ * `suspendDataLimit` is per-spec characters: SCORM 1.2 RTE §3.4.5.2 = 4096;
14
+ * SCORM 2004 4E §4.2 = 64000. The `LMS*`-prefixed (1.2) vs bare (2004)
15
+ * method names are abstracted here so the base class can stay version-
16
+ * agnostic.
17
+ */
7
18
  export interface ScormDialect<TApi> {
8
- /** SCORM 1.2: `cmi.core.session_time`. SCORM 2004: `cmi.session_time`. */
9
19
  sessionTimeKey: string;
10
- /** Format `seconds` for the session-time field — HHMMSS for 1.2, ISO8601 for 2004. */
11
20
  formatDuration(seconds: number): string;
12
- /**
13
- * Per-spec maximum byte length for `cmi.suspend_data` (SCORM 1.2 RTE
14
- * §3.4.5.2 = 4096; SCORM 2004 4E §4.2 = 64000). Used by `saveState` to
15
- * warn once when the serialized payload would be silently truncated by
16
- * the LMS. Treated as "characters" since SCORM data-model lengths are
17
- * specified in characters and Tessera stores ASCII-safe JSON.
18
- */
19
21
  suspendDataLimit: number;
20
- /** Human label for the limit warning, e.g. "SCORM 1.2 (4096 chars)". */
21
22
  suspendDataLimitLabel: string;
22
- /** Per-interaction-row field config passed to `buildScormInteractionFields`. */
23
23
  interactionFields: {
24
24
  responseField: 'student_response' | 'learner_response';
25
25
  timestampField: 'time' | 'timestamp';
26
- /** Build the per-call timestamp string (HH:MM:SS for 1.2, ISO8601 for 2004). */
27
26
  timestamp(): string;
28
27
  typeValue(type: Interaction['type']): string;
29
28
  resultLabels: { correct: string; incorrect: string };
29
+ format: InteractionFormat;
30
30
  };
31
- /** API method wrappers — abstract over the `LMS*`-prefixed and bare names. */
32
31
  initialize(api: TApi): string;
33
32
  terminate(api: TApi): string;
34
33
  getValue(api: TApi, key: string): string;
@@ -36,12 +35,14 @@ export interface ScormDialect<TApi> {
36
35
  commit(api: TApi): string;
37
36
  getLastError(api: TApi): string;
38
37
  getErrorString(api: TApi, code: string): string;
38
+ getDiagnostic?(api: TApi, code: string): string;
39
39
  }
40
40
 
41
41
  export abstract class BaseScormAdapter<TApi> implements PersistenceAdapter {
42
42
  protected readonly api: TApi;
43
43
  protected readonly dialect: ScormDialect<TApi>;
44
44
  protected readonly queue = new WriteQueue();
45
+ protected readonly errorReporter: LMSErrorReporter;
45
46
  #state: SavedState | null = null;
46
47
  #terminated = false;
47
48
  #suspendOverflowWarned = false;
@@ -50,41 +51,76 @@ export abstract class BaseScormAdapter<TApi> implements PersistenceAdapter {
50
51
  constructor(api: TApi, dialect: ScormDialect<TApi>) {
51
52
  this.api = api;
52
53
  this.dialect = dialect;
53
- // Wire up GetLastError/GetErrorString so retry warnings can name the
54
- // real LMS failure (e.g. "201 Invalid argument error") instead of a
55
- // generic "LMS call failed" — production triage needs the code.
56
- this.queue.errorReporter = {
54
+ this.errorReporter = {
57
55
  code: () => this.dialect.getLastError(this.api),
58
56
  message: (c) => this.dialect.getErrorString(this.api, c),
57
+ diagnostic: this.dialect.getDiagnostic
58
+ ? (c) => this.dialect.getDiagnostic!(this.api, c)
59
+ : undefined,
59
60
  };
61
+ this.queue.errorReporter = this.errorReporter;
60
62
  }
61
63
 
62
- /** Expose the underlying SCORM API so xAPI actor synthesis can read learner fields. */
64
+ /** Exposed for xAPI actor synthesis (reads learner fields off the API). */
63
65
  getAPI(): TApi {
64
66
  return this.api;
65
67
  }
66
68
 
67
69
  async init(): Promise<void> {
68
- await withRetry(() => this.dialect.initialize(this.api));
70
+ const initialized = await withRetry(
71
+ () => this.dialect.initialize(this.api),
72
+ undefined,
73
+ this.errorReporter,
74
+ 'Initialize'
75
+ );
76
+ if (!initialized) {
77
+ console.warn(
78
+ 'Tessera: LMS Initialize failed — all subsequent persistence calls will fail with error 301 (Not Initialized). Reload the launch from the LMS.'
79
+ );
80
+ return;
81
+ }
69
82
 
83
+ let raw = '';
70
84
  try {
71
- const raw = this.dialect.getValue(this.api, 'cmi.suspend_data');
72
- if (raw && raw.trim()) {
85
+ raw = this.dialect.getValue(this.api, 'cmi.suspend_data');
86
+ } catch (err) {
87
+ console.warn(
88
+ 'Tessera: LMS threw on GetValue(cmi.suspend_data); resume disabled for this launch',
89
+ err
90
+ );
91
+ }
92
+ if (raw && raw.trim()) {
93
+ try {
73
94
  this.#state = JSON.parse(raw);
95
+ } catch (err) {
96
+ console.warn(
97
+ 'Tessera: cmi.suspend_data is not valid JSON; resume disabled for this launch (the LMS may have truncated a prior write)',
98
+ err
99
+ );
100
+ this.#state = null;
74
101
  }
75
- } catch {
76
- this.#state = null;
77
102
  }
78
103
 
79
- // Continue cmi.interactions.n indexing where the previous session left
80
- // off. Restarting at 0 would overwrite prior records (the LMS uses n
81
- // as the array key, not an upsert field).
104
+ // n indexing must continue from _count — restarting at 0 would overwrite
105
+ // the prior session's records (the LMS uses n as the array key).
106
+ let countRaw = '';
82
107
  try {
83
- const count = this.dialect.getValue(this.api, 'cmi.interactions._count');
84
- const n = parseInt(count, 10);
85
- if (Number.isFinite(n) && n >= 0) this.interactionCount = n;
86
- } catch {
87
- // Some LMSes throw on _count when no interactions exist — fall back to 0.
108
+ countRaw = this.dialect.getValue(this.api, 'cmi.interactions._count');
109
+ } catch (err) {
110
+ console.warn(
111
+ 'Tessera: LMS threw on GetValue(cmi.interactions._count); new interactions will be written from index 0 and may overwrite prior session records',
112
+ err
113
+ );
114
+ return;
115
+ }
116
+ if (countRaw === '' || countRaw === '0') return;
117
+ const n = parseInt(countRaw, 10);
118
+ if (Number.isFinite(n) && n >= 0) {
119
+ this.interactionCount = n;
120
+ } else {
121
+ console.warn(
122
+ `Tessera: LMS returned non-numeric cmi.interactions._count="${countRaw}"; new interactions will be written from index 0 and may overwrite prior session records`
123
+ );
88
124
  }
89
125
  }
90
126
 
@@ -108,15 +144,18 @@ export abstract class BaseScormAdapter<TApi> implements PersistenceAdapter {
108
144
  `larger-limit standard (scorm2004/cmi5).`
109
145
  );
110
146
  }
111
- this.queue.enqueue(() =>
112
- this.dialect.setValue(this.api, 'cmi.suspend_data', json)
147
+ this.queue.enqueue(
148
+ () => this.dialect.setValue(this.api, 'cmi.suspend_data', json),
149
+ 'cmi.suspend_data'
113
150
  );
114
151
  }
115
152
 
116
153
  setDuration(seconds: number): void {
117
154
  const formatted = this.dialect.formatDuration(seconds);
118
- this.queue.enqueue(() =>
119
- this.dialect.setValue(this.api, this.dialect.sessionTimeKey, formatted)
155
+ this.queue.enqueue(
156
+ () =>
157
+ this.dialect.setValue(this.api, this.dialect.sessionTimeKey, formatted),
158
+ this.dialect.sessionTimeKey
120
159
  );
121
160
  }
122
161
 
@@ -137,29 +176,34 @@ export abstract class BaseScormAdapter<TApi> implements PersistenceAdapter {
137
176
  timestamp: this.dialect.interactionFields.timestamp(),
138
177
  typeValue: this.dialect.interactionFields.typeValue(interaction.type),
139
178
  resultLabels: this.dialect.interactionFields.resultLabels,
179
+ format: this.dialect.interactionFields.format,
140
180
  }
141
181
  );
142
182
  for (const [key, value] of fields) {
143
- this.queue.enqueue(() => this.dialect.setValue(this.api, key, value));
183
+ this.queue.enqueue(
184
+ () => this.dialect.setValue(this.api, key, value),
185
+ key
186
+ );
144
187
  }
145
188
  }
146
189
 
147
190
  commit(): void {
148
- this.queue.enqueue(() => this.dialect.commit(this.api));
191
+ this.queue.enqueue(() => this.dialect.commit(this.api), 'Commit');
149
192
  }
150
193
 
151
194
  terminate(): void {
152
195
  if (this.#terminated) return;
153
196
  this.#terminated = true;
154
- // During page unload, async retries can't run.
155
- // Drain any pending queue operations synchronously (single attempt each),
156
- // then commit and finish synchronously.
197
+ // Async retries can't run during page unload drain + commit + finish synchronously.
157
198
  this.queue.drainSync();
158
- callSync(() => this.dialect.commit(this.api));
159
- callSync(() => this.dialect.terminate(this.api));
199
+ callSyncOrWarn(() => this.dialect.commit(this.api), 'Commit', this.errorReporter);
200
+ callSyncOrWarn(
201
+ () => this.dialect.terminate(this.api),
202
+ 'Terminate',
203
+ this.errorReporter
204
+ );
160
205
  }
161
206
 
162
- // The four operations that genuinely diverge between SCORM versions.
163
207
  abstract setScore(score: number): void;
164
208
  abstract setCompletionStatus(status: 'incomplete' | 'complete'): void;
165
209
  abstract setSuccessStatus(status: 'passed' | 'failed' | 'unknown'): void;
@@ -1,6 +1,10 @@
1
- import { scorm12Type } from '../interaction-format.js';
1
+ import {
2
+ SCORM12_INTERACTION_FORMAT,
3
+ scorm12Type,
4
+ } from '../interaction-format.js';
5
+ import type { SavedState } from '../persistence.js';
2
6
  import { BaseScormAdapter, type ScormDialect } from './scorm-base.js';
3
- import { formatHHMMSS } from './retry.js';
7
+ import { formatHHMMSS, formatReal107 } from './retry.js';
4
8
 
5
9
  /**
6
10
  * SCORM 1.2 API interface.
@@ -27,6 +31,7 @@ const SCORM12_DIALECT: ScormDialect<SCORM12API> = {
27
31
  timestamp: () => new Date().toTimeString().slice(0, 8),
28
32
  typeValue: (t) => scorm12Type(t),
29
33
  resultLabels: { correct: 'correct', incorrect: 'wrong' },
34
+ format: SCORM12_INTERACTION_FORMAT,
30
35
  },
31
36
  initialize: (api) => api.LMSInitialize(''),
32
37
  terminate: (api) => api.LMSFinish(''),
@@ -35,6 +40,7 @@ const SCORM12_DIALECT: ScormDialect<SCORM12API> = {
35
40
  commit: (api) => api.LMSCommit(''),
36
41
  getLastError: (api) => api.LMSGetLastError(),
37
42
  getErrorString: (api, code) => api.LMSGetErrorString(code),
43
+ getDiagnostic: (api, code) => api.LMSGetDiagnostic(code),
38
44
  };
39
45
 
40
46
  /**
@@ -57,12 +63,29 @@ export class SCORM12Adapter extends BaseScormAdapter<SCORM12API> {
57
63
  super(api, SCORM12_DIALECT);
58
64
  }
59
65
 
66
+ saveState(state: SavedState): void {
67
+ super.saveState(state);
68
+ // §3.4.5.3 — bookmark for LMS "Resume from page N" affordances.
69
+ this.queue.enqueue(
70
+ () =>
71
+ this.api.LMSSetValue('cmi.core.lesson_location', String(state.b)),
72
+ 'cmi.core.lesson_location'
73
+ );
74
+ }
75
+
60
76
  setScore(score: number): void {
61
- this.queue.enqueue(() =>
62
- this.api.LMSSetValue('cmi.core.score.raw', String(score))
77
+ this.queue.enqueue(
78
+ () => this.api.LMSSetValue('cmi.core.score.raw', formatReal107(score)),
79
+ 'cmi.core.score.raw'
80
+ );
81
+ this.queue.enqueue(
82
+ () => this.api.LMSSetValue('cmi.core.score.min', '0'),
83
+ 'cmi.core.score.min'
84
+ );
85
+ this.queue.enqueue(
86
+ () => this.api.LMSSetValue('cmi.core.score.max', '100'),
87
+ 'cmi.core.score.max'
63
88
  );
64
- this.queue.enqueue(() => this.api.LMSSetValue('cmi.core.score.min', '0'));
65
- this.queue.enqueue(() => this.api.LMSSetValue('cmi.core.score.max', '100'));
66
89
  }
67
90
 
68
91
  setCompletionStatus(status: 'incomplete' | 'complete'): void {
@@ -78,17 +101,19 @@ export class SCORM12Adapter extends BaseScormAdapter<SCORM12API> {
78
101
  }
79
102
 
80
103
  #flushLessonStatus(): void {
81
- // Success status takes priority — it's the more specific status.
82
104
  const value = this.#successStatus ?? this.#completionStatus;
83
- this.queue.enqueue(() =>
84
- this.api.LMSSetValue('cmi.core.lesson_status', value)
105
+ this.queue.enqueue(
106
+ () => this.api.LMSSetValue('cmi.core.lesson_status', value),
107
+ 'cmi.core.lesson_status'
85
108
  );
86
109
  }
87
110
 
88
111
  setExit(mode: 'suspend' | 'normal'): void {
89
112
  // SCORM 1.2 §4.2.2 vocabulary: time-out, suspend, logout, "" (normal).
90
- // We only map 'suspend' and the empty/normal case.
91
113
  const value = mode === 'suspend' ? 'suspend' : '';
92
- this.queue.enqueue(() => this.api.LMSSetValue('cmi.core.exit', value));
114
+ this.queue.enqueue(
115
+ () => this.api.LMSSetValue('cmi.core.exit', value),
116
+ 'cmi.core.exit'
117
+ );
93
118
  }
94
119
  }
@@ -1,9 +1,12 @@
1
+ import { SCORM2004_INTERACTION_FORMAT } from '../interaction-format.js';
2
+ import type { SavedState } from '../persistence.js';
1
3
  import { BaseScormAdapter, type ScormDialect } from './scorm-base.js';
2
- import { formatISO8601Duration } from './retry.js';
4
+ import {
5
+ formatISO8601Duration,
6
+ formatISO8601Timestamp,
7
+ formatReal107,
8
+ } from './retry.js';
3
9
 
4
- /**
5
- * SCORM 2004 API interface.
6
- */
7
10
  export interface SCORM2004API {
8
11
  Initialize(param: string): string;
9
12
  Terminate(param: string): string;
@@ -23,10 +26,11 @@ const SCORM2004_DIALECT: ScormDialect<SCORM2004API> = {
23
26
  interactionFields: {
24
27
  responseField: 'learner_response',
25
28
  timestampField: 'timestamp',
26
- timestamp: () => new Date().toISOString(),
29
+ timestamp: () => formatISO8601Timestamp(new Date()),
27
30
  // SCORM 2004 accepts the canonical interaction `type` strings unchanged.
28
31
  typeValue: (t) => t,
29
32
  resultLabels: { correct: 'correct', incorrect: 'incorrect' },
33
+ format: SCORM2004_INTERACTION_FORMAT,
30
34
  },
31
35
  initialize: (api) => api.Initialize(''),
32
36
  terminate: (api) => api.Terminate(''),
@@ -35,49 +39,148 @@ const SCORM2004_DIALECT: ScormDialect<SCORM2004API> = {
35
39
  commit: (api) => api.Commit(''),
36
40
  getLastError: (api) => api.GetLastError(),
37
41
  getErrorString: (api, code) => api.GetErrorString(code),
42
+ getDiagnostic: (api, code) => api.GetDiagnostic(code),
38
43
  };
39
44
 
45
+ /** SCORM 2004 4E §4.2.1.5 cmi.mode vocabulary. */
46
+ export type SCORM2004Mode = 'browse' | 'normal' | 'review';
47
+
40
48
  /**
41
- * SCORM 2004 persistence adapter.
42
- *
43
- * Uses a sequential write queue for all LMS SetValue/Commit calls.
44
- * On terminate, the queue is drained synchronously (single attempt)
45
- * since async retries cannot complete during page unload.
49
+ * Per §4.2.1.5, the SCO MUST NOT alter the learner record in `browse` or
50
+ * `review` mode — every write below is gated on `#mode === 'normal'`.
51
+ * `#masteryScore` (§4.2.4.3) and `#completionThreshold` (§4.2.4.4) are
52
+ * LMS-supplied thresholds in [0,1].
46
53
  */
47
54
  export class SCORM2004Adapter extends BaseScormAdapter<SCORM2004API> {
55
+ #mode: SCORM2004Mode = 'normal';
56
+ #masteryScore: number | null = null;
57
+ #completionThreshold: number | null = null;
58
+
48
59
  constructor(api: SCORM2004API) {
49
60
  super(api, SCORM2004_DIALECT);
50
61
  }
51
62
 
63
+ async init(): Promise<void> {
64
+ await super.init();
65
+ this.#mode = this.#readMode();
66
+ this.#masteryScore = this.#readScaledThreshold('cmi.scaled_passing_score');
67
+ this.#completionThreshold = this.#readScaledThreshold(
68
+ 'cmi.completion_threshold'
69
+ );
70
+ }
71
+
72
+ getLaunchMode(): SCORM2004Mode {
73
+ return this.#mode;
74
+ }
75
+
76
+ /** Read by App.svelte to override `course.config.js scoring.passingScore`. */
77
+ getMasteryScore(): number | null {
78
+ return this.#masteryScore;
79
+ }
80
+
81
+ getCompletionThreshold(): number | null {
82
+ return this.#completionThreshold;
83
+ }
84
+
85
+ #readMode(): SCORM2004Mode {
86
+ try {
87
+ const v = this.api.GetValue('cmi.mode');
88
+ if (v === 'browse' || v === 'review' || v === 'normal') return v;
89
+ } catch {}
90
+ return 'normal';
91
+ }
92
+
93
+ #readScaledThreshold(key: string): number | null {
94
+ let raw = '';
95
+ try {
96
+ raw = this.api.GetValue(key);
97
+ } catch {
98
+ return null;
99
+ }
100
+ if (!raw) return null;
101
+ const n = Number(raw);
102
+ if (Number.isFinite(n) && n >= 0 && n <= 1) return n;
103
+ return null;
104
+ }
105
+
106
+ saveState(state: SavedState): void {
107
+ if (this.#mode !== 'normal') return;
108
+ super.saveState(state);
109
+ // §4.2.1.4 — bookmark for LMS "Resume from page N" affordances.
110
+ this.queue.enqueue(
111
+ () => this.api.SetValue('cmi.location', String(state.b)),
112
+ 'cmi.location'
113
+ );
114
+ }
115
+
116
+ setDuration(seconds: number): void {
117
+ if (this.#mode !== 'normal') return;
118
+ super.setDuration(seconds);
119
+ }
120
+
121
+ reportInteraction(
122
+ questionId: string,
123
+ interaction: import('../interaction.js').Interaction,
124
+ correct: boolean | null
125
+ ): void {
126
+ if (this.#mode !== 'normal') return;
127
+ super.reportInteraction(questionId, interaction, correct);
128
+ }
129
+
52
130
  setScore(score: number): void {
53
- this.queue.enqueue(() =>
54
- this.api.SetValue('cmi.score.raw', String(score))
131
+ if (this.#mode !== 'normal') return;
132
+ const raw = formatReal107(score);
133
+ // §4.2.4.3.5 — score.scaled is bounded to [-1, 1].
134
+ const scaled = formatReal107(Math.max(0, Math.min(1, score / 100)));
135
+ this.queue.enqueue(
136
+ () => this.api.SetValue('cmi.score.raw', raw),
137
+ 'cmi.score.raw'
138
+ );
139
+ this.queue.enqueue(
140
+ () => this.api.SetValue('cmi.score.min', '0'),
141
+ 'cmi.score.min'
55
142
  );
56
- this.queue.enqueue(() => this.api.SetValue('cmi.score.min', '0'));
57
- this.queue.enqueue(() => this.api.SetValue('cmi.score.max', '100'));
58
- this.queue.enqueue(() =>
59
- this.api.SetValue('cmi.score.scaled', String(score / 100))
143
+ this.queue.enqueue(
144
+ () => this.api.SetValue('cmi.score.max', '100'),
145
+ 'cmi.score.max'
146
+ );
147
+ this.queue.enqueue(
148
+ () => this.api.SetValue('cmi.score.scaled', scaled),
149
+ 'cmi.score.scaled'
60
150
  );
61
151
  }
62
152
 
63
- // Note: cmi.completion_threshold and cmi.scaled_passing_score are typically
64
- // set by the LMS, not the SCO. Tessera manages completion and passing
65
- // logic internally via course.config.js settings.
66
153
  setCompletionStatus(status: 'incomplete' | 'complete'): void {
154
+ if (this.#mode !== 'normal') return;
67
155
  const value = status === 'complete' ? 'completed' : 'incomplete';
68
- this.queue.enqueue(() =>
69
- this.api.SetValue('cmi.completion_status', value)
156
+ this.queue.enqueue(
157
+ () => this.api.SetValue('cmi.completion_status', value),
158
+ 'cmi.completion_status'
70
159
  );
160
+ // §4.2.4.2 — writing 1.0 surfaces a "100%" reading on LMS dashboards.
161
+ if (status === 'complete') {
162
+ this.queue.enqueue(
163
+ () => this.api.SetValue('cmi.progress_measure', '1'),
164
+ 'cmi.progress_measure'
165
+ );
166
+ }
71
167
  }
72
168
 
73
169
  setSuccessStatus(status: 'passed' | 'failed' | 'unknown'): void {
74
- // "unknown" is a valid SCORM 2004 value — setting it explicitly prevents
75
- // LMSes (notably SCORM Cloud) from rolling up a null status to "passed".
76
- this.queue.enqueue(() => this.api.SetValue('cmi.success_status', status));
170
+ if (this.#mode !== 'normal') return;
171
+ // Setting "unknown" explicitly prevents SCORM Cloud from rolling up
172
+ // a null status to "passed".
173
+ this.queue.enqueue(
174
+ () => this.api.SetValue('cmi.success_status', status),
175
+ 'cmi.success_status'
176
+ );
77
177
  }
78
178
 
79
179
  setExit(mode: 'suspend' | 'normal'): void {
80
- // SCORM 2004 §4.2 cmi.exit vocabulary: time-out, suspend, logout, normal, "".
81
- this.queue.enqueue(() => this.api.SetValue('cmi.exit', mode));
180
+ if (this.#mode !== 'normal') return;
181
+ this.queue.enqueue(
182
+ () => this.api.SetValue('cmi.exit', mode),
183
+ 'cmi.exit'
184
+ );
82
185
  }
83
186
  }
@@ -1,4 +1,4 @@
1
- import { getContext, setContext, onDestroy } from 'svelte';
1
+ import { getContext, setContext, onDestroy, onMount, tick } from 'svelte';
2
2
  import type { Interaction } from './interaction.js';
3
3
  import { isCorrect as isCorrectInteraction } from './interaction.js';
4
4
  import {
@@ -311,6 +311,20 @@ export function __warnUnsubmittedQuiz(stats: {
311
311
  );
312
312
  }
313
313
 
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
+ export function __warnEmptyQuiz(questionsCount: number): void {
321
+ if (questionsCount > 0) return;
322
+ console.warn(
323
+ '[tessera] useQuiz: quiz mounted with no registered questions. Question widgets ' +
324
+ 'must call useQuestion() to be scored and reported to the LMS.'
325
+ );
326
+ }
327
+
314
328
  /**
315
329
  * Programmatic quiz orchestration for custom quiz shells. Returns a handle
316
330
  * exposing the same state machine `<Quiz>` runs internally — register
@@ -585,6 +599,13 @@ export function useQuiz(opts: { element: () => HTMLElement | null }): UseQuizHan
585
599
  get isLockedCorrect() { return (i: number) => lockedCorrect.has(i); },
586
600
  });
587
601
 
602
+ onMount(() => {
603
+ 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));
607
+ });
608
+
588
609
  onDestroy(() => {
589
610
  __warnUnsubmittedQuiz({
590
611
  questionsCount: questions.length,