@longsightgroup/qti3-core 0.2.0 → 0.3.0

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/src/types.ts CHANGED
@@ -416,7 +416,10 @@ export interface QtiAssessmentItem {
416
416
  title?: string | undefined;
417
417
  language?: string | undefined;
418
418
  adaptive: boolean;
419
+ timeDependent?: boolean | undefined;
420
+ attributes: Record<string, string>;
419
421
  prompt?: string | undefined;
422
+ itemBodySource?: QtiSourceLocation | undefined;
420
423
  responseDeclarations: QtiResponseDeclaration[];
421
424
  outcomeDeclarations: QtiOutcomeDeclaration[];
422
425
  templateDeclarations: QtiTemplateDeclaration[];
package/src/validation.ts CHANGED
@@ -21,6 +21,7 @@ import type {
21
21
  QtiValidationResult,
22
22
  } from "./types.js";
23
23
  import { validateQtiDataSsmlMetadata } from "./tts.js";
24
+ import { qtiValueToStringList } from "./value-format.js";
24
25
 
25
26
  const BUILT_IN_COMPLETION_STATUS = "completionStatus";
26
27
 
@@ -29,6 +30,8 @@ export function validateAssessmentItem(document: QtiDocument): QtiValidationResu
29
30
  const item = document.item;
30
31
 
31
32
  requireIdentifier("qti-assessment-item", item.identifier, diagnostics, item.source);
33
+ validateAssessmentItemRoot(item, diagnostics);
34
+ validateItemBody(item, diagnostics);
32
35
  validateDeclarationIdentifiers(item, diagnostics);
33
36
  validateOutcomeLookupTables(item, diagnostics);
34
37
  validateInteractions(item, diagnostics);
@@ -36,6 +39,7 @@ export function validateAssessmentItem(document: QtiDocument): QtiValidationResu
36
39
  validateCatalogInfo(item, diagnostics);
37
40
  validateStylesheets(item, diagnostics);
38
41
  diagnostics.push(...validateQtiDataSsmlMetadata(item));
42
+ validateResponseProcessingTemplate(item, diagnostics);
39
43
  validateProcessingReferences(item, diagnostics);
40
44
 
41
45
  return {
@@ -44,6 +48,152 @@ export function validateAssessmentItem(document: QtiDocument): QtiValidationResu
44
48
  };
45
49
  }
46
50
 
51
+ function validateResponseProcessingTemplate(
52
+ item: QtiAssessmentItem,
53
+ diagnostics: QtiDiagnostic[],
54
+ ): void {
55
+ const template = item.responseProcessing?.template;
56
+ if (!template) return;
57
+ const templateKind = responseProcessingTemplateKind(template);
58
+ if (templateKind) {
59
+ validateBuiltInResponseProcessingTemplate(item, templateKind, diagnostics);
60
+ return;
61
+ }
62
+ diagnostics.push({
63
+ code: "processing.template.unsupported",
64
+ severity: "error",
65
+ message: `qti-response-processing template ${template} is not currently supported.`,
66
+ path: item.source?.path,
67
+ source: item.source,
68
+ });
69
+ }
70
+
71
+ function responseProcessingTemplateKind(
72
+ template: string,
73
+ ): "matchCorrect" | "mapResponse" | "mapResponsePoint" | undefined {
74
+ const path = template.split(/[?#]/, 1)[0] ?? "";
75
+ const name = path.slice(path.lastIndexOf("/") + 1).replace(/\.xml$/i, "");
76
+ if (name === "match_correct") return "matchCorrect";
77
+ if (name === "map_response") return "mapResponse";
78
+ if (name === "map_response_point") return "mapResponsePoint";
79
+ return undefined;
80
+ }
81
+
82
+ function validateAssessmentItemRoot(item: QtiAssessmentItem, diagnostics: QtiDiagnostic[]): void {
83
+ if (!item.attributes.title?.trim()) {
84
+ diagnostics.push({
85
+ code: "assessmentItem.title.required",
86
+ severity: "error",
87
+ message: "qti-assessment-item requires a non-empty title attribute.",
88
+ path: item.source?.path,
89
+ source: item.source,
90
+ });
91
+ }
92
+ const timeDependent = item.attributes["time-dependent"];
93
+ if (timeDependent === undefined || timeDependent.trim() === "") {
94
+ diagnostics.push({
95
+ code: "assessmentItem.timeDependent.required",
96
+ severity: "error",
97
+ message: "qti-assessment-item requires a time-dependent attribute.",
98
+ path: item.source?.path,
99
+ source: item.source,
100
+ });
101
+ } else if (!isXmlBoolean(timeDependent)) {
102
+ diagnostics.push({
103
+ code: "assessmentItem.timeDependent.boolean",
104
+ severity: "error",
105
+ message: `qti-assessment-item time-dependent must be an XML boolean, found ${timeDependent}.`,
106
+ path: item.source?.path,
107
+ source: item.source,
108
+ });
109
+ }
110
+ }
111
+
112
+ function validateBuiltInResponseProcessingTemplate(
113
+ item: QtiAssessmentItem,
114
+ templateKind: "matchCorrect" | "mapResponse" | "mapResponsePoint",
115
+ diagnostics: QtiDiagnostic[],
116
+ ): void {
117
+ const response = item.responseDeclarations.find(
118
+ (declaration) => declaration.identifier === "RESPONSE",
119
+ );
120
+ const score = item.outcomeDeclarations.find((declaration) => declaration.identifier === "SCORE");
121
+ if (!response) {
122
+ diagnostics.push({
123
+ code: "processing.template.responseIdentifier",
124
+ severity: "error",
125
+ message:
126
+ "Built-in response-processing templates require a response declaration named RESPONSE.",
127
+ path: item.source?.path,
128
+ source: item.source,
129
+ });
130
+ }
131
+ if (!score) {
132
+ diagnostics.push({
133
+ code: "processing.template.scoreIdentifier",
134
+ severity: "error",
135
+ message: "Built-in response-processing templates require an outcome declaration named SCORE.",
136
+ path: item.source?.path,
137
+ source: item.source,
138
+ });
139
+ } else if (score.cardinality !== "single" || score.baseType !== "float") {
140
+ diagnostics.push({
141
+ code: "processing.template.scoreDeclaration",
142
+ severity: "error",
143
+ message:
144
+ "Built-in response-processing templates require SCORE to be single cardinality with base-type float.",
145
+ path: score.source?.path,
146
+ source: score.source,
147
+ });
148
+ }
149
+ const responseInteractions = item.interactions.filter(
150
+ (interaction) => interaction.responseIdentifier === "RESPONSE",
151
+ );
152
+ if (responseInteractions.length !== 1 || item.interactions.length !== 1) {
153
+ diagnostics.push({
154
+ code: "processing.template.singleInteraction",
155
+ severity: "error",
156
+ message:
157
+ "Built-in response-processing templates require a single interaction bound to RESPONSE.",
158
+ path: item.source?.path,
159
+ source: item.source,
160
+ });
161
+ }
162
+ if (templateKind === "mapResponse" && response && !response.mapping) {
163
+ diagnostics.push({
164
+ code: "processing.template.mapping",
165
+ severity: "error",
166
+ message: "The map_response template requires RESPONSE to define qti-mapping.",
167
+ path: response.source?.path,
168
+ source: response.source,
169
+ });
170
+ }
171
+ if (templateKind === "mapResponsePoint" && response && !response.areaMapping) {
172
+ diagnostics.push({
173
+ code: "processing.template.areaMapping",
174
+ severity: "error",
175
+ message: "The map_response_point template requires RESPONSE to define qti-area-mapping.",
176
+ path: response.source?.path,
177
+ source: response.source,
178
+ });
179
+ }
180
+ }
181
+
182
+ function isXmlBoolean(value: string): boolean {
183
+ return value === "true" || value === "false" || value === "1" || value === "0";
184
+ }
185
+
186
+ function validateItemBody(item: QtiAssessmentItem, diagnostics: QtiDiagnostic[]): void {
187
+ if (item.itemBodySource) return;
188
+ diagnostics.push({
189
+ code: "itemBody.required",
190
+ severity: "error",
191
+ message: "qti-assessment-item requires a qti-item-body.",
192
+ path: item.source?.path,
193
+ source: item.source,
194
+ });
195
+ }
196
+
47
197
  function requireIdentifier(
48
198
  elementName: string,
49
199
  identifier: string | undefined,
@@ -1599,8 +1749,7 @@ function validateCorrectResponseReferences(
1599
1749
  }
1600
1750
 
1601
1751
  function responseValues(value: QtiResponseDeclaration["correctResponse"]): string[] {
1602
- if (Array.isArray(value)) return value.map(String);
1603
- return [String(value)];
1752
+ return qtiValueToStringList(value);
1604
1753
  }
1605
1754
 
1606
1755
  function invalidCorrectResponseReference(
@@ -0,0 +1,39 @@
1
+ import type { QtiScalarValue, QtiValue } from "./types.js";
2
+
3
+ export function qtiScalarToString(value: QtiScalarValue): string {
4
+ return String(value);
5
+ }
6
+
7
+ export function qtiValueToString(value: QtiValue): string {
8
+ if (value === null) return "";
9
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
10
+ return qtiScalarToString(value);
11
+ }
12
+ if (Array.isArray(value)) return value.map(qtiScalarToString).join(" ");
13
+ return JSON.stringify(value);
14
+ }
15
+
16
+ export function qtiValueToStringList(value: QtiValue): string[] {
17
+ if (value === null) return [""];
18
+ if (Array.isArray(value)) return value.map(qtiScalarToString);
19
+ if (typeof value === "object") return [JSON.stringify(value)];
20
+ return [qtiScalarToString(value)];
21
+ }
22
+
23
+ export function qtiValueToIdentifierList(value: QtiValue): string[] {
24
+ if (value === null) return [];
25
+ if (Array.isArray(value)) return value.map(qtiScalarToString);
26
+ if (typeof value === "object") return [JSON.stringify(value)];
27
+ return [qtiScalarToString(value)];
28
+ }
29
+
30
+ export function unknownToDisplayString(value: unknown): string {
31
+ if (value === undefined || value === null) return "";
32
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
33
+ return String(value);
34
+ }
35
+ if (typeof value === "bigint" || typeof value === "symbol") return String(value);
36
+ if (Array.isArray(value)) return value.map(unknownToDisplayString).join(", ");
37
+ if (typeof value === "object") return JSON.stringify(value);
38
+ return JSON.stringify(value);
39
+ }
package/src/xml.ts CHANGED
@@ -3,6 +3,8 @@ import { StaxXmlParserSync, XmlEventType } from "stax-xml";
3
3
  export interface XmlNode {
4
4
  name: string;
5
5
  localName: string;
6
+ prefix?: string | undefined;
7
+ uri?: string | undefined;
6
8
  attributes: Record<string, string>;
7
9
  children: XmlNode[];
8
10
  content: Array<string | XmlNode>;
@@ -40,6 +42,8 @@ export function parseXmlTree(xml: string): { root: XmlNode | undefined; errors:
40
42
  const node: XmlNode = {
41
43
  name: event.name,
42
44
  localName: event.localName ?? event.name,
45
+ prefix: event.prefix,
46
+ uri: event.uri,
43
47
  attributes: event.attributes,
44
48
  children: [],
45
49
  content: [],