@longsightgroup/qti3-core 0.11.0 → 0.12.1

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.
Files changed (91) hide show
  1. package/README.md +7 -2
  2. package/dist/binary-choice.d.ts +9 -0
  3. package/dist/binary-choice.d.ts.map +1 -0
  4. package/dist/binary-choice.js +81 -0
  5. package/dist/binary-choice.js.map +1 -0
  6. package/dist/index.d.ts +16 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +10 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/qti-identifier.d.ts +3 -0
  11. package/dist/qti-identifier.d.ts.map +1 -0
  12. package/dist/qti-identifier.js +5 -0
  13. package/dist/qti-identifier.js.map +1 -0
  14. package/dist/qti-package-items.d.ts.map +1 -1
  15. package/dist/qti-package-items.js +22 -26
  16. package/dist/qti-package-items.js.map +1 -1
  17. package/dist/qti-package-paths.d.ts +2 -0
  18. package/dist/qti-package-paths.d.ts.map +1 -1
  19. package/dist/qti-package-paths.js +6 -0
  20. package/dist/qti-package-paths.js.map +1 -1
  21. package/dist/qti-package-types.d.ts +1 -0
  22. package/dist/qti-package-types.d.ts.map +1 -1
  23. package/dist/support.d.ts.map +1 -1
  24. package/dist/support.js +3 -1
  25. package/dist/support.js.map +1 -1
  26. package/dist/test-expression-parser.d.ts +6 -0
  27. package/dist/test-expression-parser.d.ts.map +1 -0
  28. package/dist/test-expression-parser.js +76 -0
  29. package/dist/test-expression-parser.js.map +1 -0
  30. package/dist/test-expression-serializer.d.ts +5 -0
  31. package/dist/test-expression-serializer.d.ts.map +1 -0
  32. package/dist/test-expression-serializer.js +54 -0
  33. package/dist/test-expression-serializer.js.map +1 -0
  34. package/dist/test-expression-validation.d.ts +14 -0
  35. package/dist/test-expression-validation.d.ts.map +1 -0
  36. package/dist/test-expression-validation.js +101 -0
  37. package/dist/test-expression-validation.js.map +1 -0
  38. package/dist/test-expression.d.ts +99 -0
  39. package/dist/test-expression.d.ts.map +1 -0
  40. package/dist/test-expression.js +28 -0
  41. package/dist/test-expression.js.map +1 -0
  42. package/dist/test-model.d.ts +62 -0
  43. package/dist/test-model.d.ts.map +1 -0
  44. package/dist/test-model.js +5 -0
  45. package/dist/test-model.js.map +1 -0
  46. package/dist/test-parser.d.ts +13 -0
  47. package/dist/test-parser.d.ts.map +1 -0
  48. package/dist/test-parser.js +144 -0
  49. package/dist/test-parser.js.map +1 -0
  50. package/dist/test-processing.d.ts +22 -0
  51. package/dist/test-processing.d.ts.map +1 -0
  52. package/dist/test-processing.js +84 -0
  53. package/dist/test-processing.js.map +1 -0
  54. package/dist/test-session.d.ts +31 -0
  55. package/dist/test-session.d.ts.map +1 -0
  56. package/dist/test-session.js +102 -0
  57. package/dist/test-session.js.map +1 -0
  58. package/dist/test-support.d.ts +4 -0
  59. package/dist/test-support.d.ts.map +1 -0
  60. package/dist/test-support.js +30 -0
  61. package/dist/test-support.js.map +1 -0
  62. package/dist/test-validation.d.ts +4 -0
  63. package/dist/test-validation.d.ts.map +1 -0
  64. package/dist/test-validation.js +133 -0
  65. package/dist/test-validation.js.map +1 -0
  66. package/dist/test-xml.d.ts +7 -0
  67. package/dist/test-xml.d.ts.map +1 -0
  68. package/dist/test-xml.js +23 -0
  69. package/dist/test-xml.js.map +1 -0
  70. package/dist/types.d.ts +5 -1
  71. package/dist/types.d.ts.map +1 -1
  72. package/package.json +1 -1
  73. package/src/binary-choice.ts +96 -0
  74. package/src/index.ts +29 -0
  75. package/src/qti-identifier.ts +4 -0
  76. package/src/qti-package-items.ts +28 -37
  77. package/src/qti-package-paths.ts +9 -0
  78. package/src/qti-package-types.ts +1 -0
  79. package/src/support.ts +3 -1
  80. package/src/test-expression-parser.ts +89 -0
  81. package/src/test-expression-serializer.ts +71 -0
  82. package/src/test-expression-validation.ts +139 -0
  83. package/src/test-expression.ts +66 -0
  84. package/src/test-model.ts +68 -0
  85. package/src/test-parser.ts +201 -0
  86. package/src/test-processing.ts +107 -0
  87. package/src/test-session.ts +160 -0
  88. package/src/test-support.ts +31 -0
  89. package/src/test-validation.ts +190 -0
  90. package/src/test-xml.ts +37 -0
  91. package/src/types.ts +6 -0
@@ -0,0 +1,160 @@
1
+ import {
2
+ evaluateTestExpression,
3
+ processTestOutcomes,
4
+ type QtiTestSubmission,
5
+ } from "./test-processing.js";
6
+ import {
7
+ testFailure,
8
+ type QtiExecutableTest,
9
+ type QtiTestItemRef,
10
+ type QtiTestResult,
11
+ } from "./test-model.js";
12
+ import type { QtiValue } from "./types.js";
13
+
14
+ interface TestSessionBase {
15
+ readonly testIdentifier: string;
16
+ readonly submissions: readonly QtiTestSubmission[];
17
+ readonly outcomes: Readonly<Record<string, QtiValue>>;
18
+ }
19
+
20
+ /** Live route state. Only an active session has a current item. */
21
+ export type QtiTestSession = TestSessionBase &
22
+ (
23
+ | { readonly status: "active"; readonly currentItemRef: string }
24
+ | { readonly status: "completed" }
25
+ );
26
+
27
+ /** Stable JSON contract: computed frontier/outcomes are reconstructed, never trusted. */
28
+ export interface QtiTestSessionSnapshot {
29
+ readonly version: 1;
30
+ readonly testIdentifier: string;
31
+ readonly submissions: readonly QtiTestSubmission[];
32
+ }
33
+
34
+ /** Start a validated test at its first item. */
35
+ export function startQtiTest(test: QtiExecutableTest): QtiTestSession {
36
+ const first = test.sections[0].items[0];
37
+ return {
38
+ status: "active",
39
+ testIdentifier: test.identifier,
40
+ currentItemRef: first.identifier,
41
+ submissions: [],
42
+ outcomes: processTestOutcomes(test, []).outcomes,
43
+ };
44
+ }
45
+
46
+ /** Accept one trusted scored answer at the frontier; does not mutate input state. */
47
+ export function submitQtiTestAnswer(
48
+ test: QtiExecutableTest,
49
+ session: QtiTestSession,
50
+ submission: QtiTestSubmission,
51
+ ): QtiTestResult<QtiTestSession> {
52
+ if (session.testIdentifier !== test.identifier)
53
+ return testFailure("session.test", "Session belongs to another test.");
54
+ if (session.status !== "active" || session.currentItemRef !== submission.itemRef)
55
+ return testFailure("session.frontier", "Only the current unanswered item can be submitted.");
56
+ if (!Number.isFinite(submission.score))
57
+ return testFailure("session.score", "Item score must be a finite number.");
58
+ const sectionIndex = test.sections.findIndex((s) =>
59
+ s.items.some((i) => i.identifier === submission.itemRef),
60
+ );
61
+ const section = test.sections[sectionIndex];
62
+ if (!section) return testFailure("session.item", "Current item does not belong to the test.");
63
+ const submissions = [
64
+ ...session.submissions,
65
+ { itemRef: submission.itemRef, score: submission.score },
66
+ ];
67
+ const { outcomes } = processTestOutcomes(test, submissions);
68
+ const base = { testIdentifier: test.identifier, submissions, outcomes };
69
+ const next = nextItemAfter(
70
+ test,
71
+ sectionIndex,
72
+ submission.itemRef,
73
+ (expression) =>
74
+ evaluateTestExpression(expression, {
75
+ outcomes,
76
+ submissions,
77
+ categories: new Map(
78
+ test.sections.flatMap((candidate) =>
79
+ candidate.items.map((item) => [item.identifier, item.categories] as const),
80
+ ),
81
+ ),
82
+ }) === true,
83
+ );
84
+ return {
85
+ ok: true,
86
+ value: next
87
+ ? { ...base, status: "active", currentItemRef: next.identifier }
88
+ : { ...base, status: "completed" },
89
+ };
90
+ }
91
+
92
+ function nextItemAfter(
93
+ test: QtiExecutableTest,
94
+ sectionIndex: number,
95
+ itemRef: string,
96
+ evaluate: (expression: import("./test-expression.js").QtiTestExpression) => boolean,
97
+ ): QtiTestItemRef | undefined {
98
+ const section = test.sections[sectionIndex];
99
+ if (!section) return undefined;
100
+ const nextItem =
101
+ section.items[section.items.findIndex((item) => item.identifier === itemRef) + 1];
102
+ if (nextItem) return nextItem;
103
+ const branch = section.branches.find((candidate) => evaluate(candidate.expression));
104
+ if (branch?.target === "EXIT_TEST") return undefined;
105
+ const nextSection = branch
106
+ ? test.sections.find((candidate) => candidate.identifier === branch.target)
107
+ : test.sections[sectionIndex + 1];
108
+ return nextSection?.items[0];
109
+ }
110
+
111
+ /** Project session state into its minimal persisted contract. */
112
+ export function snapshotQtiTestSession(session: QtiTestSession): QtiTestSessionSnapshot {
113
+ return {
114
+ version: 1,
115
+ testIdentifier: session.testIdentifier,
116
+ submissions: session.submissions.map((s) => ({ itemRef: s.itemRef, score: s.score })),
117
+ };
118
+ }
119
+
120
+ /** Parse persisted state and replay it against the pinned test to reject impossible routes. */
121
+ export function restoreQtiTestSession(
122
+ test: QtiExecutableTest,
123
+ input: unknown,
124
+ ): QtiTestResult<QtiTestSession> {
125
+ if (
126
+ typeof input !== "object" ||
127
+ input === null ||
128
+ !("version" in input) ||
129
+ input.version !== 1 ||
130
+ !("testIdentifier" in input) ||
131
+ input.testIdentifier !== test.identifier ||
132
+ !("submissions" in input) ||
133
+ !Array.isArray(input.submissions) ||
134
+ Object.keys(input).some((k) => !["version", "testIdentifier", "submissions"].includes(k))
135
+ )
136
+ return testFailure("session.snapshot", "Invalid test session snapshot.");
137
+ if (input.submissions.length > test.sections.reduce((n, s) => n + s.items.length, 0))
138
+ return testFailure("session.length", "Session exceeds test inventory.");
139
+ let session = startQtiTest(test);
140
+ for (const raw of input.submissions) {
141
+ const value: unknown = raw;
142
+ if (
143
+ typeof value !== "object" ||
144
+ value === null ||
145
+ !("itemRef" in value) ||
146
+ typeof value.itemRef !== "string" ||
147
+ !("score" in value) ||
148
+ typeof value.score !== "number" ||
149
+ Object.keys(value).some((k) => k !== "itemRef" && k !== "score")
150
+ )
151
+ return testFailure("session.submission", "Invalid saved test submission.");
152
+ const result = submitQtiTestAnswer(test, session, {
153
+ itemRef: value.itemRef,
154
+ score: value.score,
155
+ });
156
+ if (!result.ok) return result;
157
+ session = result.value;
158
+ }
159
+ return { ok: true, value: session };
160
+ }
@@ -0,0 +1,31 @@
1
+ import { testExpressionSyntax } from "./test-expression.js";
2
+ import type { QtiTestElementSupport } from "./types.js";
3
+
4
+ /** Explicit scope and evidence for the finite forward-branching execution profile. */
5
+ export const testExecutionSupport: readonly QtiTestElementSupport[] = [
6
+ "qti-assessment-test",
7
+ "qti-test-part",
8
+ "qti-assessment-section",
9
+ "qti-assessment-item-ref",
10
+ "qti-branch-rule",
11
+ "qti-outcome-processing",
12
+ "qti-test-variables",
13
+ ].map((qtiName) => ({
14
+ qtiName,
15
+ category: "test",
16
+ support: "supported",
17
+ specReference: "QTI 3.0.1 ASI test sequencing and outcome processing",
18
+ parse: true,
19
+ validate: true,
20
+ render: false,
21
+ process: true,
22
+ fixtures: ["tests/fixtures/staged-assessment-test.xml"],
23
+ tests: [
24
+ "packages/core/src/test-session.test.ts",
25
+ "packages/core/src/test-validation.test.ts",
26
+ "packages/core/src/test-language.test.ts",
27
+ "packages/writer/src/assessment-test.test.ts",
28
+ "packages/conformance/src/staged-assessment.test.ts",
29
+ ],
30
+ notes: `parseQtiTest / startQtiTest / submitQtiTestAnswer only: one linear, individually submitted part; flat fixed sections; forward section branches and EXIT_TEST; scalar test outcomes, SCORE aggregation by one category, expressions: ${testExpressionSyntax.map((entry) => entry.name).join(", ")}. Unsupported test features are rejected. Not item-player or general test-runner certification.`,
31
+ }));
@@ -0,0 +1,190 @@
1
+ import { assertNever } from "./assert-never.js";
2
+ import type { QtiTestExpression } from "./test-expression.js";
3
+ import { isQtiIdentifier } from "./qti-identifier.js";
4
+ import { isQtiPackageItemHref } from "./qti-package-paths.js";
5
+ import { isTestBaseType } from "./test-expression.js";
6
+ import { checkTestExpression, isBoolean, isTestScalarValue } from "./test-expression-validation.js";
7
+ import type { QtiDiagnostic } from "./types.js";
8
+ import type { QtiExecutableTest, QtiTestDefinition, QtiTestResult } from "./test-model.js";
9
+
10
+ type Reject = (code: string, message: string) => void;
11
+
12
+ /** Establish executable support for the finite, flat-section QTI test profile. */
13
+ export function validateQtiTest(test: QtiTestDefinition): QtiTestResult<QtiExecutableTest> {
14
+ const diagnostics: QtiDiagnostic[] = [];
15
+ const reject: Reject = (code, message) => {
16
+ diagnostics.push({ code: `test.${code}`, severity: "error", message });
17
+ };
18
+ validateStructure(test, reject);
19
+ validateDeclarations(test, reject);
20
+ validateExpressions(test, diagnostics, reject);
21
+ validateGraph(test, reject);
22
+ if (diagnostics.length) return { ok: false, diagnostics };
23
+ // SAFETY: Validation establishes nonempty sections/items, scalar declarations, expression types
24
+ // and forward edges. Clone so later mutation of the caller's definition cannot invalidate the brand.
25
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- SAFETY: Sole constructor of the validated executable test brand.
26
+ return { ok: true, value: copyExecutableDefinition(test) as QtiExecutableTest };
27
+ }
28
+
29
+ function validateStructure(test: QtiTestDefinition, reject: Reject): void {
30
+ const identifiers = new Set<string>();
31
+ const identifier = (value: string) => {
32
+ if (
33
+ !isQtiIdentifier(value) ||
34
+ ["EXIT_TEST", "__proto__", "constructor", "prototype"].includes(value) ||
35
+ identifiers.has(value)
36
+ )
37
+ reject("identifier", "Test identifiers must be valid, unique and non-reserved.");
38
+ identifiers.add(value);
39
+ };
40
+ identifier(test.identifier);
41
+ identifier(test.partIdentifier);
42
+ for (const declaration of test.outcomeDeclarations) identifier(declaration.identifier);
43
+ if (!test.title.trim() || !test.sections.length)
44
+ reject("structure", "A test requires a title and at least one section.");
45
+ const hrefs = new Set<string>();
46
+ for (const section of test.sections) {
47
+ identifier(section.identifier);
48
+ if (!section.title.trim() || !section.items.length)
49
+ reject("section.empty", "Sections require a title and at least one item.");
50
+ for (const item of section.items) {
51
+ identifier(item.identifier);
52
+ if (!isQtiPackageItemHref(item.href) || hrefs.has(item.href))
53
+ reject("item.href", "Item references must have distinct package-local paths.");
54
+ hrefs.add(item.href);
55
+ if (item.categories.some((category) => !isQtiIdentifier(category)))
56
+ reject("item.category", "Item categories must be QTI identifiers.");
57
+ }
58
+ }
59
+ }
60
+
61
+ function validateDeclarations(test: QtiTestDefinition, reject: Reject): void {
62
+ for (const declaration of test.outcomeDeclarations) {
63
+ if (
64
+ declaration.cardinality !== "single" ||
65
+ declaration.lookupTable !== undefined ||
66
+ !isTestBaseType(declaration.baseType)
67
+ )
68
+ reject(
69
+ "declaration.unsupported",
70
+ "Test outcomes require a supported single-cardinality base type without lookup tables.",
71
+ );
72
+ if (
73
+ declaration.defaultValue !== null &&
74
+ !isTestScalarValue(declaration.baseType, declaration.defaultValue)
75
+ )
76
+ reject("declaration.default", "Test outcome default does not match its declared type.");
77
+ }
78
+ }
79
+
80
+ function validateExpressions(
81
+ test: QtiTestDefinition,
82
+ diagnostics: QtiDiagnostic[],
83
+ reject: Reject,
84
+ ): void {
85
+ const declarations = new Map(
86
+ test.outcomeDeclarations.map((declaration) => [declaration.identifier, declaration]),
87
+ );
88
+ const categories = new Set(
89
+ test.sections.flatMap((section) => section.items.flatMap((item) => item.categories)),
90
+ );
91
+ for (const section of test.sections) {
92
+ for (const branch of section.branches) {
93
+ if (
94
+ !isBoolean(
95
+ checkTestExpression(branch.expression, declarations, categories, "branch", diagnostics),
96
+ )
97
+ )
98
+ reject("branch.expression", "Branch expressions must produce a boolean.");
99
+ }
100
+ }
101
+ for (const rule of test.outcomeProcessing) {
102
+ const target = declarations.get(rule.identifier);
103
+ const actual = checkTestExpression(
104
+ rule.expression,
105
+ declarations,
106
+ categories,
107
+ "outcome",
108
+ diagnostics,
109
+ );
110
+ const compatible =
111
+ actual &&
112
+ target &&
113
+ (actual.baseType === target.baseType ||
114
+ (actual.baseType === "integer" && target.baseType === "float"));
115
+ if (!compatible || actual.cardinality !== "single")
116
+ reject("outcome.type", "Outcome rules must assign the declared outcome type.");
117
+ }
118
+ }
119
+
120
+ function validateGraph(test: QtiTestDefinition, reject: Reject): void {
121
+ const indices = new Map(test.sections.map((section, index) => [section.identifier, index]));
122
+ for (const [index, section] of test.sections.entries()) {
123
+ for (const branch of section.branches) {
124
+ if (branch.target !== "EXIT_TEST" && (indices.get(branch.target) ?? -1) <= index)
125
+ reject("branch.target", "Branch targets must identify a later section or EXIT_TEST.");
126
+ }
127
+ }
128
+ }
129
+
130
+ function copyExecutableDefinition(test: QtiTestDefinition): QtiTestDefinition {
131
+ return Object.freeze({
132
+ ...test,
133
+ outcomeDeclarations: Object.freeze(
134
+ test.outcomeDeclarations.map((declaration) =>
135
+ Object.freeze({
136
+ ...declaration,
137
+ attributes: { ...declaration.attributes },
138
+ }),
139
+ ),
140
+ ),
141
+ outcomeProcessing: Object.freeze(
142
+ test.outcomeProcessing.map((rule) =>
143
+ Object.freeze({ ...rule, expression: copyExpression(rule.expression) }),
144
+ ),
145
+ ),
146
+ sections: Object.freeze(
147
+ test.sections.map((section) =>
148
+ Object.freeze({
149
+ ...section,
150
+ items: Object.freeze(
151
+ section.items.map((item) =>
152
+ Object.freeze({ ...item, categories: Object.freeze([...item.categories]) }),
153
+ ),
154
+ ),
155
+ branches: Object.freeze(
156
+ section.branches.map((branch) =>
157
+ Object.freeze({ ...branch, expression: copyExpression(branch.expression) }),
158
+ ),
159
+ ),
160
+ }),
161
+ ),
162
+ ),
163
+ });
164
+ }
165
+
166
+ function copyExpression(expression: QtiTestExpression): QtiTestExpression {
167
+ switch (expression.type) {
168
+ case "sum":
169
+ case "and":
170
+ case "or":
171
+ return Object.freeze({
172
+ ...expression,
173
+ expressions: Object.freeze(expression.expressions.map(copyExpression)),
174
+ });
175
+ case "numericCompare":
176
+ return Object.freeze({
177
+ ...expression,
178
+ left: copyExpression(expression.left),
179
+ right: copyExpression(expression.right),
180
+ });
181
+ case "not":
182
+ return Object.freeze({ ...expression, expression: copyExpression(expression.expression) });
183
+ case "baseValue":
184
+ case "variable":
185
+ case "testVariables":
186
+ return Object.freeze({ ...expression });
187
+ default:
188
+ return assertNever(expression);
189
+ }
190
+ }
@@ -0,0 +1,37 @@
1
+ import { QTI_ASI_NAMESPACE } from "./qti-namespaces.js";
2
+ import type { QtiDiagnostic } from "./types.js";
3
+ import type { XmlNode } from "./xml.js";
4
+
5
+ /** Report an unsupported construct at its XML source location. */
6
+ export function rejectTestXml(node: XmlNode, diagnostics: QtiDiagnostic[]): void {
7
+ diagnostics.push({
8
+ code: "test.xml.unsupported",
9
+ severity: "error",
10
+ message: `Unsupported test element or attributes: ${node.localName}.`,
11
+ source: node.source,
12
+ });
13
+ }
14
+
15
+ /** Check the closed test profile's namespace, attributes, children and element-only content. */
16
+ export function checkTestXml(
17
+ node: XmlNode,
18
+ names: readonly string[],
19
+ attrs: readonly string[],
20
+ diagnostics: QtiDiagnostic[],
21
+ allowText = false,
22
+ ): void {
23
+ if (
24
+ node.uri !== QTI_ASI_NAMESPACE ||
25
+ Object.keys(node.attributes).some(
26
+ (name) =>
27
+ name !== "xmlns" &&
28
+ !name.startsWith("xmlns:") &&
29
+ !["xsi:schemaLocation", "xml:lang", ...attrs].includes(name),
30
+ ) ||
31
+ (!allowText && node.text.trim().length > 0)
32
+ )
33
+ rejectTestXml(node, diagnostics);
34
+ for (const child of node.children)
35
+ if (child.uri !== QTI_ASI_NAMESPACE || !names.includes(child.localName))
36
+ rejectTestXml(child, diagnostics);
37
+ }
package/src/types.ts CHANGED
@@ -770,6 +770,7 @@ export interface QtiScoreResult {
770
770
  export type QtiElementSupport =
771
771
  | QtiInteractionElementSupport
772
772
  | QtiProcessingElementSupport
773
+ | QtiTestElementSupport
773
774
  | QtiItemMetadataElementSupport;
774
775
 
775
776
  interface QtiElementSupportBase {
@@ -794,6 +795,11 @@ export interface QtiProcessingElementSupport extends QtiElementSupportBase {
794
795
  category: "processing";
795
796
  }
796
797
 
798
+ /** Executable assessment-test support, separate from item processing/rendering. */
799
+ export interface QtiTestElementSupport extends QtiElementSupportBase {
800
+ category: "test";
801
+ }
802
+
797
803
  export interface QtiItemMetadataElementSupport extends QtiElementSupportBase {
798
804
  category: "itemMetadata";
799
805
  }