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