@longsightgroup/qti3-core 0.9.2 → 0.9.4
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 +108 -11
- package/dist/adaptive-turn-materializer.d.ts +2 -0
- package/dist/adaptive-turn-materializer.d.ts.map +1 -1
- package/dist/adaptive-turn-materializer.js +25 -19
- package/dist/adaptive-turn-materializer.js.map +1 -1
- package/dist/adaptive-turn.d.ts +8 -1
- package/dist/adaptive-turn.d.ts.map +1 -1
- package/dist/adaptive-turn.js +15 -5
- package/dist/adaptive-turn.js.map +1 -1
- package/dist/assert-never.d.ts +3 -0
- package/dist/assert-never.d.ts.map +1 -0
- package/dist/assert-never.js +5 -0
- package/dist/assert-never.js.map +1 -0
- package/dist/attempt-state-constants.d.ts +6 -0
- package/dist/attempt-state-constants.d.ts.map +1 -0
- package/dist/attempt-state-constants.js +6 -0
- package/dist/attempt-state-constants.js.map +1 -0
- package/dist/attempt-state.d.ts +5 -0
- package/dist/attempt-state.d.ts.map +1 -0
- package/dist/attempt-state.js +190 -0
- package/dist/attempt-state.js.map +1 -0
- package/dist/catalog.js +1 -1
- package/dist/catalog.js.map +1 -1
- package/dist/content-text.d.ts.map +1 -1
- package/dist/content-text.js +3 -0
- package/dist/content-text.js.map +1 -1
- package/dist/custom-operators.d.ts +13 -0
- package/dist/custom-operators.d.ts.map +1 -0
- package/dist/custom-operators.js +2 -0
- package/dist/custom-operators.js.map +1 -0
- package/dist/delivery-preparation.d.ts +20 -0
- package/dist/delivery-preparation.d.ts.map +1 -0
- package/dist/delivery-preparation.js +71 -0
- package/dist/delivery-preparation.js.map +1 -0
- package/dist/delivery-redaction.d.ts +5 -0
- package/dist/delivery-redaction.d.ts.map +1 -1
- package/dist/delivery-redaction.js +27 -6
- package/dist/delivery-redaction.js.map +1 -1
- package/dist/delivery-security.js +2 -2
- package/dist/delivery-security.js.map +1 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/item-scoring-expectation.d.ts +4 -0
- package/dist/item-scoring-expectation.d.ts.map +1 -0
- package/dist/item-scoring-expectation.js +6 -0
- package/dist/item-scoring-expectation.js.map +1 -0
- package/dist/item-submission-materialization.d.ts +25 -0
- package/dist/item-submission-materialization.d.ts.map +1 -0
- package/dist/item-submission-materialization.js +68 -0
- package/dist/item-submission-materialization.js.map +1 -0
- package/dist/parser-custom-interactions.js +1 -1
- package/dist/parser-custom-interactions.js.map +1 -1
- package/dist/parser-declarations.d.ts.map +1 -1
- package/dist/parser-declarations.js +4 -4
- package/dist/parser-declarations.js.map +1 -1
- package/dist/parser-item-metadata.d.ts.map +1 -1
- package/dist/parser-item-metadata.js +8 -7
- package/dist/parser-item-metadata.js.map +1 -1
- package/dist/parser-processing.js +2 -2
- package/dist/parser-processing.js.map +1 -1
- package/dist/parser-values.d.ts +5 -1
- package/dist/parser-values.d.ts.map +1 -1
- package/dist/parser-values.js +33 -0
- package/dist/parser-values.js.map +1 -1
- package/dist/processing-evaluator-boolean.d.ts +8 -0
- package/dist/processing-evaluator-boolean.d.ts.map +1 -0
- package/dist/processing-evaluator-boolean.js +47 -0
- package/dist/processing-evaluator-boolean.js.map +1 -0
- package/dist/processing-evaluator-collection.d.ts +12 -0
- package/dist/processing-evaluator-collection.d.ts.map +1 -0
- package/dist/processing-evaluator-collection.js +35 -0
- package/dist/processing-evaluator-collection.js.map +1 -0
- package/dist/processing-evaluator-comparison.d.ts +8 -0
- package/dist/processing-evaluator-comparison.d.ts.map +1 -0
- package/dist/processing-evaluator-comparison.js +88 -0
- package/dist/processing-evaluator-comparison.js.map +1 -0
- package/dist/processing-evaluator-custom.d.ts +8 -0
- package/dist/processing-evaluator-custom.d.ts.map +1 -0
- package/dist/processing-evaluator-custom.js +14 -0
- package/dist/processing-evaluator-custom.js.map +1 -0
- package/dist/processing-evaluator-geometry.d.ts +8 -0
- package/dist/processing-evaluator-geometry.d.ts.map +1 -0
- package/dist/processing-evaluator-geometry.js +21 -0
- package/dist/processing-evaluator-geometry.js.map +1 -0
- package/dist/processing-evaluator-numeric.d.ts +8 -0
- package/dist/processing-evaluator-numeric.d.ts.map +1 -0
- package/dist/processing-evaluator-numeric.js +112 -0
- package/dist/processing-evaluator-numeric.js.map +1 -0
- package/dist/processing-evaluator-string.d.ts +8 -0
- package/dist/processing-evaluator-string.d.ts.map +1 -0
- package/dist/processing-evaluator-string.js +33 -0
- package/dist/processing-evaluator-string.js.map +1 -0
- package/dist/processing-evaluator-variable.d.ts +8 -0
- package/dist/processing-evaluator-variable.d.ts.map +1 -0
- package/dist/processing-evaluator-variable.js +39 -0
- package/dist/processing-evaluator-variable.js.map +1 -0
- package/dist/processing-evaluator.d.ts +22 -0
- package/dist/processing-evaluator.d.ts.map +1 -0
- package/dist/processing-evaluator.js +137 -0
- package/dist/processing-evaluator.js.map +1 -0
- package/dist/processing-expression-children.d.ts +3 -0
- package/dist/processing-expression-children.d.ts.map +1 -0
- package/dist/processing-expression-children.js +68 -0
- package/dist/processing-expression-children.js.map +1 -0
- package/dist/processing-mapping.d.ts +13 -0
- package/dist/processing-mapping.d.ts.map +1 -0
- package/dist/processing-mapping.js +120 -0
- package/dist/processing-mapping.js.map +1 -0
- package/dist/processing-operators.d.ts +11 -0
- package/dist/processing-operators.d.ts.map +1 -0
- package/dist/processing-operators.js +178 -0
- package/dist/processing-operators.js.map +1 -0
- package/dist/processing-random.d.ts +2 -0
- package/dist/processing-random.d.ts.map +1 -0
- package/dist/processing-random.js +16 -0
- package/dist/processing-random.js.map +1 -0
- package/dist/processing-state.d.ts +8 -0
- package/dist/processing-state.d.ts.map +1 -0
- package/dist/processing-state.js +44 -0
- package/dist/processing-state.js.map +1 -0
- package/dist/processing-templates.d.ts +3 -0
- package/dist/processing-templates.d.ts.map +1 -0
- package/dist/processing-templates.js +12 -0
- package/dist/processing-templates.js.map +1 -0
- package/dist/processing-values.d.ts +15 -0
- package/dist/processing-values.d.ts.map +1 -0
- package/dist/processing-values.js +150 -0
- package/dist/processing-values.js.map +1 -0
- package/dist/processing-variables.d.ts +7 -0
- package/dist/processing-variables.d.ts.map +1 -0
- package/dist/processing-variables.js +40 -0
- package/dist/processing-variables.js.map +1 -0
- package/dist/qti-package-assets.d.ts +8 -0
- package/dist/qti-package-assets.d.ts.map +1 -0
- package/dist/qti-package-assets.js +173 -0
- package/dist/qti-package-assets.js.map +1 -0
- package/dist/qti-package-items.d.ts +14 -0
- package/dist/qti-package-items.d.ts.map +1 -0
- package/dist/qti-package-items.js +138 -0
- package/dist/qti-package-items.js.map +1 -0
- package/dist/qti-package-manifest.d.ts +13 -0
- package/dist/qti-package-manifest.d.ts.map +1 -0
- package/dist/qti-package-manifest.js +109 -0
- package/dist/qti-package-manifest.js.map +1 -0
- package/dist/qti-package-metadata.d.ts +8 -0
- package/dist/qti-package-metadata.d.ts.map +1 -0
- package/dist/qti-package-metadata.js +115 -0
- package/dist/qti-package-metadata.js.map +1 -0
- package/dist/qti-package-paths.d.ts +6 -0
- package/dist/qti-package-paths.d.ts.map +1 -0
- package/dist/qti-package-paths.js +32 -0
- package/dist/qti-package-paths.js.map +1 -0
- package/dist/qti-package-types.d.ts +98 -0
- package/dist/qti-package-types.d.ts.map +1 -0
- package/dist/qti-package-types.js +2 -0
- package/dist/qti-package-types.js.map +1 -0
- package/dist/qti-package-xml.d.ts +21 -0
- package/dist/qti-package-xml.d.ts.map +1 -0
- package/dist/qti-package-xml.js +68 -0
- package/dist/qti-package-xml.js.map +1 -0
- package/dist/qti-package-zip.d.ts +24 -0
- package/dist/qti-package-zip.d.ts.map +1 -0
- package/dist/qti-package-zip.js +185 -0
- package/dist/qti-package-zip.js.map +1 -0
- package/dist/qti-package.d.ts +7 -0
- package/dist/qti-package.d.ts.map +1 -0
- package/dist/qti-package.js +76 -0
- package/dist/qti-package.js.map +1 -0
- package/dist/response-input.d.ts +9 -0
- package/dist/response-input.d.ts.map +1 -0
- package/dist/response-input.js +9 -0
- package/dist/response-input.js.map +1 -0
- package/dist/response-validation-policy.d.ts +31 -0
- package/dist/response-validation-policy.d.ts.map +1 -0
- package/dist/response-validation-policy.js +158 -0
- package/dist/response-validation-policy.js.map +1 -0
- package/dist/response-validation.d.ts +23 -0
- package/dist/response-validation.d.ts.map +1 -0
- package/dist/response-validation.js +157 -0
- package/dist/response-validation.js.map +1 -0
- package/dist/scoring-disposition-policy.d.ts +8 -0
- package/dist/scoring-disposition-policy.d.ts.map +1 -0
- package/dist/scoring-disposition-policy.js +24 -0
- package/dist/scoring-disposition-policy.js.map +1 -0
- package/dist/serializer-processing-expressions.d.ts.map +1 -1
- package/dist/serializer-processing-expressions.js +2 -8
- package/dist/serializer-processing-expressions.js.map +1 -1
- package/dist/serializer-processing-xml.js +2 -2
- package/dist/serializer-processing-xml.js.map +1 -1
- package/dist/serializer-processing.d.ts.map +1 -1
- package/dist/serializer-processing.js +3 -0
- package/dist/serializer-processing.js.map +1 -1
- package/dist/session.d.ts +5 -14
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +76 -1158
- package/dist/session.js.map +1 -1
- package/dist/shared-vocabulary-coverage-policy.js +1 -1
- package/dist/shared-vocabulary-coverage-policy.js.map +1 -1
- package/dist/shared-vocabulary-generated-families.js +1 -1
- package/dist/shared-vocabulary-generated-families.js.map +1 -1
- package/dist/shared-vocabulary-support.js +3 -3
- package/dist/shared-vocabulary-support.js.map +1 -1
- package/dist/shared-vocabulary.d.ts.map +1 -1
- package/dist/shared-vocabulary.js +4 -3
- package/dist/shared-vocabulary.js.map +1 -1
- package/dist/support-evidence.d.ts +22 -0
- package/dist/support-evidence.d.ts.map +1 -0
- package/dist/support-evidence.js +93 -0
- package/dist/support-evidence.js.map +1 -0
- package/dist/support.d.ts.map +1 -1
- package/dist/support.js +30 -43
- package/dist/support.js.map +1 -1
- package/dist/template-presentation-materializer.d.ts +14 -0
- package/dist/template-presentation-materializer.d.ts.map +1 -0
- package/dist/template-presentation-materializer.js +143 -0
- package/dist/template-presentation-materializer.js.map +1 -0
- package/dist/trusted-item-session.d.ts +25 -8
- package/dist/trusted-item-session.d.ts.map +1 -1
- package/dist/trusted-item-session.js +57 -51
- package/dist/trusted-item-session.js.map +1 -1
- package/dist/trusted-session-materialization.d.ts +33 -0
- package/dist/trusted-session-materialization.d.ts.map +1 -0
- package/dist/trusted-session-materialization.js +26 -0
- package/dist/trusted-session-materialization.js.map +1 -0
- package/dist/tts.d.ts.map +1 -1
- package/dist/tts.js +16 -2
- package/dist/tts.js.map +1 -1
- package/dist/validation-geometry.d.ts +8 -0
- package/dist/validation-geometry.d.ts.map +1 -0
- package/dist/validation-geometry.js +187 -0
- package/dist/validation-geometry.js.map +1 -0
- package/dist/validation-interactions.d.ts +3 -0
- package/dist/validation-interactions.d.ts.map +1 -0
- package/dist/validation-interactions.js +644 -0
- package/dist/validation-interactions.js.map +1 -0
- package/dist/validation-primitives.d.ts +12 -0
- package/dist/validation-primitives.d.ts.map +1 -0
- package/dist/validation-primitives.js +53 -0
- package/dist/validation-primitives.js.map +1 -0
- package/dist/validation-processing.d.ts +4 -0
- package/dist/validation-processing.d.ts.map +1 -0
- package/dist/validation-processing.js +569 -0
- package/dist/validation-processing.js.map +1 -0
- package/dist/validation-shared-vocabulary-layout.d.ts +10 -0
- package/dist/validation-shared-vocabulary-layout.d.ts.map +1 -0
- package/dist/validation-shared-vocabulary-layout.js +102 -0
- package/dist/validation-shared-vocabulary-layout.js.map +1 -0
- package/dist/validation.d.ts.map +1 -1
- package/dist/validation.js +27 -1641
- package/dist/validation.js.map +1 -1
- package/dist/variable-snapshot.d.ts +9 -0
- package/dist/variable-snapshot.d.ts.map +1 -0
- package/dist/variable-snapshot.js +9 -0
- package/dist/variable-snapshot.js.map +1 -0
- package/dist/xml.js +1 -1
- package/dist/xml.js.map +1 -1
- package/package.json +1 -1
- package/src/adaptive-turn-materializer.ts +29 -16
- package/src/adaptive-turn.ts +29 -6
- package/src/assert-never.ts +4 -0
- package/src/attempt-state-constants.ts +5 -0
- package/src/attempt-state.ts +245 -0
- package/src/catalog.ts +1 -1
- package/src/content-text.ts +3 -0
- package/src/custom-operators.ts +12 -0
- package/src/delivery-preparation.ts +121 -0
- package/src/delivery-redaction.ts +37 -6
- package/src/delivery-security.ts +2 -2
- package/src/index.ts +67 -0
- package/src/item-scoring-expectation.ts +9 -0
- package/src/item-submission-materialization.ts +120 -0
- package/src/parser-custom-interactions.ts +1 -1
- package/src/parser-declarations.ts +4 -3
- package/src/parser-item-metadata.ts +8 -7
- package/src/parser-processing.ts +2 -2
- package/src/parser-values.ts +36 -0
- package/src/processing-evaluator-boolean.ts +48 -0
- package/src/processing-evaluator-collection.ts +50 -0
- package/src/processing-evaluator-comparison.ts +106 -0
- package/src/processing-evaluator-custom.ts +20 -0
- package/src/processing-evaluator-geometry.ts +26 -0
- package/src/processing-evaluator-numeric.ts +155 -0
- package/src/processing-evaluator-string.ts +60 -0
- package/src/processing-evaluator-variable.ts +81 -0
- package/src/processing-evaluator.ts +173 -0
- package/src/processing-expression-children.ts +69 -0
- package/src/processing-mapping.ts +153 -0
- package/src/processing-operators.ts +184 -0
- package/src/processing-random.ts +16 -0
- package/src/processing-state.ts +69 -0
- package/src/processing-templates.ts +12 -0
- package/src/processing-values.ts +162 -0
- package/src/processing-variables.ts +83 -0
- package/src/qti-package-assets.ts +256 -0
- package/src/qti-package-items.ts +240 -0
- package/src/qti-package-manifest.ts +210 -0
- package/src/qti-package-metadata.ts +143 -0
- package/src/qti-package-paths.ts +56 -0
- package/src/qti-package-types.ts +117 -0
- package/src/qti-package-xml.ts +97 -0
- package/src/qti-package-zip.ts +325 -0
- package/src/qti-package.ts +176 -0
- package/src/response-input.ts +19 -0
- package/src/response-validation-policy.ts +218 -0
- package/src/response-validation.ts +282 -0
- package/src/scoring-disposition-policy.ts +37 -0
- package/src/serializer-processing-expressions.ts +2 -12
- package/src/serializer-processing-xml.ts +2 -2
- package/src/serializer-processing.ts +3 -0
- package/src/session.ts +128 -2006
- package/src/shared-vocabulary-coverage-policy.ts +1 -1
- package/src/shared-vocabulary-generated-families.ts +1 -1
- package/src/shared-vocabulary-support.ts +3 -3
- package/src/shared-vocabulary.ts +3 -2
- package/src/support-evidence.ts +107 -0
- package/src/support.ts +59 -50
- package/src/template-presentation-materializer.ts +219 -0
- package/src/trusted-item-session.ts +86 -78
- package/src/trusted-session-materialization.ts +67 -0
- package/src/tts.ts +29 -15
- package/src/validation-geometry.ts +204 -0
- package/src/validation-interactions.ts +831 -0
- package/src/validation-primitives.ts +68 -0
- package/src/validation-processing.ts +785 -0
- package/src/validation-shared-vocabulary-layout.ts +117 -0
- package/src/validation.ts +44 -2077
- package/src/variable-snapshot.ts +20 -0
- package/src/xml.ts +1 -1
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { parseQtiXml } from "./parser.js";
|
|
2
|
+
import { itemExpectsAutomatedScore } from "./item-scoring-expectation.js";
|
|
3
|
+
import { validateQtiResponseVariables } from "./response-validation.js";
|
|
2
4
|
import { createItemSession, isQtiAttemptStateV1, type QtiItemSession } from "./session.js";
|
|
3
5
|
import type {
|
|
4
|
-
QtiAssessmentItem,
|
|
5
6
|
QtiAttemptStateV1,
|
|
6
7
|
QtiAttemptStatus,
|
|
7
8
|
QtiDiagnostic,
|
|
@@ -10,20 +11,20 @@ import type {
|
|
|
10
11
|
QtiValue,
|
|
11
12
|
} from "./types.js";
|
|
12
13
|
import { isQtiPortableCustomStateValue, readQtiJsonValue } from "./value-format.js";
|
|
14
|
+
import {
|
|
15
|
+
listNamedResponseInputs,
|
|
16
|
+
type QtiNamedResponseInput,
|
|
17
|
+
type QtiNamedResponsesInput,
|
|
18
|
+
} from "./response-input.js";
|
|
13
19
|
|
|
14
|
-
export type
|
|
20
|
+
export type QtiTrustedResponseInput = QtiNamedResponseInput;
|
|
15
21
|
|
|
16
|
-
export
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
}
|
|
22
|
+
export type QtiTrustedResponsesInput = QtiNamedResponsesInput;
|
|
23
|
+
|
|
24
|
+
export type QtiTrustedInputDiagnosticPrefix = "serverScoring" | "adaptiveTurn" | "itemSubmission";
|
|
20
25
|
|
|
21
|
-
export type
|
|
22
|
-
| Record<string, unknown>
|
|
23
|
-
| readonly QtiTrustedResponseInput[]
|
|
24
|
-
| undefined;
|
|
26
|
+
export type QtiTrustedSubmissionValidation = "trusted" | "strict";
|
|
25
27
|
|
|
26
|
-
/** Trusted candidate responses and portable custom interaction state for one application. */
|
|
27
28
|
export interface QtiTrustedResponseApplication {
|
|
28
29
|
trustedResponses?: QtiTrustedResponsesInput;
|
|
29
30
|
trustedInteractionStates?: Record<string, QtiPortableCustomStateValue> | undefined;
|
|
@@ -33,7 +34,7 @@ export interface QtiTrustedItemParseOptions {
|
|
|
33
34
|
allowedUndeclaredResponseIdentifiers?: readonly string[] | undefined;
|
|
34
35
|
}
|
|
35
36
|
|
|
36
|
-
interface
|
|
37
|
+
export interface QtiTrustedItemDocument {
|
|
37
38
|
document: QtiDocument;
|
|
38
39
|
diagnostics: QtiDiagnostic[];
|
|
39
40
|
responseIdentifiers: Set<string>;
|
|
@@ -43,6 +44,7 @@ export type QtiTrustedItemScoringPolicy = "always" | "onSubmission";
|
|
|
43
44
|
|
|
44
45
|
export interface RunTrustedItemSessionInput extends QtiTrustedItemParseOptions {
|
|
45
46
|
itemXml: string;
|
|
47
|
+
parsedItem?: QtiTrustedItemDocument | undefined;
|
|
46
48
|
diagnosticPrefix: QtiTrustedInputDiagnosticPrefix;
|
|
47
49
|
submission: QtiTrustedResponseApplication;
|
|
48
50
|
priorState?: QtiAttemptStateV1 | null | undefined;
|
|
@@ -50,6 +52,12 @@ export interface RunTrustedItemSessionInput extends QtiTrustedItemParseOptions {
|
|
|
50
52
|
attemptStatus?: QtiAttemptStatus | undefined;
|
|
51
53
|
scoring: QtiTrustedItemScoringPolicy;
|
|
52
54
|
requireNumericScore: boolean;
|
|
55
|
+
/**
|
|
56
|
+
* `strict` rejects undeclared and malformed responses before trusted application.
|
|
57
|
+
* `trusted` keeps the legacy warning path for undeclared identifiers.
|
|
58
|
+
*/
|
|
59
|
+
submissionValidation?: QtiTrustedSubmissionValidation | undefined;
|
|
60
|
+
allowIncompleteResponses?: boolean | undefined;
|
|
53
61
|
}
|
|
54
62
|
|
|
55
63
|
export interface RunTrustedItemSessionSuccess {
|
|
@@ -86,19 +94,58 @@ export function readPriorAttemptState(
|
|
|
86
94
|
{
|
|
87
95
|
code: `${diagnosticPrefix}.state.value`,
|
|
88
96
|
severity: "error",
|
|
89
|
-
message: "Prior
|
|
97
|
+
message: "Prior attempt state is not a valid qti3.attempt-state.v1 value.",
|
|
90
98
|
},
|
|
91
99
|
],
|
|
92
100
|
};
|
|
93
101
|
}
|
|
94
102
|
|
|
103
|
+
export function parseTrustedItemDocument(
|
|
104
|
+
itemXml: string,
|
|
105
|
+
allowedUndeclaredResponseIdentifiers: readonly string[] = [],
|
|
106
|
+
): { ok: true; parsed: QtiTrustedItemDocument } | { ok: false; diagnostics: QtiDiagnostic[] } {
|
|
107
|
+
let parsed: ReturnType<typeof parseQtiXml>;
|
|
108
|
+
try {
|
|
109
|
+
parsed = parseQtiXml(itemXml);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
return {
|
|
112
|
+
ok: false,
|
|
113
|
+
diagnostics: [
|
|
114
|
+
{
|
|
115
|
+
code: "xml.parse",
|
|
116
|
+
severity: "error",
|
|
117
|
+
message: error instanceof Error ? error.message : String(error),
|
|
118
|
+
},
|
|
119
|
+
],
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const allowedUndeclared = new Set(allowedUndeclaredResponseIdentifiers);
|
|
124
|
+
const diagnostics = parsed.diagnostics.filter(
|
|
125
|
+
(diagnostic) => !isAllowedUndeclaredVariableReference(diagnostic, allowedUndeclared),
|
|
126
|
+
);
|
|
127
|
+
if (!parsed.document || diagnostics.some((diagnostic) => diagnostic.severity === "error")) {
|
|
128
|
+
return { ok: false, diagnostics };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
ok: true,
|
|
133
|
+
parsed: {
|
|
134
|
+
document: parsed.document,
|
|
135
|
+
diagnostics,
|
|
136
|
+
responseIdentifiers: new Set(
|
|
137
|
+
parsed.document.item.responseDeclarations.map((declaration) => declaration.identifier),
|
|
138
|
+
),
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
95
143
|
export function runTrustedItemSession(
|
|
96
144
|
input: RunTrustedItemSessionInput,
|
|
97
145
|
): RunTrustedItemSessionResult {
|
|
98
|
-
const parsedResult =
|
|
99
|
-
input.
|
|
100
|
-
input.allowedUndeclaredResponseIdentifiers
|
|
101
|
-
);
|
|
146
|
+
const parsedResult = input.parsedItem
|
|
147
|
+
? { ok: true as const, parsed: input.parsedItem }
|
|
148
|
+
: parseTrustedItemDocument(input.itemXml, input.allowedUndeclaredResponseIdentifiers);
|
|
102
149
|
if (!parsedResult.ok) {
|
|
103
150
|
return emptyTrustedItemSessionFailure(parsedResult.diagnostics);
|
|
104
151
|
}
|
|
@@ -107,6 +154,7 @@ export function runTrustedItemSession(
|
|
|
107
154
|
parsedResult.parsed,
|
|
108
155
|
input.priorState,
|
|
109
156
|
input.diagnosticPrefix,
|
|
157
|
+
input.allowedUndeclaredResponseIdentifiers,
|
|
110
158
|
parsedResult.parsed.diagnostics,
|
|
111
159
|
);
|
|
112
160
|
if (!sessionResult.ok) {
|
|
@@ -115,6 +163,18 @@ export function runTrustedItemSession(
|
|
|
115
163
|
|
|
116
164
|
if (input.attemptStatus) sessionResult.session.setStatus(input.attemptStatus);
|
|
117
165
|
|
|
166
|
+
if (input.submissionValidation === "strict") {
|
|
167
|
+
const validation = validateQtiResponseVariables({
|
|
168
|
+
item: parsedResult.parsed.document.item,
|
|
169
|
+
responses: input.submission.trustedResponses ?? {},
|
|
170
|
+
allowIncompleteResponses: input.allowIncompleteResponses,
|
|
171
|
+
allowedUndeclaredResponseIdentifiers: input.allowedUndeclaredResponseIdentifiers,
|
|
172
|
+
});
|
|
173
|
+
if (!validation.ok) {
|
|
174
|
+
return emptyTrustedItemSessionFailure(validation.diagnostics);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
118
178
|
const applicationResult = applyTrustedResponseApplication(
|
|
119
179
|
sessionResult.session,
|
|
120
180
|
parsedResult.parsed,
|
|
@@ -127,9 +187,7 @@ export function runTrustedItemSession(
|
|
|
127
187
|
return emptyTrustedItemSessionFailure(diagnostics);
|
|
128
188
|
}
|
|
129
189
|
|
|
130
|
-
const shouldScore =
|
|
131
|
-
input.scoring === "always" ||
|
|
132
|
-
(input.scoring === "onSubmission" && applicationResult.appliedSubmission);
|
|
190
|
+
const shouldScore = input.scoring === "always" || applicationResult.appliedSubmission;
|
|
133
191
|
|
|
134
192
|
let outcomes = sessionResult.session.serialize().outcomes;
|
|
135
193
|
let state = sessionResult.session.serialize();
|
|
@@ -165,7 +223,7 @@ export function runTrustedItemSession(
|
|
|
165
223
|
|
|
166
224
|
if (
|
|
167
225
|
input.requireNumericScore &&
|
|
168
|
-
|
|
226
|
+
itemExpectsAutomatedScore(parsedResult.parsed.document.item) &&
|
|
169
227
|
score === null
|
|
170
228
|
) {
|
|
171
229
|
return emptyTrustedItemSessionFailure(
|
|
@@ -210,56 +268,19 @@ function emptyTrustedItemSessionFailure(
|
|
|
210
268
|
};
|
|
211
269
|
}
|
|
212
270
|
|
|
213
|
-
function parseTrustedItemXml(
|
|
214
|
-
itemXml: string,
|
|
215
|
-
allowedUndeclaredResponseIdentifiers: readonly string[] = [],
|
|
216
|
-
): { ok: true; parsed: QtiParsedTrustedItem } | { ok: false; diagnostics: QtiDiagnostic[] } {
|
|
217
|
-
let parsed: ReturnType<typeof parseQtiXml>;
|
|
218
|
-
try {
|
|
219
|
-
parsed = parseQtiXml(itemXml);
|
|
220
|
-
} catch (error) {
|
|
221
|
-
return {
|
|
222
|
-
ok: false,
|
|
223
|
-
diagnostics: [
|
|
224
|
-
{
|
|
225
|
-
code: "xml.parse",
|
|
226
|
-
severity: "error",
|
|
227
|
-
message: error instanceof Error ? error.message : String(error),
|
|
228
|
-
},
|
|
229
|
-
],
|
|
230
|
-
};
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
const allowedUndeclared = new Set(allowedUndeclaredResponseIdentifiers);
|
|
234
|
-
const diagnostics = parsed.diagnostics.filter(
|
|
235
|
-
(diagnostic) => !isAllowedUndeclaredVariableReference(diagnostic, allowedUndeclared),
|
|
236
|
-
);
|
|
237
|
-
if (!parsed.document || diagnostics.some((diagnostic) => diagnostic.severity === "error")) {
|
|
238
|
-
return { ok: false, diagnostics };
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
return {
|
|
242
|
-
ok: true,
|
|
243
|
-
parsed: {
|
|
244
|
-
document: parsed.document,
|
|
245
|
-
diagnostics,
|
|
246
|
-
responseIdentifiers: new Set(
|
|
247
|
-
parsed.document.item.responseDeclarations.map((declaration) => declaration.identifier),
|
|
248
|
-
),
|
|
249
|
-
},
|
|
250
|
-
};
|
|
251
|
-
}
|
|
252
|
-
|
|
253
271
|
function createTrustedItemSession(
|
|
254
|
-
parsed:
|
|
272
|
+
parsed: QtiTrustedItemDocument,
|
|
255
273
|
priorState: QtiAttemptStateV1 | null | undefined,
|
|
256
274
|
diagnosticPrefix: QtiTrustedInputDiagnosticPrefix,
|
|
275
|
+
allowedUndeclaredResponseIdentifiers: readonly string[] | undefined,
|
|
257
276
|
existingDiagnostics: QtiDiagnostic[] = [],
|
|
258
277
|
): { ok: true; session: QtiItemSession } | { ok: false; diagnostics: QtiDiagnostic[] } {
|
|
259
278
|
try {
|
|
260
279
|
return {
|
|
261
280
|
ok: true,
|
|
262
|
-
session: createItemSession(parsed.document, priorState ?? undefined
|
|
281
|
+
session: createItemSession(parsed.document, priorState ?? undefined, {
|
|
282
|
+
allowedUndeclaredResponseIdentifiers,
|
|
283
|
+
}),
|
|
263
284
|
};
|
|
264
285
|
} catch (error) {
|
|
265
286
|
return {
|
|
@@ -278,7 +299,7 @@ function createTrustedItemSession(
|
|
|
278
299
|
|
|
279
300
|
function applyTrustedResponseApplication(
|
|
280
301
|
session: QtiItemSession,
|
|
281
|
-
parsed:
|
|
302
|
+
parsed: QtiTrustedItemDocument,
|
|
282
303
|
submission: QtiTrustedResponseApplication,
|
|
283
304
|
allowedUndeclaredResponseIdentifiers: readonly string[] | undefined,
|
|
284
305
|
diagnosticPrefix: QtiTrustedInputDiagnosticPrefix,
|
|
@@ -287,7 +308,7 @@ function applyTrustedResponseApplication(
|
|
|
287
308
|
const allowedUndeclared = new Set(allowedUndeclaredResponseIdentifiers ?? []);
|
|
288
309
|
let appliedSubmission = false;
|
|
289
310
|
|
|
290
|
-
for (const response of
|
|
311
|
+
for (const response of listNamedResponseInputs(submission.trustedResponses)) {
|
|
291
312
|
const identifier = response.identifier.trim();
|
|
292
313
|
if (!identifier) {
|
|
293
314
|
diagnostics.push({
|
|
@@ -369,13 +390,6 @@ function scoreTrustedItemSession(
|
|
|
369
390
|
}
|
|
370
391
|
}
|
|
371
392
|
|
|
372
|
-
function shouldRequireNumericScore(item: QtiAssessmentItem): boolean {
|
|
373
|
-
return (
|
|
374
|
-
Boolean(item.responseProcessing) ||
|
|
375
|
-
item.outcomeDeclarations.some((declaration) => declaration.identifier === "SCORE")
|
|
376
|
-
);
|
|
377
|
-
}
|
|
378
|
-
|
|
379
393
|
function readNumericScore(value: QtiValue | undefined): number | null {
|
|
380
394
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
381
395
|
if (typeof value === "string") {
|
|
@@ -409,12 +423,6 @@ function stripUndeclaredResponses(
|
|
|
409
423
|
};
|
|
410
424
|
}
|
|
411
425
|
|
|
412
|
-
function normalizeResponseInputs(responses: QtiTrustedResponsesInput): QtiTrustedResponseInput[] {
|
|
413
|
-
if (!responses) return [];
|
|
414
|
-
if (Array.isArray(responses)) return [...responses];
|
|
415
|
-
return Object.entries(responses).map(([identifier, value]) => ({ identifier, value }));
|
|
416
|
-
}
|
|
417
|
-
|
|
418
426
|
function isAllowedUndeclaredVariableReference(
|
|
419
427
|
diagnostic: QtiDiagnostic,
|
|
420
428
|
allowedUndeclaredResponseIdentifiers: Set<string>,
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import {
|
|
2
|
+
classifyQtiItemScoringDisposition,
|
|
3
|
+
type QtiItemSubmissionScoringDisposition,
|
|
4
|
+
} from "./scoring-disposition-policy.js";
|
|
5
|
+
import type { QtiAssessmentItem, QtiAttemptStateV1, QtiDiagnostic, QtiValue } from "./types.js";
|
|
6
|
+
import { snapshotQtiVariableDeclarations, type QtiVariableSnapshot } from "./variable-snapshot.js";
|
|
7
|
+
|
|
8
|
+
export interface QtiTrustedSessionMaterialization {
|
|
9
|
+
diagnostics: QtiDiagnostic[];
|
|
10
|
+
state: QtiAttemptStateV1;
|
|
11
|
+
responses: Record<string, QtiValue>;
|
|
12
|
+
outcomes: Record<string, QtiValue>;
|
|
13
|
+
score: number | null;
|
|
14
|
+
candidateSafeXml?: string | undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface QtiItemSubmissionMaterializationSuccess {
|
|
18
|
+
ok: true;
|
|
19
|
+
diagnostics: QtiDiagnostic[];
|
|
20
|
+
state: QtiAttemptStateV1;
|
|
21
|
+
responseVariables: QtiVariableSnapshot[];
|
|
22
|
+
outcomeVariables: QtiVariableSnapshot[];
|
|
23
|
+
score: number | null;
|
|
24
|
+
scoringDisposition: Exclude<QtiItemSubmissionScoringDisposition, "invalid">;
|
|
25
|
+
candidateSafeXml?: string | undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface QtiItemSubmissionMaterializationFailure {
|
|
29
|
+
ok: false;
|
|
30
|
+
diagnostics: QtiDiagnostic[];
|
|
31
|
+
state?: QtiAttemptStateV1 | undefined;
|
|
32
|
+
responseVariables: QtiVariableSnapshot[];
|
|
33
|
+
outcomeVariables: QtiVariableSnapshot[];
|
|
34
|
+
score: null;
|
|
35
|
+
scoringDisposition: "invalid";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function buildItemSubmissionMaterializationSuccess(
|
|
39
|
+
item: QtiAssessmentItem,
|
|
40
|
+
result: QtiTrustedSessionMaterialization,
|
|
41
|
+
): QtiItemSubmissionMaterializationSuccess {
|
|
42
|
+
return {
|
|
43
|
+
ok: true,
|
|
44
|
+
diagnostics: result.diagnostics,
|
|
45
|
+
state: result.state,
|
|
46
|
+
responseVariables: snapshotQtiVariableDeclarations(item.responseDeclarations, result.responses),
|
|
47
|
+
outcomeVariables: snapshotQtiVariableDeclarations(item.outcomeDeclarations, result.outcomes),
|
|
48
|
+
score: result.score,
|
|
49
|
+
scoringDisposition: classifyQtiItemScoringDisposition(item, result.score),
|
|
50
|
+
candidateSafeXml: result.candidateSafeXml,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function buildItemSubmissionMaterializationFailure(
|
|
55
|
+
diagnostics: QtiDiagnostic[],
|
|
56
|
+
state?: QtiAttemptStateV1,
|
|
57
|
+
): QtiItemSubmissionMaterializationFailure {
|
|
58
|
+
return {
|
|
59
|
+
ok: false,
|
|
60
|
+
diagnostics,
|
|
61
|
+
state,
|
|
62
|
+
responseVariables: [],
|
|
63
|
+
outcomeVariables: [],
|
|
64
|
+
score: null,
|
|
65
|
+
scoringDisposition: "invalid",
|
|
66
|
+
};
|
|
67
|
+
}
|
package/src/tts.ts
CHANGED
|
@@ -103,10 +103,17 @@ interface SegmentInput {
|
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
const dataSsmlFunctionNames = new Set(["break", "phoneme", "prosody", "say-as", "sub"]);
|
|
106
|
-
const breakStrengths = new Set([
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
106
|
+
const breakStrengths = new Set<QtiDataSsmlBreakStrength>([
|
|
107
|
+
"medium",
|
|
108
|
+
"none",
|
|
109
|
+
"strong",
|
|
110
|
+
"weak",
|
|
111
|
+
"x-strong",
|
|
112
|
+
"x-weak",
|
|
113
|
+
]);
|
|
114
|
+
const phonemeAlphabets = new Set<NonNullable<QtiDataSsmlPhoneme["alphabet"]>>(["ipa", "x-sampa"]);
|
|
115
|
+
const phonemeTypes = new Set<NonNullable<QtiDataSsmlPhoneme["type"]>>(["default", "ruby"]);
|
|
116
|
+
const sayAsInterpretations = new Set<QtiDataSsmlSayAs["interpret-as"]>([
|
|
110
117
|
"cardinal",
|
|
111
118
|
"characters",
|
|
112
119
|
"date",
|
|
@@ -200,7 +207,7 @@ function validateBreak(value: Record<string, unknown>, errors: string[]): QtiDat
|
|
|
200
207
|
const result: QtiDataSsmlBreak = {};
|
|
201
208
|
const strength = optionalEnum(value, "strength", "break.strength", breakStrengths, errors);
|
|
202
209
|
const time = optionalString(value, "time", "break.time", errors);
|
|
203
|
-
if (strength) result.strength = strength
|
|
210
|
+
if (strength) result.strength = strength;
|
|
204
211
|
if (time !== undefined) result.time = time;
|
|
205
212
|
return result;
|
|
206
213
|
}
|
|
@@ -215,8 +222,8 @@ function validatePhoneme(
|
|
|
215
222
|
const type = optionalEnum(value, "type", "phoneme.type", phonemeTypes, errors);
|
|
216
223
|
if (!ph) return undefined;
|
|
217
224
|
const result: QtiDataSsmlPhoneme = { ph };
|
|
218
|
-
if (alphabet) result.alphabet = alphabet
|
|
219
|
-
if (type) result.type = type
|
|
225
|
+
if (alphabet) result.alphabet = alphabet;
|
|
226
|
+
if (type) result.type = type;
|
|
220
227
|
return result;
|
|
221
228
|
}
|
|
222
229
|
|
|
@@ -241,7 +248,7 @@ function validateSayAs(
|
|
|
241
248
|
errors,
|
|
242
249
|
);
|
|
243
250
|
if (!interpretation) return undefined;
|
|
244
|
-
return { "interpret-as": interpretation
|
|
251
|
+
return { "interpret-as": interpretation };
|
|
245
252
|
}
|
|
246
253
|
|
|
247
254
|
function validateSub(value: Record<string, unknown>, errors: string[]): QtiDataSsmlSub | undefined {
|
|
@@ -520,29 +527,36 @@ function requiredString(
|
|
|
520
527
|
return optionalString(value, name, path, errors);
|
|
521
528
|
}
|
|
522
529
|
|
|
523
|
-
function
|
|
530
|
+
function isAllowedEnum<T extends string>(value: string, allowed: ReadonlySet<T>): value is T {
|
|
531
|
+
for (const entry of allowed) {
|
|
532
|
+
if (entry === value) return true;
|
|
533
|
+
}
|
|
534
|
+
return false;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function optionalEnum<T extends string>(
|
|
524
538
|
value: Record<string, unknown>,
|
|
525
539
|
name: string,
|
|
526
540
|
path: string,
|
|
527
|
-
allowed:
|
|
541
|
+
allowed: ReadonlySet<T>,
|
|
528
542
|
errors: string[],
|
|
529
|
-
):
|
|
543
|
+
): T | undefined {
|
|
530
544
|
const property = optionalString(value, name, path, errors);
|
|
531
545
|
if (property === undefined) return undefined;
|
|
532
|
-
if (!
|
|
546
|
+
if (!isAllowedEnum(property, allowed)) {
|
|
533
547
|
errors.push(`${path} has unsupported value "${property}".`);
|
|
534
548
|
return undefined;
|
|
535
549
|
}
|
|
536
550
|
return property;
|
|
537
551
|
}
|
|
538
552
|
|
|
539
|
-
function requiredEnum(
|
|
553
|
+
function requiredEnum<T extends string>(
|
|
540
554
|
value: Record<string, unknown>,
|
|
541
555
|
name: string,
|
|
542
556
|
path: string,
|
|
543
|
-
allowed:
|
|
557
|
+
allowed: ReadonlySet<T>,
|
|
544
558
|
errors: string[],
|
|
545
|
-
):
|
|
559
|
+
): T | undefined {
|
|
546
560
|
if (!Object.hasOwn(value, name)) {
|
|
547
561
|
errors.push(`${path} is required.`);
|
|
548
562
|
return undefined;
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import type { QtiChoice, QtiDiagnostic, QtiInteraction } from "./types.js";
|
|
2
|
+
import { assertNever } from "./assert-never.js";
|
|
3
|
+
import { numericTuple3, numericTuple4 } from "./parser-values.js";
|
|
4
|
+
import { isFiniteNumber } from "./validation-primitives.js";
|
|
5
|
+
|
|
6
|
+
export function validateHotspotGeometry(choice: QtiChoice, diagnostics: QtiDiagnostic[]): void {
|
|
7
|
+
if (choice.qtiName !== "qti-hotspot-choice" && choice.qtiName !== "qti-associable-hotspot") {
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const shape = choice.attributes.shape;
|
|
12
|
+
const coords = choice.attributes.coords;
|
|
13
|
+
if (!shape) {
|
|
14
|
+
diagnostics.push({
|
|
15
|
+
code: "choice.shape.required",
|
|
16
|
+
severity: "error",
|
|
17
|
+
message: `${choice.qtiName} ${choice.identifier} requires shape.`,
|
|
18
|
+
path: choice.source?.path,
|
|
19
|
+
source: choice.source,
|
|
20
|
+
});
|
|
21
|
+
} else if (!isHotspotShape(shape)) {
|
|
22
|
+
diagnostics.push({
|
|
23
|
+
code: "choice.shape",
|
|
24
|
+
severity: "error",
|
|
25
|
+
message: `${choice.qtiName} ${choice.identifier} has unsupported shape ${shape}.`,
|
|
26
|
+
path: choice.source?.path,
|
|
27
|
+
source: choice.source,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!coords) {
|
|
32
|
+
diagnostics.push({
|
|
33
|
+
code: "choice.coords.required",
|
|
34
|
+
severity: "error",
|
|
35
|
+
message: `${choice.qtiName} ${choice.identifier} requires coords.`,
|
|
36
|
+
path: choice.source?.path,
|
|
37
|
+
source: choice.source,
|
|
38
|
+
});
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const values = coords.split(",").map((value) => value.trim());
|
|
43
|
+
if (values.length === 0 || values.some((value) => value.length === 0 || !isFiniteNumber(value))) {
|
|
44
|
+
diagnostics.push({
|
|
45
|
+
code: "choice.coords",
|
|
46
|
+
severity: "error",
|
|
47
|
+
message: `${choice.qtiName} ${choice.identifier} requires comma-separated numeric coords.`,
|
|
48
|
+
path: choice.source?.path,
|
|
49
|
+
source: choice.source,
|
|
50
|
+
});
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (shape && isHotspotShape(shape) && !hasValidShapeCoordinateCount(shape, values.map(Number))) {
|
|
55
|
+
diagnostics.push({
|
|
56
|
+
code: "choice.coords.shape",
|
|
57
|
+
severity: "error",
|
|
58
|
+
message: `${choice.qtiName} ${choice.identifier} shape ${shape} has invalid coords arity.`,
|
|
59
|
+
path: choice.source?.path,
|
|
60
|
+
source: choice.source,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function validateGraphicHotspotObjectDimensions(
|
|
66
|
+
interaction: QtiInteraction,
|
|
67
|
+
diagnostics: QtiDiagnostic[],
|
|
68
|
+
): void {
|
|
69
|
+
if (!usesGraphicHotspots(interaction)) return;
|
|
70
|
+
const hotspotChoices = interaction.choices.filter(isHotspotChoice);
|
|
71
|
+
if (hotspotChoices.length === 0) return;
|
|
72
|
+
|
|
73
|
+
const width = positiveDimension(interaction.object?.width);
|
|
74
|
+
const height = positiveDimension(interaction.object?.height);
|
|
75
|
+
if (width === undefined || height === undefined) {
|
|
76
|
+
diagnostics.push({
|
|
77
|
+
code: "interaction.graphicObjectDimensions",
|
|
78
|
+
severity: "warning",
|
|
79
|
+
message: `${interaction.qtiName} should declare object width and height so hotspot coords map to the rendered image.`,
|
|
80
|
+
path: interaction.object?.source?.path ?? interaction.source?.path,
|
|
81
|
+
source: interaction.object?.source ?? interaction.source,
|
|
82
|
+
});
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (const choice of hotspotChoices) {
|
|
87
|
+
const bounds = hotspotBounds(choice);
|
|
88
|
+
if (!bounds) continue;
|
|
89
|
+
if (bounds.left >= 0 && bounds.top >= 0 && bounds.right <= width && bounds.bottom <= height) {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
diagnostics.push({
|
|
93
|
+
code: "choice.coords.bounds",
|
|
94
|
+
severity: "warning",
|
|
95
|
+
message: `${choice.qtiName} ${choice.identifier} coords extend outside the ${width} by ${height} object image.`,
|
|
96
|
+
path: choice.source?.path,
|
|
97
|
+
source: choice.source,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function usesGraphicHotspots(interaction: QtiInteraction): boolean {
|
|
103
|
+
return (
|
|
104
|
+
interaction.type === "graphicOrder" ||
|
|
105
|
+
interaction.type === "graphicAssociate" ||
|
|
106
|
+
interaction.type === "graphicGapMatch" ||
|
|
107
|
+
interaction.type === "hotspot"
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function isHotspotChoice(choice: QtiChoice): boolean {
|
|
112
|
+
return choice.qtiName === "qti-hotspot-choice" || choice.qtiName === "qti-associable-hotspot";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function positiveDimension(value: string | undefined): number | undefined {
|
|
116
|
+
if (!value || value.trim().endsWith("%")) return undefined;
|
|
117
|
+
const match = value.trim().match(/^(\d+(?:\.\d+)?|\.\d+)(?:px)?$/i);
|
|
118
|
+
if (!match?.[1]) return undefined;
|
|
119
|
+
const parsed = Number(match[1]);
|
|
120
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function hotspotBounds(
|
|
124
|
+
choice: QtiChoice,
|
|
125
|
+
): { left: number; top: number; right: number; bottom: number } | undefined {
|
|
126
|
+
const shape = choice.attributes.shape;
|
|
127
|
+
const coords = choice.attributes.coords;
|
|
128
|
+
if (!shape || !coords || !isHotspotShape(shape) || !isNumericCsv(coords)) return undefined;
|
|
129
|
+
const values = numericCsv(coords);
|
|
130
|
+
if (!hasValidShapeCoordinateCount(shape, values)) return undefined;
|
|
131
|
+
|
|
132
|
+
if (shape === "default") return undefined;
|
|
133
|
+
if (shape === "circle") {
|
|
134
|
+
const tuple = numericTuple3(values);
|
|
135
|
+
if (!tuple) return undefined;
|
|
136
|
+
const [x, y, radius] = tuple;
|
|
137
|
+
return { left: x - radius, top: y - radius, right: x + radius, bottom: y + radius };
|
|
138
|
+
}
|
|
139
|
+
if (shape === "ellipse") {
|
|
140
|
+
const tuple = numericTuple4(values);
|
|
141
|
+
if (!tuple) return undefined;
|
|
142
|
+
const [x, y, radiusX, radiusY] = tuple;
|
|
143
|
+
return { left: x - radiusX, top: y - radiusY, right: x + radiusX, bottom: y + radiusY };
|
|
144
|
+
}
|
|
145
|
+
if (shape === "rect") {
|
|
146
|
+
const tuple = numericTuple4(values);
|
|
147
|
+
if (!tuple) return undefined;
|
|
148
|
+
const [left, top, right, bottom] = tuple;
|
|
149
|
+
return { left, top, right, bottom };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const xs = values.filter((_, index) => index % 2 === 0);
|
|
153
|
+
const ys = values.filter((_, index) => index % 2 === 1);
|
|
154
|
+
return {
|
|
155
|
+
left: Math.min(...xs),
|
|
156
|
+
top: Math.min(...ys),
|
|
157
|
+
right: Math.max(...xs),
|
|
158
|
+
bottom: Math.max(...ys),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
type QtiHotspotShape = "circle" | "default" | "ellipse" | "poly" | "rect";
|
|
163
|
+
|
|
164
|
+
function isHotspotShape(value: string): value is QtiHotspotShape {
|
|
165
|
+
return (
|
|
166
|
+
value === "circle" ||
|
|
167
|
+
value === "default" ||
|
|
168
|
+
value === "ellipse" ||
|
|
169
|
+
value === "poly" ||
|
|
170
|
+
value === "rect"
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function isAreaShape(value: string): boolean {
|
|
175
|
+
return value === "circle" || value === "default" || value === "poly" || value === "rect";
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function isNumericCsv(value: string): boolean {
|
|
179
|
+
return value
|
|
180
|
+
.split(",")
|
|
181
|
+
.map((part) => part.trim())
|
|
182
|
+
.every((part) => part.length > 0 && isFiniteNumber(part));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function numericCsv(value: string): number[] {
|
|
186
|
+
return value.split(",").map((part) => Number(part.trim()));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function hasValidShapeCoordinateCount(shape: string, coords: number[]): boolean {
|
|
190
|
+
if (!isHotspotShape(shape)) return false;
|
|
191
|
+
switch (shape) {
|
|
192
|
+
case "circle":
|
|
193
|
+
return coords.length === 3;
|
|
194
|
+
case "ellipse":
|
|
195
|
+
case "rect":
|
|
196
|
+
return coords.length === 4;
|
|
197
|
+
case "poly":
|
|
198
|
+
return coords.length >= 6 && coords.length % 2 === 0;
|
|
199
|
+
case "default":
|
|
200
|
+
return true;
|
|
201
|
+
default:
|
|
202
|
+
return assertNever(shape);
|
|
203
|
+
}
|
|
204
|
+
}
|