@playcademy/sandbox 0.7.1-beta.25 → 0.7.1-beta.26
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/dist/cli.js +1638 -1240
- package/dist/server.js +1638 -1240
- package/package.json +1 -1
package/dist/server.js
CHANGED
|
@@ -1127,7 +1127,7 @@ var package_default;
|
|
|
1127
1127
|
var init_package = __esm(() => {
|
|
1128
1128
|
package_default = {
|
|
1129
1129
|
name: "@playcademy/sandbox",
|
|
1130
|
-
version: "0.7.1-beta.
|
|
1130
|
+
version: "0.7.1-beta.26",
|
|
1131
1131
|
description: "Local development server for Playcademy game development",
|
|
1132
1132
|
type: "module",
|
|
1133
1133
|
exports: {
|
|
@@ -10762,6 +10762,14 @@ function compareAssessmentCatalogOrder(left, right) {
|
|
|
10762
10762
|
} else if (leftHasValidDate !== rightHasValidDate) {
|
|
10763
10763
|
return leftHasValidDate ? -1 : 1;
|
|
10764
10764
|
}
|
|
10765
|
+
if (left.assessmentKey !== null && right.assessmentKey !== null) {
|
|
10766
|
+
const keyDifference = left.assessmentKey.localeCompare(right.assessmentKey);
|
|
10767
|
+
if (keyDifference) {
|
|
10768
|
+
return keyDifference;
|
|
10769
|
+
}
|
|
10770
|
+
} else if (left.assessmentKey !== null || right.assessmentKey !== null) {
|
|
10771
|
+
return left.assessmentKey === null ? 1 : -1;
|
|
10772
|
+
}
|
|
10765
10773
|
return left.qtiTestIdentifier.localeCompare(right.qtiTestIdentifier);
|
|
10766
10774
|
}
|
|
10767
10775
|
function orderRuntimeAssessmentTests(tests) {
|
|
@@ -10781,13 +10789,13 @@ function selectRuntimeAssessment(liveTests, attempts) {
|
|
|
10781
10789
|
return null;
|
|
10782
10790
|
}
|
|
10783
10791
|
const completed = attempts.filter(isAssessmentAttemptCompleted);
|
|
10784
|
-
const
|
|
10785
|
-
const unattempted = orderedTests.find((test) => !
|
|
10792
|
+
const exposedAssessmentKeys = new Set(attempts.filter((attempt) => isAssessmentAttemptOpen(attempt) || isAssessmentAttemptCompleted(attempt)).map((attempt) => attempt.selectedAssessmentKey));
|
|
10793
|
+
const unattempted = orderedTests.find((test) => !exposedAssessmentKeys.has(test.assessmentKey));
|
|
10786
10794
|
if (unattempted) {
|
|
10787
10795
|
return { kind: "start", test: unattempted, reason: "unattempted" };
|
|
10788
10796
|
}
|
|
10789
10797
|
const latestCompleted = completed.toSorted(compareNewestCompletedAssessmentAttempt)[0];
|
|
10790
|
-
const latestTestIndex = latestCompleted ? orderedTests.findIndex((test) => test.
|
|
10798
|
+
const latestTestIndex = latestCompleted ? orderedTests.findIndex((test) => test.assessmentKey === latestCompleted.selectedAssessmentKey) : -1;
|
|
10791
10799
|
if (latestTestIndex === -1) {
|
|
10792
10800
|
return { kind: "start", test: orderedTests[0], reason: "first_live" };
|
|
10793
10801
|
}
|
|
@@ -11449,7 +11457,7 @@ function isPlaycademyAssessmentResultMetadataV1(value) {
|
|
|
11449
11457
|
const review = metadata2.review;
|
|
11450
11458
|
const diagnostic = metadata2.diagnostic;
|
|
11451
11459
|
const routedDiagnostic = metadata2.purpose === "diagnostic" && diagnostic !== undefined && Array.isArray(metadata2.itemSubmissions) && Number.isInteger(metadata2.responseVersion) && isRoutedDiagnosticAttemptMetadata(diagnostic, metadata2.itemSubmissions, metadata2.responseVersion);
|
|
11452
|
-
return metadata2.version === 1 && typeof metadata2.activityId === "string" && isAssessmentPurpose(metadata2.purpose) && typeof metadata2.courseId === "string" && typeof metadata2.integrationId === "string" && typeof metadata2.enrollmentId === "string" && Boolean(selectedTest) && typeof selectedTest?.identifier === "string" && typeof selectedTest.contentRevision === "string" && Number.isInteger(metadata2.attemptNumber) && Number.isInteger(metadata2.responseVersion) && Boolean(metadata2.responses) && typeof metadata2.responses === "object" && Array.isArray(metadata2.itemSubmissions) && metadata2.itemSubmissions.every(isAssessmentItemSubmission) && typeof metadata2.startedAt === "string" && typeof metadata2.updatedAt === "string" && (metadata2.purpose === "mastery" ? isMasteryAttemptMetadata(mastery) : mastery === undefined) && (metadata2.purpose === "review" ? isReviewAttemptMetadata(review) : review === undefined) && (metadata2.purpose === "diagnostic" ? diagnostic === undefined || routedDiagnostic : diagnostic === undefined) && (metadata2.submissionId === undefined || typeof metadata2.submissionId === "string") && (metadata2.score === undefined || isAssessmentScore(metadata2.score)) && isCompletionMetadata(metadata2.completion, metadata2.purpose, routedDiagnostic);
|
|
11460
|
+
return metadata2.version === 1 && typeof metadata2.activityId === "string" && isAssessmentPurpose(metadata2.purpose) && typeof metadata2.courseId === "string" && typeof metadata2.integrationId === "string" && typeof metadata2.enrollmentId === "string" && Boolean(selectedTest) && (selectedTest?.assessmentKey === undefined || isNonEmptyString(selectedTest.assessmentKey)) && typeof selectedTest?.identifier === "string" && typeof selectedTest.contentRevision === "string" && Number.isInteger(metadata2.attemptNumber) && Number.isInteger(metadata2.responseVersion) && Boolean(metadata2.responses) && typeof metadata2.responses === "object" && Array.isArray(metadata2.itemSubmissions) && metadata2.itemSubmissions.every(isAssessmentItemSubmission) && typeof metadata2.startedAt === "string" && typeof metadata2.updatedAt === "string" && (metadata2.purpose === "mastery" ? isMasteryAttemptMetadata(mastery) : mastery === undefined) && (metadata2.purpose === "review" ? isReviewAttemptMetadata(review) : review === undefined) && (metadata2.purpose === "diagnostic" ? diagnostic === undefined || routedDiagnostic : diagnostic === undefined) && (metadata2.submissionId === undefined || typeof metadata2.submissionId === "string") && (metadata2.score === undefined || isAssessmentScore(metadata2.score)) && isCompletionMetadata(metadata2.completion, metadata2.purpose, routedDiagnostic);
|
|
11453
11461
|
}
|
|
11454
11462
|
function isPlaycademyReviewAssessmentItemResultMetadataV1(value) {
|
|
11455
11463
|
if (!isRecord(value) || !isRecord(value.responses)) {
|
|
@@ -11537,7 +11545,7 @@ function playcademyDiagnosticAssessmentItemResultMetadata(value) {
|
|
|
11537
11545
|
const normalized = metadata2.responses === undefined ? { ...metadata2, responses: {} } : metadata2;
|
|
11538
11546
|
return isPlaycademyDiagnosticAssessmentItemResultMetadataV1(normalized) ? normalized : null;
|
|
11539
11547
|
}
|
|
11540
|
-
var SCRIPT_SCHEMES, DATA_RASTER_IMAGE_PATTERN, QTI_MATHML_ALLOWED_TAGS, metadataSymbol, parser, SUPPORTED_QTI_INTERACTION_TYPES, URL_ATTRIBUTES, IMAGE_URL_ATTRIBUTES, INTERACTION_TYPES, EMPHASIS_TAGS, STRONG_TAGS, CONTENT_SKIPPED_TAGS, BLOCK_CONTENT_KINDS, GAP_MATCH_TOKEN_TAGS, STAGE_GRAPHIC_EXCLUDED_CONTAINERS, BLANK_SENTINEL = "", MARKED_BLANK_PATTERN, MARKED_BLANK_GLOBAL_PATTERN, MATCH_CORRECT_TEMPLATE = "http://www.imsglobal.org/question/qti_v3p0/rptemplates/match_correct", MAP_RESPONSE_TEMPLATE = "https://purl.imsglobal.org/spec/qti/v3p0/rptemplates/map_response", MAP_RESPONSE_TEMPLATES, RESPONSE_PROCESSING_TEMPLATES, VOID_BODY_ELEMENTS, URL_ATTRIBUTES2, IMAGE_URL_ATTRIBUTES2, VOID_CHOICE_ELEMENTS, NUMERIC_COMPARISON_ATTRIBUTES, RESPONSE_CARDINALITIES, RESPONSE_BASE_TYPES, POINT_INTERACTION_TYPES, INDEPENDENT_PROCESSING_TAGS, UUID_PATTERN, COMMON_CORE_MATH_FRAMEWORK_ALIASES, COMMON_CORE_ELA_FRAMEWORK_ALIASES, COMMON_CORE_FRAMEWORK_ALIASES, ASSESSMENT_ATTEMPT_OPEN, ASSESSMENT_ATTEMPT_COMPLETED, ASSESSMENT_ATTEMPT_SUPERSEDED, ASSESSMENT_RUNTIME_ERROR_STATUS, ROUTING_KEY_MAX_LENGTH = 128, ROUTING_RESULT_KEY_MAX_LENGTH = 256, ROUTING_STAGE_LIMIT = 32, ROUTING_TRACK_LIMIT = 128, ROUTING_NODE_LIMIT = 1000, ROUTING_TERMINAL_LIMIT = 1000, ROUTING_GROUP_LIMIT = 128, ROUTING_PREDICATE_DEPTH_LIMIT = 8, ROUTING_EXPLORATION_STATE_LIMIT = 200000, RoutingKeySchema, RoutingResultKeySchema, DiagnosticTrackOutcomeSchema, DiagnosticActivationPredicateSchema, DiagnosticRoutingTransitionSchema, DiagnosticRoutingManifestV1Schema, GRADE_VALUES, POINT_RESPONSE_PATTERN, REVIEW_SELECTION_POLICY_VERSION = "unseen-first-least-recently-delivered-v1", PLAYCADEMY_REVIEW_BANK_MANIFEST_METADATA_KEY = "playcademyReviewBank", PLAYCADEMY_REVIEW_BANK_MANIFEST_VERSION = 1, DEFAULT_REVIEW_SELECTION_POLICY, RuntimeSubjectSchema, RuntimeGradeSchema, OptionalQueryGradeSchema, AssessmentResponseValueSchema, AssessmentRuntimeIdentitySchema, ResponseKeySchema, StartAssessmentBaseSchema, AssessmentStandardRefSchema, LatestAssessmentFilterBaseSchema, LatestAssessmentFiltersSchema, LatestRuntimeAssessmentQuerySchema, StartAssessmentBodySchema, SaveAssessmentBodySchema, SubmitAssessmentItemBodySchema, SubmitAssessmentBodySchema, StartRuntimeAssessmentRequestSchema, SaveRuntimeAssessmentRequestSchema, SubmitAssessmentItemRuntimeRequestSchema, SubmitRuntimeAssessmentRequestSchema;
|
|
11548
|
+
var SCRIPT_SCHEMES, DATA_RASTER_IMAGE_PATTERN, QTI_MATHML_ALLOWED_TAGS, metadataSymbol, parser, SUPPORTED_QTI_INTERACTION_TYPES, URL_ATTRIBUTES, IMAGE_URL_ATTRIBUTES, INTERACTION_TYPES, EMPHASIS_TAGS, STRONG_TAGS, CONTENT_SKIPPED_TAGS, BLOCK_CONTENT_KINDS, GAP_MATCH_TOKEN_TAGS, STAGE_GRAPHIC_EXCLUDED_CONTAINERS, BLANK_SENTINEL = "", MARKED_BLANK_PATTERN, MARKED_BLANK_GLOBAL_PATTERN, MATCH_CORRECT_TEMPLATE = "http://www.imsglobal.org/question/qti_v3p0/rptemplates/match_correct", MAP_RESPONSE_TEMPLATE = "https://purl.imsglobal.org/spec/qti/v3p0/rptemplates/map_response", MAP_RESPONSE_TEMPLATES, RESPONSE_PROCESSING_TEMPLATES, VOID_BODY_ELEMENTS, URL_ATTRIBUTES2, IMAGE_URL_ATTRIBUTES2, VOID_CHOICE_ELEMENTS, NUMERIC_COMPARISON_ATTRIBUTES, RESPONSE_CARDINALITIES, RESPONSE_BASE_TYPES, POINT_INTERACTION_TYPES, INDEPENDENT_PROCESSING_TAGS, UUID_PATTERN, COMMON_CORE_MATH_FRAMEWORK_ALIASES, COMMON_CORE_ELA_FRAMEWORK_ALIASES, COMMON_CORE_FRAMEWORK_ALIASES, ASSESSMENT_ATTEMPT_OPEN, ASSESSMENT_ATTEMPT_COMPLETED, ASSESSMENT_ATTEMPT_SUPERSEDED, ASSESSMENT_RUNTIME_ERROR_STATUS, ROUTING_KEY_MAX_LENGTH = 128, ROUTING_RESULT_KEY_MAX_LENGTH = 256, ROUTING_STAGE_LIMIT = 32, ROUTING_TRACK_LIMIT = 128, ROUTING_NODE_LIMIT = 1000, ROUTING_TERMINAL_LIMIT = 1000, ROUTING_GROUP_LIMIT = 128, ROUTING_PREDICATE_DEPTH_LIMIT = 8, ROUTING_EXPLORATION_STATE_LIMIT = 200000, RoutingKeySchema, RoutingResultKeySchema, DiagnosticTrackOutcomeSchema, DiagnosticActivationPredicateSchema, DiagnosticRoutingTransitionSchema, DiagnosticRoutingManifestV1Schema, GRADE_VALUES, POINT_RESPONSE_PATTERN, REVIEW_SELECTION_POLICY_VERSION = "unseen-first-least-recently-delivered-v1", PLAYCADEMY_REVIEW_BANK_MANIFEST_METADATA_KEY = "playcademyReviewBank", PLAYCADEMY_REVIEW_BANK_MANIFEST_VERSION = 1, DEFAULT_REVIEW_SELECTION_POLICY, ASSESSMENT_RUNTIME_FIXTURE_BUNDLE_VERSION = 7, RuntimeSubjectSchema, RuntimeGradeSchema, OptionalQueryGradeSchema, AssessmentResponseValueSchema, AssessmentRuntimeIdentitySchema, ResponseKeySchema, StartAssessmentBaseSchema, AssessmentStandardRefSchema, LatestAssessmentFilterBaseSchema, LatestAssessmentFiltersSchema, LatestRuntimeAssessmentQuerySchema, StartAssessmentBodySchema, SaveAssessmentBodySchema, SubmitAssessmentItemBodySchema, SubmitAssessmentBodySchema, StartRuntimeAssessmentRequestSchema, SaveRuntimeAssessmentRequestSchema, SubmitAssessmentItemRuntimeRequestSchema, SubmitRuntimeAssessmentRequestSchema;
|
|
11541
11549
|
var init_assessment_runtime2 = __esm(() => {
|
|
11542
11550
|
init_timeback3();
|
|
11543
11551
|
init_timeback3();
|
|
@@ -18195,6 +18203,7 @@ var init_table7 = __esm(() => {
|
|
|
18195
18203
|
gameTimebackAssessmentTests = pgTable("game_timeback_assessment_tests", {
|
|
18196
18204
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
18197
18205
|
integrationId: uuid("integration_id").notNull().references(() => gameTimebackIntegrations.id, { onDelete: "cascade" }),
|
|
18206
|
+
assessmentKey: text("assessment_key"),
|
|
18198
18207
|
qtiTestIdentifier: text("qti_test_identifier").notNull(),
|
|
18199
18208
|
purpose: gameTimebackAssessmentPurposeEnum("purpose").notNull().default("end_of_course"),
|
|
18200
18209
|
status: gameTimebackAssessmentStatusEnum("status").notNull().default("draft"),
|
|
@@ -18207,6 +18216,7 @@ var init_table7 = __esm(() => {
|
|
|
18207
18216
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
|
|
18208
18217
|
}, (table3) => [
|
|
18209
18218
|
uniqueIndex("game_timeback_assessment_tests_integration_qti_idx").on(table3.integrationId, table3.qtiTestIdentifier),
|
|
18219
|
+
uniqueIndex("game_timeback_assessment_tests_integration_key_idx").on(table3.integrationId, table3.assessmentKey).where(sql`${table3.assessmentKey} IS NOT NULL`),
|
|
18210
18220
|
uniqueIndex("game_timeback_assessment_tests_one_live_review_idx").on(table3.integrationId).where(sql`${table3.purpose} = 'review' AND ${table3.status} = 'live'`),
|
|
18211
18221
|
uniqueIndex("game_timeback_assessment_tests_one_live_diagnostic_key_idx").on(table3.integrationId, table3.diagnosticKey).where(sql`${table3.purpose} = 'diagnostic' AND ${table3.status} = 'live' AND ${table3.diagnosticKey} IS NOT NULL`),
|
|
18212
18222
|
check("game_timeback_assessment_tests_mastery_standard_check", sql`(
|
|
@@ -47068,7 +47078,7 @@ function requireMasteryStandard(input, context2) {
|
|
|
47068
47078
|
});
|
|
47069
47079
|
}
|
|
47070
47080
|
}
|
|
47071
|
-
var TimebackGradeSchema, TimebackSubjectSchema, CourseGoalsSchema, UpdateGameTimebackIntegrationRequestSchema, CreateGameTimebackIntegrationRequestSchema, TimebackActivityDataSchema, EndActivityRequestSchema, GameRunMetricsSchema, GameCourseMetricsSchema, GameMetricsResponseSchema, AdvanceCourseRequestSchema, UnenrollCourseRequestSchema, HeartbeatRequestSchema, PopulateStudentRequestSchema, DerivedPlatformCourseConfigSchema, TimebackBaseConfigSchema, PlatformTimebackSetupRequestSchema, AdminTimebackMutationBaseSchema, AdminAttributionDateSchema, ADMIN_GRANT_XP_MIN = -1e5, ADMIN_GRANT_XP_MAX = 1e5, ADMIN_GRANT_XP_AMOUNT_RANGE_MESSAGE, GrantTimebackXpRequestSchema, AdjustTimebackTimeRequestSchema, AdjustTimebackMasteryRequestSchema, ReconcileMasteryForConfigChangeSchema, EnrollStudentRequestSchema, UnenrollStudentRequestSchema, ReactivateEnrollmentRequestSchema, VerifyTimebackMetricDiscrepancyRequestSchema, AssessmentPurposeSchema, AssessmentStatusSchema, DiagnosticKeySchema, DiagnosticRoutingManifestSchema, DiagnosticAssessmentDefinitionInputSchema, AssessmentStandardRefSchema2, CreateAssessmentRequestSchema, UpdateAssessmentRequestSchema, CopyAssessmentRequestSchema, AssessmentAssociationImportEntrySchema, AttachExistingAssessmentsRequestSchema, ReorderAssessmentsRequestSchema, ReorderQuestionsRequestSchema;
|
|
47081
|
+
var TimebackGradeSchema, TimebackSubjectSchema, CourseGoalsSchema, UpdateGameTimebackIntegrationRequestSchema, CreateGameTimebackIntegrationRequestSchema, TimebackActivityDataSchema, EndActivityRequestSchema, GameRunMetricsSchema, GameCourseMetricsSchema, GameMetricsResponseSchema, AdvanceCourseRequestSchema, UnenrollCourseRequestSchema, HeartbeatRequestSchema, PopulateStudentRequestSchema, DerivedPlatformCourseConfigSchema, TimebackBaseConfigSchema, PlatformTimebackSetupRequestSchema, AdminTimebackMutationBaseSchema, AdminAttributionDateSchema, ADMIN_GRANT_XP_MIN = -1e5, ADMIN_GRANT_XP_MAX = 1e5, ADMIN_GRANT_XP_AMOUNT_RANGE_MESSAGE, GrantTimebackXpRequestSchema, AdjustTimebackTimeRequestSchema, AdjustTimebackMasteryRequestSchema, ReconcileMasteryForConfigChangeSchema, EnrollStudentRequestSchema, UnenrollStudentRequestSchema, ReactivateEnrollmentRequestSchema, VerifyTimebackMetricDiscrepancyRequestSchema, AssessmentPurposeSchema, AssessmentStatusSchema, AssessmentKeySchema, DiagnosticKeySchema, DiagnosticRoutingManifestSchema, DiagnosticAssessmentDefinitionInputSchema, AssessmentStandardRefSchema2, CreateAssessmentRequestSchema, UpdateAssessmentRequestSchema, CopyAssessmentRequestSchema, AssessmentAssociationImportEntrySchema, AttachExistingAssessmentsRequestSchema, ReorderAssessmentsRequestSchema, ReorderQuestionsRequestSchema;
|
|
47072
47082
|
var init_schemas4 = __esm(() => {
|
|
47073
47083
|
init_esm();
|
|
47074
47084
|
init_src();
|
|
@@ -47313,6 +47323,7 @@ var init_schemas4 = __esm(() => {
|
|
|
47313
47323
|
});
|
|
47314
47324
|
AssessmentPurposeSchema = exports_external.enum(gameTimebackAssessmentPurposeEnum.enumValues);
|
|
47315
47325
|
AssessmentStatusSchema = exports_external.enum(gameTimebackAssessmentStatusEnum.enumValues);
|
|
47326
|
+
AssessmentKeySchema = exports_external.string().min(1, "Assessment key is required").max(100, "Assessment key cannot exceed 100 characters").regex(/^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$/, "Assessment key must use lowercase letters, numbers, dots, underscores, or hyphens");
|
|
47316
47327
|
DiagnosticKeySchema = exports_external.string().trim().min(1).max(200);
|
|
47317
47328
|
DiagnosticRoutingManifestSchema = exports_external.custom((value) => typeof value === "object" && value !== null && !Array.isArray(value), "Diagnostic routing manifest must be an object");
|
|
47318
47329
|
DiagnosticAssessmentDefinitionInputSchema = exports_external.object({
|
|
@@ -47324,6 +47335,7 @@ var init_schemas4 = __esm(() => {
|
|
|
47324
47335
|
identifier: exports_external.string().trim().min(1).max(TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.standardFieldLength)
|
|
47325
47336
|
});
|
|
47326
47337
|
CreateAssessmentRequestSchema = exports_external.object({
|
|
47338
|
+
assessmentKey: AssessmentKeySchema,
|
|
47327
47339
|
title: exports_external.string().min(1, "Assessment title is required"),
|
|
47328
47340
|
purpose: AssessmentPurposeSchema,
|
|
47329
47341
|
standard: AssessmentStandardRefSchema2.optional()
|
|
@@ -47338,11 +47350,13 @@ var init_schemas4 = __esm(() => {
|
|
|
47338
47350
|
message: "Title, purpose, standard, diagnostic, or status is required"
|
|
47339
47351
|
});
|
|
47340
47352
|
CopyAssessmentRequestSchema = exports_external.object({
|
|
47353
|
+
assessmentKey: AssessmentKeySchema,
|
|
47341
47354
|
testIdentifier: exports_external.string().trim().min(1, "Assessment identifier is required"),
|
|
47342
47355
|
purpose: AssessmentPurposeSchema,
|
|
47343
47356
|
standard: AssessmentStandardRefSchema2.optional()
|
|
47344
47357
|
}).superRefine(requireMasteryStandard);
|
|
47345
47358
|
AssessmentAssociationImportEntrySchema = exports_external.object({
|
|
47359
|
+
assessmentKey: AssessmentKeySchema,
|
|
47346
47360
|
qtiTestIdentifier: exports_external.string().trim().min(1, "Assessment identifier is required"),
|
|
47347
47361
|
purpose: AssessmentPurposeSchema,
|
|
47348
47362
|
standard: AssessmentStandardRefSchema2.optional(),
|
|
@@ -47374,16 +47388,25 @@ var init_schemas4 = __esm(() => {
|
|
|
47374
47388
|
targetStatus: exports_external.enum(["draft", "live"]),
|
|
47375
47389
|
assessments: exports_external.array(AssessmentAssociationImportEntrySchema).min(1, "At least one assessment is required").max(500, "A bulk import can contain at most 500 assessments")
|
|
47376
47390
|
}).superRefine((input, context2) => {
|
|
47377
|
-
const
|
|
47391
|
+
const seenIdentifiers = new Set;
|
|
47392
|
+
const seenKeys = new Set;
|
|
47378
47393
|
input.assessments.forEach((assessment, index2) => {
|
|
47379
|
-
if (
|
|
47394
|
+
if (seenIdentifiers.has(assessment.qtiTestIdentifier)) {
|
|
47380
47395
|
context2.addIssue({
|
|
47381
47396
|
code: "custom",
|
|
47382
47397
|
path: ["assessments", index2, "qtiTestIdentifier"],
|
|
47383
47398
|
message: "Assessment identifiers must be unique"
|
|
47384
47399
|
});
|
|
47385
47400
|
}
|
|
47386
|
-
|
|
47401
|
+
if (seenKeys.has(assessment.assessmentKey)) {
|
|
47402
|
+
context2.addIssue({
|
|
47403
|
+
code: "custom",
|
|
47404
|
+
path: ["assessments", index2, "assessmentKey"],
|
|
47405
|
+
message: "Assessment keys must be unique"
|
|
47406
|
+
});
|
|
47407
|
+
}
|
|
47408
|
+
seenIdentifiers.add(assessment.qtiTestIdentifier);
|
|
47409
|
+
seenKeys.add(assessment.assessmentKey);
|
|
47387
47410
|
});
|
|
47388
47411
|
});
|
|
47389
47412
|
ReorderAssessmentsRequestSchema = exports_external.object({
|
|
@@ -98355,114 +98378,945 @@ var init_assessment_runtime_lock_util = __esm(() => {
|
|
|
98355
98378
|
};
|
|
98356
98379
|
});
|
|
98357
98380
|
|
|
98358
|
-
// ../api-core/src/utils/timeback-
|
|
98359
|
-
function
|
|
98360
|
-
|
|
98381
|
+
// ../api-core/src/utils/timeback-qti-authoring.util.ts
|
|
98382
|
+
function recordValue(value) {
|
|
98383
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
|
|
98384
|
+
}
|
|
98385
|
+
function persistableAuthoringDocument(value) {
|
|
98386
|
+
const document2 = recordValue(value);
|
|
98387
|
+
const item = recordValue(document2?.item);
|
|
98388
|
+
if (!document2 || !item) {
|
|
98361
98389
|
return;
|
|
98362
98390
|
}
|
|
98363
|
-
const
|
|
98364
|
-
|
|
98365
|
-
|
|
98391
|
+
const persistableItem = { ...item };
|
|
98392
|
+
Reflect.deleteProperty(persistableItem, "metadata");
|
|
98393
|
+
return { ...document2, item: persistableItem };
|
|
98394
|
+
}
|
|
98395
|
+
function qtiAuthoringSnapshot(input) {
|
|
98396
|
+
const document2 = persistableAuthoringDocument(input.document);
|
|
98397
|
+
if (!recordValue(input.interaction) && !document2) {
|
|
98398
|
+
return;
|
|
98366
98399
|
}
|
|
98400
|
+
return Object.fromEntries(QTI_AUTHORING_FIELDS.flatMap((field) => {
|
|
98401
|
+
if (field === "document") {
|
|
98402
|
+
return document2 ? [[field, document2]] : [];
|
|
98403
|
+
}
|
|
98404
|
+
return input[field] === undefined ? [] : [[field, input[field]]];
|
|
98405
|
+
}));
|
|
98367
98406
|
}
|
|
98368
|
-
function
|
|
98369
|
-
|
|
98407
|
+
function buildOwnedQtiQuestionMetadata(input, ownership, existingMetadata = {}) {
|
|
98408
|
+
const inputMetadata = recordValue(input.metadata) ?? {};
|
|
98409
|
+
const metadata2 = {
|
|
98410
|
+
...existingMetadata,
|
|
98411
|
+
...inputMetadata
|
|
98412
|
+
};
|
|
98413
|
+
const existingAuthoring = recordValue(existingMetadata[PLAYCADEMY_QTI_AUTHORING_METADATA_KEY]);
|
|
98414
|
+
Reflect.deleteProperty(metadata2, PLAYCADEMY_QTI_AUTHORING_METADATA_KEY);
|
|
98415
|
+
Reflect.deleteProperty(metadata2, PLAYCADEMY_QTI_EDITOR_MODE_KEY);
|
|
98416
|
+
const authoring = qtiAuthoringSnapshot(input);
|
|
98417
|
+
const shouldReplaceAuthoring = "interaction" in input || "numericTextEntry" in input || "document" in input;
|
|
98418
|
+
const persistedAuthoring = authoring ?? (shouldReplaceAuthoring ? undefined : existingAuthoring);
|
|
98419
|
+
return {
|
|
98420
|
+
...metadata2,
|
|
98421
|
+
ownerSystem: PLAYCADEMY_QTI_OWNER_SYSTEM,
|
|
98422
|
+
ownerGameSlug: ownership.gameSlug,
|
|
98423
|
+
[PLAYCADEMY_QTI_OWNER_TEST_IDENTIFIER_KEY]: ownership.testIdentifier,
|
|
98424
|
+
...persistedAuthoring ? { [PLAYCADEMY_QTI_AUTHORING_METADATA_KEY]: persistedAuthoring } : {}
|
|
98425
|
+
};
|
|
98370
98426
|
}
|
|
98371
|
-
function
|
|
98372
|
-
|
|
98373
|
-
|
|
98427
|
+
function restoreQtiQuestionAuthoringData(item) {
|
|
98428
|
+
const authoring = recordValue(item.metadata?.[PLAYCADEMY_QTI_AUTHORING_METADATA_KEY]);
|
|
98429
|
+
if (!authoring) {
|
|
98430
|
+
return item;
|
|
98374
98431
|
}
|
|
98432
|
+
const restored = Object.fromEntries(QTI_AUTHORING_FIELDS.flatMap((field) => authoring[field] === undefined ? [] : [[field, authoring[field]]]));
|
|
98433
|
+
return { ...item, ...restored };
|
|
98375
98434
|
}
|
|
98376
|
-
function
|
|
98377
|
-
|
|
98378
|
-
|
|
98435
|
+
function mergeQtiQuestionItem(item, fallbackItem, itemIdentifier, metadata2) {
|
|
98436
|
+
return restoreQtiQuestionAuthoringData({
|
|
98437
|
+
...fallbackItem,
|
|
98438
|
+
...item,
|
|
98439
|
+
identifier: itemIdentifier,
|
|
98440
|
+
title: item.title || fallbackItem?.title || itemIdentifier,
|
|
98441
|
+
type: item.type ?? fallbackItem?.type,
|
|
98442
|
+
rawXml: item.rawXml ?? fallbackItem?.rawXml,
|
|
98443
|
+
interaction: item.interaction ?? fallbackItem?.interaction,
|
|
98444
|
+
responseDeclarations: item.responseDeclarations ?? fallbackItem?.responseDeclarations,
|
|
98445
|
+
metadata: {
|
|
98446
|
+
...fallbackItem?.metadata,
|
|
98447
|
+
...item.metadata,
|
|
98448
|
+
...metadata2
|
|
98449
|
+
}
|
|
98450
|
+
});
|
|
98451
|
+
}
|
|
98452
|
+
function newPlaycademyQuestionIdentifier(testIdentifier) {
|
|
98453
|
+
return `${testIdentifier}-q${crypto.randomUUID().slice(0, 8)}`;
|
|
98454
|
+
}
|
|
98455
|
+
function parseQtiQuestionCreationInput(input) {
|
|
98456
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
98457
|
+
throw new ValidationError("Question creation input must be an object");
|
|
98458
|
+
}
|
|
98459
|
+
const rawInput = input;
|
|
98460
|
+
if (rawInput.mode === undefined) {
|
|
98461
|
+
return { kind: "authoring", input: rawInput };
|
|
98379
98462
|
}
|
|
98463
|
+
if (rawInput.mode !== "copy") {
|
|
98464
|
+
throw new ValidationError("Question creation mode is invalid");
|
|
98465
|
+
}
|
|
98466
|
+
const sourceItemIdentifier = typeof rawInput.sourceItemIdentifier === "string" ? rawInput.sourceItemIdentifier.trim() : "";
|
|
98467
|
+
if (!sourceItemIdentifier) {
|
|
98468
|
+
throw new ValidationError("A source question identifier is required to create a copy");
|
|
98469
|
+
}
|
|
98470
|
+
const unexpectedField = Object.keys(rawInput).find((field) => field !== "mode" && field !== "sourceItemIdentifier");
|
|
98471
|
+
if (unexpectedField) {
|
|
98472
|
+
throw new ValidationError(`Question copy input contains an unexpected “${unexpectedField}” field`);
|
|
98473
|
+
}
|
|
98474
|
+
return { kind: "copy", sourceItemIdentifier };
|
|
98475
|
+
}
|
|
98476
|
+
function nonEmptyMetadataString(value) {
|
|
98477
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
98380
98478
|
}
|
|
98381
|
-
function
|
|
98382
|
-
if (
|
|
98383
|
-
|
|
98479
|
+
function qtiQuestionOwnerTestIdentifier(item) {
|
|
98480
|
+
if (item.metadata?.ownerSystem !== PLAYCADEMY_QTI_OWNER_SYSTEM) {
|
|
98481
|
+
return;
|
|
98384
98482
|
}
|
|
98483
|
+
return nonEmptyMetadataString(item.metadata[PLAYCADEMY_QTI_OWNER_TEST_IDENTIFIER_KEY]);
|
|
98385
98484
|
}
|
|
98386
|
-
function
|
|
98387
|
-
return
|
|
98388
|
-
diagnosticKey: row.diagnosticKey,
|
|
98389
|
-
routingManifest: row.diagnosticRoutingManifest
|
|
98390
|
-
} : null;
|
|
98485
|
+
function qtiTestReferencesItem(test, itemIdentifier) {
|
|
98486
|
+
return (test["qti-test-part"] ?? []).some((part) => (part["qti-assessment-section"] ?? []).some((section) => (section["qti-assessment-item-ref"] ?? []).some((reference) => reference.identifier === itemIdentifier)));
|
|
98391
98487
|
}
|
|
98392
|
-
function
|
|
98393
|
-
if (
|
|
98394
|
-
|
|
98488
|
+
function resolveQtiQuestionOwnerGameSlug(item, ownerTest) {
|
|
98489
|
+
if (item.metadata?.ownerSystem !== PLAYCADEMY_QTI_OWNER_SYSTEM) {
|
|
98490
|
+
return;
|
|
98491
|
+
}
|
|
98492
|
+
if ("ownerGameSlug" in item.metadata) {
|
|
98493
|
+
return nonEmptyMetadataString(item.metadata.ownerGameSlug);
|
|
98494
|
+
}
|
|
98495
|
+
const ownerTestIdentifier = qtiQuestionOwnerTestIdentifier(item);
|
|
98496
|
+
if (!ownerTestIdentifier || !ownerTest || ownerTest.identifier !== ownerTestIdentifier || !qtiTestReferencesItem(ownerTest, item.identifier) || ownerTest.metadata?.ownerSystem !== PLAYCADEMY_QTI_OWNER_SYSTEM) {
|
|
98497
|
+
return;
|
|
98395
98498
|
}
|
|
98499
|
+
return nonEmptyMetadataString(ownerTest.metadata.ownerGameSlug);
|
|
98396
98500
|
}
|
|
98397
|
-
function
|
|
98398
|
-
|
|
98399
|
-
|
|
98501
|
+
function isQtiQuestionOwnedByGame(item, gameSlug, ownerTest) {
|
|
98502
|
+
return resolveQtiQuestionOwnerGameSlug(item, ownerTest) === gameSlug;
|
|
98503
|
+
}
|
|
98504
|
+
function isQtiQuestionOwnedByContext(item, ownership, ownerTest) {
|
|
98505
|
+
const declaredOwnerTest = qtiQuestionOwnerTestIdentifier(item);
|
|
98506
|
+
if (declaredOwnerTest) {
|
|
98507
|
+
return declaredOwnerTest === ownership.testIdentifier && isQtiQuestionOwnedByGame(item, ownership.gameSlug, ownerTest);
|
|
98400
98508
|
}
|
|
98509
|
+
return isQtiItemOwnedByTest(item, ownership.testIdentifier);
|
|
98401
98510
|
}
|
|
98402
|
-
function
|
|
98403
|
-
if (
|
|
98404
|
-
|
|
98511
|
+
function normalizedQtiInteractionType(value) {
|
|
98512
|
+
if (typeof value !== "string") {
|
|
98513
|
+
return;
|
|
98405
98514
|
}
|
|
98515
|
+
const normalized = value.trim().toLowerCase().replaceAll("_", "-");
|
|
98516
|
+
return normalized || undefined;
|
|
98406
98517
|
}
|
|
98407
|
-
function
|
|
98408
|
-
|
|
98409
|
-
|
|
98518
|
+
function authoringDocumentItem(value) {
|
|
98519
|
+
const document2 = recordValue(value);
|
|
98520
|
+
if (!document2) {
|
|
98521
|
+
throw new ValidationError("Authored question data is invalid");
|
|
98410
98522
|
}
|
|
98411
|
-
if (
|
|
98412
|
-
|
|
98523
|
+
if (document2.format !== "playcademy-question" || document2.version !== 1) {
|
|
98524
|
+
throw new ValidationError("Authored questions require a version 1 playcademy-question document");
|
|
98413
98525
|
}
|
|
98414
|
-
|
|
98526
|
+
const item = recordValue(document2.item);
|
|
98527
|
+
if (!item || typeof item.title !== "string" || !Array.isArray(item.body) || !Array.isArray(item.responseDeclarations)) {
|
|
98528
|
+
throw new ValidationError("An authored question needs an item with a title, a body, and response declarations");
|
|
98529
|
+
}
|
|
98530
|
+
const declarations = item.responseDeclarations.map(recordValue);
|
|
98531
|
+
const declaredIdentifiers = declarations.map((declaration) => typeof declaration?.identifier === "string" ? declaration.identifier : "");
|
|
98532
|
+
if (declarations.length === 0) {
|
|
98533
|
+
throw new ValidationError("An authored question requires at least one response declaration");
|
|
98534
|
+
}
|
|
98535
|
+
const declaredIdentifierSet = new Set(declaredIdentifiers);
|
|
98536
|
+
if (declaredIdentifiers.some((identifier) => !identifier) || declaredIdentifierSet.size !== declaredIdentifiers.length) {
|
|
98537
|
+
throw new ValidationError("Response declaration identifiers must be unique");
|
|
98538
|
+
}
|
|
98539
|
+
const projection = projectQtiAuthoringBody(item.body);
|
|
98540
|
+
if (projection.nestedInteractions.length > 0) {
|
|
98541
|
+
throw new ValidationError("Interactions nested inside another interaction cannot be compiled");
|
|
98542
|
+
}
|
|
98543
|
+
if (projection.interactions.length !== declarations.length) {
|
|
98544
|
+
throw new ValidationError("Every response declaration must belong to exactly one interaction");
|
|
98545
|
+
}
|
|
98546
|
+
const interactionIdentifiers = projection.interactions.map((interaction) => interaction.responseIdentifier);
|
|
98547
|
+
const interactionIdentifierSet = new Set(interactionIdentifiers);
|
|
98548
|
+
if (interactionIdentifierSet.size !== interactionIdentifiers.length) {
|
|
98549
|
+
throw new ValidationError("Every interaction needs its own response identifier");
|
|
98550
|
+
}
|
|
98551
|
+
if (interactionIdentifiers.some((identifier) => !declaredIdentifierSet.has(identifier)) || declaredIdentifiers.some((identifier) => !interactionIdentifierSet.has(identifier))) {
|
|
98552
|
+
throw new ValidationError("Every interaction must declare exactly one response");
|
|
98553
|
+
}
|
|
98554
|
+
const unauthorable = projection.interactions.find((interaction) => !qtiAuthoringInteractionType(interaction));
|
|
98555
|
+
if (unauthorable) {
|
|
98556
|
+
throw new ValidationError(`Interaction type “${String(unauthorable.type)}” cannot be authored by Playcademy yet`);
|
|
98557
|
+
}
|
|
98558
|
+
const inline = projection.interactions.filter(isInlineQtiAuthoringInteraction);
|
|
98559
|
+
if (projection.interactions.length - inline.length > 1) {
|
|
98560
|
+
throw new ValidationError("A question supports any number of inline interactions but at most one block interaction");
|
|
98561
|
+
}
|
|
98562
|
+
if (item.body.some((node) => node.kind === "interaction" && isInlineQtiAuthoringInteraction(node))) {
|
|
98563
|
+
throw new ValidationError("Every inline interaction must sit at its blank inside the question’s prose");
|
|
98564
|
+
}
|
|
98565
|
+
const blanks = inline.map((interaction) => projection.blankIndexes.get(interaction));
|
|
98566
|
+
if (blanks.some((blank) => blank === undefined) || new Set(blanks).size !== blanks.length) {
|
|
98567
|
+
throw new ValidationError("Every inline interaction needs its own blank position");
|
|
98568
|
+
}
|
|
98569
|
+
return item;
|
|
98415
98570
|
}
|
|
98416
|
-
function
|
|
98417
|
-
|
|
98418
|
-
|
|
98419
|
-
|
|
98420
|
-
|
|
98421
|
-
|
|
98422
|
-
|
|
98571
|
+
function parsedQtiItemOrNull(rawXml) {
|
|
98572
|
+
if (typeof rawXml !== "string" || !rawXml) {
|
|
98573
|
+
return null;
|
|
98574
|
+
}
|
|
98575
|
+
try {
|
|
98576
|
+
return parseQtiItemXml(rawXml);
|
|
98577
|
+
} catch {
|
|
98578
|
+
return null;
|
|
98579
|
+
}
|
|
98580
|
+
}
|
|
98581
|
+
function isAuthorableMultiInteractionQtiItem(question) {
|
|
98582
|
+
const item = parsedQtiItemOrNull(question.rawXml);
|
|
98583
|
+
return item !== null && (authorableQtiInteractions(item)?.length ?? 0) > 1;
|
|
98584
|
+
}
|
|
98585
|
+
function assertPlayableQtiQuestion(question) {
|
|
98586
|
+
if (!question.rawXml) {
|
|
98587
|
+
assertSupportedQtiQuestionInteraction(question);
|
|
98588
|
+
return;
|
|
98589
|
+
}
|
|
98590
|
+
const item = parsedQtiItemOrNull(question.rawXml);
|
|
98591
|
+
const playable = item && playableQtiInteractions(item);
|
|
98592
|
+
if (!playable || qtiItemScoringValidationMessage(item)) {
|
|
98593
|
+
throw new ValidationError(`Question ${question.identifier} uses an interaction Playcademy cannot play or score.`);
|
|
98594
|
+
}
|
|
98595
|
+
}
|
|
98596
|
+
function assertSupportedQtiQuestionInteraction(question) {
|
|
98597
|
+
const candidate = recordValue(question);
|
|
98598
|
+
if (candidate?.type === AUTHORING_DOCUMENT_QUESTION_TYPE) {
|
|
98599
|
+
authoringDocumentItem(candidate.document);
|
|
98600
|
+
return;
|
|
98601
|
+
}
|
|
98602
|
+
if (!supportedQtiQuestionInteractionType(question) && !isAuthorableMultiInteractionQtiItem(question)) {
|
|
98603
|
+
throw new ValidationError("This QTI interaction type is not supported by the Playcademy question editor.");
|
|
98604
|
+
}
|
|
98605
|
+
}
|
|
98606
|
+
function invalidQtiCopyXml() {
|
|
98607
|
+
throw new ValidationError("The source question does not contain valid QTI item XML");
|
|
98608
|
+
}
|
|
98609
|
+
function markupEnd2(xml, start2, allowInternalSubset) {
|
|
98610
|
+
let quote = null;
|
|
98611
|
+
let subsetDepth = 0;
|
|
98612
|
+
for (let index2 = start2;index2 < xml.length; index2 += 1) {
|
|
98613
|
+
const character = xml[index2];
|
|
98614
|
+
if (quote) {
|
|
98615
|
+
if (character === quote) {
|
|
98616
|
+
quote = null;
|
|
98617
|
+
}
|
|
98618
|
+
} else if (character === '"' || character === "'") {
|
|
98619
|
+
quote = character;
|
|
98620
|
+
} else if (allowInternalSubset && character === "[") {
|
|
98621
|
+
subsetDepth += 1;
|
|
98622
|
+
} else if (allowInternalSubset && character === "]") {
|
|
98623
|
+
subsetDepth = Math.max(0, subsetDepth - 1);
|
|
98624
|
+
} else if (character === ">" && subsetDepth === 0) {
|
|
98625
|
+
return index2;
|
|
98423
98626
|
}
|
|
98424
|
-
|
|
98425
|
-
|
|
98627
|
+
}
|
|
98628
|
+
return invalidQtiCopyXml();
|
|
98629
|
+
}
|
|
98630
|
+
function skipXmlWhitespace(xml, start2, end = xml.length) {
|
|
98631
|
+
let cursor2 = start2;
|
|
98632
|
+
while (cursor2 < end && /\s/.test(xml[cursor2] ?? "")) {
|
|
98633
|
+
cursor2 += 1;
|
|
98634
|
+
}
|
|
98635
|
+
return cursor2;
|
|
98636
|
+
}
|
|
98637
|
+
function xmlPreambleEnd(xml, cursor2) {
|
|
98638
|
+
if (xml.startsWith("<?", cursor2)) {
|
|
98639
|
+
const end = xml.indexOf("?>", cursor2 + 2);
|
|
98640
|
+
if (end === -1) {
|
|
98641
|
+
return invalidQtiCopyXml();
|
|
98426
98642
|
}
|
|
98643
|
+
return end + 2;
|
|
98427
98644
|
}
|
|
98428
|
-
if (
|
|
98429
|
-
|
|
98430
|
-
|
|
98645
|
+
if (xml.startsWith("<!--", cursor2)) {
|
|
98646
|
+
const end = xml.indexOf("-->", cursor2 + 4);
|
|
98647
|
+
if (end === -1) {
|
|
98648
|
+
return invalidQtiCopyXml();
|
|
98649
|
+
}
|
|
98650
|
+
return end + 3;
|
|
98431
98651
|
}
|
|
98432
|
-
if (
|
|
98433
|
-
|
|
98434
|
-
|
|
98435
|
-
|
|
98652
|
+
if (/^<!DOCTYPE\b/i.test(xml.slice(cursor2))) {
|
|
98653
|
+
return markupEnd2(xml, cursor2 + 2, true) + 1;
|
|
98654
|
+
}
|
|
98655
|
+
return null;
|
|
98656
|
+
}
|
|
98657
|
+
function qtiRootAttributes(xml, start2, end) {
|
|
98658
|
+
const attributes2 = [];
|
|
98659
|
+
let cursor2 = start2;
|
|
98660
|
+
while (cursor2 < end) {
|
|
98661
|
+
cursor2 = skipXmlWhitespace(xml, cursor2, end);
|
|
98662
|
+
if (xml[cursor2] === "/") {
|
|
98663
|
+
cursor2 += 1;
|
|
98664
|
+
} else if (cursor2 < end) {
|
|
98665
|
+
const nameMatch = /^[A-Za-z_][\w.:-]*/.exec(xml.slice(cursor2, end));
|
|
98666
|
+
if (!nameMatch) {
|
|
98667
|
+
return invalidQtiCopyXml();
|
|
98668
|
+
}
|
|
98669
|
+
const name3 = nameMatch[0];
|
|
98670
|
+
cursor2 = skipXmlWhitespace(xml, cursor2 + name3.length, end);
|
|
98671
|
+
if (xml[cursor2] !== "=") {
|
|
98672
|
+
return invalidQtiCopyXml();
|
|
98673
|
+
}
|
|
98674
|
+
cursor2 = skipXmlWhitespace(xml, cursor2 + 1, end);
|
|
98675
|
+
const quote = xml[cursor2];
|
|
98676
|
+
if (quote !== '"' && quote !== "'") {
|
|
98677
|
+
return invalidQtiCopyXml();
|
|
98678
|
+
}
|
|
98679
|
+
const valueStart = cursor2 + 1;
|
|
98680
|
+
const valueEnd = xml.indexOf(quote, valueStart);
|
|
98681
|
+
if (valueEnd === -1 || valueEnd > end) {
|
|
98682
|
+
return invalidQtiCopyXml();
|
|
98683
|
+
}
|
|
98684
|
+
attributes2.push({ name: name3, quote, valueStart, valueEnd });
|
|
98685
|
+
cursor2 = valueEnd + 1;
|
|
98436
98686
|
}
|
|
98437
98687
|
}
|
|
98438
|
-
return
|
|
98688
|
+
return attributes2;
|
|
98439
98689
|
}
|
|
98440
|
-
function
|
|
98441
|
-
|
|
98690
|
+
function qtiItemStartTag(xml) {
|
|
98691
|
+
let cursor2 = xml.charCodeAt(0) === 65279 ? 1 : 0;
|
|
98692
|
+
while (cursor2 < xml.length) {
|
|
98693
|
+
cursor2 = skipXmlWhitespace(xml, cursor2);
|
|
98694
|
+
const preambleEnd = xmlPreambleEnd(xml, cursor2);
|
|
98695
|
+
if (preambleEnd !== null) {
|
|
98696
|
+
cursor2 = preambleEnd;
|
|
98697
|
+
} else {
|
|
98698
|
+
const root = /^<([A-Za-z_][\w.:-]*)/.exec(xml.slice(cursor2));
|
|
98699
|
+
if (!root || root[1].split(":").at(-1) !== "qti-assessment-item") {
|
|
98700
|
+
return invalidQtiCopyXml();
|
|
98701
|
+
}
|
|
98702
|
+
const attributesStart = cursor2 + root[0].length;
|
|
98703
|
+
const end = markupEnd2(xml, attributesStart, false);
|
|
98704
|
+
const attributes2 = qtiRootAttributes(xml, attributesStart, end);
|
|
98705
|
+
let insertionPoint = skipXmlWhitespaceBackward(xml, end);
|
|
98706
|
+
if (xml[insertionPoint - 1] === "/") {
|
|
98707
|
+
insertionPoint -= 1;
|
|
98708
|
+
}
|
|
98709
|
+
return { attributes: attributes2, insertionPoint };
|
|
98710
|
+
}
|
|
98711
|
+
}
|
|
98712
|
+
return invalidQtiCopyXml();
|
|
98442
98713
|
}
|
|
98443
|
-
function
|
|
98444
|
-
|
|
98445
|
-
|
|
98714
|
+
function skipXmlWhitespaceBackward(xml, start2) {
|
|
98715
|
+
let cursor2 = start2;
|
|
98716
|
+
while (/\s/.test(xml[cursor2 - 1] ?? "")) {
|
|
98717
|
+
cursor2 -= 1;
|
|
98446
98718
|
}
|
|
98719
|
+
return cursor2;
|
|
98447
98720
|
}
|
|
98448
|
-
function
|
|
98449
|
-
|
|
98450
|
-
|
|
98721
|
+
function escapeXmlAttribute(value, quote) {
|
|
98722
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(quote, quote === '"' ? """ : "'");
|
|
98723
|
+
}
|
|
98724
|
+
function rewriteQtiItemIdentity(xml, identifier, title) {
|
|
98725
|
+
if (!xml.trim() || !identifier.trim() || !title.trim()) {
|
|
98726
|
+
return invalidQtiCopyXml();
|
|
98451
98727
|
}
|
|
98452
|
-
|
|
98728
|
+
const root = qtiItemStartTag(xml);
|
|
98729
|
+
const replacements = [];
|
|
98730
|
+
const targetAttributes = new Map([
|
|
98731
|
+
["identifier", identifier],
|
|
98732
|
+
["title", title]
|
|
98733
|
+
]);
|
|
98734
|
+
for (const [name3, value] of targetAttributes) {
|
|
98735
|
+
const matches = root.attributes.filter((attribute2) => attribute2.name === name3);
|
|
98736
|
+
if (matches.length > 1) {
|
|
98737
|
+
return invalidQtiCopyXml();
|
|
98738
|
+
}
|
|
98739
|
+
const attribute = matches[0];
|
|
98740
|
+
if (attribute) {
|
|
98741
|
+
replacements.push({
|
|
98742
|
+
start: attribute.valueStart,
|
|
98743
|
+
end: attribute.valueEnd,
|
|
98744
|
+
value: escapeXmlAttribute(value, attribute.quote)
|
|
98745
|
+
});
|
|
98746
|
+
} else {
|
|
98747
|
+
replacements.push({
|
|
98748
|
+
start: root.insertionPoint,
|
|
98749
|
+
end: root.insertionPoint,
|
|
98750
|
+
value: ` ${name3}="${escapeXmlAttribute(value, '"')}"`
|
|
98751
|
+
});
|
|
98752
|
+
}
|
|
98753
|
+
}
|
|
98754
|
+
return replacements.toSorted((left, right) => right.start - left.start).reduce((rewritten, replacement) => `${rewritten.slice(0, replacement.start)}${replacement.value}${rewritten.slice(replacement.end)}`, xml);
|
|
98453
98755
|
}
|
|
98454
|
-
function
|
|
98455
|
-
|
|
98756
|
+
function buildQtiQuestionCopyInput(input) {
|
|
98757
|
+
assertPlayableQtiQuestion(input.source);
|
|
98758
|
+
const title = input.source.title?.trim() || input.source.identifier;
|
|
98759
|
+
if (!input.source.rawXml) {
|
|
98760
|
+
return invalidQtiCopyXml();
|
|
98761
|
+
}
|
|
98762
|
+
const sourceMetadata = { ...input.source.metadata };
|
|
98763
|
+
if (sourceMetadata.ownerSystem !== PLAYCADEMY_QTI_OWNER_SYSTEM) {
|
|
98764
|
+
Reflect.deleteProperty(sourceMetadata, PLAYCADEMY_QTI_AUTHORING_METADATA_KEY);
|
|
98765
|
+
}
|
|
98766
|
+
const metadata2 = buildOwnedQtiQuestionMetadata({
|
|
98767
|
+
metadata: {
|
|
98768
|
+
copiedFromItemIdentifier: input.source.identifier,
|
|
98769
|
+
integrationId: input.integrationId
|
|
98770
|
+
}
|
|
98771
|
+
}, {
|
|
98772
|
+
gameSlug: input.gameSlug,
|
|
98773
|
+
testIdentifier: input.targetTestIdentifier
|
|
98774
|
+
}, sourceMetadata);
|
|
98775
|
+
return {
|
|
98776
|
+
identifier: input.targetIdentifier,
|
|
98777
|
+
title,
|
|
98778
|
+
xml: rewriteQtiItemIdentity(input.source.rawXml, input.targetIdentifier, title),
|
|
98779
|
+
metadata: metadata2
|
|
98780
|
+
};
|
|
98456
98781
|
}
|
|
98457
|
-
function
|
|
98458
|
-
|
|
98459
|
-
|
|
98460
|
-
throw new ValidationError(`Assessment order must include every live ${purpose} assessment exactly once`);
|
|
98782
|
+
function assertQtiQuestionEditable(item, ownership, ownerTest) {
|
|
98783
|
+
if (!isQtiQuestionOwnedByContext(item, ownership, ownerTest)) {
|
|
98784
|
+
throw new ValidationError("Shared question references are read-only. Copy the question to create an editable independent item.");
|
|
98461
98785
|
}
|
|
98462
|
-
return testIdentifiers.map((identifier) => rowsByIdentifier.get(identifier));
|
|
98463
98786
|
}
|
|
98464
|
-
|
|
98787
|
+
function buildNumericQuestionXml(input, identifier, title) {
|
|
98788
|
+
if (input.format === "xml") {
|
|
98789
|
+
throw new ValidationError("Raw question XML is not accepted at this API boundary");
|
|
98790
|
+
}
|
|
98791
|
+
const candidate = input.numericTextEntry;
|
|
98792
|
+
if (candidate === undefined) {
|
|
98793
|
+
return null;
|
|
98794
|
+
}
|
|
98795
|
+
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) {
|
|
98796
|
+
throw new ValidationError("Numeric text-entry data is invalid");
|
|
98797
|
+
}
|
|
98798
|
+
const prompt = "prompt" in candidate ? candidate.prompt : undefined;
|
|
98799
|
+
const numeric3 = parseNumericTextEntry({
|
|
98800
|
+
baseType: "baseType" in candidate ? candidate.baseType : undefined,
|
|
98801
|
+
answer: "answer" in candidate ? candidate.answer : undefined,
|
|
98802
|
+
comparison: "comparison" in candidate ? candidate.comparison : undefined
|
|
98803
|
+
});
|
|
98804
|
+
if (!numeric3.success) {
|
|
98805
|
+
throw new ValidationError(numeric3.message);
|
|
98806
|
+
}
|
|
98807
|
+
if (!title || typeof prompt !== "string" || !prompt.trim()) {
|
|
98808
|
+
throw new ValidationError("Numeric questions require a title and prompt");
|
|
98809
|
+
}
|
|
98810
|
+
return numericTextEntryXml({ identifier, title, prompt: prompt.trim(), numeric: numeric3.value });
|
|
98811
|
+
}
|
|
98812
|
+
function buildExactMatchQuestionXml(input) {
|
|
98813
|
+
const responseIdentifier = escapeXml(input.responseIdentifier);
|
|
98814
|
+
const correctValues = input.correctIdentifiers.map((identifier) => ` <qti-value>${escapeXml(identifier)}</qti-value>`).join(`
|
|
98815
|
+
`);
|
|
98816
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
98817
|
+
<qti-assessment-item xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0" identifier="${escapeXml(input.identifier)}" title="${escapeXml(input.title)}" adaptive="false" time-dependent="false">
|
|
98818
|
+
<qti-response-declaration identifier="${responseIdentifier}" cardinality="${input.cardinality}" base-type="identifier">
|
|
98819
|
+
<qti-correct-response>
|
|
98820
|
+
${correctValues}
|
|
98821
|
+
</qti-correct-response>
|
|
98822
|
+
</qti-response-declaration>
|
|
98823
|
+
<qti-outcome-declaration identifier="FEEDBACK" cardinality="single" base-type="identifier" />
|
|
98824
|
+
<qti-outcome-declaration identifier="SCORE" cardinality="single" base-type="float">
|
|
98825
|
+
<qti-default-value><qti-value>0</qti-value></qti-default-value>
|
|
98826
|
+
</qti-outcome-declaration>
|
|
98827
|
+
<qti-item-body>
|
|
98828
|
+
${input.itemBody}
|
|
98829
|
+
</qti-item-body>
|
|
98830
|
+
<qti-response-processing>
|
|
98831
|
+
<qti-response-condition>
|
|
98832
|
+
<qti-response-if>
|
|
98833
|
+
<qti-match><qti-variable identifier="${responseIdentifier}" /><qti-correct identifier="${responseIdentifier}" /></qti-match>
|
|
98834
|
+
<qti-set-outcome-value identifier="FEEDBACK"><qti-base-value base-type="identifier">CORRECT</qti-base-value></qti-set-outcome-value>
|
|
98835
|
+
<qti-set-outcome-value identifier="SCORE"><qti-base-value base-type="float">1</qti-base-value></qti-set-outcome-value>
|
|
98836
|
+
</qti-response-if>
|
|
98837
|
+
<qti-response-else>
|
|
98838
|
+
<qti-set-outcome-value identifier="FEEDBACK"><qti-base-value base-type="identifier">INCORRECT</qti-base-value></qti-set-outcome-value>
|
|
98839
|
+
<qti-set-outcome-value identifier="SCORE"><qti-base-value base-type="float">0</qti-base-value></qti-set-outcome-value>
|
|
98840
|
+
</qti-response-else>
|
|
98841
|
+
</qti-response-condition>
|
|
98842
|
+
</qti-response-processing>
|
|
98843
|
+
</qti-assessment-item>`;
|
|
98844
|
+
}
|
|
98845
|
+
function parseStructuredChoices(value, options) {
|
|
98846
|
+
const { label, minimum, validate: validate2 } = options;
|
|
98847
|
+
if (!Array.isArray(value) || value.length < minimum) {
|
|
98848
|
+
throw new ValidationError(`${label} questions require at least ${minimum === 1 ? "one choice" : `${minimum} choices`}`);
|
|
98849
|
+
}
|
|
98850
|
+
const choices = value.map((choice) => {
|
|
98851
|
+
const record3 = recordValue(choice);
|
|
98852
|
+
const identifier = record3?.identifier;
|
|
98853
|
+
const content = record3?.content;
|
|
98854
|
+
if (typeof identifier !== "string" || !identifier.trim() || typeof content !== "string" || !content.trim() || validate2 !== undefined && record3 !== undefined && !validate2(record3)) {
|
|
98855
|
+
throw new ValidationError(`${label} options are invalid`);
|
|
98856
|
+
}
|
|
98857
|
+
return { identifier: identifier.trim(), content: content.trim() };
|
|
98858
|
+
});
|
|
98859
|
+
if (new Set(choices.map((choice) => choice.identifier)).size !== choices.length) {
|
|
98860
|
+
throw new ValidationError(`${label} option identifiers must be unique`);
|
|
98861
|
+
}
|
|
98862
|
+
return choices;
|
|
98863
|
+
}
|
|
98864
|
+
function declaredCorrectResponse(input, responseIdentifier) {
|
|
98865
|
+
const declarations = Array.isArray(input.responseDeclarations) ? input.responseDeclarations : [];
|
|
98866
|
+
const declaration = declarations.map(recordValue).find((candidate) => candidate?.identifier === responseIdentifier);
|
|
98867
|
+
const rawValues = recordValue(declaration?.correctResponse)?.value;
|
|
98868
|
+
return {
|
|
98869
|
+
cardinality: declaration?.cardinality,
|
|
98870
|
+
baseType: declaration?.baseType,
|
|
98871
|
+
values: Array.isArray(rawValues) ? rawValues.filter((value) => typeof value === "string") : []
|
|
98872
|
+
};
|
|
98873
|
+
}
|
|
98874
|
+
function inlineChoiceCorrectIdentifier(input, responseIdentifier, choiceIdentifiers) {
|
|
98875
|
+
const { cardinality, baseType, values } = declaredCorrectResponse(input, responseIdentifier);
|
|
98876
|
+
if (cardinality !== "single" || baseType !== "identifier") {
|
|
98877
|
+
throw new ValidationError("Inline-choice questions require a single-identifier response declaration");
|
|
98878
|
+
}
|
|
98879
|
+
if (values.length !== 1 || !choiceIdentifiers.has(values[0])) {
|
|
98880
|
+
throw new ValidationError("Inline-choice questions require one valid correct option");
|
|
98881
|
+
}
|
|
98882
|
+
return values[0];
|
|
98883
|
+
}
|
|
98884
|
+
function structuredQuestionEnvelope(input, type, label, title) {
|
|
98885
|
+
const interaction = recordValue(input.interaction);
|
|
98886
|
+
if (normalizedQtiInteractionType(interaction?.type ?? input.type) !== type) {
|
|
98887
|
+
return null;
|
|
98888
|
+
}
|
|
98889
|
+
const structure = recordValue(interaction?.questionStructure);
|
|
98890
|
+
const responseIdentifier = interaction?.responseIdentifier;
|
|
98891
|
+
const prompt = structure?.prompt;
|
|
98892
|
+
if (!interaction || !structure || typeof responseIdentifier !== "string" || !responseIdentifier.trim()) {
|
|
98893
|
+
throw new ValidationError(`${label} question data is invalid`);
|
|
98894
|
+
}
|
|
98895
|
+
if (!title || typeof prompt !== "string" || !prompt.trim()) {
|
|
98896
|
+
throw new ValidationError(`${label} questions require a title and prompt`);
|
|
98897
|
+
}
|
|
98898
|
+
return {
|
|
98899
|
+
interaction,
|
|
98900
|
+
structure,
|
|
98901
|
+
responseIdentifier: responseIdentifier.trim(),
|
|
98902
|
+
prompt: prompt.trim()
|
|
98903
|
+
};
|
|
98904
|
+
}
|
|
98905
|
+
function buildInlineChoiceQuestionXml(input, identifier, title) {
|
|
98906
|
+
const envelope = structuredQuestionEnvelope(input, "inline-choice", "Inline-choice", title);
|
|
98907
|
+
if (!envelope) {
|
|
98908
|
+
return null;
|
|
98909
|
+
}
|
|
98910
|
+
const { structure, responseIdentifier, prompt } = envelope;
|
|
98911
|
+
const promptParts = prompt.split(INLINE_CHOICE_BLANK);
|
|
98912
|
+
if (promptParts.length !== 2) {
|
|
98913
|
+
throw new ValidationError("Inline-choice questions require exactly one blank");
|
|
98914
|
+
}
|
|
98915
|
+
const choices = parseStructuredChoices(structure.inlineChoices, {
|
|
98916
|
+
label: "Inline-choice",
|
|
98917
|
+
minimum: 2
|
|
98918
|
+
});
|
|
98919
|
+
const choiceIdentifiers = new Set(choices.map((choice) => choice.identifier));
|
|
98920
|
+
const correctIdentifier = inlineChoiceCorrectIdentifier(input, responseIdentifier, choiceIdentifiers);
|
|
98921
|
+
const choicesXml = choices.map((choice) => ` <qti-inline-choice identifier="${escapeXml(choice.identifier)}">${escapeXml(choice.content)}</qti-inline-choice>`).join(`
|
|
98922
|
+
`);
|
|
98923
|
+
const [before = "", after = ""] = promptParts;
|
|
98924
|
+
const itemBody = ` <p>${escapeXml(before)}<qti-inline-choice-interaction response-identifier="${escapeXml(responseIdentifier)}">
|
|
98925
|
+
${choicesXml}
|
|
98926
|
+
</qti-inline-choice-interaction>${escapeXml(after)}</p>`;
|
|
98927
|
+
return buildExactMatchQuestionXml({
|
|
98928
|
+
identifier,
|
|
98929
|
+
title,
|
|
98930
|
+
responseIdentifier,
|
|
98931
|
+
cardinality: "single",
|
|
98932
|
+
correctIdentifiers: [correctIdentifier],
|
|
98933
|
+
itemBody
|
|
98934
|
+
});
|
|
98935
|
+
}
|
|
98936
|
+
function parseStructuredMatchChoices(value, label) {
|
|
98937
|
+
return parseStructuredChoices(value, {
|
|
98938
|
+
label: `Match ${label}`,
|
|
98939
|
+
minimum: 1,
|
|
98940
|
+
validate: (choice) => choice.matchMax === 1
|
|
98941
|
+
});
|
|
98942
|
+
}
|
|
98943
|
+
function structuredMatchCorrectPairs(input, responseIdentifier, sources, targets) {
|
|
98944
|
+
const { cardinality, baseType, values } = declaredCorrectResponse(input, responseIdentifier);
|
|
98945
|
+
if (cardinality !== "multiple" || baseType !== "directedPair") {
|
|
98946
|
+
throw new ValidationError("Match questions require a multiple directed-pair response declaration");
|
|
98947
|
+
}
|
|
98948
|
+
const sourceIdentifiers = new Set(sources.map((choice) => choice.identifier));
|
|
98949
|
+
const targetIdentifiers = new Set(targets.map((choice) => choice.identifier));
|
|
98950
|
+
const valid = values.length > 0 && new Set(values).size === values.length && values.every((value) => {
|
|
98951
|
+
const [source, target, extra] = value.split(" ");
|
|
98952
|
+
return extra === undefined && Boolean(source && target) && sourceIdentifiers.has(source) && targetIdentifiers.has(target);
|
|
98953
|
+
});
|
|
98954
|
+
if (!valid) {
|
|
98955
|
+
throw new ValidationError("Every Match correct response must name one declared source and target choice");
|
|
98956
|
+
}
|
|
98957
|
+
return values;
|
|
98958
|
+
}
|
|
98959
|
+
function buildMatchQuestionXml(input, identifier, title) {
|
|
98960
|
+
const envelope = structuredQuestionEnvelope(input, "match", "Match", title);
|
|
98961
|
+
if (!envelope) {
|
|
98962
|
+
return null;
|
|
98963
|
+
}
|
|
98964
|
+
const { interaction, structure, responseIdentifier, prompt } = envelope;
|
|
98965
|
+
const sources = parseStructuredMatchChoices(structure.sourceChoices, "source");
|
|
98966
|
+
const targets = parseStructuredMatchChoices(structure.targetChoices, "target");
|
|
98967
|
+
const sourceIdentifiers = new Set(sources.map((choice) => choice.identifier));
|
|
98968
|
+
if (targets.some((choice) => sourceIdentifiers.has(choice.identifier))) {
|
|
98969
|
+
throw new ValidationError("Match source and target choice identifiers must be unique");
|
|
98970
|
+
}
|
|
98971
|
+
const correctValues = structuredMatchCorrectPairs(input, responseIdentifier, sources, targets);
|
|
98972
|
+
const maxAssociations = interaction.maxAssociations;
|
|
98973
|
+
if (typeof maxAssociations !== "number" || !Number.isInteger(maxAssociations) || maxAssociations < 0 || maxAssociations !== 0 && maxAssociations < correctValues.length) {
|
|
98974
|
+
throw new ValidationError("Match max associations must fit every correct pair");
|
|
98975
|
+
}
|
|
98976
|
+
const item = {
|
|
98977
|
+
key: identifier,
|
|
98978
|
+
title,
|
|
98979
|
+
body: [
|
|
98980
|
+
{
|
|
98981
|
+
kind: "interaction",
|
|
98982
|
+
type: "match",
|
|
98983
|
+
responseIdentifier,
|
|
98984
|
+
attributes: {
|
|
98985
|
+
shuffle: interaction.shuffle === true,
|
|
98986
|
+
"max-associations": maxAssociations
|
|
98987
|
+
},
|
|
98988
|
+
content: [
|
|
98989
|
+
{
|
|
98990
|
+
kind: "element",
|
|
98991
|
+
name: "qti-prompt",
|
|
98992
|
+
children: [{ kind: "text", text: prompt }]
|
|
98993
|
+
}
|
|
98994
|
+
],
|
|
98995
|
+
choiceSets: [sources, targets].map((choices) => choices.map((choice) => ({
|
|
98996
|
+
identifier: choice.identifier,
|
|
98997
|
+
content: [{ kind: "text", text: choice.content }]
|
|
98998
|
+
})))
|
|
98999
|
+
}
|
|
99000
|
+
],
|
|
99001
|
+
responseDeclarations: [
|
|
99002
|
+
{
|
|
99003
|
+
identifier: responseIdentifier,
|
|
99004
|
+
cardinality: "multiple",
|
|
99005
|
+
baseType: "directedPair",
|
|
99006
|
+
correctValues
|
|
99007
|
+
}
|
|
99008
|
+
]
|
|
99009
|
+
};
|
|
99010
|
+
try {
|
|
99011
|
+
return compileQtiAuthoringItemXml(item, { identifier, title });
|
|
99012
|
+
} catch (error88) {
|
|
99013
|
+
throw new ValidationError(`This Match question cannot be compiled to QTI: ${errorMessage(error88)}`);
|
|
99014
|
+
}
|
|
99015
|
+
}
|
|
99016
|
+
function parseHottextSegments2(value) {
|
|
99017
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
99018
|
+
throw new ValidationError("Hottext questions require passage segments");
|
|
99019
|
+
}
|
|
99020
|
+
const segments = value.map((segment) => {
|
|
99021
|
+
const record3 = recordValue(segment);
|
|
99022
|
+
const identifier = record3?.identifier;
|
|
99023
|
+
const content = record3?.content;
|
|
99024
|
+
const mode = record3?.mode;
|
|
99025
|
+
if (typeof identifier !== "string" || typeof content !== "string" || !content.trim() || !["plain", "option", "correct"].includes(String(mode))) {
|
|
99026
|
+
throw new ValidationError("Hottext passage segments are invalid");
|
|
99027
|
+
}
|
|
99028
|
+
return {
|
|
99029
|
+
identifier: identifier.trim(),
|
|
99030
|
+
content: content.trim(),
|
|
99031
|
+
mode
|
|
99032
|
+
};
|
|
99033
|
+
});
|
|
99034
|
+
const selectable = segments.filter((segment) => segment.mode !== "plain");
|
|
99035
|
+
const correct = selectable.filter((segment) => segment.mode === "correct");
|
|
99036
|
+
const identifiers = selectable.map((segment) => segment.identifier);
|
|
99037
|
+
if (selectable.length < 2) {
|
|
99038
|
+
throw new ValidationError("Hottext questions require at least two selectable phrases");
|
|
99039
|
+
}
|
|
99040
|
+
if (identifiers.some((identifier) => !identifier)) {
|
|
99041
|
+
throw new ValidationError("Hottext selectable phrases require identifiers");
|
|
99042
|
+
}
|
|
99043
|
+
if (new Set(identifiers).size !== identifiers.length) {
|
|
99044
|
+
throw new ValidationError("Hottext selectable phrase identifiers must be unique");
|
|
99045
|
+
}
|
|
99046
|
+
if (correct.length === 0) {
|
|
99047
|
+
throw new ValidationError("Hottext questions require a correct phrase");
|
|
99048
|
+
}
|
|
99049
|
+
return { segments, selectable, correct };
|
|
99050
|
+
}
|
|
99051
|
+
function buildHottextQuestionXml(input, identifier, title) {
|
|
99052
|
+
const candidate = input.hottext;
|
|
99053
|
+
if (candidate === undefined) {
|
|
99054
|
+
return null;
|
|
99055
|
+
}
|
|
99056
|
+
const hottext = recordValue(candidate);
|
|
99057
|
+
if (!hottext) {
|
|
99058
|
+
throw new ValidationError("Hottext question data is invalid");
|
|
99059
|
+
}
|
|
99060
|
+
const prompt = hottext.prompt;
|
|
99061
|
+
if (!title || typeof prompt !== "string" || !prompt.trim()) {
|
|
99062
|
+
throw new ValidationError("Hottext questions require a title and prompt");
|
|
99063
|
+
}
|
|
99064
|
+
const { segments, selectable, correct } = parseHottextSegments2(hottext.segments);
|
|
99065
|
+
const multiple = correct.length > 1;
|
|
99066
|
+
const maxChoices = hottextMaxChoices(selectable.length, multiple);
|
|
99067
|
+
const passage = segments.map((segment) => segment.mode === "plain" ? escapeXml(segment.content) : `<qti-hottext identifier="${escapeXml(segment.identifier)}">${escapeXml(segment.content)}</qti-hottext>`).join(" ");
|
|
99068
|
+
const itemBody = ` <qti-hottext-interaction response-identifier="RESPONSE" max-choices="${maxChoices}">
|
|
99069
|
+
<qti-prompt>${escapeXml(prompt.trim())}</qti-prompt>
|
|
99070
|
+
<p>${passage}</p>
|
|
99071
|
+
</qti-hottext-interaction>`;
|
|
99072
|
+
return buildExactMatchQuestionXml({
|
|
99073
|
+
identifier,
|
|
99074
|
+
title,
|
|
99075
|
+
responseIdentifier: "RESPONSE",
|
|
99076
|
+
cardinality: multiple ? "multiple" : "single",
|
|
99077
|
+
correctIdentifiers: correct.map((segment) => segment.identifier),
|
|
99078
|
+
itemBody
|
|
99079
|
+
});
|
|
99080
|
+
}
|
|
99081
|
+
function buildAuthoringDocumentQuestionXml(input, identifier, title) {
|
|
99082
|
+
if (input.type !== AUTHORING_DOCUMENT_QUESTION_TYPE) {
|
|
99083
|
+
return null;
|
|
99084
|
+
}
|
|
99085
|
+
const item = authoringDocumentItem(input.document);
|
|
99086
|
+
if (!title) {
|
|
99087
|
+
throw new ValidationError("Authored questions require a title");
|
|
99088
|
+
}
|
|
99089
|
+
try {
|
|
99090
|
+
return compileQtiAuthoringItemXml(item, { identifier, title });
|
|
99091
|
+
} catch (error88) {
|
|
99092
|
+
throw new ValidationError(`This question cannot be compiled to QTI: ${errorMessage(error88)}`);
|
|
99093
|
+
}
|
|
99094
|
+
}
|
|
99095
|
+
function buildQuestionXml(input, identifier, title) {
|
|
99096
|
+
for (const build2 of QUESTION_XML_BUILDERS) {
|
|
99097
|
+
const xml = build2(input, identifier, title);
|
|
99098
|
+
if (xml !== null) {
|
|
99099
|
+
return xml;
|
|
99100
|
+
}
|
|
99101
|
+
}
|
|
99102
|
+
return null;
|
|
99103
|
+
}
|
|
99104
|
+
function buildCreatedQtiQuestionReference(input) {
|
|
99105
|
+
return {
|
|
99106
|
+
ownership: "owned",
|
|
99107
|
+
reference: {
|
|
99108
|
+
identifier: input.itemIdentifier,
|
|
99109
|
+
href: input.href,
|
|
99110
|
+
testPart: input.partIdentifier,
|
|
99111
|
+
section: input.sectionIdentifier
|
|
99112
|
+
},
|
|
99113
|
+
question: mergeQtiQuestionItem(input.item, input.fallbackItem, input.itemIdentifier, input.metadata)
|
|
99114
|
+
};
|
|
99115
|
+
}
|
|
99116
|
+
function buildHydratedQtiQuestionReference(input) {
|
|
99117
|
+
const identifier = input.reference.reference.identifier;
|
|
99118
|
+
const ownerGameSlug = resolveQtiQuestionOwnerGameSlug(input.item, input.ownerTest);
|
|
99119
|
+
const authoritativeItem = ownerGameSlug ? {
|
|
99120
|
+
...input.item,
|
|
99121
|
+
metadata: { ...input.item.metadata, ownerGameSlug }
|
|
99122
|
+
} : input.item;
|
|
99123
|
+
const question = mergeQtiQuestionItem(authoritativeItem, input.reference.question, identifier);
|
|
99124
|
+
return {
|
|
99125
|
+
...input.reference,
|
|
99126
|
+
ownership: isQtiQuestionOwnedByContext(question, { gameSlug: input.gameSlug, testIdentifier: input.testIdentifier }, input.ownerTest) ? "owned" : "shared",
|
|
99127
|
+
question
|
|
99128
|
+
};
|
|
99129
|
+
}
|
|
99130
|
+
function isQtiTestOwnedByGame(test, gameSlug) {
|
|
99131
|
+
return test.metadata?.ownerSystem === PLAYCADEMY_QTI_OWNER_SYSTEM && test.metadata.ownerGameSlug === gameSlug;
|
|
99132
|
+
}
|
|
99133
|
+
function assertQtiTestOwnedByGame(test, gameSlug) {
|
|
99134
|
+
if (!isQtiTestOwnedByGame(test, gameSlug)) {
|
|
99135
|
+
throw new ValidationError("Shared assessment references are read-only. Copy the assessment to create an editable independent test.");
|
|
99136
|
+
}
|
|
99137
|
+
}
|
|
99138
|
+
function qtiTestParts(test) {
|
|
99139
|
+
const parts2 = test["qti-test-part"];
|
|
99140
|
+
if (!Array.isArray(parts2) || parts2.length === 0) {
|
|
99141
|
+
throw new ValidationError(`Assessment ${test.identifier} has no test parts`);
|
|
99142
|
+
}
|
|
99143
|
+
for (const [partIndex, part] of parts2.entries()) {
|
|
99144
|
+
if (!part || typeof part !== "object" || Array.isArray(part)) {
|
|
99145
|
+
throw new ValidationError(`Assessment ${test.identifier} contains an invalid test part at position ${partIndex + 1}`);
|
|
99146
|
+
}
|
|
99147
|
+
const sections = part["qti-assessment-section"];
|
|
99148
|
+
if (!Array.isArray(sections)) {
|
|
99149
|
+
throw new ValidationError(`Assessment ${test.identifier} test part ${partIndex + 1} has no section list`);
|
|
99150
|
+
}
|
|
99151
|
+
}
|
|
99152
|
+
return parts2;
|
|
99153
|
+
}
|
|
99154
|
+
function qtiTestOptionalAttributes(test) {
|
|
99155
|
+
return {
|
|
99156
|
+
...test.qtiVersion ? { qtiVersion: test.qtiVersion } : {},
|
|
99157
|
+
...test.timeLimit !== undefined ? { timeLimit: test.timeLimit } : {},
|
|
99158
|
+
...test.maxAttempts !== undefined ? { maxAttempts: test.maxAttempts } : {},
|
|
99159
|
+
...test.toolsEnabled !== undefined ? { toolsEnabled: test.toolsEnabled } : {}
|
|
99160
|
+
};
|
|
99161
|
+
}
|
|
99162
|
+
function buildQtiAssessmentItemCopyPlan(test, targetTestIdentifier, hrefForIdentifier, createIdentifier = newPlaycademyQuestionIdentifier) {
|
|
99163
|
+
const sourceIdentifiers = [];
|
|
99164
|
+
const seen = new Set;
|
|
99165
|
+
for (const part of qtiTestParts(test)) {
|
|
99166
|
+
for (const section of part["qti-assessment-section"]) {
|
|
99167
|
+
for (const reference of section["qti-assessment-item-ref"] ?? []) {
|
|
99168
|
+
if (!seen.has(reference.identifier)) {
|
|
99169
|
+
seen.add(reference.identifier);
|
|
99170
|
+
sourceIdentifiers.push(reference.identifier);
|
|
99171
|
+
}
|
|
99172
|
+
}
|
|
99173
|
+
}
|
|
99174
|
+
}
|
|
99175
|
+
return sourceIdentifiers.map((sourceIdentifier) => {
|
|
99176
|
+
const targetIdentifier = createIdentifier(targetTestIdentifier);
|
|
99177
|
+
return {
|
|
99178
|
+
sourceIdentifier,
|
|
99179
|
+
targetIdentifier,
|
|
99180
|
+
href: hrefForIdentifier(targetIdentifier)
|
|
99181
|
+
};
|
|
99182
|
+
});
|
|
99183
|
+
}
|
|
99184
|
+
function buildQtiTestStructureInput(test, targetTestIdentifier, itemCopies) {
|
|
99185
|
+
const outcomeDeclarations = test["qti-outcome-declaration"];
|
|
99186
|
+
if (outcomeDeclarations !== undefined && !Array.isArray(outcomeDeclarations)) {
|
|
99187
|
+
throw new ValidationError(`Assessment ${test.identifier} has an invalid outcome declaration list`);
|
|
99188
|
+
}
|
|
99189
|
+
return {
|
|
99190
|
+
"qti-test-part": qtiTestParts(test).map((part, partIndex) => ({
|
|
99191
|
+
identifier: targetTestIdentifier ? `${targetTestIdentifier}-part${partIndex + 1}` : part.identifier,
|
|
99192
|
+
navigationMode: part.navigationMode,
|
|
99193
|
+
submissionMode: part.submissionMode,
|
|
99194
|
+
"qti-assessment-section": part["qti-assessment-section"].map((section, sectionIndex) => {
|
|
99195
|
+
let sectionIdentifier = section.identifier;
|
|
99196
|
+
if (targetTestIdentifier) {
|
|
99197
|
+
sectionIdentifier = partIndex === 0 && sectionIndex === 0 ? `${targetTestIdentifier}-section1` : `${targetTestIdentifier}-part${partIndex + 1}-section${sectionIndex + 1}`;
|
|
99198
|
+
}
|
|
99199
|
+
return {
|
|
99200
|
+
identifier: sectionIdentifier,
|
|
99201
|
+
title: section.title,
|
|
99202
|
+
visible: section.visible ?? true,
|
|
99203
|
+
...section.required !== undefined ? { required: section.required } : {},
|
|
99204
|
+
...section.fixed !== undefined ? { fixed: section.fixed } : {},
|
|
99205
|
+
sequence: section.sequence ?? sectionIndex + 1,
|
|
99206
|
+
...section["qti-assessment-item-ref"] ? {
|
|
99207
|
+
"qti-assessment-item-ref": section["qti-assessment-item-ref"].map((item, itemIndex) => {
|
|
99208
|
+
const copy = itemCopies?.get(item.identifier);
|
|
99209
|
+
if (itemCopies && !copy) {
|
|
99210
|
+
throw new ValidationError(`Assessment copy is missing question ${item.identifier}`);
|
|
99211
|
+
}
|
|
99212
|
+
return {
|
|
99213
|
+
identifier: copy?.targetIdentifier ?? item.identifier,
|
|
99214
|
+
href: copy?.href ?? item.href,
|
|
99215
|
+
sequence: item.sequence ?? itemIndex + 1
|
|
99216
|
+
};
|
|
99217
|
+
})
|
|
99218
|
+
} : {}
|
|
99219
|
+
};
|
|
99220
|
+
})
|
|
99221
|
+
})),
|
|
99222
|
+
...outcomeDeclarations ? {
|
|
99223
|
+
"qti-outcome-declaration": outcomeDeclarations.map((declaration) => ({
|
|
99224
|
+
identifier: declaration.identifier,
|
|
99225
|
+
...declaration.cardinality !== undefined ? { cardinality: declaration.cardinality } : {},
|
|
99226
|
+
baseType: declaration.baseType,
|
|
99227
|
+
...declaration.normalMaximum !== undefined ? { normalMaximum: declaration.normalMaximum } : {},
|
|
99228
|
+
...declaration.normalMinimum !== undefined ? { normalMinimum: declaration.normalMinimum } : {},
|
|
99229
|
+
...declaration.defaultValue ? {
|
|
99230
|
+
defaultValue: declaration.defaultValue.value !== undefined ? { value: declaration.defaultValue.value } : {}
|
|
99231
|
+
} : {}
|
|
99232
|
+
}))
|
|
99233
|
+
} : {}
|
|
99234
|
+
};
|
|
99235
|
+
}
|
|
99236
|
+
function buildQtiTestUpdateInput(test, title) {
|
|
99237
|
+
return {
|
|
99238
|
+
title,
|
|
99239
|
+
...qtiTestOptionalAttributes(test),
|
|
99240
|
+
...test.metadata ? { metadata: test.metadata } : {},
|
|
99241
|
+
...buildQtiTestStructureInput(test)
|
|
99242
|
+
};
|
|
99243
|
+
}
|
|
99244
|
+
function buildQtiTestCopyInput(source, targetTestIdentifier, metadata2, itemCopies) {
|
|
99245
|
+
const copiesBySourceIdentifier = new Map(itemCopies.map((copy) => [copy.sourceIdentifier, copy]));
|
|
99246
|
+
return {
|
|
99247
|
+
identifier: targetTestIdentifier,
|
|
99248
|
+
title: `${source.title} (copy)`,
|
|
99249
|
+
...qtiTestOptionalAttributes(source),
|
|
99250
|
+
metadata: metadata2,
|
|
99251
|
+
...buildQtiTestStructureInput(source, targetTestIdentifier, copiesBySourceIdentifier)
|
|
99252
|
+
};
|
|
99253
|
+
}
|
|
99254
|
+
function validateUniqueQuestionIdentifiers(itemIdentifiers) {
|
|
99255
|
+
if (itemIdentifiers.length === 0 || new Set(itemIdentifiers).size !== itemIdentifiers.length) {
|
|
99256
|
+
throw new ValidationError("Question order must contain unique question identifiers");
|
|
99257
|
+
}
|
|
99258
|
+
}
|
|
99259
|
+
function resolveQtiQuestionSection(test, qtiTestIdentifier, itemIdentifier, expectedItemIdentifiers) {
|
|
99260
|
+
let selectedPart;
|
|
99261
|
+
let selectedSection;
|
|
99262
|
+
for (const part of qtiTestParts(test)) {
|
|
99263
|
+
for (const section of part["qti-assessment-section"]) {
|
|
99264
|
+
if (!itemIdentifier || section["qti-assessment-item-ref"]?.some((item) => item.identifier === itemIdentifier)) {
|
|
99265
|
+
selectedPart = part;
|
|
99266
|
+
selectedSection = section;
|
|
99267
|
+
break;
|
|
99268
|
+
}
|
|
99269
|
+
}
|
|
99270
|
+
if (selectedSection) {
|
|
99271
|
+
break;
|
|
99272
|
+
}
|
|
99273
|
+
}
|
|
99274
|
+
if (!selectedPart || !selectedSection) {
|
|
99275
|
+
throw new ValidationError(itemIdentifier ? `Question ${itemIdentifier} is not referenced by assessment ${qtiTestIdentifier}` : `Assessment ${qtiTestIdentifier} has no section for questions`);
|
|
99276
|
+
}
|
|
99277
|
+
if (expectedItemIdentifiers) {
|
|
99278
|
+
const sectionIdentifiers = selectedSection["qti-assessment-item-ref"]?.map((item) => item.identifier) ?? [];
|
|
99279
|
+
const expectedIdentifiers = new Set(expectedItemIdentifiers);
|
|
99280
|
+
if (expectedIdentifiers.size !== sectionIdentifiers.length || sectionIdentifiers.some((identifier) => !expectedIdentifiers.has(identifier))) {
|
|
99281
|
+
throw new ValidationError("Questions can only be reordered within one complete assessment section");
|
|
99282
|
+
}
|
|
99283
|
+
}
|
|
99284
|
+
return {
|
|
99285
|
+
partIdentifier: selectedPart.identifier,
|
|
99286
|
+
sectionIdentifier: selectedSection.identifier
|
|
99287
|
+
};
|
|
99288
|
+
}
|
|
99289
|
+
var PLAYCADEMY_QTI_AUTHORING_METADATA_KEY = "playcademyAuthoring", AUTHORING_DOCUMENT_QUESTION_TYPE = "authoring-document", QTI_AUTHORING_FIELDS, QUESTION_XML_BUILDERS;
|
|
99290
|
+
var init_timeback_qti_authoring_util = __esm(() => {
|
|
99291
|
+
init_qti();
|
|
99292
|
+
init_timeback3();
|
|
98465
99293
|
init_errors2();
|
|
99294
|
+
QTI_AUTHORING_FIELDS = [
|
|
99295
|
+
"type",
|
|
99296
|
+
"document",
|
|
99297
|
+
"qtiVersion",
|
|
99298
|
+
"timeDependent",
|
|
99299
|
+
"adaptive",
|
|
99300
|
+
"preInteraction",
|
|
99301
|
+
"interaction",
|
|
99302
|
+
"postInteraction",
|
|
99303
|
+
"responseDeclarations",
|
|
99304
|
+
"outcomeDeclarations",
|
|
99305
|
+
"responseProcessing",
|
|
99306
|
+
"modalFeedback",
|
|
99307
|
+
"feedbackInline",
|
|
99308
|
+
"feedbackBlock",
|
|
99309
|
+
"rubrics",
|
|
99310
|
+
"stimulus",
|
|
99311
|
+
"content"
|
|
99312
|
+
];
|
|
99313
|
+
QUESTION_XML_BUILDERS = [
|
|
99314
|
+
buildNumericQuestionXml,
|
|
99315
|
+
buildAuthoringDocumentQuestionXml,
|
|
99316
|
+
buildHottextQuestionXml,
|
|
99317
|
+
buildInlineChoiceQuestionXml,
|
|
99318
|
+
buildMatchQuestionXml
|
|
99319
|
+
];
|
|
98466
99320
|
});
|
|
98467
99321
|
|
|
98468
99322
|
// ../api-core/src/utils/timeback-assessment-runtime.util.ts
|
|
@@ -99096,6 +99950,429 @@ var init_timeback_assessment_runtime_util = __esm(() => {
|
|
|
99096
99950
|
PLAYABLE_SHAPE_SET = new Set(PLAYABLE_ASSESSMENT_SHAPES);
|
|
99097
99951
|
});
|
|
99098
99952
|
|
|
99953
|
+
// ../api-core/src/utils/timeback-review-mapping.util.ts
|
|
99954
|
+
function reviewMappingIssueSummary(label, identifiers) {
|
|
99955
|
+
if (identifiers.length === 0) {
|
|
99956
|
+
return null;
|
|
99957
|
+
}
|
|
99958
|
+
const displayed = identifiers.slice(0, 10).join(", ");
|
|
99959
|
+
const remainder = identifiers.length > 10 ? `, and ${identifiers.length - 10} more` : "";
|
|
99960
|
+
return `${label} (${identifiers.length}): ${displayed}${remainder}`;
|
|
99961
|
+
}
|
|
99962
|
+
async function prepareReviewMappingUpdate(test, questions) {
|
|
99963
|
+
const contentRevision = await assessmentContentRevision(test, questions);
|
|
99964
|
+
const assessment = {
|
|
99965
|
+
identifier: test.identifier,
|
|
99966
|
+
contractVersion: 1,
|
|
99967
|
+
contentRevision,
|
|
99968
|
+
title: test.title,
|
|
99969
|
+
items: questions.questions.map(({ question }) => ({
|
|
99970
|
+
identifier: question.identifier,
|
|
99971
|
+
title: question.title,
|
|
99972
|
+
prompt: "",
|
|
99973
|
+
maxScore: 1,
|
|
99974
|
+
interactions: []
|
|
99975
|
+
}))
|
|
99976
|
+
};
|
|
99977
|
+
const bank = await buildReviewBankIndex(assessment, questions, { customOnly: true });
|
|
99978
|
+
const issues = reviewBankMappingIssues(bank);
|
|
99979
|
+
if (issues.missingOrInvalidItemIdentifiers.length > 0 || issues.multipleStandardItemIdentifiers.length > 0) {
|
|
99980
|
+
const details = [
|
|
99981
|
+
reviewMappingIssueSummary("missing or invalid associations", issues.missingOrInvalidItemIdentifiers),
|
|
99982
|
+
reviewMappingIssueSummary("multiple associations", issues.multipleStandardItemIdentifiers)
|
|
99983
|
+
].filter((detail) => detail !== null);
|
|
99984
|
+
throw new ValidationError(`Every review question must have exactly one valid standard/node association. ${details.join("; ")}.`);
|
|
99985
|
+
}
|
|
99986
|
+
const manifest = await buildReviewBankManifest(bank);
|
|
99987
|
+
return {
|
|
99988
|
+
update: {
|
|
99989
|
+
...buildQtiTestUpdateInput(test, test.title),
|
|
99990
|
+
metadata: {
|
|
99991
|
+
...test.metadata,
|
|
99992
|
+
[PLAYCADEMY_REVIEW_BANK_MANIFEST_METADATA_KEY]: manifest
|
|
99993
|
+
}
|
|
99994
|
+
},
|
|
99995
|
+
summary: {
|
|
99996
|
+
itemCount: bank.items.length,
|
|
99997
|
+
standardCount: Object.keys(manifest.itemsByStandard).length,
|
|
99998
|
+
sourceFingerprint: manifest.sourceFingerprint
|
|
99999
|
+
}
|
|
100000
|
+
};
|
|
100001
|
+
}
|
|
100002
|
+
var init_timeback_review_mapping_util = __esm(() => {
|
|
100003
|
+
init_assessment_runtime2();
|
|
100004
|
+
init_errors2();
|
|
100005
|
+
init_timeback_assessment_runtime_util();
|
|
100006
|
+
init_timeback_qti_authoring_util();
|
|
100007
|
+
});
|
|
100008
|
+
|
|
100009
|
+
// ../api-core/src/utils/timeback-assessment-publication.util.ts
|
|
100010
|
+
function assessmentKeyFromManagedQtiIdentifier(identifier) {
|
|
100011
|
+
const assessmentKey = /^playcademy-test\.(.+)\.[a-f0-9]{64}$/.exec(identifier)?.[1];
|
|
100012
|
+
return assessmentKey && AssessmentKeySchema.safeParse(assessmentKey).success ? assessmentKey : null;
|
|
100013
|
+
}
|
|
100014
|
+
function assertManagedAssessmentIdentity(test, assessmentKey) {
|
|
100015
|
+
const managed = managedMetadataRecord(test.metadata, PLAYCADEMY_MANAGED_ASSESSMENT_METADATA_KEY);
|
|
100016
|
+
const sourceHash = managed?.sourceHash;
|
|
100017
|
+
const publicationHash = managed?.publicationHash;
|
|
100018
|
+
const hashPattern = /^[a-f0-9]{64}$/;
|
|
100019
|
+
if (managed?.version !== 1 || managed.assessmentKey !== assessmentKey || typeof sourceHash !== "string" || !hashPattern.test(sourceHash) || typeof publicationHash !== "string" || !hashPattern.test(publicationHash) || test.identifier !== `playcademy-test.${assessmentKey}.${publicationHash}`) {
|
|
100020
|
+
throw new ValidationError(`QTI assessment ${test.identifier} is not a managed publication for ${assessmentKey}`);
|
|
100021
|
+
}
|
|
100022
|
+
}
|
|
100023
|
+
function canonicalManagedAssetUrls(source) {
|
|
100024
|
+
return source.replace(MANAGED_ASSET_URL, `${CANONICAL_ASSESSMENT_ASSET_ORIGIN}/$1`);
|
|
100025
|
+
}
|
|
100026
|
+
function declarationNeutralQtiXml(source) {
|
|
100027
|
+
return source.replaceAll(`\r
|
|
100028
|
+
`, `
|
|
100029
|
+
`).replaceAll("\r", `
|
|
100030
|
+
`).replace(/^\s*<\?xml\b[^?]*\?>\s*/i, "");
|
|
100031
|
+
}
|
|
100032
|
+
function managedMetadataRecord(metadata2, key) {
|
|
100033
|
+
const managed = metadata2?.[key];
|
|
100034
|
+
return isRecord5(managed) ? managed : null;
|
|
100035
|
+
}
|
|
100036
|
+
function sourceMetadata(metadata2) {
|
|
100037
|
+
return Object.fromEntries(Object.entries(metadata2 ?? {}).filter(([key]) => !GENERATED_METADATA_KEYS.has(key)));
|
|
100038
|
+
}
|
|
100039
|
+
function normalizedQtiItemSource(item) {
|
|
100040
|
+
if (!item.rawXml?.trim()) {
|
|
100041
|
+
throw new ValidationError(`Question ${item.identifier} has no authoritative QTI XML`);
|
|
100042
|
+
}
|
|
100043
|
+
const title = item.title?.trim() || item.identifier;
|
|
100044
|
+
const declarationNeutralXml = declarationNeutralQtiXml(item.rawXml);
|
|
100045
|
+
const identityNeutralXml = rewriteQtiItemIdentity(declarationNeutralXml, "playcademy-item.content", title);
|
|
100046
|
+
return identityNeutralXml.replace(MANAGED_ASSET_URL, "playcademy-asset://$1").trim();
|
|
100047
|
+
}
|
|
100048
|
+
function testOptionalAttributes(test) {
|
|
100049
|
+
return {
|
|
100050
|
+
...test.qtiVersion ? { qtiVersion: test.qtiVersion } : {},
|
|
100051
|
+
...test.timeLimit !== undefined ? { timeLimit: test.timeLimit } : {},
|
|
100052
|
+
...test.maxAttempts !== undefined ? { maxAttempts: test.maxAttempts } : {},
|
|
100053
|
+
...test.toolsEnabled !== undefined ? { toolsEnabled: test.toolsEnabled } : {}
|
|
100054
|
+
};
|
|
100055
|
+
}
|
|
100056
|
+
function normalizedTestStructure(test, itemIdentity) {
|
|
100057
|
+
const structure = buildQtiTestStructureInput(test);
|
|
100058
|
+
return {
|
|
100059
|
+
"qti-test-part": structure["qti-test-part"].map((part) => ({
|
|
100060
|
+
navigationMode: part.navigationMode,
|
|
100061
|
+
submissionMode: part.submissionMode,
|
|
100062
|
+
"qti-assessment-section": part["qti-assessment-section"].map((section) => ({
|
|
100063
|
+
title: section.title,
|
|
100064
|
+
visible: section.visible,
|
|
100065
|
+
...section.required !== undefined ? { required: section.required } : {},
|
|
100066
|
+
...section.fixed !== undefined ? { fixed: section.fixed } : {},
|
|
100067
|
+
sequence: section.sequence,
|
|
100068
|
+
..."qti-assessment-item-ref" in section && section["qti-assessment-item-ref"] ? {
|
|
100069
|
+
"qti-assessment-item-ref": section["qti-assessment-item-ref"].map((item) => {
|
|
100070
|
+
const identity = itemIdentity.get(item.identifier);
|
|
100071
|
+
if (!identity) {
|
|
100072
|
+
throw new ValidationError(`Assessment ${test.identifier} references unavailable question ${item.identifier}`);
|
|
100073
|
+
}
|
|
100074
|
+
return { identity, sequence: item.sequence };
|
|
100075
|
+
})
|
|
100076
|
+
} : {}
|
|
100077
|
+
}))
|
|
100078
|
+
})),
|
|
100079
|
+
..."qti-outcome-declaration" in structure ? { "qti-outcome-declaration": structure["qti-outcome-declaration"] } : {}
|
|
100080
|
+
};
|
|
100081
|
+
}
|
|
100082
|
+
function remapDiagnosticRouting(manifest, itemIdentity) {
|
|
100083
|
+
if (!manifest) {
|
|
100084
|
+
return null;
|
|
100085
|
+
}
|
|
100086
|
+
return {
|
|
100087
|
+
...manifest,
|
|
100088
|
+
nodes: manifest.nodes.map((node) => {
|
|
100089
|
+
const itemIdentifier = itemIdentity.get(node.itemIdentifier);
|
|
100090
|
+
if (!itemIdentifier) {
|
|
100091
|
+
throw new ValidationError(`Diagnostic routing node ${node.key} references unavailable question ${node.itemIdentifier}`);
|
|
100092
|
+
}
|
|
100093
|
+
return { ...node, itemIdentifier };
|
|
100094
|
+
})
|
|
100095
|
+
};
|
|
100096
|
+
}
|
|
100097
|
+
function canonicalManagedAssessmentArtifact(test) {
|
|
100098
|
+
const structure = buildQtiTestStructureInput(test);
|
|
100099
|
+
const physicalItemIdentity = new Map;
|
|
100100
|
+
for (const part of structure["qti-test-part"]) {
|
|
100101
|
+
for (const section of part["qti-assessment-section"]) {
|
|
100102
|
+
for (const reference of section["qti-assessment-item-ref"] ?? []) {
|
|
100103
|
+
physicalItemIdentity.set(reference.identifier, reference.identifier);
|
|
100104
|
+
}
|
|
100105
|
+
}
|
|
100106
|
+
}
|
|
100107
|
+
return canonicalJson2({
|
|
100108
|
+
title: test.title,
|
|
100109
|
+
...testOptionalAttributes(test),
|
|
100110
|
+
metadata: sourceMetadata(test.metadata),
|
|
100111
|
+
structure: normalizedTestStructure(test, physicalItemIdentity)
|
|
100112
|
+
});
|
|
100113
|
+
}
|
|
100114
|
+
async function prepareManagedAssessmentPublication(input) {
|
|
100115
|
+
const parsedKey = AssessmentKeySchema.safeParse(input.assessmentKey);
|
|
100116
|
+
if (!parsedKey.success) {
|
|
100117
|
+
throw new ValidationError(parsedKey.error.issues[0]?.message ?? "Invalid assessment key");
|
|
100118
|
+
}
|
|
100119
|
+
const assessmentKey = parsedKey.data;
|
|
100120
|
+
const items = await Promise.all(input.questions.questions.map(async ({ question }) => {
|
|
100121
|
+
const metadata2 = sourceMetadata(question.metadata);
|
|
100122
|
+
const normalizedSource = normalizedQtiItemSource(question);
|
|
100123
|
+
const contentHash = await sha256Hex(canonicalJson2({ version: 1, xml: normalizedSource, metadata: metadata2 }));
|
|
100124
|
+
const qtiItemIdentifier = `playcademy-item.${contentHash}`;
|
|
100125
|
+
const title = question.title?.trim() || question.identifier;
|
|
100126
|
+
return {
|
|
100127
|
+
sourceItemIdentifier: question.identifier,
|
|
100128
|
+
qtiItemIdentifier,
|
|
100129
|
+
contentHash,
|
|
100130
|
+
xml: canonicalManagedAssetUrls(rewriteQtiItemIdentity(declarationNeutralQtiXml(question.rawXml), qtiItemIdentifier, title)),
|
|
100131
|
+
metadata: {
|
|
100132
|
+
...metadata2,
|
|
100133
|
+
ownerSystem: PLAYCADEMY_QTI_OWNER_SYSTEM,
|
|
100134
|
+
[PLAYCADEMY_MANAGED_ITEM_METADATA_KEY]: { version: 1, contentHash }
|
|
100135
|
+
}
|
|
100136
|
+
};
|
|
100137
|
+
}));
|
|
100138
|
+
const itemBySource = new Map(items.map((item) => [item.sourceItemIdentifier, item]));
|
|
100139
|
+
const contentHashBySource = new Map([...itemBySource].map(([source, item]) => [source, item.contentHash]));
|
|
100140
|
+
const qtiIdentifierBySource = new Map([...itemBySource].map(([source, item]) => [source, item.qtiItemIdentifier]));
|
|
100141
|
+
const normalizedRouting = remapDiagnosticRouting(input.diagnosticRoutingManifest, contentHashBySource);
|
|
100142
|
+
const sourceHash = await sha256Hex(canonicalJson2({
|
|
100143
|
+
version: 1,
|
|
100144
|
+
title: input.test.title,
|
|
100145
|
+
...testOptionalAttributes(input.test),
|
|
100146
|
+
metadata: sourceMetadata(input.test.metadata),
|
|
100147
|
+
structure: normalizedTestStructure(input.test, contentHashBySource),
|
|
100148
|
+
diagnosticRoutingManifest: normalizedRouting
|
|
100149
|
+
}));
|
|
100150
|
+
const compiledRouting = remapDiagnosticRouting(input.diagnosticRoutingManifest, qtiIdentifierBySource);
|
|
100151
|
+
const publicationHash = await sha256Hex(canonicalJson2({
|
|
100152
|
+
version: 1,
|
|
100153
|
+
sourceHash,
|
|
100154
|
+
itemIdentifiers: items.map((item) => item.qtiItemIdentifier),
|
|
100155
|
+
structure: normalizedTestStructure(input.test, qtiIdentifierBySource),
|
|
100156
|
+
diagnosticRoutingManifest: compiledRouting
|
|
100157
|
+
}));
|
|
100158
|
+
const qtiTestIdentifier = `playcademy-test.${assessmentKey}.${publicationHash}`;
|
|
100159
|
+
const copiesBySource = new Map(items.map((item) => [
|
|
100160
|
+
item.sourceItemIdentifier,
|
|
100161
|
+
{
|
|
100162
|
+
sourceIdentifier: item.sourceItemIdentifier,
|
|
100163
|
+
targetIdentifier: item.qtiItemIdentifier,
|
|
100164
|
+
href: input.itemHref(item.qtiItemIdentifier)
|
|
100165
|
+
}
|
|
100166
|
+
]));
|
|
100167
|
+
let testInput = {
|
|
100168
|
+
identifier: qtiTestIdentifier,
|
|
100169
|
+
title: input.test.title,
|
|
100170
|
+
...testOptionalAttributes(input.test),
|
|
100171
|
+
metadata: {
|
|
100172
|
+
...sourceMetadata(input.test.metadata),
|
|
100173
|
+
ownerSystem: PLAYCADEMY_QTI_OWNER_SYSTEM,
|
|
100174
|
+
[PLAYCADEMY_MANAGED_ASSESSMENT_METADATA_KEY]: {
|
|
100175
|
+
version: 1,
|
|
100176
|
+
assessmentKey,
|
|
100177
|
+
sourceHash,
|
|
100178
|
+
publicationHash,
|
|
100179
|
+
...compiledRouting ? { diagnosticRoutingManifest: compiledRouting } : {}
|
|
100180
|
+
}
|
|
100181
|
+
},
|
|
100182
|
+
...buildQtiTestStructureInput(input.test, qtiTestIdentifier, copiesBySource)
|
|
100183
|
+
};
|
|
100184
|
+
const physicalQuestions = {
|
|
100185
|
+
...input.questions,
|
|
100186
|
+
assessmentTest: qtiTestIdentifier,
|
|
100187
|
+
questions: input.questions.questions.map((entry) => {
|
|
100188
|
+
const item = itemBySource.get(entry.question.identifier);
|
|
100189
|
+
return {
|
|
100190
|
+
reference: {
|
|
100191
|
+
...entry.reference,
|
|
100192
|
+
identifier: item.qtiItemIdentifier,
|
|
100193
|
+
href: input.itemHref(item.qtiItemIdentifier)
|
|
100194
|
+
},
|
|
100195
|
+
question: {
|
|
100196
|
+
...entry.question,
|
|
100197
|
+
identifier: item.qtiItemIdentifier,
|
|
100198
|
+
rawXml: item.xml,
|
|
100199
|
+
metadata: item.metadata
|
|
100200
|
+
}
|
|
100201
|
+
};
|
|
100202
|
+
})
|
|
100203
|
+
};
|
|
100204
|
+
try {
|
|
100205
|
+
const prepared = await prepareReviewMappingUpdate(testInput, physicalQuestions);
|
|
100206
|
+
testInput = { identifier: qtiTestIdentifier, ...prepared.update };
|
|
100207
|
+
} catch (error88) {
|
|
100208
|
+
if (input.purpose === "review" || !(error88 instanceof ValidationError)) {
|
|
100209
|
+
throw error88;
|
|
100210
|
+
}
|
|
100211
|
+
}
|
|
100212
|
+
return {
|
|
100213
|
+
assessmentKey,
|
|
100214
|
+
sourceHash,
|
|
100215
|
+
publicationHash,
|
|
100216
|
+
qtiTestIdentifier,
|
|
100217
|
+
testInput,
|
|
100218
|
+
items,
|
|
100219
|
+
diagnosticRoutingManifest: compiledRouting
|
|
100220
|
+
};
|
|
100221
|
+
}
|
|
100222
|
+
function assertManagedAssessmentPublication(test, expected) {
|
|
100223
|
+
const managed = managedMetadataRecord(test.metadata, PLAYCADEMY_MANAGED_ASSESSMENT_METADATA_KEY);
|
|
100224
|
+
if (test.identifier !== expected.qtiTestIdentifier || !managed || managed.assessmentKey !== expected.assessmentKey || managed.sourceHash !== expected.sourceHash || managed.publicationHash !== expected.publicationHash || canonicalJson2(managed.diagnosticRoutingManifest ?? null) !== canonicalJson2(expected.diagnosticRoutingManifest) || canonicalManagedAssessmentArtifact(test) !== canonicalManagedAssessmentArtifact(expected.testInput)) {
|
|
100225
|
+
throw new ValidationError(`QTI identifier collision for managed assessment ${expected.assessmentKey}`);
|
|
100226
|
+
}
|
|
100227
|
+
}
|
|
100228
|
+
function managedAssessmentDiagnosticRoutingManifest(test, assessmentKey) {
|
|
100229
|
+
const managed = managedMetadataRecord(test.metadata, PLAYCADEMY_MANAGED_ASSESSMENT_METADATA_KEY);
|
|
100230
|
+
const manifest = managed?.diagnosticRoutingManifest;
|
|
100231
|
+
return managed?.assessmentKey === assessmentKey && isRecord5(manifest) ? manifest : null;
|
|
100232
|
+
}
|
|
100233
|
+
async function assertManagedAssessmentItem(item, expected) {
|
|
100234
|
+
const managed = managedMetadataRecord(item.metadata, PLAYCADEMY_MANAGED_ITEM_METADATA_KEY);
|
|
100235
|
+
const actualHash = await sha256Hex(canonicalJson2({
|
|
100236
|
+
version: 1,
|
|
100237
|
+
xml: normalizedQtiItemSource(item),
|
|
100238
|
+
metadata: sourceMetadata(item.metadata)
|
|
100239
|
+
}));
|
|
100240
|
+
if (item.identifier !== expected.qtiItemIdentifier || actualHash !== expected.contentHash || managed?.contentHash !== expected.contentHash) {
|
|
100241
|
+
throw new ValidationError(`QTI identifier collision for item ${expected.qtiItemIdentifier}`);
|
|
100242
|
+
}
|
|
100243
|
+
}
|
|
100244
|
+
var PLAYCADEMY_MANAGED_ASSESSMENT_METADATA_KEY = "playcademyManagedAssessment", PLAYCADEMY_MANAGED_ITEM_METADATA_KEY = "playcademyManagedItem", GENERATED_METADATA_KEYS, MANAGED_ASSET_URL, CANONICAL_ASSESSMENT_ASSET_ORIGIN = "https://cdn.playcademy.net";
|
|
100245
|
+
var init_timeback_assessment_publication_util = __esm(() => {
|
|
100246
|
+
init_src();
|
|
100247
|
+
init_schemas_index();
|
|
100248
|
+
init_assessment_runtime2();
|
|
100249
|
+
init_timeback3();
|
|
100250
|
+
init_errors2();
|
|
100251
|
+
init_timeback_qti_authoring_util();
|
|
100252
|
+
init_timeback_review_mapping_util();
|
|
100253
|
+
init_timeback_util();
|
|
100254
|
+
GENERATED_METADATA_KEYS = new Set([
|
|
100255
|
+
PLAYCADEMY_QTI_AUTHORING_METADATA_KEY,
|
|
100256
|
+
PLAYCADEMY_QTI_EDITOR_MODE_KEY,
|
|
100257
|
+
PLAYCADEMY_QTI_OWNER_TEST_IDENTIFIER_KEY,
|
|
100258
|
+
PLAYCADEMY_REVIEW_BANK_MANIFEST_METADATA_KEY,
|
|
100259
|
+
PLAYCADEMY_MANAGED_ASSESSMENT_METADATA_KEY,
|
|
100260
|
+
PLAYCADEMY_MANAGED_ITEM_METADATA_KEY,
|
|
100261
|
+
"copiedFromItemIdentifier",
|
|
100262
|
+
"copiedFromTestIdentifier",
|
|
100263
|
+
"integrationId",
|
|
100264
|
+
"ownerGameSlug",
|
|
100265
|
+
"ownerSystem",
|
|
100266
|
+
"sourceIdentifier"
|
|
100267
|
+
]);
|
|
100268
|
+
MANAGED_ASSET_URL = new RegExp(`https?://[^\\s"')]+/(${ASSESSMENT_ASSET_KEY_PREFIX}v1/sha256/[a-f0-9]{64}\\.[a-z0-9]+)`, "gi");
|
|
100269
|
+
});
|
|
100270
|
+
|
|
100271
|
+
// ../api-core/src/utils/timeback-assessment-rules.util.ts
|
|
100272
|
+
function validateAssessmentStatusTransition(current, next) {
|
|
100273
|
+
if (current === next) {
|
|
100274
|
+
return;
|
|
100275
|
+
}
|
|
100276
|
+
const allowed = current === "draft" && next === "live" || current === "live" && next === "archived" || current === "archived" && next === "live";
|
|
100277
|
+
if (!allowed) {
|
|
100278
|
+
throw new ValidationError(`Assessment status cannot change from ${current} to ${next}`);
|
|
100279
|
+
}
|
|
100280
|
+
}
|
|
100281
|
+
function isAssessmentPublicationTransition(current, next) {
|
|
100282
|
+
return current !== "live" && next === "live";
|
|
100283
|
+
}
|
|
100284
|
+
function assertDraftAssessment(row) {
|
|
100285
|
+
if (row.status !== "draft") {
|
|
100286
|
+
throw new ValidationError("Only draft assessments can change QTI content or question membership");
|
|
100287
|
+
}
|
|
100288
|
+
}
|
|
100289
|
+
function assertPurposeChangeDraft(row, nextPurpose) {
|
|
100290
|
+
if (row.purpose !== nextPurpose && row.status !== "draft") {
|
|
100291
|
+
throw new ValidationError("Only draft assessments can change purpose");
|
|
100292
|
+
}
|
|
100293
|
+
}
|
|
100294
|
+
function diagnosticDefinitionForRow(row) {
|
|
100295
|
+
return row.diagnosticKey && row.diagnosticRoutingManifest ? {
|
|
100296
|
+
diagnosticKey: row.diagnosticKey,
|
|
100297
|
+
routingManifest: row.diagnosticRoutingManifest
|
|
100298
|
+
} : null;
|
|
100299
|
+
}
|
|
100300
|
+
function assertAllAssessmentAssociationsDraft(rows) {
|
|
100301
|
+
if (rows.some((row) => row.status !== "draft")) {
|
|
100302
|
+
throw new ValidationError("QTI content cannot change while any associated assessment is live or archived");
|
|
100303
|
+
}
|
|
100304
|
+
}
|
|
100305
|
+
function assertAssessmentHasQuestions(questions) {
|
|
100306
|
+
if (questions.length === 0) {
|
|
100307
|
+
throw new ValidationError("An assessment must contain at least one question to publish");
|
|
100308
|
+
}
|
|
100309
|
+
}
|
|
100310
|
+
function assertReviewAssessmentHasStandards(standardCounts) {
|
|
100311
|
+
if (standardCounts.some((count) => count !== 1)) {
|
|
100312
|
+
throw new ValidationError("Every question in a review assessment must have exactly one standards alignment");
|
|
100313
|
+
}
|
|
100314
|
+
}
|
|
100315
|
+
function planAssessmentRemoval(status) {
|
|
100316
|
+
if (status === "draft") {
|
|
100317
|
+
return { kind: "delete", action: "discarded", operation: "discard_draft" };
|
|
100318
|
+
}
|
|
100319
|
+
if (status === "live") {
|
|
100320
|
+
return { kind: "archive", action: "archived", operation: "archive" };
|
|
100321
|
+
}
|
|
100322
|
+
return { kind: "none", action: "archived" };
|
|
100323
|
+
}
|
|
100324
|
+
function buildAssessmentAssociationUpdates(row, input) {
|
|
100325
|
+
const updates = {};
|
|
100326
|
+
if (input.purpose !== undefined) {
|
|
100327
|
+
updates.purpose = input.purpose;
|
|
100328
|
+
if (input.purpose !== "mastery" && (row.purpose === "mastery" || row.standardFramework || row.standardIdentifier)) {
|
|
100329
|
+
updates.standardFramework = null;
|
|
100330
|
+
updates.standardIdentifier = null;
|
|
100331
|
+
}
|
|
100332
|
+
if (row.status === "live" && input.purpose !== row.purpose) {
|
|
100333
|
+
updates.sortOrder = null;
|
|
100334
|
+
}
|
|
100335
|
+
}
|
|
100336
|
+
if (input.standard !== undefined) {
|
|
100337
|
+
updates.standardFramework = input.standard.framework;
|
|
100338
|
+
updates.standardIdentifier = input.standard.identifier;
|
|
100339
|
+
}
|
|
100340
|
+
if (input.status !== undefined) {
|
|
100341
|
+
updates.status = input.status;
|
|
100342
|
+
if (input.status === "archived") {
|
|
100343
|
+
updates.sortOrder = null;
|
|
100344
|
+
}
|
|
100345
|
+
}
|
|
100346
|
+
return updates;
|
|
100347
|
+
}
|
|
100348
|
+
function assessmentStandardForRow(row) {
|
|
100349
|
+
return row.standardFramework && row.standardIdentifier ? { framework: row.standardFramework, identifier: row.standardIdentifier } : null;
|
|
100350
|
+
}
|
|
100351
|
+
function validateUniqueAssessmentIdentifiers(testIdentifiers) {
|
|
100352
|
+
if (new Set(testIdentifiers).size !== testIdentifiers.length) {
|
|
100353
|
+
throw new ValidationError("Assessment order must contain unique identifiers");
|
|
100354
|
+
}
|
|
100355
|
+
}
|
|
100356
|
+
function assertAssessmentOrderUpdateSucceeded(updatedRow) {
|
|
100357
|
+
if (!updatedRow) {
|
|
100358
|
+
throw new ValidationError("Assessment order changed while it was being saved. Refresh and try again.");
|
|
100359
|
+
}
|
|
100360
|
+
return updatedRow;
|
|
100361
|
+
}
|
|
100362
|
+
function lockOrderAssessmentRows(rows) {
|
|
100363
|
+
return rows.toSorted((left, right) => left.id.localeCompare(right.id));
|
|
100364
|
+
}
|
|
100365
|
+
function orderLiveAssessmentRows(liveRows, purpose, testIdentifiers) {
|
|
100366
|
+
const rowsByIdentifier = new Map(liveRows.map((row) => [row.qtiTestIdentifier, row]));
|
|
100367
|
+
if (liveRows.length !== testIdentifiers.length || testIdentifiers.some((identifier) => !rowsByIdentifier.has(identifier))) {
|
|
100368
|
+
throw new ValidationError(`Assessment order must include every live ${purpose} assessment exactly once`);
|
|
100369
|
+
}
|
|
100370
|
+
return testIdentifiers.map((identifier) => rowsByIdentifier.get(identifier));
|
|
100371
|
+
}
|
|
100372
|
+
var init_timeback_assessment_rules_util = __esm(() => {
|
|
100373
|
+
init_errors2();
|
|
100374
|
+
});
|
|
100375
|
+
|
|
99099
100376
|
// ../api-core/src/utils/timeback-qti-hydration.util.ts
|
|
99100
100377
|
async function hydrateQtiTestQuestions(client2, references) {
|
|
99101
100378
|
const questions = await runWithConcurrency(references.questions, QTI_HYDRATION_CONCURRENCY, async (reference) => ({
|
|
@@ -99240,6 +100517,7 @@ class TimebackAssessmentRuntimeService {
|
|
|
99240
100517
|
integrationId: context2.integration.id,
|
|
99241
100518
|
enrollmentId: context2.enrollment.id,
|
|
99242
100519
|
selectedTest: {
|
|
100520
|
+
assessmentKey: decision.test.assessmentKey,
|
|
99243
100521
|
identifier: assessment.identifier,
|
|
99244
100522
|
contentRevision: assessment.contentRevision
|
|
99245
100523
|
},
|
|
@@ -99357,6 +100635,7 @@ class TimebackAssessmentRuntimeService {
|
|
|
99357
100635
|
integrationId: context2.integration.id,
|
|
99358
100636
|
enrollmentId: context2.enrollment.id,
|
|
99359
100637
|
selectedTest: {
|
|
100638
|
+
assessmentKey: definition.assessmentKey,
|
|
99360
100639
|
identifier: assessment.identifier,
|
|
99361
100640
|
contentRevision: assessment.contentRevision
|
|
99362
100641
|
},
|
|
@@ -99498,6 +100777,7 @@ class TimebackAssessmentRuntimeService {
|
|
|
99498
100777
|
integrationId: context2.integration.id,
|
|
99499
100778
|
enrollmentId: context2.enrollment.id,
|
|
99500
100779
|
selectedTest: {
|
|
100780
|
+
assessmentKey: decision.test.assessmentKey,
|
|
99501
100781
|
identifier: source.assessment.identifier,
|
|
99502
100782
|
contentRevision: source.assessment.contentRevision
|
|
99503
100783
|
},
|
|
@@ -100341,7 +101621,7 @@ class TimebackAssessmentRuntimeService {
|
|
|
100341
101621
|
});
|
|
100342
101622
|
const catalogs = loadedCatalogs.filter((catalog) => catalog !== null);
|
|
100343
101623
|
return {
|
|
100344
|
-
version:
|
|
101624
|
+
version: ASSESSMENT_RUNTIME_FIXTURE_BUNDLE_VERSION,
|
|
100345
101625
|
gameId,
|
|
100346
101626
|
exportedAt: new Date().toISOString(),
|
|
100347
101627
|
catalogs
|
|
@@ -100375,7 +101655,7 @@ class TimebackAssessmentRuntimeService {
|
|
|
100375
101655
|
const enrollmentByCourse = new Map(enrollments.map((enrollment) => [enrollment.course.id, enrollment]));
|
|
100376
101656
|
const candidateRows = integrations.filter((integration) => enrollmentByCourse.has(integration.courseId));
|
|
100377
101657
|
const liveTestRows = candidateRows.length === 0 ? [] : await db2.query.gameTimebackAssessmentTests.findMany({
|
|
100378
|
-
where: and(inArray(gameTimebackAssessmentTests.integrationId, candidateRows.map((row) => row.id)), eq(gameTimebackAssessmentTests.purpose, input.purpose), eq(gameTimebackAssessmentTests.status, "live")),
|
|
101658
|
+
where: and(inArray(gameTimebackAssessmentTests.integrationId, candidateRows.map((row) => row.id)), eq(gameTimebackAssessmentTests.purpose, input.purpose), eq(gameTimebackAssessmentTests.status, "live"), isNotNull(gameTimebackAssessmentTests.assessmentKey)),
|
|
100379
101659
|
columns: {
|
|
100380
101660
|
integrationId: true,
|
|
100381
101661
|
standardFramework: true,
|
|
@@ -100431,10 +101711,11 @@ class TimebackAssessmentRuntimeService {
|
|
|
100431
101711
|
async liveTests(integrationId, purpose, db2 = this.deps.db, requestedStandard, requestedDiagnosticKey) {
|
|
100432
101712
|
const matchesMasteryStandard = requestedStandard ? masteryStandardMatcher(requestedStandard) : undefined;
|
|
100433
101713
|
const rows = await db2.query.gameTimebackAssessmentTests.findMany({
|
|
100434
|
-
where: and(eq(gameTimebackAssessmentTests.integrationId, integrationId), eq(gameTimebackAssessmentTests.purpose, purpose), eq(gameTimebackAssessmentTests.status, "live"))
|
|
101714
|
+
where: and(eq(gameTimebackAssessmentTests.integrationId, integrationId), eq(gameTimebackAssessmentTests.purpose, purpose), eq(gameTimebackAssessmentTests.status, "live"), isNotNull(gameTimebackAssessmentTests.assessmentKey))
|
|
100435
101715
|
});
|
|
100436
101716
|
return rows.filter((row) => purpose !== "diagnostic" || requestedDiagnosticKey === undefined || row.diagnosticKey === requestedDiagnosticKey).map((row) => ({
|
|
100437
101717
|
id: row.id,
|
|
101718
|
+
assessmentKey: row.assessmentKey,
|
|
100438
101719
|
qtiTestIdentifier: row.qtiTestIdentifier,
|
|
100439
101720
|
sortOrder: row.sortOrder,
|
|
100440
101721
|
updatedAt: row.updatedAt.toISOString(),
|
|
@@ -100476,19 +101757,23 @@ class TimebackAssessmentRuntimeService {
|
|
|
100476
101757
|
reviewChildren.set(result.sourcedId, result);
|
|
100477
101758
|
}
|
|
100478
101759
|
if (metadata2 && metadata2.activityId === scope.activityId && metadata2.purpose === scope.purpose && metadata2.courseId === scope.courseId && metadata2.integrationId === scope.integrationId && result.student.sourcedId === scope.studentId) {
|
|
100479
|
-
|
|
100480
|
-
|
|
100481
|
-
|
|
100482
|
-
|
|
100483
|
-
|
|
100484
|
-
|
|
100485
|
-
|
|
100486
|
-
|
|
100487
|
-
|
|
100488
|
-
|
|
100489
|
-
|
|
100490
|
-
|
|
100491
|
-
|
|
101760
|
+
const selectedAssessmentKey = metadata2.selectedTest.assessmentKey ?? assessmentKeyFromManagedQtiIdentifier(metadata2.selectedTest.identifier);
|
|
101761
|
+
if (selectedAssessmentKey) {
|
|
101762
|
+
attempts.set(result.sourcedId, {
|
|
101763
|
+
result,
|
|
101764
|
+
metadata: metadata2,
|
|
101765
|
+
selection: {
|
|
101766
|
+
attemptId: result.sourcedId,
|
|
101767
|
+
selectedAssessmentKey,
|
|
101768
|
+
selectedTestIdentifier: metadata2.selectedTest.identifier,
|
|
101769
|
+
inProgress: result.inProgress ?? "",
|
|
101770
|
+
scoreStatus: result.scoreStatus,
|
|
101771
|
+
scoreDate: result.scoreDate,
|
|
101772
|
+
updatedAt: result.dateLastModified ?? metadata2.updatedAt,
|
|
101773
|
+
resumable: metadata2.enrollmentId === scope.enrollmentId
|
|
101774
|
+
}
|
|
101775
|
+
});
|
|
101776
|
+
}
|
|
100492
101777
|
}
|
|
100493
101778
|
}
|
|
100494
101779
|
return { attempts, reviewChildren };
|
|
@@ -100765,14 +102050,23 @@ class TimebackAssessmentRuntimeService {
|
|
|
100765
102050
|
isPlatformRoutedDiagnosticMetadata(metadata2) {
|
|
100766
102051
|
return metadata2.purpose === "diagnostic" && metadata2.diagnostic !== undefined;
|
|
100767
102052
|
}
|
|
100768
|
-
async requireDiagnosticDefinition(definitionId, integrationId, db2 = this.deps.db, requireLive = false) {
|
|
102053
|
+
async requireDiagnosticDefinition(definitionId, integrationId, db2 = this.deps.db, requireLive = false, pinnedQtiTestIdentifier) {
|
|
100769
102054
|
const row = await db2.query.gameTimebackAssessmentTests.findFirst({
|
|
100770
102055
|
where: and(eq(gameTimebackAssessmentTests.id, definitionId), eq(gameTimebackAssessmentTests.integrationId, integrationId))
|
|
100771
102056
|
});
|
|
100772
|
-
if (!row || row.purpose !== "diagnostic" || requireLive && row.status !== "live" || !row.
|
|
102057
|
+
if (!row || row.purpose !== "diagnostic" || requireLive && row.status !== "live" || !row.assessmentKey || !row.diagnosticKey) {
|
|
100773
102058
|
throw new AssessmentRuntimeError("SELECTED_TEST_VERSION_UNAVAILABLE", `The selected diagnostic definition ${definitionId} is unavailable.`, { definitionId });
|
|
100774
102059
|
}
|
|
100775
|
-
|
|
102060
|
+
const requestedQtiTestIdentifier = pinnedQtiTestIdentifier ?? row.qtiTestIdentifier;
|
|
102061
|
+
const pinnedManifest = requestedQtiTestIdentifier === row.qtiTestIdentifier ? row.diagnosticRoutingManifest : managedAssessmentDiagnosticRoutingManifest(await this.requireClient().qtiApi.assessmentTests.get(requestedQtiTestIdentifier), row.assessmentKey);
|
|
102062
|
+
if (!pinnedManifest) {
|
|
102063
|
+
throw new AssessmentRuntimeError("SELECTED_TEST_VERSION_UNAVAILABLE", `The pinned diagnostic publication for ${definitionId} is unavailable.`, { definitionId, pinnedQtiTestIdentifier });
|
|
102064
|
+
}
|
|
102065
|
+
return {
|
|
102066
|
+
...row,
|
|
102067
|
+
qtiTestIdentifier: requestedQtiTestIdentifier,
|
|
102068
|
+
diagnosticRoutingManifest: pinnedManifest
|
|
102069
|
+
};
|
|
100776
102070
|
}
|
|
100777
102071
|
async initializeHostedDiagnostic(definition, assessment) {
|
|
100778
102072
|
const initialized = await this.initializeHostedDiagnosticManifest(definition);
|
|
@@ -100806,7 +102100,7 @@ class TimebackAssessmentRuntimeService {
|
|
|
100806
102100
|
};
|
|
100807
102101
|
}
|
|
100808
102102
|
async loadAttemptDiagnosticManifest(metadata2, db2 = this.deps.db) {
|
|
100809
|
-
const definition = await this.requireDiagnosticDefinition(metadata2.diagnostic.definitionId, metadata2.integrationId, db2);
|
|
102103
|
+
const definition = await this.requireDiagnosticDefinition(metadata2.diagnostic.definitionId, metadata2.integrationId, db2, false, metadata2.selectedTest.identifier);
|
|
100810
102104
|
const initialized = await this.initializeHostedDiagnosticManifest(definition);
|
|
100811
102105
|
if (definition.qtiTestIdentifier !== metadata2.selectedTest.identifier || definition.diagnosticKey !== metadata2.diagnostic.diagnosticKey || initialized.routingRevision !== metadata2.diagnostic.routingRevision) {
|
|
100812
102106
|
throw new AssessmentRuntimeError("SELECTED_TEST_VERSION_UNAVAILABLE", `The pinned definition for diagnostic ${metadata2.diagnostic.diagnosticKey} no longer matches the attempt.`, { definitionId: metadata2.diagnostic.definitionId });
|
|
@@ -101681,6 +102975,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
|
|
|
101681
102975
|
init_uuid();
|
|
101682
102976
|
init_errors2();
|
|
101683
102977
|
init_assessment_runtime_lock_util();
|
|
102978
|
+
init_timeback_assessment_publication_util();
|
|
101684
102979
|
init_timeback_assessment_rules_util();
|
|
101685
102980
|
init_timeback_assessment_runtime_util();
|
|
101686
102981
|
init_timeback_qti_hydration_util();
|
|
@@ -101690,945 +102985,50 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
|
|
|
101690
102985
|
]);
|
|
101691
102986
|
});
|
|
101692
102987
|
|
|
101693
|
-
// ../api-core/src/utils/timeback-
|
|
101694
|
-
function
|
|
101695
|
-
return
|
|
101696
|
-
|
|
101697
|
-
|
|
101698
|
-
|
|
101699
|
-
|
|
101700
|
-
|
|
101701
|
-
return;
|
|
101702
|
-
}
|
|
101703
|
-
const persistableItem = { ...item };
|
|
101704
|
-
Reflect.deleteProperty(persistableItem, "metadata");
|
|
101705
|
-
return { ...document2, item: persistableItem };
|
|
101706
|
-
}
|
|
101707
|
-
function qtiAuthoringSnapshot(input) {
|
|
101708
|
-
const document2 = persistableAuthoringDocument(input.document);
|
|
101709
|
-
if (!recordValue(input.interaction) && !document2) {
|
|
101710
|
-
return;
|
|
101711
|
-
}
|
|
101712
|
-
return Object.fromEntries(QTI_AUTHORING_FIELDS.flatMap((field) => {
|
|
101713
|
-
if (field === "document") {
|
|
101714
|
-
return document2 ? [[field, document2]] : [];
|
|
101715
|
-
}
|
|
101716
|
-
return input[field] === undefined ? [] : [[field, input[field]]];
|
|
102988
|
+
// ../api-core/src/utils/timeback-assessment-diagnostic.util.ts
|
|
102989
|
+
function diagnosticRoutingCapabilities(assessment, scoring) {
|
|
102990
|
+
return assessment.items.map((item) => ({
|
|
102991
|
+
itemIdentifier: item.identifier,
|
|
102992
|
+
supportsDeterminateBinaryGrading: item.interactions.length > 0 && item.interactions.every((interaction) => {
|
|
102993
|
+
const responseIdentifier = interaction.responseIdentifier;
|
|
102994
|
+
return Object.hasOwn(scoring.correctResponses[item.identifier] ?? {}, responseIdentifier) || (scoring.responseAreas?.[item.identifier]?.[responseIdentifier]?.length ?? 0) > 0;
|
|
102995
|
+
})
|
|
101717
102996
|
}));
|
|
101718
102997
|
}
|
|
101719
|
-
function
|
|
101720
|
-
const
|
|
101721
|
-
|
|
101722
|
-
|
|
101723
|
-
...inputMetadata
|
|
101724
|
-
};
|
|
101725
|
-
const existingAuthoring = recordValue(existingMetadata[PLAYCADEMY_QTI_AUTHORING_METADATA_KEY]);
|
|
101726
|
-
Reflect.deleteProperty(metadata2, PLAYCADEMY_QTI_AUTHORING_METADATA_KEY);
|
|
101727
|
-
Reflect.deleteProperty(metadata2, PLAYCADEMY_QTI_EDITOR_MODE_KEY);
|
|
101728
|
-
const authoring = qtiAuthoringSnapshot(input);
|
|
101729
|
-
const shouldReplaceAuthoring = "interaction" in input || "numericTextEntry" in input || "document" in input;
|
|
101730
|
-
const persistedAuthoring = authoring ?? (shouldReplaceAuthoring ? undefined : existingAuthoring);
|
|
101731
|
-
return {
|
|
101732
|
-
...metadata2,
|
|
101733
|
-
ownerSystem: PLAYCADEMY_QTI_OWNER_SYSTEM,
|
|
101734
|
-
ownerGameSlug: ownership.gameSlug,
|
|
101735
|
-
[PLAYCADEMY_QTI_OWNER_TEST_IDENTIFIER_KEY]: ownership.testIdentifier,
|
|
101736
|
-
...persistedAuthoring ? { [PLAYCADEMY_QTI_AUTHORING_METADATA_KEY]: persistedAuthoring } : {}
|
|
101737
|
-
};
|
|
101738
|
-
}
|
|
101739
|
-
function restoreQtiQuestionAuthoringData(item) {
|
|
101740
|
-
const authoring = recordValue(item.metadata?.[PLAYCADEMY_QTI_AUTHORING_METADATA_KEY]);
|
|
101741
|
-
if (!authoring) {
|
|
101742
|
-
return item;
|
|
101743
|
-
}
|
|
101744
|
-
const restored = Object.fromEntries(QTI_AUTHORING_FIELDS.flatMap((field) => authoring[field] === undefined ? [] : [[field, authoring[field]]]));
|
|
101745
|
-
return { ...item, ...restored };
|
|
101746
|
-
}
|
|
101747
|
-
function mergeQtiQuestionItem(item, fallbackItem, itemIdentifier, metadata2) {
|
|
101748
|
-
return restoreQtiQuestionAuthoringData({
|
|
101749
|
-
...fallbackItem,
|
|
101750
|
-
...item,
|
|
101751
|
-
identifier: itemIdentifier,
|
|
101752
|
-
title: item.title || fallbackItem?.title || itemIdentifier,
|
|
101753
|
-
type: item.type ?? fallbackItem?.type,
|
|
101754
|
-
rawXml: item.rawXml ?? fallbackItem?.rawXml,
|
|
101755
|
-
interaction: item.interaction ?? fallbackItem?.interaction,
|
|
101756
|
-
responseDeclarations: item.responseDeclarations ?? fallbackItem?.responseDeclarations,
|
|
101757
|
-
metadata: {
|
|
101758
|
-
...fallbackItem?.metadata,
|
|
101759
|
-
...item.metadata,
|
|
101760
|
-
...metadata2
|
|
101761
|
-
}
|
|
101762
|
-
});
|
|
101763
|
-
}
|
|
101764
|
-
function newPlaycademyQuestionIdentifier(testIdentifier) {
|
|
101765
|
-
return `${testIdentifier}-q${crypto.randomUUID().slice(0, 8)}`;
|
|
101766
|
-
}
|
|
101767
|
-
function parseQtiQuestionCreationInput(input) {
|
|
101768
|
-
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
101769
|
-
throw new ValidationError("Question creation input must be an object");
|
|
102998
|
+
function prepareDiagnosticDefinition(input) {
|
|
102999
|
+
const diagnosticKey = input.diagnosticKey.trim();
|
|
103000
|
+
if (!diagnosticKey || diagnosticKey.length > 200) {
|
|
103001
|
+
throw new ValidationError("Diagnostic key must contain between 1 and 200 characters");
|
|
101770
103002
|
}
|
|
101771
|
-
const
|
|
101772
|
-
|
|
101773
|
-
return { kind: "authoring", input: rawInput };
|
|
101774
|
-
}
|
|
101775
|
-
if (rawInput.mode !== "copy") {
|
|
101776
|
-
throw new ValidationError("Question creation mode is invalid");
|
|
101777
|
-
}
|
|
101778
|
-
const sourceItemIdentifier = typeof rawInput.sourceItemIdentifier === "string" ? rawInput.sourceItemIdentifier.trim() : "";
|
|
101779
|
-
if (!sourceItemIdentifier) {
|
|
101780
|
-
throw new ValidationError("A source question identifier is required to create a copy");
|
|
101781
|
-
}
|
|
101782
|
-
const unexpectedField = Object.keys(rawInput).find((field) => field !== "mode" && field !== "sourceItemIdentifier");
|
|
101783
|
-
if (unexpectedField) {
|
|
101784
|
-
throw new ValidationError(`Question copy input contains an unexpected “${unexpectedField}” field`);
|
|
101785
|
-
}
|
|
101786
|
-
return { kind: "copy", sourceItemIdentifier };
|
|
101787
|
-
}
|
|
101788
|
-
function nonEmptyMetadataString(value) {
|
|
101789
|
-
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
101790
|
-
}
|
|
101791
|
-
function qtiQuestionOwnerTestIdentifier(item) {
|
|
101792
|
-
if (item.metadata?.ownerSystem !== PLAYCADEMY_QTI_OWNER_SYSTEM) {
|
|
101793
|
-
return;
|
|
101794
|
-
}
|
|
101795
|
-
return nonEmptyMetadataString(item.metadata[PLAYCADEMY_QTI_OWNER_TEST_IDENTIFIER_KEY]);
|
|
101796
|
-
}
|
|
101797
|
-
function qtiTestReferencesItem(test, itemIdentifier) {
|
|
101798
|
-
return (test["qti-test-part"] ?? []).some((part) => (part["qti-assessment-section"] ?? []).some((section) => (section["qti-assessment-item-ref"] ?? []).some((reference) => reference.identifier === itemIdentifier)));
|
|
101799
|
-
}
|
|
101800
|
-
function resolveQtiQuestionOwnerGameSlug(item, ownerTest) {
|
|
101801
|
-
if (item.metadata?.ownerSystem !== PLAYCADEMY_QTI_OWNER_SYSTEM) {
|
|
101802
|
-
return;
|
|
101803
|
-
}
|
|
101804
|
-
if ("ownerGameSlug" in item.metadata) {
|
|
101805
|
-
return nonEmptyMetadataString(item.metadata.ownerGameSlug);
|
|
101806
|
-
}
|
|
101807
|
-
const ownerTestIdentifier = qtiQuestionOwnerTestIdentifier(item);
|
|
101808
|
-
if (!ownerTestIdentifier || !ownerTest || ownerTest.identifier !== ownerTestIdentifier || !qtiTestReferencesItem(ownerTest, item.identifier) || ownerTest.metadata?.ownerSystem !== PLAYCADEMY_QTI_OWNER_SYSTEM) {
|
|
101809
|
-
return;
|
|
101810
|
-
}
|
|
101811
|
-
return nonEmptyMetadataString(ownerTest.metadata.ownerGameSlug);
|
|
101812
|
-
}
|
|
101813
|
-
function isQtiQuestionOwnedByGame(item, gameSlug, ownerTest) {
|
|
101814
|
-
return resolveQtiQuestionOwnerGameSlug(item, ownerTest) === gameSlug;
|
|
101815
|
-
}
|
|
101816
|
-
function isQtiQuestionOwnedByContext(item, ownership, ownerTest) {
|
|
101817
|
-
const declaredOwnerTest = qtiQuestionOwnerTestIdentifier(item);
|
|
101818
|
-
if (declaredOwnerTest) {
|
|
101819
|
-
return declaredOwnerTest === ownership.testIdentifier && isQtiQuestionOwnedByGame(item, ownership.gameSlug, ownerTest);
|
|
101820
|
-
}
|
|
101821
|
-
return isQtiItemOwnedByTest(item, ownership.testIdentifier);
|
|
101822
|
-
}
|
|
101823
|
-
function normalizedQtiInteractionType(value) {
|
|
101824
|
-
if (typeof value !== "string") {
|
|
101825
|
-
return;
|
|
101826
|
-
}
|
|
101827
|
-
const normalized = value.trim().toLowerCase().replaceAll("_", "-");
|
|
101828
|
-
return normalized || undefined;
|
|
101829
|
-
}
|
|
101830
|
-
function authoringDocumentItem(value) {
|
|
101831
|
-
const document2 = recordValue(value);
|
|
101832
|
-
if (!document2) {
|
|
101833
|
-
throw new ValidationError("Authored question data is invalid");
|
|
101834
|
-
}
|
|
101835
|
-
if (document2.format !== "playcademy-question" || document2.version !== 1) {
|
|
101836
|
-
throw new ValidationError("Authored questions require a version 1 playcademy-question document");
|
|
101837
|
-
}
|
|
101838
|
-
const item = recordValue(document2.item);
|
|
101839
|
-
if (!item || typeof item.title !== "string" || !Array.isArray(item.body) || !Array.isArray(item.responseDeclarations)) {
|
|
101840
|
-
throw new ValidationError("An authored question needs an item with a title, a body, and response declarations");
|
|
101841
|
-
}
|
|
101842
|
-
const declarations = item.responseDeclarations.map(recordValue);
|
|
101843
|
-
const declaredIdentifiers = declarations.map((declaration) => typeof declaration?.identifier === "string" ? declaration.identifier : "");
|
|
101844
|
-
if (declarations.length === 0) {
|
|
101845
|
-
throw new ValidationError("An authored question requires at least one response declaration");
|
|
101846
|
-
}
|
|
101847
|
-
const declaredIdentifierSet = new Set(declaredIdentifiers);
|
|
101848
|
-
if (declaredIdentifiers.some((identifier) => !identifier) || declaredIdentifierSet.size !== declaredIdentifiers.length) {
|
|
101849
|
-
throw new ValidationError("Response declaration identifiers must be unique");
|
|
101850
|
-
}
|
|
101851
|
-
const projection = projectQtiAuthoringBody(item.body);
|
|
101852
|
-
if (projection.nestedInteractions.length > 0) {
|
|
101853
|
-
throw new ValidationError("Interactions nested inside another interaction cannot be compiled");
|
|
101854
|
-
}
|
|
101855
|
-
if (projection.interactions.length !== declarations.length) {
|
|
101856
|
-
throw new ValidationError("Every response declaration must belong to exactly one interaction");
|
|
101857
|
-
}
|
|
101858
|
-
const interactionIdentifiers = projection.interactions.map((interaction) => interaction.responseIdentifier);
|
|
101859
|
-
const interactionIdentifierSet = new Set(interactionIdentifiers);
|
|
101860
|
-
if (interactionIdentifierSet.size !== interactionIdentifiers.length) {
|
|
101861
|
-
throw new ValidationError("Every interaction needs its own response identifier");
|
|
101862
|
-
}
|
|
101863
|
-
if (interactionIdentifiers.some((identifier) => !declaredIdentifierSet.has(identifier)) || declaredIdentifiers.some((identifier) => !interactionIdentifierSet.has(identifier))) {
|
|
101864
|
-
throw new ValidationError("Every interaction must declare exactly one response");
|
|
101865
|
-
}
|
|
101866
|
-
const unauthorable = projection.interactions.find((interaction) => !qtiAuthoringInteractionType(interaction));
|
|
101867
|
-
if (unauthorable) {
|
|
101868
|
-
throw new ValidationError(`Interaction type “${String(unauthorable.type)}” cannot be authored by Playcademy yet`);
|
|
101869
|
-
}
|
|
101870
|
-
const inline = projection.interactions.filter(isInlineQtiAuthoringInteraction);
|
|
101871
|
-
if (projection.interactions.length - inline.length > 1) {
|
|
101872
|
-
throw new ValidationError("A question supports any number of inline interactions but at most one block interaction");
|
|
101873
|
-
}
|
|
101874
|
-
if (item.body.some((node) => node.kind === "interaction" && isInlineQtiAuthoringInteraction(node))) {
|
|
101875
|
-
throw new ValidationError("Every inline interaction must sit at its blank inside the question’s prose");
|
|
101876
|
-
}
|
|
101877
|
-
const blanks = inline.map((interaction) => projection.blankIndexes.get(interaction));
|
|
101878
|
-
if (blanks.some((blank) => blank === undefined) || new Set(blanks).size !== blanks.length) {
|
|
101879
|
-
throw new ValidationError("Every inline interaction needs its own blank position");
|
|
101880
|
-
}
|
|
101881
|
-
return item;
|
|
101882
|
-
}
|
|
101883
|
-
function parsedQtiItemOrNull(rawXml) {
|
|
101884
|
-
if (typeof rawXml !== "string" || !rawXml) {
|
|
101885
|
-
return null;
|
|
101886
|
-
}
|
|
101887
|
-
try {
|
|
101888
|
-
return parseQtiItemXml(rawXml);
|
|
101889
|
-
} catch {
|
|
101890
|
-
return null;
|
|
101891
|
-
}
|
|
101892
|
-
}
|
|
101893
|
-
function isAuthorableMultiInteractionQtiItem(question) {
|
|
101894
|
-
const item = parsedQtiItemOrNull(question.rawXml);
|
|
101895
|
-
return item !== null && (authorableQtiInteractions(item)?.length ?? 0) > 1;
|
|
101896
|
-
}
|
|
101897
|
-
function assertPlayableQtiQuestion(question) {
|
|
101898
|
-
if (!question.rawXml) {
|
|
101899
|
-
assertSupportedQtiQuestionInteraction(question);
|
|
101900
|
-
return;
|
|
101901
|
-
}
|
|
101902
|
-
const item = parsedQtiItemOrNull(question.rawXml);
|
|
101903
|
-
const playable = item && playableQtiInteractions(item);
|
|
101904
|
-
if (!playable || qtiItemScoringValidationMessage(item)) {
|
|
101905
|
-
throw new ValidationError(`Question ${question.identifier} uses an interaction Playcademy cannot play or score.`);
|
|
101906
|
-
}
|
|
101907
|
-
}
|
|
101908
|
-
function assertSupportedQtiQuestionInteraction(question) {
|
|
101909
|
-
const candidate = recordValue(question);
|
|
101910
|
-
if (candidate?.type === AUTHORING_DOCUMENT_QUESTION_TYPE) {
|
|
101911
|
-
authoringDocumentItem(candidate.document);
|
|
101912
|
-
return;
|
|
101913
|
-
}
|
|
101914
|
-
if (!supportedQtiQuestionInteractionType(question) && !isAuthorableMultiInteractionQtiItem(question)) {
|
|
101915
|
-
throw new ValidationError("This QTI interaction type is not supported by the Playcademy question editor.");
|
|
101916
|
-
}
|
|
101917
|
-
}
|
|
101918
|
-
function invalidQtiCopyXml() {
|
|
101919
|
-
throw new ValidationError("The source question does not contain valid QTI item XML");
|
|
101920
|
-
}
|
|
101921
|
-
function markupEnd2(xml, start2, allowInternalSubset) {
|
|
101922
|
-
let quote = null;
|
|
101923
|
-
let subsetDepth = 0;
|
|
101924
|
-
for (let index2 = start2;index2 < xml.length; index2 += 1) {
|
|
101925
|
-
const character = xml[index2];
|
|
101926
|
-
if (quote) {
|
|
101927
|
-
if (character === quote) {
|
|
101928
|
-
quote = null;
|
|
101929
|
-
}
|
|
101930
|
-
} else if (character === '"' || character === "'") {
|
|
101931
|
-
quote = character;
|
|
101932
|
-
} else if (allowInternalSubset && character === "[") {
|
|
101933
|
-
subsetDepth += 1;
|
|
101934
|
-
} else if (allowInternalSubset && character === "]") {
|
|
101935
|
-
subsetDepth = Math.max(0, subsetDepth - 1);
|
|
101936
|
-
} else if (character === ">" && subsetDepth === 0) {
|
|
101937
|
-
return index2;
|
|
101938
|
-
}
|
|
101939
|
-
}
|
|
101940
|
-
return invalidQtiCopyXml();
|
|
101941
|
-
}
|
|
101942
|
-
function skipXmlWhitespace(xml, start2, end = xml.length) {
|
|
101943
|
-
let cursor2 = start2;
|
|
101944
|
-
while (cursor2 < end && /\s/.test(xml[cursor2] ?? "")) {
|
|
101945
|
-
cursor2 += 1;
|
|
101946
|
-
}
|
|
101947
|
-
return cursor2;
|
|
101948
|
-
}
|
|
101949
|
-
function xmlPreambleEnd(xml, cursor2) {
|
|
101950
|
-
if (xml.startsWith("<?", cursor2)) {
|
|
101951
|
-
const end = xml.indexOf("?>", cursor2 + 2);
|
|
101952
|
-
if (end === -1) {
|
|
101953
|
-
return invalidQtiCopyXml();
|
|
101954
|
-
}
|
|
101955
|
-
return end + 2;
|
|
101956
|
-
}
|
|
101957
|
-
if (xml.startsWith("<!--", cursor2)) {
|
|
101958
|
-
const end = xml.indexOf("-->", cursor2 + 4);
|
|
101959
|
-
if (end === -1) {
|
|
101960
|
-
return invalidQtiCopyXml();
|
|
101961
|
-
}
|
|
101962
|
-
return end + 3;
|
|
101963
|
-
}
|
|
101964
|
-
if (/^<!DOCTYPE\b/i.test(xml.slice(cursor2))) {
|
|
101965
|
-
return markupEnd2(xml, cursor2 + 2, true) + 1;
|
|
101966
|
-
}
|
|
101967
|
-
return null;
|
|
101968
|
-
}
|
|
101969
|
-
function qtiRootAttributes(xml, start2, end) {
|
|
101970
|
-
const attributes2 = [];
|
|
101971
|
-
let cursor2 = start2;
|
|
101972
|
-
while (cursor2 < end) {
|
|
101973
|
-
cursor2 = skipXmlWhitespace(xml, cursor2, end);
|
|
101974
|
-
if (xml[cursor2] === "/") {
|
|
101975
|
-
cursor2 += 1;
|
|
101976
|
-
} else if (cursor2 < end) {
|
|
101977
|
-
const nameMatch = /^[A-Za-z_][\w.:-]*/.exec(xml.slice(cursor2, end));
|
|
101978
|
-
if (!nameMatch) {
|
|
101979
|
-
return invalidQtiCopyXml();
|
|
101980
|
-
}
|
|
101981
|
-
const name3 = nameMatch[0];
|
|
101982
|
-
cursor2 = skipXmlWhitespace(xml, cursor2 + name3.length, end);
|
|
101983
|
-
if (xml[cursor2] !== "=") {
|
|
101984
|
-
return invalidQtiCopyXml();
|
|
101985
|
-
}
|
|
101986
|
-
cursor2 = skipXmlWhitespace(xml, cursor2 + 1, end);
|
|
101987
|
-
const quote = xml[cursor2];
|
|
101988
|
-
if (quote !== '"' && quote !== "'") {
|
|
101989
|
-
return invalidQtiCopyXml();
|
|
101990
|
-
}
|
|
101991
|
-
const valueStart = cursor2 + 1;
|
|
101992
|
-
const valueEnd = xml.indexOf(quote, valueStart);
|
|
101993
|
-
if (valueEnd === -1 || valueEnd > end) {
|
|
101994
|
-
return invalidQtiCopyXml();
|
|
101995
|
-
}
|
|
101996
|
-
attributes2.push({ name: name3, quote, valueStart, valueEnd });
|
|
101997
|
-
cursor2 = valueEnd + 1;
|
|
101998
|
-
}
|
|
101999
|
-
}
|
|
102000
|
-
return attributes2;
|
|
102001
|
-
}
|
|
102002
|
-
function qtiItemStartTag(xml) {
|
|
102003
|
-
let cursor2 = xml.charCodeAt(0) === 65279 ? 1 : 0;
|
|
102004
|
-
while (cursor2 < xml.length) {
|
|
102005
|
-
cursor2 = skipXmlWhitespace(xml, cursor2);
|
|
102006
|
-
const preambleEnd = xmlPreambleEnd(xml, cursor2);
|
|
102007
|
-
if (preambleEnd !== null) {
|
|
102008
|
-
cursor2 = preambleEnd;
|
|
102009
|
-
} else {
|
|
102010
|
-
const root = /^<([A-Za-z_][\w.:-]*)/.exec(xml.slice(cursor2));
|
|
102011
|
-
if (!root || root[1].split(":").at(-1) !== "qti-assessment-item") {
|
|
102012
|
-
return invalidQtiCopyXml();
|
|
102013
|
-
}
|
|
102014
|
-
const attributesStart = cursor2 + root[0].length;
|
|
102015
|
-
const end = markupEnd2(xml, attributesStart, false);
|
|
102016
|
-
const attributes2 = qtiRootAttributes(xml, attributesStart, end);
|
|
102017
|
-
let insertionPoint = skipXmlWhitespaceBackward(xml, end);
|
|
102018
|
-
if (xml[insertionPoint - 1] === "/") {
|
|
102019
|
-
insertionPoint -= 1;
|
|
102020
|
-
}
|
|
102021
|
-
return { attributes: attributes2, insertionPoint };
|
|
102022
|
-
}
|
|
102023
|
-
}
|
|
102024
|
-
return invalidQtiCopyXml();
|
|
102025
|
-
}
|
|
102026
|
-
function skipXmlWhitespaceBackward(xml, start2) {
|
|
102027
|
-
let cursor2 = start2;
|
|
102028
|
-
while (/\s/.test(xml[cursor2 - 1] ?? "")) {
|
|
102029
|
-
cursor2 -= 1;
|
|
102030
|
-
}
|
|
102031
|
-
return cursor2;
|
|
102032
|
-
}
|
|
102033
|
-
function escapeXmlAttribute(value, quote) {
|
|
102034
|
-
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(quote, quote === '"' ? """ : "'");
|
|
102035
|
-
}
|
|
102036
|
-
function rewriteQtiItemIdentity(xml, identifier, title) {
|
|
102037
|
-
if (!xml.trim() || !identifier.trim() || !title.trim()) {
|
|
102038
|
-
return invalidQtiCopyXml();
|
|
102039
|
-
}
|
|
102040
|
-
const root = qtiItemStartTag(xml);
|
|
102041
|
-
const replacements = [];
|
|
102042
|
-
const targetAttributes = new Map([
|
|
102043
|
-
["identifier", identifier],
|
|
102044
|
-
["title", title]
|
|
102045
|
-
]);
|
|
102046
|
-
for (const [name3, value] of targetAttributes) {
|
|
102047
|
-
const matches = root.attributes.filter((attribute2) => attribute2.name === name3);
|
|
102048
|
-
if (matches.length > 1) {
|
|
102049
|
-
return invalidQtiCopyXml();
|
|
102050
|
-
}
|
|
102051
|
-
const attribute = matches[0];
|
|
102052
|
-
if (attribute) {
|
|
102053
|
-
replacements.push({
|
|
102054
|
-
start: attribute.valueStart,
|
|
102055
|
-
end: attribute.valueEnd,
|
|
102056
|
-
value: escapeXmlAttribute(value, attribute.quote)
|
|
102057
|
-
});
|
|
102058
|
-
} else {
|
|
102059
|
-
replacements.push({
|
|
102060
|
-
start: root.insertionPoint,
|
|
102061
|
-
end: root.insertionPoint,
|
|
102062
|
-
value: ` ${name3}="${escapeXmlAttribute(value, '"')}"`
|
|
102063
|
-
});
|
|
102064
|
-
}
|
|
102065
|
-
}
|
|
102066
|
-
return replacements.toSorted((left, right) => right.start - left.start).reduce((rewritten, replacement) => `${rewritten.slice(0, replacement.start)}${replacement.value}${rewritten.slice(replacement.end)}`, xml);
|
|
102067
|
-
}
|
|
102068
|
-
function buildQtiQuestionCopyInput(input) {
|
|
102069
|
-
assertPlayableQtiQuestion(input.source);
|
|
102070
|
-
const title = input.source.title?.trim() || input.source.identifier;
|
|
102071
|
-
if (!input.source.rawXml) {
|
|
102072
|
-
return invalidQtiCopyXml();
|
|
102073
|
-
}
|
|
102074
|
-
const sourceMetadata = { ...input.source.metadata };
|
|
102075
|
-
if (sourceMetadata.ownerSystem !== PLAYCADEMY_QTI_OWNER_SYSTEM) {
|
|
102076
|
-
Reflect.deleteProperty(sourceMetadata, PLAYCADEMY_QTI_AUTHORING_METADATA_KEY);
|
|
102077
|
-
}
|
|
102078
|
-
const metadata2 = buildOwnedQtiQuestionMetadata({
|
|
102079
|
-
metadata: {
|
|
102080
|
-
copiedFromItemIdentifier: input.source.identifier,
|
|
102081
|
-
integrationId: input.integrationId
|
|
102082
|
-
}
|
|
102083
|
-
}, {
|
|
102084
|
-
gameSlug: input.gameSlug,
|
|
102085
|
-
testIdentifier: input.targetTestIdentifier
|
|
102086
|
-
}, sourceMetadata);
|
|
102087
|
-
return {
|
|
102088
|
-
identifier: input.targetIdentifier,
|
|
102089
|
-
title,
|
|
102090
|
-
xml: rewriteQtiItemIdentity(input.source.rawXml, input.targetIdentifier, title),
|
|
102091
|
-
metadata: metadata2
|
|
102092
|
-
};
|
|
102093
|
-
}
|
|
102094
|
-
function assertQtiQuestionEditable(item, ownership, ownerTest) {
|
|
102095
|
-
if (!isQtiQuestionOwnedByContext(item, ownership, ownerTest)) {
|
|
102096
|
-
throw new ValidationError("Shared question references are read-only. Copy the question to create an editable independent item.");
|
|
102097
|
-
}
|
|
102098
|
-
}
|
|
102099
|
-
function buildNumericQuestionXml(input, identifier, title) {
|
|
102100
|
-
if (input.format === "xml") {
|
|
102101
|
-
throw new ValidationError("Raw question XML is not accepted at this API boundary");
|
|
102102
|
-
}
|
|
102103
|
-
const candidate = input.numericTextEntry;
|
|
102104
|
-
if (candidate === undefined) {
|
|
102105
|
-
return null;
|
|
102106
|
-
}
|
|
102107
|
-
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) {
|
|
102108
|
-
throw new ValidationError("Numeric text-entry data is invalid");
|
|
102109
|
-
}
|
|
102110
|
-
const prompt = "prompt" in candidate ? candidate.prompt : undefined;
|
|
102111
|
-
const numeric3 = parseNumericTextEntry({
|
|
102112
|
-
baseType: "baseType" in candidate ? candidate.baseType : undefined,
|
|
102113
|
-
answer: "answer" in candidate ? candidate.answer : undefined,
|
|
102114
|
-
comparison: "comparison" in candidate ? candidate.comparison : undefined
|
|
102115
|
-
});
|
|
102116
|
-
if (!numeric3.success) {
|
|
102117
|
-
throw new ValidationError(numeric3.message);
|
|
102118
|
-
}
|
|
102119
|
-
if (!title || typeof prompt !== "string" || !prompt.trim()) {
|
|
102120
|
-
throw new ValidationError("Numeric questions require a title and prompt");
|
|
102121
|
-
}
|
|
102122
|
-
return numericTextEntryXml({ identifier, title, prompt: prompt.trim(), numeric: numeric3.value });
|
|
102123
|
-
}
|
|
102124
|
-
function buildExactMatchQuestionXml(input) {
|
|
102125
|
-
const responseIdentifier = escapeXml(input.responseIdentifier);
|
|
102126
|
-
const correctValues = input.correctIdentifiers.map((identifier) => ` <qti-value>${escapeXml(identifier)}</qti-value>`).join(`
|
|
102127
|
-
`);
|
|
102128
|
-
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
102129
|
-
<qti-assessment-item xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0" identifier="${escapeXml(input.identifier)}" title="${escapeXml(input.title)}" adaptive="false" time-dependent="false">
|
|
102130
|
-
<qti-response-declaration identifier="${responseIdentifier}" cardinality="${input.cardinality}" base-type="identifier">
|
|
102131
|
-
<qti-correct-response>
|
|
102132
|
-
${correctValues}
|
|
102133
|
-
</qti-correct-response>
|
|
102134
|
-
</qti-response-declaration>
|
|
102135
|
-
<qti-outcome-declaration identifier="FEEDBACK" cardinality="single" base-type="identifier" />
|
|
102136
|
-
<qti-outcome-declaration identifier="SCORE" cardinality="single" base-type="float">
|
|
102137
|
-
<qti-default-value><qti-value>0</qti-value></qti-default-value>
|
|
102138
|
-
</qti-outcome-declaration>
|
|
102139
|
-
<qti-item-body>
|
|
102140
|
-
${input.itemBody}
|
|
102141
|
-
</qti-item-body>
|
|
102142
|
-
<qti-response-processing>
|
|
102143
|
-
<qti-response-condition>
|
|
102144
|
-
<qti-response-if>
|
|
102145
|
-
<qti-match><qti-variable identifier="${responseIdentifier}" /><qti-correct identifier="${responseIdentifier}" /></qti-match>
|
|
102146
|
-
<qti-set-outcome-value identifier="FEEDBACK"><qti-base-value base-type="identifier">CORRECT</qti-base-value></qti-set-outcome-value>
|
|
102147
|
-
<qti-set-outcome-value identifier="SCORE"><qti-base-value base-type="float">1</qti-base-value></qti-set-outcome-value>
|
|
102148
|
-
</qti-response-if>
|
|
102149
|
-
<qti-response-else>
|
|
102150
|
-
<qti-set-outcome-value identifier="FEEDBACK"><qti-base-value base-type="identifier">INCORRECT</qti-base-value></qti-set-outcome-value>
|
|
102151
|
-
<qti-set-outcome-value identifier="SCORE"><qti-base-value base-type="float">0</qti-base-value></qti-set-outcome-value>
|
|
102152
|
-
</qti-response-else>
|
|
102153
|
-
</qti-response-condition>
|
|
102154
|
-
</qti-response-processing>
|
|
102155
|
-
</qti-assessment-item>`;
|
|
102156
|
-
}
|
|
102157
|
-
function parseStructuredChoices(value, options) {
|
|
102158
|
-
const { label, minimum, validate: validate2 } = options;
|
|
102159
|
-
if (!Array.isArray(value) || value.length < minimum) {
|
|
102160
|
-
throw new ValidationError(`${label} questions require at least ${minimum === 1 ? "one choice" : `${minimum} choices`}`);
|
|
102161
|
-
}
|
|
102162
|
-
const choices = value.map((choice) => {
|
|
102163
|
-
const record3 = recordValue(choice);
|
|
102164
|
-
const identifier = record3?.identifier;
|
|
102165
|
-
const content = record3?.content;
|
|
102166
|
-
if (typeof identifier !== "string" || !identifier.trim() || typeof content !== "string" || !content.trim() || validate2 !== undefined && record3 !== undefined && !validate2(record3)) {
|
|
102167
|
-
throw new ValidationError(`${label} options are invalid`);
|
|
102168
|
-
}
|
|
102169
|
-
return { identifier: identifier.trim(), content: content.trim() };
|
|
102170
|
-
});
|
|
102171
|
-
if (new Set(choices.map((choice) => choice.identifier)).size !== choices.length) {
|
|
102172
|
-
throw new ValidationError(`${label} option identifiers must be unique`);
|
|
102173
|
-
}
|
|
102174
|
-
return choices;
|
|
102175
|
-
}
|
|
102176
|
-
function declaredCorrectResponse(input, responseIdentifier) {
|
|
102177
|
-
const declarations = Array.isArray(input.responseDeclarations) ? input.responseDeclarations : [];
|
|
102178
|
-
const declaration = declarations.map(recordValue).find((candidate) => candidate?.identifier === responseIdentifier);
|
|
102179
|
-
const rawValues = recordValue(declaration?.correctResponse)?.value;
|
|
102180
|
-
return {
|
|
102181
|
-
cardinality: declaration?.cardinality,
|
|
102182
|
-
baseType: declaration?.baseType,
|
|
102183
|
-
values: Array.isArray(rawValues) ? rawValues.filter((value) => typeof value === "string") : []
|
|
102184
|
-
};
|
|
102185
|
-
}
|
|
102186
|
-
function inlineChoiceCorrectIdentifier(input, responseIdentifier, choiceIdentifiers) {
|
|
102187
|
-
const { cardinality, baseType, values } = declaredCorrectResponse(input, responseIdentifier);
|
|
102188
|
-
if (cardinality !== "single" || baseType !== "identifier") {
|
|
102189
|
-
throw new ValidationError("Inline-choice questions require a single-identifier response declaration");
|
|
102190
|
-
}
|
|
102191
|
-
if (values.length !== 1 || !choiceIdentifiers.has(values[0])) {
|
|
102192
|
-
throw new ValidationError("Inline-choice questions require one valid correct option");
|
|
102193
|
-
}
|
|
102194
|
-
return values[0];
|
|
102195
|
-
}
|
|
102196
|
-
function structuredQuestionEnvelope(input, type, label, title) {
|
|
102197
|
-
const interaction = recordValue(input.interaction);
|
|
102198
|
-
if (normalizedQtiInteractionType(interaction?.type ?? input.type) !== type) {
|
|
102199
|
-
return null;
|
|
102200
|
-
}
|
|
102201
|
-
const structure = recordValue(interaction?.questionStructure);
|
|
102202
|
-
const responseIdentifier = interaction?.responseIdentifier;
|
|
102203
|
-
const prompt = structure?.prompt;
|
|
102204
|
-
if (!interaction || !structure || typeof responseIdentifier !== "string" || !responseIdentifier.trim()) {
|
|
102205
|
-
throw new ValidationError(`${label} question data is invalid`);
|
|
102206
|
-
}
|
|
102207
|
-
if (!title || typeof prompt !== "string" || !prompt.trim()) {
|
|
102208
|
-
throw new ValidationError(`${label} questions require a title and prompt`);
|
|
102209
|
-
}
|
|
102210
|
-
return {
|
|
102211
|
-
interaction,
|
|
102212
|
-
structure,
|
|
102213
|
-
responseIdentifier: responseIdentifier.trim(),
|
|
102214
|
-
prompt: prompt.trim()
|
|
102215
|
-
};
|
|
102216
|
-
}
|
|
102217
|
-
function buildInlineChoiceQuestionXml(input, identifier, title) {
|
|
102218
|
-
const envelope = structuredQuestionEnvelope(input, "inline-choice", "Inline-choice", title);
|
|
102219
|
-
if (!envelope) {
|
|
102220
|
-
return null;
|
|
102221
|
-
}
|
|
102222
|
-
const { structure, responseIdentifier, prompt } = envelope;
|
|
102223
|
-
const promptParts = prompt.split(INLINE_CHOICE_BLANK);
|
|
102224
|
-
if (promptParts.length !== 2) {
|
|
102225
|
-
throw new ValidationError("Inline-choice questions require exactly one blank");
|
|
102226
|
-
}
|
|
102227
|
-
const choices = parseStructuredChoices(structure.inlineChoices, {
|
|
102228
|
-
label: "Inline-choice",
|
|
102229
|
-
minimum: 2
|
|
102230
|
-
});
|
|
102231
|
-
const choiceIdentifiers = new Set(choices.map((choice) => choice.identifier));
|
|
102232
|
-
const correctIdentifier = inlineChoiceCorrectIdentifier(input, responseIdentifier, choiceIdentifiers);
|
|
102233
|
-
const choicesXml = choices.map((choice) => ` <qti-inline-choice identifier="${escapeXml(choice.identifier)}">${escapeXml(choice.content)}</qti-inline-choice>`).join(`
|
|
102234
|
-
`);
|
|
102235
|
-
const [before = "", after = ""] = promptParts;
|
|
102236
|
-
const itemBody = ` <p>${escapeXml(before)}<qti-inline-choice-interaction response-identifier="${escapeXml(responseIdentifier)}">
|
|
102237
|
-
${choicesXml}
|
|
102238
|
-
</qti-inline-choice-interaction>${escapeXml(after)}</p>`;
|
|
102239
|
-
return buildExactMatchQuestionXml({
|
|
102240
|
-
identifier,
|
|
102241
|
-
title,
|
|
102242
|
-
responseIdentifier,
|
|
102243
|
-
cardinality: "single",
|
|
102244
|
-
correctIdentifiers: [correctIdentifier],
|
|
102245
|
-
itemBody
|
|
102246
|
-
});
|
|
102247
|
-
}
|
|
102248
|
-
function parseStructuredMatchChoices(value, label) {
|
|
102249
|
-
return parseStructuredChoices(value, {
|
|
102250
|
-
label: `Match ${label}`,
|
|
102251
|
-
minimum: 1,
|
|
102252
|
-
validate: (choice) => choice.matchMax === 1
|
|
102253
|
-
});
|
|
102254
|
-
}
|
|
102255
|
-
function structuredMatchCorrectPairs(input, responseIdentifier, sources, targets) {
|
|
102256
|
-
const { cardinality, baseType, values } = declaredCorrectResponse(input, responseIdentifier);
|
|
102257
|
-
if (cardinality !== "multiple" || baseType !== "directedPair") {
|
|
102258
|
-
throw new ValidationError("Match questions require a multiple directed-pair response declaration");
|
|
102259
|
-
}
|
|
102260
|
-
const sourceIdentifiers = new Set(sources.map((choice) => choice.identifier));
|
|
102261
|
-
const targetIdentifiers = new Set(targets.map((choice) => choice.identifier));
|
|
102262
|
-
const valid = values.length > 0 && new Set(values).size === values.length && values.every((value) => {
|
|
102263
|
-
const [source, target, extra] = value.split(" ");
|
|
102264
|
-
return extra === undefined && Boolean(source && target) && sourceIdentifiers.has(source) && targetIdentifiers.has(target);
|
|
102265
|
-
});
|
|
102266
|
-
if (!valid) {
|
|
102267
|
-
throw new ValidationError("Every Match correct response must name one declared source and target choice");
|
|
102268
|
-
}
|
|
102269
|
-
return values;
|
|
102270
|
-
}
|
|
102271
|
-
function buildMatchQuestionXml(input, identifier, title) {
|
|
102272
|
-
const envelope = structuredQuestionEnvelope(input, "match", "Match", title);
|
|
102273
|
-
if (!envelope) {
|
|
102274
|
-
return null;
|
|
102275
|
-
}
|
|
102276
|
-
const { interaction, structure, responseIdentifier, prompt } = envelope;
|
|
102277
|
-
const sources = parseStructuredMatchChoices(structure.sourceChoices, "source");
|
|
102278
|
-
const targets = parseStructuredMatchChoices(structure.targetChoices, "target");
|
|
102279
|
-
const sourceIdentifiers = new Set(sources.map((choice) => choice.identifier));
|
|
102280
|
-
if (targets.some((choice) => sourceIdentifiers.has(choice.identifier))) {
|
|
102281
|
-
throw new ValidationError("Match source and target choice identifiers must be unique");
|
|
102282
|
-
}
|
|
102283
|
-
const correctValues = structuredMatchCorrectPairs(input, responseIdentifier, sources, targets);
|
|
102284
|
-
const maxAssociations = interaction.maxAssociations;
|
|
102285
|
-
if (typeof maxAssociations !== "number" || !Number.isInteger(maxAssociations) || maxAssociations < 0 || maxAssociations !== 0 && maxAssociations < correctValues.length) {
|
|
102286
|
-
throw new ValidationError("Match max associations must fit every correct pair");
|
|
102287
|
-
}
|
|
102288
|
-
const item = {
|
|
102289
|
-
key: identifier,
|
|
102290
|
-
title,
|
|
102291
|
-
body: [
|
|
102292
|
-
{
|
|
102293
|
-
kind: "interaction",
|
|
102294
|
-
type: "match",
|
|
102295
|
-
responseIdentifier,
|
|
102296
|
-
attributes: {
|
|
102297
|
-
shuffle: interaction.shuffle === true,
|
|
102298
|
-
"max-associations": maxAssociations
|
|
102299
|
-
},
|
|
102300
|
-
content: [
|
|
102301
|
-
{
|
|
102302
|
-
kind: "element",
|
|
102303
|
-
name: "qti-prompt",
|
|
102304
|
-
children: [{ kind: "text", text: prompt }]
|
|
102305
|
-
}
|
|
102306
|
-
],
|
|
102307
|
-
choiceSets: [sources, targets].map((choices) => choices.map((choice) => ({
|
|
102308
|
-
identifier: choice.identifier,
|
|
102309
|
-
content: [{ kind: "text", text: choice.content }]
|
|
102310
|
-
})))
|
|
102311
|
-
}
|
|
102312
|
-
],
|
|
102313
|
-
responseDeclarations: [
|
|
102314
|
-
{
|
|
102315
|
-
identifier: responseIdentifier,
|
|
102316
|
-
cardinality: "multiple",
|
|
102317
|
-
baseType: "directedPair",
|
|
102318
|
-
correctValues
|
|
102319
|
-
}
|
|
102320
|
-
]
|
|
102321
|
-
};
|
|
102322
|
-
try {
|
|
102323
|
-
return compileQtiAuthoringItemXml(item, { identifier, title });
|
|
102324
|
-
} catch (error88) {
|
|
102325
|
-
throw new ValidationError(`This Match question cannot be compiled to QTI: ${errorMessage(error88)}`);
|
|
102326
|
-
}
|
|
102327
|
-
}
|
|
102328
|
-
function parseHottextSegments2(value) {
|
|
102329
|
-
if (!Array.isArray(value) || value.length === 0) {
|
|
102330
|
-
throw new ValidationError("Hottext questions require passage segments");
|
|
102331
|
-
}
|
|
102332
|
-
const segments = value.map((segment) => {
|
|
102333
|
-
const record3 = recordValue(segment);
|
|
102334
|
-
const identifier = record3?.identifier;
|
|
102335
|
-
const content = record3?.content;
|
|
102336
|
-
const mode = record3?.mode;
|
|
102337
|
-
if (typeof identifier !== "string" || typeof content !== "string" || !content.trim() || !["plain", "option", "correct"].includes(String(mode))) {
|
|
102338
|
-
throw new ValidationError("Hottext passage segments are invalid");
|
|
102339
|
-
}
|
|
102340
|
-
return {
|
|
102341
|
-
identifier: identifier.trim(),
|
|
102342
|
-
content: content.trim(),
|
|
102343
|
-
mode
|
|
102344
|
-
};
|
|
102345
|
-
});
|
|
102346
|
-
const selectable = segments.filter((segment) => segment.mode !== "plain");
|
|
102347
|
-
const correct = selectable.filter((segment) => segment.mode === "correct");
|
|
102348
|
-
const identifiers = selectable.map((segment) => segment.identifier);
|
|
102349
|
-
if (selectable.length < 2) {
|
|
102350
|
-
throw new ValidationError("Hottext questions require at least two selectable phrases");
|
|
102351
|
-
}
|
|
102352
|
-
if (identifiers.some((identifier) => !identifier)) {
|
|
102353
|
-
throw new ValidationError("Hottext selectable phrases require identifiers");
|
|
102354
|
-
}
|
|
102355
|
-
if (new Set(identifiers).size !== identifiers.length) {
|
|
102356
|
-
throw new ValidationError("Hottext selectable phrase identifiers must be unique");
|
|
102357
|
-
}
|
|
102358
|
-
if (correct.length === 0) {
|
|
102359
|
-
throw new ValidationError("Hottext questions require a correct phrase");
|
|
102360
|
-
}
|
|
102361
|
-
return { segments, selectable, correct };
|
|
102362
|
-
}
|
|
102363
|
-
function buildHottextQuestionXml(input, identifier, title) {
|
|
102364
|
-
const candidate = input.hottext;
|
|
102365
|
-
if (candidate === undefined) {
|
|
102366
|
-
return null;
|
|
102367
|
-
}
|
|
102368
|
-
const hottext = recordValue(candidate);
|
|
102369
|
-
if (!hottext) {
|
|
102370
|
-
throw new ValidationError("Hottext question data is invalid");
|
|
102371
|
-
}
|
|
102372
|
-
const prompt = hottext.prompt;
|
|
102373
|
-
if (!title || typeof prompt !== "string" || !prompt.trim()) {
|
|
102374
|
-
throw new ValidationError("Hottext questions require a title and prompt");
|
|
102375
|
-
}
|
|
102376
|
-
const { segments, selectable, correct } = parseHottextSegments2(hottext.segments);
|
|
102377
|
-
const multiple = correct.length > 1;
|
|
102378
|
-
const maxChoices = hottextMaxChoices(selectable.length, multiple);
|
|
102379
|
-
const passage = segments.map((segment) => segment.mode === "plain" ? escapeXml(segment.content) : `<qti-hottext identifier="${escapeXml(segment.identifier)}">${escapeXml(segment.content)}</qti-hottext>`).join(" ");
|
|
102380
|
-
const itemBody = ` <qti-hottext-interaction response-identifier="RESPONSE" max-choices="${maxChoices}">
|
|
102381
|
-
<qti-prompt>${escapeXml(prompt.trim())}</qti-prompt>
|
|
102382
|
-
<p>${passage}</p>
|
|
102383
|
-
</qti-hottext-interaction>`;
|
|
102384
|
-
return buildExactMatchQuestionXml({
|
|
102385
|
-
identifier,
|
|
102386
|
-
title,
|
|
102387
|
-
responseIdentifier: "RESPONSE",
|
|
102388
|
-
cardinality: multiple ? "multiple" : "single",
|
|
102389
|
-
correctIdentifiers: correct.map((segment) => segment.identifier),
|
|
102390
|
-
itemBody
|
|
102391
|
-
});
|
|
102392
|
-
}
|
|
102393
|
-
function buildAuthoringDocumentQuestionXml(input, identifier, title) {
|
|
102394
|
-
if (input.type !== AUTHORING_DOCUMENT_QUESTION_TYPE) {
|
|
102395
|
-
return null;
|
|
102396
|
-
}
|
|
102397
|
-
const item = authoringDocumentItem(input.document);
|
|
102398
|
-
if (!title) {
|
|
102399
|
-
throw new ValidationError("Authored questions require a title");
|
|
102400
|
-
}
|
|
102401
|
-
try {
|
|
102402
|
-
return compileQtiAuthoringItemXml(item, { identifier, title });
|
|
102403
|
-
} catch (error88) {
|
|
102404
|
-
throw new ValidationError(`This question cannot be compiled to QTI: ${errorMessage(error88)}`);
|
|
102405
|
-
}
|
|
102406
|
-
}
|
|
102407
|
-
function buildQuestionXml(input, identifier, title) {
|
|
102408
|
-
for (const build2 of QUESTION_XML_BUILDERS) {
|
|
102409
|
-
const xml = build2(input, identifier, title);
|
|
102410
|
-
if (xml !== null) {
|
|
102411
|
-
return xml;
|
|
102412
|
-
}
|
|
102413
|
-
}
|
|
102414
|
-
return null;
|
|
102415
|
-
}
|
|
102416
|
-
function buildCreatedQtiQuestionReference(input) {
|
|
102417
|
-
return {
|
|
102418
|
-
ownership: "owned",
|
|
102419
|
-
reference: {
|
|
102420
|
-
identifier: input.itemIdentifier,
|
|
102421
|
-
href: input.href,
|
|
102422
|
-
testPart: input.partIdentifier,
|
|
102423
|
-
section: input.sectionIdentifier
|
|
102424
|
-
},
|
|
102425
|
-
question: mergeQtiQuestionItem(input.item, input.fallbackItem, input.itemIdentifier, input.metadata)
|
|
102426
|
-
};
|
|
102427
|
-
}
|
|
102428
|
-
function buildHydratedQtiQuestionReference(input) {
|
|
102429
|
-
const identifier = input.reference.reference.identifier;
|
|
102430
|
-
const ownerGameSlug = resolveQtiQuestionOwnerGameSlug(input.item, input.ownerTest);
|
|
102431
|
-
const authoritativeItem = ownerGameSlug ? {
|
|
102432
|
-
...input.item,
|
|
102433
|
-
metadata: { ...input.item.metadata, ownerGameSlug }
|
|
102434
|
-
} : input.item;
|
|
102435
|
-
const question = mergeQtiQuestionItem(authoritativeItem, input.reference.question, identifier);
|
|
102436
|
-
return {
|
|
102437
|
-
...input.reference,
|
|
102438
|
-
ownership: isQtiQuestionOwnedByContext(question, { gameSlug: input.gameSlug, testIdentifier: input.testIdentifier }, input.ownerTest) ? "owned" : "shared",
|
|
102439
|
-
question
|
|
102440
|
-
};
|
|
102441
|
-
}
|
|
102442
|
-
function isQtiTestOwnedByGame(test, gameSlug) {
|
|
102443
|
-
return test.metadata?.ownerSystem === PLAYCADEMY_QTI_OWNER_SYSTEM && test.metadata.ownerGameSlug === gameSlug;
|
|
102444
|
-
}
|
|
102445
|
-
function assertQtiTestOwnedByGame(test, gameSlug) {
|
|
102446
|
-
if (!isQtiTestOwnedByGame(test, gameSlug)) {
|
|
102447
|
-
throw new ValidationError("Shared assessment references are read-only. Copy the assessment to create an editable independent test.");
|
|
102448
|
-
}
|
|
102449
|
-
}
|
|
102450
|
-
function qtiTestParts(test) {
|
|
102451
|
-
const parts2 = test["qti-test-part"];
|
|
102452
|
-
if (!Array.isArray(parts2) || parts2.length === 0) {
|
|
102453
|
-
throw new ValidationError(`Assessment ${test.identifier} has no test parts`);
|
|
102454
|
-
}
|
|
102455
|
-
for (const [partIndex, part] of parts2.entries()) {
|
|
102456
|
-
if (!part || typeof part !== "object" || Array.isArray(part)) {
|
|
102457
|
-
throw new ValidationError(`Assessment ${test.identifier} contains an invalid test part at position ${partIndex + 1}`);
|
|
102458
|
-
}
|
|
102459
|
-
const sections = part["qti-assessment-section"];
|
|
102460
|
-
if (!Array.isArray(sections)) {
|
|
102461
|
-
throw new ValidationError(`Assessment ${test.identifier} test part ${partIndex + 1} has no section list`);
|
|
102462
|
-
}
|
|
102463
|
-
}
|
|
102464
|
-
return parts2;
|
|
102465
|
-
}
|
|
102466
|
-
function qtiTestOptionalAttributes(test) {
|
|
102467
|
-
return {
|
|
102468
|
-
...test.qtiVersion ? { qtiVersion: test.qtiVersion } : {},
|
|
102469
|
-
...test.timeLimit !== undefined ? { timeLimit: test.timeLimit } : {},
|
|
102470
|
-
...test.maxAttempts !== undefined ? { maxAttempts: test.maxAttempts } : {},
|
|
102471
|
-
...test.toolsEnabled !== undefined ? { toolsEnabled: test.toolsEnabled } : {}
|
|
102472
|
-
};
|
|
102473
|
-
}
|
|
102474
|
-
function buildQtiAssessmentItemCopyPlan(test, targetTestIdentifier, hrefForIdentifier, createIdentifier = newPlaycademyQuestionIdentifier) {
|
|
102475
|
-
const sourceIdentifiers = [];
|
|
102476
|
-
const seen = new Set;
|
|
102477
|
-
for (const part of qtiTestParts(test)) {
|
|
102478
|
-
for (const section of part["qti-assessment-section"]) {
|
|
102479
|
-
for (const reference of section["qti-assessment-item-ref"] ?? []) {
|
|
102480
|
-
if (!seen.has(reference.identifier)) {
|
|
102481
|
-
seen.add(reference.identifier);
|
|
102482
|
-
sourceIdentifiers.push(reference.identifier);
|
|
102483
|
-
}
|
|
102484
|
-
}
|
|
102485
|
-
}
|
|
102486
|
-
}
|
|
102487
|
-
return sourceIdentifiers.map((sourceIdentifier) => {
|
|
102488
|
-
const targetIdentifier = createIdentifier(targetTestIdentifier);
|
|
102489
|
-
return {
|
|
102490
|
-
sourceIdentifier,
|
|
102491
|
-
targetIdentifier,
|
|
102492
|
-
href: hrefForIdentifier(targetIdentifier)
|
|
102493
|
-
};
|
|
103003
|
+
const validation = validateDiagnosticRoutingManifest(input.routingManifest, undefined, {
|
|
103004
|
+
analyzeBounds: false
|
|
102494
103005
|
});
|
|
102495
|
-
|
|
102496
|
-
|
|
102497
|
-
|
|
102498
|
-
if (outcomeDeclarations !== undefined && !Array.isArray(outcomeDeclarations)) {
|
|
102499
|
-
throw new ValidationError(`Assessment ${test.identifier} has an invalid outcome declaration list`);
|
|
102500
|
-
}
|
|
102501
|
-
return {
|
|
102502
|
-
"qti-test-part": qtiTestParts(test).map((part, partIndex) => ({
|
|
102503
|
-
identifier: targetTestIdentifier ? `${targetTestIdentifier}-part${partIndex + 1}` : part.identifier,
|
|
102504
|
-
navigationMode: part.navigationMode,
|
|
102505
|
-
submissionMode: part.submissionMode,
|
|
102506
|
-
"qti-assessment-section": part["qti-assessment-section"].map((section, sectionIndex) => {
|
|
102507
|
-
let sectionIdentifier = section.identifier;
|
|
102508
|
-
if (targetTestIdentifier) {
|
|
102509
|
-
sectionIdentifier = partIndex === 0 && sectionIndex === 0 ? `${targetTestIdentifier}-section1` : `${targetTestIdentifier}-part${partIndex + 1}-section${sectionIndex + 1}`;
|
|
102510
|
-
}
|
|
102511
|
-
return {
|
|
102512
|
-
identifier: sectionIdentifier,
|
|
102513
|
-
title: section.title,
|
|
102514
|
-
visible: section.visible ?? true,
|
|
102515
|
-
...section.required !== undefined ? { required: section.required } : {},
|
|
102516
|
-
...section.fixed !== undefined ? { fixed: section.fixed } : {},
|
|
102517
|
-
sequence: section.sequence ?? sectionIndex + 1,
|
|
102518
|
-
...section["qti-assessment-item-ref"] ? {
|
|
102519
|
-
"qti-assessment-item-ref": section["qti-assessment-item-ref"].map((item, itemIndex) => {
|
|
102520
|
-
const copy = itemCopies?.get(item.identifier);
|
|
102521
|
-
if (itemCopies && !copy) {
|
|
102522
|
-
throw new ValidationError(`Assessment copy is missing question ${item.identifier}`);
|
|
102523
|
-
}
|
|
102524
|
-
return {
|
|
102525
|
-
identifier: copy?.targetIdentifier ?? item.identifier,
|
|
102526
|
-
href: copy?.href ?? item.href,
|
|
102527
|
-
sequence: item.sequence ?? itemIndex + 1
|
|
102528
|
-
};
|
|
102529
|
-
})
|
|
102530
|
-
} : {}
|
|
102531
|
-
};
|
|
102532
|
-
})
|
|
102533
|
-
})),
|
|
102534
|
-
...outcomeDeclarations ? {
|
|
102535
|
-
"qti-outcome-declaration": outcomeDeclarations.map((declaration) => ({
|
|
102536
|
-
identifier: declaration.identifier,
|
|
102537
|
-
...declaration.cardinality !== undefined ? { cardinality: declaration.cardinality } : {},
|
|
102538
|
-
baseType: declaration.baseType,
|
|
102539
|
-
...declaration.normalMaximum !== undefined ? { normalMaximum: declaration.normalMaximum } : {},
|
|
102540
|
-
...declaration.normalMinimum !== undefined ? { normalMinimum: declaration.normalMinimum } : {},
|
|
102541
|
-
...declaration.defaultValue ? {
|
|
102542
|
-
defaultValue: declaration.defaultValue.value !== undefined ? { value: declaration.defaultValue.value } : {}
|
|
102543
|
-
} : {}
|
|
102544
|
-
}))
|
|
102545
|
-
} : {}
|
|
102546
|
-
};
|
|
102547
|
-
}
|
|
102548
|
-
function buildQtiTestUpdateInput(test, title) {
|
|
102549
|
-
return {
|
|
102550
|
-
title,
|
|
102551
|
-
...qtiTestOptionalAttributes(test),
|
|
102552
|
-
...test.metadata ? { metadata: test.metadata } : {},
|
|
102553
|
-
...buildQtiTestStructureInput(test)
|
|
102554
|
-
};
|
|
102555
|
-
}
|
|
102556
|
-
function buildQtiTestCopyInput(source, targetTestIdentifier, metadata2, itemCopies) {
|
|
102557
|
-
const copiesBySourceIdentifier = new Map(itemCopies.map((copy) => [copy.sourceIdentifier, copy]));
|
|
102558
|
-
return {
|
|
102559
|
-
identifier: targetTestIdentifier,
|
|
102560
|
-
title: `${source.title} (copy)`,
|
|
102561
|
-
...qtiTestOptionalAttributes(source),
|
|
102562
|
-
metadata: metadata2,
|
|
102563
|
-
...buildQtiTestStructureInput(source, targetTestIdentifier, copiesBySourceIdentifier)
|
|
102564
|
-
};
|
|
102565
|
-
}
|
|
102566
|
-
function validateUniqueQuestionIdentifiers(itemIdentifiers) {
|
|
102567
|
-
if (itemIdentifiers.length === 0 || new Set(itemIdentifiers).size !== itemIdentifiers.length) {
|
|
102568
|
-
throw new ValidationError("Question order must contain unique question identifiers");
|
|
103006
|
+
if (!validation.valid || !validation.manifest) {
|
|
103007
|
+
const details = validation.errors.slice(0, 3).map((issue7) => issue7.message).join(" ");
|
|
103008
|
+
throw new ValidationError(`Diagnostic routing manifest is invalid.${details ? ` ${details}` : ""}`);
|
|
102569
103009
|
}
|
|
103010
|
+
return { diagnosticKey, routingManifest: validation.manifest };
|
|
102570
103011
|
}
|
|
102571
|
-
function
|
|
102572
|
-
|
|
102573
|
-
|
|
102574
|
-
|
|
102575
|
-
for (const section of part["qti-assessment-section"]) {
|
|
102576
|
-
if (!itemIdentifier || section["qti-assessment-item-ref"]?.some((item) => item.identifier === itemIdentifier)) {
|
|
102577
|
-
selectedPart = part;
|
|
102578
|
-
selectedSection = section;
|
|
102579
|
-
break;
|
|
102580
|
-
}
|
|
102581
|
-
}
|
|
102582
|
-
if (selectedSection) {
|
|
102583
|
-
break;
|
|
102584
|
-
}
|
|
102585
|
-
}
|
|
102586
|
-
if (!selectedPart || !selectedSection) {
|
|
102587
|
-
throw new ValidationError(itemIdentifier ? `Question ${itemIdentifier} is not referenced by assessment ${qtiTestIdentifier}` : `Assessment ${qtiTestIdentifier} has no section for questions`);
|
|
103012
|
+
async function preparePublishedDiagnosticDefinition(definition, loaded) {
|
|
103013
|
+
assertAssessmentHasQuestions(loaded.questions.questions);
|
|
103014
|
+
for (const { question } of loaded.questions.questions) {
|
|
103015
|
+
assertPlayableQtiQuestion(question);
|
|
102588
103016
|
}
|
|
102589
|
-
|
|
102590
|
-
|
|
102591
|
-
|
|
102592
|
-
|
|
102593
|
-
|
|
102594
|
-
}
|
|
103017
|
+
const assessment = await buildPlayableAssessment(loaded.test, loaded.questions);
|
|
103018
|
+
const scoring = assessmentFixtureScoringKeys(assessment, loaded.questions);
|
|
103019
|
+
const validation = validateDiagnosticRoutingManifest(definition.routingManifest, diagnosticRoutingCapabilities(assessment, scoring));
|
|
103020
|
+
if (!validation.valid || !validation.manifest) {
|
|
103021
|
+
const details = validation.errors.slice(0, 3).map((issue7) => issue7.message).join(" ");
|
|
103022
|
+
throw new ValidationError(`Diagnostic routing manifest cannot be published.${details ? ` ${details}` : ""}`);
|
|
102595
103023
|
}
|
|
102596
|
-
return {
|
|
102597
|
-
partIdentifier: selectedPart.identifier,
|
|
102598
|
-
sectionIdentifier: selectedSection.identifier
|
|
102599
|
-
};
|
|
103024
|
+
return { diagnosticKey: definition.diagnosticKey, routingManifest: validation.manifest };
|
|
102600
103025
|
}
|
|
102601
|
-
var
|
|
102602
|
-
|
|
102603
|
-
init_qti();
|
|
102604
|
-
init_timeback3();
|
|
103026
|
+
var init_timeback_assessment_diagnostic_util = __esm(() => {
|
|
103027
|
+
init_assessment_runtime2();
|
|
102605
103028
|
init_errors2();
|
|
102606
|
-
|
|
102607
|
-
|
|
102608
|
-
|
|
102609
|
-
"qtiVersion",
|
|
102610
|
-
"timeDependent",
|
|
102611
|
-
"adaptive",
|
|
102612
|
-
"preInteraction",
|
|
102613
|
-
"interaction",
|
|
102614
|
-
"postInteraction",
|
|
102615
|
-
"responseDeclarations",
|
|
102616
|
-
"outcomeDeclarations",
|
|
102617
|
-
"responseProcessing",
|
|
102618
|
-
"modalFeedback",
|
|
102619
|
-
"feedbackInline",
|
|
102620
|
-
"feedbackBlock",
|
|
102621
|
-
"rubrics",
|
|
102622
|
-
"stimulus",
|
|
102623
|
-
"content"
|
|
102624
|
-
];
|
|
102625
|
-
QUESTION_XML_BUILDERS = [
|
|
102626
|
-
buildNumericQuestionXml,
|
|
102627
|
-
buildAuthoringDocumentQuestionXml,
|
|
102628
|
-
buildHottextQuestionXml,
|
|
102629
|
-
buildInlineChoiceQuestionXml,
|
|
102630
|
-
buildMatchQuestionXml
|
|
102631
|
-
];
|
|
103029
|
+
init_timeback_assessment_rules_util();
|
|
103030
|
+
init_timeback_assessment_runtime_util();
|
|
103031
|
+
init_timeback_qti_authoring_util();
|
|
102632
103032
|
});
|
|
102633
103033
|
|
|
102634
103034
|
// ../api-core/src/utils/timeback-assessment-import.util.ts
|
|
@@ -102648,34 +103048,44 @@ function assertAssessmentImportCourseMatches(manifest, integration) {
|
|
|
102648
103048
|
function associationMetadataMatches(row, entry) {
|
|
102649
103049
|
return row.purpose === entry.purpose && (row.standardFramework ?? undefined) === entry.standard?.framework && (row.standardIdentifier ?? undefined) === entry.standard?.identifier;
|
|
102650
103050
|
}
|
|
102651
|
-
function
|
|
102652
|
-
|
|
102653
|
-
|
|
102654
|
-
|
|
102655
|
-
|
|
103051
|
+
function planAssessmentAssociationImport(entry, existingByKey, existingByIdentifier) {
|
|
103052
|
+
if (!existingByKey) {
|
|
103053
|
+
if (!existingByIdentifier) {
|
|
103054
|
+
return { kind: "insert" };
|
|
103055
|
+
}
|
|
103056
|
+
const existingKey = existingByIdentifier.assessmentKey;
|
|
102656
103057
|
return {
|
|
103058
|
+
kind: "fail",
|
|
102657
103059
|
status: "failed",
|
|
102658
|
-
message: "
|
|
103060
|
+
message: existingKey ? `QTI publication is already attached under assessment key ${existingKey}.` : "QTI publication is already attached without a managed assessment key."
|
|
102659
103061
|
};
|
|
102660
103062
|
}
|
|
102661
|
-
if (
|
|
102662
|
-
|
|
102663
|
-
}
|
|
102664
|
-
if (row.status === "archived") {
|
|
103063
|
+
if (existingByIdentifier && existingByIdentifier.id !== existingByKey.id) {
|
|
103064
|
+
const existingKey = existingByIdentifier.assessmentKey;
|
|
102665
103065
|
return {
|
|
103066
|
+
kind: "fail",
|
|
102666
103067
|
status: "failed",
|
|
102667
|
-
message:
|
|
103068
|
+
message: existingKey ? `QTI publication is already attached under assessment key ${existingKey}.` : "QTI publication is already attached without a managed assessment key."
|
|
102668
103069
|
};
|
|
102669
103070
|
}
|
|
102670
|
-
if (
|
|
103071
|
+
if (!associationMetadataMatches(existingByKey, entry)) {
|
|
102671
103072
|
return {
|
|
103073
|
+
kind: "fail",
|
|
102672
103074
|
status: "failed",
|
|
102673
|
-
message: "
|
|
103075
|
+
message: "Assessment key is already attached with different purpose or standard metadata."
|
|
103076
|
+
};
|
|
103077
|
+
}
|
|
103078
|
+
if (existingByKey.qtiTestIdentifier === entry.qtiTestIdentifier) {
|
|
103079
|
+
return {
|
|
103080
|
+
kind: "skip",
|
|
103081
|
+
status: "skipped",
|
|
103082
|
+
message: "Assessment key already points to this publication."
|
|
102674
103083
|
};
|
|
102675
103084
|
}
|
|
102676
103085
|
return {
|
|
103086
|
+
kind: "fail",
|
|
102677
103087
|
status: "failed",
|
|
102678
|
-
message:
|
|
103088
|
+
message: "Assessment key is already attached to a different QTI publication."
|
|
102679
103089
|
};
|
|
102680
103090
|
}
|
|
102681
103091
|
function attachedAssessmentImportMessage(targetStatus, editable) {
|
|
@@ -102737,62 +103147,6 @@ function buildQtiLibraryListPlan(params) {
|
|
|
102737
103147
|
}
|
|
102738
103148
|
var PLAYCADEMY_QTI_SOURCE_PREFIX = "playcademy-test-", PLAYCADEMY_QTI_SOURCE_UPPER_BOUND = "playcademy-test.";
|
|
102739
103149
|
|
|
102740
|
-
// ../api-core/src/utils/timeback-review-mapping.util.ts
|
|
102741
|
-
function reviewMappingIssueSummary(label, identifiers) {
|
|
102742
|
-
if (identifiers.length === 0) {
|
|
102743
|
-
return null;
|
|
102744
|
-
}
|
|
102745
|
-
const displayed = identifiers.slice(0, 10).join(", ");
|
|
102746
|
-
const remainder = identifiers.length > 10 ? `, and ${identifiers.length - 10} more` : "";
|
|
102747
|
-
return `${label} (${identifiers.length}): ${displayed}${remainder}`;
|
|
102748
|
-
}
|
|
102749
|
-
async function prepareReviewMappingUpdate(test, questions) {
|
|
102750
|
-
const contentRevision = await assessmentContentRevision(test, questions);
|
|
102751
|
-
const assessment = {
|
|
102752
|
-
identifier: test.identifier,
|
|
102753
|
-
contractVersion: 1,
|
|
102754
|
-
contentRevision,
|
|
102755
|
-
title: test.title,
|
|
102756
|
-
items: questions.questions.map(({ question }) => ({
|
|
102757
|
-
identifier: question.identifier,
|
|
102758
|
-
title: question.title,
|
|
102759
|
-
prompt: "",
|
|
102760
|
-
maxScore: 1,
|
|
102761
|
-
interactions: []
|
|
102762
|
-
}))
|
|
102763
|
-
};
|
|
102764
|
-
const bank = await buildReviewBankIndex(assessment, questions, { customOnly: true });
|
|
102765
|
-
const issues = reviewBankMappingIssues(bank);
|
|
102766
|
-
if (issues.missingOrInvalidItemIdentifiers.length > 0 || issues.multipleStandardItemIdentifiers.length > 0) {
|
|
102767
|
-
const details = [
|
|
102768
|
-
reviewMappingIssueSummary("missing or invalid associations", issues.missingOrInvalidItemIdentifiers),
|
|
102769
|
-
reviewMappingIssueSummary("multiple associations", issues.multipleStandardItemIdentifiers)
|
|
102770
|
-
].filter((detail) => detail !== null);
|
|
102771
|
-
throw new ValidationError(`Every review question must have exactly one valid standard/node association. ${details.join("; ")}.`);
|
|
102772
|
-
}
|
|
102773
|
-
const manifest = await buildReviewBankManifest(bank);
|
|
102774
|
-
return {
|
|
102775
|
-
update: {
|
|
102776
|
-
...buildQtiTestUpdateInput(test, test.title),
|
|
102777
|
-
metadata: {
|
|
102778
|
-
...test.metadata,
|
|
102779
|
-
[PLAYCADEMY_REVIEW_BANK_MANIFEST_METADATA_KEY]: manifest
|
|
102780
|
-
}
|
|
102781
|
-
},
|
|
102782
|
-
summary: {
|
|
102783
|
-
itemCount: bank.items.length,
|
|
102784
|
-
standardCount: Object.keys(manifest.itemsByStandard).length,
|
|
102785
|
-
sourceFingerprint: manifest.sourceFingerprint
|
|
102786
|
-
}
|
|
102787
|
-
};
|
|
102788
|
-
}
|
|
102789
|
-
var init_timeback_review_mapping_util = __esm(() => {
|
|
102790
|
-
init_assessment_runtime2();
|
|
102791
|
-
init_errors2();
|
|
102792
|
-
init_timeback_assessment_runtime_util();
|
|
102793
|
-
init_timeback_qti_authoring_util();
|
|
102794
|
-
});
|
|
102795
|
-
|
|
102796
103150
|
// ../api-core/src/services/timeback-assessments.service.ts
|
|
102797
103151
|
function databaseConstraintName(error88) {
|
|
102798
103152
|
if (typeof error88 !== "object" || error88 === null) {
|
|
@@ -102801,15 +103155,6 @@ function databaseConstraintName(error88) {
|
|
|
102801
103155
|
const name3 = error88.constraint_name;
|
|
102802
103156
|
return typeof name3 === "string" ? name3 : undefined;
|
|
102803
103157
|
}
|
|
102804
|
-
function diagnosticRoutingCapabilities(assessment, scoring) {
|
|
102805
|
-
return assessment.items.map((item) => ({
|
|
102806
|
-
itemIdentifier: item.identifier,
|
|
102807
|
-
supportsDeterminateBinaryGrading: item.interactions.length > 0 && item.interactions.every((interaction) => {
|
|
102808
|
-
const responseIdentifier = interaction.responseIdentifier;
|
|
102809
|
-
return Object.hasOwn(scoring.correctResponses[item.identifier] ?? {}, responseIdentifier) || (scoring.responseAreas?.[item.identifier]?.[responseIdentifier]?.length ?? 0) > 0;
|
|
102810
|
-
})
|
|
102811
|
-
}));
|
|
102812
|
-
}
|
|
102813
103158
|
|
|
102814
103159
|
class TimebackAssessmentsService {
|
|
102815
103160
|
deps;
|
|
@@ -102873,6 +103218,7 @@ class TimebackAssessmentsService {
|
|
|
102873
103218
|
metadata: {
|
|
102874
103219
|
ownerSystem: PLAYCADEMY_QTI_OWNER_SYSTEM,
|
|
102875
103220
|
ownerGameSlug: ownership.gameSlug,
|
|
103221
|
+
assessmentKey: input.assessmentKey,
|
|
102876
103222
|
integrationId,
|
|
102877
103223
|
subject: integration.subject,
|
|
102878
103224
|
grade: String(integration.grade),
|
|
@@ -102882,6 +103228,7 @@ class TimebackAssessmentsService {
|
|
|
102882
103228
|
try {
|
|
102883
103229
|
const [row] = await this.deps.db.insert(gameTimebackAssessmentTests).values({
|
|
102884
103230
|
integrationId,
|
|
103231
|
+
assessmentKey: input.assessmentKey,
|
|
102885
103232
|
qtiTestIdentifier: input.qtiTestIdentifier,
|
|
102886
103233
|
purpose: input.purpose,
|
|
102887
103234
|
status: "draft",
|
|
@@ -102938,12 +103285,33 @@ class TimebackAssessmentsService {
|
|
|
102938
103285
|
const existingRows = await this.deps.db.query.gameTimebackAssessmentTests.findMany({
|
|
102939
103286
|
where: eq(gameTimebackAssessmentTests.integrationId, integrationId)
|
|
102940
103287
|
});
|
|
103288
|
+
const existingByKey = new Map(existingRows.flatMap((row) => row.assessmentKey ? [[row.assessmentKey, row]] : []));
|
|
102941
103289
|
const existingByIdentifier = new Map(existingRows.map((row) => [row.qtiTestIdentifier, row]));
|
|
102942
|
-
const validated = await
|
|
103290
|
+
const validated = await this.validateAssessmentImportEntries(client2, entries, manifest.targetStatus);
|
|
103291
|
+
const results = [];
|
|
103292
|
+
const pendingInserts = this.planAssessmentImports({
|
|
103293
|
+
validated,
|
|
103294
|
+
existingByKey,
|
|
103295
|
+
existingByIdentifier,
|
|
103296
|
+
gameSlug,
|
|
103297
|
+
results
|
|
103298
|
+
});
|
|
103299
|
+
const liveReviewFailures = liveReviewAssessmentImportFailures(manifest.targetStatus, pendingInserts.map(({ entry }) => entry), existingRows);
|
|
103300
|
+
this.recordLiveReviewImportFailures(pendingInserts, liveReviewFailures, gameSlug, results);
|
|
103301
|
+
const attachablePending = pendingInserts.filter(({ entry }) => !liveReviewFailures.has(entry.qtiTestIdentifier));
|
|
103302
|
+
await this.insertImportedAssessments(integrationId, attachablePending, manifest.targetStatus, gameSlug, results);
|
|
103303
|
+
setAttribute("app.assessment.operation", "bulk_attach_existing");
|
|
103304
|
+
setAttribute("app.assessment.bulk_attach.target_status", manifest.targetStatus);
|
|
103305
|
+
setAttribute("app.assessment.bulk_attach.created", results.filter((result) => result.status === "created").length);
|
|
103306
|
+
return { results };
|
|
103307
|
+
}
|
|
103308
|
+
async validateAssessmentImportEntries(client2, entries, targetStatus) {
|
|
103309
|
+
return runWithConcurrency(entries, QTI_HYDRATION_CONCURRENCY, async (entry) => {
|
|
102943
103310
|
try {
|
|
102944
103311
|
const loaded = await loadHydratedQtiTest(client2, entry.qtiTestIdentifier);
|
|
103312
|
+
assertManagedAssessmentIdentity(loaded.test, entry.assessmentKey);
|
|
102945
103313
|
assertAssessmentHasQuestions(loaded.questions.questions);
|
|
102946
|
-
assertPlayableAssessmentImportQuestions(
|
|
103314
|
+
assertPlayableAssessmentImportQuestions(targetStatus, loaded.questions.questions.map(({ question }) => question));
|
|
102947
103315
|
if (entry.purpose === "review") {
|
|
102948
103316
|
assertReviewAssessmentHasStandards(loaded.questions.questions.map(({ question }) => reviewRoutingStandardRefsFromMetadata(question.metadata, {
|
|
102949
103317
|
customOnly: true
|
|
@@ -102954,77 +103322,86 @@ class TimebackAssessmentsService {
|
|
|
102954
103322
|
return { entry, error: error88 };
|
|
102955
103323
|
}
|
|
102956
103324
|
});
|
|
102957
|
-
|
|
102958
|
-
|
|
102959
|
-
|
|
103325
|
+
}
|
|
103326
|
+
associationImportResultBase(entry, test, gameSlug) {
|
|
103327
|
+
return {
|
|
103328
|
+
assessmentKey: entry.assessmentKey,
|
|
103329
|
+
qtiTestIdentifier: entry.qtiTestIdentifier,
|
|
103330
|
+
...test ? { title: test.title } : {},
|
|
103331
|
+
purpose: entry.purpose,
|
|
103332
|
+
...entry.standard ? { standard: entry.standard } : {},
|
|
103333
|
+
sourceSortOrder: entry.sortOrder,
|
|
103334
|
+
...test && gameSlug ? { editable: isQtiTestOwnedByGame(test, gameSlug) } : {}
|
|
103335
|
+
};
|
|
103336
|
+
}
|
|
103337
|
+
planAssessmentImports(input) {
|
|
103338
|
+
const pendingInserts = [];
|
|
103339
|
+
for (const validation of input.validated) {
|
|
102960
103340
|
const { entry } = validation;
|
|
102961
|
-
const
|
|
102962
|
-
const base = {
|
|
102963
|
-
qtiTestIdentifier: entry.qtiTestIdentifier,
|
|
102964
|
-
purpose: entry.purpose,
|
|
102965
|
-
...entry.standard ? { standard: entry.standard } : {},
|
|
102966
|
-
sourceSortOrder: entry.sortOrder
|
|
102967
|
-
};
|
|
103341
|
+
const base = this.associationImportResultBase(entry, "test" in validation ? validation.test : undefined, input.gameSlug);
|
|
102968
103342
|
if ("error" in validation) {
|
|
102969
|
-
results[
|
|
103343
|
+
input.results[entry.index] = {
|
|
102970
103344
|
...base,
|
|
102971
103345
|
status: "failed",
|
|
102972
103346
|
message: `QTI validation failed: ${errorMessage(validation.error)}`
|
|
102973
103347
|
};
|
|
102974
103348
|
} else {
|
|
102975
|
-
const
|
|
102976
|
-
const
|
|
102977
|
-
|
|
102978
|
-
|
|
102979
|
-
pending.push({ entry, test: validation.test });
|
|
103349
|
+
const existingForKey = input.existingByKey.get(entry.assessmentKey);
|
|
103350
|
+
const decision = planAssessmentAssociationImport(entry, existingForKey, input.existingByIdentifier.get(entry.qtiTestIdentifier));
|
|
103351
|
+
if (decision.kind === "insert") {
|
|
103352
|
+
pendingInserts.push({ entry, test: validation.test });
|
|
102980
103353
|
} else {
|
|
102981
|
-
|
|
102982
|
-
|
|
102983
|
-
...entry.standard ? { standard: entry.standard } : {}
|
|
102984
|
-
}, manifest.targetStatus);
|
|
102985
|
-
results[index2] = {
|
|
102986
|
-
...hydratedBase,
|
|
103354
|
+
input.results[entry.index] = {
|
|
103355
|
+
...base,
|
|
102987
103356
|
status: decision.status,
|
|
102988
|
-
association: this.associationImportSummary(
|
|
103357
|
+
...existingForKey ? { association: this.associationImportSummary(existingForKey) } : {},
|
|
102989
103358
|
message: decision.message
|
|
102990
103359
|
};
|
|
102991
103360
|
}
|
|
102992
103361
|
}
|
|
102993
103362
|
}
|
|
102994
|
-
|
|
103363
|
+
return pendingInserts;
|
|
103364
|
+
}
|
|
103365
|
+
recordLiveReviewImportFailures(pending, failures, gameSlug, results) {
|
|
102995
103366
|
for (const { entry, test } of pending) {
|
|
102996
|
-
const message =
|
|
103367
|
+
const message = failures.get(entry.qtiTestIdentifier);
|
|
102997
103368
|
if (message) {
|
|
102998
103369
|
results[entry.index] = {
|
|
102999
|
-
|
|
103000
|
-
title: test.title,
|
|
103001
|
-
purpose: entry.purpose,
|
|
103002
|
-
...entry.standard ? { standard: entry.standard } : {},
|
|
103003
|
-
sourceSortOrder: entry.sortOrder,
|
|
103004
|
-
editable: isQtiTestOwnedByGame(test, gameSlug),
|
|
103370
|
+
...this.associationImportResultBase(entry, test, gameSlug),
|
|
103005
103371
|
status: "failed",
|
|
103006
103372
|
message
|
|
103007
103373
|
};
|
|
103008
103374
|
}
|
|
103009
103375
|
}
|
|
103010
|
-
|
|
103011
|
-
|
|
103012
|
-
|
|
103013
|
-
const
|
|
103014
|
-
|
|
103015
|
-
|
|
103016
|
-
|
|
103017
|
-
|
|
103018
|
-
|
|
103019
|
-
|
|
103020
|
-
|
|
103376
|
+
}
|
|
103377
|
+
async concurrentAssessmentImportDecision(integrationId, entry) {
|
|
103378
|
+
try {
|
|
103379
|
+
const [byKey, byIdentifier] = await Promise.all([
|
|
103380
|
+
this.deps.db.query.gameTimebackAssessmentTests.findFirst({
|
|
103381
|
+
where: and(eq(gameTimebackAssessmentTests.integrationId, integrationId), eq(gameTimebackAssessmentTests.assessmentKey, entry.assessmentKey))
|
|
103382
|
+
}),
|
|
103383
|
+
this.deps.db.query.gameTimebackAssessmentTests.findFirst({
|
|
103384
|
+
where: and(eq(gameTimebackAssessmentTests.integrationId, integrationId), eq(gameTimebackAssessmentTests.qtiTestIdentifier, entry.qtiTestIdentifier))
|
|
103385
|
+
})
|
|
103386
|
+
]);
|
|
103387
|
+
return {
|
|
103388
|
+
byKey,
|
|
103389
|
+
decision: planAssessmentAssociationImport(entry, byKey, byIdentifier)
|
|
103021
103390
|
};
|
|
103391
|
+
} catch {
|
|
103392
|
+
return null;
|
|
103393
|
+
}
|
|
103394
|
+
}
|
|
103395
|
+
async insertImportedAssessments(integrationId, pending, targetStatus, gameSlug, results) {
|
|
103396
|
+
for (const { entry, test } of pending) {
|
|
103397
|
+
const base = this.associationImportResultBase(entry, test, gameSlug);
|
|
103022
103398
|
try {
|
|
103023
103399
|
const [row] = await this.deps.db.insert(gameTimebackAssessmentTests).values({
|
|
103024
103400
|
integrationId,
|
|
103401
|
+
assessmentKey: entry.assessmentKey,
|
|
103025
103402
|
qtiTestIdentifier: entry.qtiTestIdentifier,
|
|
103026
103403
|
purpose: entry.purpose,
|
|
103027
|
-
status:
|
|
103404
|
+
status: targetStatus,
|
|
103028
103405
|
sortOrder: entry.sortOrder,
|
|
103029
103406
|
standardFramework: entry.standard?.framework,
|
|
103030
103407
|
standardIdentifier: entry.standard?.identifier
|
|
@@ -103032,51 +103409,37 @@ class TimebackAssessmentsService {
|
|
|
103032
103409
|
if (!row) {
|
|
103033
103410
|
throw new Error("Assessment association create returned no row");
|
|
103034
103411
|
}
|
|
103035
|
-
results[
|
|
103412
|
+
results[entry.index] = {
|
|
103036
103413
|
...base,
|
|
103037
103414
|
status: "created",
|
|
103038
103415
|
association: this.associationImportSummary(row),
|
|
103039
|
-
message: attachedAssessmentImportMessage(
|
|
103416
|
+
message: attachedAssessmentImportMessage(targetStatus, base.editable ?? false)
|
|
103040
103417
|
};
|
|
103041
103418
|
} catch (error88) {
|
|
103042
|
-
results[
|
|
103419
|
+
results[entry.index] = {
|
|
103043
103420
|
...base,
|
|
103044
103421
|
status: "failed",
|
|
103045
103422
|
message: `Association attach failed: ${errorMessage(error88)}`
|
|
103046
103423
|
};
|
|
103047
103424
|
if (isUniqueViolation(error88)) {
|
|
103048
|
-
|
|
103049
|
-
|
|
103050
|
-
|
|
103051
|
-
where: and(eq(gameTimebackAssessmentTests.integrationId, integrationId), eq(gameTimebackAssessmentTests.qtiTestIdentifier, entry.qtiTestIdentifier))
|
|
103052
|
-
});
|
|
103053
|
-
} catch {}
|
|
103054
|
-
if (concurrentAssociation) {
|
|
103055
|
-
const decision = classifyExistingAssessmentImport(concurrentAssociation, {
|
|
103056
|
-
purpose: entry.purpose,
|
|
103057
|
-
...entry.standard ? { standard: entry.standard } : {}
|
|
103058
|
-
}, manifest.targetStatus);
|
|
103059
|
-
results[index2] = {
|
|
103425
|
+
const concurrent = await this.concurrentAssessmentImportDecision(integrationId, entry);
|
|
103426
|
+
if (concurrent?.decision.kind === "skip" || concurrent?.decision.kind === "fail") {
|
|
103427
|
+
results[entry.index] = {
|
|
103060
103428
|
...base,
|
|
103061
|
-
status: decision.status,
|
|
103062
|
-
association: this.associationImportSummary(
|
|
103063
|
-
message: decision.message
|
|
103429
|
+
status: concurrent.decision.status,
|
|
103430
|
+
...concurrent.byKey ? { association: this.associationImportSummary(concurrent.byKey) } : {},
|
|
103431
|
+
message: concurrent.decision.message
|
|
103064
103432
|
};
|
|
103065
103433
|
}
|
|
103066
103434
|
}
|
|
103067
103435
|
}
|
|
103068
103436
|
}
|
|
103069
|
-
setAttribute("app.assessment.operation", "bulk_attach_existing");
|
|
103070
|
-
setAttribute("app.assessment.bulk_attach.target_status", manifest.targetStatus);
|
|
103071
|
-
setAttribute("app.assessment.bulk_attach.created", results.filter((result) => result.status === "created").length);
|
|
103072
|
-
return { results };
|
|
103073
103437
|
}
|
|
103074
103438
|
async updateAssessment(integrationId, qtiTestIdentifier, input) {
|
|
103075
103439
|
try {
|
|
103076
103440
|
return await this.withLockedAssessmentAssociations(integrationId, qtiTestIdentifier, async (row, tx, associations) => {
|
|
103077
103441
|
const nextPurpose = input.purpose ?? row.purpose;
|
|
103078
|
-
|
|
103079
|
-
assertDiagnosticPurposeChangeDraft(row, nextPurpose);
|
|
103442
|
+
assertPurposeChangeDraft(row, nextPurpose);
|
|
103080
103443
|
const requestedStandard = input.standard ? this.canonicalAssessmentStandard(input.standard) : undefined;
|
|
103081
103444
|
const nextStandard = requestedStandard ?? (nextPurpose === "mastery" ? assessmentStandardForRow(row) : null);
|
|
103082
103445
|
this.assertPurposeStandard(nextPurpose, nextStandard);
|
|
@@ -103101,7 +103464,10 @@ class TimebackAssessmentsService {
|
|
|
103101
103464
|
}
|
|
103102
103465
|
if (!publishing && !activatingReview && nextPurpose === "review" && row.purpose !== "review") {
|
|
103103
103466
|
const ownership = await this.requireQtiTestOwnershipContext(integrationId, tx);
|
|
103104
|
-
await this.rebuildReviewMapping(this.requireClient(), qtiTestIdentifier,
|
|
103467
|
+
await this.rebuildReviewMapping(this.requireClient(), qtiTestIdentifier, {
|
|
103468
|
+
kind: "owned",
|
|
103469
|
+
gameSlug: ownership.gameSlug
|
|
103470
|
+
});
|
|
103105
103471
|
}
|
|
103106
103472
|
if (input.title !== undefined) {
|
|
103107
103473
|
assertDraftAssessment(row);
|
|
@@ -103113,16 +103479,24 @@ class TimebackAssessmentsService {
|
|
|
103113
103479
|
await client2.qtiApi.assessmentTests.update(qtiTestIdentifier, buildQtiTestUpdateInput(test, input.title));
|
|
103114
103480
|
}
|
|
103115
103481
|
if (publishing || activatingReview) {
|
|
103116
|
-
if (nextPurpose === "diagnostic") {
|
|
103117
|
-
|
|
103118
|
-
|
|
103119
|
-
|
|
103120
|
-
|
|
103482
|
+
if (nextPurpose === "diagnostic" && !nextDiagnostic) {
|
|
103483
|
+
throw new ValidationError("Platform-routed diagnostics require a diagnostic key and routing manifest before publication.");
|
|
103484
|
+
}
|
|
103485
|
+
const loaded = await loadHydratedQtiTest(this.requireClient(), qtiTestIdentifier);
|
|
103486
|
+
if (nextPurpose === "diagnostic" && nextDiagnostic) {
|
|
103487
|
+
nextDiagnostic = await preparePublishedDiagnosticDefinition(nextDiagnostic, loaded);
|
|
103121
103488
|
updates.diagnosticKey = nextDiagnostic.diagnosticKey;
|
|
103122
103489
|
updates.diagnosticRoutingManifest = nextDiagnostic.routingManifest;
|
|
103123
103490
|
} else {
|
|
103124
103491
|
const reviewOwnership = nextPurpose === "review" && nextStatus === "live" ? await this.requireQtiTestOwnershipContext(integrationId, tx) : undefined;
|
|
103125
|
-
await this.validateAssessmentHasQuestions(
|
|
103492
|
+
await this.validateAssessmentHasQuestions(loaded, reviewOwnership?.gameSlug);
|
|
103493
|
+
}
|
|
103494
|
+
if (publishing) {
|
|
103495
|
+
const publication = await this.publishManagedAssessment(row, nextPurpose, nextDiagnostic?.routingManifest ?? null, loaded);
|
|
103496
|
+
updates.qtiTestIdentifier = publication.qtiTestIdentifier;
|
|
103497
|
+
if (nextPurpose === "diagnostic") {
|
|
103498
|
+
updates.diagnosticRoutingManifest = publication.diagnosticRoutingManifest;
|
|
103499
|
+
}
|
|
103126
103500
|
}
|
|
103127
103501
|
}
|
|
103128
103502
|
let updated = row;
|
|
@@ -103218,13 +103592,14 @@ class TimebackAssessmentsService {
|
|
|
103218
103592
|
return { ...result, questions };
|
|
103219
103593
|
}
|
|
103220
103594
|
async updateReviewMapping(integrationId, qtiTestIdentifier) {
|
|
103221
|
-
return this.withLockedAssessmentAssociations(integrationId, qtiTestIdentifier, async (
|
|
103595
|
+
return this.withLockedAssessmentAssociations(integrationId, qtiTestIdentifier, async (_row, _tx, associations) => {
|
|
103222
103596
|
if (!associations.some((association) => association.purpose === "review")) {
|
|
103223
103597
|
throw new ValidationError("Review mapping is available only for standards-review assessments.");
|
|
103224
103598
|
}
|
|
103225
103599
|
const client2 = this.requireClient();
|
|
103226
|
-
const
|
|
103227
|
-
|
|
103600
|
+
const result = await this.rebuildReviewMapping(client2, qtiTestIdentifier, {
|
|
103601
|
+
kind: "live-review-repair"
|
|
103602
|
+
});
|
|
103228
103603
|
setAttribute("app.assessment.operation", "update_review_mapping");
|
|
103229
103604
|
setAttribute("app.assessment.review_mapping_item_count", result.itemCount);
|
|
103230
103605
|
return result;
|
|
@@ -103240,7 +103615,14 @@ class TimebackAssessmentsService {
|
|
|
103240
103615
|
await this.requireIntegration(integrationId);
|
|
103241
103616
|
return this.listQtiLibrary(params, async (listParams) => await client2.qtiApi.assessmentTests.list(listParams));
|
|
103242
103617
|
}
|
|
103243
|
-
async copyAssessment(integrationId,
|
|
103618
|
+
async copyAssessment(integrationId, input) {
|
|
103619
|
+
const {
|
|
103620
|
+
sourceTestIdentifier,
|
|
103621
|
+
targetTestIdentifier,
|
|
103622
|
+
assessmentKey,
|
|
103623
|
+
purpose,
|
|
103624
|
+
standard: standardInput
|
|
103625
|
+
} = input;
|
|
103244
103626
|
if (purpose === "diagnostic") {
|
|
103245
103627
|
throw new ValidationError("Adaptive diagnostics must be created by importing assessment JSON with a routing sidecar.");
|
|
103246
103628
|
}
|
|
@@ -103267,6 +103649,7 @@ class TimebackAssessmentsService {
|
|
|
103267
103649
|
...source.metadata,
|
|
103268
103650
|
ownerSystem: PLAYCADEMY_QTI_OWNER_SYSTEM,
|
|
103269
103651
|
ownerGameSlug: ownership.gameSlug,
|
|
103652
|
+
assessmentKey,
|
|
103270
103653
|
integrationId,
|
|
103271
103654
|
subject: integration.subject,
|
|
103272
103655
|
grade: String(integration.grade),
|
|
@@ -103289,6 +103672,7 @@ class TimebackAssessmentsService {
|
|
|
103289
103672
|
associationCreationAttempted = true;
|
|
103290
103673
|
const [row] = await this.deps.db.insert(gameTimebackAssessmentTests).values({
|
|
103291
103674
|
integrationId,
|
|
103675
|
+
assessmentKey,
|
|
103292
103676
|
qtiTestIdentifier: targetTestIdentifier,
|
|
103293
103677
|
purpose,
|
|
103294
103678
|
status: "draft",
|
|
@@ -103583,6 +103967,50 @@ class TimebackAssessmentsService {
|
|
|
103583
103967
|
qtiItemHref(client2, itemIdentifier) {
|
|
103584
103968
|
return `${client2.getQtiBaseUrl()}/assessment-items/${itemIdentifier}`;
|
|
103585
103969
|
}
|
|
103970
|
+
async ensureImmutableQtiResource(get, create) {
|
|
103971
|
+
try {
|
|
103972
|
+
return await get();
|
|
103973
|
+
} catch (error88) {
|
|
103974
|
+
if (!isApiError(error88) || error88.statusCode !== 404) {
|
|
103975
|
+
throw error88;
|
|
103976
|
+
}
|
|
103977
|
+
}
|
|
103978
|
+
try {
|
|
103979
|
+
await create();
|
|
103980
|
+
} catch (error88) {
|
|
103981
|
+
if (!isApiError(error88) || error88.statusCode !== 409) {
|
|
103982
|
+
throw error88;
|
|
103983
|
+
}
|
|
103984
|
+
}
|
|
103985
|
+
return get();
|
|
103986
|
+
}
|
|
103987
|
+
async ensureManagedQtiPublication(client2, plan) {
|
|
103988
|
+
await runWithConcurrency(plan.items, QTI_HYDRATION_CONCURRENCY, async (plannedItem) => {
|
|
103989
|
+
const existing = await this.ensureImmutableQtiResource(async () => await client2.qtiApi.assessmentItems.get(plannedItem.qtiItemIdentifier), () => client2.course.createAssessmentItemXml({
|
|
103990
|
+
xml: plannedItem.xml,
|
|
103991
|
+
metadata: plannedItem.metadata
|
|
103992
|
+
}));
|
|
103993
|
+
await assertManagedAssessmentItem(existing, plannedItem);
|
|
103994
|
+
});
|
|
103995
|
+
const existingTest = await this.ensureImmutableQtiResource(async () => await client2.qtiApi.assessmentTests.get(plan.qtiTestIdentifier), () => client2.qtiApi.assessmentTests.create(plan.testInput));
|
|
103996
|
+
assertManagedAssessmentPublication(existingTest, plan);
|
|
103997
|
+
}
|
|
103998
|
+
async publishManagedAssessment(row, purpose, diagnosticRoutingManifest, loaded) {
|
|
103999
|
+
if (!row.assessmentKey) {
|
|
104000
|
+
throw new ValidationError("This unmanaged assessment cannot be published. Republish it with an explicit assessment key.");
|
|
104001
|
+
}
|
|
104002
|
+
const client2 = this.requireClient();
|
|
104003
|
+
const plan = await prepareManagedAssessmentPublication({
|
|
104004
|
+
assessmentKey: row.assessmentKey,
|
|
104005
|
+
purpose,
|
|
104006
|
+
test: loaded.test,
|
|
104007
|
+
questions: loaded.questions,
|
|
104008
|
+
diagnosticRoutingManifest,
|
|
104009
|
+
itemHref: (identifier) => this.qtiItemHref(client2, identifier)
|
|
104010
|
+
});
|
|
104011
|
+
await this.ensureManagedQtiPublication(client2, plan);
|
|
104012
|
+
return plan;
|
|
104013
|
+
}
|
|
103586
104014
|
async cleanupQtiAssessmentCopy(client2, testIdentifier, itemIdentifiers) {
|
|
103587
104015
|
let testDeleted = !testIdentifier;
|
|
103588
104016
|
if (testIdentifier) {
|
|
@@ -103618,6 +104046,7 @@ class TimebackAssessmentsService {
|
|
|
103618
104046
|
return {
|
|
103619
104047
|
id: row.id,
|
|
103620
104048
|
integrationId: row.integrationId,
|
|
104049
|
+
assessmentKey: row.assessmentKey,
|
|
103621
104050
|
qtiTestIdentifier: row.qtiTestIdentifier,
|
|
103622
104051
|
purpose: row.purpose,
|
|
103623
104052
|
status: row.status,
|
|
@@ -103659,20 +104088,14 @@ class TimebackAssessmentsService {
|
|
|
103659
104088
|
const plan = buildQtiLibraryListPlan(params);
|
|
103660
104089
|
return list(plan.params);
|
|
103661
104090
|
}
|
|
103662
|
-
async validateAssessmentHasQuestions(
|
|
103663
|
-
|
|
103664
|
-
const
|
|
103665
|
-
client2.qtiApi.assessmentTests.get(qtiTestIdentifier),
|
|
103666
|
-
client2.qtiApi.assessmentTests.getQuestions(qtiTestIdentifier)
|
|
103667
|
-
]);
|
|
103668
|
-
assertAssessmentHasQuestions(result.questions);
|
|
103669
|
-
const hydrated = await hydrateQtiTestQuestions(client2, result);
|
|
103670
|
-
for (const { question } of hydrated.questions) {
|
|
104091
|
+
async validateAssessmentHasQuestions(loaded, reviewGameSlug) {
|
|
104092
|
+
assertAssessmentHasQuestions(loaded.questions.questions);
|
|
104093
|
+
for (const { question } of loaded.questions.questions) {
|
|
103671
104094
|
assertPlayableQtiQuestion(question);
|
|
103672
104095
|
}
|
|
103673
104096
|
if (reviewGameSlug) {
|
|
103674
|
-
assertQtiTestOwnedByGame(test, reviewGameSlug);
|
|
103675
|
-
await this.writeReviewMapping(
|
|
104097
|
+
assertQtiTestOwnedByGame(loaded.test, reviewGameSlug);
|
|
104098
|
+
await this.writeReviewMapping(this.requireClient(), loaded.test, loaded.questions);
|
|
103676
104099
|
}
|
|
103677
104100
|
}
|
|
103678
104101
|
async prepareDiagnosticAssociationChanges(row, nextPurpose, requested) {
|
|
@@ -103689,7 +104112,7 @@ class TimebackAssessmentsService {
|
|
|
103689
104112
|
if (requested === null || nextPurpose !== "diagnostic") {
|
|
103690
104113
|
nextDiagnostic = null;
|
|
103691
104114
|
} else if (requested) {
|
|
103692
|
-
nextDiagnostic =
|
|
104115
|
+
nextDiagnostic = prepareDiagnosticDefinition(requested);
|
|
103693
104116
|
}
|
|
103694
104117
|
if (requested === undefined && nextPurpose === row.purpose) {
|
|
103695
104118
|
return { nextDiagnostic, updates: {} };
|
|
@@ -103702,47 +104125,14 @@ class TimebackAssessmentsService {
|
|
|
103702
104125
|
}
|
|
103703
104126
|
};
|
|
103704
104127
|
}
|
|
103705
|
-
|
|
103706
|
-
const diagnosticKey = input.diagnosticKey.trim();
|
|
103707
|
-
if (!diagnosticKey || diagnosticKey.length > 200) {
|
|
103708
|
-
throw new ValidationError("Diagnostic key must contain between 1 and 200 characters");
|
|
103709
|
-
}
|
|
103710
|
-
const validation = validateDiagnosticRoutingManifest(input.routingManifest, undefined, {
|
|
103711
|
-
analyzeBounds: false
|
|
103712
|
-
});
|
|
103713
|
-
if (!validation.valid || !validation.manifest) {
|
|
103714
|
-
const details = validation.errors.slice(0, 3).map((issue7) => issue7.message).join(" ");
|
|
103715
|
-
throw new ValidationError(`Diagnostic routing manifest is invalid.${details ? ` ${details}` : ""}`);
|
|
103716
|
-
}
|
|
103717
|
-
return {
|
|
103718
|
-
diagnosticKey,
|
|
103719
|
-
routingManifest: validation.manifest
|
|
103720
|
-
};
|
|
103721
|
-
}
|
|
103722
|
-
async preparePublishedDiagnosticDefinition(qtiTestIdentifier, definition) {
|
|
103723
|
-
const loaded = await loadHydratedQtiTest(this.requireClient(), qtiTestIdentifier);
|
|
103724
|
-
assertAssessmentHasQuestions(loaded.questions.questions);
|
|
103725
|
-
for (const { question } of loaded.questions.questions) {
|
|
103726
|
-
assertPlayableQtiQuestion(question);
|
|
103727
|
-
}
|
|
103728
|
-
const assessment = await buildPlayableAssessment(loaded.test, loaded.questions);
|
|
103729
|
-
const scoring = assessmentFixtureScoringKeys(assessment, loaded.questions);
|
|
103730
|
-
const validation = validateDiagnosticRoutingManifest(definition.routingManifest, diagnosticRoutingCapabilities(assessment, scoring));
|
|
103731
|
-
if (!validation.valid || !validation.manifest) {
|
|
103732
|
-
const details = validation.errors.slice(0, 3).map((issue7) => issue7.message).join(" ");
|
|
103733
|
-
throw new ValidationError(`Diagnostic routing manifest cannot be published.${details ? ` ${details}` : ""}`);
|
|
103734
|
-
}
|
|
103735
|
-
return {
|
|
103736
|
-
diagnosticKey: definition.diagnosticKey,
|
|
103737
|
-
routingManifest: validation.manifest
|
|
103738
|
-
};
|
|
103739
|
-
}
|
|
103740
|
-
async rebuildReviewMapping(client2, qtiTestIdentifier, gameSlug) {
|
|
104128
|
+
async rebuildReviewMapping(client2, qtiTestIdentifier, scope) {
|
|
103741
104129
|
const [test, references] = await Promise.all([
|
|
103742
104130
|
client2.qtiApi.assessmentTests.get(qtiTestIdentifier),
|
|
103743
104131
|
client2.qtiApi.assessmentTests.getQuestions(qtiTestIdentifier)
|
|
103744
104132
|
]);
|
|
103745
|
-
|
|
104133
|
+
if (scope.kind === "owned") {
|
|
104134
|
+
assertQtiTestOwnedByGame(test, scope.gameSlug);
|
|
104135
|
+
}
|
|
103746
104136
|
return this.writeReviewMapping(client2, test, await hydrateQtiTestQuestions(client2, references));
|
|
103747
104137
|
}
|
|
103748
104138
|
async writeReviewMapping(client2, test, questions) {
|
|
@@ -103759,9 +104149,10 @@ var init_timeback_assessments_service = __esm(async () => {
|
|
|
103759
104149
|
init_assessment_runtime2();
|
|
103760
104150
|
init_timeback3();
|
|
103761
104151
|
init_errors2();
|
|
104152
|
+
init_timeback_assessment_diagnostic_util();
|
|
103762
104153
|
init_timeback_assessment_import_util();
|
|
104154
|
+
init_timeback_assessment_publication_util();
|
|
103763
104155
|
init_timeback_assessment_rules_util();
|
|
103764
|
-
init_timeback_assessment_runtime_util();
|
|
103765
104156
|
init_timeback_qti_authoring_util();
|
|
103766
104157
|
init_timeback_qti_hydration_util();
|
|
103767
104158
|
init_timeback_review_mapping_util();
|
|
@@ -164421,6 +164812,7 @@ var init_timeback_controller = __esm(() => {
|
|
|
164421
164812
|
const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
|
|
164422
164813
|
const qtiTestIdentifier = newPlaycademyTestIdentifier();
|
|
164423
164814
|
return ctx.services.timebackAssessments.createAssessment(integrationId, {
|
|
164815
|
+
assessmentKey: body2.assessmentKey,
|
|
164424
164816
|
title: body2.title,
|
|
164425
164817
|
purpose: body2.purpose,
|
|
164426
164818
|
standard: body2.standard,
|
|
@@ -164515,7 +164907,13 @@ var init_timeback_controller = __esm(() => {
|
|
|
164515
164907
|
const body2 = await parseRequestBody(ctx.request, CopyAssessmentRequestSchema);
|
|
164516
164908
|
const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
|
|
164517
164909
|
const targetTestIdentifier = newPlaycademyTestIdentifier();
|
|
164518
|
-
return ctx.services.timebackAssessments.copyAssessment(integrationId,
|
|
164910
|
+
return ctx.services.timebackAssessments.copyAssessment(integrationId, {
|
|
164911
|
+
sourceTestIdentifier: body2.testIdentifier,
|
|
164912
|
+
targetTestIdentifier,
|
|
164913
|
+
assessmentKey: body2.assessmentKey,
|
|
164914
|
+
purpose: body2.purpose,
|
|
164915
|
+
standard: body2.standard
|
|
164916
|
+
});
|
|
164519
164917
|
});
|
|
164520
164918
|
createQuestion = requireDeveloper(async (ctx) => {
|
|
164521
164919
|
const { gameId, courseId, testIdentifier } = ctx.params;
|