Skip to content

Commit 96e5076

Browse files
redmoddclaude
andcommitted
refactor: dedupe SCORM writes and xAPI publisher fetch
- Add a gated `set()` helper + `canWrite()` hook on BaseScormAdapter so the SCORM 1.2/2004 adapters stop repeating `queue.enqueue(() => SetValue...)`, and SCORM 2004 expresses its browse/review write-gating once instead of in seven methods. - Collapse the duplicate `scorm12Type` cases. - Extract a single `#post()` in XAPIPublisher so the 401-retry path no longer rebuilds the statements POST. - Drop a redundant `isCorrectOption` call in MultipleChoice. Behavior-preserving; full unit suite (976 tests) green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 922fa69 commit 96e5076

7 files changed

Lines changed: 51 additions & 111 deletions

File tree

.changeset/scorm-write-helper.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'tessera-learn': patch
3+
'create-tessera': patch
4+
---
5+
6+
Internal refactor of the SCORM adapters and xAPI publisher; no behavior change.

packages/tessera-learn/src/components/MultipleChoice.svelte

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,7 @@
6262
function getOptionClass(optIndex) {
6363
if (!q.feedbackVisible) return '';
6464
if (isCorrectOption(optIndex)) return 'correct';
65-
if (optIndex === selectedOption && !isCorrectOption(optIndex))
66-
return 'incorrect';
65+
if (optIndex === selectedOption) return 'incorrect';
6766
return '';
6867
}
6968
</script>

packages/tessera-learn/src/runtime/adapters/scorm-base.ts

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,16 @@ export abstract class BaseScormAdapter<TApi> implements PersistenceAdapter {
6666
return this.api;
6767
}
6868

69+
// SCORM 2004 overrides this to block writes in browse/review mode (§4.2.1.5).
70+
protected canWrite(): boolean {
71+
return true;
72+
}
73+
74+
protected set(key: string, value: string): void {
75+
if (!this.canWrite()) return;
76+
this.queue.enqueue(() => this.dialect.setValue(this.api, key, value), key);
77+
}
78+
6979
async init(): Promise<void> {
7080
const initialized = await withRetry(
7181
() => this.dialect.initialize(this.api),
@@ -129,6 +139,7 @@ export abstract class BaseScormAdapter<TApi> implements PersistenceAdapter {
129139
}
130140

131141
saveState(state: SavedState): void {
142+
if (!this.canWrite()) return;
132143
this.#state = state;
133144
const json = JSON.stringify(state);
134145
if (
@@ -144,26 +155,19 @@ export abstract class BaseScormAdapter<TApi> implements PersistenceAdapter {
144155
`larger-limit standard (scorm2004/cmi5).`,
145156
);
146157
}
147-
this.queue.enqueue(
148-
() => this.dialect.setValue(this.api, 'cmi.suspend_data', json),
149-
'cmi.suspend_data',
150-
);
158+
this.set('cmi.suspend_data', json);
151159
}
152160

153161
setDuration(seconds: number): void {
154-
const formatted = this.dialect.formatDuration(seconds);
155-
this.queue.enqueue(
156-
() =>
157-
this.dialect.setValue(this.api, this.dialect.sessionTimeKey, formatted),
158-
this.dialect.sessionTimeKey,
159-
);
162+
this.set(this.dialect.sessionTimeKey, this.dialect.formatDuration(seconds));
160163
}
161164

162165
reportInteraction(
163166
questionId: string,
164167
interaction: Interaction,
165168
correct: boolean | null,
166169
): void {
170+
if (!this.canWrite()) return;
167171
const n = this.interactionCount++;
168172
const fields = buildScormInteractionFields(
169173
`cmi.interactions.${n}`,
@@ -180,10 +184,7 @@ export abstract class BaseScormAdapter<TApi> implements PersistenceAdapter {
180184
},
181185
);
182186
for (const [key, value] of fields) {
183-
this.queue.enqueue(
184-
() => this.dialect.setValue(this.api, key, value),
185-
key,
186-
);
187+
this.set(key, value);
187188
}
188189
}
189190

packages/tessera-learn/src/runtime/adapters/scorm12.ts

Lines changed: 6 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -66,25 +66,13 @@ export class SCORM12Adapter extends BaseScormAdapter<SCORM12API> {
6666
saveState(state: SavedState): void {
6767
super.saveState(state);
6868
// §3.4.5.3 — bookmark for LMS "Resume from page N" affordances.
69-
this.queue.enqueue(
70-
() => this.api.LMSSetValue('cmi.core.lesson_location', String(state.b)),
71-
'cmi.core.lesson_location',
72-
);
69+
this.set('cmi.core.lesson_location', String(state.b));
7370
}
7471

7572
setScore(score: number): void {
76-
this.queue.enqueue(
77-
() => this.api.LMSSetValue('cmi.core.score.raw', formatReal107(score)),
78-
'cmi.core.score.raw',
79-
);
80-
this.queue.enqueue(
81-
() => this.api.LMSSetValue('cmi.core.score.min', '0'),
82-
'cmi.core.score.min',
83-
);
84-
this.queue.enqueue(
85-
() => this.api.LMSSetValue('cmi.core.score.max', '100'),
86-
'cmi.core.score.max',
87-
);
73+
this.set('cmi.core.score.raw', formatReal107(score));
74+
this.set('cmi.core.score.min', '0');
75+
this.set('cmi.core.score.max', '100');
8876
}
8977

9078
setCompletionStatus(status: 'incomplete' | 'complete'): void {
@@ -101,18 +89,11 @@ export class SCORM12Adapter extends BaseScormAdapter<SCORM12API> {
10189

10290
#flushLessonStatus(): void {
10391
const value = this.#successStatus ?? this.#completionStatus;
104-
this.queue.enqueue(
105-
() => this.api.LMSSetValue('cmi.core.lesson_status', value),
106-
'cmi.core.lesson_status',
107-
);
92+
this.set('cmi.core.lesson_status', value);
10893
}
10994

11095
setExit(mode: 'suspend' | 'normal'): void {
11196
// SCORM 1.2 §4.2.2 vocabulary: time-out, suspend, logout, "" (normal).
112-
const value = mode === 'suspend' ? 'suspend' : '';
113-
this.queue.enqueue(
114-
() => this.api.LMSSetValue('cmi.core.exit', value),
115-
'cmi.core.exit',
116-
);
97+
this.set('cmi.core.exit', mode === 'suspend' ? 'suspend' : '');
11798
}
11899
}

packages/tessera-learn/src/runtime/adapters/scorm2004.ts

Lines changed: 12 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { SCORM2004_INTERACTION_FORMAT } from '../interaction-format.js';
2-
import type { Interaction } from '../interaction.js';
32
import type { SavedState } from '../persistence.js';
43
import { BaseScormAdapter, type ScormDialect } from './scorm-base.js';
54
import {
@@ -75,7 +74,7 @@ export class SCORM2004Adapter extends BaseScormAdapter<SCORM2004API> {
7574
return this.#masteryScore;
7675
}
7776

78-
get #canWrite(): boolean {
77+
protected canWrite(): boolean {
7978
return this.#mode === 'normal';
8079
}
8180

@@ -98,80 +97,38 @@ export class SCORM2004Adapter extends BaseScormAdapter<SCORM2004API> {
9897
}
9998

10099
saveState(state: SavedState): void {
101-
if (!this.#canWrite) return;
102100
super.saveState(state);
103101
// §4.2.1.4 — bookmark for LMS "Resume from page N" affordances.
104-
this.queue.enqueue(
105-
() => this.api.SetValue('cmi.location', String(state.b)),
106-
'cmi.location',
107-
);
108-
}
109-
110-
setDuration(seconds: number): void {
111-
if (!this.#canWrite) return;
112-
super.setDuration(seconds);
113-
}
114-
115-
reportInteraction(
116-
questionId: string,
117-
interaction: Interaction,
118-
correct: boolean | null,
119-
): void {
120-
if (!this.#canWrite) return;
121-
super.reportInteraction(questionId, interaction, correct);
102+
this.set('cmi.location', String(state.b));
122103
}
123104

124105
setScore(score: number): void {
125-
if (!this.#canWrite) return;
126-
const raw = formatReal107(score);
106+
this.set('cmi.score.raw', formatReal107(score));
107+
this.set('cmi.score.min', '0');
108+
this.set('cmi.score.max', '100');
127109
// §4.2.4.3.5 — score.scaled is bounded to [-1, 1].
128-
const scaled = formatReal107(Math.max(0, Math.min(1, score / 100)));
129-
this.queue.enqueue(
130-
() => this.api.SetValue('cmi.score.raw', raw),
131-
'cmi.score.raw',
132-
);
133-
this.queue.enqueue(
134-
() => this.api.SetValue('cmi.score.min', '0'),
135-
'cmi.score.min',
136-
);
137-
this.queue.enqueue(
138-
() => this.api.SetValue('cmi.score.max', '100'),
139-
'cmi.score.max',
140-
);
141-
this.queue.enqueue(
142-
() => this.api.SetValue('cmi.score.scaled', scaled),
110+
this.set(
143111
'cmi.score.scaled',
112+
formatReal107(Math.max(0, Math.min(1, score / 100))),
144113
);
145114
}
146115

147116
setCompletionStatus(status: 'incomplete' | 'complete'): void {
148-
if (!this.#canWrite) return;
149-
const value = status === 'complete' ? 'completed' : 'incomplete';
150-
this.queue.enqueue(
151-
() => this.api.SetValue('cmi.completion_status', value),
117+
this.set(
152118
'cmi.completion_status',
119+
status === 'complete' ? 'completed' : 'incomplete',
153120
);
154121
// §4.2.4.2 — writing 1.0 surfaces a "100%" reading on LMS dashboards.
155-
if (status === 'complete') {
156-
this.queue.enqueue(
157-
() => this.api.SetValue('cmi.progress_measure', '1'),
158-
'cmi.progress_measure',
159-
);
160-
}
122+
if (status === 'complete') this.set('cmi.progress_measure', '1');
161123
}
162124

163125
setSuccessStatus(status: 'passed' | 'failed' | 'unknown'): void {
164-
if (!this.#canWrite) return;
165126
// Setting "unknown" explicitly prevents SCORM Cloud from rolling up
166127
// a null status to "passed".
167-
this.queue.enqueue(
168-
() => this.api.SetValue('cmi.success_status', status),
169-
'cmi.success_status',
170-
);
128+
this.set('cmi.success_status', status);
171129
}
172130

173131
setExit(mode: 'suspend' | 'normal'): void {
174-
if (!this.#canWrite) return;
175-
this.queue.enqueue(() => this.api.SetValue('cmi.exit', mode), 'cmi.exit');
132+
this.set('cmi.exit', mode);
176133
}
177134
}

packages/tessera-learn/src/runtime/interaction-format.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,6 @@ export function formatCorrectPattern(
165165
export function scorm12Type(type: Interaction['type']): string {
166166
switch (type) {
167167
case 'long-fill-in':
168-
return 'fill-in';
169168
case 'other':
170169
return 'fill-in';
171170
default:

packages/tessera-learn/src/runtime/xapi/publisher.ts

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -494,17 +494,21 @@ export class XAPIPublisher {
494494
return headers;
495495
}
496496

497-
#fetchWithToken(
498-
token: string,
499-
body: string,
500-
keepalive: boolean,
501-
): Promise<SendOutcome> {
497+
#post(token: string, body: string, keepalive: boolean): Promise<Response> {
502498
return fetch(this.#statementsUrl, {
503499
method: 'POST',
504500
headers: this.#buildHeaders(token),
505501
body,
506502
keepalive,
507-
})
503+
});
504+
}
505+
506+
#fetchWithToken(
507+
token: string,
508+
body: string,
509+
keepalive: boolean,
510+
): Promise<SendOutcome> {
511+
return this.#post(token, body, keepalive)
508512
.then((resp) => this.#handleResponse(resp, body, keepalive))
509513
.catch((err) => ({
510514
ok: false,
@@ -529,14 +533,7 @@ export class XAPIPublisher {
529533
) {
530534
this.#cachedAuth = null;
531535
return this.#resolveAuth(true)
532-
.then((newToken) =>
533-
fetch(this.#statementsUrl, {
534-
method: 'POST',
535-
headers: this.#buildHeaders(newToken),
536-
body,
537-
keepalive,
538-
}),
539-
)
536+
.then((newToken) => this.#post(newToken, body, keepalive))
540537
.then((retryResp): SendOutcome => {
541538
if (retryResp.ok || retryResp.status === 409) {
542539
return { ok: true, status: retryResp.status };

0 commit comments

Comments
 (0)