tessera-learn 0.0.5 → 0.0.6
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 +17 -3
- package/dist/plugin/index.js +7 -3
- package/dist/plugin/index.js.map +1 -1
- package/package.json +1 -1
- package/src/plugin/export.ts +12 -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/interaction-format.ts +83 -48
|
@@ -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,79 +1,125 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* reuses the same encoding for `cmi.interaction` activity statements.
|
|
5
|
-
*
|
|
6
|
-
* ITEM delimiter [,]
|
|
7
|
-
* PAIR delimiter [.]
|
|
8
|
-
* RANGE delimiter [:]
|
|
2
|
+
* SCORM 1.2 RTE §3.4.7 vs SCORM 2004 4E RTE §4.2.7 differ in delimiter
|
|
3
|
+
* encoding and identifier rules; cmi5 (xAPI) reuses the 2004 encoding.
|
|
9
4
|
*/
|
|
10
5
|
|
|
11
6
|
import type { Interaction } from './interaction.js';
|
|
12
7
|
|
|
8
|
+
export interface InteractionFormat {
|
|
9
|
+
itemDelim: string;
|
|
10
|
+
pairDelim: string;
|
|
11
|
+
rangeDelim: string;
|
|
12
|
+
/**
|
|
13
|
+
* SCORM 1.2 has no numeric range syntax — `correct_responses.n.pattern`
|
|
14
|
+
* is a single CMIDecimal. SCORM 2004 supports `min[:]max`.
|
|
15
|
+
*/
|
|
16
|
+
supportsNumericRange: boolean;
|
|
17
|
+
formatBoolean(value: boolean): string;
|
|
18
|
+
identifier(value: string): string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const SCORM12_INTERACTION_FORMAT: InteractionFormat = {
|
|
22
|
+
itemDelim: ',',
|
|
23
|
+
pairDelim: '.',
|
|
24
|
+
rangeDelim: ':',
|
|
25
|
+
supportsNumericRange: false,
|
|
26
|
+
formatBoolean: (v) => (v ? 't' : 'f'),
|
|
27
|
+
identifier: shortIdentifier,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Bracketed delimiters are literal text, not regex. xAPI parses them the
|
|
32
|
+
* same way.
|
|
33
|
+
*/
|
|
34
|
+
export const SCORM2004_INTERACTION_FORMAT: InteractionFormat = {
|
|
35
|
+
itemDelim: '[,]',
|
|
36
|
+
pairDelim: '[.]',
|
|
37
|
+
rangeDelim: '[:]',
|
|
38
|
+
supportsNumericRange: true,
|
|
39
|
+
formatBoolean: (v) => (v ? 'true' : 'false'),
|
|
40
|
+
identifier: shortIdentifier,
|
|
41
|
+
};
|
|
42
|
+
|
|
13
43
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
44
|
+
* SCORM `short_identifier_type` / `CMIIdentifier`: alphanumerics +
|
|
45
|
+
* underscore, max 250 chars. Strict validators (SCORM Cloud) reject raw
|
|
46
|
+
* option labels with spaces or punctuation with error 405/406.
|
|
16
47
|
*/
|
|
17
|
-
|
|
48
|
+
function shortIdentifier(value: string): string {
|
|
49
|
+
const cleaned = value.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
|
|
50
|
+
const trimmed = cleaned.slice(0, 250);
|
|
51
|
+
return trimmed || '_';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function formatResponse(
|
|
55
|
+
i: Interaction,
|
|
56
|
+
fmt: InteractionFormat = SCORM2004_INTERACTION_FORMAT
|
|
57
|
+
): string {
|
|
18
58
|
switch (i.type) {
|
|
19
59
|
case 'choice':
|
|
20
60
|
case 'sequencing':
|
|
21
|
-
return i.response.join(
|
|
61
|
+
return i.response.map(fmt.identifier).join(fmt.itemDelim);
|
|
22
62
|
case 'true-false':
|
|
23
|
-
return i.response
|
|
63
|
+
return fmt.formatBoolean(i.response);
|
|
24
64
|
case 'fill-in':
|
|
25
65
|
case 'long-fill-in':
|
|
26
66
|
case 'likert':
|
|
27
67
|
case 'other':
|
|
28
68
|
return i.response;
|
|
29
69
|
case 'matching':
|
|
30
|
-
return i.response
|
|
70
|
+
return i.response
|
|
71
|
+
.map(([l, r]) => `${fmt.identifier(l)}${fmt.pairDelim}${fmt.identifier(r)}`)
|
|
72
|
+
.join(fmt.itemDelim);
|
|
31
73
|
case 'numeric':
|
|
32
74
|
return String(i.response);
|
|
33
75
|
case 'performance':
|
|
34
|
-
return i.response
|
|
76
|
+
return i.response
|
|
77
|
+
.map(([s, v]) => `${fmt.identifier(s)}${fmt.pairDelim}${fmt.identifier(String(v))}`)
|
|
78
|
+
.join(fmt.itemDelim);
|
|
35
79
|
}
|
|
36
80
|
}
|
|
37
81
|
|
|
38
|
-
/**
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
82
|
+
/** Returns null when no correct pattern was provided. */
|
|
83
|
+
export function formatCorrectPattern(
|
|
84
|
+
i: Interaction,
|
|
85
|
+
fmt: InteractionFormat = SCORM2004_INTERACTION_FORMAT
|
|
86
|
+
): string | null {
|
|
43
87
|
if (i.correct === undefined) return null;
|
|
44
88
|
switch (i.type) {
|
|
45
89
|
case 'choice':
|
|
46
90
|
case 'sequencing':
|
|
47
|
-
return (i.correct as string[]).join(
|
|
91
|
+
return (i.correct as string[]).map(fmt.identifier).join(fmt.itemDelim);
|
|
48
92
|
case 'true-false':
|
|
49
|
-
return (i.correct as boolean)
|
|
93
|
+
return fmt.formatBoolean(i.correct as boolean);
|
|
50
94
|
case 'fill-in':
|
|
51
95
|
case 'long-fill-in':
|
|
52
|
-
|
|
53
|
-
return (i.correct as string[]).join('[,]');
|
|
96
|
+
return (i.correct as string[]).join(fmt.itemDelim);
|
|
54
97
|
case 'matching':
|
|
55
|
-
return (i.correct as Array<[string, string]>)
|
|
98
|
+
return (i.correct as Array<[string, string]>)
|
|
99
|
+
.map(([l, r]) => `${fmt.identifier(l)}${fmt.pairDelim}${fmt.identifier(r)}`)
|
|
100
|
+
.join(fmt.itemDelim);
|
|
56
101
|
case 'numeric': {
|
|
57
102
|
const c = i.correct as { min?: number; max?: number };
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
103
|
+
if (c.min !== undefined && c.max !== undefined && c.min === c.max) {
|
|
104
|
+
return String(c.min);
|
|
105
|
+
}
|
|
106
|
+
if (c.min !== undefined && c.max === undefined) return String(c.min);
|
|
107
|
+
if (c.min === undefined && c.max !== undefined) return String(c.max);
|
|
108
|
+
// True range — drop the pattern in 1.2 (rely on `result` for pass/fail).
|
|
109
|
+
if (!fmt.supportsNumericRange) return null;
|
|
110
|
+
return `${c.min ?? ''}${fmt.rangeDelim}${c.max ?? ''}`;
|
|
61
111
|
}
|
|
62
112
|
case 'likert':
|
|
63
113
|
case 'other':
|
|
64
114
|
return i.correct as string;
|
|
65
115
|
case 'performance':
|
|
66
116
|
return (i.correct as Array<[string, string | number]>)
|
|
67
|
-
.map(([s, v]) => `${s}
|
|
68
|
-
.join(
|
|
117
|
+
.map(([s, v]) => `${fmt.identifier(s)}${fmt.pairDelim}${fmt.identifier(String(v))}`)
|
|
118
|
+
.join(fmt.itemDelim);
|
|
69
119
|
}
|
|
70
120
|
}
|
|
71
121
|
|
|
72
|
-
/**
|
|
73
|
-
* Map Tessera interaction types to SCORM 1.2's narrower vocabulary. SCORM 1.2
|
|
74
|
-
* does not define `long-fill-in`; fall back to `fill-in`. `other` is not in
|
|
75
|
-
* the spec either — fall back to `fill-in` (free text).
|
|
76
|
-
*/
|
|
122
|
+
/** SCORM 1.2 has no `long-fill-in` or `other` — both fall back to `fill-in`. */
|
|
77
123
|
export function scorm12Type(type: Interaction['type']): string {
|
|
78
124
|
switch (type) {
|
|
79
125
|
case 'long-fill-in':
|
|
@@ -85,26 +131,15 @@ export function scorm12Type(type: Interaction['type']): string {
|
|
|
85
131
|
}
|
|
86
132
|
}
|
|
87
133
|
|
|
88
|
-
/**
|
|
89
|
-
* Per-standard differences in how `cmi.interactions.n.*` is written. The
|
|
90
|
-
* SCORM 1.2 vs 2004 deltas are: response field name, result vocabulary,
|
|
91
|
-
* timestamp field name+format, and the type vocabulary mapping.
|
|
92
|
-
*/
|
|
93
134
|
export interface ScormInteractionSpec {
|
|
94
135
|
responseField: 'student_response' | 'learner_response';
|
|
95
136
|
timestampField: 'time' | 'timestamp';
|
|
96
|
-
/** Wall-clock value formatted to whichever style the standard expects. */
|
|
97
137
|
timestamp: string;
|
|
98
|
-
/** Mapped interaction type — already narrowed for SCORM 1.2 callers. */
|
|
99
138
|
typeValue: string;
|
|
100
139
|
resultLabels: { correct: string; incorrect: string };
|
|
140
|
+
format: InteractionFormat;
|
|
101
141
|
}
|
|
102
142
|
|
|
103
|
-
/**
|
|
104
|
-
* Build the ordered list of `cmi.interactions.n.*` writes that SCORM 1.2 and
|
|
105
|
-
* SCORM 2004 adapters share. Caller wires each pair through its own LMS
|
|
106
|
-
* SetValue queue (the queueing semantics differ between adapters).
|
|
107
|
-
*/
|
|
108
143
|
export function buildScormInteractionFields(
|
|
109
144
|
prefix: string,
|
|
110
145
|
questionId: string,
|
|
@@ -115,9 +150,9 @@ export function buildScormInteractionFields(
|
|
|
115
150
|
const fields: Array<[string, string]> = [
|
|
116
151
|
[`${prefix}.id`, questionId],
|
|
117
152
|
[`${prefix}.type`, spec.typeValue],
|
|
118
|
-
[`${prefix}.${spec.responseField}`, formatResponse(interaction)],
|
|
153
|
+
[`${prefix}.${spec.responseField}`, formatResponse(interaction, spec.format)],
|
|
119
154
|
];
|
|
120
|
-
const pattern = formatCorrectPattern(interaction);
|
|
155
|
+
const pattern = formatCorrectPattern(interaction, spec.format);
|
|
121
156
|
if (pattern !== null) {
|
|
122
157
|
fields.push([`${prefix}.correct_responses.0.pattern`, pattern]);
|
|
123
158
|
}
|