tessera-learn 0.0.1 → 0.0.2
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 +93 -75
- package/README.md +11 -0
- package/dist/plugin/index.js +79 -78
- package/dist/plugin/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/FillInTheBlank.svelte +19 -69
- package/src/components/LockedBanner.svelte +30 -0
- package/src/components/Matching.svelte +44 -80
- package/src/components/MultipleChoice.svelte +14 -43
- package/src/components/Quiz.svelte +69 -263
- package/src/components/ResultIcon.svelte +13 -0
- package/src/components/RetryButton.svelte +25 -0
- package/src/components/Sorting.svelte +33 -76
- package/src/components/util.ts +10 -0
- package/src/plugin/export.ts +39 -33
- package/src/plugin/manifest.ts +38 -12
- package/src/plugin/validation.ts +36 -69
- package/src/runtime/App.svelte +15 -20
- package/src/runtime/ErrorPage.svelte +1 -1
- package/src/runtime/adapters/retry.ts +48 -41
- package/src/runtime/adapters/scorm-base.ts +143 -0
- package/src/runtime/adapters/scorm12.ts +37 -117
- package/src/runtime/adapters/scorm2004.ts +34 -115
- package/src/runtime/hooks.svelte.ts +63 -29
- package/src/runtime/xapi/client.ts +2 -2
- package/src/runtime/xapi/publisher.ts +15 -6
- package/src/runtime/xapi/setup.ts +8 -15
- package/styles/layout.css +21 -10
- package/styles/theme.css +4 -0
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
3
|
-
import { buildScormInteractionFields } from '../interaction-format.js';
|
|
4
|
-
import { WriteQueue, callSync, withRetry, formatISO8601Duration } from './retry.js';
|
|
1
|
+
import { BaseScormAdapter, type ScormDialect } from './scorm-base.js';
|
|
2
|
+
import { formatISO8601Duration } from './retry.js';
|
|
5
3
|
|
|
6
4
|
/**
|
|
7
5
|
* SCORM 2004 API interface.
|
|
@@ -17,6 +15,26 @@ export interface SCORM2004API {
|
|
|
17
15
|
GetDiagnostic(errorCode: string): string;
|
|
18
16
|
}
|
|
19
17
|
|
|
18
|
+
const SCORM2004_DIALECT: ScormDialect<SCORM2004API> = {
|
|
19
|
+
sessionTimeKey: 'cmi.session_time',
|
|
20
|
+
formatDuration: formatISO8601Duration,
|
|
21
|
+
interactionFields: {
|
|
22
|
+
responseField: 'learner_response',
|
|
23
|
+
timestampField: 'timestamp',
|
|
24
|
+
timestamp: () => new Date().toISOString(),
|
|
25
|
+
// SCORM 2004 accepts the canonical interaction `type` strings unchanged.
|
|
26
|
+
typeValue: (t) => t,
|
|
27
|
+
resultLabels: { correct: 'correct', incorrect: 'incorrect' },
|
|
28
|
+
},
|
|
29
|
+
initialize: (api) => api.Initialize(''),
|
|
30
|
+
terminate: (api) => api.Terminate(''),
|
|
31
|
+
getValue: (api, key) => api.GetValue(key),
|
|
32
|
+
setValue: (api, key, value) => api.SetValue(key, value),
|
|
33
|
+
commit: (api) => api.Commit(''),
|
|
34
|
+
getLastError: (api) => api.GetLastError(),
|
|
35
|
+
getErrorString: (api, code) => api.GetErrorString(code),
|
|
36
|
+
};
|
|
37
|
+
|
|
20
38
|
/**
|
|
21
39
|
* SCORM 2004 persistence adapter.
|
|
22
40
|
*
|
|
@@ -24,70 +42,19 @@ export interface SCORM2004API {
|
|
|
24
42
|
* On terminate, the queue is drained synchronously (single attempt)
|
|
25
43
|
* since async retries cannot complete during page unload.
|
|
26
44
|
*/
|
|
27
|
-
export class SCORM2004Adapter
|
|
28
|
-
#api: SCORM2004API;
|
|
29
|
-
#queue = new WriteQueue();
|
|
30
|
-
#state: SavedState | null = null;
|
|
31
|
-
#terminated = false;
|
|
32
|
-
#interactionCount = 0;
|
|
33
|
-
|
|
45
|
+
export class SCORM2004Adapter extends BaseScormAdapter<SCORM2004API> {
|
|
34
46
|
constructor(api: SCORM2004API) {
|
|
35
|
-
|
|
36
|
-
// Wire up GetLastError/GetErrorString so retry warnings can name the
|
|
37
|
-
// real LMS failure (e.g. "405 Incorrect Data Type") instead of a
|
|
38
|
-
// generic "LMS call failed" — production triage needs the code.
|
|
39
|
-
this.#queue.errorReporter = {
|
|
40
|
-
code: () => this.#api.GetLastError(),
|
|
41
|
-
message: (c) => this.#api.GetErrorString(c),
|
|
42
|
-
};
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** Expose the underlying SCORM 2004 API so xAPI actor synthesis can read learner fields. */
|
|
46
|
-
getAPI(): SCORM2004API {
|
|
47
|
-
return this.#api;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
async init(): Promise<void> {
|
|
51
|
-
await withRetry(() => this.#api.Initialize(''));
|
|
52
|
-
|
|
53
|
-
try {
|
|
54
|
-
const raw = this.#api.GetValue('cmi.suspend_data');
|
|
55
|
-
if (raw && raw.trim()) {
|
|
56
|
-
this.#state = JSON.parse(raw);
|
|
57
|
-
}
|
|
58
|
-
} catch {
|
|
59
|
-
this.#state = null;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// Continue cmi.interactions.n indexing where the previous session left
|
|
63
|
-
// off. Restarting at 0 would overwrite prior records.
|
|
64
|
-
try {
|
|
65
|
-
const count = this.#api.GetValue('cmi.interactions._count');
|
|
66
|
-
const n = parseInt(count, 10);
|
|
67
|
-
if (Number.isFinite(n) && n >= 0) this.#interactionCount = n;
|
|
68
|
-
} catch {
|
|
69
|
-
// Fallback to 0 if _count read fails.
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
getState(): SavedState | null {
|
|
74
|
-
return this.#state;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
saveState(state: SavedState): void {
|
|
78
|
-
this.#state = state;
|
|
79
|
-
const json = JSON.stringify(state);
|
|
80
|
-
this.#queue.enqueue(() => this.#api.SetValue('cmi.suspend_data', json));
|
|
47
|
+
super(api, SCORM2004_DIALECT);
|
|
81
48
|
}
|
|
82
49
|
|
|
83
50
|
setScore(score: number): void {
|
|
84
|
-
this
|
|
85
|
-
this
|
|
51
|
+
this.queue.enqueue(() =>
|
|
52
|
+
this.api.SetValue('cmi.score.raw', String(score))
|
|
86
53
|
);
|
|
87
|
-
this
|
|
88
|
-
this
|
|
89
|
-
this
|
|
90
|
-
this
|
|
54
|
+
this.queue.enqueue(() => this.api.SetValue('cmi.score.min', '0'));
|
|
55
|
+
this.queue.enqueue(() => this.api.SetValue('cmi.score.max', '100'));
|
|
56
|
+
this.queue.enqueue(() =>
|
|
57
|
+
this.api.SetValue('cmi.score.scaled', String(score / 100))
|
|
91
58
|
);
|
|
92
59
|
}
|
|
93
60
|
|
|
@@ -96,67 +63,19 @@ export class SCORM2004Adapter implements PersistenceAdapter {
|
|
|
96
63
|
// logic internally via course.config.js settings.
|
|
97
64
|
setCompletionStatus(status: 'incomplete' | 'complete'): void {
|
|
98
65
|
const value = status === 'complete' ? 'completed' : 'incomplete';
|
|
99
|
-
this
|
|
100
|
-
this
|
|
66
|
+
this.queue.enqueue(() =>
|
|
67
|
+
this.api.SetValue('cmi.completion_status', value)
|
|
101
68
|
);
|
|
102
69
|
}
|
|
103
70
|
|
|
104
71
|
setSuccessStatus(status: 'passed' | 'failed' | 'unknown'): void {
|
|
105
72
|
// "unknown" is a valid SCORM 2004 value — setting it explicitly prevents
|
|
106
73
|
// LMSes (notably SCORM Cloud) from rolling up a null status to "passed".
|
|
107
|
-
this
|
|
108
|
-
this.#api.SetValue('cmi.success_status', status)
|
|
109
|
-
);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
setDuration(seconds: number): void {
|
|
113
|
-
const formatted = formatISO8601Duration(seconds);
|
|
114
|
-
this.#queue.enqueue(() =>
|
|
115
|
-
this.#api.SetValue('cmi.session_time', formatted)
|
|
116
|
-
);
|
|
74
|
+
this.queue.enqueue(() => this.api.SetValue('cmi.success_status', status));
|
|
117
75
|
}
|
|
118
76
|
|
|
119
77
|
setExit(mode: 'suspend' | 'normal'): void {
|
|
120
78
|
// SCORM 2004 §4.2 cmi.exit vocabulary: time-out, suspend, logout, normal, "".
|
|
121
|
-
this
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
reportInteraction(
|
|
125
|
-
questionId: string,
|
|
126
|
-
interaction: Interaction,
|
|
127
|
-
correct: boolean | null
|
|
128
|
-
): void {
|
|
129
|
-
const n = this.#interactionCount++;
|
|
130
|
-
const fields = buildScormInteractionFields(
|
|
131
|
-
`cmi.interactions.${n}`,
|
|
132
|
-
questionId,
|
|
133
|
-
interaction,
|
|
134
|
-
correct,
|
|
135
|
-
{
|
|
136
|
-
responseField: 'learner_response',
|
|
137
|
-
timestampField: 'timestamp',
|
|
138
|
-
timestamp: new Date().toISOString(),
|
|
139
|
-
typeValue: interaction.type,
|
|
140
|
-
resultLabels: { correct: 'correct', incorrect: 'incorrect' },
|
|
141
|
-
}
|
|
142
|
-
);
|
|
143
|
-
for (const [key, value] of fields) {
|
|
144
|
-
this.#queue.enqueue(() => this.#api.SetValue(key, value));
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
commit(): void {
|
|
149
|
-
this.#queue.enqueue(() => this.#api.Commit(''));
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
terminate(): void {
|
|
153
|
-
if (this.#terminated) return;
|
|
154
|
-
this.#terminated = true;
|
|
155
|
-
// During page unload, async retries can't run.
|
|
156
|
-
// Drain any pending queue operations synchronously (single attempt each),
|
|
157
|
-
// then commit and terminate synchronously.
|
|
158
|
-
this.#queue.drainSync();
|
|
159
|
-
callSync(() => this.#api.Commit(''));
|
|
160
|
-
callSync(() => this.#api.Terminate(''));
|
|
79
|
+
this.queue.enqueue(() => this.api.SetValue('cmi.exit', mode));
|
|
161
80
|
}
|
|
162
81
|
}
|
|
@@ -10,6 +10,12 @@ import {
|
|
|
10
10
|
} from './contexts.js';
|
|
11
11
|
import { buildQuizInteractions } from '../components/quiz-payload.js';
|
|
12
12
|
import type { QuizContext } from '../components/quiz-payload.js';
|
|
13
|
+
import {
|
|
14
|
+
resolveFeedbackMode,
|
|
15
|
+
resolveRetryStrategy,
|
|
16
|
+
type QuizPolicyConfig,
|
|
17
|
+
type QuizQuestionResult,
|
|
18
|
+
} from './quiz-policy.js';
|
|
13
19
|
|
|
14
20
|
export interface UseQuestionOptions {
|
|
15
21
|
/** Stable identifier used for LMS interaction reporting. Must be unique on the page. */
|
|
@@ -22,6 +28,8 @@ export interface UseQuestionOptions {
|
|
|
22
28
|
* Default 1; ignored in standalone mode.
|
|
23
29
|
*/
|
|
24
30
|
weight?: number;
|
|
31
|
+
/** Standalone retry cap. Default `Infinity`. Ignored inside a `<Quiz>`. */
|
|
32
|
+
maxRetries?: number;
|
|
25
33
|
/** Called on submit — returns the current learner response payload. */
|
|
26
34
|
response: () => Interaction;
|
|
27
35
|
/**
|
|
@@ -38,8 +46,12 @@ export interface UseQuestionOptions {
|
|
|
38
46
|
export interface UseQuestionHandle {
|
|
39
47
|
submit(): void;
|
|
40
48
|
reset(): void;
|
|
49
|
+
/** Standalone retry. No-op once `maxRetries` is hit or inside a `<Quiz>`. */
|
|
50
|
+
retry(): void;
|
|
41
51
|
readonly submitted: boolean;
|
|
42
52
|
readonly correct: boolean | null;
|
|
53
|
+
readonly canRetry: boolean;
|
|
54
|
+
readonly retryCount: number;
|
|
43
55
|
readonly mode: 'standalone' | 'quiz';
|
|
44
56
|
/** Index returned by the parent Quiz registration, used for per-question context reads. Undefined in standalone mode. */
|
|
45
57
|
readonly quizIndex: number | undefined;
|
|
@@ -68,18 +80,23 @@ export function useQuestion(opts: UseQuestionOptions): UseQuestionHandle {
|
|
|
68
80
|
return {
|
|
69
81
|
submit() {},
|
|
70
82
|
reset() { opts.reset?.(); },
|
|
83
|
+
retry() {},
|
|
71
84
|
get submitted() { return quizCtx.submitted ?? false; },
|
|
72
85
|
get correct() {
|
|
73
86
|
if (!(quizCtx.submitted ?? false)) return null;
|
|
74
87
|
return isCorrectInteraction(opts.response());
|
|
75
88
|
},
|
|
89
|
+
canRetry: false,
|
|
90
|
+
retryCount: 0,
|
|
76
91
|
mode: 'quiz' as const,
|
|
77
92
|
quizIndex,
|
|
78
93
|
};
|
|
79
94
|
}
|
|
80
95
|
|
|
96
|
+
const maxRetries = opts.maxRetries ?? Infinity;
|
|
81
97
|
let submitted = $state(false);
|
|
82
98
|
let correct = $state<boolean | null>(null);
|
|
99
|
+
let retryCount = $state(0);
|
|
83
100
|
|
|
84
101
|
function submit() {
|
|
85
102
|
if (submitted) return;
|
|
@@ -111,11 +128,20 @@ export function useQuestion(opts: UseQuestionOptions): UseQuestionHandle {
|
|
|
111
128
|
opts.reset?.();
|
|
112
129
|
}
|
|
113
130
|
|
|
131
|
+
function retry() {
|
|
132
|
+
if (retryCount >= maxRetries) return;
|
|
133
|
+
retryCount++;
|
|
134
|
+
reset();
|
|
135
|
+
}
|
|
136
|
+
|
|
114
137
|
return {
|
|
115
138
|
submit,
|
|
116
139
|
reset,
|
|
140
|
+
retry,
|
|
117
141
|
get submitted() { return submitted; },
|
|
118
142
|
get correct() { return correct; },
|
|
143
|
+
get canRetry() { return retryCount < maxRetries; },
|
|
144
|
+
get retryCount() { return retryCount; },
|
|
119
145
|
mode: 'standalone' as const,
|
|
120
146
|
quizIndex: undefined,
|
|
121
147
|
};
|
|
@@ -267,7 +293,6 @@ export function useQuiz(opts: { element: () => HTMLElement | null }): UseQuizHan
|
|
|
267
293
|
);
|
|
268
294
|
}
|
|
269
295
|
const quizConfig = pageCtx.quiz;
|
|
270
|
-
const passingScore = pageCtx.passingScore ?? 70;
|
|
271
296
|
|
|
272
297
|
// Dev-mode warning: a second useQuiz on the same page silently overwrites
|
|
273
298
|
// the first quiz's pageIndex-keyed score. We can't prevent it (some pages
|
|
@@ -283,9 +308,15 @@ export function useQuiz(opts: { element: () => HTMLElement | null }): UseQuizHan
|
|
|
283
308
|
|
|
284
309
|
const maxAttempts = quizConfig.maxAttempts ?? Infinity;
|
|
285
310
|
const showFeedback = quizConfig.showFeedback ?? true;
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
const
|
|
311
|
+
|
|
312
|
+
const policyCfg = quizConfig as QuizPolicyConfig;
|
|
313
|
+
const feedbackPredicate = resolveFeedbackMode(policyCfg);
|
|
314
|
+
const retryPredicate = resolveRetryStrategy(policyCfg);
|
|
315
|
+
// Lock the answer once feedback is revealed in 'immediate' mode and under any
|
|
316
|
+
// custom predicate (opaque; lock conservatively). 'review' mode is post-submit.
|
|
317
|
+
const revealsLockAnswer =
|
|
318
|
+
policyCfg.feedbackMode === 'immediate' ||
|
|
319
|
+
typeof policyCfg.feedbackMode === 'function';
|
|
289
320
|
|
|
290
321
|
interface InternalQuestion extends UseQuizQuestionApi {
|
|
291
322
|
weight: number;
|
|
@@ -380,9 +411,14 @@ export function useQuiz(opts: { element: () => HTMLElement | null }): UseQuizHan
|
|
|
380
411
|
|
|
381
412
|
function feedbackVisible(index: number): boolean {
|
|
382
413
|
if (!showFeedback) return false;
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
414
|
+
return feedbackPredicate({
|
|
415
|
+
questionIndex: index,
|
|
416
|
+
submitted,
|
|
417
|
+
reviewing,
|
|
418
|
+
hasAnswered: answers.has(index),
|
|
419
|
+
revealed: feedbackShown.has(index),
|
|
420
|
+
attemptCount,
|
|
421
|
+
});
|
|
386
422
|
}
|
|
387
423
|
|
|
388
424
|
function revealFeedback(index: number): void {
|
|
@@ -442,7 +478,6 @@ export function useQuiz(opts: { element: () => HTMLElement | null }): UseQuizHan
|
|
|
442
478
|
attemptCount++;
|
|
443
479
|
|
|
444
480
|
const interactions = buildQuizInteractions(questions, answers);
|
|
445
|
-
void passingScore; // reserved for future custom-shell extensions
|
|
446
481
|
el.dispatchEvent(
|
|
447
482
|
new CustomEvent('tessera-quiz-complete', {
|
|
448
483
|
detail: { score: rounded, interactions },
|
|
@@ -462,26 +497,25 @@ export function useQuiz(opts: { element: () => HTMLElement | null }): UseQuizHan
|
|
|
462
497
|
|
|
463
498
|
function retry(): void {
|
|
464
499
|
if (!canRetry) return;
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
const
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
for (const q of questions) q.reset?.();
|
|
500
|
+
const results: QuizQuestionResult[] = [];
|
|
501
|
+
for (let i = 0; i < questions.length; i++) {
|
|
502
|
+
const a = answers.has(i) ? answers.get(i) : undefined;
|
|
503
|
+
results.push({
|
|
504
|
+
interaction: questions[i].interaction?.() ?? ({} as never),
|
|
505
|
+
correct: questions[i].checkAnswer(a),
|
|
506
|
+
weight: questions[i].weight,
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
const newLocked = retryPredicate(results);
|
|
510
|
+
const preserved = new Map<number, unknown>();
|
|
511
|
+
for (const i of newLocked) {
|
|
512
|
+
if (answers.has(i)) preserved.set(i, answers.get(i));
|
|
513
|
+
}
|
|
514
|
+
lockedCorrect = newLocked;
|
|
515
|
+
answers.clear();
|
|
516
|
+
for (const [i, a] of preserved) answers.set(i, a);
|
|
517
|
+
for (let i = 0; i < questions.length; i++) {
|
|
518
|
+
if (!newLocked.has(i) && questions[i].reset) questions[i].reset!();
|
|
485
519
|
}
|
|
486
520
|
answersVersion++;
|
|
487
521
|
feedbackShown = new Set();
|
|
@@ -507,7 +541,7 @@ export function useQuiz(opts: { element: () => HTMLElement | null }): UseQuizHan
|
|
|
507
541
|
return (i: number) =>
|
|
508
542
|
submitted ||
|
|
509
543
|
lockedCorrect.has(i) ||
|
|
510
|
-
(
|
|
544
|
+
(revealsLockAnswer && feedbackShown.has(i));
|
|
511
545
|
},
|
|
512
546
|
get isLockedCorrect() { return (i: number) => lockedCorrect.has(i); },
|
|
513
547
|
});
|
|
@@ -6,8 +6,8 @@ import type {
|
|
|
6
6
|
Statement,
|
|
7
7
|
DestinationOutcome,
|
|
8
8
|
} from './types.js';
|
|
9
|
-
import { XAPIPublisher
|
|
10
|
-
import { validatePartialStatement } from './validation.js';
|
|
9
|
+
import { XAPIPublisher } from './publisher.js';
|
|
10
|
+
import { validatePartialStatement, XAPIConfigError } from './validation.js';
|
|
11
11
|
import { uuidv4 } from './uuid.js';
|
|
12
12
|
|
|
13
13
|
/**
|
|
@@ -15,15 +15,12 @@ import {
|
|
|
15
15
|
XAPIConfigError,
|
|
16
16
|
XAPIStatementError,
|
|
17
17
|
} from './validation.js';
|
|
18
|
+
import { RETRY_ATTEMPTS, backoffMs } from '../adapters/retry.js';
|
|
18
19
|
|
|
19
20
|
/** cmi5 §9.6.2 — well-known IRI owned by ADL for the cmi5 session id extension. */
|
|
20
21
|
const CMI5_SESSIONID_EXT =
|
|
21
22
|
'https://w3id.org/xapi/cmi5/context/extensions/sessionid';
|
|
22
23
|
|
|
23
|
-
// Re-exported so existing callers (xapi/client.ts, tests, etc.) that pull
|
|
24
|
-
// these symbols from the publisher entry point keep working.
|
|
25
|
-
export { XAPIConfigError, XAPIStatementError, validateAgent, validateAuthCredential };
|
|
26
|
-
|
|
27
24
|
/**
|
|
28
25
|
* Combine a field label (e.g. `xapi.actor`) with the prefix-friendly suffix
|
|
29
26
|
* returned by `validateAgent`. Sub-field suffixes start with `.` and chain
|
|
@@ -59,6 +56,8 @@ export interface XAPIPublisherOptions {
|
|
|
59
56
|
* Set by the cmi5 adapter and by 'lms'-inherited destinations under cmi5.
|
|
60
57
|
*/
|
|
61
58
|
cmi5Mode?: boolean;
|
|
59
|
+
/** When set, every send method rejects with the returned Error without hitting the network. */
|
|
60
|
+
unavailableReason?: () => Error;
|
|
62
61
|
}
|
|
63
62
|
|
|
64
63
|
interface SendOutcome {
|
|
@@ -67,7 +66,7 @@ interface SendOutcome {
|
|
|
67
66
|
error?: Error;
|
|
68
67
|
}
|
|
69
68
|
|
|
70
|
-
const STATEMENT_RETRY_ATTEMPTS =
|
|
69
|
+
const STATEMENT_RETRY_ATTEMPTS = RETRY_ATTEMPTS;
|
|
71
70
|
/**
|
|
72
71
|
* Soft cap on the number of in-flight statements queued behind the head of
|
|
73
72
|
* the chain. We log a one-time warning when the queue grows past this so
|
|
@@ -106,6 +105,9 @@ export class XAPIPublisher {
|
|
|
106
105
|
readonly #sessionId: string;
|
|
107
106
|
readonly #cmi5Mode: boolean;
|
|
108
107
|
|
|
108
|
+
// When set, every send method short-circuits with a rejected promise.
|
|
109
|
+
readonly #unavailableReason: (() => Error) | null;
|
|
110
|
+
|
|
109
111
|
// Auth — string or resolver. Cached after first resolution.
|
|
110
112
|
readonly #authValue: string | (() => string | Promise<string>);
|
|
111
113
|
#cachedAuth: string | null = null;
|
|
@@ -149,6 +151,7 @@ export class XAPIPublisher {
|
|
|
149
151
|
this.#authValue = opts.auth;
|
|
150
152
|
this.#actorValue = opts.actor;
|
|
151
153
|
this.#sessionId = opts.sessionId ?? uuidv4();
|
|
154
|
+
this.#unavailableReason = opts.unavailableReason ?? null;
|
|
152
155
|
|
|
153
156
|
if (typeof this.#actorValue !== 'function') {
|
|
154
157
|
this.#cachedActor = this.#actorValue;
|
|
@@ -298,6 +301,9 @@ export class XAPIPublisher {
|
|
|
298
301
|
partial: PartialStatement,
|
|
299
302
|
options?: SendStatementOptions & { id?: string }
|
|
300
303
|
): Promise<SendStatementResult> {
|
|
304
|
+
if (this.#unavailableReason) {
|
|
305
|
+
return Promise.reject(this.#unavailableReason());
|
|
306
|
+
}
|
|
301
307
|
try {
|
|
302
308
|
validatePartialStatement(partial);
|
|
303
309
|
} catch (err) {
|
|
@@ -332,6 +338,9 @@ export class XAPIPublisher {
|
|
|
332
338
|
statementOrBatch: Statement | Statement[],
|
|
333
339
|
options?: SendStatementOptions
|
|
334
340
|
): Promise<DestinationOutcome> {
|
|
341
|
+
if (this.#unavailableReason) {
|
|
342
|
+
return Promise.reject(this.#unavailableReason());
|
|
343
|
+
}
|
|
335
344
|
if (this.#queueDepth >= QUEUE_DEPTH_SATURATED) {
|
|
336
345
|
return Promise.resolve<DestinationOutcome>({
|
|
337
346
|
endpoint: this.#endpoint,
|
|
@@ -449,7 +458,7 @@ export class XAPIPublisher {
|
|
|
449
458
|
}
|
|
450
459
|
if (isFinal) return outcome;
|
|
451
460
|
return new Promise<void>((r) =>
|
|
452
|
-
setTimeout(r,
|
|
461
|
+
setTimeout(r, backoffMs(n))
|
|
453
462
|
).then(() => attempt(n + 1));
|
|
454
463
|
});
|
|
455
464
|
};
|
|
@@ -42,28 +42,21 @@ class XAPIDevFallbackError extends Error {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
/**
|
|
45
|
-
* Build a stub publisher whose sends reject with
|
|
46
|
-
* dev-fallback paths: cmi5 `endpoint: 'lms'` with no launch params, and
|
|
47
|
-
* SCORM explicit endpoints that depend on a learner identity the dev
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
45
|
+
* Build a stub publisher whose sends reject with the supplied error. Used for
|
|
46
|
+
* both dev-fallback paths: cmi5 `endpoint: 'lms'` with no launch params, and
|
|
47
|
+
* SCORM explicit endpoints that depend on a learner identity the dev fallback
|
|
48
|
+
* can't synthesize. The placeholder carries a static actor so the constructor
|
|
49
|
+
* invariants hold and `XAPIClient.buildStatement` can run without throwing —
|
|
50
|
+
* the `unavailableReason` opt makes only the network-bound methods reject.
|
|
51
51
|
*/
|
|
52
52
|
function makeRejectingPublisher(error: () => Error): XAPIPublisher {
|
|
53
|
-
|
|
53
|
+
return new XAPIPublisher({
|
|
54
54
|
endpoint: 'http://localhost/__tessera_dev_fallback__/',
|
|
55
55
|
auth: '',
|
|
56
56
|
actor: { mbox: 'mailto:nobody@example.invalid', objectType: 'Agent' },
|
|
57
57
|
activityId: 'http://localhost/__tessera_dev_fallback__',
|
|
58
|
+
unavailableReason: error,
|
|
58
59
|
});
|
|
59
|
-
// The static actor is cached at construction so getActor()/buildStatement
|
|
60
|
-
// work without a separate init() call. We only override the methods that
|
|
61
|
-
// would otherwise hit the network so author code surfaces the explicit
|
|
62
|
-
// error rather than silently no-oping.
|
|
63
|
-
const reject = (): Promise<never> => Promise.reject(error());
|
|
64
|
-
(pub as any).sendStatement = reject;
|
|
65
|
-
(pub as any).enqueueBuilt = reject;
|
|
66
|
-
return pub;
|
|
67
60
|
}
|
|
68
61
|
|
|
69
62
|
function makeDevFallbackPublisher(): XAPIPublisher {
|
package/styles/layout.css
CHANGED
|
@@ -315,6 +315,27 @@
|
|
|
315
315
|
text-align: center;
|
|
316
316
|
}
|
|
317
317
|
|
|
318
|
+
/* ---- Buttons ---- */
|
|
319
|
+
.tessera-btn-primary {
|
|
320
|
+
background-color: var(--tessera-primary);
|
|
321
|
+
color: #ffffff;
|
|
322
|
+
border: none;
|
|
323
|
+
border-radius: 6px;
|
|
324
|
+
cursor: pointer;
|
|
325
|
+
min-height: 44px;
|
|
326
|
+
font-family: var(--tessera-font-family);
|
|
327
|
+
transition: background-color var(--tessera-transition-fast), opacity var(--tessera-transition-fast);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
.tessera-btn-primary:hover:not(:disabled) {
|
|
331
|
+
background-color: var(--tessera-primary-dark);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
.tessera-btn-primary:disabled {
|
|
335
|
+
opacity: 0.4;
|
|
336
|
+
cursor: not-allowed;
|
|
337
|
+
}
|
|
338
|
+
|
|
318
339
|
/* ---- Error Page ---- */
|
|
319
340
|
.tessera-error {
|
|
320
341
|
text-align: center;
|
|
@@ -338,16 +359,6 @@
|
|
|
338
359
|
padding: var(--tessera-spacing-sm) var(--tessera-spacing-lg);
|
|
339
360
|
font-size: 0.875rem;
|
|
340
361
|
font-weight: 600;
|
|
341
|
-
color: #ffffff;
|
|
342
|
-
background-color: var(--tessera-primary);
|
|
343
|
-
border: none;
|
|
344
|
-
border-radius: 6px;
|
|
345
|
-
cursor: pointer;
|
|
346
|
-
transition: background-color var(--tessera-transition-fast);
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
.tessera-error-retry:hover {
|
|
350
|
-
background-color: var(--tessera-primary-dark);
|
|
351
362
|
}
|
|
352
363
|
|
|
353
364
|
/* ---- Responsive: Tablet (max-width: 1024px) ---- */
|
package/styles/theme.css
CHANGED
|
@@ -9,7 +9,11 @@
|
|
|
9
9
|
--tessera-bg-secondary: #f9fafb;
|
|
10
10
|
--tessera-border: #e5e7eb;
|
|
11
11
|
--tessera-success: #16a34a;
|
|
12
|
+
--tessera-success-bg: color-mix(in srgb, var(--tessera-success) 8%, transparent);
|
|
13
|
+
--tessera-success-border: color-mix(in srgb, var(--tessera-success) 25%, transparent);
|
|
12
14
|
--tessera-error: #dc2626;
|
|
15
|
+
--tessera-error-bg: color-mix(in srgb, var(--tessera-error) 8%, transparent);
|
|
16
|
+
--tessera-error-border: color-mix(in srgb, var(--tessera-error) 25%, transparent);
|
|
13
17
|
--tessera-warning: #d97706;
|
|
14
18
|
|
|
15
19
|
/* Typography */
|