@longsightgroup/qti3-core 0.10.5 → 0.10.6
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 +27 -4
- package/dist/catalog.d.ts +8 -0
- package/dist/catalog.d.ts.map +1 -1
- package/dist/catalog.js +10 -0
- package/dist/catalog.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/item-scoring-expectation.d.ts +2 -0
- package/dist/item-scoring-expectation.d.ts.map +1 -1
- package/dist/item-scoring-expectation.js +8 -0
- package/dist/item-scoring-expectation.js.map +1 -1
- package/dist/parser.js +4 -6
- package/dist/parser.js.map +1 -1
- package/dist/response-validation-policy.d.ts +3 -1
- package/dist/response-validation-policy.d.ts.map +1 -1
- package/dist/response-validation-policy.js +63 -5
- package/dist/response-validation-policy.js.map +1 -1
- package/dist/response-validation.d.ts +3 -1
- package/dist/response-validation.d.ts.map +1 -1
- package/dist/response-validation.js +30 -9
- package/dist/response-validation.js.map +1 -1
- package/dist/scoring-disposition-policy.d.ts.map +1 -1
- package/dist/scoring-disposition-policy.js +6 -2
- package/dist/scoring-disposition-policy.js.map +1 -1
- package/dist/support-evidence.d.ts.map +1 -1
- package/dist/support-evidence.js +14 -3
- package/dist/support-evidence.js.map +1 -1
- package/dist/support.js +9 -2
- package/dist/support.js.map +1 -1
- package/dist/text-response.d.ts +9 -0
- package/dist/text-response.d.ts.map +1 -0
- package/dist/text-response.js +102 -0
- package/dist/text-response.js.map +1 -0
- package/dist/trusted-item-session.d.ts.map +1 -1
- package/dist/trusted-item-session.js +4 -3
- package/dist/trusted-item-session.js.map +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/validation-interactions.d.ts.map +1 -1
- package/dist/validation-interactions.js +102 -12
- package/dist/validation-interactions.js.map +1 -1
- package/package.json +1 -1
- package/src/catalog.ts +29 -0
- package/src/index.ts +7 -0
- package/src/item-scoring-expectation.ts +8 -0
- package/src/parser.ts +4 -6
- package/src/response-validation-policy.ts +71 -3
- package/src/response-validation.ts +49 -4
- package/src/scoring-disposition-policy.ts +7 -2
- package/src/support-evidence.ts +14 -3
- package/src/support.ts +11 -2
- package/src/text-response.ts +106 -0
- package/src/trusted-item-session.ts +4 -3
- package/src/types.ts +2 -0
- package/src/validation-interactions.ts +139 -14
|
@@ -8,11 +8,13 @@ import type {
|
|
|
8
8
|
QtiScalarValue,
|
|
9
9
|
QtiValue,
|
|
10
10
|
} from "./types.js";
|
|
11
|
+
import { isQtiTextResponseRecord, qtiTextResponseString } from "./text-response.js";
|
|
11
12
|
import { assertNever } from "./assert-never.js";
|
|
12
13
|
import { isNullResponse, isRecordValue, valueContainer } from "./processing-values.js";
|
|
13
14
|
import { listNamedResponseInputs, type QtiNamedResponseInput } from "./response-input.js";
|
|
14
15
|
import {
|
|
15
16
|
matchMaxDiagnostics,
|
|
17
|
+
matchMinDiagnostics,
|
|
16
18
|
maximumAllowedResponses,
|
|
17
19
|
maximumResponseDiagnostic,
|
|
18
20
|
mediaPlayCount,
|
|
@@ -33,6 +35,7 @@ export type QtiResponseValidationDiagnosticCode =
|
|
|
33
35
|
| "response.required"
|
|
34
36
|
| "response.maximum"
|
|
35
37
|
| "response.matchMax"
|
|
38
|
+
| "response.matchMin"
|
|
36
39
|
| "response.cardinality"
|
|
37
40
|
| "response.baseType"
|
|
38
41
|
| "response.domain"
|
|
@@ -65,6 +68,8 @@ export interface QtiResponseValidationInput {
|
|
|
65
68
|
item: QtiAssessmentItem;
|
|
66
69
|
responses: QtiResponseVariablesInput;
|
|
67
70
|
allowIncompleteResponses?: boolean | undefined;
|
|
71
|
+
/** Host policy: require a scored response when no minimum is authored; defaults to false. */
|
|
72
|
+
requireScoredResponses?: boolean | undefined;
|
|
68
73
|
allowedUndeclaredResponseIdentifiers?: readonly string[] | undefined;
|
|
69
74
|
responseIdentifiers?: Iterable<string> | undefined;
|
|
70
75
|
}
|
|
@@ -113,6 +118,17 @@ export function parseQtiResponseVariables(
|
|
|
113
118
|
if (cardinalityMatches && parsedValue !== undefined) {
|
|
114
119
|
responses.set(declaration.identifier, parsedValue);
|
|
115
120
|
validateResponseDomain(declaration, interactions ?? [], parsedValue, diagnostics);
|
|
121
|
+
if (
|
|
122
|
+
declaration.cardinality === "record" &&
|
|
123
|
+
parsedValue !== null &&
|
|
124
|
+
interactions?.some(
|
|
125
|
+
(interaction) =>
|
|
126
|
+
interaction.type === "textEntry" || interaction.type === "extendedText",
|
|
127
|
+
) &&
|
|
128
|
+
!isQtiTextResponseRecord(parsedValue)
|
|
129
|
+
) {
|
|
130
|
+
pushResponseDomainDiagnostic(declaration, parsedValue, diagnostics);
|
|
131
|
+
}
|
|
116
132
|
}
|
|
117
133
|
}
|
|
118
134
|
|
|
@@ -122,6 +138,7 @@ export function parseQtiResponseVariables(
|
|
|
122
138
|
undefined,
|
|
123
139
|
value,
|
|
124
140
|
input.allowIncompleteResponses,
|
|
141
|
+
input.requireScoredResponses,
|
|
125
142
|
diagnostics,
|
|
126
143
|
);
|
|
127
144
|
continue;
|
|
@@ -133,6 +150,7 @@ export function parseQtiResponseVariables(
|
|
|
133
150
|
interaction,
|
|
134
151
|
value,
|
|
135
152
|
input.allowIncompleteResponses,
|
|
153
|
+
input.requireScoredResponses,
|
|
136
154
|
diagnostics,
|
|
137
155
|
);
|
|
138
156
|
}
|
|
@@ -379,17 +397,37 @@ function validateDeclarationResponse(
|
|
|
379
397
|
interaction: QtiInteraction | undefined,
|
|
380
398
|
value: QtiValue | undefined,
|
|
381
399
|
allowIncompleteResponses: boolean | undefined,
|
|
400
|
+
requireScoredResponses: boolean | undefined,
|
|
382
401
|
diagnostics: QtiResponseValidationDiagnostic[],
|
|
383
402
|
): void {
|
|
384
|
-
|
|
403
|
+
if (interaction && !allowIncompleteResponses) {
|
|
404
|
+
diagnostics.push(
|
|
405
|
+
...matchMinDiagnostics(declaration.identifier, interaction, value ?? null).map((diagnostic) =>
|
|
406
|
+
attachResponseIdentifier(declaration.identifier, diagnostic),
|
|
407
|
+
),
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
const policy = responseValidationPolicy(declaration, interaction, requireScoredResponses);
|
|
385
411
|
if (!policy.checkMinimum && !policy.checkMaximum && !policy.checkMatchMax) return;
|
|
386
412
|
|
|
387
413
|
const effectiveValue = value ?? null;
|
|
388
414
|
const count =
|
|
389
|
-
interaction?.type === "
|
|
415
|
+
interaction?.type === "extendedText" || interaction?.type === "textEntry"
|
|
416
|
+
? Array.isArray(effectiveValue)
|
|
417
|
+
? effectiveValue.filter((entry) => entry !== "").length
|
|
418
|
+
: qtiTextResponseString(effectiveValue) === ""
|
|
419
|
+
? 0
|
|
420
|
+
: 1
|
|
421
|
+
: interaction?.type === "media"
|
|
422
|
+
? mediaPlayCount(effectiveValue)
|
|
423
|
+
: responseCount(effectiveValue);
|
|
390
424
|
|
|
391
425
|
if (policy.checkMinimum && !allowIncompleteResponses) {
|
|
392
|
-
const minimum = effectiveMinimumRequiredResponses(
|
|
426
|
+
const minimum = effectiveMinimumRequiredResponses(
|
|
427
|
+
declaration,
|
|
428
|
+
interaction,
|
|
429
|
+
requireScoredResponses,
|
|
430
|
+
);
|
|
393
431
|
if (count < minimum) {
|
|
394
432
|
diagnostics.push(
|
|
395
433
|
attachResponseIdentifier(
|
|
@@ -424,9 +462,15 @@ function validateDeclarationResponse(
|
|
|
424
462
|
function effectiveMinimumRequiredResponses(
|
|
425
463
|
declaration: { readonly correctResponse: QtiValue | null },
|
|
426
464
|
interaction: QtiInteraction | undefined,
|
|
465
|
+
requireScoredResponses: boolean | undefined,
|
|
427
466
|
): number {
|
|
428
467
|
const minimum = minimumRequiredResponses(interaction);
|
|
429
|
-
if (
|
|
468
|
+
if (
|
|
469
|
+
!requireScoredResponses ||
|
|
470
|
+
declaration.correctResponse === null ||
|
|
471
|
+
hasAuthoredMinimum(interaction)
|
|
472
|
+
)
|
|
473
|
+
return minimum;
|
|
430
474
|
return Math.max(minimum, 1);
|
|
431
475
|
}
|
|
432
476
|
|
|
@@ -453,6 +497,7 @@ function isResponseValidationDiagnosticCode(
|
|
|
453
497
|
code === "response.required" ||
|
|
454
498
|
code === "response.maximum" ||
|
|
455
499
|
code === "response.matchMax" ||
|
|
500
|
+
code === "response.matchMin" ||
|
|
456
501
|
code === "response.cardinality" ||
|
|
457
502
|
code === "response.baseType" ||
|
|
458
503
|
code === "response.domain" ||
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { QtiAssessmentItem } from "./types.js";
|
|
2
|
-
import { itemExpectsAutomatedScore } from "./item-scoring-expectation.js";
|
|
2
|
+
import { itemExpectsAutomatedScore, itemHasExternalScore } from "./item-scoring-expectation.js";
|
|
3
3
|
|
|
4
4
|
export type QtiItemSubmissionScoringDisposition =
|
|
5
5
|
| "scored"
|
|
@@ -23,7 +23,11 @@ export function itemHasManuallyScoredInteractions(item: QtiAssessmentItem): bool
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
export function itemNeedsScoringFollowUpWhenUnscored(item: QtiAssessmentItem): boolean {
|
|
26
|
-
return
|
|
26
|
+
return (
|
|
27
|
+
itemHasExternalScore(item) ||
|
|
28
|
+
itemExpectsAutomatedScore(item) ||
|
|
29
|
+
itemHasManuallyScoredInteractions(item)
|
|
30
|
+
);
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
/** Default generic disposition taxonomy shipped by qti3-core for submission materialization. */
|
|
@@ -31,6 +35,7 @@ export function classifyQtiItemScoringDisposition(
|
|
|
31
35
|
item: QtiAssessmentItem,
|
|
32
36
|
score: number | null,
|
|
33
37
|
): Exclude<QtiItemSubmissionScoringDisposition, "invalid"> {
|
|
38
|
+
if (itemHasExternalScore(item)) return "manual-scoring-required";
|
|
34
39
|
if (score !== null) return "scored";
|
|
35
40
|
if (itemNeedsScoringFollowUpWhenUnscored(item)) return "manual-scoring-required";
|
|
36
41
|
return "unscored-reference";
|
package/src/support-evidence.ts
CHANGED
|
@@ -51,11 +51,16 @@ export function browserTestsFor(interactionType: QtiInteractionType): string[] {
|
|
|
51
51
|
"tests/browser/player-interaction-sweep.spec.ts",
|
|
52
52
|
];
|
|
53
53
|
const extras: Partial<Record<QtiInteractionType, string[]>> = {
|
|
54
|
-
associate:
|
|
54
|
+
associate: [
|
|
55
|
+
...browserKeyboardA11yTests,
|
|
56
|
+
"packages/core/src/association-contracts.test.ts",
|
|
57
|
+
"tests/browser/player-dom-behavior.spec.ts",
|
|
58
|
+
],
|
|
55
59
|
choice: ["tests/browser/player-choice.spec.ts", "tests/browser/player-dom-behavior.spec.ts"],
|
|
56
60
|
drawing: ["tests/browser/player-graphic.spec.ts"],
|
|
57
61
|
endAttempt: ["tests/browser/player-dom-behavior.spec.ts", ...browserKeyboardA11yTests],
|
|
58
62
|
extendedText: [
|
|
63
|
+
"packages/core/src/text-response.test.ts",
|
|
59
64
|
"tests/browser/player-dom-behavior.spec.ts",
|
|
60
65
|
"tests/browser/player-extended-text-xhtml.spec.ts",
|
|
61
66
|
],
|
|
@@ -65,7 +70,10 @@ export function browserTestsFor(interactionType: QtiInteractionType): string[] {
|
|
|
65
70
|
"tests/browser/player-graphic-gap-match.spec.ts",
|
|
66
71
|
"tests/browser/player-graphic.spec.ts",
|
|
67
72
|
],
|
|
68
|
-
graphicOrder: [
|
|
73
|
+
graphicOrder: [
|
|
74
|
+
"packages/core/src/graphic-image-contracts.test.ts",
|
|
75
|
+
"tests/browser/player-graphic.spec.ts",
|
|
76
|
+
],
|
|
69
77
|
hotspot: ["tests/browser/player-graphic.spec.ts"],
|
|
70
78
|
hottext: ["tests/browser/player-hottext.spec.ts", "tests/browser/player-dom-behavior.spec.ts"],
|
|
71
79
|
inlineChoice: ["tests/browser/player-inline-choice.spec.ts"],
|
|
@@ -81,7 +89,10 @@ export function browserTestsFor(interactionType: QtiInteractionType): string[] {
|
|
|
81
89
|
"tests/browser/player-slider-cross-browser.spec.ts",
|
|
82
90
|
...browserKeyboardA11yTests,
|
|
83
91
|
],
|
|
84
|
-
textEntry: [
|
|
92
|
+
textEntry: [
|
|
93
|
+
"packages/core/src/text-response.test.ts",
|
|
94
|
+
"tests/browser/player-dom-behavior.spec.ts",
|
|
95
|
+
],
|
|
85
96
|
upload: ["tests/browser/player-dom-behavior.spec.ts"],
|
|
86
97
|
};
|
|
87
98
|
return [...base, ...(extras[interactionType] ?? [])];
|
package/src/support.ts
CHANGED
|
@@ -401,20 +401,29 @@ function entry(qtiName: string, interactionType: QtiInteractionType): QtiInterac
|
|
|
401
401
|
process: true,
|
|
402
402
|
fixtures: interactionSupportFixtures(interactionType),
|
|
403
403
|
tests: interactionSupportTests(interactionType),
|
|
404
|
+
notes: ["associate", "graphicAssociate", "match", "gapMatch"].includes(interactionType)
|
|
405
|
+
? interactionType === "associate" || interactionType === "graphicAssociate"
|
|
406
|
+
? "Supports single and multiple unordered pair responses."
|
|
407
|
+
: "Supports single and multiple directedPair responses."
|
|
408
|
+
: interactionType === "graphicGapMatch"
|
|
409
|
+
? "Requires multiple directedPair responses."
|
|
410
|
+
: undefined,
|
|
404
411
|
};
|
|
405
412
|
}
|
|
406
413
|
|
|
407
414
|
function extendedTextInteractionEntry(): QtiInteractionElementSupport {
|
|
408
415
|
return {
|
|
409
416
|
...entry("qti-extended-text-interaction", "extendedText"),
|
|
410
|
-
notes:
|
|
417
|
+
notes:
|
|
418
|
+
"Supports single, multiple, ordered and numeric record responses; string/integer/float values; min-strings/max-strings; plain, preformatted and string XHTML capture.",
|
|
411
419
|
};
|
|
412
420
|
}
|
|
413
421
|
|
|
414
422
|
function textEntryInteractionEntry(): QtiInteractionElementSupport {
|
|
415
423
|
return {
|
|
416
424
|
...entry("qti-text-entry-interaction", "textEntry"),
|
|
417
|
-
notes:
|
|
425
|
+
notes:
|
|
426
|
+
"Supports single string/integer/float and numeric record responses, base 2–36, string-identifier, placeholder-text and pattern-mask; text-entry format is plain only.",
|
|
418
427
|
};
|
|
419
428
|
}
|
|
420
429
|
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { QtiInteraction, QtiRecordValue, QtiValue } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/** Capture text using the interaction's declared response type and authored radix. */
|
|
4
|
+
export function captureQtiTextResponse(interaction: QtiInteraction, text: string): QtiValue {
|
|
5
|
+
if (interaction.responseCardinality === "record") return numericRecord(text, radix(interaction));
|
|
6
|
+
if (interaction.responseBaseType === "string") return text;
|
|
7
|
+
if (text.trim() === "") return null;
|
|
8
|
+
const record = numericRecord(text, radix(interaction));
|
|
9
|
+
return interaction.responseBaseType === "integer"
|
|
10
|
+
? (record.integerValue ?? null)
|
|
11
|
+
: (record.floatValue ?? null);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Recover editable text, including the original lexical form in a numeric record. */
|
|
15
|
+
export function qtiTextResponseString(value: QtiValue): string {
|
|
16
|
+
if (value !== null && typeof value === "object") {
|
|
17
|
+
return !Array.isArray(value) && typeof value.stringValue === "string" ? value.stringValue : "";
|
|
18
|
+
}
|
|
19
|
+
return value === null ? "" : String(value);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Format a captured scalar in its authored radix, preserving companion and record text. */
|
|
23
|
+
export function formatQtiTextResponse(interaction: QtiInteraction, value: QtiValue): string {
|
|
24
|
+
const base = radix(interaction);
|
|
25
|
+
if (typeof value === "number" && Number.isInteger(base) && base >= 2 && base <= 36) {
|
|
26
|
+
return value.toString(base);
|
|
27
|
+
}
|
|
28
|
+
return qtiTextResponseString(value);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function radix(interaction: QtiInteraction): number {
|
|
32
|
+
return Number(interaction.attributes.base ?? 10);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function numericRecord(text: string, base: number): QtiRecordValue {
|
|
36
|
+
const empty: QtiRecordValue = {
|
|
37
|
+
stringValue: text,
|
|
38
|
+
floatValue: null,
|
|
39
|
+
integerValue: null,
|
|
40
|
+
leftDigits: null,
|
|
41
|
+
rightDigits: null,
|
|
42
|
+
ndp: null,
|
|
43
|
+
nsf: null,
|
|
44
|
+
exponent: null,
|
|
45
|
+
};
|
|
46
|
+
if (!Number.isInteger(base) || base < 2 || base > 36) return empty;
|
|
47
|
+
const normalized = text.trim();
|
|
48
|
+
const match = (
|
|
49
|
+
base === 10
|
|
50
|
+
? /^([+-]?)(\d*)(?:\.(\d*))?(?:[eE]([+-]?\d+))?$/
|
|
51
|
+
: /^([+-]?)([0-9a-z]*)(?:\.([0-9a-z]*))?$/i
|
|
52
|
+
).exec(normalized);
|
|
53
|
+
if (!match) return empty;
|
|
54
|
+
const left = match[2] ?? "";
|
|
55
|
+
const right = match[3] ?? "";
|
|
56
|
+
const digits = left + right;
|
|
57
|
+
if (!digits || digits.split("").some((digit) => Number.parseInt(digit, 36) >= base)) return empty;
|
|
58
|
+
const exponent = match[4] === undefined ? null : Number(match[4]);
|
|
59
|
+
if (exponent !== null && !Number.isSafeInteger(exponent)) return empty;
|
|
60
|
+
const ndp = Math.max(0, right.length - (exponent ?? 0));
|
|
61
|
+
if (!Number.isSafeInteger(ndp)) return empty;
|
|
62
|
+
const magnitude =
|
|
63
|
+
(Number.parseInt(left || "0", base) +
|
|
64
|
+
right
|
|
65
|
+
.split("")
|
|
66
|
+
.reduce(
|
|
67
|
+
(sum, digit, index) => sum + Number.parseInt(digit, base) / base ** (index + 1),
|
|
68
|
+
0,
|
|
69
|
+
)) *
|
|
70
|
+
base ** (exponent ?? 0);
|
|
71
|
+
const value = base === 10 ? Number(normalized) : match[1] === "-" ? -magnitude : magnitude;
|
|
72
|
+
if (!Number.isFinite(value)) return empty;
|
|
73
|
+
const integer =
|
|
74
|
+
match[3] === undefined && exponent === null && Number.isSafeInteger(value) ? value : null;
|
|
75
|
+
return {
|
|
76
|
+
stringValue: text,
|
|
77
|
+
floatValue: value,
|
|
78
|
+
integerValue: integer,
|
|
79
|
+
leftDigits: left.length,
|
|
80
|
+
rightDigits: right.length,
|
|
81
|
+
ndp,
|
|
82
|
+
nsf: digits.replace(/^0+/, "").length || 1,
|
|
83
|
+
exponent,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function isQtiTextResponseRecord(value: QtiValue): boolean {
|
|
88
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
89
|
+
const integerFields = new Set([
|
|
90
|
+
"integerValue",
|
|
91
|
+
"leftDigits",
|
|
92
|
+
"rightDigits",
|
|
93
|
+
"ndp",
|
|
94
|
+
"nsf",
|
|
95
|
+
"exponent",
|
|
96
|
+
]);
|
|
97
|
+
return Object.entries(value).every(([field, entry]) => {
|
|
98
|
+
if (field === "stringValue") return typeof entry === "string" || entry === null;
|
|
99
|
+
if (field === "floatValue")
|
|
100
|
+
return entry === null || (typeof entry === "number" && Number.isFinite(entry));
|
|
101
|
+
return (
|
|
102
|
+
integerFields.has(field) &&
|
|
103
|
+
(entry === null || (typeof entry === "number" && Number.isSafeInteger(entry)))
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { parseQtiXml } from "./parser.js";
|
|
2
|
-
import { itemExpectsAutomatedScore } from "./item-scoring-expectation.js";
|
|
2
|
+
import { itemExpectsAutomatedScore, itemHasExternalScore } from "./item-scoring-expectation.js";
|
|
3
3
|
import { parseQtiResponseVariables } from "./response-validation.js";
|
|
4
4
|
import { createItemSession, isQtiAttemptStateV1, type QtiItemSession } from "./session.js";
|
|
5
5
|
import type {
|
|
@@ -190,6 +190,7 @@ export function runTrustedItemSession(
|
|
|
190
190
|
}
|
|
191
191
|
|
|
192
192
|
const shouldScore = input.scoring === "always" || applicationResult.appliedSubmission;
|
|
193
|
+
const externalScore = itemHasExternalScore(parsedResult.parsed.document.item);
|
|
193
194
|
|
|
194
195
|
let outcomes = sessionResult.session.serialize().outcomes;
|
|
195
196
|
let state = sessionResult.session.serialize();
|
|
@@ -212,7 +213,7 @@ export function runTrustedItemSession(
|
|
|
212
213
|
scoredResult.scored.state,
|
|
213
214
|
parsedResult.parsed.responseIdentifiers,
|
|
214
215
|
);
|
|
215
|
-
score = readNumericScore(outcomes.SCORE);
|
|
216
|
+
score = externalScore ? null : readNumericScore(outcomes.SCORE);
|
|
216
217
|
|
|
217
218
|
if (scoredResult.scored.diagnostics.some((diagnostic) => diagnostic.severity === "error")) {
|
|
218
219
|
return emptyTrustedItemSessionFailure(scoredDiagnostics, {
|
|
@@ -241,7 +242,7 @@ export function runTrustedItemSession(
|
|
|
241
242
|
} else {
|
|
242
243
|
state = stripUndeclaredResponses(state, parsedResult.parsed.responseIdentifiers);
|
|
243
244
|
outcomes = state.outcomes;
|
|
244
|
-
score = readNumericScore(outcomes.SCORE);
|
|
245
|
+
score = externalScore ? null : readNumericScore(outcomes.SCORE);
|
|
245
246
|
}
|
|
246
247
|
|
|
247
248
|
return {
|
package/src/types.ts
CHANGED
|
@@ -250,6 +250,8 @@ export interface QtiGapMatchGapSegment {
|
|
|
250
250
|
}
|
|
251
251
|
|
|
252
252
|
export interface QtiObjectAsset {
|
|
253
|
+
/** Attributes of an authored image or picture fallback, when present. */
|
|
254
|
+
imageAttributes?: Record<string, string> | undefined;
|
|
253
255
|
data?: string | undefined;
|
|
254
256
|
type?: string | undefined;
|
|
255
257
|
width?: string | undefined;
|
|
@@ -34,10 +34,13 @@ export function validateInteractions(item: QtiAssessmentItem, diagnostics: QtiDi
|
|
|
34
34
|
validateInteractionSharedVocabulary(interaction, diagnostics);
|
|
35
35
|
validateInteractionChoices(interaction, diagnostics);
|
|
36
36
|
validateInteractionChildren(interaction, diagnostics);
|
|
37
|
+
validateGraphicGapMatchChildren(interaction, diagnostics);
|
|
38
|
+
validatePositionObjectChildren(interaction, diagnostics);
|
|
37
39
|
validateInteractionRequiredAttributes(interaction, diagnostics);
|
|
38
40
|
validatePortableCustomInteraction(interaction, item, diagnostics);
|
|
39
41
|
validateInteractionLimitAttributes(interaction, diagnostics);
|
|
40
42
|
validatePatternMaskAttribute(interaction, diagnostics);
|
|
43
|
+
validateTextInteractionContract(interaction, responseDeclarations, diagnostics);
|
|
41
44
|
validateGraphicHotspotObjectDimensions(interaction, diagnostics);
|
|
42
45
|
validateCorrectResponseReferences(
|
|
43
46
|
interaction,
|
|
@@ -649,7 +652,7 @@ function allowedInteractionChildren(interaction: QtiInteraction): Set<string> |
|
|
|
649
652
|
case "order":
|
|
650
653
|
return setOf(common, ["qti-simple-choice"]);
|
|
651
654
|
case "associate":
|
|
652
|
-
return setOf(common, ["qti-simple-
|
|
655
|
+
return setOf(common, ["qti-simple-associable-choice"]);
|
|
653
656
|
case "match":
|
|
654
657
|
return setOf(common, ["qti-simple-match-set"]);
|
|
655
658
|
case "gapMatch":
|
|
@@ -659,22 +662,24 @@ function allowedInteractionChildren(interaction: QtiInteraction): Set<string> |
|
|
|
659
662
|
case "hottext":
|
|
660
663
|
return setOf(common, staticContentNames());
|
|
661
664
|
case "graphicOrder":
|
|
662
|
-
return setOf(common, ["object", "qti-hotspot-choice"]);
|
|
665
|
+
return setOf(common, ["object", "img", "picture", "qti-hotspot-choice"]);
|
|
663
666
|
case "graphicAssociate":
|
|
664
|
-
return setOf(common, ["object", "qti-associable-hotspot"]);
|
|
667
|
+
return setOf(common, ["object", "img", "picture", "qti-associable-hotspot"]);
|
|
665
668
|
case "graphicGapMatch":
|
|
666
669
|
return setOf(common, [
|
|
667
670
|
"object",
|
|
671
|
+
"img",
|
|
672
|
+
"picture",
|
|
668
673
|
"qti-gap-text",
|
|
669
674
|
"qti-gap-img",
|
|
670
675
|
"qti-associable-hotspot",
|
|
671
|
-
...staticContentNames(),
|
|
672
676
|
]);
|
|
673
677
|
case "hotspot":
|
|
674
|
-
return setOf(common, ["object", "qti-hotspot-choice"]);
|
|
678
|
+
return setOf(common, ["object", "img", "picture", "qti-hotspot-choice"]);
|
|
675
679
|
case "positionObject":
|
|
676
|
-
return
|
|
680
|
+
return new Set(["object", "img", "picture"]);
|
|
677
681
|
case "selectPoint":
|
|
682
|
+
return setOf(common, ["object", "img", "picture"]);
|
|
678
683
|
case "media":
|
|
679
684
|
return setOf(common, ["audio", "video", "object", "img"]);
|
|
680
685
|
case "drawing":
|
|
@@ -722,13 +727,12 @@ function expectedResponseShape(
|
|
|
722
727
|
return { cardinalities: ["ordered"], baseTypes: ["identifier"] };
|
|
723
728
|
}
|
|
724
729
|
if (interaction.type === "associate" || interaction.type === "graphicAssociate") {
|
|
725
|
-
return { cardinalities: ["multiple"], baseTypes: ["pair"
|
|
730
|
+
return { cardinalities: ["single", "multiple"], baseTypes: ["pair"] };
|
|
726
731
|
}
|
|
727
|
-
if (
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
) {
|
|
732
|
+
if (interaction.type === "match" || interaction.type === "gapMatch") {
|
|
733
|
+
return { cardinalities: ["single", "multiple"], baseTypes: ["directedPair"] };
|
|
734
|
+
}
|
|
735
|
+
if (interaction.type === "graphicGapMatch") {
|
|
732
736
|
return { cardinalities: ["multiple"], baseTypes: ["directedPair"] };
|
|
733
737
|
}
|
|
734
738
|
if (interaction.type === "selectPoint" || interaction.type === "positionObject") {
|
|
@@ -740,8 +744,14 @@ function expectedResponseShape(
|
|
|
740
744
|
if (interaction.type === "upload") {
|
|
741
745
|
return { cardinalities: ["single"], baseTypes: ["file"] };
|
|
742
746
|
}
|
|
743
|
-
if (interaction.type === "textEntry"
|
|
744
|
-
return { cardinalities: ["single"], baseTypes: ["string"] };
|
|
747
|
+
if (interaction.type === "textEntry") {
|
|
748
|
+
return { cardinalities: ["single", "record"], baseTypes: ["string", "integer", "float"] };
|
|
749
|
+
}
|
|
750
|
+
if (interaction.type === "extendedText") {
|
|
751
|
+
return {
|
|
752
|
+
cardinalities: ["single", "multiple", "ordered", "record"],
|
|
753
|
+
baseTypes: ["string", "integer", "float"],
|
|
754
|
+
};
|
|
745
755
|
}
|
|
746
756
|
if (interaction.type === "drawing") return { cardinalities: ["single"], baseTypes: ["file"] };
|
|
747
757
|
if (interaction.type === "portableCustom") {
|
|
@@ -780,3 +790,118 @@ function needsChoices(interaction: QtiInteraction): boolean {
|
|
|
780
790
|
interaction.type === "hotspot"
|
|
781
791
|
);
|
|
782
792
|
}
|
|
793
|
+
|
|
794
|
+
function validateTextInteractionContract(
|
|
795
|
+
interaction: QtiInteraction,
|
|
796
|
+
declarations: ReadonlyMap<string, QtiResponseDeclaration>,
|
|
797
|
+
diagnostics: QtiDiagnostic[],
|
|
798
|
+
): void {
|
|
799
|
+
if (interaction.type !== "textEntry" && interaction.type !== "extendedText") return;
|
|
800
|
+
const report = (code: string, message: string) =>
|
|
801
|
+
diagnostics.push({
|
|
802
|
+
code,
|
|
803
|
+
severity: "error",
|
|
804
|
+
message,
|
|
805
|
+
path: interaction.source?.path,
|
|
806
|
+
source: interaction.source,
|
|
807
|
+
});
|
|
808
|
+
const base = Number(interaction.attributes.base ?? 10);
|
|
809
|
+
if (!Number.isInteger(base) || base < 2 || base > 36) {
|
|
810
|
+
report("interaction.text.base", "Text response base must be an integer from 2 through 36.");
|
|
811
|
+
}
|
|
812
|
+
const format = interaction.attributes.format;
|
|
813
|
+
if (
|
|
814
|
+
format !== undefined &&
|
|
815
|
+
format !== "plain" &&
|
|
816
|
+
!(interaction.type === "extendedText" && format === "preformatted") &&
|
|
817
|
+
!(
|
|
818
|
+
interaction.type === "extendedText" &&
|
|
819
|
+
format === "xhtml" &&
|
|
820
|
+
interaction.responseBaseType === "string"
|
|
821
|
+
)
|
|
822
|
+
) {
|
|
823
|
+
report(
|
|
824
|
+
"interaction.text.format.unsupported",
|
|
825
|
+
`Text format ${format} is not supported; use plain text.`,
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
if (interaction.type === "extendedText") {
|
|
829
|
+
const container =
|
|
830
|
+
interaction.responseCardinality === "multiple" ||
|
|
831
|
+
interaction.responseCardinality === "ordered";
|
|
832
|
+
const max = interaction.attributes["max-strings"];
|
|
833
|
+
const min = interaction.attributes["min-strings"] ?? "0";
|
|
834
|
+
if (
|
|
835
|
+
(container && max === undefined) ||
|
|
836
|
+
(max !== undefined && !isNonNegativeInteger(max)) ||
|
|
837
|
+
!isNonNegativeInteger(min) ||
|
|
838
|
+
Number(min) > (container ? Number(max) : 1)
|
|
839
|
+
) {
|
|
840
|
+
report(
|
|
841
|
+
"interaction.text.strings",
|
|
842
|
+
"Extended text requires valid min-strings/max-strings limits; containers require max-strings and the minimum must not exceed the maximum.",
|
|
843
|
+
);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
const companion = interaction.attributes["string-identifier"];
|
|
847
|
+
if (companion !== undefined) {
|
|
848
|
+
const declaration = declarations.get(companion);
|
|
849
|
+
if (
|
|
850
|
+
!declaration ||
|
|
851
|
+
declaration.baseType !== "string" ||
|
|
852
|
+
declaration.cardinality !== interaction.responseCardinality ||
|
|
853
|
+
companion === interaction.responseIdentifier ||
|
|
854
|
+
(interaction.responseBaseType !== "integer" && interaction.responseBaseType !== "float")
|
|
855
|
+
) {
|
|
856
|
+
report(
|
|
857
|
+
"interaction.text.stringIdentifier",
|
|
858
|
+
"string-identifier must reference a separate string response with matching cardinality for a numeric text interaction.",
|
|
859
|
+
);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function validateGraphicGapMatchChildren(
|
|
865
|
+
interaction: QtiInteraction,
|
|
866
|
+
diagnostics: QtiDiagnostic[],
|
|
867
|
+
): void {
|
|
868
|
+
if (interaction.type !== "graphicGapMatch") return;
|
|
869
|
+
const names = interaction.childElements.map((child) => child.qtiName);
|
|
870
|
+
const sequence = names.join(" ");
|
|
871
|
+
if (
|
|
872
|
+
/^(?:qti-prompt )?(?:object|img|picture)(?: qti-gap-(?:text|img))+(?: qti-associable-hotspot)+$/.test(
|
|
873
|
+
sequence,
|
|
874
|
+
)
|
|
875
|
+
)
|
|
876
|
+
return;
|
|
877
|
+
diagnostics.push({
|
|
878
|
+
code: "interaction.graphicGapMatch.children",
|
|
879
|
+
severity: "error",
|
|
880
|
+
message:
|
|
881
|
+
"Graphic Gap Match requires an optional prompt, one image, gap choices, then one or more associable hotspots, in that order.",
|
|
882
|
+
path: interaction.source?.path,
|
|
883
|
+
source: interaction.source,
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function validatePositionObjectChildren(
|
|
888
|
+
interaction: QtiInteraction,
|
|
889
|
+
diagnostics: QtiDiagnostic[],
|
|
890
|
+
): void {
|
|
891
|
+
if (interaction.type !== "positionObject") return;
|
|
892
|
+
const [child] = interaction.childElements;
|
|
893
|
+
if (
|
|
894
|
+
interaction.childElements.length === 1 &&
|
|
895
|
+
child &&
|
|
896
|
+
["object", "img", "picture"].includes(child.qtiName)
|
|
897
|
+
)
|
|
898
|
+
return;
|
|
899
|
+
diagnostics.push({
|
|
900
|
+
code: "interaction.positionObject.children",
|
|
901
|
+
severity: "error",
|
|
902
|
+
message:
|
|
903
|
+
"Position Object requires exactly one marker image (object, img, or picture); instructions belong before its stage.",
|
|
904
|
+
path: interaction.source?.path,
|
|
905
|
+
source: interaction.source,
|
|
906
|
+
});
|
|
907
|
+
}
|