@longsightgroup/qti3-core 0.10.2 → 0.10.3
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 +21 -2
- package/dist/attempt-state.d.ts.map +1 -1
- package/dist/attempt-state.js +18 -0
- package/dist/attempt-state.js.map +1 -1
- package/dist/content-text.d.ts +4 -0
- package/dist/content-text.d.ts.map +1 -1
- package/dist/content-text.js +112 -9
- package/dist/content-text.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/media-definition.d.ts +55 -0
- package/dist/media-definition.d.ts.map +1 -0
- package/dist/media-definition.js +97 -0
- package/dist/media-definition.js.map +1 -0
- package/dist/parser-declarations.d.ts +4 -4
- package/dist/parser-declarations.d.ts.map +1 -1
- package/dist/parser-declarations.js +87 -46
- package/dist/parser-declarations.js.map +1 -1
- package/dist/parser-item-metadata.d.ts.map +1 -1
- package/dist/parser-item-metadata.js +4 -3
- package/dist/parser-item-metadata.js.map +1 -1
- package/dist/parser-processing.js +10 -9
- package/dist/parser-processing.js.map +1 -1
- package/dist/parser-values.d.ts +2 -0
- package/dist/parser-values.d.ts.map +1 -1
- package/dist/parser-values.js +7 -0
- package/dist/parser-values.js.map +1 -1
- package/dist/parser.js +16 -13
- package/dist/parser.js.map +1 -1
- package/dist/processing-evaluator-collection.d.ts.map +1 -1
- package/dist/processing-evaluator-collection.js +8 -2
- package/dist/processing-evaluator-collection.js.map +1 -1
- package/dist/processing-evaluator-comparison.d.ts.map +1 -1
- package/dist/processing-evaluator-comparison.js +6 -0
- package/dist/processing-evaluator-comparison.js.map +1 -1
- package/dist/processing-evaluator-numeric.d.ts.map +1 -1
- package/dist/processing-evaluator-numeric.js +8 -1
- package/dist/processing-evaluator-numeric.js.map +1 -1
- package/dist/processing-expression-children.d.ts.map +1 -1
- package/dist/processing-expression-children.js +2 -1
- package/dist/processing-expression-children.js.map +1 -1
- package/dist/processing-limits.d.ts +3 -0
- package/dist/processing-limits.d.ts.map +1 -0
- package/dist/processing-limits.js +3 -0
- package/dist/processing-limits.js.map +1 -0
- package/dist/processing-mapping.d.ts.map +1 -1
- package/dist/processing-mapping.js +13 -2
- package/dist/processing-mapping.js.map +1 -1
- package/dist/processing-operators.d.ts +2 -0
- package/dist/processing-operators.d.ts.map +1 -1
- package/dist/processing-operators.js +34 -5
- package/dist/processing-operators.js.map +1 -1
- package/dist/response-validation-policy.d.ts.map +1 -1
- package/dist/response-validation-policy.js +5 -6
- package/dist/response-validation-policy.js.map +1 -1
- package/dist/serializer-processing-expressions.js +1 -1
- package/dist/serializer-processing-expressions.js.map +1 -1
- package/dist/slider-definition.d.ts +59 -0
- package/dist/slider-definition.d.ts.map +1 -0
- package/dist/slider-definition.js +198 -0
- package/dist/slider-definition.js.map +1 -0
- package/dist/support-evidence.d.ts +6 -2
- package/dist/support-evidence.d.ts.map +1 -1
- package/dist/support-evidence.js +15 -11
- package/dist/support-evidence.js.map +1 -1
- package/dist/support.d.ts.map +1 -1
- package/dist/support.js +96 -100
- package/dist/support.js.map +1 -1
- package/dist/types.d.ts +3 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/validation-declaration-entries.d.ts +13 -0
- package/dist/validation-declaration-entries.d.ts.map +1 -0
- package/dist/validation-declaration-entries.js +136 -0
- package/dist/validation-declaration-entries.js.map +1 -0
- package/dist/validation-interactions.d.ts.map +1 -1
- package/dist/validation-interactions.js +14 -46
- package/dist/validation-interactions.js.map +1 -1
- package/dist/validation-primitives.js +2 -2
- package/dist/validation-primitives.js.map +1 -1
- package/dist/validation-processing.d.ts.map +1 -1
- package/dist/validation-processing.js +125 -1
- package/dist/validation-processing.js.map +1 -1
- package/dist/validation.d.ts.map +1 -1
- package/dist/validation.js +8 -134
- package/dist/validation.js.map +1 -1
- package/dist/xml.d.ts.map +1 -1
- package/dist/xml.js +3 -2
- package/dist/xml.js.map +1 -1
- package/package.json +1 -1
- package/src/attempt-state.ts +20 -0
- package/src/content-text.ts +135 -11
- package/src/index.ts +25 -0
- package/src/media-definition.ts +174 -0
- package/src/parser-declarations.ts +134 -40
- package/src/parser-item-metadata.ts +4 -3
- package/src/parser-processing.ts +10 -9
- package/src/parser-values.ts +7 -0
- package/src/parser.ts +16 -13
- package/src/processing-evaluator-collection.ts +7 -2
- package/src/processing-evaluator-comparison.ts +6 -0
- package/src/processing-evaluator-numeric.ts +5 -1
- package/src/processing-expression-children.ts +2 -1
- package/src/processing-limits.ts +2 -0
- package/src/processing-mapping.ts +10 -2
- package/src/processing-operators.ts +38 -5
- package/src/response-validation-policy.ts +5 -5
- package/src/serializer-processing-expressions.ts +1 -1
- package/src/slider-definition.ts +363 -0
- package/src/support-evidence.ts +15 -17
- package/src/support.ts +106 -102
- package/src/types.ts +8 -1
- package/src/validation-declaration-entries.ts +162 -0
- package/src/validation-interactions.ts +14 -64
- package/src/validation-primitives.ts +2 -2
- package/src/validation-processing.ts +145 -1
- package/src/validation.ts +11 -151
- package/src/xml.ts +3 -4
package/src/content-text.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { QtiChoice, QtiContentNode, QtiSourceLocation } from "./types.js";
|
|
2
2
|
import { assertNever } from "./assert-never.js";
|
|
3
|
+
import type { XmlNode } from "./xml.js";
|
|
3
4
|
|
|
4
5
|
export interface FlatTextFromContentOptions {
|
|
5
6
|
excludeAnnotations?: boolean;
|
|
7
|
+
interactionText?: ((interactionIndex: number) => string) | undefined;
|
|
6
8
|
}
|
|
7
9
|
|
|
8
10
|
export function normalizeFlatText(text: string): string {
|
|
@@ -32,13 +34,14 @@ export function flatTextFromContent(
|
|
|
32
34
|
nodes: QtiContentNode[],
|
|
33
35
|
options: FlatTextFromContentOptions = {},
|
|
34
36
|
): string {
|
|
35
|
-
|
|
36
|
-
return normalizeFlatText(
|
|
37
|
-
nodes.map((node) => flatTextFromContentNode(node, excludeAnnotations)).join(" "),
|
|
38
|
-
);
|
|
37
|
+
return normalizeFlatText(nodes.map((node) => flatTextFromContentNode(node, options)).join(""));
|
|
39
38
|
}
|
|
40
39
|
|
|
41
|
-
function flatTextFromContentNode(
|
|
40
|
+
function flatTextFromContentNode(
|
|
41
|
+
node: QtiContentNode,
|
|
42
|
+
options: FlatTextFromContentOptions,
|
|
43
|
+
): string {
|
|
44
|
+
const excludeAnnotations = options.excludeAnnotations ?? false;
|
|
42
45
|
switch (node.kind) {
|
|
43
46
|
case "text":
|
|
44
47
|
return node.text;
|
|
@@ -54,14 +57,26 @@ function flatTextFromContentNode(node: QtiContentNode, excludeAnnotations: boole
|
|
|
54
57
|
if (node.qtiName === "object" && node.attributes["object-label"]) {
|
|
55
58
|
return node.attributes["object-label"];
|
|
56
59
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
+
if (node.qtiName === "math") {
|
|
61
|
+
return withVisibleBoundary(
|
|
62
|
+
node.qtiName,
|
|
63
|
+
node.children
|
|
64
|
+
.map((child) => flatMathText(child, excludeAnnotations))
|
|
65
|
+
.filter(Boolean)
|
|
66
|
+
.join(" "),
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
return withVisibleBoundary(
|
|
70
|
+
node.qtiName,
|
|
71
|
+
node.children.map((child) => flatTextFromContentNode(child, options)).join(""),
|
|
72
|
+
);
|
|
60
73
|
case "feedback":
|
|
61
|
-
return
|
|
62
|
-
.
|
|
63
|
-
.join("
|
|
74
|
+
return withVisibleBoundary(
|
|
75
|
+
node.feedbackType === "block" ? "qti-feedback-block" : "qti-feedback-inline",
|
|
76
|
+
node.children.map((child) => flatTextFromContentNode(child, options)).join(""),
|
|
77
|
+
);
|
|
64
78
|
case "interaction":
|
|
79
|
+
return options.interactionText?.(node.interactionIndex) ?? "";
|
|
65
80
|
case "printedVariable":
|
|
66
81
|
return "";
|
|
67
82
|
default:
|
|
@@ -69,6 +84,115 @@ function flatTextFromContentNode(node: QtiContentNode, excludeAnnotations: boole
|
|
|
69
84
|
}
|
|
70
85
|
}
|
|
71
86
|
|
|
87
|
+
function flatMathText(node: QtiContentNode, excludeAnnotations: boolean): string {
|
|
88
|
+
if (
|
|
89
|
+
node.kind === "element" &&
|
|
90
|
+
excludeAnnotations &&
|
|
91
|
+
(node.qtiName === "annotation" || node.qtiName === "annotation-xml")
|
|
92
|
+
) {
|
|
93
|
+
return "";
|
|
94
|
+
}
|
|
95
|
+
if (node.kind === "text") return node.text;
|
|
96
|
+
if (node.kind === "element" || node.kind === "feedback") {
|
|
97
|
+
return node.children
|
|
98
|
+
.map((child) => flatMathText(child, excludeAnnotations))
|
|
99
|
+
.filter(Boolean)
|
|
100
|
+
.join(" ");
|
|
101
|
+
}
|
|
102
|
+
return "";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Flatten rendered XML text without inventing separators at inline element boundaries. */
|
|
106
|
+
export function visibleTextContent(node: XmlNode): string {
|
|
107
|
+
return normalizeFlatText(visibleTextFromXmlNode(node));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function visibleTextFromXmlNode(node: XmlNode): string {
|
|
111
|
+
const accessibleLabel = accessibleXmlLabel(node);
|
|
112
|
+
const text =
|
|
113
|
+
accessibleLabel ??
|
|
114
|
+
(node.localName === "math"
|
|
115
|
+
? flatMathXmlText(node)
|
|
116
|
+
: node.content
|
|
117
|
+
.map((entry) => (typeof entry === "string" ? entry : visibleTextFromXmlNode(entry)))
|
|
118
|
+
.join(""));
|
|
119
|
+
return withVisibleBoundary(node.localName, text);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function flatMathXmlText(node: XmlNode): string {
|
|
123
|
+
return node.content
|
|
124
|
+
.map((entry) =>
|
|
125
|
+
typeof entry === "string"
|
|
126
|
+
? entry
|
|
127
|
+
: entry.localName === "annotation" || entry.localName === "annotation-xml"
|
|
128
|
+
? ""
|
|
129
|
+
: flatMathXmlText(entry),
|
|
130
|
+
)
|
|
131
|
+
.filter(Boolean)
|
|
132
|
+
.join(" ");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function accessibleXmlLabel(node: XmlNode): string | undefined {
|
|
136
|
+
if (node.localName === "math" && node.attributes.alttext) return node.attributes.alttext;
|
|
137
|
+
if (node.localName === "img" && node.attributes.alt) return node.attributes.alt;
|
|
138
|
+
if (node.localName === "object" && node.attributes["object-label"]) {
|
|
139
|
+
return node.attributes["object-label"];
|
|
140
|
+
}
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function withVisibleBoundary(qtiName: string, text: string): string {
|
|
145
|
+
return visibleBoundaryNames.has(qtiName) ? ` ${text} ` : text;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const visibleBoundaryNames = new Set([
|
|
149
|
+
"address",
|
|
150
|
+
"article",
|
|
151
|
+
"aside",
|
|
152
|
+
"blockquote",
|
|
153
|
+
"br",
|
|
154
|
+
"dd",
|
|
155
|
+
"div",
|
|
156
|
+
"dl",
|
|
157
|
+
"dt",
|
|
158
|
+
"fieldset",
|
|
159
|
+
"figcaption",
|
|
160
|
+
"figure",
|
|
161
|
+
"footer",
|
|
162
|
+
"form",
|
|
163
|
+
"h1",
|
|
164
|
+
"h2",
|
|
165
|
+
"h3",
|
|
166
|
+
"h4",
|
|
167
|
+
"h5",
|
|
168
|
+
"h6",
|
|
169
|
+
"header",
|
|
170
|
+
"hgroup",
|
|
171
|
+
"hr",
|
|
172
|
+
"li",
|
|
173
|
+
"main",
|
|
174
|
+
"math",
|
|
175
|
+
"nav",
|
|
176
|
+
"ol",
|
|
177
|
+
"p",
|
|
178
|
+
"pre",
|
|
179
|
+
"qti-feedback-block",
|
|
180
|
+
"qti-gap-img",
|
|
181
|
+
"qti-gap-text",
|
|
182
|
+
"qti-inline-choice",
|
|
183
|
+
"qti-simple-associable-choice",
|
|
184
|
+
"qti-simple-choice",
|
|
185
|
+
"section",
|
|
186
|
+
"table",
|
|
187
|
+
"tbody",
|
|
188
|
+
"td",
|
|
189
|
+
"tfoot",
|
|
190
|
+
"th",
|
|
191
|
+
"thead",
|
|
192
|
+
"tr",
|
|
193
|
+
"ul",
|
|
194
|
+
]);
|
|
195
|
+
|
|
72
196
|
export function choiceAccessibleLabel(
|
|
73
197
|
choice: QtiChoice | undefined,
|
|
74
198
|
fallbackIdentifier?: string,
|
package/src/index.ts
CHANGED
|
@@ -85,7 +85,32 @@ export {
|
|
|
85
85
|
export { detectPackageMediaType } from "./qti-package-assets.js";
|
|
86
86
|
export { parseQtiXml } from "./parser.js";
|
|
87
87
|
export { numericTuple3, numericTuple4, parseXmlBoolean } from "./parser-values.js";
|
|
88
|
+
export { escapeXmlAttribute, escapeXmlText } from "./xml.js";
|
|
89
|
+
export {
|
|
90
|
+
applyQtiMediaPlaybackEvent,
|
|
91
|
+
parseQtiMediaDefinition,
|
|
92
|
+
QTI_MEDIA_RESTART_THRESHOLD_SECONDS,
|
|
93
|
+
type QtiMediaDefinition,
|
|
94
|
+
type QtiMediaDefinitionResult,
|
|
95
|
+
type QtiMediaPlaybackEvent,
|
|
96
|
+
type QtiMediaPlaybackResult,
|
|
97
|
+
type QtiMediaPlaySession,
|
|
98
|
+
} from "./media-definition.js";
|
|
99
|
+
export {
|
|
100
|
+
parseQtiSliderDefinition,
|
|
101
|
+
parseQtiSliderValue,
|
|
102
|
+
qtiSliderDiscreteValue,
|
|
103
|
+
qtiSliderRatio,
|
|
104
|
+
snapQtiSliderValue,
|
|
105
|
+
type QtiSliderDefinition,
|
|
106
|
+
type QtiSliderDefinitionResult,
|
|
107
|
+
type QtiSliderOrientation,
|
|
108
|
+
type QtiSliderStep,
|
|
109
|
+
type QtiSliderValueFailureReason,
|
|
110
|
+
type QtiSliderValueResult,
|
|
111
|
+
} from "./slider-definition.js";
|
|
88
112
|
export { collectQtiResponseProcessingExpressions } from "./processing-expression-collection.js";
|
|
113
|
+
export { MAX_QTI_REPEAT_RESULT_ELEMENTS } from "./processing-limits.js";
|
|
89
114
|
export { isConformanceParseDiagnostic } from "./parse-diagnostics.js";
|
|
90
115
|
export {
|
|
91
116
|
scoreQtiItemServerSide,
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { assertNever } from "./assert-never.js";
|
|
2
|
+
import { parseXmlBoolean } from "./parser-values.js";
|
|
3
|
+
import type { QtiDiagnostic, QtiInteraction } from "./types.js";
|
|
4
|
+
import { isNonNegativeInteger } from "./validation-primitives.js";
|
|
5
|
+
|
|
6
|
+
/** A validated media play-count domain and native playback policy. */
|
|
7
|
+
export interface QtiMediaDefinition {
|
|
8
|
+
readonly minPlays: number;
|
|
9
|
+
readonly maxPlays: number | undefined;
|
|
10
|
+
readonly autostart: boolean;
|
|
11
|
+
readonly nativeLoop: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The typed result of refining raw media interaction attributes. */
|
|
15
|
+
export type QtiMediaDefinitionResult =
|
|
16
|
+
| { readonly ok: true; readonly value: QtiMediaDefinition }
|
|
17
|
+
| { readonly ok: false; readonly diagnostics: readonly QtiDiagnostic[] };
|
|
18
|
+
|
|
19
|
+
/** Seconds from the start within which a play event counts as a new play experience. */
|
|
20
|
+
export const QTI_MEDIA_RESTART_THRESHOLD_SECONDS = 0.25;
|
|
21
|
+
|
|
22
|
+
/** The play-experience session used to decide whether a native media event counts. */
|
|
23
|
+
export type QtiMediaPlaySession =
|
|
24
|
+
| { readonly status: "idle"; readonly playCount: number }
|
|
25
|
+
| { readonly status: "playing"; readonly playCount: number }
|
|
26
|
+
| { readonly status: "ended"; readonly playCount: number };
|
|
27
|
+
|
|
28
|
+
/** A native media event projected into the QTI play-count machine. */
|
|
29
|
+
export type QtiMediaPlaybackEvent =
|
|
30
|
+
| { readonly kind: "play"; readonly currentTime: number }
|
|
31
|
+
| { readonly kind: "ended" }
|
|
32
|
+
| { readonly kind: "seeked"; readonly currentTime: number; readonly paused: boolean };
|
|
33
|
+
|
|
34
|
+
/** The session update and any player-side block from one playback event. */
|
|
35
|
+
export type QtiMediaPlaybackResult =
|
|
36
|
+
| { readonly kind: "applied"; readonly session: QtiMediaPlaySession }
|
|
37
|
+
| { readonly kind: "blocked"; readonly session: QtiMediaPlaySession };
|
|
38
|
+
|
|
39
|
+
function diagnostic(interaction: QtiInteraction, code: string, message: string): QtiDiagnostic {
|
|
40
|
+
return {
|
|
41
|
+
code,
|
|
42
|
+
severity: "error",
|
|
43
|
+
message,
|
|
44
|
+
path: interaction.source?.path,
|
|
45
|
+
source: interaction.source,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function optionalBoolean(
|
|
50
|
+
interaction: QtiInteraction,
|
|
51
|
+
attribute: "autostart" | "loop",
|
|
52
|
+
diagnostics: QtiDiagnostic[],
|
|
53
|
+
): boolean {
|
|
54
|
+
const raw = interaction.attributes[attribute];
|
|
55
|
+
if (raw === undefined) return false;
|
|
56
|
+
const value = parseXmlBoolean(raw);
|
|
57
|
+
if (value !== undefined) return value;
|
|
58
|
+
diagnostics.push(
|
|
59
|
+
diagnostic(
|
|
60
|
+
interaction,
|
|
61
|
+
"interaction.booleanAttribute",
|
|
62
|
+
`${interaction.qtiName} requires boolean ${attribute}, got ${raw}.`,
|
|
63
|
+
),
|
|
64
|
+
);
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function optionalNonNegativeInteger(
|
|
69
|
+
interaction: QtiInteraction,
|
|
70
|
+
attribute: "min-plays" | "max-plays",
|
|
71
|
+
diagnostics: QtiDiagnostic[],
|
|
72
|
+
): number | undefined {
|
|
73
|
+
const raw = interaction.attributes[attribute];
|
|
74
|
+
if (raw === undefined) return undefined;
|
|
75
|
+
if (!isNonNegativeInteger(raw)) {
|
|
76
|
+
diagnostics.push(
|
|
77
|
+
diagnostic(
|
|
78
|
+
interaction,
|
|
79
|
+
"interaction.integerAttribute",
|
|
80
|
+
`${interaction.qtiName} requires non-negative integer ${attribute}, got ${raw}.`,
|
|
81
|
+
),
|
|
82
|
+
);
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
return Number(raw);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function atMaximumPlays(session: QtiMediaPlaySession, maxPlays: number | undefined): boolean {
|
|
89
|
+
return maxPlays !== undefined && session.playCount >= maxPlays;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Refines raw QTI media attributes into the play-count domain shared by validation and players.
|
|
94
|
+
*/
|
|
95
|
+
export function parseQtiMediaDefinition(interaction: QtiInteraction): QtiMediaDefinitionResult {
|
|
96
|
+
const diagnostics: QtiDiagnostic[] = [];
|
|
97
|
+
const minPlaysAttribute = optionalNonNegativeInteger(interaction, "min-plays", diagnostics);
|
|
98
|
+
const maxPlaysAttribute = optionalNonNegativeInteger(interaction, "max-plays", diagnostics);
|
|
99
|
+
const autostart = optionalBoolean(interaction, "autostart", diagnostics);
|
|
100
|
+
const loop = optionalBoolean(interaction, "loop", diagnostics);
|
|
101
|
+
const required = parseXmlBoolean(interaction.attributes.required) === true;
|
|
102
|
+
const maxPlays =
|
|
103
|
+
maxPlaysAttribute === undefined || maxPlaysAttribute <= 0 ? undefined : maxPlaysAttribute;
|
|
104
|
+
|
|
105
|
+
if (minPlaysAttribute !== undefined && maxPlays !== undefined && minPlaysAttribute > maxPlays) {
|
|
106
|
+
diagnostics.push(
|
|
107
|
+
diagnostic(
|
|
108
|
+
interaction,
|
|
109
|
+
"interaction.minMax",
|
|
110
|
+
`${interaction.qtiName} requires min-plays to be less than or equal to max-plays, unless max-plays is 0 for unlimited.`,
|
|
111
|
+
),
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (diagnostics.length > 0) return { ok: false, diagnostics };
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
ok: true,
|
|
119
|
+
value: {
|
|
120
|
+
minPlays: minPlaysAttribute ?? (required ? 1 : 0),
|
|
121
|
+
maxPlays,
|
|
122
|
+
autostart,
|
|
123
|
+
nativeLoop: loop && maxPlays === undefined,
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function applyPlayEvent(
|
|
129
|
+
session: QtiMediaPlaySession,
|
|
130
|
+
event: Extract<QtiMediaPlaybackEvent, { kind: "play" }>,
|
|
131
|
+
maxPlays: number | undefined,
|
|
132
|
+
): QtiMediaPlaybackResult {
|
|
133
|
+
if (session.status !== "playing" && atMaximumPlays(session, maxPlays)) {
|
|
134
|
+
return { kind: "blocked", session };
|
|
135
|
+
}
|
|
136
|
+
const counts =
|
|
137
|
+
session.status === "ended" ||
|
|
138
|
+
(session.status === "idle" && event.currentTime <= QTI_MEDIA_RESTART_THRESHOLD_SECONDS);
|
|
139
|
+
return {
|
|
140
|
+
kind: "applied",
|
|
141
|
+
session: {
|
|
142
|
+
status: "playing",
|
|
143
|
+
playCount: session.playCount + (counts ? 1 : 0),
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function applySeekedEvent(
|
|
149
|
+
session: QtiMediaPlaySession,
|
|
150
|
+
event: Extract<QtiMediaPlaybackEvent, { kind: "seeked" }>,
|
|
151
|
+
): QtiMediaPlaybackResult {
|
|
152
|
+
if (!event.paused || event.currentTime > QTI_MEDIA_RESTART_THRESHOLD_SECONDS) {
|
|
153
|
+
return { kind: "applied", session };
|
|
154
|
+
}
|
|
155
|
+
return { kind: "applied", session: { status: "idle", playCount: session.playCount } };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Applies one native media event to the QTI play-experience session. */
|
|
159
|
+
export function applyQtiMediaPlaybackEvent(
|
|
160
|
+
session: QtiMediaPlaySession,
|
|
161
|
+
event: QtiMediaPlaybackEvent,
|
|
162
|
+
maxPlays: number | undefined,
|
|
163
|
+
): QtiMediaPlaybackResult {
|
|
164
|
+
switch (event.kind) {
|
|
165
|
+
case "play":
|
|
166
|
+
return applyPlayEvent(session, event, maxPlays);
|
|
167
|
+
case "ended":
|
|
168
|
+
return { kind: "applied", session: { status: "ended", playCount: session.playCount } };
|
|
169
|
+
case "seeked":
|
|
170
|
+
return applySeekedEvent(session, event);
|
|
171
|
+
default:
|
|
172
|
+
return assertNever(event);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -4,11 +4,13 @@ import {
|
|
|
4
4
|
parseBaseType,
|
|
5
5
|
parseCardinality,
|
|
6
6
|
parseCoords,
|
|
7
|
+
parseFiniteNumber,
|
|
7
8
|
parseShape,
|
|
8
9
|
parseXmlBoolean,
|
|
9
10
|
} from "./parser-values.js";
|
|
10
11
|
import type {
|
|
11
12
|
QtiBaseType,
|
|
13
|
+
QtiDiagnostic,
|
|
12
14
|
QtiLookupTable,
|
|
13
15
|
QtiOutcomeDeclaration,
|
|
14
16
|
QtiResponseDeclaration,
|
|
@@ -16,9 +18,17 @@ import type {
|
|
|
16
18
|
QtiTemplateDeclaration,
|
|
17
19
|
QtiValue,
|
|
18
20
|
} from "./types.js";
|
|
21
|
+
import {
|
|
22
|
+
validateAreaMapEntryAttributes,
|
|
23
|
+
validateLookupTableEntryAttributes,
|
|
24
|
+
validateMapEntryAttributes,
|
|
25
|
+
} from "./validation-declaration-entries.js";
|
|
19
26
|
import { childElements, textContent, type XmlNode } from "./xml.js";
|
|
20
27
|
|
|
21
|
-
export function parseResponseDeclaration(
|
|
28
|
+
export function parseResponseDeclaration(
|
|
29
|
+
node: XmlNode,
|
|
30
|
+
diagnostics: QtiDiagnostic[],
|
|
31
|
+
): QtiResponseDeclaration {
|
|
22
32
|
const cardinality = parseCardinality(node.attributes.cardinality);
|
|
23
33
|
const baseType = parseBaseType(node.attributes["base-type"]);
|
|
24
34
|
return {
|
|
@@ -26,40 +36,66 @@ export function parseResponseDeclaration(node: XmlNode): QtiResponseDeclaration
|
|
|
26
36
|
identifier: node.attributes.identifier ?? "",
|
|
27
37
|
cardinality,
|
|
28
38
|
baseType,
|
|
29
|
-
defaultValue: parseVariableValue(
|
|
39
|
+
defaultValue: parseVariableValue(
|
|
40
|
+
childElements(node, "qti-default-value")[0],
|
|
41
|
+
baseType,
|
|
42
|
+
diagnostics,
|
|
43
|
+
),
|
|
30
44
|
correctResponse: normalizeValueForCardinality(
|
|
31
|
-
parseVariableValue(childElements(node, "qti-correct-response")[0], baseType),
|
|
45
|
+
parseVariableValue(childElements(node, "qti-correct-response")[0], baseType, diagnostics),
|
|
32
46
|
cardinality,
|
|
33
47
|
),
|
|
34
|
-
mapping: parseMapping(
|
|
35
|
-
|
|
48
|
+
mapping: parseMapping(
|
|
49
|
+
childElements(node, "qti-mapping")[0],
|
|
50
|
+
node.attributes.identifier ?? "",
|
|
51
|
+
diagnostics,
|
|
52
|
+
),
|
|
53
|
+
areaMapping: parseAreaMapping(
|
|
54
|
+
childElements(node, "qti-area-mapping")[0],
|
|
55
|
+
node.attributes.identifier ?? "",
|
|
56
|
+
diagnostics,
|
|
57
|
+
),
|
|
36
58
|
attributes: node.attributes,
|
|
37
59
|
source: node.source,
|
|
38
60
|
};
|
|
39
61
|
}
|
|
40
62
|
|
|
41
|
-
export function parseOutcomeDeclaration(
|
|
63
|
+
export function parseOutcomeDeclaration(
|
|
64
|
+
node: XmlNode,
|
|
65
|
+
diagnostics: QtiDiagnostic[],
|
|
66
|
+
): QtiOutcomeDeclaration {
|
|
42
67
|
const baseType = parseBaseType(node.attributes["base-type"]);
|
|
43
68
|
return {
|
|
44
69
|
kind: "outcome",
|
|
45
70
|
identifier: node.attributes.identifier ?? "",
|
|
46
71
|
cardinality: parseCardinality(node.attributes.cardinality),
|
|
47
72
|
baseType,
|
|
48
|
-
defaultValue: parseVariableValue(
|
|
49
|
-
|
|
73
|
+
defaultValue: parseVariableValue(
|
|
74
|
+
childElements(node, "qti-default-value")[0],
|
|
75
|
+
baseType,
|
|
76
|
+
diagnostics,
|
|
77
|
+
),
|
|
78
|
+
lookupTable: parseLookupTable(node, baseType, diagnostics),
|
|
50
79
|
attributes: node.attributes,
|
|
51
80
|
source: node.source,
|
|
52
81
|
};
|
|
53
82
|
}
|
|
54
83
|
|
|
55
|
-
export function parseTemplateDeclaration(
|
|
84
|
+
export function parseTemplateDeclaration(
|
|
85
|
+
node: XmlNode,
|
|
86
|
+
diagnostics: QtiDiagnostic[],
|
|
87
|
+
): QtiTemplateDeclaration {
|
|
56
88
|
const baseType = parseBaseType(node.attributes["base-type"]);
|
|
57
89
|
return {
|
|
58
90
|
kind: "template",
|
|
59
91
|
identifier: node.attributes.identifier ?? "",
|
|
60
92
|
cardinality: parseCardinality(node.attributes.cardinality),
|
|
61
93
|
baseType,
|
|
62
|
-
defaultValue: parseVariableValue(
|
|
94
|
+
defaultValue: parseVariableValue(
|
|
95
|
+
childElements(node, "qti-default-value")[0],
|
|
96
|
+
baseType,
|
|
97
|
+
diagnostics,
|
|
98
|
+
),
|
|
63
99
|
attributes: node.attributes,
|
|
64
100
|
source: node.source,
|
|
65
101
|
};
|
|
@@ -68,6 +104,7 @@ export function parseTemplateDeclaration(node: XmlNode): QtiTemplateDeclaration
|
|
|
68
104
|
function parseVariableValue(
|
|
69
105
|
node: XmlNode | undefined,
|
|
70
106
|
baseType: QtiBaseType | undefined,
|
|
107
|
+
diagnostics: QtiDiagnostic[],
|
|
71
108
|
): QtiValue {
|
|
72
109
|
if (!node) return null;
|
|
73
110
|
const valueNodes = childElements(node, "qti-value");
|
|
@@ -79,6 +116,16 @@ function parseVariableValue(
|
|
|
79
116
|
(entry): entry is { fieldIdentifier: string; value: QtiScalarValue } =>
|
|
80
117
|
Boolean(entry.fieldIdentifier),
|
|
81
118
|
);
|
|
119
|
+
const fieldedEntryCount = entries.filter((entry) => entry.fieldIdentifier !== undefined).length;
|
|
120
|
+
if (fieldedEntryCount > 0 && fieldedEntryCount < entries.length) {
|
|
121
|
+
diagnostics.push({
|
|
122
|
+
code: "declaration.value.fieldIdentifier.mixed",
|
|
123
|
+
severity: "error",
|
|
124
|
+
message: `${node.localName} must not mix qti-value children with and without field-identifier.`,
|
|
125
|
+
path: node.source.path,
|
|
126
|
+
source: node.source,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
82
129
|
if (recordEntries.length > 0) {
|
|
83
130
|
return Object.fromEntries(recordEntries.map((entry) => [entry.fieldIdentifier, entry.value]));
|
|
84
131
|
}
|
|
@@ -90,87 +137,134 @@ function parseVariableValue(
|
|
|
90
137
|
return entries.map((entry) => entry.value);
|
|
91
138
|
}
|
|
92
139
|
|
|
93
|
-
function parseMapping(
|
|
140
|
+
function parseMapping(
|
|
141
|
+
node: XmlNode | undefined,
|
|
142
|
+
declarationIdentifier: string,
|
|
143
|
+
diagnostics: QtiDiagnostic[],
|
|
144
|
+
): QtiResponseDeclaration["mapping"] | undefined {
|
|
94
145
|
if (!node) return undefined;
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
146
|
+
const defaultValue = parseFiniteNumber(node.attributes["default-value"]);
|
|
147
|
+
const entries: NonNullable<QtiResponseDeclaration["mapping"]>["entries"] = [];
|
|
148
|
+
for (const entry of childElements(node, "qti-map-entry")) {
|
|
149
|
+
const mappedValue = parseFiniteNumber(entry.attributes["mapped-value"]);
|
|
150
|
+
if (mappedValue === undefined) {
|
|
151
|
+
validateMapEntryAttributes(declarationIdentifier, entry, diagnostics);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
entries.push({
|
|
100
155
|
mapKey: entry.attributes["map-key"],
|
|
101
|
-
mappedValue
|
|
156
|
+
mappedValue,
|
|
102
157
|
attributes: entry.attributes,
|
|
103
158
|
source: entry.source,
|
|
104
|
-
})
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
defaultValue: defaultValue ?? 0,
|
|
163
|
+
attributes: node.attributes,
|
|
164
|
+
source: node.source,
|
|
165
|
+
entries,
|
|
105
166
|
};
|
|
106
167
|
}
|
|
107
168
|
|
|
108
169
|
function parseAreaMapping(
|
|
109
170
|
node: XmlNode | undefined,
|
|
171
|
+
declarationIdentifier: string,
|
|
172
|
+
diagnostics: QtiDiagnostic[],
|
|
110
173
|
): QtiResponseDeclaration["areaMapping"] | undefined {
|
|
111
174
|
if (!node) return undefined;
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
175
|
+
const defaultValue = parseFiniteNumber(node.attributes["default-value"]);
|
|
176
|
+
const entries: NonNullable<QtiResponseDeclaration["areaMapping"]>["entries"] = [];
|
|
177
|
+
for (const entry of childElements(node, "qti-area-map-entry")) {
|
|
178
|
+
const mappedValue = parseFiniteNumber(entry.attributes["mapped-value"]);
|
|
179
|
+
if (mappedValue === undefined) {
|
|
180
|
+
validateAreaMapEntryAttributes(declarationIdentifier, entry, diagnostics);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
entries.push({
|
|
117
184
|
shape: parseShape(entry.attributes.shape),
|
|
118
185
|
coords: parseCoords(entry.attributes.coords),
|
|
119
|
-
mappedValue
|
|
186
|
+
mappedValue,
|
|
120
187
|
attributes: entry.attributes,
|
|
121
188
|
source: entry.source,
|
|
122
|
-
})
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
defaultValue: defaultValue ?? 0,
|
|
193
|
+
attributes: node.attributes,
|
|
194
|
+
source: node.source,
|
|
195
|
+
entries,
|
|
123
196
|
};
|
|
124
197
|
}
|
|
125
198
|
|
|
126
199
|
function parseLookupTable(
|
|
127
200
|
node: XmlNode,
|
|
128
201
|
baseType: QtiOutcomeDeclaration["baseType"],
|
|
202
|
+
diagnostics: QtiDiagnostic[],
|
|
129
203
|
): QtiLookupTable | undefined {
|
|
130
204
|
const matchTable = childElements(node, "qti-match-table")[0];
|
|
131
|
-
if (matchTable) return parseMatchTable(matchTable, baseType);
|
|
205
|
+
if (matchTable) return parseMatchTable(matchTable, baseType, diagnostics);
|
|
132
206
|
const interpolationTable = childElements(node, "qti-interpolation-table")[0];
|
|
133
|
-
if (interpolationTable) return parseInterpolationTable(interpolationTable, baseType);
|
|
207
|
+
if (interpolationTable) return parseInterpolationTable(interpolationTable, baseType, diagnostics);
|
|
134
208
|
return undefined;
|
|
135
209
|
}
|
|
136
210
|
|
|
137
211
|
function parseMatchTable(
|
|
138
212
|
node: XmlNode,
|
|
139
213
|
baseType: QtiOutcomeDeclaration["baseType"],
|
|
214
|
+
diagnostics: QtiDiagnostic[],
|
|
140
215
|
): QtiLookupTable {
|
|
141
216
|
return {
|
|
142
217
|
type: "match",
|
|
143
218
|
defaultValue: parseLookupValue(node.attributes["default-value"], baseType),
|
|
144
219
|
attributes: node.attributes,
|
|
145
220
|
source: node.source,
|
|
146
|
-
entries:
|
|
147
|
-
sourceValue: Number(entry.attributes["source-value"]),
|
|
148
|
-
targetValue: parseLookupValue(entry.attributes["target-value"], baseType),
|
|
149
|
-
attributes: entry.attributes,
|
|
150
|
-
source: entry.source,
|
|
151
|
-
})),
|
|
221
|
+
entries: parseLookupEntries(node, "match", baseType, diagnostics),
|
|
152
222
|
};
|
|
153
223
|
}
|
|
154
224
|
|
|
155
225
|
function parseInterpolationTable(
|
|
156
226
|
node: XmlNode,
|
|
157
227
|
baseType: QtiOutcomeDeclaration["baseType"],
|
|
228
|
+
diagnostics: QtiDiagnostic[],
|
|
158
229
|
): QtiLookupTable {
|
|
159
230
|
return {
|
|
160
231
|
type: "interpolation",
|
|
161
232
|
defaultValue: parseLookupValue(node.attributes["default-value"], baseType),
|
|
162
233
|
attributes: node.attributes,
|
|
163
234
|
source: node.source,
|
|
164
|
-
entries:
|
|
165
|
-
sourceValue: Number(entry.attributes["source-value"]),
|
|
166
|
-
targetValue: parseLookupValue(entry.attributes["target-value"], baseType),
|
|
167
|
-
includeBoundary: parseXmlBoolean(entry.attributes["include-boundary"]) ?? true,
|
|
168
|
-
attributes: entry.attributes,
|
|
169
|
-
source: entry.source,
|
|
170
|
-
})),
|
|
235
|
+
entries: parseLookupEntries(node, "interpolation", baseType, diagnostics),
|
|
171
236
|
};
|
|
172
237
|
}
|
|
173
238
|
|
|
174
239
|
function parseLookupValue(value: string | undefined, baseType: string | undefined): QtiValue {
|
|
175
240
|
return value === undefined ? null : coerceValue(value, baseType);
|
|
176
241
|
}
|
|
242
|
+
|
|
243
|
+
function parseLookupEntries(
|
|
244
|
+
node: XmlNode,
|
|
245
|
+
tableType: QtiLookupTable["type"],
|
|
246
|
+
baseType: QtiOutcomeDeclaration["baseType"],
|
|
247
|
+
diagnostics: QtiDiagnostic[],
|
|
248
|
+
): QtiLookupTable["entries"] {
|
|
249
|
+
const entryName =
|
|
250
|
+
tableType === "match" ? "qti-match-table-entry" : "qti-interpolation-table-entry";
|
|
251
|
+
const entries: QtiLookupTable["entries"] = [];
|
|
252
|
+
for (const entry of childElements(node, entryName)) {
|
|
253
|
+
const sourceValue = parseFiniteNumber(entry.attributes["source-value"]);
|
|
254
|
+
if (sourceValue === undefined) {
|
|
255
|
+
validateLookupTableEntryAttributes(tableType, entry, diagnostics);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
entries.push({
|
|
259
|
+
sourceValue,
|
|
260
|
+
targetValue: parseLookupValue(entry.attributes["target-value"], baseType),
|
|
261
|
+
includeBoundary:
|
|
262
|
+
tableType === "interpolation"
|
|
263
|
+
? (parseXmlBoolean(entry.attributes["include-boundary"]) ?? true)
|
|
264
|
+
: undefined,
|
|
265
|
+
attributes: entry.attributes,
|
|
266
|
+
source: entry.source,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
return entries;
|
|
270
|
+
}
|