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.
- package/dist/plugin/cli.d.ts +1 -0
- package/dist/plugin/cli.js +18 -0
- package/dist/plugin/cli.js.map +1 -0
- package/dist/plugin/index.js +9 -730
- package/dist/plugin/index.js.map +1 -1
- package/dist/validation-B4UhCY5y.js +911 -0
- package/dist/validation-B4UhCY5y.js.map +1 -0
- package/package.json +4 -2
- package/src/plugin/cli.ts +30 -0
- package/src/plugin/export.ts +12 -1
- package/src/plugin/validation.ts +336 -62
- package/src/runtime/adapters/index.ts +1 -1
- package/src/runtime/adapters/retry.ts +86 -15
- package/src/runtime/adapters/scorm-base.ts +90 -46
- package/src/runtime/adapters/scorm12.ts +36 -11
- package/src/runtime/adapters/scorm2004.ts +129 -26
- package/src/runtime/hooks.svelte.ts +22 -1
- package/src/runtime/interaction-format.ts +83 -48
- package/AGENTS.md +0 -1362
|
@@ -1,34 +1,33 @@
|
|
|
1
1
|
import type { PersistenceAdapter, SavedState } from '../persistence.js';
|
|
2
2
|
import type { Interaction } from '../interaction.js';
|
|
3
|
-
import {
|
|
4
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
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(
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
//
|
|
80
|
-
//
|
|
81
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
//
|
|
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
|
-
|
|
159
|
-
|
|
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 {
|
|
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',
|
|
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(
|
|
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 {
|
|
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()
|
|
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
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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
|
|
54
|
-
|
|
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(
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
75
|
-
//
|
|
76
|
-
|
|
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
|
-
|
|
81
|
-
this.queue.enqueue(
|
|
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,
|