tessera-learn 0.0.10 → 0.0.13
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/README.md +1 -0
- package/dist/audit-BBJpQGqb.js +204 -0
- package/dist/audit-BBJpQGqb.js.map +1 -0
- package/dist/plugin/a11y-cli.d.ts +1 -0
- package/dist/plugin/a11y-cli.js +36 -0
- package/dist/plugin/a11y-cli.js.map +1 -0
- package/dist/plugin/cli.js +6 -3
- package/dist/plugin/cli.js.map +1 -1
- package/dist/plugin/index.d.ts +16 -1
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +171 -140
- package/dist/plugin/index.js.map +1 -1
- package/dist/{validation-BxWAMMnJ.js → validation-B-xTvM9B.js} +417 -81
- package/dist/validation-B-xTvM9B.js.map +1 -0
- package/package.json +17 -2
- package/src/components/Accordion.svelte +3 -1
- package/src/components/AccordionItem.svelte +1 -5
- package/src/components/Audio.svelte +22 -5
- package/src/components/Callout.svelte +5 -1
- package/src/components/Carousel.svelte +24 -8
- package/src/components/DefaultLayout.svelte +41 -12
- package/src/components/FillInTheBlank.svelte +75 -103
- package/src/components/Image.svelte +14 -10
- package/src/components/LockedBanner.svelte +5 -5
- package/src/components/Matching.svelte +48 -19
- package/src/components/MediaTracks.svelte +21 -0
- package/src/components/MultipleChoice.svelte +81 -102
- package/src/components/Quiz.svelte +63 -21
- package/src/components/ResultIcon.svelte +20 -4
- package/src/components/RevealModal.svelte +25 -22
- package/src/components/Sorting.svelte +61 -26
- package/src/components/Transcript.svelte +37 -0
- package/src/components/Video.svelte +25 -20
- package/src/components/util.ts +4 -1
- package/src/components/video-embed.ts +25 -0
- package/src/index.ts +2 -7
- package/src/plugin/a11y/audit.ts +299 -0
- package/src/plugin/a11y/contrast.ts +67 -0
- package/src/plugin/a11y-cli.ts +35 -0
- package/src/plugin/cli.ts +6 -8
- package/src/plugin/export.ts +60 -50
- package/src/plugin/index.ts +244 -101
- package/src/plugin/layout.ts +6 -51
- package/src/plugin/manifest.ts +90 -24
- package/src/plugin/override-plugin.ts +68 -0
- package/src/plugin/quiz.ts +9 -54
- package/src/plugin/validation.ts +768 -183
- package/src/runtime/App.svelte +128 -64
- package/src/runtime/LoadingBar.svelte +12 -3
- package/src/runtime/Sidebar.svelte +24 -8
- package/src/runtime/access.ts +15 -3
- package/src/runtime/adapters/cmi5.ts +68 -116
- package/src/runtime/adapters/format.ts +67 -0
- package/src/runtime/adapters/index.ts +45 -34
- package/src/runtime/adapters/retry.ts +25 -84
- package/src/runtime/adapters/scorm-base.ts +19 -15
- package/src/runtime/adapters/scorm12.ts +8 -9
- package/src/runtime/adapters/scorm2004.ts +22 -30
- package/src/runtime/adapters/web.ts +1 -1
- package/src/runtime/hooks.svelte.ts +152 -328
- package/src/runtime/interaction-format.ts +30 -12
- package/src/runtime/interaction.ts +44 -11
- package/src/runtime/navigation.svelte.ts +29 -40
- package/src/runtime/persistence.ts +2 -2
- package/src/runtime/progress.svelte.ts +22 -9
- package/src/runtime/quiz-engine.svelte.ts +361 -0
- package/src/runtime/quiz-policy.ts +28 -179
- package/src/runtime/types.ts +24 -2
- package/src/runtime/xapi/agent-rules.ts +11 -3
- package/src/runtime/xapi/client.ts +5 -5
- package/src/runtime/xapi/derive-actor.ts +2 -2
- package/src/runtime/xapi/publisher.ts +33 -40
- package/src/runtime/xapi/setup.ts +18 -15
- package/src/runtime/xapi/validation.ts +15 -6
- package/src/virtual.d.ts +4 -1
- package/styles/base.css +32 -11
- package/styles/layout.css +39 -18
- package/styles/theme.css +15 -3
- package/dist/validation-BxWAMMnJ.js.map +0 -1
|
@@ -55,19 +55,26 @@ export const XAPI_INTERACTION_FORMAT: InteractionFormat = {
|
|
|
55
55
|
* underscore, max 250 chars. Strict validators (SCORM Cloud) reject raw
|
|
56
56
|
* option labels with spaces or punctuation with error 405/406.
|
|
57
57
|
*/
|
|
58
|
-
function shortIdentifier(value: string): string {
|
|
58
|
+
export function shortIdentifier(value: string): string {
|
|
59
59
|
const cleaned = value.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
|
|
60
60
|
const trimmed = cleaned.slice(0, 250);
|
|
61
61
|
return trimmed || '_';
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
function indexLookup(
|
|
64
|
+
function indexLookup(
|
|
65
|
+
options: string[] | undefined,
|
|
66
|
+
value: string,
|
|
67
|
+
): string | null {
|
|
65
68
|
if (!options) return null;
|
|
66
69
|
const idx = options.indexOf(value);
|
|
67
70
|
return idx >= 0 ? String(idx) : null;
|
|
68
71
|
}
|
|
69
72
|
|
|
70
|
-
function encodeListItem(
|
|
73
|
+
function encodeListItem(
|
|
74
|
+
value: string,
|
|
75
|
+
options: string[] | undefined,
|
|
76
|
+
fmt: InteractionFormat,
|
|
77
|
+
): string {
|
|
71
78
|
if (fmt === SCORM12_INTERACTION_FORMAT) {
|
|
72
79
|
const idx = indexLookup(options, value);
|
|
73
80
|
if (idx !== null) return idx;
|
|
@@ -77,12 +84,14 @@ function encodeListItem(value: string, options: string[] | undefined, fmt: Inter
|
|
|
77
84
|
|
|
78
85
|
export function formatResponse(
|
|
79
86
|
i: Interaction,
|
|
80
|
-
fmt: InteractionFormat = SCORM2004_INTERACTION_FORMAT
|
|
87
|
+
fmt: InteractionFormat = SCORM2004_INTERACTION_FORMAT,
|
|
81
88
|
): string {
|
|
82
89
|
switch (i.type) {
|
|
83
90
|
case 'choice':
|
|
84
91
|
case 'sequencing':
|
|
85
|
-
return i.response
|
|
92
|
+
return i.response
|
|
93
|
+
.map((v) => encodeListItem(v, i.options, fmt))
|
|
94
|
+
.join(fmt.itemDelim);
|
|
86
95
|
case 'true-false':
|
|
87
96
|
return fmt.formatBoolean(i.response);
|
|
88
97
|
case 'fill-in':
|
|
@@ -94,14 +103,17 @@ export function formatResponse(
|
|
|
94
103
|
return i.response
|
|
95
104
|
.map(
|
|
96
105
|
([l, r]) =>
|
|
97
|
-
`${encodeListItem(l, i.optionPairs?.left, fmt)}${fmt.pairDelim}${encodeListItem(r, i.optionPairs?.right, fmt)}
|
|
106
|
+
`${encodeListItem(l, i.optionPairs?.left, fmt)}${fmt.pairDelim}${encodeListItem(r, i.optionPairs?.right, fmt)}`,
|
|
98
107
|
)
|
|
99
108
|
.join(fmt.itemDelim);
|
|
100
109
|
case 'numeric':
|
|
101
110
|
return String(i.response);
|
|
102
111
|
case 'performance':
|
|
103
112
|
return i.response
|
|
104
|
-
.map(
|
|
113
|
+
.map(
|
|
114
|
+
([s, v]) =>
|
|
115
|
+
`${fmt.identifier(s)}${fmt.pairDelim}${fmt.identifier(String(v))}`,
|
|
116
|
+
)
|
|
105
117
|
.join(fmt.itemDelim);
|
|
106
118
|
}
|
|
107
119
|
}
|
|
@@ -109,7 +121,7 @@ export function formatResponse(
|
|
|
109
121
|
/** Returns null when no correct pattern was provided. */
|
|
110
122
|
export function formatCorrectPattern(
|
|
111
123
|
i: Interaction,
|
|
112
|
-
fmt: InteractionFormat = SCORM2004_INTERACTION_FORMAT
|
|
124
|
+
fmt: InteractionFormat = SCORM2004_INTERACTION_FORMAT,
|
|
113
125
|
): string | null {
|
|
114
126
|
if (i.correct === undefined) return null;
|
|
115
127
|
switch (i.type) {
|
|
@@ -127,7 +139,7 @@ export function formatCorrectPattern(
|
|
|
127
139
|
return (i.correct as Array<[string, string]>)
|
|
128
140
|
.map(
|
|
129
141
|
([l, r]) =>
|
|
130
|
-
`${encodeListItem(l, i.optionPairs?.left, fmt)}${fmt.pairDelim}${encodeListItem(r, i.optionPairs?.right, fmt)}
|
|
142
|
+
`${encodeListItem(l, i.optionPairs?.left, fmt)}${fmt.pairDelim}${encodeListItem(r, i.optionPairs?.right, fmt)}`,
|
|
131
143
|
)
|
|
132
144
|
.join(fmt.itemDelim);
|
|
133
145
|
case 'numeric': {
|
|
@@ -146,7 +158,10 @@ export function formatCorrectPattern(
|
|
|
146
158
|
return i.correct as string;
|
|
147
159
|
case 'performance':
|
|
148
160
|
return (i.correct as Array<[string, string | number]>)
|
|
149
|
-
.map(
|
|
161
|
+
.map(
|
|
162
|
+
([s, v]) =>
|
|
163
|
+
`${fmt.identifier(s)}${fmt.pairDelim}${fmt.identifier(String(v))}`,
|
|
164
|
+
)
|
|
150
165
|
.join(fmt.itemDelim);
|
|
151
166
|
}
|
|
152
167
|
}
|
|
@@ -177,7 +192,7 @@ export function buildScormInteractionFields(
|
|
|
177
192
|
questionId: string,
|
|
178
193
|
interaction: Interaction,
|
|
179
194
|
correct: boolean | null,
|
|
180
|
-
spec: ScormInteractionSpec
|
|
195
|
+
spec: ScormInteractionSpec,
|
|
181
196
|
): Array<[string, string]> {
|
|
182
197
|
const fields: Array<[string, string]> = [
|
|
183
198
|
[`${prefix}.id`, spec.format.identifier(questionId)],
|
|
@@ -187,7 +202,10 @@ export function buildScormInteractionFields(
|
|
|
187
202
|
if (pattern !== null) {
|
|
188
203
|
fields.push([`${prefix}.correct_responses.0.pattern`, pattern]);
|
|
189
204
|
}
|
|
190
|
-
fields.push([
|
|
205
|
+
fields.push([
|
|
206
|
+
`${prefix}.${spec.responseField}`,
|
|
207
|
+
formatResponse(interaction, spec.format),
|
|
208
|
+
]);
|
|
191
209
|
if (correct !== null) {
|
|
192
210
|
fields.push([
|
|
193
211
|
`${prefix}.result`,
|
|
@@ -7,16 +7,49 @@
|
|
|
7
7
|
* so there is no impedance mismatch when writing to an LMS.
|
|
8
8
|
*/
|
|
9
9
|
export type Interaction =
|
|
10
|
-
| {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
| { type: '
|
|
17
|
-
| {
|
|
18
|
-
|
|
19
|
-
|
|
10
|
+
| {
|
|
11
|
+
type: 'choice';
|
|
12
|
+
response: string[];
|
|
13
|
+
correct?: string[];
|
|
14
|
+
options?: string[];
|
|
15
|
+
}
|
|
16
|
+
| { type: 'true-false'; response: boolean; correct?: boolean }
|
|
17
|
+
| {
|
|
18
|
+
type: 'fill-in';
|
|
19
|
+
response: string;
|
|
20
|
+
correct?: string[];
|
|
21
|
+
caseMatters?: boolean;
|
|
22
|
+
}
|
|
23
|
+
| {
|
|
24
|
+
type: 'long-fill-in';
|
|
25
|
+
response: string;
|
|
26
|
+
correct?: string[];
|
|
27
|
+
caseMatters?: boolean;
|
|
28
|
+
}
|
|
29
|
+
| {
|
|
30
|
+
type: 'matching';
|
|
31
|
+
response: Array<[string, string]>;
|
|
32
|
+
correct?: Array<[string, string]>;
|
|
33
|
+
optionPairs?: { left: string[]; right: string[] };
|
|
34
|
+
}
|
|
35
|
+
| {
|
|
36
|
+
type: 'sequencing';
|
|
37
|
+
response: string[];
|
|
38
|
+
correct?: string[];
|
|
39
|
+
options?: string[];
|
|
40
|
+
}
|
|
41
|
+
| {
|
|
42
|
+
type: 'numeric';
|
|
43
|
+
response: number;
|
|
44
|
+
correct?: { min?: number; max?: number };
|
|
45
|
+
}
|
|
46
|
+
| { type: 'likert'; response: string; correct?: string }
|
|
47
|
+
| {
|
|
48
|
+
type: 'performance';
|
|
49
|
+
response: Array<[string, string | number]>;
|
|
50
|
+
correct?: Array<[string, string | number]>;
|
|
51
|
+
}
|
|
52
|
+
| { type: 'other'; response: string; correct?: string };
|
|
20
53
|
|
|
21
54
|
/**
|
|
22
55
|
* Decide whether a learner response is correct. Returns:
|
|
@@ -86,7 +119,7 @@ function setEqual(a: string[], b: string[]): boolean {
|
|
|
86
119
|
|
|
87
120
|
function pairSetEqual(
|
|
88
121
|
a: Array<[string, string]>,
|
|
89
|
-
b: Array<[string, string]
|
|
122
|
+
b: Array<[string, string]>,
|
|
90
123
|
): boolean {
|
|
91
124
|
if (a.length !== b.length) return false;
|
|
92
125
|
const key = ([l, r]: [string, string]) => `${l}\u241F${r}`;
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import type { Manifest } from '../plugin/manifest.js';
|
|
2
2
|
import type { CourseConfig } from './types.js';
|
|
3
3
|
import { ProgressState } from './progress.svelte.js';
|
|
4
|
+
import { resolveAccess } from './access.js';
|
|
4
5
|
|
|
5
6
|
export function isPageComplete(
|
|
6
7
|
index: number,
|
|
7
8
|
manifest: Manifest,
|
|
8
9
|
progress: ProgressState,
|
|
9
|
-
config: CourseConfig
|
|
10
|
+
config: CourseConfig,
|
|
10
11
|
): boolean {
|
|
11
12
|
const page = manifest.pages[index];
|
|
12
13
|
if (!page) return false;
|
|
@@ -29,6 +30,10 @@ export class NavigationState {
|
|
|
29
30
|
#progress: ProgressState;
|
|
30
31
|
#config: CourseConfig;
|
|
31
32
|
#pageModules: PageModuleMap | null = null;
|
|
33
|
+
// Audit mode unlocks every page so the Tier-2 auditor can render and scan
|
|
34
|
+
// each one's DOM. Safe because gating is a runtime-only UX affordance — the
|
|
35
|
+
// whole course already ships client-side (see access.ts).
|
|
36
|
+
#auditMode: boolean;
|
|
32
37
|
currentPageIndex = $state(0);
|
|
33
38
|
|
|
34
39
|
canGoPrev = $derived(this.currentPageIndex > 0);
|
|
@@ -50,7 +55,10 @@ export class NavigationState {
|
|
|
50
55
|
if (prev && prev.size === next.size) {
|
|
51
56
|
let same = true;
|
|
52
57
|
for (const i of next) {
|
|
53
|
-
if (!prev.has(i)) {
|
|
58
|
+
if (!prev.has(i)) {
|
|
59
|
+
same = false;
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
54
62
|
}
|
|
55
63
|
if (same) return prev;
|
|
56
64
|
}
|
|
@@ -58,10 +66,16 @@ export class NavigationState {
|
|
|
58
66
|
return next;
|
|
59
67
|
});
|
|
60
68
|
|
|
61
|
-
constructor(
|
|
69
|
+
constructor(
|
|
70
|
+
manifest: Manifest,
|
|
71
|
+
progress: ProgressState,
|
|
72
|
+
config: CourseConfig,
|
|
73
|
+
auditMode = false,
|
|
74
|
+
) {
|
|
62
75
|
this.manifest = manifest;
|
|
63
76
|
this.#progress = progress;
|
|
64
77
|
this.#config = config;
|
|
78
|
+
this.#auditMode = auditMode;
|
|
65
79
|
}
|
|
66
80
|
|
|
67
81
|
setPageModules(modules: PageModuleMap) {
|
|
@@ -78,7 +92,7 @@ export class NavigationState {
|
|
|
78
92
|
if (index < 0 || index >= this.manifest.totalPages) return;
|
|
79
93
|
if (this.isPageLocked(index)) return;
|
|
80
94
|
const page = this.manifest.pages[index];
|
|
81
|
-
this.#pageModules[page.importPath]?.();
|
|
95
|
+
void this.#pageModules[page.importPath]?.();
|
|
82
96
|
}
|
|
83
97
|
|
|
84
98
|
goToPage(index: number) {
|
|
@@ -96,53 +110,28 @@ export class NavigationState {
|
|
|
96
110
|
}
|
|
97
111
|
|
|
98
112
|
isPageLocked(index: number): boolean {
|
|
113
|
+
if (this.#auditMode) return false;
|
|
99
114
|
return this.#lockedSet.has(index);
|
|
100
115
|
}
|
|
101
116
|
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
// per-page evaluation since their semantics are arbitrary — but it still
|
|
106
|
-
// runs once per state change rather than once per page per render.
|
|
117
|
+
// Resolve the access predicate once (custom canAccess, or the free /
|
|
118
|
+
// sequential preset) and evaluate it per page. Runs once per state change
|
|
119
|
+
// — the presets are the single source of truth for the gating rules.
|
|
107
120
|
#computeLockedSet(): Set<number> {
|
|
108
121
|
const total = this.manifest.totalPages;
|
|
109
122
|
const locked = new Set<number>();
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
if (!fn({
|
|
123
|
+
const access = resolveAccess(this.#config);
|
|
124
|
+
for (let i = 0; i < total; i++) {
|
|
125
|
+
if (
|
|
126
|
+
!access({
|
|
115
127
|
pageIndex: i,
|
|
116
128
|
page: this.manifest.pages[i],
|
|
117
129
|
manifest: this.manifest,
|
|
118
130
|
progress: this.#progress,
|
|
119
131
|
config: this.#config,
|
|
120
|
-
})
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
}
|
|
124
|
-
return locked;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
if (this.#config.navigation.mode === 'sequential') {
|
|
128
|
-
// Once any page is incomplete, every later page is locked.
|
|
129
|
-
for (let i = 1; i < total; i++) {
|
|
130
|
-
if (!isPageComplete(i - 1, this.manifest, this.#progress, this.#config)) {
|
|
131
|
-
for (let k = i; k < total; k++) locked.add(k);
|
|
132
|
-
return locked;
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
return locked;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// Free mode: a page is locked iff its most-recent gating quiz is unmet.
|
|
139
|
-
let lastGatingUnmet = false;
|
|
140
|
-
for (let i = 0; i < total; i++) {
|
|
141
|
-
if (lastGatingUnmet) locked.add(i);
|
|
142
|
-
const page = this.manifest.pages[i];
|
|
143
|
-
if (page.quiz?.gatesProgress) {
|
|
144
|
-
const score = this.#progress.quizScores.get(i) ?? 0;
|
|
145
|
-
lastGatingUnmet = score < this.#config.scoring.passingScore;
|
|
132
|
+
})
|
|
133
|
+
) {
|
|
134
|
+
locked.add(i);
|
|
146
135
|
}
|
|
147
136
|
}
|
|
148
137
|
return locked;
|
|
@@ -14,7 +14,7 @@ export interface PersistenceAdapter {
|
|
|
14
14
|
/** Tell the adapter what was already emitted in prior sessions, so it skips re-emitting on resume. */
|
|
15
15
|
seedLifecycle?(
|
|
16
16
|
completion: 'incomplete' | 'complete',
|
|
17
|
-
success: 'unknown' | 'passed' | 'failed'
|
|
17
|
+
success: 'unknown' | 'passed' | 'failed',
|
|
18
18
|
): void;
|
|
19
19
|
setDuration(seconds: number): void;
|
|
20
20
|
/**
|
|
@@ -31,7 +31,7 @@ export interface PersistenceAdapter {
|
|
|
31
31
|
reportInteraction(
|
|
32
32
|
questionId: string,
|
|
33
33
|
interaction: Interaction,
|
|
34
|
-
correct: boolean | null
|
|
34
|
+
correct: boolean | null,
|
|
35
35
|
): void;
|
|
36
36
|
commit(): void;
|
|
37
37
|
terminate(): void;
|
|
@@ -20,7 +20,9 @@ export class ProgressState {
|
|
|
20
20
|
* Tracked separately from `quizScores` because <Quiz> blocks score as a unit
|
|
21
21
|
* while standalone questions score individually and average per page.
|
|
22
22
|
*/
|
|
23
|
-
standaloneQuestionScores = $state(
|
|
23
|
+
standaloneQuestionScores = $state(
|
|
24
|
+
new SvelteMap<number, Map<string, number>>(),
|
|
25
|
+
);
|
|
24
26
|
/**
|
|
25
27
|
* Set of page indices that have at least one graded standalone question.
|
|
26
28
|
* Pages in this set contribute to course success status via their standalone average.
|
|
@@ -96,7 +98,7 @@ export class ProgressState {
|
|
|
96
98
|
pageIndex: number,
|
|
97
99
|
questionId: string,
|
|
98
100
|
score: number,
|
|
99
|
-
graded: boolean
|
|
101
|
+
graded: boolean,
|
|
100
102
|
) {
|
|
101
103
|
let pageMap = this.standaloneQuestionScores.get(pageIndex);
|
|
102
104
|
if (!pageMap) {
|
|
@@ -124,9 +126,8 @@ export class ProgressState {
|
|
|
124
126
|
if (config.completion.mode === 'manual') return;
|
|
125
127
|
if (config.completion.mode === 'percentage') {
|
|
126
128
|
const threshold = config.completion.percentageThreshold ?? 100;
|
|
127
|
-
const percent =
|
|
128
|
-
? (this.visitedPages.size / totalPages) * 100
|
|
129
|
-
: 0;
|
|
129
|
+
const percent =
|
|
130
|
+
totalPages > 0 ? (this.visitedPages.size / totalPages) * 100 : 0;
|
|
130
131
|
this.completionStatus = percent >= threshold ? 'complete' : 'incomplete';
|
|
131
132
|
} else if (config.completion.mode === 'quiz') {
|
|
132
133
|
const { indices } = this.#gradedPages();
|
|
@@ -135,7 +136,8 @@ export class ProgressState {
|
|
|
135
136
|
return;
|
|
136
137
|
}
|
|
137
138
|
const average = this.#gradedAverage(indices);
|
|
138
|
-
this.completionStatus =
|
|
139
|
+
this.completionStatus =
|
|
140
|
+
average >= config.scoring.passingScore ? 'complete' : 'incomplete';
|
|
139
141
|
}
|
|
140
142
|
}
|
|
141
143
|
|
|
@@ -144,7 +146,8 @@ export class ProgressState {
|
|
|
144
146
|
const want = config.completion.requireSuccessStatus;
|
|
145
147
|
// Stay 'unknown' until manual mark fires, so a learner who never
|
|
146
148
|
// finishes isn't reported as passed.
|
|
147
|
-
this.successStatus =
|
|
149
|
+
this.successStatus =
|
|
150
|
+
this.#manuallyCompleted && want !== undefined ? want : 'unknown';
|
|
148
151
|
return;
|
|
149
152
|
}
|
|
150
153
|
|
|
@@ -160,7 +163,17 @@ export class ProgressState {
|
|
|
160
163
|
return;
|
|
161
164
|
}
|
|
162
165
|
const average = this.#gradedAverage(indices);
|
|
163
|
-
this.successStatus =
|
|
166
|
+
this.successStatus =
|
|
167
|
+
average >= config.scoring.passingScore ? 'passed' : 'failed';
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Effective graded score for LMS reporting — same union and averaging as
|
|
172
|
+
* recalculateSuccess, so score and success status can't disagree.
|
|
173
|
+
*/
|
|
174
|
+
gradedScore(): { average: number; attempted: boolean } {
|
|
175
|
+
const { indices, attempted } = this.#gradedPages();
|
|
176
|
+
return { average: this.#gradedAverage(indices), attempted };
|
|
164
177
|
}
|
|
165
178
|
|
|
166
179
|
/**
|
|
@@ -172,7 +185,7 @@ export class ProgressState {
|
|
|
172
185
|
const merged = new Set(this.#quizGradedIndices);
|
|
173
186
|
for (const i of this.gradedStandalonePages) merged.add(i);
|
|
174
187
|
const indices = [...merged];
|
|
175
|
-
const attempted = indices.some(i => this.#hasScore(i));
|
|
188
|
+
const attempted = indices.some((i) => this.#hasScore(i));
|
|
176
189
|
return { indices, attempted };
|
|
177
190
|
}
|
|
178
191
|
|