tessera-learn 0.0.7 → 0.0.9
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.js +1 -1
- package/dist/plugin/index.js +3 -2
- package/dist/plugin/index.js.map +1 -1
- package/dist/{validation-B4UhCY5y.js → validation-BxWAMMnJ.js} +4 -7
- package/dist/validation-BxWAMMnJ.js.map +1 -0
- package/package.json +1 -1
- package/src/components/FillInTheBlank.svelte +32 -37
- package/src/components/Matching.svelte +35 -68
- package/src/components/MultipleChoice.svelte +25 -38
- package/src/components/Quiz.svelte +22 -26
- package/src/components/Sorting.svelte +40 -42
- package/src/index.ts +1 -0
- package/src/plugin/index.ts +5 -0
- package/src/plugin/validation.ts +7 -2
- package/src/runtime/App.svelte +2 -7
- package/src/runtime/adapters/cmi5.ts +44 -14
- package/src/runtime/hooks.svelte.ts +259 -217
- package/src/runtime/interaction-format.ts +40 -8
- package/src/runtime/interaction.ts +3 -3
- package/src/runtime/persistence.ts +5 -0
- package/src/runtime/quiz-policy.ts +16 -16
- package/src/runtime/types.ts +1 -2
- package/dist/validation-B4UhCY5y.js.map +0 -1
- package/src/components/quiz-payload.ts +0 -71
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script>
|
|
2
|
-
import {
|
|
2
|
+
import { onMount } from 'svelte';
|
|
3
3
|
import { SvelteMap } from 'svelte/reactivity';
|
|
4
4
|
import { useQuestion } from '../runtime/hooks.svelte.js';
|
|
5
5
|
import { slugFromQuestion, shuffle } from './util.js';
|
|
@@ -19,9 +19,6 @@
|
|
|
19
19
|
weight = 1,
|
|
20
20
|
} = $props();
|
|
21
21
|
|
|
22
|
-
const quiz = getContext('tessera-quiz');
|
|
23
|
-
const standalone = !quiz;
|
|
24
|
-
|
|
25
22
|
let queue = $state([]); // item indices not yet placed; queue[0] is current
|
|
26
23
|
let placements = $state(new SvelteMap()); // itemIdx → targetIdx
|
|
27
24
|
let dragOver = $state(null); // target index highlighted during drag
|
|
@@ -36,15 +33,6 @@
|
|
|
36
33
|
isDragging = false;
|
|
37
34
|
}
|
|
38
35
|
|
|
39
|
-
if (standalone) {
|
|
40
|
-
initQueue();
|
|
41
|
-
} else {
|
|
42
|
-
onMount(() => {
|
|
43
|
-
initQueue();
|
|
44
|
-
quiz.setRender(myIndex, renderQuestion);
|
|
45
|
-
});
|
|
46
|
-
}
|
|
47
|
-
|
|
48
36
|
function checkAnswer(answer) {
|
|
49
37
|
if (!answer || !(answer instanceof Map)) return false;
|
|
50
38
|
if (answer.size !== items.length) return false;
|
|
@@ -61,7 +49,7 @@
|
|
|
61
49
|
// Sorting is semantically a categorization (each item → one target) and maps
|
|
62
50
|
// cleanly to SCORM 2004's `matching` interaction. We emit [itemIdx, targetIdx]
|
|
63
51
|
// pairs as stringified ids.
|
|
64
|
-
const
|
|
52
|
+
const q = useQuestion({
|
|
65
53
|
get id() { return id ?? `sorting-${slugFromQuestion(question)}`; },
|
|
66
54
|
get weight() { return weight; },
|
|
67
55
|
get maxRetries() { return maxRetries; },
|
|
@@ -73,13 +61,20 @@
|
|
|
73
61
|
reset: resetState,
|
|
74
62
|
});
|
|
75
63
|
|
|
76
|
-
|
|
64
|
+
// `q.mode` is fixed for the lifetime of the widget; capture it once so
|
|
65
|
+
// setup-time branches don't trip Svelte's "state_referenced_locally" warning.
|
|
66
|
+
const inQuiz = q.mode === 'quiz';
|
|
77
67
|
|
|
78
|
-
|
|
68
|
+
if (!inQuiz) {
|
|
69
|
+
initQueue();
|
|
70
|
+
} else {
|
|
71
|
+
onMount(() => {
|
|
72
|
+
initQueue();
|
|
73
|
+
q.setRender(renderQuestion);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
79
76
|
|
|
80
|
-
let
|
|
81
|
-
let isDisabled = $derived(standalone ? handle.submitted : quiz.isAnswerLocked(myIndex));
|
|
82
|
-
let showFeedback = $derived(standalone ? handle.submitted : quiz.feedbackVisible(myIndex));
|
|
77
|
+
let currentItemIdx = $derived(queue.length > 0 ? queue[0] : null);
|
|
83
78
|
|
|
84
79
|
function getItemsForTarget(targetIdx) {
|
|
85
80
|
const result = [];
|
|
@@ -94,19 +89,22 @@
|
|
|
94
89
|
}
|
|
95
90
|
|
|
96
91
|
function placeCard(targetIdx) {
|
|
97
|
-
if (
|
|
92
|
+
if (q.locked || currentItemIdx === null) return;
|
|
98
93
|
const itemIdx = queue[0];
|
|
99
94
|
placements.set(itemIdx, targetIdx);
|
|
100
95
|
queue = queue.slice(1);
|
|
101
96
|
cardSelected = false;
|
|
102
|
-
if (
|
|
97
|
+
if (inQuiz) {
|
|
98
|
+
q.setAnswer(new Map(placements));
|
|
99
|
+
if (placements.size === items.length) q.commit();
|
|
100
|
+
}
|
|
103
101
|
}
|
|
104
102
|
|
|
105
103
|
function returnCard(itemIdx) {
|
|
106
|
-
if (
|
|
104
|
+
if (q.locked) return;
|
|
107
105
|
placements.delete(itemIdx);
|
|
108
106
|
queue = [itemIdx, ...queue];
|
|
109
|
-
if (
|
|
107
|
+
if (inQuiz) q.setAnswer(new Map(placements));
|
|
110
108
|
}
|
|
111
109
|
|
|
112
110
|
// --- Drag handlers ---
|
|
@@ -122,7 +120,7 @@
|
|
|
122
120
|
}
|
|
123
121
|
|
|
124
122
|
function onDragOver(e, targetIdx) {
|
|
125
|
-
if (
|
|
123
|
+
if (q.locked) return;
|
|
126
124
|
e.preventDefault();
|
|
127
125
|
e.dataTransfer.dropEffect = 'move';
|
|
128
126
|
dragOver = targetIdx;
|
|
@@ -145,7 +143,7 @@
|
|
|
145
143
|
// --- Click / tap handlers ---
|
|
146
144
|
|
|
147
145
|
function onCardClick() {
|
|
148
|
-
if (
|
|
146
|
+
if (q.locked || currentItemIdx === null) return;
|
|
149
147
|
cardSelected = !cardSelected;
|
|
150
148
|
}
|
|
151
149
|
|
|
@@ -157,7 +155,7 @@
|
|
|
157
155
|
}
|
|
158
156
|
|
|
159
157
|
function onTargetClick(targetIdx) {
|
|
160
|
-
if (
|
|
158
|
+
if (q.locked || !cardSelected) return;
|
|
161
159
|
placeCard(targetIdx);
|
|
162
160
|
}
|
|
163
161
|
|
|
@@ -174,7 +172,7 @@
|
|
|
174
172
|
<p class="tessera-sorting-question">{question}</p>
|
|
175
173
|
|
|
176
174
|
<!-- Card deck: shows the current card to be placed -->
|
|
177
|
-
{#if !
|
|
175
|
+
{#if !q.locked}
|
|
178
176
|
<div class="tessera-sorting-deck" aria-live="polite" aria-atomic="false">
|
|
179
177
|
{#if currentItemIdx !== null}
|
|
180
178
|
<div class="tessera-sorting-deck-inner">
|
|
@@ -212,17 +210,17 @@
|
|
|
212
210
|
{/if}
|
|
213
211
|
|
|
214
212
|
<!-- Drop targets -->
|
|
215
|
-
<div class="tessera-sorting-targets" class:targets-active={cardSelected && !
|
|
213
|
+
<div class="tessera-sorting-targets" class:targets-active={cardSelected && !q.locked}>
|
|
216
214
|
{#each targets as targetLabel, targetIdx}
|
|
217
215
|
{@const targetItems = getItemsForTarget(targetIdx)}
|
|
218
216
|
<div
|
|
219
217
|
class="tessera-sorting-target"
|
|
220
218
|
class:drag-over={dragOver === targetIdx}
|
|
221
|
-
class:clickable={cardSelected && !
|
|
219
|
+
class:clickable={cardSelected && !q.locked}
|
|
222
220
|
role="button"
|
|
223
221
|
tabindex="0"
|
|
224
|
-
aria-disabled={!(cardSelected && !
|
|
225
|
-
aria-label="Target: {targetLabel}{cardSelected && !
|
|
222
|
+
aria-disabled={!(cardSelected && !q.locked)}
|
|
223
|
+
aria-label="Target: {targetLabel}{cardSelected && !q.locked ? ` (activate to place ${items[currentItemIdx]})` : ''}"
|
|
226
224
|
ondragover={(e) => onDragOver(e, targetIdx)}
|
|
227
225
|
ondragleave={onDragLeave}
|
|
228
226
|
ondrop={(e) => onDrop(e, targetIdx)}
|
|
@@ -235,17 +233,17 @@
|
|
|
235
233
|
{#each targetItems as itemIdx}
|
|
236
234
|
<div
|
|
237
235
|
class="tessera-sorting-placed-item"
|
|
238
|
-
class:correct={
|
|
239
|
-
class:incorrect={
|
|
236
|
+
class:correct={q.feedbackVisible && isCorrectPlacement(itemIdx)}
|
|
237
|
+
class:incorrect={q.feedbackVisible && !isCorrectPlacement(itemIdx)}
|
|
240
238
|
>
|
|
241
239
|
<span class="tessera-sorting-item-text">{items[itemIdx]}</span>
|
|
242
|
-
{#if !
|
|
240
|
+
{#if !q.locked}
|
|
243
241
|
<button
|
|
244
242
|
class="tessera-sorting-remove"
|
|
245
243
|
aria-label="Return '{items[itemIdx]}' to deck"
|
|
246
244
|
onclick={(e) => { e.stopPropagation(); returnCard(itemIdx); }}
|
|
247
245
|
>×</button>
|
|
248
|
-
{:else if
|
|
246
|
+
{:else if q.feedbackVisible}
|
|
249
247
|
<span class="tessera-sorting-item-icon" aria-hidden="true">
|
|
250
248
|
{isCorrectPlacement(itemIdx) ? '✓' : '✗'}
|
|
251
249
|
</span>
|
|
@@ -259,7 +257,7 @@
|
|
|
259
257
|
</div>
|
|
260
258
|
|
|
261
259
|
<!-- Feedback (shown after standalone submit or quiz feedbackVisible) -->
|
|
262
|
-
{#if
|
|
260
|
+
{#if q.feedbackVisible}
|
|
263
261
|
{@const isCorrect = checkAnswer(placements)}
|
|
264
262
|
<div class="tessera-sorting-review">
|
|
265
263
|
{#if isCorrect}
|
|
@@ -285,23 +283,23 @@
|
|
|
285
283
|
<p class="tessera-sorting-feedback incorrect">{incorrectFeedback}</p>
|
|
286
284
|
{/if}
|
|
287
285
|
{/if}
|
|
288
|
-
{#if
|
|
289
|
-
<RetryButton onclick={() =>
|
|
286
|
+
{#if !inQuiz && q.canRetry}
|
|
287
|
+
<RetryButton onclick={() => q.retry()} />
|
|
290
288
|
{/if}
|
|
291
289
|
</div>
|
|
292
290
|
{/if}
|
|
293
291
|
|
|
294
292
|
<!-- Standalone Check button (shown once all cards are placed) -->
|
|
295
|
-
{#if
|
|
293
|
+
{#if !inQuiz && !q.submitted && placements.size === items.length}
|
|
296
294
|
<div class="tessera-sorting-actions">
|
|
297
|
-
<button class="tessera-btn-primary tessera-sorting-check" onclick={() =>
|
|
295
|
+
<button class="tessera-btn-primary tessera-sorting-check" onclick={() => q.submit()}>
|
|
298
296
|
Check Answer
|
|
299
297
|
</button>
|
|
300
298
|
</div>
|
|
301
299
|
{/if}
|
|
302
300
|
{/snippet}
|
|
303
301
|
|
|
304
|
-
{#if
|
|
302
|
+
{#if !inQuiz}
|
|
305
303
|
<div class="tessera-sorting" aria-label={question}>
|
|
306
304
|
{@render sortingContent()}
|
|
307
305
|
</div>
|
|
@@ -309,7 +307,7 @@
|
|
|
309
307
|
|
|
310
308
|
{#snippet renderQuestion()}
|
|
311
309
|
<div class="tessera-sorting" aria-label={question}>
|
|
312
|
-
{#if
|
|
310
|
+
{#if q.isLockedCorrect}
|
|
313
311
|
<LockedBanner />
|
|
314
312
|
{/if}
|
|
315
313
|
{@render sortingContent()}
|
package/src/index.ts
CHANGED
package/src/plugin/index.ts
CHANGED
|
@@ -207,6 +207,11 @@ function tesseraConfigPlugin(): Plugin {
|
|
|
207
207
|
'$assets': resolve(root, 'assets'),
|
|
208
208
|
},
|
|
209
209
|
},
|
|
210
|
+
// tessera-learn ships .ts/.svelte.ts source; Vite's dep optimizer
|
|
211
|
+
// doesn't run vite-plugin-svelte's preprocessor, so skip pre-bundling.
|
|
212
|
+
optimizeDeps: {
|
|
213
|
+
exclude: ['tessera-learn'],
|
|
214
|
+
},
|
|
210
215
|
};
|
|
211
216
|
},
|
|
212
217
|
|
package/src/plugin/validation.ts
CHANGED
|
@@ -542,7 +542,8 @@ function validatePageFile(
|
|
|
542
542
|
if (
|
|
543
543
|
pageConfig?.quiz &&
|
|
544
544
|
!HAS_USE_QUESTION_RE.test(content) &&
|
|
545
|
-
!HAS_QUESTION_TAG_RE.test(content)
|
|
545
|
+
!HAS_QUESTION_TAG_RE.test(content) &&
|
|
546
|
+
!HAS_LOCAL_SVELTE_IMPORT_RE.test(content)
|
|
546
547
|
) {
|
|
547
548
|
warnings.push(
|
|
548
549
|
`${fileRel}: quiz page has no question components or useQuestion() calls — ` +
|
|
@@ -810,7 +811,7 @@ function validateQuizConfig(quiz: unknown, fileRel: string, errors: string[]): v
|
|
|
810
811
|
}
|
|
811
812
|
}
|
|
812
813
|
|
|
813
|
-
for (const field of ['graded', 'gatesProgress'
|
|
814
|
+
for (const field of ['graded', 'gatesProgress']) {
|
|
814
815
|
if (cfg[field] !== undefined && typeof cfg[field] !== 'boolean') {
|
|
815
816
|
errors.push(
|
|
816
817
|
`${fileRel}: quiz.${field} must be a boolean, got ${typeof cfg[field]}`
|
|
@@ -1041,6 +1042,10 @@ const HAS_USE_QUESTION_RE = /\buseQuestion\s*\(/;
|
|
|
1041
1042
|
const HAS_QUESTION_TAG_RE = new RegExp(
|
|
1042
1043
|
`<(${Object.keys(QUESTION_COMPONENT_REQUIRED).join('|')})(?=[\\s/>])`
|
|
1043
1044
|
);
|
|
1045
|
+
// Custom widget imported from a local `.svelte` file may wrap useQuestion.
|
|
1046
|
+
// Treat its presence as enough to suppress the "no questions" warning —
|
|
1047
|
+
// false negatives are acceptable for a heuristic that's already advisory.
|
|
1048
|
+
const HAS_LOCAL_SVELTE_IMPORT_RE = /from\s+['"][^'"]+\.svelte['"]/;
|
|
1044
1049
|
|
|
1045
1050
|
/**
|
|
1046
1051
|
* Detect ways an author file can bypass the LMS data contract. These check
|
package/src/runtime/App.svelte
CHANGED
|
@@ -177,18 +177,12 @@
|
|
|
177
177
|
}
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
-
// ---- Quiz completion handler ----
|
|
181
180
|
function handleQuizComplete(e) {
|
|
182
|
-
const { score
|
|
181
|
+
const { score } = e.detail;
|
|
183
182
|
const pageIndex = nav.currentPageIndex;
|
|
184
183
|
progress.quizCompleted(pageIndex, score);
|
|
185
|
-
for (const { id, interaction, correct } of interactions) {
|
|
186
|
-
adapter.reportInteraction(id, interaction, correct);
|
|
187
|
-
}
|
|
188
184
|
progress.recalculateCompletion(manifest, config);
|
|
189
185
|
progress.recalculateSuccess(manifest, config);
|
|
190
|
-
// Persistence is scheduled by the version-tracking effect below; no
|
|
191
|
-
// explicit call needed here.
|
|
192
186
|
}
|
|
193
187
|
|
|
194
188
|
// ---- Persistence: serialize / restore ----
|
|
@@ -403,6 +397,7 @@
|
|
|
403
397
|
restoreState(saved);
|
|
404
398
|
prevCompletionStatus = progress.completionStatus;
|
|
405
399
|
prevSuccessStatus = progress.successStatus;
|
|
400
|
+
adapter.seedLifecycle?.(progress.completionStatus, progress.successStatus);
|
|
406
401
|
}
|
|
407
402
|
persistenceReady = true;
|
|
408
403
|
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import type { PersistenceAdapter, SavedState } from '../persistence.js';
|
|
2
2
|
import type { Interaction } from '../interaction.js';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
formatResponse,
|
|
5
|
+
formatCorrectPattern,
|
|
6
|
+
XAPI_INTERACTION_FORMAT,
|
|
7
|
+
} from '../interaction-format.js';
|
|
4
8
|
import { formatISO8601Duration } from './retry.js';
|
|
5
9
|
import { XAPIPublisher } from '../xapi/publisher.js';
|
|
6
10
|
import { X_API_VERSION } from '../xapi/version.js';
|
|
@@ -139,8 +143,8 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
139
143
|
#score: number | null = null;
|
|
140
144
|
#durationSeconds = 0;
|
|
141
145
|
#state: SavedState | null = null;
|
|
142
|
-
#
|
|
143
|
-
#
|
|
146
|
+
#completedEmitted = false;
|
|
147
|
+
#lastSuccessEmitted: 'unknown' | 'passed' | 'failed' = 'unknown';
|
|
144
148
|
#terminated = false;
|
|
145
149
|
|
|
146
150
|
// cmi5 §8 launch params. masteryScore (when present) overrides the
|
|
@@ -240,13 +244,28 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
240
244
|
// Basic credential (already base64); we don't re-encode.
|
|
241
245
|
let token = '';
|
|
242
246
|
if (text.startsWith('{')) {
|
|
247
|
+
let parsed: unknown;
|
|
243
248
|
try {
|
|
244
|
-
|
|
245
|
-
if (parsed && typeof parsed['auth-token'] === 'string') {
|
|
246
|
-
token = parsed['auth-token'].trim();
|
|
247
|
-
}
|
|
249
|
+
parsed = JSON.parse(text);
|
|
248
250
|
} catch {
|
|
249
|
-
|
|
251
|
+
parsed = undefined;
|
|
252
|
+
}
|
|
253
|
+
if (parsed && typeof parsed === 'object') {
|
|
254
|
+
const obj = parsed as Record<string, unknown>;
|
|
255
|
+
if (typeof obj['auth-token'] === 'string') {
|
|
256
|
+
token = (obj['auth-token'] as string).trim();
|
|
257
|
+
} else {
|
|
258
|
+
const code = typeof obj['error-code'] === 'string' ? obj['error-code'] : undefined;
|
|
259
|
+
const errText = typeof obj['error-text'] === 'string' ? obj['error-text'] : undefined;
|
|
260
|
+
const detail =
|
|
261
|
+
code !== undefined || errText !== undefined
|
|
262
|
+
? ` (error-code=${code ?? 'unknown'}${errText ? `: ${errText}` : ''})`
|
|
263
|
+
: '';
|
|
264
|
+
throw new Error(
|
|
265
|
+
`Tessera cmi5: fetch URL returned a JSON response without an 'auth-token' field${detail}. ` +
|
|
266
|
+
'The cmi5 fetch URL is single-use (§8.2.3.1); reload from the LMS to obtain a fresh launch.'
|
|
267
|
+
);
|
|
268
|
+
}
|
|
250
269
|
}
|
|
251
270
|
}
|
|
252
271
|
if (!token) {
|
|
@@ -434,11 +453,21 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
434
453
|
this.#score = Math.max(0, Math.min(100, score));
|
|
435
454
|
}
|
|
436
455
|
|
|
456
|
+
seedLifecycle(
|
|
457
|
+
completion: 'incomplete' | 'complete',
|
|
458
|
+
success: 'unknown' | 'passed' | 'failed'
|
|
459
|
+
): void {
|
|
460
|
+
if (completion === 'complete') this.#completedEmitted = true;
|
|
461
|
+
if (success === 'passed' || success === 'failed') {
|
|
462
|
+
this.#lastSuccessEmitted = success;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
437
466
|
setCompletionStatus(status: 'incomplete' | 'complete'): void {
|
|
438
|
-
if (status !== 'complete' || this.#
|
|
467
|
+
if (status !== 'complete' || this.#completedEmitted || !this.#publisher) return;
|
|
439
468
|
// cmi5 §10.2.2 — Browse/Review launches MUST NOT emit Completed.
|
|
440
469
|
if (this.#launchMode !== 'Normal') return;
|
|
441
|
-
this.#
|
|
470
|
+
this.#completedEmitted = true;
|
|
442
471
|
// cmi5 §9.5.1 — `score` MUST NOT appear on Completed (Passed/Failed only).
|
|
443
472
|
const result: Record<string, unknown> = {
|
|
444
473
|
completion: true,
|
|
@@ -457,10 +486,11 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
457
486
|
}
|
|
458
487
|
|
|
459
488
|
setSuccessStatus(status: 'passed' | 'failed' | 'unknown'): void {
|
|
460
|
-
if (status === 'unknown' ||
|
|
489
|
+
if (status === 'unknown' || !this.#publisher) return;
|
|
490
|
+
if (status === this.#lastSuccessEmitted) return;
|
|
461
491
|
// cmi5 §10.2.2 — Browse/Review launches MUST NOT emit Passed/Failed.
|
|
462
492
|
if (this.#launchMode !== 'Normal') return;
|
|
463
|
-
this.#
|
|
493
|
+
this.#lastSuccessEmitted = status;
|
|
464
494
|
|
|
465
495
|
const verb = status === 'passed' ? VERBS.passed : VERBS.failed;
|
|
466
496
|
const verbName = status === 'passed' ? 'passed' : 'failed';
|
|
@@ -518,8 +548,8 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
518
548
|
correct: boolean | null
|
|
519
549
|
): void {
|
|
520
550
|
if (!this.#publisher) return;
|
|
521
|
-
const response = formatResponse(interaction);
|
|
522
|
-
const pattern = formatCorrectPattern(interaction);
|
|
551
|
+
const response = formatResponse(interaction, XAPI_INTERACTION_FORMAT);
|
|
552
|
+
const pattern = formatCorrectPattern(interaction, XAPI_INTERACTION_FORMAT);
|
|
523
553
|
const definition: Record<string, unknown> = {
|
|
524
554
|
type: CMI_INTERACTION_TYPE,
|
|
525
555
|
interactionType: interaction.type,
|