@playcademy/vite-plugin 1.2.1-beta.27 → 1.2.1-beta.28
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/index.js +1638 -1238
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -24317,7 +24317,7 @@ import path2 from "node:path";
|
|
|
24317
24317
|
// package.json
|
|
24318
24318
|
var package_default = {
|
|
24319
24319
|
name: "@playcademy/vite-plugin",
|
|
24320
|
-
version: "1.2.1-beta.
|
|
24320
|
+
version: "1.2.1-beta.28",
|
|
24321
24321
|
type: "module",
|
|
24322
24322
|
exports: {
|
|
24323
24323
|
".": {
|
|
@@ -25934,7 +25934,7 @@ var package_default2;
|
|
|
25934
25934
|
var init_package = __esm(() => {
|
|
25935
25935
|
package_default2 = {
|
|
25936
25936
|
name: "@playcademy/sandbox",
|
|
25937
|
-
version: "0.7.1-beta.
|
|
25937
|
+
version: "0.7.1-beta.26",
|
|
25938
25938
|
description: "Local development server for Playcademy game development",
|
|
25939
25939
|
type: "module",
|
|
25940
25940
|
exports: {
|
|
@@ -35693,6 +35693,14 @@ function compareAssessmentCatalogOrder(left, right) {
|
|
|
35693
35693
|
} else if (leftHasValidDate !== rightHasValidDate) {
|
|
35694
35694
|
return leftHasValidDate ? -1 : 1;
|
|
35695
35695
|
}
|
|
35696
|
+
if (left.assessmentKey !== null && right.assessmentKey !== null) {
|
|
35697
|
+
const keyDifference = left.assessmentKey.localeCompare(right.assessmentKey);
|
|
35698
|
+
if (keyDifference) {
|
|
35699
|
+
return keyDifference;
|
|
35700
|
+
}
|
|
35701
|
+
} else if (left.assessmentKey !== null || right.assessmentKey !== null) {
|
|
35702
|
+
return left.assessmentKey === null ? 1 : -1;
|
|
35703
|
+
}
|
|
35696
35704
|
return left.qtiTestIdentifier.localeCompare(right.qtiTestIdentifier);
|
|
35697
35705
|
}
|
|
35698
35706
|
function orderRuntimeAssessmentTests(tests) {
|
|
@@ -35712,13 +35720,13 @@ function selectRuntimeAssessment(liveTests, attempts) {
|
|
|
35712
35720
|
return null;
|
|
35713
35721
|
}
|
|
35714
35722
|
const completed = attempts.filter(isAssessmentAttemptCompleted);
|
|
35715
|
-
const
|
|
35716
|
-
const unattempted = orderedTests.find((test) => !
|
|
35723
|
+
const exposedAssessmentKeys = new Set(attempts.filter((attempt) => isAssessmentAttemptOpen(attempt) || isAssessmentAttemptCompleted(attempt)).map((attempt) => attempt.selectedAssessmentKey));
|
|
35724
|
+
const unattempted = orderedTests.find((test) => !exposedAssessmentKeys.has(test.assessmentKey));
|
|
35717
35725
|
if (unattempted) {
|
|
35718
35726
|
return { kind: "start", test: unattempted, reason: "unattempted" };
|
|
35719
35727
|
}
|
|
35720
35728
|
const latestCompleted = completed.toSorted(compareNewestCompletedAssessmentAttempt)[0];
|
|
35721
|
-
const latestTestIndex = latestCompleted ? orderedTests.findIndex((test) => test.
|
|
35729
|
+
const latestTestIndex = latestCompleted ? orderedTests.findIndex((test) => test.assessmentKey === latestCompleted.selectedAssessmentKey) : -1;
|
|
35722
35730
|
if (latestTestIndex === -1) {
|
|
35723
35731
|
return { kind: "start", test: orderedTests[0], reason: "first_live" };
|
|
35724
35732
|
}
|
|
@@ -36380,7 +36388,7 @@ function isPlaycademyAssessmentResultMetadataV1(value) {
|
|
|
36380
36388
|
const review = metadata2.review;
|
|
36381
36389
|
const diagnostic = metadata2.diagnostic;
|
|
36382
36390
|
const routedDiagnostic = metadata2.purpose === "diagnostic" && diagnostic !== undefined && Array.isArray(metadata2.itemSubmissions) && Number.isInteger(metadata2.responseVersion) && isRoutedDiagnosticAttemptMetadata(diagnostic, metadata2.itemSubmissions, metadata2.responseVersion);
|
|
36383
|
-
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);
|
|
36391
|
+
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);
|
|
36384
36392
|
}
|
|
36385
36393
|
function isPlaycademyReviewAssessmentItemResultMetadataV1(value) {
|
|
36386
36394
|
if (!isRecord(value) || !isRecord(value.responses)) {
|
|
@@ -36528,6 +36536,7 @@ var REVIEW_SELECTION_POLICY_VERSION = "unseen-first-least-recently-delivered-v1"
|
|
|
36528
36536
|
var PLAYCADEMY_REVIEW_BANK_MANIFEST_METADATA_KEY = "playcademyReviewBank";
|
|
36529
36537
|
var PLAYCADEMY_REVIEW_BANK_MANIFEST_VERSION = 1;
|
|
36530
36538
|
var DEFAULT_REVIEW_SELECTION_POLICY;
|
|
36539
|
+
var ASSESSMENT_RUNTIME_FIXTURE_BUNDLE_VERSION = 7;
|
|
36531
36540
|
var RuntimeSubjectSchema;
|
|
36532
36541
|
var RuntimeGradeSchema;
|
|
36533
36542
|
var OptionalQueryGradeSchema;
|
|
@@ -43138,6 +43147,7 @@ var init_table7 = __esm(() => {
|
|
|
43138
43147
|
gameTimebackAssessmentTests = pgTable("game_timeback_assessment_tests", {
|
|
43139
43148
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
43140
43149
|
integrationId: uuid("integration_id").notNull().references(() => gameTimebackIntegrations.id, { onDelete: "cascade" }),
|
|
43150
|
+
assessmentKey: text("assessment_key"),
|
|
43141
43151
|
qtiTestIdentifier: text("qti_test_identifier").notNull(),
|
|
43142
43152
|
purpose: gameTimebackAssessmentPurposeEnum("purpose").notNull().default("end_of_course"),
|
|
43143
43153
|
status: gameTimebackAssessmentStatusEnum("status").notNull().default("draft"),
|
|
@@ -43150,6 +43160,7 @@ var init_table7 = __esm(() => {
|
|
|
43150
43160
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
|
|
43151
43161
|
}, (table3) => [
|
|
43152
43162
|
uniqueIndex("game_timeback_assessment_tests_integration_qti_idx").on(table3.integrationId, table3.qtiTestIdentifier),
|
|
43163
|
+
uniqueIndex("game_timeback_assessment_tests_integration_key_idx").on(table3.integrationId, table3.assessmentKey).where(sql`${table3.assessmentKey} IS NOT NULL`),
|
|
43153
43164
|
uniqueIndex("game_timeback_assessment_tests_one_live_review_idx").on(table3.integrationId).where(sql`${table3.purpose} = 'review' AND ${table3.status} = 'live'`),
|
|
43154
43165
|
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`),
|
|
43155
43166
|
check("game_timeback_assessment_tests_mastery_standard_check", sql`(
|
|
@@ -71629,6 +71640,7 @@ var ReactivateEnrollmentRequestSchema;
|
|
|
71629
71640
|
var VerifyTimebackMetricDiscrepancyRequestSchema;
|
|
71630
71641
|
var AssessmentPurposeSchema;
|
|
71631
71642
|
var AssessmentStatusSchema;
|
|
71643
|
+
var AssessmentKeySchema;
|
|
71632
71644
|
var DiagnosticKeySchema;
|
|
71633
71645
|
var DiagnosticRoutingManifestSchema;
|
|
71634
71646
|
var DiagnosticAssessmentDefinitionInputSchema;
|
|
@@ -71884,6 +71896,7 @@ var init_schemas4 = __esm(() => {
|
|
|
71884
71896
|
});
|
|
71885
71897
|
AssessmentPurposeSchema = exports_external.enum(gameTimebackAssessmentPurposeEnum.enumValues);
|
|
71886
71898
|
AssessmentStatusSchema = exports_external.enum(gameTimebackAssessmentStatusEnum.enumValues);
|
|
71899
|
+
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");
|
|
71887
71900
|
DiagnosticKeySchema = exports_external.string().trim().min(1).max(200);
|
|
71888
71901
|
DiagnosticRoutingManifestSchema = exports_external.custom((value) => typeof value === "object" && value !== null && !Array.isArray(value), "Diagnostic routing manifest must be an object");
|
|
71889
71902
|
DiagnosticAssessmentDefinitionInputSchema = exports_external.object({
|
|
@@ -71895,6 +71908,7 @@ var init_schemas4 = __esm(() => {
|
|
|
71895
71908
|
identifier: exports_external.string().trim().min(1).max(TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.standardFieldLength)
|
|
71896
71909
|
});
|
|
71897
71910
|
CreateAssessmentRequestSchema = exports_external.object({
|
|
71911
|
+
assessmentKey: AssessmentKeySchema,
|
|
71898
71912
|
title: exports_external.string().min(1, "Assessment title is required"),
|
|
71899
71913
|
purpose: AssessmentPurposeSchema,
|
|
71900
71914
|
standard: AssessmentStandardRefSchema2.optional()
|
|
@@ -71909,11 +71923,13 @@ var init_schemas4 = __esm(() => {
|
|
|
71909
71923
|
message: "Title, purpose, standard, diagnostic, or status is required"
|
|
71910
71924
|
});
|
|
71911
71925
|
CopyAssessmentRequestSchema = exports_external.object({
|
|
71926
|
+
assessmentKey: AssessmentKeySchema,
|
|
71912
71927
|
testIdentifier: exports_external.string().trim().min(1, "Assessment identifier is required"),
|
|
71913
71928
|
purpose: AssessmentPurposeSchema,
|
|
71914
71929
|
standard: AssessmentStandardRefSchema2.optional()
|
|
71915
71930
|
}).superRefine(requireMasteryStandard);
|
|
71916
71931
|
AssessmentAssociationImportEntrySchema = exports_external.object({
|
|
71932
|
+
assessmentKey: AssessmentKeySchema,
|
|
71917
71933
|
qtiTestIdentifier: exports_external.string().trim().min(1, "Assessment identifier is required"),
|
|
71918
71934
|
purpose: AssessmentPurposeSchema,
|
|
71919
71935
|
standard: AssessmentStandardRefSchema2.optional(),
|
|
@@ -71945,16 +71961,25 @@ var init_schemas4 = __esm(() => {
|
|
|
71945
71961
|
targetStatus: exports_external.enum(["draft", "live"]),
|
|
71946
71962
|
assessments: exports_external.array(AssessmentAssociationImportEntrySchema).min(1, "At least one assessment is required").max(500, "A bulk import can contain at most 500 assessments")
|
|
71947
71963
|
}).superRefine((input, context2) => {
|
|
71948
|
-
const
|
|
71964
|
+
const seenIdentifiers = new Set;
|
|
71965
|
+
const seenKeys = new Set;
|
|
71949
71966
|
input.assessments.forEach((assessment, index2) => {
|
|
71950
|
-
if (
|
|
71967
|
+
if (seenIdentifiers.has(assessment.qtiTestIdentifier)) {
|
|
71951
71968
|
context2.addIssue({
|
|
71952
71969
|
code: "custom",
|
|
71953
71970
|
path: ["assessments", index2, "qtiTestIdentifier"],
|
|
71954
71971
|
message: "Assessment identifiers must be unique"
|
|
71955
71972
|
});
|
|
71956
71973
|
}
|
|
71957
|
-
|
|
71974
|
+
if (seenKeys.has(assessment.assessmentKey)) {
|
|
71975
|
+
context2.addIssue({
|
|
71976
|
+
code: "custom",
|
|
71977
|
+
path: ["assessments", index2, "assessmentKey"],
|
|
71978
|
+
message: "Assessment keys must be unique"
|
|
71979
|
+
});
|
|
71980
|
+
}
|
|
71981
|
+
seenIdentifiers.add(assessment.qtiTestIdentifier);
|
|
71982
|
+
seenKeys.add(assessment.assessmentKey);
|
|
71958
71983
|
});
|
|
71959
71984
|
});
|
|
71960
71985
|
ReorderAssessmentsRequestSchema = exports_external.object({
|
|
@@ -124173,113 +124198,947 @@ var init_assessment_runtime_lock_util = __esm(() => {
|
|
|
124173
124198
|
}
|
|
124174
124199
|
};
|
|
124175
124200
|
});
|
|
124176
|
-
function
|
|
124177
|
-
|
|
124201
|
+
function recordValue(value) {
|
|
124202
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
|
|
124203
|
+
}
|
|
124204
|
+
function persistableAuthoringDocument(value) {
|
|
124205
|
+
const document2 = recordValue(value);
|
|
124206
|
+
const item = recordValue(document2?.item);
|
|
124207
|
+
if (!document2 || !item) {
|
|
124178
124208
|
return;
|
|
124179
124209
|
}
|
|
124180
|
-
const
|
|
124181
|
-
|
|
124182
|
-
|
|
124210
|
+
const persistableItem = { ...item };
|
|
124211
|
+
Reflect.deleteProperty(persistableItem, "metadata");
|
|
124212
|
+
return { ...document2, item: persistableItem };
|
|
124213
|
+
}
|
|
124214
|
+
function qtiAuthoringSnapshot(input) {
|
|
124215
|
+
const document2 = persistableAuthoringDocument(input.document);
|
|
124216
|
+
if (!recordValue(input.interaction) && !document2) {
|
|
124217
|
+
return;
|
|
124183
124218
|
}
|
|
124219
|
+
return Object.fromEntries(QTI_AUTHORING_FIELDS.flatMap((field) => {
|
|
124220
|
+
if (field === "document") {
|
|
124221
|
+
return document2 ? [[field, document2]] : [];
|
|
124222
|
+
}
|
|
124223
|
+
return input[field] === undefined ? [] : [[field, input[field]]];
|
|
124224
|
+
}));
|
|
124184
124225
|
}
|
|
124185
|
-
function
|
|
124186
|
-
|
|
124226
|
+
function buildOwnedQtiQuestionMetadata(input, ownership, existingMetadata = {}) {
|
|
124227
|
+
const inputMetadata = recordValue(input.metadata) ?? {};
|
|
124228
|
+
const metadata2 = {
|
|
124229
|
+
...existingMetadata,
|
|
124230
|
+
...inputMetadata
|
|
124231
|
+
};
|
|
124232
|
+
const existingAuthoring = recordValue(existingMetadata[PLAYCADEMY_QTI_AUTHORING_METADATA_KEY]);
|
|
124233
|
+
Reflect.deleteProperty(metadata2, PLAYCADEMY_QTI_AUTHORING_METADATA_KEY);
|
|
124234
|
+
Reflect.deleteProperty(metadata2, PLAYCADEMY_QTI_EDITOR_MODE_KEY);
|
|
124235
|
+
const authoring = qtiAuthoringSnapshot(input);
|
|
124236
|
+
const shouldReplaceAuthoring = "interaction" in input || "numericTextEntry" in input || "document" in input;
|
|
124237
|
+
const persistedAuthoring = authoring ?? (shouldReplaceAuthoring ? undefined : existingAuthoring);
|
|
124238
|
+
return {
|
|
124239
|
+
...metadata2,
|
|
124240
|
+
ownerSystem: PLAYCADEMY_QTI_OWNER_SYSTEM,
|
|
124241
|
+
ownerGameSlug: ownership.gameSlug,
|
|
124242
|
+
[PLAYCADEMY_QTI_OWNER_TEST_IDENTIFIER_KEY]: ownership.testIdentifier,
|
|
124243
|
+
...persistedAuthoring ? { [PLAYCADEMY_QTI_AUTHORING_METADATA_KEY]: persistedAuthoring } : {}
|
|
124244
|
+
};
|
|
124187
124245
|
}
|
|
124188
|
-
function
|
|
124189
|
-
|
|
124190
|
-
|
|
124246
|
+
function restoreQtiQuestionAuthoringData(item) {
|
|
124247
|
+
const authoring = recordValue(item.metadata?.[PLAYCADEMY_QTI_AUTHORING_METADATA_KEY]);
|
|
124248
|
+
if (!authoring) {
|
|
124249
|
+
return item;
|
|
124191
124250
|
}
|
|
124251
|
+
const restored = Object.fromEntries(QTI_AUTHORING_FIELDS.flatMap((field) => authoring[field] === undefined ? [] : [[field, authoring[field]]]));
|
|
124252
|
+
return { ...item, ...restored };
|
|
124192
124253
|
}
|
|
124193
|
-
function
|
|
124194
|
-
|
|
124195
|
-
|
|
124254
|
+
function mergeQtiQuestionItem(item, fallbackItem, itemIdentifier, metadata2) {
|
|
124255
|
+
return restoreQtiQuestionAuthoringData({
|
|
124256
|
+
...fallbackItem,
|
|
124257
|
+
...item,
|
|
124258
|
+
identifier: itemIdentifier,
|
|
124259
|
+
title: item.title || fallbackItem?.title || itemIdentifier,
|
|
124260
|
+
type: item.type ?? fallbackItem?.type,
|
|
124261
|
+
rawXml: item.rawXml ?? fallbackItem?.rawXml,
|
|
124262
|
+
interaction: item.interaction ?? fallbackItem?.interaction,
|
|
124263
|
+
responseDeclarations: item.responseDeclarations ?? fallbackItem?.responseDeclarations,
|
|
124264
|
+
metadata: {
|
|
124265
|
+
...fallbackItem?.metadata,
|
|
124266
|
+
...item.metadata,
|
|
124267
|
+
...metadata2
|
|
124268
|
+
}
|
|
124269
|
+
});
|
|
124270
|
+
}
|
|
124271
|
+
function newPlaycademyQuestionIdentifier(testIdentifier) {
|
|
124272
|
+
return `${testIdentifier}-q${crypto.randomUUID().slice(0, 8)}`;
|
|
124273
|
+
}
|
|
124274
|
+
function parseQtiQuestionCreationInput(input) {
|
|
124275
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
124276
|
+
throw new ValidationError("Question creation input must be an object");
|
|
124196
124277
|
}
|
|
124278
|
+
const rawInput = input;
|
|
124279
|
+
if (rawInput.mode === undefined) {
|
|
124280
|
+
return { kind: "authoring", input: rawInput };
|
|
124281
|
+
}
|
|
124282
|
+
if (rawInput.mode !== "copy") {
|
|
124283
|
+
throw new ValidationError("Question creation mode is invalid");
|
|
124284
|
+
}
|
|
124285
|
+
const sourceItemIdentifier = typeof rawInput.sourceItemIdentifier === "string" ? rawInput.sourceItemIdentifier.trim() : "";
|
|
124286
|
+
if (!sourceItemIdentifier) {
|
|
124287
|
+
throw new ValidationError("A source question identifier is required to create a copy");
|
|
124288
|
+
}
|
|
124289
|
+
const unexpectedField = Object.keys(rawInput).find((field) => field !== "mode" && field !== "sourceItemIdentifier");
|
|
124290
|
+
if (unexpectedField) {
|
|
124291
|
+
throw new ValidationError(`Question copy input contains an unexpected “${unexpectedField}” field`);
|
|
124292
|
+
}
|
|
124293
|
+
return { kind: "copy", sourceItemIdentifier };
|
|
124197
124294
|
}
|
|
124198
|
-
function
|
|
124199
|
-
|
|
124200
|
-
|
|
124295
|
+
function nonEmptyMetadataString(value) {
|
|
124296
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
124297
|
+
}
|
|
124298
|
+
function qtiQuestionOwnerTestIdentifier(item) {
|
|
124299
|
+
if (item.metadata?.ownerSystem !== PLAYCADEMY_QTI_OWNER_SYSTEM) {
|
|
124300
|
+
return;
|
|
124201
124301
|
}
|
|
124302
|
+
return nonEmptyMetadataString(item.metadata[PLAYCADEMY_QTI_OWNER_TEST_IDENTIFIER_KEY]);
|
|
124202
124303
|
}
|
|
124203
|
-
function
|
|
124204
|
-
return
|
|
124205
|
-
diagnosticKey: row.diagnosticKey,
|
|
124206
|
-
routingManifest: row.diagnosticRoutingManifest
|
|
124207
|
-
} : null;
|
|
124304
|
+
function qtiTestReferencesItem(test, itemIdentifier) {
|
|
124305
|
+
return (test["qti-test-part"] ?? []).some((part) => (part["qti-assessment-section"] ?? []).some((section) => (section["qti-assessment-item-ref"] ?? []).some((reference) => reference.identifier === itemIdentifier)));
|
|
124208
124306
|
}
|
|
124209
|
-
function
|
|
124210
|
-
if (
|
|
124211
|
-
|
|
124307
|
+
function resolveQtiQuestionOwnerGameSlug(item, ownerTest) {
|
|
124308
|
+
if (item.metadata?.ownerSystem !== PLAYCADEMY_QTI_OWNER_SYSTEM) {
|
|
124309
|
+
return;
|
|
124212
124310
|
}
|
|
124311
|
+
if ("ownerGameSlug" in item.metadata) {
|
|
124312
|
+
return nonEmptyMetadataString(item.metadata.ownerGameSlug);
|
|
124313
|
+
}
|
|
124314
|
+
const ownerTestIdentifier = qtiQuestionOwnerTestIdentifier(item);
|
|
124315
|
+
if (!ownerTestIdentifier || !ownerTest || ownerTest.identifier !== ownerTestIdentifier || !qtiTestReferencesItem(ownerTest, item.identifier) || ownerTest.metadata?.ownerSystem !== PLAYCADEMY_QTI_OWNER_SYSTEM) {
|
|
124316
|
+
return;
|
|
124317
|
+
}
|
|
124318
|
+
return nonEmptyMetadataString(ownerTest.metadata.ownerGameSlug);
|
|
124213
124319
|
}
|
|
124214
|
-
function
|
|
124215
|
-
|
|
124216
|
-
|
|
124320
|
+
function isQtiQuestionOwnedByGame(item, gameSlug, ownerTest) {
|
|
124321
|
+
return resolveQtiQuestionOwnerGameSlug(item, ownerTest) === gameSlug;
|
|
124322
|
+
}
|
|
124323
|
+
function isQtiQuestionOwnedByContext(item, ownership, ownerTest) {
|
|
124324
|
+
const declaredOwnerTest = qtiQuestionOwnerTestIdentifier(item);
|
|
124325
|
+
if (declaredOwnerTest) {
|
|
124326
|
+
return declaredOwnerTest === ownership.testIdentifier && isQtiQuestionOwnedByGame(item, ownership.gameSlug, ownerTest);
|
|
124217
124327
|
}
|
|
124328
|
+
return isQtiItemOwnedByTest(item, ownership.testIdentifier);
|
|
124218
124329
|
}
|
|
124219
|
-
function
|
|
124220
|
-
if (
|
|
124221
|
-
|
|
124330
|
+
function normalizedQtiInteractionType(value) {
|
|
124331
|
+
if (typeof value !== "string") {
|
|
124332
|
+
return;
|
|
124222
124333
|
}
|
|
124334
|
+
const normalized = value.trim().toLowerCase().replaceAll("_", "-");
|
|
124335
|
+
return normalized || undefined;
|
|
124223
124336
|
}
|
|
124224
|
-
function
|
|
124225
|
-
|
|
124226
|
-
|
|
124337
|
+
function authoringDocumentItem(value) {
|
|
124338
|
+
const document2 = recordValue(value);
|
|
124339
|
+
if (!document2) {
|
|
124340
|
+
throw new ValidationError("Authored question data is invalid");
|
|
124227
124341
|
}
|
|
124228
|
-
if (
|
|
124229
|
-
|
|
124342
|
+
if (document2.format !== "playcademy-question" || document2.version !== 1) {
|
|
124343
|
+
throw new ValidationError("Authored questions require a version 1 playcademy-question document");
|
|
124230
124344
|
}
|
|
124231
|
-
|
|
124345
|
+
const item = recordValue(document2.item);
|
|
124346
|
+
if (!item || typeof item.title !== "string" || !Array.isArray(item.body) || !Array.isArray(item.responseDeclarations)) {
|
|
124347
|
+
throw new ValidationError("An authored question needs an item with a title, a body, and response declarations");
|
|
124348
|
+
}
|
|
124349
|
+
const declarations = item.responseDeclarations.map(recordValue);
|
|
124350
|
+
const declaredIdentifiers = declarations.map((declaration) => typeof declaration?.identifier === "string" ? declaration.identifier : "");
|
|
124351
|
+
if (declarations.length === 0) {
|
|
124352
|
+
throw new ValidationError("An authored question requires at least one response declaration");
|
|
124353
|
+
}
|
|
124354
|
+
const declaredIdentifierSet = new Set(declaredIdentifiers);
|
|
124355
|
+
if (declaredIdentifiers.some((identifier) => !identifier) || declaredIdentifierSet.size !== declaredIdentifiers.length) {
|
|
124356
|
+
throw new ValidationError("Response declaration identifiers must be unique");
|
|
124357
|
+
}
|
|
124358
|
+
const projection = projectQtiAuthoringBody(item.body);
|
|
124359
|
+
if (projection.nestedInteractions.length > 0) {
|
|
124360
|
+
throw new ValidationError("Interactions nested inside another interaction cannot be compiled");
|
|
124361
|
+
}
|
|
124362
|
+
if (projection.interactions.length !== declarations.length) {
|
|
124363
|
+
throw new ValidationError("Every response declaration must belong to exactly one interaction");
|
|
124364
|
+
}
|
|
124365
|
+
const interactionIdentifiers = projection.interactions.map((interaction) => interaction.responseIdentifier);
|
|
124366
|
+
const interactionIdentifierSet = new Set(interactionIdentifiers);
|
|
124367
|
+
if (interactionIdentifierSet.size !== interactionIdentifiers.length) {
|
|
124368
|
+
throw new ValidationError("Every interaction needs its own response identifier");
|
|
124369
|
+
}
|
|
124370
|
+
if (interactionIdentifiers.some((identifier) => !declaredIdentifierSet.has(identifier)) || declaredIdentifiers.some((identifier) => !interactionIdentifierSet.has(identifier))) {
|
|
124371
|
+
throw new ValidationError("Every interaction must declare exactly one response");
|
|
124372
|
+
}
|
|
124373
|
+
const unauthorable = projection.interactions.find((interaction) => !qtiAuthoringInteractionType(interaction));
|
|
124374
|
+
if (unauthorable) {
|
|
124375
|
+
throw new ValidationError(`Interaction type “${String(unauthorable.type)}” cannot be authored by Playcademy yet`);
|
|
124376
|
+
}
|
|
124377
|
+
const inline = projection.interactions.filter(isInlineQtiAuthoringInteraction);
|
|
124378
|
+
if (projection.interactions.length - inline.length > 1) {
|
|
124379
|
+
throw new ValidationError("A question supports any number of inline interactions but at most one block interaction");
|
|
124380
|
+
}
|
|
124381
|
+
if (item.body.some((node) => node.kind === "interaction" && isInlineQtiAuthoringInteraction(node))) {
|
|
124382
|
+
throw new ValidationError("Every inline interaction must sit at its blank inside the question’s prose");
|
|
124383
|
+
}
|
|
124384
|
+
const blanks = inline.map((interaction) => projection.blankIndexes.get(interaction));
|
|
124385
|
+
if (blanks.some((blank) => blank === undefined) || new Set(blanks).size !== blanks.length) {
|
|
124386
|
+
throw new ValidationError("Every inline interaction needs its own blank position");
|
|
124387
|
+
}
|
|
124388
|
+
return item;
|
|
124232
124389
|
}
|
|
124233
|
-
function
|
|
124234
|
-
|
|
124235
|
-
|
|
124236
|
-
|
|
124237
|
-
|
|
124238
|
-
|
|
124239
|
-
|
|
124390
|
+
function parsedQtiItemOrNull(rawXml) {
|
|
124391
|
+
if (typeof rawXml !== "string" || !rawXml) {
|
|
124392
|
+
return null;
|
|
124393
|
+
}
|
|
124394
|
+
try {
|
|
124395
|
+
return parseQtiItemXml(rawXml);
|
|
124396
|
+
} catch {
|
|
124397
|
+
return null;
|
|
124398
|
+
}
|
|
124399
|
+
}
|
|
124400
|
+
function isAuthorableMultiInteractionQtiItem(question) {
|
|
124401
|
+
const item = parsedQtiItemOrNull(question.rawXml);
|
|
124402
|
+
return item !== null && (authorableQtiInteractions(item)?.length ?? 0) > 1;
|
|
124403
|
+
}
|
|
124404
|
+
function assertPlayableQtiQuestion(question) {
|
|
124405
|
+
if (!question.rawXml) {
|
|
124406
|
+
assertSupportedQtiQuestionInteraction(question);
|
|
124407
|
+
return;
|
|
124408
|
+
}
|
|
124409
|
+
const item = parsedQtiItemOrNull(question.rawXml);
|
|
124410
|
+
const playable = item && playableQtiInteractions(item);
|
|
124411
|
+
if (!playable || qtiItemScoringValidationMessage(item)) {
|
|
124412
|
+
throw new ValidationError(`Question ${question.identifier} uses an interaction Playcademy cannot play or score.`);
|
|
124413
|
+
}
|
|
124414
|
+
}
|
|
124415
|
+
function assertSupportedQtiQuestionInteraction(question) {
|
|
124416
|
+
const candidate = recordValue(question);
|
|
124417
|
+
if (candidate?.type === AUTHORING_DOCUMENT_QUESTION_TYPE) {
|
|
124418
|
+
authoringDocumentItem(candidate.document);
|
|
124419
|
+
return;
|
|
124420
|
+
}
|
|
124421
|
+
if (!supportedQtiQuestionInteractionType(question) && !isAuthorableMultiInteractionQtiItem(question)) {
|
|
124422
|
+
throw new ValidationError("This QTI interaction type is not supported by the Playcademy question editor.");
|
|
124423
|
+
}
|
|
124424
|
+
}
|
|
124425
|
+
function invalidQtiCopyXml() {
|
|
124426
|
+
throw new ValidationError("The source question does not contain valid QTI item XML");
|
|
124427
|
+
}
|
|
124428
|
+
function markupEnd2(xml, start2, allowInternalSubset) {
|
|
124429
|
+
let quote = null;
|
|
124430
|
+
let subsetDepth = 0;
|
|
124431
|
+
for (let index2 = start2;index2 < xml.length; index2 += 1) {
|
|
124432
|
+
const character = xml[index2];
|
|
124433
|
+
if (quote) {
|
|
124434
|
+
if (character === quote) {
|
|
124435
|
+
quote = null;
|
|
124436
|
+
}
|
|
124437
|
+
} else if (character === '"' || character === "'") {
|
|
124438
|
+
quote = character;
|
|
124439
|
+
} else if (allowInternalSubset && character === "[") {
|
|
124440
|
+
subsetDepth += 1;
|
|
124441
|
+
} else if (allowInternalSubset && character === "]") {
|
|
124442
|
+
subsetDepth = Math.max(0, subsetDepth - 1);
|
|
124443
|
+
} else if (character === ">" && subsetDepth === 0) {
|
|
124444
|
+
return index2;
|
|
124240
124445
|
}
|
|
124241
|
-
|
|
124242
|
-
|
|
124446
|
+
}
|
|
124447
|
+
return invalidQtiCopyXml();
|
|
124448
|
+
}
|
|
124449
|
+
function skipXmlWhitespace(xml, start2, end = xml.length) {
|
|
124450
|
+
let cursor2 = start2;
|
|
124451
|
+
while (cursor2 < end && /\s/.test(xml[cursor2] ?? "")) {
|
|
124452
|
+
cursor2 += 1;
|
|
124453
|
+
}
|
|
124454
|
+
return cursor2;
|
|
124455
|
+
}
|
|
124456
|
+
function xmlPreambleEnd(xml, cursor2) {
|
|
124457
|
+
if (xml.startsWith("<?", cursor2)) {
|
|
124458
|
+
const end = xml.indexOf("?>", cursor2 + 2);
|
|
124459
|
+
if (end === -1) {
|
|
124460
|
+
return invalidQtiCopyXml();
|
|
124243
124461
|
}
|
|
124462
|
+
return end + 2;
|
|
124244
124463
|
}
|
|
124245
|
-
if (
|
|
124246
|
-
|
|
124247
|
-
|
|
124464
|
+
if (xml.startsWith("<!--", cursor2)) {
|
|
124465
|
+
const end = xml.indexOf("-->", cursor2 + 4);
|
|
124466
|
+
if (end === -1) {
|
|
124467
|
+
return invalidQtiCopyXml();
|
|
124468
|
+
}
|
|
124469
|
+
return end + 3;
|
|
124248
124470
|
}
|
|
124249
|
-
if (
|
|
124250
|
-
|
|
124251
|
-
|
|
124252
|
-
|
|
124471
|
+
if (/^<!DOCTYPE\b/i.test(xml.slice(cursor2))) {
|
|
124472
|
+
return markupEnd2(xml, cursor2 + 2, true) + 1;
|
|
124473
|
+
}
|
|
124474
|
+
return null;
|
|
124475
|
+
}
|
|
124476
|
+
function qtiRootAttributes(xml, start2, end) {
|
|
124477
|
+
const attributes2 = [];
|
|
124478
|
+
let cursor2 = start2;
|
|
124479
|
+
while (cursor2 < end) {
|
|
124480
|
+
cursor2 = skipXmlWhitespace(xml, cursor2, end);
|
|
124481
|
+
if (xml[cursor2] === "/") {
|
|
124482
|
+
cursor2 += 1;
|
|
124483
|
+
} else if (cursor2 < end) {
|
|
124484
|
+
const nameMatch = /^[A-Za-z_][\w.:-]*/.exec(xml.slice(cursor2, end));
|
|
124485
|
+
if (!nameMatch) {
|
|
124486
|
+
return invalidQtiCopyXml();
|
|
124487
|
+
}
|
|
124488
|
+
const name3 = nameMatch[0];
|
|
124489
|
+
cursor2 = skipXmlWhitespace(xml, cursor2 + name3.length, end);
|
|
124490
|
+
if (xml[cursor2] !== "=") {
|
|
124491
|
+
return invalidQtiCopyXml();
|
|
124492
|
+
}
|
|
124493
|
+
cursor2 = skipXmlWhitespace(xml, cursor2 + 1, end);
|
|
124494
|
+
const quote = xml[cursor2];
|
|
124495
|
+
if (quote !== '"' && quote !== "'") {
|
|
124496
|
+
return invalidQtiCopyXml();
|
|
124497
|
+
}
|
|
124498
|
+
const valueStart = cursor2 + 1;
|
|
124499
|
+
const valueEnd = xml.indexOf(quote, valueStart);
|
|
124500
|
+
if (valueEnd === -1 || valueEnd > end) {
|
|
124501
|
+
return invalidQtiCopyXml();
|
|
124502
|
+
}
|
|
124503
|
+
attributes2.push({ name: name3, quote, valueStart, valueEnd });
|
|
124504
|
+
cursor2 = valueEnd + 1;
|
|
124253
124505
|
}
|
|
124254
124506
|
}
|
|
124255
|
-
return
|
|
124507
|
+
return attributes2;
|
|
124256
124508
|
}
|
|
124257
|
-
function
|
|
124258
|
-
|
|
124509
|
+
function qtiItemStartTag(xml) {
|
|
124510
|
+
let cursor2 = xml.charCodeAt(0) === 65279 ? 1 : 0;
|
|
124511
|
+
while (cursor2 < xml.length) {
|
|
124512
|
+
cursor2 = skipXmlWhitespace(xml, cursor2);
|
|
124513
|
+
const preambleEnd = xmlPreambleEnd(xml, cursor2);
|
|
124514
|
+
if (preambleEnd !== null) {
|
|
124515
|
+
cursor2 = preambleEnd;
|
|
124516
|
+
} else {
|
|
124517
|
+
const root = /^<([A-Za-z_][\w.:-]*)/.exec(xml.slice(cursor2));
|
|
124518
|
+
if (!root || root[1].split(":").at(-1) !== "qti-assessment-item") {
|
|
124519
|
+
return invalidQtiCopyXml();
|
|
124520
|
+
}
|
|
124521
|
+
const attributesStart = cursor2 + root[0].length;
|
|
124522
|
+
const end = markupEnd2(xml, attributesStart, false);
|
|
124523
|
+
const attributes2 = qtiRootAttributes(xml, attributesStart, end);
|
|
124524
|
+
let insertionPoint = skipXmlWhitespaceBackward(xml, end);
|
|
124525
|
+
if (xml[insertionPoint - 1] === "/") {
|
|
124526
|
+
insertionPoint -= 1;
|
|
124527
|
+
}
|
|
124528
|
+
return { attributes: attributes2, insertionPoint };
|
|
124529
|
+
}
|
|
124530
|
+
}
|
|
124531
|
+
return invalidQtiCopyXml();
|
|
124259
124532
|
}
|
|
124260
|
-
function
|
|
124261
|
-
|
|
124262
|
-
|
|
124533
|
+
function skipXmlWhitespaceBackward(xml, start2) {
|
|
124534
|
+
let cursor2 = start2;
|
|
124535
|
+
while (/\s/.test(xml[cursor2 - 1] ?? "")) {
|
|
124536
|
+
cursor2 -= 1;
|
|
124263
124537
|
}
|
|
124538
|
+
return cursor2;
|
|
124264
124539
|
}
|
|
124265
|
-
function
|
|
124266
|
-
|
|
124267
|
-
|
|
124540
|
+
function escapeXmlAttribute(value, quote) {
|
|
124541
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(quote, quote === '"' ? """ : "'");
|
|
124542
|
+
}
|
|
124543
|
+
function rewriteQtiItemIdentity(xml, identifier, title) {
|
|
124544
|
+
if (!xml.trim() || !identifier.trim() || !title.trim()) {
|
|
124545
|
+
return invalidQtiCopyXml();
|
|
124268
124546
|
}
|
|
124269
|
-
|
|
124547
|
+
const root = qtiItemStartTag(xml);
|
|
124548
|
+
const replacements = [];
|
|
124549
|
+
const targetAttributes = new Map([
|
|
124550
|
+
["identifier", identifier],
|
|
124551
|
+
["title", title]
|
|
124552
|
+
]);
|
|
124553
|
+
for (const [name3, value] of targetAttributes) {
|
|
124554
|
+
const matches = root.attributes.filter((attribute2) => attribute2.name === name3);
|
|
124555
|
+
if (matches.length > 1) {
|
|
124556
|
+
return invalidQtiCopyXml();
|
|
124557
|
+
}
|
|
124558
|
+
const attribute = matches[0];
|
|
124559
|
+
if (attribute) {
|
|
124560
|
+
replacements.push({
|
|
124561
|
+
start: attribute.valueStart,
|
|
124562
|
+
end: attribute.valueEnd,
|
|
124563
|
+
value: escapeXmlAttribute(value, attribute.quote)
|
|
124564
|
+
});
|
|
124565
|
+
} else {
|
|
124566
|
+
replacements.push({
|
|
124567
|
+
start: root.insertionPoint,
|
|
124568
|
+
end: root.insertionPoint,
|
|
124569
|
+
value: ` ${name3}="${escapeXmlAttribute(value, '"')}"`
|
|
124570
|
+
});
|
|
124571
|
+
}
|
|
124572
|
+
}
|
|
124573
|
+
return replacements.toSorted((left, right) => right.start - left.start).reduce((rewritten, replacement) => `${rewritten.slice(0, replacement.start)}${replacement.value}${rewritten.slice(replacement.end)}`, xml);
|
|
124270
124574
|
}
|
|
124271
|
-
function
|
|
124272
|
-
|
|
124575
|
+
function buildQtiQuestionCopyInput(input) {
|
|
124576
|
+
assertPlayableQtiQuestion(input.source);
|
|
124577
|
+
const title = input.source.title?.trim() || input.source.identifier;
|
|
124578
|
+
if (!input.source.rawXml) {
|
|
124579
|
+
return invalidQtiCopyXml();
|
|
124580
|
+
}
|
|
124581
|
+
const sourceMetadata = { ...input.source.metadata };
|
|
124582
|
+
if (sourceMetadata.ownerSystem !== PLAYCADEMY_QTI_OWNER_SYSTEM) {
|
|
124583
|
+
Reflect.deleteProperty(sourceMetadata, PLAYCADEMY_QTI_AUTHORING_METADATA_KEY);
|
|
124584
|
+
}
|
|
124585
|
+
const metadata2 = buildOwnedQtiQuestionMetadata({
|
|
124586
|
+
metadata: {
|
|
124587
|
+
copiedFromItemIdentifier: input.source.identifier,
|
|
124588
|
+
integrationId: input.integrationId
|
|
124589
|
+
}
|
|
124590
|
+
}, {
|
|
124591
|
+
gameSlug: input.gameSlug,
|
|
124592
|
+
testIdentifier: input.targetTestIdentifier
|
|
124593
|
+
}, sourceMetadata);
|
|
124594
|
+
return {
|
|
124595
|
+
identifier: input.targetIdentifier,
|
|
124596
|
+
title,
|
|
124597
|
+
xml: rewriteQtiItemIdentity(input.source.rawXml, input.targetIdentifier, title),
|
|
124598
|
+
metadata: metadata2
|
|
124599
|
+
};
|
|
124273
124600
|
}
|
|
124274
|
-
function
|
|
124275
|
-
|
|
124276
|
-
|
|
124277
|
-
throw new ValidationError(`Assessment order must include every live ${purpose} assessment exactly once`);
|
|
124601
|
+
function assertQtiQuestionEditable(item, ownership, ownerTest) {
|
|
124602
|
+
if (!isQtiQuestionOwnedByContext(item, ownership, ownerTest)) {
|
|
124603
|
+
throw new ValidationError("Shared question references are read-only. Copy the question to create an editable independent item.");
|
|
124278
124604
|
}
|
|
124279
|
-
return testIdentifiers.map((identifier) => rowsByIdentifier.get(identifier));
|
|
124280
124605
|
}
|
|
124281
|
-
|
|
124606
|
+
function buildNumericQuestionXml(input, identifier, title) {
|
|
124607
|
+
if (input.format === "xml") {
|
|
124608
|
+
throw new ValidationError("Raw question XML is not accepted at this API boundary");
|
|
124609
|
+
}
|
|
124610
|
+
const candidate = input.numericTextEntry;
|
|
124611
|
+
if (candidate === undefined) {
|
|
124612
|
+
return null;
|
|
124613
|
+
}
|
|
124614
|
+
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) {
|
|
124615
|
+
throw new ValidationError("Numeric text-entry data is invalid");
|
|
124616
|
+
}
|
|
124617
|
+
const prompt = "prompt" in candidate ? candidate.prompt : undefined;
|
|
124618
|
+
const numeric3 = parseNumericTextEntry({
|
|
124619
|
+
baseType: "baseType" in candidate ? candidate.baseType : undefined,
|
|
124620
|
+
answer: "answer" in candidate ? candidate.answer : undefined,
|
|
124621
|
+
comparison: "comparison" in candidate ? candidate.comparison : undefined
|
|
124622
|
+
});
|
|
124623
|
+
if (!numeric3.success) {
|
|
124624
|
+
throw new ValidationError(numeric3.message);
|
|
124625
|
+
}
|
|
124626
|
+
if (!title || typeof prompt !== "string" || !prompt.trim()) {
|
|
124627
|
+
throw new ValidationError("Numeric questions require a title and prompt");
|
|
124628
|
+
}
|
|
124629
|
+
return numericTextEntryXml({ identifier, title, prompt: prompt.trim(), numeric: numeric3.value });
|
|
124630
|
+
}
|
|
124631
|
+
function buildExactMatchQuestionXml(input) {
|
|
124632
|
+
const responseIdentifier = escapeXml(input.responseIdentifier);
|
|
124633
|
+
const correctValues = input.correctIdentifiers.map((identifier) => ` <qti-value>${escapeXml(identifier)}</qti-value>`).join(`
|
|
124634
|
+
`);
|
|
124635
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
124636
|
+
<qti-assessment-item xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0" identifier="${escapeXml(input.identifier)}" title="${escapeXml(input.title)}" adaptive="false" time-dependent="false">
|
|
124637
|
+
<qti-response-declaration identifier="${responseIdentifier}" cardinality="${input.cardinality}" base-type="identifier">
|
|
124638
|
+
<qti-correct-response>
|
|
124639
|
+
${correctValues}
|
|
124640
|
+
</qti-correct-response>
|
|
124641
|
+
</qti-response-declaration>
|
|
124642
|
+
<qti-outcome-declaration identifier="FEEDBACK" cardinality="single" base-type="identifier" />
|
|
124643
|
+
<qti-outcome-declaration identifier="SCORE" cardinality="single" base-type="float">
|
|
124644
|
+
<qti-default-value><qti-value>0</qti-value></qti-default-value>
|
|
124645
|
+
</qti-outcome-declaration>
|
|
124646
|
+
<qti-item-body>
|
|
124647
|
+
${input.itemBody}
|
|
124648
|
+
</qti-item-body>
|
|
124649
|
+
<qti-response-processing>
|
|
124650
|
+
<qti-response-condition>
|
|
124651
|
+
<qti-response-if>
|
|
124652
|
+
<qti-match><qti-variable identifier="${responseIdentifier}" /><qti-correct identifier="${responseIdentifier}" /></qti-match>
|
|
124653
|
+
<qti-set-outcome-value identifier="FEEDBACK"><qti-base-value base-type="identifier">CORRECT</qti-base-value></qti-set-outcome-value>
|
|
124654
|
+
<qti-set-outcome-value identifier="SCORE"><qti-base-value base-type="float">1</qti-base-value></qti-set-outcome-value>
|
|
124655
|
+
</qti-response-if>
|
|
124656
|
+
<qti-response-else>
|
|
124657
|
+
<qti-set-outcome-value identifier="FEEDBACK"><qti-base-value base-type="identifier">INCORRECT</qti-base-value></qti-set-outcome-value>
|
|
124658
|
+
<qti-set-outcome-value identifier="SCORE"><qti-base-value base-type="float">0</qti-base-value></qti-set-outcome-value>
|
|
124659
|
+
</qti-response-else>
|
|
124660
|
+
</qti-response-condition>
|
|
124661
|
+
</qti-response-processing>
|
|
124662
|
+
</qti-assessment-item>`;
|
|
124663
|
+
}
|
|
124664
|
+
function parseStructuredChoices(value, options) {
|
|
124665
|
+
const { label, minimum, validate: validate2 } = options;
|
|
124666
|
+
if (!Array.isArray(value) || value.length < minimum) {
|
|
124667
|
+
throw new ValidationError(`${label} questions require at least ${minimum === 1 ? "one choice" : `${minimum} choices`}`);
|
|
124668
|
+
}
|
|
124669
|
+
const choices = value.map((choice) => {
|
|
124670
|
+
const record3 = recordValue(choice);
|
|
124671
|
+
const identifier = record3?.identifier;
|
|
124672
|
+
const content = record3?.content;
|
|
124673
|
+
if (typeof identifier !== "string" || !identifier.trim() || typeof content !== "string" || !content.trim() || validate2 !== undefined && record3 !== undefined && !validate2(record3)) {
|
|
124674
|
+
throw new ValidationError(`${label} options are invalid`);
|
|
124675
|
+
}
|
|
124676
|
+
return { identifier: identifier.trim(), content: content.trim() };
|
|
124677
|
+
});
|
|
124678
|
+
if (new Set(choices.map((choice) => choice.identifier)).size !== choices.length) {
|
|
124679
|
+
throw new ValidationError(`${label} option identifiers must be unique`);
|
|
124680
|
+
}
|
|
124681
|
+
return choices;
|
|
124682
|
+
}
|
|
124683
|
+
function declaredCorrectResponse(input, responseIdentifier) {
|
|
124684
|
+
const declarations = Array.isArray(input.responseDeclarations) ? input.responseDeclarations : [];
|
|
124685
|
+
const declaration = declarations.map(recordValue).find((candidate) => candidate?.identifier === responseIdentifier);
|
|
124686
|
+
const rawValues = recordValue(declaration?.correctResponse)?.value;
|
|
124687
|
+
return {
|
|
124688
|
+
cardinality: declaration?.cardinality,
|
|
124689
|
+
baseType: declaration?.baseType,
|
|
124690
|
+
values: Array.isArray(rawValues) ? rawValues.filter((value) => typeof value === "string") : []
|
|
124691
|
+
};
|
|
124692
|
+
}
|
|
124693
|
+
function inlineChoiceCorrectIdentifier(input, responseIdentifier, choiceIdentifiers) {
|
|
124694
|
+
const { cardinality, baseType, values } = declaredCorrectResponse(input, responseIdentifier);
|
|
124695
|
+
if (cardinality !== "single" || baseType !== "identifier") {
|
|
124696
|
+
throw new ValidationError("Inline-choice questions require a single-identifier response declaration");
|
|
124697
|
+
}
|
|
124698
|
+
if (values.length !== 1 || !choiceIdentifiers.has(values[0])) {
|
|
124699
|
+
throw new ValidationError("Inline-choice questions require one valid correct option");
|
|
124700
|
+
}
|
|
124701
|
+
return values[0];
|
|
124702
|
+
}
|
|
124703
|
+
function structuredQuestionEnvelope(input, type, label, title) {
|
|
124704
|
+
const interaction = recordValue(input.interaction);
|
|
124705
|
+
if (normalizedQtiInteractionType(interaction?.type ?? input.type) !== type) {
|
|
124706
|
+
return null;
|
|
124707
|
+
}
|
|
124708
|
+
const structure = recordValue(interaction?.questionStructure);
|
|
124709
|
+
const responseIdentifier = interaction?.responseIdentifier;
|
|
124710
|
+
const prompt = structure?.prompt;
|
|
124711
|
+
if (!interaction || !structure || typeof responseIdentifier !== "string" || !responseIdentifier.trim()) {
|
|
124712
|
+
throw new ValidationError(`${label} question data is invalid`);
|
|
124713
|
+
}
|
|
124714
|
+
if (!title || typeof prompt !== "string" || !prompt.trim()) {
|
|
124715
|
+
throw new ValidationError(`${label} questions require a title and prompt`);
|
|
124716
|
+
}
|
|
124717
|
+
return {
|
|
124718
|
+
interaction,
|
|
124719
|
+
structure,
|
|
124720
|
+
responseIdentifier: responseIdentifier.trim(),
|
|
124721
|
+
prompt: prompt.trim()
|
|
124722
|
+
};
|
|
124723
|
+
}
|
|
124724
|
+
function buildInlineChoiceQuestionXml(input, identifier, title) {
|
|
124725
|
+
const envelope = structuredQuestionEnvelope(input, "inline-choice", "Inline-choice", title);
|
|
124726
|
+
if (!envelope) {
|
|
124727
|
+
return null;
|
|
124728
|
+
}
|
|
124729
|
+
const { structure, responseIdentifier, prompt } = envelope;
|
|
124730
|
+
const promptParts = prompt.split(INLINE_CHOICE_BLANK);
|
|
124731
|
+
if (promptParts.length !== 2) {
|
|
124732
|
+
throw new ValidationError("Inline-choice questions require exactly one blank");
|
|
124733
|
+
}
|
|
124734
|
+
const choices = parseStructuredChoices(structure.inlineChoices, {
|
|
124735
|
+
label: "Inline-choice",
|
|
124736
|
+
minimum: 2
|
|
124737
|
+
});
|
|
124738
|
+
const choiceIdentifiers = new Set(choices.map((choice) => choice.identifier));
|
|
124739
|
+
const correctIdentifier = inlineChoiceCorrectIdentifier(input, responseIdentifier, choiceIdentifiers);
|
|
124740
|
+
const choicesXml = choices.map((choice) => ` <qti-inline-choice identifier="${escapeXml(choice.identifier)}">${escapeXml(choice.content)}</qti-inline-choice>`).join(`
|
|
124741
|
+
`);
|
|
124742
|
+
const [before = "", after = ""] = promptParts;
|
|
124743
|
+
const itemBody = ` <p>${escapeXml(before)}<qti-inline-choice-interaction response-identifier="${escapeXml(responseIdentifier)}">
|
|
124744
|
+
${choicesXml}
|
|
124745
|
+
</qti-inline-choice-interaction>${escapeXml(after)}</p>`;
|
|
124746
|
+
return buildExactMatchQuestionXml({
|
|
124747
|
+
identifier,
|
|
124748
|
+
title,
|
|
124749
|
+
responseIdentifier,
|
|
124750
|
+
cardinality: "single",
|
|
124751
|
+
correctIdentifiers: [correctIdentifier],
|
|
124752
|
+
itemBody
|
|
124753
|
+
});
|
|
124754
|
+
}
|
|
124755
|
+
function parseStructuredMatchChoices(value, label) {
|
|
124756
|
+
return parseStructuredChoices(value, {
|
|
124757
|
+
label: `Match ${label}`,
|
|
124758
|
+
minimum: 1,
|
|
124759
|
+
validate: (choice) => choice.matchMax === 1
|
|
124760
|
+
});
|
|
124761
|
+
}
|
|
124762
|
+
function structuredMatchCorrectPairs(input, responseIdentifier, sources, targets) {
|
|
124763
|
+
const { cardinality, baseType, values } = declaredCorrectResponse(input, responseIdentifier);
|
|
124764
|
+
if (cardinality !== "multiple" || baseType !== "directedPair") {
|
|
124765
|
+
throw new ValidationError("Match questions require a multiple directed-pair response declaration");
|
|
124766
|
+
}
|
|
124767
|
+
const sourceIdentifiers = new Set(sources.map((choice) => choice.identifier));
|
|
124768
|
+
const targetIdentifiers = new Set(targets.map((choice) => choice.identifier));
|
|
124769
|
+
const valid = values.length > 0 && new Set(values).size === values.length && values.every((value) => {
|
|
124770
|
+
const [source, target, extra] = value.split(" ");
|
|
124771
|
+
return extra === undefined && Boolean(source && target) && sourceIdentifiers.has(source) && targetIdentifiers.has(target);
|
|
124772
|
+
});
|
|
124773
|
+
if (!valid) {
|
|
124774
|
+
throw new ValidationError("Every Match correct response must name one declared source and target choice");
|
|
124775
|
+
}
|
|
124776
|
+
return values;
|
|
124777
|
+
}
|
|
124778
|
+
function buildMatchQuestionXml(input, identifier, title) {
|
|
124779
|
+
const envelope = structuredQuestionEnvelope(input, "match", "Match", title);
|
|
124780
|
+
if (!envelope) {
|
|
124781
|
+
return null;
|
|
124782
|
+
}
|
|
124783
|
+
const { interaction, structure, responseIdentifier, prompt } = envelope;
|
|
124784
|
+
const sources = parseStructuredMatchChoices(structure.sourceChoices, "source");
|
|
124785
|
+
const targets = parseStructuredMatchChoices(structure.targetChoices, "target");
|
|
124786
|
+
const sourceIdentifiers = new Set(sources.map((choice) => choice.identifier));
|
|
124787
|
+
if (targets.some((choice) => sourceIdentifiers.has(choice.identifier))) {
|
|
124788
|
+
throw new ValidationError("Match source and target choice identifiers must be unique");
|
|
124789
|
+
}
|
|
124790
|
+
const correctValues = structuredMatchCorrectPairs(input, responseIdentifier, sources, targets);
|
|
124791
|
+
const maxAssociations = interaction.maxAssociations;
|
|
124792
|
+
if (typeof maxAssociations !== "number" || !Number.isInteger(maxAssociations) || maxAssociations < 0 || maxAssociations !== 0 && maxAssociations < correctValues.length) {
|
|
124793
|
+
throw new ValidationError("Match max associations must fit every correct pair");
|
|
124794
|
+
}
|
|
124795
|
+
const item = {
|
|
124796
|
+
key: identifier,
|
|
124797
|
+
title,
|
|
124798
|
+
body: [
|
|
124799
|
+
{
|
|
124800
|
+
kind: "interaction",
|
|
124801
|
+
type: "match",
|
|
124802
|
+
responseIdentifier,
|
|
124803
|
+
attributes: {
|
|
124804
|
+
shuffle: interaction.shuffle === true,
|
|
124805
|
+
"max-associations": maxAssociations
|
|
124806
|
+
},
|
|
124807
|
+
content: [
|
|
124808
|
+
{
|
|
124809
|
+
kind: "element",
|
|
124810
|
+
name: "qti-prompt",
|
|
124811
|
+
children: [{ kind: "text", text: prompt }]
|
|
124812
|
+
}
|
|
124813
|
+
],
|
|
124814
|
+
choiceSets: [sources, targets].map((choices) => choices.map((choice) => ({
|
|
124815
|
+
identifier: choice.identifier,
|
|
124816
|
+
content: [{ kind: "text", text: choice.content }]
|
|
124817
|
+
})))
|
|
124818
|
+
}
|
|
124819
|
+
],
|
|
124820
|
+
responseDeclarations: [
|
|
124821
|
+
{
|
|
124822
|
+
identifier: responseIdentifier,
|
|
124823
|
+
cardinality: "multiple",
|
|
124824
|
+
baseType: "directedPair",
|
|
124825
|
+
correctValues
|
|
124826
|
+
}
|
|
124827
|
+
]
|
|
124828
|
+
};
|
|
124829
|
+
try {
|
|
124830
|
+
return compileQtiAuthoringItemXml(item, { identifier, title });
|
|
124831
|
+
} catch (error88) {
|
|
124832
|
+
throw new ValidationError(`This Match question cannot be compiled to QTI: ${errorMessage2(error88)}`);
|
|
124833
|
+
}
|
|
124834
|
+
}
|
|
124835
|
+
function parseHottextSegments2(value) {
|
|
124836
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
124837
|
+
throw new ValidationError("Hottext questions require passage segments");
|
|
124838
|
+
}
|
|
124839
|
+
const segments = value.map((segment) => {
|
|
124840
|
+
const record3 = recordValue(segment);
|
|
124841
|
+
const identifier = record3?.identifier;
|
|
124842
|
+
const content = record3?.content;
|
|
124843
|
+
const mode = record3?.mode;
|
|
124844
|
+
if (typeof identifier !== "string" || typeof content !== "string" || !content.trim() || !["plain", "option", "correct"].includes(String(mode))) {
|
|
124845
|
+
throw new ValidationError("Hottext passage segments are invalid");
|
|
124846
|
+
}
|
|
124847
|
+
return {
|
|
124848
|
+
identifier: identifier.trim(),
|
|
124849
|
+
content: content.trim(),
|
|
124850
|
+
mode
|
|
124851
|
+
};
|
|
124852
|
+
});
|
|
124853
|
+
const selectable = segments.filter((segment) => segment.mode !== "plain");
|
|
124854
|
+
const correct = selectable.filter((segment) => segment.mode === "correct");
|
|
124855
|
+
const identifiers = selectable.map((segment) => segment.identifier);
|
|
124856
|
+
if (selectable.length < 2) {
|
|
124857
|
+
throw new ValidationError("Hottext questions require at least two selectable phrases");
|
|
124858
|
+
}
|
|
124859
|
+
if (identifiers.some((identifier) => !identifier)) {
|
|
124860
|
+
throw new ValidationError("Hottext selectable phrases require identifiers");
|
|
124861
|
+
}
|
|
124862
|
+
if (new Set(identifiers).size !== identifiers.length) {
|
|
124863
|
+
throw new ValidationError("Hottext selectable phrase identifiers must be unique");
|
|
124864
|
+
}
|
|
124865
|
+
if (correct.length === 0) {
|
|
124866
|
+
throw new ValidationError("Hottext questions require a correct phrase");
|
|
124867
|
+
}
|
|
124868
|
+
return { segments, selectable, correct };
|
|
124869
|
+
}
|
|
124870
|
+
function buildHottextQuestionXml(input, identifier, title) {
|
|
124871
|
+
const candidate = input.hottext;
|
|
124872
|
+
if (candidate === undefined) {
|
|
124873
|
+
return null;
|
|
124874
|
+
}
|
|
124875
|
+
const hottext = recordValue(candidate);
|
|
124876
|
+
if (!hottext) {
|
|
124877
|
+
throw new ValidationError("Hottext question data is invalid");
|
|
124878
|
+
}
|
|
124879
|
+
const prompt = hottext.prompt;
|
|
124880
|
+
if (!title || typeof prompt !== "string" || !prompt.trim()) {
|
|
124881
|
+
throw new ValidationError("Hottext questions require a title and prompt");
|
|
124882
|
+
}
|
|
124883
|
+
const { segments, selectable, correct } = parseHottextSegments2(hottext.segments);
|
|
124884
|
+
const multiple = correct.length > 1;
|
|
124885
|
+
const maxChoices = hottextMaxChoices(selectable.length, multiple);
|
|
124886
|
+
const passage = segments.map((segment) => segment.mode === "plain" ? escapeXml(segment.content) : `<qti-hottext identifier="${escapeXml(segment.identifier)}">${escapeXml(segment.content)}</qti-hottext>`).join(" ");
|
|
124887
|
+
const itemBody = ` <qti-hottext-interaction response-identifier="RESPONSE" max-choices="${maxChoices}">
|
|
124888
|
+
<qti-prompt>${escapeXml(prompt.trim())}</qti-prompt>
|
|
124889
|
+
<p>${passage}</p>
|
|
124890
|
+
</qti-hottext-interaction>`;
|
|
124891
|
+
return buildExactMatchQuestionXml({
|
|
124892
|
+
identifier,
|
|
124893
|
+
title,
|
|
124894
|
+
responseIdentifier: "RESPONSE",
|
|
124895
|
+
cardinality: multiple ? "multiple" : "single",
|
|
124896
|
+
correctIdentifiers: correct.map((segment) => segment.identifier),
|
|
124897
|
+
itemBody
|
|
124898
|
+
});
|
|
124899
|
+
}
|
|
124900
|
+
function buildAuthoringDocumentQuestionXml(input, identifier, title) {
|
|
124901
|
+
if (input.type !== AUTHORING_DOCUMENT_QUESTION_TYPE) {
|
|
124902
|
+
return null;
|
|
124903
|
+
}
|
|
124904
|
+
const item = authoringDocumentItem(input.document);
|
|
124905
|
+
if (!title) {
|
|
124906
|
+
throw new ValidationError("Authored questions require a title");
|
|
124907
|
+
}
|
|
124908
|
+
try {
|
|
124909
|
+
return compileQtiAuthoringItemXml(item, { identifier, title });
|
|
124910
|
+
} catch (error88) {
|
|
124911
|
+
throw new ValidationError(`This question cannot be compiled to QTI: ${errorMessage2(error88)}`);
|
|
124912
|
+
}
|
|
124913
|
+
}
|
|
124914
|
+
function buildQuestionXml(input, identifier, title) {
|
|
124915
|
+
for (const build2 of QUESTION_XML_BUILDERS) {
|
|
124916
|
+
const xml = build2(input, identifier, title);
|
|
124917
|
+
if (xml !== null) {
|
|
124918
|
+
return xml;
|
|
124919
|
+
}
|
|
124920
|
+
}
|
|
124921
|
+
return null;
|
|
124922
|
+
}
|
|
124923
|
+
function buildCreatedQtiQuestionReference(input) {
|
|
124924
|
+
return {
|
|
124925
|
+
ownership: "owned",
|
|
124926
|
+
reference: {
|
|
124927
|
+
identifier: input.itemIdentifier,
|
|
124928
|
+
href: input.href,
|
|
124929
|
+
testPart: input.partIdentifier,
|
|
124930
|
+
section: input.sectionIdentifier
|
|
124931
|
+
},
|
|
124932
|
+
question: mergeQtiQuestionItem(input.item, input.fallbackItem, input.itemIdentifier, input.metadata)
|
|
124933
|
+
};
|
|
124934
|
+
}
|
|
124935
|
+
function buildHydratedQtiQuestionReference(input) {
|
|
124936
|
+
const identifier = input.reference.reference.identifier;
|
|
124937
|
+
const ownerGameSlug = resolveQtiQuestionOwnerGameSlug(input.item, input.ownerTest);
|
|
124938
|
+
const authoritativeItem = ownerGameSlug ? {
|
|
124939
|
+
...input.item,
|
|
124940
|
+
metadata: { ...input.item.metadata, ownerGameSlug }
|
|
124941
|
+
} : input.item;
|
|
124942
|
+
const question = mergeQtiQuestionItem(authoritativeItem, input.reference.question, identifier);
|
|
124943
|
+
return {
|
|
124944
|
+
...input.reference,
|
|
124945
|
+
ownership: isQtiQuestionOwnedByContext(question, { gameSlug: input.gameSlug, testIdentifier: input.testIdentifier }, input.ownerTest) ? "owned" : "shared",
|
|
124946
|
+
question
|
|
124947
|
+
};
|
|
124948
|
+
}
|
|
124949
|
+
function isQtiTestOwnedByGame(test, gameSlug) {
|
|
124950
|
+
return test.metadata?.ownerSystem === PLAYCADEMY_QTI_OWNER_SYSTEM && test.metadata.ownerGameSlug === gameSlug;
|
|
124951
|
+
}
|
|
124952
|
+
function assertQtiTestOwnedByGame(test, gameSlug) {
|
|
124953
|
+
if (!isQtiTestOwnedByGame(test, gameSlug)) {
|
|
124954
|
+
throw new ValidationError("Shared assessment references are read-only. Copy the assessment to create an editable independent test.");
|
|
124955
|
+
}
|
|
124956
|
+
}
|
|
124957
|
+
function qtiTestParts(test) {
|
|
124958
|
+
const parts2 = test["qti-test-part"];
|
|
124959
|
+
if (!Array.isArray(parts2) || parts2.length === 0) {
|
|
124960
|
+
throw new ValidationError(`Assessment ${test.identifier} has no test parts`);
|
|
124961
|
+
}
|
|
124962
|
+
for (const [partIndex, part] of parts2.entries()) {
|
|
124963
|
+
if (!part || typeof part !== "object" || Array.isArray(part)) {
|
|
124964
|
+
throw new ValidationError(`Assessment ${test.identifier} contains an invalid test part at position ${partIndex + 1}`);
|
|
124965
|
+
}
|
|
124966
|
+
const sections = part["qti-assessment-section"];
|
|
124967
|
+
if (!Array.isArray(sections)) {
|
|
124968
|
+
throw new ValidationError(`Assessment ${test.identifier} test part ${partIndex + 1} has no section list`);
|
|
124969
|
+
}
|
|
124970
|
+
}
|
|
124971
|
+
return parts2;
|
|
124972
|
+
}
|
|
124973
|
+
function qtiTestOptionalAttributes(test) {
|
|
124974
|
+
return {
|
|
124975
|
+
...test.qtiVersion ? { qtiVersion: test.qtiVersion } : {},
|
|
124976
|
+
...test.timeLimit !== undefined ? { timeLimit: test.timeLimit } : {},
|
|
124977
|
+
...test.maxAttempts !== undefined ? { maxAttempts: test.maxAttempts } : {},
|
|
124978
|
+
...test.toolsEnabled !== undefined ? { toolsEnabled: test.toolsEnabled } : {}
|
|
124979
|
+
};
|
|
124980
|
+
}
|
|
124981
|
+
function buildQtiAssessmentItemCopyPlan(test, targetTestIdentifier, hrefForIdentifier, createIdentifier = newPlaycademyQuestionIdentifier) {
|
|
124982
|
+
const sourceIdentifiers = [];
|
|
124983
|
+
const seen = new Set;
|
|
124984
|
+
for (const part of qtiTestParts(test)) {
|
|
124985
|
+
for (const section of part["qti-assessment-section"]) {
|
|
124986
|
+
for (const reference of section["qti-assessment-item-ref"] ?? []) {
|
|
124987
|
+
if (!seen.has(reference.identifier)) {
|
|
124988
|
+
seen.add(reference.identifier);
|
|
124989
|
+
sourceIdentifiers.push(reference.identifier);
|
|
124990
|
+
}
|
|
124991
|
+
}
|
|
124992
|
+
}
|
|
124993
|
+
}
|
|
124994
|
+
return sourceIdentifiers.map((sourceIdentifier) => {
|
|
124995
|
+
const targetIdentifier = createIdentifier(targetTestIdentifier);
|
|
124996
|
+
return {
|
|
124997
|
+
sourceIdentifier,
|
|
124998
|
+
targetIdentifier,
|
|
124999
|
+
href: hrefForIdentifier(targetIdentifier)
|
|
125000
|
+
};
|
|
125001
|
+
});
|
|
125002
|
+
}
|
|
125003
|
+
function buildQtiTestStructureInput(test, targetTestIdentifier, itemCopies) {
|
|
125004
|
+
const outcomeDeclarations = test["qti-outcome-declaration"];
|
|
125005
|
+
if (outcomeDeclarations !== undefined && !Array.isArray(outcomeDeclarations)) {
|
|
125006
|
+
throw new ValidationError(`Assessment ${test.identifier} has an invalid outcome declaration list`);
|
|
125007
|
+
}
|
|
125008
|
+
return {
|
|
125009
|
+
"qti-test-part": qtiTestParts(test).map((part, partIndex) => ({
|
|
125010
|
+
identifier: targetTestIdentifier ? `${targetTestIdentifier}-part${partIndex + 1}` : part.identifier,
|
|
125011
|
+
navigationMode: part.navigationMode,
|
|
125012
|
+
submissionMode: part.submissionMode,
|
|
125013
|
+
"qti-assessment-section": part["qti-assessment-section"].map((section, sectionIndex) => {
|
|
125014
|
+
let sectionIdentifier = section.identifier;
|
|
125015
|
+
if (targetTestIdentifier) {
|
|
125016
|
+
sectionIdentifier = partIndex === 0 && sectionIndex === 0 ? `${targetTestIdentifier}-section1` : `${targetTestIdentifier}-part${partIndex + 1}-section${sectionIndex + 1}`;
|
|
125017
|
+
}
|
|
125018
|
+
return {
|
|
125019
|
+
identifier: sectionIdentifier,
|
|
125020
|
+
title: section.title,
|
|
125021
|
+
visible: section.visible ?? true,
|
|
125022
|
+
...section.required !== undefined ? { required: section.required } : {},
|
|
125023
|
+
...section.fixed !== undefined ? { fixed: section.fixed } : {},
|
|
125024
|
+
sequence: section.sequence ?? sectionIndex + 1,
|
|
125025
|
+
...section["qti-assessment-item-ref"] ? {
|
|
125026
|
+
"qti-assessment-item-ref": section["qti-assessment-item-ref"].map((item, itemIndex) => {
|
|
125027
|
+
const copy = itemCopies?.get(item.identifier);
|
|
125028
|
+
if (itemCopies && !copy) {
|
|
125029
|
+
throw new ValidationError(`Assessment copy is missing question ${item.identifier}`);
|
|
125030
|
+
}
|
|
125031
|
+
return {
|
|
125032
|
+
identifier: copy?.targetIdentifier ?? item.identifier,
|
|
125033
|
+
href: copy?.href ?? item.href,
|
|
125034
|
+
sequence: item.sequence ?? itemIndex + 1
|
|
125035
|
+
};
|
|
125036
|
+
})
|
|
125037
|
+
} : {}
|
|
125038
|
+
};
|
|
125039
|
+
})
|
|
125040
|
+
})),
|
|
125041
|
+
...outcomeDeclarations ? {
|
|
125042
|
+
"qti-outcome-declaration": outcomeDeclarations.map((declaration) => ({
|
|
125043
|
+
identifier: declaration.identifier,
|
|
125044
|
+
...declaration.cardinality !== undefined ? { cardinality: declaration.cardinality } : {},
|
|
125045
|
+
baseType: declaration.baseType,
|
|
125046
|
+
...declaration.normalMaximum !== undefined ? { normalMaximum: declaration.normalMaximum } : {},
|
|
125047
|
+
...declaration.normalMinimum !== undefined ? { normalMinimum: declaration.normalMinimum } : {},
|
|
125048
|
+
...declaration.defaultValue ? {
|
|
125049
|
+
defaultValue: declaration.defaultValue.value !== undefined ? { value: declaration.defaultValue.value } : {}
|
|
125050
|
+
} : {}
|
|
125051
|
+
}))
|
|
125052
|
+
} : {}
|
|
125053
|
+
};
|
|
125054
|
+
}
|
|
125055
|
+
function buildQtiTestUpdateInput(test, title) {
|
|
125056
|
+
return {
|
|
125057
|
+
title,
|
|
125058
|
+
...qtiTestOptionalAttributes(test),
|
|
125059
|
+
...test.metadata ? { metadata: test.metadata } : {},
|
|
125060
|
+
...buildQtiTestStructureInput(test)
|
|
125061
|
+
};
|
|
125062
|
+
}
|
|
125063
|
+
function buildQtiTestCopyInput(source, targetTestIdentifier, metadata2, itemCopies) {
|
|
125064
|
+
const copiesBySourceIdentifier = new Map(itemCopies.map((copy) => [copy.sourceIdentifier, copy]));
|
|
125065
|
+
return {
|
|
125066
|
+
identifier: targetTestIdentifier,
|
|
125067
|
+
title: `${source.title} (copy)`,
|
|
125068
|
+
...qtiTestOptionalAttributes(source),
|
|
125069
|
+
metadata: metadata2,
|
|
125070
|
+
...buildQtiTestStructureInput(source, targetTestIdentifier, copiesBySourceIdentifier)
|
|
125071
|
+
};
|
|
125072
|
+
}
|
|
125073
|
+
function validateUniqueQuestionIdentifiers(itemIdentifiers) {
|
|
125074
|
+
if (itemIdentifiers.length === 0 || new Set(itemIdentifiers).size !== itemIdentifiers.length) {
|
|
125075
|
+
throw new ValidationError("Question order must contain unique question identifiers");
|
|
125076
|
+
}
|
|
125077
|
+
}
|
|
125078
|
+
function resolveQtiQuestionSection(test, qtiTestIdentifier, itemIdentifier, expectedItemIdentifiers) {
|
|
125079
|
+
let selectedPart;
|
|
125080
|
+
let selectedSection;
|
|
125081
|
+
for (const part of qtiTestParts(test)) {
|
|
125082
|
+
for (const section of part["qti-assessment-section"]) {
|
|
125083
|
+
if (!itemIdentifier || section["qti-assessment-item-ref"]?.some((item) => item.identifier === itemIdentifier)) {
|
|
125084
|
+
selectedPart = part;
|
|
125085
|
+
selectedSection = section;
|
|
125086
|
+
break;
|
|
125087
|
+
}
|
|
125088
|
+
}
|
|
125089
|
+
if (selectedSection) {
|
|
125090
|
+
break;
|
|
125091
|
+
}
|
|
125092
|
+
}
|
|
125093
|
+
if (!selectedPart || !selectedSection) {
|
|
125094
|
+
throw new ValidationError(itemIdentifier ? `Question ${itemIdentifier} is not referenced by assessment ${qtiTestIdentifier}` : `Assessment ${qtiTestIdentifier} has no section for questions`);
|
|
125095
|
+
}
|
|
125096
|
+
if (expectedItemIdentifiers) {
|
|
125097
|
+
const sectionIdentifiers = selectedSection["qti-assessment-item-ref"]?.map((item) => item.identifier) ?? [];
|
|
125098
|
+
const expectedIdentifiers = new Set(expectedItemIdentifiers);
|
|
125099
|
+
if (expectedIdentifiers.size !== sectionIdentifiers.length || sectionIdentifiers.some((identifier) => !expectedIdentifiers.has(identifier))) {
|
|
125100
|
+
throw new ValidationError("Questions can only be reordered within one complete assessment section");
|
|
125101
|
+
}
|
|
125102
|
+
}
|
|
125103
|
+
return {
|
|
125104
|
+
partIdentifier: selectedPart.identifier,
|
|
125105
|
+
sectionIdentifier: selectedSection.identifier
|
|
125106
|
+
};
|
|
125107
|
+
}
|
|
125108
|
+
var PLAYCADEMY_QTI_AUTHORING_METADATA_KEY = "playcademyAuthoring";
|
|
125109
|
+
var AUTHORING_DOCUMENT_QUESTION_TYPE = "authoring-document";
|
|
125110
|
+
var QTI_AUTHORING_FIELDS;
|
|
125111
|
+
var QUESTION_XML_BUILDERS;
|
|
125112
|
+
var init_timeback_qti_authoring_util = __esm(() => {
|
|
125113
|
+
init_qti();
|
|
125114
|
+
init_timeback3();
|
|
124282
125115
|
init_errors2();
|
|
125116
|
+
QTI_AUTHORING_FIELDS = [
|
|
125117
|
+
"type",
|
|
125118
|
+
"document",
|
|
125119
|
+
"qtiVersion",
|
|
125120
|
+
"timeDependent",
|
|
125121
|
+
"adaptive",
|
|
125122
|
+
"preInteraction",
|
|
125123
|
+
"interaction",
|
|
125124
|
+
"postInteraction",
|
|
125125
|
+
"responseDeclarations",
|
|
125126
|
+
"outcomeDeclarations",
|
|
125127
|
+
"responseProcessing",
|
|
125128
|
+
"modalFeedback",
|
|
125129
|
+
"feedbackInline",
|
|
125130
|
+
"feedbackBlock",
|
|
125131
|
+
"rubrics",
|
|
125132
|
+
"stimulus",
|
|
125133
|
+
"content"
|
|
125134
|
+
];
|
|
125135
|
+
QUESTION_XML_BUILDERS = [
|
|
125136
|
+
buildNumericQuestionXml,
|
|
125137
|
+
buildAuthoringDocumentQuestionXml,
|
|
125138
|
+
buildHottextQuestionXml,
|
|
125139
|
+
buildInlineChoiceQuestionXml,
|
|
125140
|
+
buildMatchQuestionXml
|
|
125141
|
+
];
|
|
124283
125142
|
});
|
|
124284
125143
|
function stageAssessmentAttemptSupersession(attempt) {
|
|
124285
125144
|
Object.assign(attempt.result, ASSESSMENT_ATTEMPT_SUPERSEDED);
|
|
@@ -124910,6 +125769,427 @@ var init_timeback_assessment_runtime_util = __esm(() => {
|
|
|
124910
125769
|
init_errors2();
|
|
124911
125770
|
PLAYABLE_SHAPE_SET = new Set(PLAYABLE_ASSESSMENT_SHAPES);
|
|
124912
125771
|
});
|
|
125772
|
+
function reviewMappingIssueSummary(label, identifiers) {
|
|
125773
|
+
if (identifiers.length === 0) {
|
|
125774
|
+
return null;
|
|
125775
|
+
}
|
|
125776
|
+
const displayed = identifiers.slice(0, 10).join(", ");
|
|
125777
|
+
const remainder = identifiers.length > 10 ? `, and ${identifiers.length - 10} more` : "";
|
|
125778
|
+
return `${label} (${identifiers.length}): ${displayed}${remainder}`;
|
|
125779
|
+
}
|
|
125780
|
+
async function prepareReviewMappingUpdate(test, questions) {
|
|
125781
|
+
const contentRevision = await assessmentContentRevision(test, questions);
|
|
125782
|
+
const assessment = {
|
|
125783
|
+
identifier: test.identifier,
|
|
125784
|
+
contractVersion: 1,
|
|
125785
|
+
contentRevision,
|
|
125786
|
+
title: test.title,
|
|
125787
|
+
items: questions.questions.map(({ question }) => ({
|
|
125788
|
+
identifier: question.identifier,
|
|
125789
|
+
title: question.title,
|
|
125790
|
+
prompt: "",
|
|
125791
|
+
maxScore: 1,
|
|
125792
|
+
interactions: []
|
|
125793
|
+
}))
|
|
125794
|
+
};
|
|
125795
|
+
const bank = await buildReviewBankIndex(assessment, questions, { customOnly: true });
|
|
125796
|
+
const issues = reviewBankMappingIssues(bank);
|
|
125797
|
+
if (issues.missingOrInvalidItemIdentifiers.length > 0 || issues.multipleStandardItemIdentifiers.length > 0) {
|
|
125798
|
+
const details = [
|
|
125799
|
+
reviewMappingIssueSummary("missing or invalid associations", issues.missingOrInvalidItemIdentifiers),
|
|
125800
|
+
reviewMappingIssueSummary("multiple associations", issues.multipleStandardItemIdentifiers)
|
|
125801
|
+
].filter((detail) => detail !== null);
|
|
125802
|
+
throw new ValidationError(`Every review question must have exactly one valid standard/node association. ${details.join("; ")}.`);
|
|
125803
|
+
}
|
|
125804
|
+
const manifest = await buildReviewBankManifest(bank);
|
|
125805
|
+
return {
|
|
125806
|
+
update: {
|
|
125807
|
+
...buildQtiTestUpdateInput(test, test.title),
|
|
125808
|
+
metadata: {
|
|
125809
|
+
...test.metadata,
|
|
125810
|
+
[PLAYCADEMY_REVIEW_BANK_MANIFEST_METADATA_KEY]: manifest
|
|
125811
|
+
}
|
|
125812
|
+
},
|
|
125813
|
+
summary: {
|
|
125814
|
+
itemCount: bank.items.length,
|
|
125815
|
+
standardCount: Object.keys(manifest.itemsByStandard).length,
|
|
125816
|
+
sourceFingerprint: manifest.sourceFingerprint
|
|
125817
|
+
}
|
|
125818
|
+
};
|
|
125819
|
+
}
|
|
125820
|
+
var init_timeback_review_mapping_util = __esm(() => {
|
|
125821
|
+
init_assessment_runtime2();
|
|
125822
|
+
init_errors2();
|
|
125823
|
+
init_timeback_assessment_runtime_util();
|
|
125824
|
+
init_timeback_qti_authoring_util();
|
|
125825
|
+
});
|
|
125826
|
+
function assessmentKeyFromManagedQtiIdentifier(identifier) {
|
|
125827
|
+
const assessmentKey = /^playcademy-test\.(.+)\.[a-f0-9]{64}$/.exec(identifier)?.[1];
|
|
125828
|
+
return assessmentKey && AssessmentKeySchema.safeParse(assessmentKey).success ? assessmentKey : null;
|
|
125829
|
+
}
|
|
125830
|
+
function assertManagedAssessmentIdentity(test, assessmentKey) {
|
|
125831
|
+
const managed = managedMetadataRecord(test.metadata, PLAYCADEMY_MANAGED_ASSESSMENT_METADATA_KEY);
|
|
125832
|
+
const sourceHash = managed?.sourceHash;
|
|
125833
|
+
const publicationHash = managed?.publicationHash;
|
|
125834
|
+
const hashPattern = /^[a-f0-9]{64}$/;
|
|
125835
|
+
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}`) {
|
|
125836
|
+
throw new ValidationError(`QTI assessment ${test.identifier} is not a managed publication for ${assessmentKey}`);
|
|
125837
|
+
}
|
|
125838
|
+
}
|
|
125839
|
+
function canonicalManagedAssetUrls(source) {
|
|
125840
|
+
return source.replace(MANAGED_ASSET_URL, `${CANONICAL_ASSESSMENT_ASSET_ORIGIN}/$1`);
|
|
125841
|
+
}
|
|
125842
|
+
function declarationNeutralQtiXml(source) {
|
|
125843
|
+
return source.replaceAll(`\r
|
|
125844
|
+
`, `
|
|
125845
|
+
`).replaceAll("\r", `
|
|
125846
|
+
`).replace(/^\s*<\?xml\b[^?]*\?>\s*/i, "");
|
|
125847
|
+
}
|
|
125848
|
+
function managedMetadataRecord(metadata2, key) {
|
|
125849
|
+
const managed = metadata2?.[key];
|
|
125850
|
+
return isRecord5(managed) ? managed : null;
|
|
125851
|
+
}
|
|
125852
|
+
function sourceMetadata(metadata2) {
|
|
125853
|
+
return Object.fromEntries(Object.entries(metadata2 ?? {}).filter(([key]) => !GENERATED_METADATA_KEYS.has(key)));
|
|
125854
|
+
}
|
|
125855
|
+
function normalizedQtiItemSource(item) {
|
|
125856
|
+
if (!item.rawXml?.trim()) {
|
|
125857
|
+
throw new ValidationError(`Question ${item.identifier} has no authoritative QTI XML`);
|
|
125858
|
+
}
|
|
125859
|
+
const title = item.title?.trim() || item.identifier;
|
|
125860
|
+
const declarationNeutralXml = declarationNeutralQtiXml(item.rawXml);
|
|
125861
|
+
const identityNeutralXml = rewriteQtiItemIdentity(declarationNeutralXml, "playcademy-item.content", title);
|
|
125862
|
+
return identityNeutralXml.replace(MANAGED_ASSET_URL, "playcademy-asset://$1").trim();
|
|
125863
|
+
}
|
|
125864
|
+
function testOptionalAttributes(test) {
|
|
125865
|
+
return {
|
|
125866
|
+
...test.qtiVersion ? { qtiVersion: test.qtiVersion } : {},
|
|
125867
|
+
...test.timeLimit !== undefined ? { timeLimit: test.timeLimit } : {},
|
|
125868
|
+
...test.maxAttempts !== undefined ? { maxAttempts: test.maxAttempts } : {},
|
|
125869
|
+
...test.toolsEnabled !== undefined ? { toolsEnabled: test.toolsEnabled } : {}
|
|
125870
|
+
};
|
|
125871
|
+
}
|
|
125872
|
+
function normalizedTestStructure(test, itemIdentity) {
|
|
125873
|
+
const structure = buildQtiTestStructureInput(test);
|
|
125874
|
+
return {
|
|
125875
|
+
"qti-test-part": structure["qti-test-part"].map((part) => ({
|
|
125876
|
+
navigationMode: part.navigationMode,
|
|
125877
|
+
submissionMode: part.submissionMode,
|
|
125878
|
+
"qti-assessment-section": part["qti-assessment-section"].map((section) => ({
|
|
125879
|
+
title: section.title,
|
|
125880
|
+
visible: section.visible,
|
|
125881
|
+
...section.required !== undefined ? { required: section.required } : {},
|
|
125882
|
+
...section.fixed !== undefined ? { fixed: section.fixed } : {},
|
|
125883
|
+
sequence: section.sequence,
|
|
125884
|
+
..."qti-assessment-item-ref" in section && section["qti-assessment-item-ref"] ? {
|
|
125885
|
+
"qti-assessment-item-ref": section["qti-assessment-item-ref"].map((item) => {
|
|
125886
|
+
const identity = itemIdentity.get(item.identifier);
|
|
125887
|
+
if (!identity) {
|
|
125888
|
+
throw new ValidationError(`Assessment ${test.identifier} references unavailable question ${item.identifier}`);
|
|
125889
|
+
}
|
|
125890
|
+
return { identity, sequence: item.sequence };
|
|
125891
|
+
})
|
|
125892
|
+
} : {}
|
|
125893
|
+
}))
|
|
125894
|
+
})),
|
|
125895
|
+
..."qti-outcome-declaration" in structure ? { "qti-outcome-declaration": structure["qti-outcome-declaration"] } : {}
|
|
125896
|
+
};
|
|
125897
|
+
}
|
|
125898
|
+
function remapDiagnosticRouting(manifest, itemIdentity) {
|
|
125899
|
+
if (!manifest) {
|
|
125900
|
+
return null;
|
|
125901
|
+
}
|
|
125902
|
+
return {
|
|
125903
|
+
...manifest,
|
|
125904
|
+
nodes: manifest.nodes.map((node) => {
|
|
125905
|
+
const itemIdentifier = itemIdentity.get(node.itemIdentifier);
|
|
125906
|
+
if (!itemIdentifier) {
|
|
125907
|
+
throw new ValidationError(`Diagnostic routing node ${node.key} references unavailable question ${node.itemIdentifier}`);
|
|
125908
|
+
}
|
|
125909
|
+
return { ...node, itemIdentifier };
|
|
125910
|
+
})
|
|
125911
|
+
};
|
|
125912
|
+
}
|
|
125913
|
+
function canonicalManagedAssessmentArtifact(test) {
|
|
125914
|
+
const structure = buildQtiTestStructureInput(test);
|
|
125915
|
+
const physicalItemIdentity = new Map;
|
|
125916
|
+
for (const part of structure["qti-test-part"]) {
|
|
125917
|
+
for (const section of part["qti-assessment-section"]) {
|
|
125918
|
+
for (const reference of section["qti-assessment-item-ref"] ?? []) {
|
|
125919
|
+
physicalItemIdentity.set(reference.identifier, reference.identifier);
|
|
125920
|
+
}
|
|
125921
|
+
}
|
|
125922
|
+
}
|
|
125923
|
+
return canonicalJson2({
|
|
125924
|
+
title: test.title,
|
|
125925
|
+
...testOptionalAttributes(test),
|
|
125926
|
+
metadata: sourceMetadata(test.metadata),
|
|
125927
|
+
structure: normalizedTestStructure(test, physicalItemIdentity)
|
|
125928
|
+
});
|
|
125929
|
+
}
|
|
125930
|
+
async function prepareManagedAssessmentPublication(input) {
|
|
125931
|
+
const parsedKey = AssessmentKeySchema.safeParse(input.assessmentKey);
|
|
125932
|
+
if (!parsedKey.success) {
|
|
125933
|
+
throw new ValidationError(parsedKey.error.issues[0]?.message ?? "Invalid assessment key");
|
|
125934
|
+
}
|
|
125935
|
+
const assessmentKey = parsedKey.data;
|
|
125936
|
+
const items = await Promise.all(input.questions.questions.map(async ({ question }) => {
|
|
125937
|
+
const metadata2 = sourceMetadata(question.metadata);
|
|
125938
|
+
const normalizedSource = normalizedQtiItemSource(question);
|
|
125939
|
+
const contentHash = await sha256Hex(canonicalJson2({ version: 1, xml: normalizedSource, metadata: metadata2 }));
|
|
125940
|
+
const qtiItemIdentifier = `playcademy-item.${contentHash}`;
|
|
125941
|
+
const title = question.title?.trim() || question.identifier;
|
|
125942
|
+
return {
|
|
125943
|
+
sourceItemIdentifier: question.identifier,
|
|
125944
|
+
qtiItemIdentifier,
|
|
125945
|
+
contentHash,
|
|
125946
|
+
xml: canonicalManagedAssetUrls(rewriteQtiItemIdentity(declarationNeutralQtiXml(question.rawXml), qtiItemIdentifier, title)),
|
|
125947
|
+
metadata: {
|
|
125948
|
+
...metadata2,
|
|
125949
|
+
ownerSystem: PLAYCADEMY_QTI_OWNER_SYSTEM,
|
|
125950
|
+
[PLAYCADEMY_MANAGED_ITEM_METADATA_KEY]: { version: 1, contentHash }
|
|
125951
|
+
}
|
|
125952
|
+
};
|
|
125953
|
+
}));
|
|
125954
|
+
const itemBySource = new Map(items.map((item) => [item.sourceItemIdentifier, item]));
|
|
125955
|
+
const contentHashBySource = new Map([...itemBySource].map(([source, item]) => [source, item.contentHash]));
|
|
125956
|
+
const qtiIdentifierBySource = new Map([...itemBySource].map(([source, item]) => [source, item.qtiItemIdentifier]));
|
|
125957
|
+
const normalizedRouting = remapDiagnosticRouting(input.diagnosticRoutingManifest, contentHashBySource);
|
|
125958
|
+
const sourceHash = await sha256Hex(canonicalJson2({
|
|
125959
|
+
version: 1,
|
|
125960
|
+
title: input.test.title,
|
|
125961
|
+
...testOptionalAttributes(input.test),
|
|
125962
|
+
metadata: sourceMetadata(input.test.metadata),
|
|
125963
|
+
structure: normalizedTestStructure(input.test, contentHashBySource),
|
|
125964
|
+
diagnosticRoutingManifest: normalizedRouting
|
|
125965
|
+
}));
|
|
125966
|
+
const compiledRouting = remapDiagnosticRouting(input.diagnosticRoutingManifest, qtiIdentifierBySource);
|
|
125967
|
+
const publicationHash = await sha256Hex(canonicalJson2({
|
|
125968
|
+
version: 1,
|
|
125969
|
+
sourceHash,
|
|
125970
|
+
itemIdentifiers: items.map((item) => item.qtiItemIdentifier),
|
|
125971
|
+
structure: normalizedTestStructure(input.test, qtiIdentifierBySource),
|
|
125972
|
+
diagnosticRoutingManifest: compiledRouting
|
|
125973
|
+
}));
|
|
125974
|
+
const qtiTestIdentifier = `playcademy-test.${assessmentKey}.${publicationHash}`;
|
|
125975
|
+
const copiesBySource = new Map(items.map((item) => [
|
|
125976
|
+
item.sourceItemIdentifier,
|
|
125977
|
+
{
|
|
125978
|
+
sourceIdentifier: item.sourceItemIdentifier,
|
|
125979
|
+
targetIdentifier: item.qtiItemIdentifier,
|
|
125980
|
+
href: input.itemHref(item.qtiItemIdentifier)
|
|
125981
|
+
}
|
|
125982
|
+
]));
|
|
125983
|
+
let testInput = {
|
|
125984
|
+
identifier: qtiTestIdentifier,
|
|
125985
|
+
title: input.test.title,
|
|
125986
|
+
...testOptionalAttributes(input.test),
|
|
125987
|
+
metadata: {
|
|
125988
|
+
...sourceMetadata(input.test.metadata),
|
|
125989
|
+
ownerSystem: PLAYCADEMY_QTI_OWNER_SYSTEM,
|
|
125990
|
+
[PLAYCADEMY_MANAGED_ASSESSMENT_METADATA_KEY]: {
|
|
125991
|
+
version: 1,
|
|
125992
|
+
assessmentKey,
|
|
125993
|
+
sourceHash,
|
|
125994
|
+
publicationHash,
|
|
125995
|
+
...compiledRouting ? { diagnosticRoutingManifest: compiledRouting } : {}
|
|
125996
|
+
}
|
|
125997
|
+
},
|
|
125998
|
+
...buildQtiTestStructureInput(input.test, qtiTestIdentifier, copiesBySource)
|
|
125999
|
+
};
|
|
126000
|
+
const physicalQuestions = {
|
|
126001
|
+
...input.questions,
|
|
126002
|
+
assessmentTest: qtiTestIdentifier,
|
|
126003
|
+
questions: input.questions.questions.map((entry2) => {
|
|
126004
|
+
const item = itemBySource.get(entry2.question.identifier);
|
|
126005
|
+
return {
|
|
126006
|
+
reference: {
|
|
126007
|
+
...entry2.reference,
|
|
126008
|
+
identifier: item.qtiItemIdentifier,
|
|
126009
|
+
href: input.itemHref(item.qtiItemIdentifier)
|
|
126010
|
+
},
|
|
126011
|
+
question: {
|
|
126012
|
+
...entry2.question,
|
|
126013
|
+
identifier: item.qtiItemIdentifier,
|
|
126014
|
+
rawXml: item.xml,
|
|
126015
|
+
metadata: item.metadata
|
|
126016
|
+
}
|
|
126017
|
+
};
|
|
126018
|
+
})
|
|
126019
|
+
};
|
|
126020
|
+
try {
|
|
126021
|
+
const prepared = await prepareReviewMappingUpdate(testInput, physicalQuestions);
|
|
126022
|
+
testInput = { identifier: qtiTestIdentifier, ...prepared.update };
|
|
126023
|
+
} catch (error88) {
|
|
126024
|
+
if (input.purpose === "review" || !(error88 instanceof ValidationError)) {
|
|
126025
|
+
throw error88;
|
|
126026
|
+
}
|
|
126027
|
+
}
|
|
126028
|
+
return {
|
|
126029
|
+
assessmentKey,
|
|
126030
|
+
sourceHash,
|
|
126031
|
+
publicationHash,
|
|
126032
|
+
qtiTestIdentifier,
|
|
126033
|
+
testInput,
|
|
126034
|
+
items,
|
|
126035
|
+
diagnosticRoutingManifest: compiledRouting
|
|
126036
|
+
};
|
|
126037
|
+
}
|
|
126038
|
+
function assertManagedAssessmentPublication(test, expected) {
|
|
126039
|
+
const managed = managedMetadataRecord(test.metadata, PLAYCADEMY_MANAGED_ASSESSMENT_METADATA_KEY);
|
|
126040
|
+
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)) {
|
|
126041
|
+
throw new ValidationError(`QTI identifier collision for managed assessment ${expected.assessmentKey}`);
|
|
126042
|
+
}
|
|
126043
|
+
}
|
|
126044
|
+
function managedAssessmentDiagnosticRoutingManifest(test, assessmentKey) {
|
|
126045
|
+
const managed = managedMetadataRecord(test.metadata, PLAYCADEMY_MANAGED_ASSESSMENT_METADATA_KEY);
|
|
126046
|
+
const manifest = managed?.diagnosticRoutingManifest;
|
|
126047
|
+
return managed?.assessmentKey === assessmentKey && isRecord5(manifest) ? manifest : null;
|
|
126048
|
+
}
|
|
126049
|
+
async function assertManagedAssessmentItem(item, expected) {
|
|
126050
|
+
const managed = managedMetadataRecord(item.metadata, PLAYCADEMY_MANAGED_ITEM_METADATA_KEY);
|
|
126051
|
+
const actualHash = await sha256Hex(canonicalJson2({
|
|
126052
|
+
version: 1,
|
|
126053
|
+
xml: normalizedQtiItemSource(item),
|
|
126054
|
+
metadata: sourceMetadata(item.metadata)
|
|
126055
|
+
}));
|
|
126056
|
+
if (item.identifier !== expected.qtiItemIdentifier || actualHash !== expected.contentHash || managed?.contentHash !== expected.contentHash) {
|
|
126057
|
+
throw new ValidationError(`QTI identifier collision for item ${expected.qtiItemIdentifier}`);
|
|
126058
|
+
}
|
|
126059
|
+
}
|
|
126060
|
+
var PLAYCADEMY_MANAGED_ASSESSMENT_METADATA_KEY = "playcademyManagedAssessment";
|
|
126061
|
+
var PLAYCADEMY_MANAGED_ITEM_METADATA_KEY = "playcademyManagedItem";
|
|
126062
|
+
var GENERATED_METADATA_KEYS;
|
|
126063
|
+
var MANAGED_ASSET_URL;
|
|
126064
|
+
var CANONICAL_ASSESSMENT_ASSET_ORIGIN = "https://cdn.playcademy.net";
|
|
126065
|
+
var init_timeback_assessment_publication_util = __esm(() => {
|
|
126066
|
+
init_src();
|
|
126067
|
+
init_schemas_index();
|
|
126068
|
+
init_assessment_runtime2();
|
|
126069
|
+
init_timeback3();
|
|
126070
|
+
init_errors2();
|
|
126071
|
+
init_timeback_qti_authoring_util();
|
|
126072
|
+
init_timeback_review_mapping_util();
|
|
126073
|
+
init_timeback_util();
|
|
126074
|
+
GENERATED_METADATA_KEYS = new Set([
|
|
126075
|
+
PLAYCADEMY_QTI_AUTHORING_METADATA_KEY,
|
|
126076
|
+
PLAYCADEMY_QTI_EDITOR_MODE_KEY,
|
|
126077
|
+
PLAYCADEMY_QTI_OWNER_TEST_IDENTIFIER_KEY,
|
|
126078
|
+
PLAYCADEMY_REVIEW_BANK_MANIFEST_METADATA_KEY,
|
|
126079
|
+
PLAYCADEMY_MANAGED_ASSESSMENT_METADATA_KEY,
|
|
126080
|
+
PLAYCADEMY_MANAGED_ITEM_METADATA_KEY,
|
|
126081
|
+
"copiedFromItemIdentifier",
|
|
126082
|
+
"copiedFromTestIdentifier",
|
|
126083
|
+
"integrationId",
|
|
126084
|
+
"ownerGameSlug",
|
|
126085
|
+
"ownerSystem",
|
|
126086
|
+
"sourceIdentifier"
|
|
126087
|
+
]);
|
|
126088
|
+
MANAGED_ASSET_URL = new RegExp(`https?://[^\\s"')]+/(${ASSESSMENT_ASSET_KEY_PREFIX}v1/sha256/[a-f0-9]{64}\\.[a-z0-9]+)`, "gi");
|
|
126089
|
+
});
|
|
126090
|
+
function validateAssessmentStatusTransition(current, next) {
|
|
126091
|
+
if (current === next) {
|
|
126092
|
+
return;
|
|
126093
|
+
}
|
|
126094
|
+
const allowed = current === "draft" && next === "live" || current === "live" && next === "archived" || current === "archived" && next === "live";
|
|
126095
|
+
if (!allowed) {
|
|
126096
|
+
throw new ValidationError(`Assessment status cannot change from ${current} to ${next}`);
|
|
126097
|
+
}
|
|
126098
|
+
}
|
|
126099
|
+
function isAssessmentPublicationTransition(current, next) {
|
|
126100
|
+
return current !== "live" && next === "live";
|
|
126101
|
+
}
|
|
126102
|
+
function assertDraftAssessment(row) {
|
|
126103
|
+
if (row.status !== "draft") {
|
|
126104
|
+
throw new ValidationError("Only draft assessments can change QTI content or question membership");
|
|
126105
|
+
}
|
|
126106
|
+
}
|
|
126107
|
+
function assertPurposeChangeDraft(row, nextPurpose) {
|
|
126108
|
+
if (row.purpose !== nextPurpose && row.status !== "draft") {
|
|
126109
|
+
throw new ValidationError("Only draft assessments can change purpose");
|
|
126110
|
+
}
|
|
126111
|
+
}
|
|
126112
|
+
function diagnosticDefinitionForRow(row) {
|
|
126113
|
+
return row.diagnosticKey && row.diagnosticRoutingManifest ? {
|
|
126114
|
+
diagnosticKey: row.diagnosticKey,
|
|
126115
|
+
routingManifest: row.diagnosticRoutingManifest
|
|
126116
|
+
} : null;
|
|
126117
|
+
}
|
|
126118
|
+
function assertAllAssessmentAssociationsDraft(rows) {
|
|
126119
|
+
if (rows.some((row) => row.status !== "draft")) {
|
|
126120
|
+
throw new ValidationError("QTI content cannot change while any associated assessment is live or archived");
|
|
126121
|
+
}
|
|
126122
|
+
}
|
|
126123
|
+
function assertAssessmentHasQuestions(questions) {
|
|
126124
|
+
if (questions.length === 0) {
|
|
126125
|
+
throw new ValidationError("An assessment must contain at least one question to publish");
|
|
126126
|
+
}
|
|
126127
|
+
}
|
|
126128
|
+
function assertReviewAssessmentHasStandards(standardCounts) {
|
|
126129
|
+
if (standardCounts.some((count) => count !== 1)) {
|
|
126130
|
+
throw new ValidationError("Every question in a review assessment must have exactly one standards alignment");
|
|
126131
|
+
}
|
|
126132
|
+
}
|
|
126133
|
+
function planAssessmentRemoval(status) {
|
|
126134
|
+
if (status === "draft") {
|
|
126135
|
+
return { kind: "delete", action: "discarded", operation: "discard_draft" };
|
|
126136
|
+
}
|
|
126137
|
+
if (status === "live") {
|
|
126138
|
+
return { kind: "archive", action: "archived", operation: "archive" };
|
|
126139
|
+
}
|
|
126140
|
+
return { kind: "none", action: "archived" };
|
|
126141
|
+
}
|
|
126142
|
+
function buildAssessmentAssociationUpdates(row, input) {
|
|
126143
|
+
const updates = {};
|
|
126144
|
+
if (input.purpose !== undefined) {
|
|
126145
|
+
updates.purpose = input.purpose;
|
|
126146
|
+
if (input.purpose !== "mastery" && (row.purpose === "mastery" || row.standardFramework || row.standardIdentifier)) {
|
|
126147
|
+
updates.standardFramework = null;
|
|
126148
|
+
updates.standardIdentifier = null;
|
|
126149
|
+
}
|
|
126150
|
+
if (row.status === "live" && input.purpose !== row.purpose) {
|
|
126151
|
+
updates.sortOrder = null;
|
|
126152
|
+
}
|
|
126153
|
+
}
|
|
126154
|
+
if (input.standard !== undefined) {
|
|
126155
|
+
updates.standardFramework = input.standard.framework;
|
|
126156
|
+
updates.standardIdentifier = input.standard.identifier;
|
|
126157
|
+
}
|
|
126158
|
+
if (input.status !== undefined) {
|
|
126159
|
+
updates.status = input.status;
|
|
126160
|
+
if (input.status === "archived") {
|
|
126161
|
+
updates.sortOrder = null;
|
|
126162
|
+
}
|
|
126163
|
+
}
|
|
126164
|
+
return updates;
|
|
126165
|
+
}
|
|
126166
|
+
function assessmentStandardForRow(row) {
|
|
126167
|
+
return row.standardFramework && row.standardIdentifier ? { framework: row.standardFramework, identifier: row.standardIdentifier } : null;
|
|
126168
|
+
}
|
|
126169
|
+
function validateUniqueAssessmentIdentifiers(testIdentifiers) {
|
|
126170
|
+
if (new Set(testIdentifiers).size !== testIdentifiers.length) {
|
|
126171
|
+
throw new ValidationError("Assessment order must contain unique identifiers");
|
|
126172
|
+
}
|
|
126173
|
+
}
|
|
126174
|
+
function assertAssessmentOrderUpdateSucceeded(updatedRow) {
|
|
126175
|
+
if (!updatedRow) {
|
|
126176
|
+
throw new ValidationError("Assessment order changed while it was being saved. Refresh and try again.");
|
|
126177
|
+
}
|
|
126178
|
+
return updatedRow;
|
|
126179
|
+
}
|
|
126180
|
+
function lockOrderAssessmentRows(rows) {
|
|
126181
|
+
return rows.toSorted((left, right) => left.id.localeCompare(right.id));
|
|
126182
|
+
}
|
|
126183
|
+
function orderLiveAssessmentRows(liveRows, purpose, testIdentifiers) {
|
|
126184
|
+
const rowsByIdentifier = new Map(liveRows.map((row) => [row.qtiTestIdentifier, row]));
|
|
126185
|
+
if (liveRows.length !== testIdentifiers.length || testIdentifiers.some((identifier) => !rowsByIdentifier.has(identifier))) {
|
|
126186
|
+
throw new ValidationError(`Assessment order must include every live ${purpose} assessment exactly once`);
|
|
126187
|
+
}
|
|
126188
|
+
return testIdentifiers.map((identifier) => rowsByIdentifier.get(identifier));
|
|
126189
|
+
}
|
|
126190
|
+
var init_timeback_assessment_rules_util = __esm(() => {
|
|
126191
|
+
init_errors2();
|
|
126192
|
+
});
|
|
124913
126193
|
async function hydrateQtiTestQuestions(client2, references) {
|
|
124914
126194
|
const questions = await runWithConcurrency(references.questions, QTI_HYDRATION_CONCURRENCY, async (reference) => ({
|
|
124915
126195
|
...reference,
|
|
@@ -125052,6 +126332,7 @@ class TimebackAssessmentRuntimeService {
|
|
|
125052
126332
|
integrationId: context2.integration.id,
|
|
125053
126333
|
enrollmentId: context2.enrollment.id,
|
|
125054
126334
|
selectedTest: {
|
|
126335
|
+
assessmentKey: decision.test.assessmentKey,
|
|
125055
126336
|
identifier: assessment.identifier,
|
|
125056
126337
|
contentRevision: assessment.contentRevision
|
|
125057
126338
|
},
|
|
@@ -125169,6 +126450,7 @@ class TimebackAssessmentRuntimeService {
|
|
|
125169
126450
|
integrationId: context2.integration.id,
|
|
125170
126451
|
enrollmentId: context2.enrollment.id,
|
|
125171
126452
|
selectedTest: {
|
|
126453
|
+
assessmentKey: definition.assessmentKey,
|
|
125172
126454
|
identifier: assessment.identifier,
|
|
125173
126455
|
contentRevision: assessment.contentRevision
|
|
125174
126456
|
},
|
|
@@ -125310,6 +126592,7 @@ class TimebackAssessmentRuntimeService {
|
|
|
125310
126592
|
integrationId: context2.integration.id,
|
|
125311
126593
|
enrollmentId: context2.enrollment.id,
|
|
125312
126594
|
selectedTest: {
|
|
126595
|
+
assessmentKey: decision.test.assessmentKey,
|
|
125313
126596
|
identifier: source.assessment.identifier,
|
|
125314
126597
|
contentRevision: source.assessment.contentRevision
|
|
125315
126598
|
},
|
|
@@ -126153,7 +127436,7 @@ class TimebackAssessmentRuntimeService {
|
|
|
126153
127436
|
});
|
|
126154
127437
|
const catalogs = loadedCatalogs.filter((catalog) => catalog !== null);
|
|
126155
127438
|
return {
|
|
126156
|
-
version:
|
|
127439
|
+
version: ASSESSMENT_RUNTIME_FIXTURE_BUNDLE_VERSION,
|
|
126157
127440
|
gameId,
|
|
126158
127441
|
exportedAt: new Date().toISOString(),
|
|
126159
127442
|
catalogs
|
|
@@ -126187,7 +127470,7 @@ class TimebackAssessmentRuntimeService {
|
|
|
126187
127470
|
const enrollmentByCourse = new Map(enrollments.map((enrollment) => [enrollment.course.id, enrollment]));
|
|
126188
127471
|
const candidateRows = integrations.filter((integration) => enrollmentByCourse.has(integration.courseId));
|
|
126189
127472
|
const liveTestRows = candidateRows.length === 0 ? [] : await db2.query.gameTimebackAssessmentTests.findMany({
|
|
126190
|
-
where: and(inArray(gameTimebackAssessmentTests.integrationId, candidateRows.map((row) => row.id)), eq(gameTimebackAssessmentTests.purpose, input.purpose), eq(gameTimebackAssessmentTests.status, "live")),
|
|
127473
|
+
where: and(inArray(gameTimebackAssessmentTests.integrationId, candidateRows.map((row) => row.id)), eq(gameTimebackAssessmentTests.purpose, input.purpose), eq(gameTimebackAssessmentTests.status, "live"), isNotNull(gameTimebackAssessmentTests.assessmentKey)),
|
|
126191
127474
|
columns: {
|
|
126192
127475
|
integrationId: true,
|
|
126193
127476
|
standardFramework: true,
|
|
@@ -126243,10 +127526,11 @@ class TimebackAssessmentRuntimeService {
|
|
|
126243
127526
|
async liveTests(integrationId, purpose, db2 = this.deps.db, requestedStandard, requestedDiagnosticKey) {
|
|
126244
127527
|
const matchesMasteryStandard = requestedStandard ? masteryStandardMatcher(requestedStandard) : undefined;
|
|
126245
127528
|
const rows = await db2.query.gameTimebackAssessmentTests.findMany({
|
|
126246
|
-
where: and(eq(gameTimebackAssessmentTests.integrationId, integrationId), eq(gameTimebackAssessmentTests.purpose, purpose), eq(gameTimebackAssessmentTests.status, "live"))
|
|
127529
|
+
where: and(eq(gameTimebackAssessmentTests.integrationId, integrationId), eq(gameTimebackAssessmentTests.purpose, purpose), eq(gameTimebackAssessmentTests.status, "live"), isNotNull(gameTimebackAssessmentTests.assessmentKey))
|
|
126247
127530
|
});
|
|
126248
127531
|
return rows.filter((row) => purpose !== "diagnostic" || requestedDiagnosticKey === undefined || row.diagnosticKey === requestedDiagnosticKey).map((row) => ({
|
|
126249
127532
|
id: row.id,
|
|
127533
|
+
assessmentKey: row.assessmentKey,
|
|
126250
127534
|
qtiTestIdentifier: row.qtiTestIdentifier,
|
|
126251
127535
|
sortOrder: row.sortOrder,
|
|
126252
127536
|
updatedAt: row.updatedAt.toISOString(),
|
|
@@ -126288,19 +127572,23 @@ class TimebackAssessmentRuntimeService {
|
|
|
126288
127572
|
reviewChildren.set(result.sourcedId, result);
|
|
126289
127573
|
}
|
|
126290
127574
|
if (metadata2 && metadata2.activityId === scope.activityId && metadata2.purpose === scope.purpose && metadata2.courseId === scope.courseId && metadata2.integrationId === scope.integrationId && result.student.sourcedId === scope.studentId) {
|
|
126291
|
-
|
|
126292
|
-
|
|
126293
|
-
|
|
126294
|
-
|
|
126295
|
-
|
|
126296
|
-
|
|
126297
|
-
|
|
126298
|
-
|
|
126299
|
-
|
|
126300
|
-
|
|
126301
|
-
|
|
126302
|
-
|
|
126303
|
-
|
|
127575
|
+
const selectedAssessmentKey = metadata2.selectedTest.assessmentKey ?? assessmentKeyFromManagedQtiIdentifier(metadata2.selectedTest.identifier);
|
|
127576
|
+
if (selectedAssessmentKey) {
|
|
127577
|
+
attempts.set(result.sourcedId, {
|
|
127578
|
+
result,
|
|
127579
|
+
metadata: metadata2,
|
|
127580
|
+
selection: {
|
|
127581
|
+
attemptId: result.sourcedId,
|
|
127582
|
+
selectedAssessmentKey,
|
|
127583
|
+
selectedTestIdentifier: metadata2.selectedTest.identifier,
|
|
127584
|
+
inProgress: result.inProgress ?? "",
|
|
127585
|
+
scoreStatus: result.scoreStatus,
|
|
127586
|
+
scoreDate: result.scoreDate,
|
|
127587
|
+
updatedAt: result.dateLastModified ?? metadata2.updatedAt,
|
|
127588
|
+
resumable: metadata2.enrollmentId === scope.enrollmentId
|
|
127589
|
+
}
|
|
127590
|
+
});
|
|
127591
|
+
}
|
|
126304
127592
|
}
|
|
126305
127593
|
}
|
|
126306
127594
|
return { attempts, reviewChildren };
|
|
@@ -126577,14 +127865,23 @@ class TimebackAssessmentRuntimeService {
|
|
|
126577
127865
|
isPlatformRoutedDiagnosticMetadata(metadata2) {
|
|
126578
127866
|
return metadata2.purpose === "diagnostic" && metadata2.diagnostic !== undefined;
|
|
126579
127867
|
}
|
|
126580
|
-
async requireDiagnosticDefinition(definitionId, integrationId, db2 = this.deps.db, requireLive = false) {
|
|
127868
|
+
async requireDiagnosticDefinition(definitionId, integrationId, db2 = this.deps.db, requireLive = false, pinnedQtiTestIdentifier) {
|
|
126581
127869
|
const row = await db2.query.gameTimebackAssessmentTests.findFirst({
|
|
126582
127870
|
where: and(eq(gameTimebackAssessmentTests.id, definitionId), eq(gameTimebackAssessmentTests.integrationId, integrationId))
|
|
126583
127871
|
});
|
|
126584
|
-
if (!row || row.purpose !== "diagnostic" || requireLive && row.status !== "live" || !row.
|
|
127872
|
+
if (!row || row.purpose !== "diagnostic" || requireLive && row.status !== "live" || !row.assessmentKey || !row.diagnosticKey) {
|
|
126585
127873
|
throw new AssessmentRuntimeError("SELECTED_TEST_VERSION_UNAVAILABLE", `The selected diagnostic definition ${definitionId} is unavailable.`, { definitionId });
|
|
126586
127874
|
}
|
|
126587
|
-
|
|
127875
|
+
const requestedQtiTestIdentifier = pinnedQtiTestIdentifier ?? row.qtiTestIdentifier;
|
|
127876
|
+
const pinnedManifest = requestedQtiTestIdentifier === row.qtiTestIdentifier ? row.diagnosticRoutingManifest : managedAssessmentDiagnosticRoutingManifest(await this.requireClient().qtiApi.assessmentTests.get(requestedQtiTestIdentifier), row.assessmentKey);
|
|
127877
|
+
if (!pinnedManifest) {
|
|
127878
|
+
throw new AssessmentRuntimeError("SELECTED_TEST_VERSION_UNAVAILABLE", `The pinned diagnostic publication for ${definitionId} is unavailable.`, { definitionId, pinnedQtiTestIdentifier });
|
|
127879
|
+
}
|
|
127880
|
+
return {
|
|
127881
|
+
...row,
|
|
127882
|
+
qtiTestIdentifier: requestedQtiTestIdentifier,
|
|
127883
|
+
diagnosticRoutingManifest: pinnedManifest
|
|
127884
|
+
};
|
|
126588
127885
|
}
|
|
126589
127886
|
async initializeHostedDiagnostic(definition, assessment) {
|
|
126590
127887
|
const initialized = await this.initializeHostedDiagnosticManifest(definition);
|
|
@@ -126618,7 +127915,7 @@ class TimebackAssessmentRuntimeService {
|
|
|
126618
127915
|
};
|
|
126619
127916
|
}
|
|
126620
127917
|
async loadAttemptDiagnosticManifest(metadata2, db2 = this.deps.db) {
|
|
126621
|
-
const definition = await this.requireDiagnosticDefinition(metadata2.diagnostic.definitionId, metadata2.integrationId, db2);
|
|
127918
|
+
const definition = await this.requireDiagnosticDefinition(metadata2.diagnostic.definitionId, metadata2.integrationId, db2, false, metadata2.selectedTest.identifier);
|
|
126622
127919
|
const initialized = await this.initializeHostedDiagnosticManifest(definition);
|
|
126623
127920
|
if (definition.qtiTestIdentifier !== metadata2.selectedTest.identifier || definition.diagnosticKey !== metadata2.diagnostic.diagnosticKey || initialized.routingRevision !== metadata2.diagnostic.routingRevision) {
|
|
126624
127921
|
throw new AssessmentRuntimeError("SELECTED_TEST_VERSION_UNAVAILABLE", `The pinned definition for diagnostic ${metadata2.diagnostic.diagnosticKey} no longer matches the attempt.`, { definitionId: metadata2.diagnostic.definitionId });
|
|
@@ -127493,6 +128790,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
|
|
|
127493
128790
|
init_uuid();
|
|
127494
128791
|
init_errors2();
|
|
127495
128792
|
init_assessment_runtime_lock_util();
|
|
128793
|
+
init_timeback_assessment_publication_util();
|
|
127496
128794
|
init_timeback_assessment_rules_util();
|
|
127497
128795
|
init_timeback_assessment_runtime_util();
|
|
127498
128796
|
init_timeback_qti_hydration_util();
|
|
@@ -127501,947 +128799,49 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
|
|
|
127501
128799
|
init_errors8()
|
|
127502
128800
|
]);
|
|
127503
128801
|
});
|
|
127504
|
-
function
|
|
127505
|
-
return
|
|
127506
|
-
|
|
127507
|
-
|
|
127508
|
-
|
|
127509
|
-
|
|
127510
|
-
|
|
127511
|
-
return;
|
|
127512
|
-
}
|
|
127513
|
-
const persistableItem = { ...item };
|
|
127514
|
-
Reflect.deleteProperty(persistableItem, "metadata");
|
|
127515
|
-
return { ...document2, item: persistableItem };
|
|
127516
|
-
}
|
|
127517
|
-
function qtiAuthoringSnapshot(input) {
|
|
127518
|
-
const document2 = persistableAuthoringDocument(input.document);
|
|
127519
|
-
if (!recordValue(input.interaction) && !document2) {
|
|
127520
|
-
return;
|
|
127521
|
-
}
|
|
127522
|
-
return Object.fromEntries(QTI_AUTHORING_FIELDS.flatMap((field) => {
|
|
127523
|
-
if (field === "document") {
|
|
127524
|
-
return document2 ? [[field, document2]] : [];
|
|
127525
|
-
}
|
|
127526
|
-
return input[field] === undefined ? [] : [[field, input[field]]];
|
|
128802
|
+
function diagnosticRoutingCapabilities(assessment, scoring) {
|
|
128803
|
+
return assessment.items.map((item) => ({
|
|
128804
|
+
itemIdentifier: item.identifier,
|
|
128805
|
+
supportsDeterminateBinaryGrading: item.interactions.length > 0 && item.interactions.every((interaction) => {
|
|
128806
|
+
const responseIdentifier = interaction.responseIdentifier;
|
|
128807
|
+
return Object.hasOwn(scoring.correctResponses[item.identifier] ?? {}, responseIdentifier) || (scoring.responseAreas?.[item.identifier]?.[responseIdentifier]?.length ?? 0) > 0;
|
|
128808
|
+
})
|
|
127527
128809
|
}));
|
|
127528
128810
|
}
|
|
127529
|
-
function
|
|
127530
|
-
const
|
|
127531
|
-
|
|
127532
|
-
|
|
127533
|
-
...inputMetadata
|
|
127534
|
-
};
|
|
127535
|
-
const existingAuthoring = recordValue(existingMetadata[PLAYCADEMY_QTI_AUTHORING_METADATA_KEY]);
|
|
127536
|
-
Reflect.deleteProperty(metadata2, PLAYCADEMY_QTI_AUTHORING_METADATA_KEY);
|
|
127537
|
-
Reflect.deleteProperty(metadata2, PLAYCADEMY_QTI_EDITOR_MODE_KEY);
|
|
127538
|
-
const authoring = qtiAuthoringSnapshot(input);
|
|
127539
|
-
const shouldReplaceAuthoring = "interaction" in input || "numericTextEntry" in input || "document" in input;
|
|
127540
|
-
const persistedAuthoring = authoring ?? (shouldReplaceAuthoring ? undefined : existingAuthoring);
|
|
127541
|
-
return {
|
|
127542
|
-
...metadata2,
|
|
127543
|
-
ownerSystem: PLAYCADEMY_QTI_OWNER_SYSTEM,
|
|
127544
|
-
ownerGameSlug: ownership.gameSlug,
|
|
127545
|
-
[PLAYCADEMY_QTI_OWNER_TEST_IDENTIFIER_KEY]: ownership.testIdentifier,
|
|
127546
|
-
...persistedAuthoring ? { [PLAYCADEMY_QTI_AUTHORING_METADATA_KEY]: persistedAuthoring } : {}
|
|
127547
|
-
};
|
|
127548
|
-
}
|
|
127549
|
-
function restoreQtiQuestionAuthoringData(item) {
|
|
127550
|
-
const authoring = recordValue(item.metadata?.[PLAYCADEMY_QTI_AUTHORING_METADATA_KEY]);
|
|
127551
|
-
if (!authoring) {
|
|
127552
|
-
return item;
|
|
127553
|
-
}
|
|
127554
|
-
const restored = Object.fromEntries(QTI_AUTHORING_FIELDS.flatMap((field) => authoring[field] === undefined ? [] : [[field, authoring[field]]]));
|
|
127555
|
-
return { ...item, ...restored };
|
|
127556
|
-
}
|
|
127557
|
-
function mergeQtiQuestionItem(item, fallbackItem, itemIdentifier, metadata2) {
|
|
127558
|
-
return restoreQtiQuestionAuthoringData({
|
|
127559
|
-
...fallbackItem,
|
|
127560
|
-
...item,
|
|
127561
|
-
identifier: itemIdentifier,
|
|
127562
|
-
title: item.title || fallbackItem?.title || itemIdentifier,
|
|
127563
|
-
type: item.type ?? fallbackItem?.type,
|
|
127564
|
-
rawXml: item.rawXml ?? fallbackItem?.rawXml,
|
|
127565
|
-
interaction: item.interaction ?? fallbackItem?.interaction,
|
|
127566
|
-
responseDeclarations: item.responseDeclarations ?? fallbackItem?.responseDeclarations,
|
|
127567
|
-
metadata: {
|
|
127568
|
-
...fallbackItem?.metadata,
|
|
127569
|
-
...item.metadata,
|
|
127570
|
-
...metadata2
|
|
127571
|
-
}
|
|
127572
|
-
});
|
|
127573
|
-
}
|
|
127574
|
-
function newPlaycademyQuestionIdentifier(testIdentifier) {
|
|
127575
|
-
return `${testIdentifier}-q${crypto.randomUUID().slice(0, 8)}`;
|
|
127576
|
-
}
|
|
127577
|
-
function parseQtiQuestionCreationInput(input) {
|
|
127578
|
-
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
127579
|
-
throw new ValidationError("Question creation input must be an object");
|
|
127580
|
-
}
|
|
127581
|
-
const rawInput = input;
|
|
127582
|
-
if (rawInput.mode === undefined) {
|
|
127583
|
-
return { kind: "authoring", input: rawInput };
|
|
127584
|
-
}
|
|
127585
|
-
if (rawInput.mode !== "copy") {
|
|
127586
|
-
throw new ValidationError("Question creation mode is invalid");
|
|
127587
|
-
}
|
|
127588
|
-
const sourceItemIdentifier = typeof rawInput.sourceItemIdentifier === "string" ? rawInput.sourceItemIdentifier.trim() : "";
|
|
127589
|
-
if (!sourceItemIdentifier) {
|
|
127590
|
-
throw new ValidationError("A source question identifier is required to create a copy");
|
|
127591
|
-
}
|
|
127592
|
-
const unexpectedField = Object.keys(rawInput).find((field) => field !== "mode" && field !== "sourceItemIdentifier");
|
|
127593
|
-
if (unexpectedField) {
|
|
127594
|
-
throw new ValidationError(`Question copy input contains an unexpected “${unexpectedField}” field`);
|
|
127595
|
-
}
|
|
127596
|
-
return { kind: "copy", sourceItemIdentifier };
|
|
127597
|
-
}
|
|
127598
|
-
function nonEmptyMetadataString(value) {
|
|
127599
|
-
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
127600
|
-
}
|
|
127601
|
-
function qtiQuestionOwnerTestIdentifier(item) {
|
|
127602
|
-
if (item.metadata?.ownerSystem !== PLAYCADEMY_QTI_OWNER_SYSTEM) {
|
|
127603
|
-
return;
|
|
127604
|
-
}
|
|
127605
|
-
return nonEmptyMetadataString(item.metadata[PLAYCADEMY_QTI_OWNER_TEST_IDENTIFIER_KEY]);
|
|
127606
|
-
}
|
|
127607
|
-
function qtiTestReferencesItem(test, itemIdentifier) {
|
|
127608
|
-
return (test["qti-test-part"] ?? []).some((part) => (part["qti-assessment-section"] ?? []).some((section) => (section["qti-assessment-item-ref"] ?? []).some((reference) => reference.identifier === itemIdentifier)));
|
|
127609
|
-
}
|
|
127610
|
-
function resolveQtiQuestionOwnerGameSlug(item, ownerTest) {
|
|
127611
|
-
if (item.metadata?.ownerSystem !== PLAYCADEMY_QTI_OWNER_SYSTEM) {
|
|
127612
|
-
return;
|
|
127613
|
-
}
|
|
127614
|
-
if ("ownerGameSlug" in item.metadata) {
|
|
127615
|
-
return nonEmptyMetadataString(item.metadata.ownerGameSlug);
|
|
127616
|
-
}
|
|
127617
|
-
const ownerTestIdentifier = qtiQuestionOwnerTestIdentifier(item);
|
|
127618
|
-
if (!ownerTestIdentifier || !ownerTest || ownerTest.identifier !== ownerTestIdentifier || !qtiTestReferencesItem(ownerTest, item.identifier) || ownerTest.metadata?.ownerSystem !== PLAYCADEMY_QTI_OWNER_SYSTEM) {
|
|
127619
|
-
return;
|
|
127620
|
-
}
|
|
127621
|
-
return nonEmptyMetadataString(ownerTest.metadata.ownerGameSlug);
|
|
127622
|
-
}
|
|
127623
|
-
function isQtiQuestionOwnedByGame(item, gameSlug, ownerTest) {
|
|
127624
|
-
return resolveQtiQuestionOwnerGameSlug(item, ownerTest) === gameSlug;
|
|
127625
|
-
}
|
|
127626
|
-
function isQtiQuestionOwnedByContext(item, ownership, ownerTest) {
|
|
127627
|
-
const declaredOwnerTest = qtiQuestionOwnerTestIdentifier(item);
|
|
127628
|
-
if (declaredOwnerTest) {
|
|
127629
|
-
return declaredOwnerTest === ownership.testIdentifier && isQtiQuestionOwnedByGame(item, ownership.gameSlug, ownerTest);
|
|
127630
|
-
}
|
|
127631
|
-
return isQtiItemOwnedByTest(item, ownership.testIdentifier);
|
|
127632
|
-
}
|
|
127633
|
-
function normalizedQtiInteractionType(value) {
|
|
127634
|
-
if (typeof value !== "string") {
|
|
127635
|
-
return;
|
|
127636
|
-
}
|
|
127637
|
-
const normalized = value.trim().toLowerCase().replaceAll("_", "-");
|
|
127638
|
-
return normalized || undefined;
|
|
127639
|
-
}
|
|
127640
|
-
function authoringDocumentItem(value) {
|
|
127641
|
-
const document2 = recordValue(value);
|
|
127642
|
-
if (!document2) {
|
|
127643
|
-
throw new ValidationError("Authored question data is invalid");
|
|
127644
|
-
}
|
|
127645
|
-
if (document2.format !== "playcademy-question" || document2.version !== 1) {
|
|
127646
|
-
throw new ValidationError("Authored questions require a version 1 playcademy-question document");
|
|
127647
|
-
}
|
|
127648
|
-
const item = recordValue(document2.item);
|
|
127649
|
-
if (!item || typeof item.title !== "string" || !Array.isArray(item.body) || !Array.isArray(item.responseDeclarations)) {
|
|
127650
|
-
throw new ValidationError("An authored question needs an item with a title, a body, and response declarations");
|
|
127651
|
-
}
|
|
127652
|
-
const declarations = item.responseDeclarations.map(recordValue);
|
|
127653
|
-
const declaredIdentifiers = declarations.map((declaration) => typeof declaration?.identifier === "string" ? declaration.identifier : "");
|
|
127654
|
-
if (declarations.length === 0) {
|
|
127655
|
-
throw new ValidationError("An authored question requires at least one response declaration");
|
|
127656
|
-
}
|
|
127657
|
-
const declaredIdentifierSet = new Set(declaredIdentifiers);
|
|
127658
|
-
if (declaredIdentifiers.some((identifier) => !identifier) || declaredIdentifierSet.size !== declaredIdentifiers.length) {
|
|
127659
|
-
throw new ValidationError("Response declaration identifiers must be unique");
|
|
127660
|
-
}
|
|
127661
|
-
const projection = projectQtiAuthoringBody(item.body);
|
|
127662
|
-
if (projection.nestedInteractions.length > 0) {
|
|
127663
|
-
throw new ValidationError("Interactions nested inside another interaction cannot be compiled");
|
|
127664
|
-
}
|
|
127665
|
-
if (projection.interactions.length !== declarations.length) {
|
|
127666
|
-
throw new ValidationError("Every response declaration must belong to exactly one interaction");
|
|
127667
|
-
}
|
|
127668
|
-
const interactionIdentifiers = projection.interactions.map((interaction) => interaction.responseIdentifier);
|
|
127669
|
-
const interactionIdentifierSet = new Set(interactionIdentifiers);
|
|
127670
|
-
if (interactionIdentifierSet.size !== interactionIdentifiers.length) {
|
|
127671
|
-
throw new ValidationError("Every interaction needs its own response identifier");
|
|
127672
|
-
}
|
|
127673
|
-
if (interactionIdentifiers.some((identifier) => !declaredIdentifierSet.has(identifier)) || declaredIdentifiers.some((identifier) => !interactionIdentifierSet.has(identifier))) {
|
|
127674
|
-
throw new ValidationError("Every interaction must declare exactly one response");
|
|
127675
|
-
}
|
|
127676
|
-
const unauthorable = projection.interactions.find((interaction) => !qtiAuthoringInteractionType(interaction));
|
|
127677
|
-
if (unauthorable) {
|
|
127678
|
-
throw new ValidationError(`Interaction type “${String(unauthorable.type)}” cannot be authored by Playcademy yet`);
|
|
127679
|
-
}
|
|
127680
|
-
const inline = projection.interactions.filter(isInlineQtiAuthoringInteraction);
|
|
127681
|
-
if (projection.interactions.length - inline.length > 1) {
|
|
127682
|
-
throw new ValidationError("A question supports any number of inline interactions but at most one block interaction");
|
|
127683
|
-
}
|
|
127684
|
-
if (item.body.some((node) => node.kind === "interaction" && isInlineQtiAuthoringInteraction(node))) {
|
|
127685
|
-
throw new ValidationError("Every inline interaction must sit at its blank inside the question’s prose");
|
|
127686
|
-
}
|
|
127687
|
-
const blanks = inline.map((interaction) => projection.blankIndexes.get(interaction));
|
|
127688
|
-
if (blanks.some((blank) => blank === undefined) || new Set(blanks).size !== blanks.length) {
|
|
127689
|
-
throw new ValidationError("Every inline interaction needs its own blank position");
|
|
127690
|
-
}
|
|
127691
|
-
return item;
|
|
127692
|
-
}
|
|
127693
|
-
function parsedQtiItemOrNull(rawXml) {
|
|
127694
|
-
if (typeof rawXml !== "string" || !rawXml) {
|
|
127695
|
-
return null;
|
|
127696
|
-
}
|
|
127697
|
-
try {
|
|
127698
|
-
return parseQtiItemXml(rawXml);
|
|
127699
|
-
} catch {
|
|
127700
|
-
return null;
|
|
127701
|
-
}
|
|
127702
|
-
}
|
|
127703
|
-
function isAuthorableMultiInteractionQtiItem(question) {
|
|
127704
|
-
const item = parsedQtiItemOrNull(question.rawXml);
|
|
127705
|
-
return item !== null && (authorableQtiInteractions(item)?.length ?? 0) > 1;
|
|
127706
|
-
}
|
|
127707
|
-
function assertPlayableQtiQuestion(question) {
|
|
127708
|
-
if (!question.rawXml) {
|
|
127709
|
-
assertSupportedQtiQuestionInteraction(question);
|
|
127710
|
-
return;
|
|
127711
|
-
}
|
|
127712
|
-
const item = parsedQtiItemOrNull(question.rawXml);
|
|
127713
|
-
const playable = item && playableQtiInteractions(item);
|
|
127714
|
-
if (!playable || qtiItemScoringValidationMessage(item)) {
|
|
127715
|
-
throw new ValidationError(`Question ${question.identifier} uses an interaction Playcademy cannot play or score.`);
|
|
127716
|
-
}
|
|
127717
|
-
}
|
|
127718
|
-
function assertSupportedQtiQuestionInteraction(question) {
|
|
127719
|
-
const candidate = recordValue(question);
|
|
127720
|
-
if (candidate?.type === AUTHORING_DOCUMENT_QUESTION_TYPE) {
|
|
127721
|
-
authoringDocumentItem(candidate.document);
|
|
127722
|
-
return;
|
|
127723
|
-
}
|
|
127724
|
-
if (!supportedQtiQuestionInteractionType(question) && !isAuthorableMultiInteractionQtiItem(question)) {
|
|
127725
|
-
throw new ValidationError("This QTI interaction type is not supported by the Playcademy question editor.");
|
|
127726
|
-
}
|
|
127727
|
-
}
|
|
127728
|
-
function invalidQtiCopyXml() {
|
|
127729
|
-
throw new ValidationError("The source question does not contain valid QTI item XML");
|
|
127730
|
-
}
|
|
127731
|
-
function markupEnd2(xml, start2, allowInternalSubset) {
|
|
127732
|
-
let quote = null;
|
|
127733
|
-
let subsetDepth = 0;
|
|
127734
|
-
for (let index2 = start2;index2 < xml.length; index2 += 1) {
|
|
127735
|
-
const character = xml[index2];
|
|
127736
|
-
if (quote) {
|
|
127737
|
-
if (character === quote) {
|
|
127738
|
-
quote = null;
|
|
127739
|
-
}
|
|
127740
|
-
} else if (character === '"' || character === "'") {
|
|
127741
|
-
quote = character;
|
|
127742
|
-
} else if (allowInternalSubset && character === "[") {
|
|
127743
|
-
subsetDepth += 1;
|
|
127744
|
-
} else if (allowInternalSubset && character === "]") {
|
|
127745
|
-
subsetDepth = Math.max(0, subsetDepth - 1);
|
|
127746
|
-
} else if (character === ">" && subsetDepth === 0) {
|
|
127747
|
-
return index2;
|
|
127748
|
-
}
|
|
127749
|
-
}
|
|
127750
|
-
return invalidQtiCopyXml();
|
|
127751
|
-
}
|
|
127752
|
-
function skipXmlWhitespace(xml, start2, end = xml.length) {
|
|
127753
|
-
let cursor2 = start2;
|
|
127754
|
-
while (cursor2 < end && /\s/.test(xml[cursor2] ?? "")) {
|
|
127755
|
-
cursor2 += 1;
|
|
127756
|
-
}
|
|
127757
|
-
return cursor2;
|
|
127758
|
-
}
|
|
127759
|
-
function xmlPreambleEnd(xml, cursor2) {
|
|
127760
|
-
if (xml.startsWith("<?", cursor2)) {
|
|
127761
|
-
const end = xml.indexOf("?>", cursor2 + 2);
|
|
127762
|
-
if (end === -1) {
|
|
127763
|
-
return invalidQtiCopyXml();
|
|
127764
|
-
}
|
|
127765
|
-
return end + 2;
|
|
127766
|
-
}
|
|
127767
|
-
if (xml.startsWith("<!--", cursor2)) {
|
|
127768
|
-
const end = xml.indexOf("-->", cursor2 + 4);
|
|
127769
|
-
if (end === -1) {
|
|
127770
|
-
return invalidQtiCopyXml();
|
|
127771
|
-
}
|
|
127772
|
-
return end + 3;
|
|
127773
|
-
}
|
|
127774
|
-
if (/^<!DOCTYPE\b/i.test(xml.slice(cursor2))) {
|
|
127775
|
-
return markupEnd2(xml, cursor2 + 2, true) + 1;
|
|
127776
|
-
}
|
|
127777
|
-
return null;
|
|
127778
|
-
}
|
|
127779
|
-
function qtiRootAttributes(xml, start2, end) {
|
|
127780
|
-
const attributes2 = [];
|
|
127781
|
-
let cursor2 = start2;
|
|
127782
|
-
while (cursor2 < end) {
|
|
127783
|
-
cursor2 = skipXmlWhitespace(xml, cursor2, end);
|
|
127784
|
-
if (xml[cursor2] === "/") {
|
|
127785
|
-
cursor2 += 1;
|
|
127786
|
-
} else if (cursor2 < end) {
|
|
127787
|
-
const nameMatch = /^[A-Za-z_][\w.:-]*/.exec(xml.slice(cursor2, end));
|
|
127788
|
-
if (!nameMatch) {
|
|
127789
|
-
return invalidQtiCopyXml();
|
|
127790
|
-
}
|
|
127791
|
-
const name3 = nameMatch[0];
|
|
127792
|
-
cursor2 = skipXmlWhitespace(xml, cursor2 + name3.length, end);
|
|
127793
|
-
if (xml[cursor2] !== "=") {
|
|
127794
|
-
return invalidQtiCopyXml();
|
|
127795
|
-
}
|
|
127796
|
-
cursor2 = skipXmlWhitespace(xml, cursor2 + 1, end);
|
|
127797
|
-
const quote = xml[cursor2];
|
|
127798
|
-
if (quote !== '"' && quote !== "'") {
|
|
127799
|
-
return invalidQtiCopyXml();
|
|
127800
|
-
}
|
|
127801
|
-
const valueStart = cursor2 + 1;
|
|
127802
|
-
const valueEnd = xml.indexOf(quote, valueStart);
|
|
127803
|
-
if (valueEnd === -1 || valueEnd > end) {
|
|
127804
|
-
return invalidQtiCopyXml();
|
|
127805
|
-
}
|
|
127806
|
-
attributes2.push({ name: name3, quote, valueStart, valueEnd });
|
|
127807
|
-
cursor2 = valueEnd + 1;
|
|
127808
|
-
}
|
|
127809
|
-
}
|
|
127810
|
-
return attributes2;
|
|
127811
|
-
}
|
|
127812
|
-
function qtiItemStartTag(xml) {
|
|
127813
|
-
let cursor2 = xml.charCodeAt(0) === 65279 ? 1 : 0;
|
|
127814
|
-
while (cursor2 < xml.length) {
|
|
127815
|
-
cursor2 = skipXmlWhitespace(xml, cursor2);
|
|
127816
|
-
const preambleEnd = xmlPreambleEnd(xml, cursor2);
|
|
127817
|
-
if (preambleEnd !== null) {
|
|
127818
|
-
cursor2 = preambleEnd;
|
|
127819
|
-
} else {
|
|
127820
|
-
const root = /^<([A-Za-z_][\w.:-]*)/.exec(xml.slice(cursor2));
|
|
127821
|
-
if (!root || root[1].split(":").at(-1) !== "qti-assessment-item") {
|
|
127822
|
-
return invalidQtiCopyXml();
|
|
127823
|
-
}
|
|
127824
|
-
const attributesStart = cursor2 + root[0].length;
|
|
127825
|
-
const end = markupEnd2(xml, attributesStart, false);
|
|
127826
|
-
const attributes2 = qtiRootAttributes(xml, attributesStart, end);
|
|
127827
|
-
let insertionPoint = skipXmlWhitespaceBackward(xml, end);
|
|
127828
|
-
if (xml[insertionPoint - 1] === "/") {
|
|
127829
|
-
insertionPoint -= 1;
|
|
127830
|
-
}
|
|
127831
|
-
return { attributes: attributes2, insertionPoint };
|
|
127832
|
-
}
|
|
127833
|
-
}
|
|
127834
|
-
return invalidQtiCopyXml();
|
|
127835
|
-
}
|
|
127836
|
-
function skipXmlWhitespaceBackward(xml, start2) {
|
|
127837
|
-
let cursor2 = start2;
|
|
127838
|
-
while (/\s/.test(xml[cursor2 - 1] ?? "")) {
|
|
127839
|
-
cursor2 -= 1;
|
|
127840
|
-
}
|
|
127841
|
-
return cursor2;
|
|
127842
|
-
}
|
|
127843
|
-
function escapeXmlAttribute(value, quote) {
|
|
127844
|
-
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(quote, quote === '"' ? """ : "'");
|
|
127845
|
-
}
|
|
127846
|
-
function rewriteQtiItemIdentity(xml, identifier, title) {
|
|
127847
|
-
if (!xml.trim() || !identifier.trim() || !title.trim()) {
|
|
127848
|
-
return invalidQtiCopyXml();
|
|
127849
|
-
}
|
|
127850
|
-
const root = qtiItemStartTag(xml);
|
|
127851
|
-
const replacements = [];
|
|
127852
|
-
const targetAttributes = new Map([
|
|
127853
|
-
["identifier", identifier],
|
|
127854
|
-
["title", title]
|
|
127855
|
-
]);
|
|
127856
|
-
for (const [name3, value] of targetAttributes) {
|
|
127857
|
-
const matches = root.attributes.filter((attribute2) => attribute2.name === name3);
|
|
127858
|
-
if (matches.length > 1) {
|
|
127859
|
-
return invalidQtiCopyXml();
|
|
127860
|
-
}
|
|
127861
|
-
const attribute = matches[0];
|
|
127862
|
-
if (attribute) {
|
|
127863
|
-
replacements.push({
|
|
127864
|
-
start: attribute.valueStart,
|
|
127865
|
-
end: attribute.valueEnd,
|
|
127866
|
-
value: escapeXmlAttribute(value, attribute.quote)
|
|
127867
|
-
});
|
|
127868
|
-
} else {
|
|
127869
|
-
replacements.push({
|
|
127870
|
-
start: root.insertionPoint,
|
|
127871
|
-
end: root.insertionPoint,
|
|
127872
|
-
value: ` ${name3}="${escapeXmlAttribute(value, '"')}"`
|
|
127873
|
-
});
|
|
127874
|
-
}
|
|
127875
|
-
}
|
|
127876
|
-
return replacements.toSorted((left, right) => right.start - left.start).reduce((rewritten, replacement) => `${rewritten.slice(0, replacement.start)}${replacement.value}${rewritten.slice(replacement.end)}`, xml);
|
|
127877
|
-
}
|
|
127878
|
-
function buildQtiQuestionCopyInput(input) {
|
|
127879
|
-
assertPlayableQtiQuestion(input.source);
|
|
127880
|
-
const title = input.source.title?.trim() || input.source.identifier;
|
|
127881
|
-
if (!input.source.rawXml) {
|
|
127882
|
-
return invalidQtiCopyXml();
|
|
127883
|
-
}
|
|
127884
|
-
const sourceMetadata = { ...input.source.metadata };
|
|
127885
|
-
if (sourceMetadata.ownerSystem !== PLAYCADEMY_QTI_OWNER_SYSTEM) {
|
|
127886
|
-
Reflect.deleteProperty(sourceMetadata, PLAYCADEMY_QTI_AUTHORING_METADATA_KEY);
|
|
127887
|
-
}
|
|
127888
|
-
const metadata2 = buildOwnedQtiQuestionMetadata({
|
|
127889
|
-
metadata: {
|
|
127890
|
-
copiedFromItemIdentifier: input.source.identifier,
|
|
127891
|
-
integrationId: input.integrationId
|
|
127892
|
-
}
|
|
127893
|
-
}, {
|
|
127894
|
-
gameSlug: input.gameSlug,
|
|
127895
|
-
testIdentifier: input.targetTestIdentifier
|
|
127896
|
-
}, sourceMetadata);
|
|
127897
|
-
return {
|
|
127898
|
-
identifier: input.targetIdentifier,
|
|
127899
|
-
title,
|
|
127900
|
-
xml: rewriteQtiItemIdentity(input.source.rawXml, input.targetIdentifier, title),
|
|
127901
|
-
metadata: metadata2
|
|
127902
|
-
};
|
|
127903
|
-
}
|
|
127904
|
-
function assertQtiQuestionEditable(item, ownership, ownerTest) {
|
|
127905
|
-
if (!isQtiQuestionOwnedByContext(item, ownership, ownerTest)) {
|
|
127906
|
-
throw new ValidationError("Shared question references are read-only. Copy the question to create an editable independent item.");
|
|
127907
|
-
}
|
|
127908
|
-
}
|
|
127909
|
-
function buildNumericQuestionXml(input, identifier, title) {
|
|
127910
|
-
if (input.format === "xml") {
|
|
127911
|
-
throw new ValidationError("Raw question XML is not accepted at this API boundary");
|
|
127912
|
-
}
|
|
127913
|
-
const candidate = input.numericTextEntry;
|
|
127914
|
-
if (candidate === undefined) {
|
|
127915
|
-
return null;
|
|
127916
|
-
}
|
|
127917
|
-
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) {
|
|
127918
|
-
throw new ValidationError("Numeric text-entry data is invalid");
|
|
127919
|
-
}
|
|
127920
|
-
const prompt = "prompt" in candidate ? candidate.prompt : undefined;
|
|
127921
|
-
const numeric3 = parseNumericTextEntry({
|
|
127922
|
-
baseType: "baseType" in candidate ? candidate.baseType : undefined,
|
|
127923
|
-
answer: "answer" in candidate ? candidate.answer : undefined,
|
|
127924
|
-
comparison: "comparison" in candidate ? candidate.comparison : undefined
|
|
127925
|
-
});
|
|
127926
|
-
if (!numeric3.success) {
|
|
127927
|
-
throw new ValidationError(numeric3.message);
|
|
127928
|
-
}
|
|
127929
|
-
if (!title || typeof prompt !== "string" || !prompt.trim()) {
|
|
127930
|
-
throw new ValidationError("Numeric questions require a title and prompt");
|
|
127931
|
-
}
|
|
127932
|
-
return numericTextEntryXml({ identifier, title, prompt: prompt.trim(), numeric: numeric3.value });
|
|
127933
|
-
}
|
|
127934
|
-
function buildExactMatchQuestionXml(input) {
|
|
127935
|
-
const responseIdentifier = escapeXml(input.responseIdentifier);
|
|
127936
|
-
const correctValues = input.correctIdentifiers.map((identifier) => ` <qti-value>${escapeXml(identifier)}</qti-value>`).join(`
|
|
127937
|
-
`);
|
|
127938
|
-
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
127939
|
-
<qti-assessment-item xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0" identifier="${escapeXml(input.identifier)}" title="${escapeXml(input.title)}" adaptive="false" time-dependent="false">
|
|
127940
|
-
<qti-response-declaration identifier="${responseIdentifier}" cardinality="${input.cardinality}" base-type="identifier">
|
|
127941
|
-
<qti-correct-response>
|
|
127942
|
-
${correctValues}
|
|
127943
|
-
</qti-correct-response>
|
|
127944
|
-
</qti-response-declaration>
|
|
127945
|
-
<qti-outcome-declaration identifier="FEEDBACK" cardinality="single" base-type="identifier" />
|
|
127946
|
-
<qti-outcome-declaration identifier="SCORE" cardinality="single" base-type="float">
|
|
127947
|
-
<qti-default-value><qti-value>0</qti-value></qti-default-value>
|
|
127948
|
-
</qti-outcome-declaration>
|
|
127949
|
-
<qti-item-body>
|
|
127950
|
-
${input.itemBody}
|
|
127951
|
-
</qti-item-body>
|
|
127952
|
-
<qti-response-processing>
|
|
127953
|
-
<qti-response-condition>
|
|
127954
|
-
<qti-response-if>
|
|
127955
|
-
<qti-match><qti-variable identifier="${responseIdentifier}" /><qti-correct identifier="${responseIdentifier}" /></qti-match>
|
|
127956
|
-
<qti-set-outcome-value identifier="FEEDBACK"><qti-base-value base-type="identifier">CORRECT</qti-base-value></qti-set-outcome-value>
|
|
127957
|
-
<qti-set-outcome-value identifier="SCORE"><qti-base-value base-type="float">1</qti-base-value></qti-set-outcome-value>
|
|
127958
|
-
</qti-response-if>
|
|
127959
|
-
<qti-response-else>
|
|
127960
|
-
<qti-set-outcome-value identifier="FEEDBACK"><qti-base-value base-type="identifier">INCORRECT</qti-base-value></qti-set-outcome-value>
|
|
127961
|
-
<qti-set-outcome-value identifier="SCORE"><qti-base-value base-type="float">0</qti-base-value></qti-set-outcome-value>
|
|
127962
|
-
</qti-response-else>
|
|
127963
|
-
</qti-response-condition>
|
|
127964
|
-
</qti-response-processing>
|
|
127965
|
-
</qti-assessment-item>`;
|
|
127966
|
-
}
|
|
127967
|
-
function parseStructuredChoices(value, options) {
|
|
127968
|
-
const { label, minimum, validate: validate2 } = options;
|
|
127969
|
-
if (!Array.isArray(value) || value.length < minimum) {
|
|
127970
|
-
throw new ValidationError(`${label} questions require at least ${minimum === 1 ? "one choice" : `${minimum} choices`}`);
|
|
127971
|
-
}
|
|
127972
|
-
const choices = value.map((choice) => {
|
|
127973
|
-
const record3 = recordValue(choice);
|
|
127974
|
-
const identifier = record3?.identifier;
|
|
127975
|
-
const content = record3?.content;
|
|
127976
|
-
if (typeof identifier !== "string" || !identifier.trim() || typeof content !== "string" || !content.trim() || validate2 !== undefined && record3 !== undefined && !validate2(record3)) {
|
|
127977
|
-
throw new ValidationError(`${label} options are invalid`);
|
|
127978
|
-
}
|
|
127979
|
-
return { identifier: identifier.trim(), content: content.trim() };
|
|
127980
|
-
});
|
|
127981
|
-
if (new Set(choices.map((choice) => choice.identifier)).size !== choices.length) {
|
|
127982
|
-
throw new ValidationError(`${label} option identifiers must be unique`);
|
|
127983
|
-
}
|
|
127984
|
-
return choices;
|
|
127985
|
-
}
|
|
127986
|
-
function declaredCorrectResponse(input, responseIdentifier) {
|
|
127987
|
-
const declarations = Array.isArray(input.responseDeclarations) ? input.responseDeclarations : [];
|
|
127988
|
-
const declaration = declarations.map(recordValue).find((candidate) => candidate?.identifier === responseIdentifier);
|
|
127989
|
-
const rawValues = recordValue(declaration?.correctResponse)?.value;
|
|
127990
|
-
return {
|
|
127991
|
-
cardinality: declaration?.cardinality,
|
|
127992
|
-
baseType: declaration?.baseType,
|
|
127993
|
-
values: Array.isArray(rawValues) ? rawValues.filter((value) => typeof value === "string") : []
|
|
127994
|
-
};
|
|
127995
|
-
}
|
|
127996
|
-
function inlineChoiceCorrectIdentifier(input, responseIdentifier, choiceIdentifiers) {
|
|
127997
|
-
const { cardinality, baseType, values } = declaredCorrectResponse(input, responseIdentifier);
|
|
127998
|
-
if (cardinality !== "single" || baseType !== "identifier") {
|
|
127999
|
-
throw new ValidationError("Inline-choice questions require a single-identifier response declaration");
|
|
128000
|
-
}
|
|
128001
|
-
if (values.length !== 1 || !choiceIdentifiers.has(values[0])) {
|
|
128002
|
-
throw new ValidationError("Inline-choice questions require one valid correct option");
|
|
128003
|
-
}
|
|
128004
|
-
return values[0];
|
|
128005
|
-
}
|
|
128006
|
-
function structuredQuestionEnvelope(input, type, label, title) {
|
|
128007
|
-
const interaction = recordValue(input.interaction);
|
|
128008
|
-
if (normalizedQtiInteractionType(interaction?.type ?? input.type) !== type) {
|
|
128009
|
-
return null;
|
|
128010
|
-
}
|
|
128011
|
-
const structure = recordValue(interaction?.questionStructure);
|
|
128012
|
-
const responseIdentifier = interaction?.responseIdentifier;
|
|
128013
|
-
const prompt = structure?.prompt;
|
|
128014
|
-
if (!interaction || !structure || typeof responseIdentifier !== "string" || !responseIdentifier.trim()) {
|
|
128015
|
-
throw new ValidationError(`${label} question data is invalid`);
|
|
128016
|
-
}
|
|
128017
|
-
if (!title || typeof prompt !== "string" || !prompt.trim()) {
|
|
128018
|
-
throw new ValidationError(`${label} questions require a title and prompt`);
|
|
128019
|
-
}
|
|
128020
|
-
return {
|
|
128021
|
-
interaction,
|
|
128022
|
-
structure,
|
|
128023
|
-
responseIdentifier: responseIdentifier.trim(),
|
|
128024
|
-
prompt: prompt.trim()
|
|
128025
|
-
};
|
|
128026
|
-
}
|
|
128027
|
-
function buildInlineChoiceQuestionXml(input, identifier, title) {
|
|
128028
|
-
const envelope = structuredQuestionEnvelope(input, "inline-choice", "Inline-choice", title);
|
|
128029
|
-
if (!envelope) {
|
|
128030
|
-
return null;
|
|
128031
|
-
}
|
|
128032
|
-
const { structure, responseIdentifier, prompt } = envelope;
|
|
128033
|
-
const promptParts = prompt.split(INLINE_CHOICE_BLANK);
|
|
128034
|
-
if (promptParts.length !== 2) {
|
|
128035
|
-
throw new ValidationError("Inline-choice questions require exactly one blank");
|
|
128036
|
-
}
|
|
128037
|
-
const choices = parseStructuredChoices(structure.inlineChoices, {
|
|
128038
|
-
label: "Inline-choice",
|
|
128039
|
-
minimum: 2
|
|
128040
|
-
});
|
|
128041
|
-
const choiceIdentifiers = new Set(choices.map((choice) => choice.identifier));
|
|
128042
|
-
const correctIdentifier = inlineChoiceCorrectIdentifier(input, responseIdentifier, choiceIdentifiers);
|
|
128043
|
-
const choicesXml = choices.map((choice) => ` <qti-inline-choice identifier="${escapeXml(choice.identifier)}">${escapeXml(choice.content)}</qti-inline-choice>`).join(`
|
|
128044
|
-
`);
|
|
128045
|
-
const [before = "", after = ""] = promptParts;
|
|
128046
|
-
const itemBody = ` <p>${escapeXml(before)}<qti-inline-choice-interaction response-identifier="${escapeXml(responseIdentifier)}">
|
|
128047
|
-
${choicesXml}
|
|
128048
|
-
</qti-inline-choice-interaction>${escapeXml(after)}</p>`;
|
|
128049
|
-
return buildExactMatchQuestionXml({
|
|
128050
|
-
identifier,
|
|
128051
|
-
title,
|
|
128052
|
-
responseIdentifier,
|
|
128053
|
-
cardinality: "single",
|
|
128054
|
-
correctIdentifiers: [correctIdentifier],
|
|
128055
|
-
itemBody
|
|
128056
|
-
});
|
|
128057
|
-
}
|
|
128058
|
-
function parseStructuredMatchChoices(value, label) {
|
|
128059
|
-
return parseStructuredChoices(value, {
|
|
128060
|
-
label: `Match ${label}`,
|
|
128061
|
-
minimum: 1,
|
|
128062
|
-
validate: (choice) => choice.matchMax === 1
|
|
128063
|
-
});
|
|
128064
|
-
}
|
|
128065
|
-
function structuredMatchCorrectPairs(input, responseIdentifier, sources, targets) {
|
|
128066
|
-
const { cardinality, baseType, values } = declaredCorrectResponse(input, responseIdentifier);
|
|
128067
|
-
if (cardinality !== "multiple" || baseType !== "directedPair") {
|
|
128068
|
-
throw new ValidationError("Match questions require a multiple directed-pair response declaration");
|
|
128069
|
-
}
|
|
128070
|
-
const sourceIdentifiers = new Set(sources.map((choice) => choice.identifier));
|
|
128071
|
-
const targetIdentifiers = new Set(targets.map((choice) => choice.identifier));
|
|
128072
|
-
const valid = values.length > 0 && new Set(values).size === values.length && values.every((value) => {
|
|
128073
|
-
const [source, target, extra] = value.split(" ");
|
|
128074
|
-
return extra === undefined && Boolean(source && target) && sourceIdentifiers.has(source) && targetIdentifiers.has(target);
|
|
128075
|
-
});
|
|
128076
|
-
if (!valid) {
|
|
128077
|
-
throw new ValidationError("Every Match correct response must name one declared source and target choice");
|
|
128078
|
-
}
|
|
128079
|
-
return values;
|
|
128080
|
-
}
|
|
128081
|
-
function buildMatchQuestionXml(input, identifier, title) {
|
|
128082
|
-
const envelope = structuredQuestionEnvelope(input, "match", "Match", title);
|
|
128083
|
-
if (!envelope) {
|
|
128084
|
-
return null;
|
|
128085
|
-
}
|
|
128086
|
-
const { interaction, structure, responseIdentifier, prompt } = envelope;
|
|
128087
|
-
const sources = parseStructuredMatchChoices(structure.sourceChoices, "source");
|
|
128088
|
-
const targets = parseStructuredMatchChoices(structure.targetChoices, "target");
|
|
128089
|
-
const sourceIdentifiers = new Set(sources.map((choice) => choice.identifier));
|
|
128090
|
-
if (targets.some((choice) => sourceIdentifiers.has(choice.identifier))) {
|
|
128091
|
-
throw new ValidationError("Match source and target choice identifiers must be unique");
|
|
128092
|
-
}
|
|
128093
|
-
const correctValues = structuredMatchCorrectPairs(input, responseIdentifier, sources, targets);
|
|
128094
|
-
const maxAssociations = interaction.maxAssociations;
|
|
128095
|
-
if (typeof maxAssociations !== "number" || !Number.isInteger(maxAssociations) || maxAssociations < 0 || maxAssociations !== 0 && maxAssociations < correctValues.length) {
|
|
128096
|
-
throw new ValidationError("Match max associations must fit every correct pair");
|
|
128097
|
-
}
|
|
128098
|
-
const item = {
|
|
128099
|
-
key: identifier,
|
|
128100
|
-
title,
|
|
128101
|
-
body: [
|
|
128102
|
-
{
|
|
128103
|
-
kind: "interaction",
|
|
128104
|
-
type: "match",
|
|
128105
|
-
responseIdentifier,
|
|
128106
|
-
attributes: {
|
|
128107
|
-
shuffle: interaction.shuffle === true,
|
|
128108
|
-
"max-associations": maxAssociations
|
|
128109
|
-
},
|
|
128110
|
-
content: [
|
|
128111
|
-
{
|
|
128112
|
-
kind: "element",
|
|
128113
|
-
name: "qti-prompt",
|
|
128114
|
-
children: [{ kind: "text", text: prompt }]
|
|
128115
|
-
}
|
|
128116
|
-
],
|
|
128117
|
-
choiceSets: [sources, targets].map((choices) => choices.map((choice) => ({
|
|
128118
|
-
identifier: choice.identifier,
|
|
128119
|
-
content: [{ kind: "text", text: choice.content }]
|
|
128120
|
-
})))
|
|
128121
|
-
}
|
|
128122
|
-
],
|
|
128123
|
-
responseDeclarations: [
|
|
128124
|
-
{
|
|
128125
|
-
identifier: responseIdentifier,
|
|
128126
|
-
cardinality: "multiple",
|
|
128127
|
-
baseType: "directedPair",
|
|
128128
|
-
correctValues
|
|
128129
|
-
}
|
|
128130
|
-
]
|
|
128131
|
-
};
|
|
128132
|
-
try {
|
|
128133
|
-
return compileQtiAuthoringItemXml(item, { identifier, title });
|
|
128134
|
-
} catch (error88) {
|
|
128135
|
-
throw new ValidationError(`This Match question cannot be compiled to QTI: ${errorMessage2(error88)}`);
|
|
128136
|
-
}
|
|
128137
|
-
}
|
|
128138
|
-
function parseHottextSegments2(value) {
|
|
128139
|
-
if (!Array.isArray(value) || value.length === 0) {
|
|
128140
|
-
throw new ValidationError("Hottext questions require passage segments");
|
|
128141
|
-
}
|
|
128142
|
-
const segments = value.map((segment) => {
|
|
128143
|
-
const record3 = recordValue(segment);
|
|
128144
|
-
const identifier = record3?.identifier;
|
|
128145
|
-
const content = record3?.content;
|
|
128146
|
-
const mode = record3?.mode;
|
|
128147
|
-
if (typeof identifier !== "string" || typeof content !== "string" || !content.trim() || !["plain", "option", "correct"].includes(String(mode))) {
|
|
128148
|
-
throw new ValidationError("Hottext passage segments are invalid");
|
|
128149
|
-
}
|
|
128150
|
-
return {
|
|
128151
|
-
identifier: identifier.trim(),
|
|
128152
|
-
content: content.trim(),
|
|
128153
|
-
mode
|
|
128154
|
-
};
|
|
128155
|
-
});
|
|
128156
|
-
const selectable = segments.filter((segment) => segment.mode !== "plain");
|
|
128157
|
-
const correct = selectable.filter((segment) => segment.mode === "correct");
|
|
128158
|
-
const identifiers = selectable.map((segment) => segment.identifier);
|
|
128159
|
-
if (selectable.length < 2) {
|
|
128160
|
-
throw new ValidationError("Hottext questions require at least two selectable phrases");
|
|
128161
|
-
}
|
|
128162
|
-
if (identifiers.some((identifier) => !identifier)) {
|
|
128163
|
-
throw new ValidationError("Hottext selectable phrases require identifiers");
|
|
128164
|
-
}
|
|
128165
|
-
if (new Set(identifiers).size !== identifiers.length) {
|
|
128166
|
-
throw new ValidationError("Hottext selectable phrase identifiers must be unique");
|
|
128167
|
-
}
|
|
128168
|
-
if (correct.length === 0) {
|
|
128169
|
-
throw new ValidationError("Hottext questions require a correct phrase");
|
|
128170
|
-
}
|
|
128171
|
-
return { segments, selectable, correct };
|
|
128172
|
-
}
|
|
128173
|
-
function buildHottextQuestionXml(input, identifier, title) {
|
|
128174
|
-
const candidate = input.hottext;
|
|
128175
|
-
if (candidate === undefined) {
|
|
128176
|
-
return null;
|
|
128177
|
-
}
|
|
128178
|
-
const hottext = recordValue(candidate);
|
|
128179
|
-
if (!hottext) {
|
|
128180
|
-
throw new ValidationError("Hottext question data is invalid");
|
|
128181
|
-
}
|
|
128182
|
-
const prompt = hottext.prompt;
|
|
128183
|
-
if (!title || typeof prompt !== "string" || !prompt.trim()) {
|
|
128184
|
-
throw new ValidationError("Hottext questions require a title and prompt");
|
|
128185
|
-
}
|
|
128186
|
-
const { segments, selectable, correct } = parseHottextSegments2(hottext.segments);
|
|
128187
|
-
const multiple = correct.length > 1;
|
|
128188
|
-
const maxChoices = hottextMaxChoices(selectable.length, multiple);
|
|
128189
|
-
const passage = segments.map((segment) => segment.mode === "plain" ? escapeXml(segment.content) : `<qti-hottext identifier="${escapeXml(segment.identifier)}">${escapeXml(segment.content)}</qti-hottext>`).join(" ");
|
|
128190
|
-
const itemBody = ` <qti-hottext-interaction response-identifier="RESPONSE" max-choices="${maxChoices}">
|
|
128191
|
-
<qti-prompt>${escapeXml(prompt.trim())}</qti-prompt>
|
|
128192
|
-
<p>${passage}</p>
|
|
128193
|
-
</qti-hottext-interaction>`;
|
|
128194
|
-
return buildExactMatchQuestionXml({
|
|
128195
|
-
identifier,
|
|
128196
|
-
title,
|
|
128197
|
-
responseIdentifier: "RESPONSE",
|
|
128198
|
-
cardinality: multiple ? "multiple" : "single",
|
|
128199
|
-
correctIdentifiers: correct.map((segment) => segment.identifier),
|
|
128200
|
-
itemBody
|
|
128201
|
-
});
|
|
128202
|
-
}
|
|
128203
|
-
function buildAuthoringDocumentQuestionXml(input, identifier, title) {
|
|
128204
|
-
if (input.type !== AUTHORING_DOCUMENT_QUESTION_TYPE) {
|
|
128205
|
-
return null;
|
|
128206
|
-
}
|
|
128207
|
-
const item = authoringDocumentItem(input.document);
|
|
128208
|
-
if (!title) {
|
|
128209
|
-
throw new ValidationError("Authored questions require a title");
|
|
128210
|
-
}
|
|
128211
|
-
try {
|
|
128212
|
-
return compileQtiAuthoringItemXml(item, { identifier, title });
|
|
128213
|
-
} catch (error88) {
|
|
128214
|
-
throw new ValidationError(`This question cannot be compiled to QTI: ${errorMessage2(error88)}`);
|
|
128215
|
-
}
|
|
128216
|
-
}
|
|
128217
|
-
function buildQuestionXml(input, identifier, title) {
|
|
128218
|
-
for (const build2 of QUESTION_XML_BUILDERS) {
|
|
128219
|
-
const xml = build2(input, identifier, title);
|
|
128220
|
-
if (xml !== null) {
|
|
128221
|
-
return xml;
|
|
128222
|
-
}
|
|
128223
|
-
}
|
|
128224
|
-
return null;
|
|
128225
|
-
}
|
|
128226
|
-
function buildCreatedQtiQuestionReference(input) {
|
|
128227
|
-
return {
|
|
128228
|
-
ownership: "owned",
|
|
128229
|
-
reference: {
|
|
128230
|
-
identifier: input.itemIdentifier,
|
|
128231
|
-
href: input.href,
|
|
128232
|
-
testPart: input.partIdentifier,
|
|
128233
|
-
section: input.sectionIdentifier
|
|
128234
|
-
},
|
|
128235
|
-
question: mergeQtiQuestionItem(input.item, input.fallbackItem, input.itemIdentifier, input.metadata)
|
|
128236
|
-
};
|
|
128237
|
-
}
|
|
128238
|
-
function buildHydratedQtiQuestionReference(input) {
|
|
128239
|
-
const identifier = input.reference.reference.identifier;
|
|
128240
|
-
const ownerGameSlug = resolveQtiQuestionOwnerGameSlug(input.item, input.ownerTest);
|
|
128241
|
-
const authoritativeItem = ownerGameSlug ? {
|
|
128242
|
-
...input.item,
|
|
128243
|
-
metadata: { ...input.item.metadata, ownerGameSlug }
|
|
128244
|
-
} : input.item;
|
|
128245
|
-
const question = mergeQtiQuestionItem(authoritativeItem, input.reference.question, identifier);
|
|
128246
|
-
return {
|
|
128247
|
-
...input.reference,
|
|
128248
|
-
ownership: isQtiQuestionOwnedByContext(question, { gameSlug: input.gameSlug, testIdentifier: input.testIdentifier }, input.ownerTest) ? "owned" : "shared",
|
|
128249
|
-
question
|
|
128250
|
-
};
|
|
128251
|
-
}
|
|
128252
|
-
function isQtiTestOwnedByGame(test, gameSlug) {
|
|
128253
|
-
return test.metadata?.ownerSystem === PLAYCADEMY_QTI_OWNER_SYSTEM && test.metadata.ownerGameSlug === gameSlug;
|
|
128254
|
-
}
|
|
128255
|
-
function assertQtiTestOwnedByGame(test, gameSlug) {
|
|
128256
|
-
if (!isQtiTestOwnedByGame(test, gameSlug)) {
|
|
128257
|
-
throw new ValidationError("Shared assessment references are read-only. Copy the assessment to create an editable independent test.");
|
|
128258
|
-
}
|
|
128259
|
-
}
|
|
128260
|
-
function qtiTestParts(test) {
|
|
128261
|
-
const parts2 = test["qti-test-part"];
|
|
128262
|
-
if (!Array.isArray(parts2) || parts2.length === 0) {
|
|
128263
|
-
throw new ValidationError(`Assessment ${test.identifier} has no test parts`);
|
|
128264
|
-
}
|
|
128265
|
-
for (const [partIndex, part] of parts2.entries()) {
|
|
128266
|
-
if (!part || typeof part !== "object" || Array.isArray(part)) {
|
|
128267
|
-
throw new ValidationError(`Assessment ${test.identifier} contains an invalid test part at position ${partIndex + 1}`);
|
|
128268
|
-
}
|
|
128269
|
-
const sections = part["qti-assessment-section"];
|
|
128270
|
-
if (!Array.isArray(sections)) {
|
|
128271
|
-
throw new ValidationError(`Assessment ${test.identifier} test part ${partIndex + 1} has no section list`);
|
|
128272
|
-
}
|
|
128273
|
-
}
|
|
128274
|
-
return parts2;
|
|
128275
|
-
}
|
|
128276
|
-
function qtiTestOptionalAttributes(test) {
|
|
128277
|
-
return {
|
|
128278
|
-
...test.qtiVersion ? { qtiVersion: test.qtiVersion } : {},
|
|
128279
|
-
...test.timeLimit !== undefined ? { timeLimit: test.timeLimit } : {},
|
|
128280
|
-
...test.maxAttempts !== undefined ? { maxAttempts: test.maxAttempts } : {},
|
|
128281
|
-
...test.toolsEnabled !== undefined ? { toolsEnabled: test.toolsEnabled } : {}
|
|
128282
|
-
};
|
|
128283
|
-
}
|
|
128284
|
-
function buildQtiAssessmentItemCopyPlan(test, targetTestIdentifier, hrefForIdentifier, createIdentifier = newPlaycademyQuestionIdentifier) {
|
|
128285
|
-
const sourceIdentifiers = [];
|
|
128286
|
-
const seen = new Set;
|
|
128287
|
-
for (const part of qtiTestParts(test)) {
|
|
128288
|
-
for (const section of part["qti-assessment-section"]) {
|
|
128289
|
-
for (const reference of section["qti-assessment-item-ref"] ?? []) {
|
|
128290
|
-
if (!seen.has(reference.identifier)) {
|
|
128291
|
-
seen.add(reference.identifier);
|
|
128292
|
-
sourceIdentifiers.push(reference.identifier);
|
|
128293
|
-
}
|
|
128294
|
-
}
|
|
128295
|
-
}
|
|
128811
|
+
function prepareDiagnosticDefinition(input) {
|
|
128812
|
+
const diagnosticKey = input.diagnosticKey.trim();
|
|
128813
|
+
if (!diagnosticKey || diagnosticKey.length > 200) {
|
|
128814
|
+
throw new ValidationError("Diagnostic key must contain between 1 and 200 characters");
|
|
128296
128815
|
}
|
|
128297
|
-
|
|
128298
|
-
|
|
128299
|
-
return {
|
|
128300
|
-
sourceIdentifier,
|
|
128301
|
-
targetIdentifier,
|
|
128302
|
-
href: hrefForIdentifier(targetIdentifier)
|
|
128303
|
-
};
|
|
128816
|
+
const validation = validateDiagnosticRoutingManifest(input.routingManifest, undefined, {
|
|
128817
|
+
analyzeBounds: false
|
|
128304
128818
|
});
|
|
128305
|
-
|
|
128306
|
-
|
|
128307
|
-
|
|
128308
|
-
if (outcomeDeclarations !== undefined && !Array.isArray(outcomeDeclarations)) {
|
|
128309
|
-
throw new ValidationError(`Assessment ${test.identifier} has an invalid outcome declaration list`);
|
|
128310
|
-
}
|
|
128311
|
-
return {
|
|
128312
|
-
"qti-test-part": qtiTestParts(test).map((part, partIndex) => ({
|
|
128313
|
-
identifier: targetTestIdentifier ? `${targetTestIdentifier}-part${partIndex + 1}` : part.identifier,
|
|
128314
|
-
navigationMode: part.navigationMode,
|
|
128315
|
-
submissionMode: part.submissionMode,
|
|
128316
|
-
"qti-assessment-section": part["qti-assessment-section"].map((section, sectionIndex) => {
|
|
128317
|
-
let sectionIdentifier = section.identifier;
|
|
128318
|
-
if (targetTestIdentifier) {
|
|
128319
|
-
sectionIdentifier = partIndex === 0 && sectionIndex === 0 ? `${targetTestIdentifier}-section1` : `${targetTestIdentifier}-part${partIndex + 1}-section${sectionIndex + 1}`;
|
|
128320
|
-
}
|
|
128321
|
-
return {
|
|
128322
|
-
identifier: sectionIdentifier,
|
|
128323
|
-
title: section.title,
|
|
128324
|
-
visible: section.visible ?? true,
|
|
128325
|
-
...section.required !== undefined ? { required: section.required } : {},
|
|
128326
|
-
...section.fixed !== undefined ? { fixed: section.fixed } : {},
|
|
128327
|
-
sequence: section.sequence ?? sectionIndex + 1,
|
|
128328
|
-
...section["qti-assessment-item-ref"] ? {
|
|
128329
|
-
"qti-assessment-item-ref": section["qti-assessment-item-ref"].map((item, itemIndex) => {
|
|
128330
|
-
const copy = itemCopies?.get(item.identifier);
|
|
128331
|
-
if (itemCopies && !copy) {
|
|
128332
|
-
throw new ValidationError(`Assessment copy is missing question ${item.identifier}`);
|
|
128333
|
-
}
|
|
128334
|
-
return {
|
|
128335
|
-
identifier: copy?.targetIdentifier ?? item.identifier,
|
|
128336
|
-
href: copy?.href ?? item.href,
|
|
128337
|
-
sequence: item.sequence ?? itemIndex + 1
|
|
128338
|
-
};
|
|
128339
|
-
})
|
|
128340
|
-
} : {}
|
|
128341
|
-
};
|
|
128342
|
-
})
|
|
128343
|
-
})),
|
|
128344
|
-
...outcomeDeclarations ? {
|
|
128345
|
-
"qti-outcome-declaration": outcomeDeclarations.map((declaration) => ({
|
|
128346
|
-
identifier: declaration.identifier,
|
|
128347
|
-
...declaration.cardinality !== undefined ? { cardinality: declaration.cardinality } : {},
|
|
128348
|
-
baseType: declaration.baseType,
|
|
128349
|
-
...declaration.normalMaximum !== undefined ? { normalMaximum: declaration.normalMaximum } : {},
|
|
128350
|
-
...declaration.normalMinimum !== undefined ? { normalMinimum: declaration.normalMinimum } : {},
|
|
128351
|
-
...declaration.defaultValue ? {
|
|
128352
|
-
defaultValue: declaration.defaultValue.value !== undefined ? { value: declaration.defaultValue.value } : {}
|
|
128353
|
-
} : {}
|
|
128354
|
-
}))
|
|
128355
|
-
} : {}
|
|
128356
|
-
};
|
|
128357
|
-
}
|
|
128358
|
-
function buildQtiTestUpdateInput(test, title) {
|
|
128359
|
-
return {
|
|
128360
|
-
title,
|
|
128361
|
-
...qtiTestOptionalAttributes(test),
|
|
128362
|
-
...test.metadata ? { metadata: test.metadata } : {},
|
|
128363
|
-
...buildQtiTestStructureInput(test)
|
|
128364
|
-
};
|
|
128365
|
-
}
|
|
128366
|
-
function buildQtiTestCopyInput(source, targetTestIdentifier, metadata2, itemCopies) {
|
|
128367
|
-
const copiesBySourceIdentifier = new Map(itemCopies.map((copy) => [copy.sourceIdentifier, copy]));
|
|
128368
|
-
return {
|
|
128369
|
-
identifier: targetTestIdentifier,
|
|
128370
|
-
title: `${source.title} (copy)`,
|
|
128371
|
-
...qtiTestOptionalAttributes(source),
|
|
128372
|
-
metadata: metadata2,
|
|
128373
|
-
...buildQtiTestStructureInput(source, targetTestIdentifier, copiesBySourceIdentifier)
|
|
128374
|
-
};
|
|
128375
|
-
}
|
|
128376
|
-
function validateUniqueQuestionIdentifiers(itemIdentifiers) {
|
|
128377
|
-
if (itemIdentifiers.length === 0 || new Set(itemIdentifiers).size !== itemIdentifiers.length) {
|
|
128378
|
-
throw new ValidationError("Question order must contain unique question identifiers");
|
|
128819
|
+
if (!validation.valid || !validation.manifest) {
|
|
128820
|
+
const details = validation.errors.slice(0, 3).map((issue7) => issue7.message).join(" ");
|
|
128821
|
+
throw new ValidationError(`Diagnostic routing manifest is invalid.${details ? ` ${details}` : ""}`);
|
|
128379
128822
|
}
|
|
128823
|
+
return { diagnosticKey, routingManifest: validation.manifest };
|
|
128380
128824
|
}
|
|
128381
|
-
function
|
|
128382
|
-
|
|
128383
|
-
|
|
128384
|
-
|
|
128385
|
-
for (const section of part["qti-assessment-section"]) {
|
|
128386
|
-
if (!itemIdentifier || section["qti-assessment-item-ref"]?.some((item) => item.identifier === itemIdentifier)) {
|
|
128387
|
-
selectedPart = part;
|
|
128388
|
-
selectedSection = section;
|
|
128389
|
-
break;
|
|
128390
|
-
}
|
|
128391
|
-
}
|
|
128392
|
-
if (selectedSection) {
|
|
128393
|
-
break;
|
|
128394
|
-
}
|
|
128395
|
-
}
|
|
128396
|
-
if (!selectedPart || !selectedSection) {
|
|
128397
|
-
throw new ValidationError(itemIdentifier ? `Question ${itemIdentifier} is not referenced by assessment ${qtiTestIdentifier}` : `Assessment ${qtiTestIdentifier} has no section for questions`);
|
|
128825
|
+
async function preparePublishedDiagnosticDefinition(definition, loaded) {
|
|
128826
|
+
assertAssessmentHasQuestions(loaded.questions.questions);
|
|
128827
|
+
for (const { question } of loaded.questions.questions) {
|
|
128828
|
+
assertPlayableQtiQuestion(question);
|
|
128398
128829
|
}
|
|
128399
|
-
|
|
128400
|
-
|
|
128401
|
-
|
|
128402
|
-
|
|
128403
|
-
|
|
128404
|
-
}
|
|
128830
|
+
const assessment = await buildPlayableAssessment(loaded.test, loaded.questions);
|
|
128831
|
+
const scoring = assessmentFixtureScoringKeys(assessment, loaded.questions);
|
|
128832
|
+
const validation = validateDiagnosticRoutingManifest(definition.routingManifest, diagnosticRoutingCapabilities(assessment, scoring));
|
|
128833
|
+
if (!validation.valid || !validation.manifest) {
|
|
128834
|
+
const details = validation.errors.slice(0, 3).map((issue7) => issue7.message).join(" ");
|
|
128835
|
+
throw new ValidationError(`Diagnostic routing manifest cannot be published.${details ? ` ${details}` : ""}`);
|
|
128405
128836
|
}
|
|
128406
|
-
return {
|
|
128407
|
-
partIdentifier: selectedPart.identifier,
|
|
128408
|
-
sectionIdentifier: selectedSection.identifier
|
|
128409
|
-
};
|
|
128837
|
+
return { diagnosticKey: definition.diagnosticKey, routingManifest: validation.manifest };
|
|
128410
128838
|
}
|
|
128411
|
-
var
|
|
128412
|
-
|
|
128413
|
-
var QTI_AUTHORING_FIELDS;
|
|
128414
|
-
var QUESTION_XML_BUILDERS;
|
|
128415
|
-
var init_timeback_qti_authoring_util = __esm(() => {
|
|
128416
|
-
init_qti();
|
|
128417
|
-
init_timeback3();
|
|
128839
|
+
var init_timeback_assessment_diagnostic_util = __esm(() => {
|
|
128840
|
+
init_assessment_runtime2();
|
|
128418
128841
|
init_errors2();
|
|
128419
|
-
|
|
128420
|
-
|
|
128421
|
-
|
|
128422
|
-
"qtiVersion",
|
|
128423
|
-
"timeDependent",
|
|
128424
|
-
"adaptive",
|
|
128425
|
-
"preInteraction",
|
|
128426
|
-
"interaction",
|
|
128427
|
-
"postInteraction",
|
|
128428
|
-
"responseDeclarations",
|
|
128429
|
-
"outcomeDeclarations",
|
|
128430
|
-
"responseProcessing",
|
|
128431
|
-
"modalFeedback",
|
|
128432
|
-
"feedbackInline",
|
|
128433
|
-
"feedbackBlock",
|
|
128434
|
-
"rubrics",
|
|
128435
|
-
"stimulus",
|
|
128436
|
-
"content"
|
|
128437
|
-
];
|
|
128438
|
-
QUESTION_XML_BUILDERS = [
|
|
128439
|
-
buildNumericQuestionXml,
|
|
128440
|
-
buildAuthoringDocumentQuestionXml,
|
|
128441
|
-
buildHottextQuestionXml,
|
|
128442
|
-
buildInlineChoiceQuestionXml,
|
|
128443
|
-
buildMatchQuestionXml
|
|
128444
|
-
];
|
|
128842
|
+
init_timeback_assessment_rules_util();
|
|
128843
|
+
init_timeback_assessment_runtime_util();
|
|
128844
|
+
init_timeback_qti_authoring_util();
|
|
128445
128845
|
});
|
|
128446
128846
|
function assertPlayableAssessmentImportQuestions(targetStatus, questions) {
|
|
128447
128847
|
if (targetStatus === "draft") {
|
|
@@ -128459,34 +128859,44 @@ function assertAssessmentImportCourseMatches(manifest, integration) {
|
|
|
128459
128859
|
function associationMetadataMatches(row, entry2) {
|
|
128460
128860
|
return row.purpose === entry2.purpose && (row.standardFramework ?? undefined) === entry2.standard?.framework && (row.standardIdentifier ?? undefined) === entry2.standard?.identifier;
|
|
128461
128861
|
}
|
|
128462
|
-
function
|
|
128463
|
-
|
|
128464
|
-
|
|
128465
|
-
|
|
128466
|
-
|
|
128862
|
+
function planAssessmentAssociationImport(entry2, existingByKey, existingByIdentifier) {
|
|
128863
|
+
if (!existingByKey) {
|
|
128864
|
+
if (!existingByIdentifier) {
|
|
128865
|
+
return { kind: "insert" };
|
|
128866
|
+
}
|
|
128867
|
+
const existingKey = existingByIdentifier.assessmentKey;
|
|
128467
128868
|
return {
|
|
128869
|
+
kind: "fail",
|
|
128468
128870
|
status: "failed",
|
|
128469
|
-
message: "
|
|
128871
|
+
message: existingKey ? `QTI publication is already attached under assessment key ${existingKey}.` : "QTI publication is already attached without a managed assessment key."
|
|
128470
128872
|
};
|
|
128471
128873
|
}
|
|
128472
|
-
if (
|
|
128473
|
-
|
|
128474
|
-
}
|
|
128475
|
-
if (row.status === "archived") {
|
|
128874
|
+
if (existingByIdentifier && existingByIdentifier.id !== existingByKey.id) {
|
|
128875
|
+
const existingKey = existingByIdentifier.assessmentKey;
|
|
128476
128876
|
return {
|
|
128877
|
+
kind: "fail",
|
|
128477
128878
|
status: "failed",
|
|
128478
|
-
message:
|
|
128879
|
+
message: existingKey ? `QTI publication is already attached under assessment key ${existingKey}.` : "QTI publication is already attached without a managed assessment key."
|
|
128479
128880
|
};
|
|
128480
128881
|
}
|
|
128481
|
-
if (
|
|
128882
|
+
if (!associationMetadataMatches(existingByKey, entry2)) {
|
|
128482
128883
|
return {
|
|
128884
|
+
kind: "fail",
|
|
128483
128885
|
status: "failed",
|
|
128484
|
-
message: "
|
|
128886
|
+
message: "Assessment key is already attached with different purpose or standard metadata."
|
|
128887
|
+
};
|
|
128888
|
+
}
|
|
128889
|
+
if (existingByKey.qtiTestIdentifier === entry2.qtiTestIdentifier) {
|
|
128890
|
+
return {
|
|
128891
|
+
kind: "skip",
|
|
128892
|
+
status: "skipped",
|
|
128893
|
+
message: "Assessment key already points to this publication."
|
|
128485
128894
|
};
|
|
128486
128895
|
}
|
|
128487
128896
|
return {
|
|
128897
|
+
kind: "fail",
|
|
128488
128898
|
status: "failed",
|
|
128489
|
-
message:
|
|
128899
|
+
message: "Assessment key is already attached to a different QTI publication."
|
|
128490
128900
|
};
|
|
128491
128901
|
}
|
|
128492
128902
|
function attachedAssessmentImportMessage(targetStatus, editable) {
|
|
@@ -128546,60 +128956,6 @@ function buildQtiLibraryListPlan(params) {
|
|
|
128546
128956
|
}
|
|
128547
128957
|
var PLAYCADEMY_QTI_SOURCE_PREFIX = "playcademy-test-";
|
|
128548
128958
|
var PLAYCADEMY_QTI_SOURCE_UPPER_BOUND = "playcademy-test.";
|
|
128549
|
-
function reviewMappingIssueSummary(label, identifiers) {
|
|
128550
|
-
if (identifiers.length === 0) {
|
|
128551
|
-
return null;
|
|
128552
|
-
}
|
|
128553
|
-
const displayed = identifiers.slice(0, 10).join(", ");
|
|
128554
|
-
const remainder = identifiers.length > 10 ? `, and ${identifiers.length - 10} more` : "";
|
|
128555
|
-
return `${label} (${identifiers.length}): ${displayed}${remainder}`;
|
|
128556
|
-
}
|
|
128557
|
-
async function prepareReviewMappingUpdate(test, questions) {
|
|
128558
|
-
const contentRevision = await assessmentContentRevision(test, questions);
|
|
128559
|
-
const assessment = {
|
|
128560
|
-
identifier: test.identifier,
|
|
128561
|
-
contractVersion: 1,
|
|
128562
|
-
contentRevision,
|
|
128563
|
-
title: test.title,
|
|
128564
|
-
items: questions.questions.map(({ question }) => ({
|
|
128565
|
-
identifier: question.identifier,
|
|
128566
|
-
title: question.title,
|
|
128567
|
-
prompt: "",
|
|
128568
|
-
maxScore: 1,
|
|
128569
|
-
interactions: []
|
|
128570
|
-
}))
|
|
128571
|
-
};
|
|
128572
|
-
const bank = await buildReviewBankIndex(assessment, questions, { customOnly: true });
|
|
128573
|
-
const issues = reviewBankMappingIssues(bank);
|
|
128574
|
-
if (issues.missingOrInvalidItemIdentifiers.length > 0 || issues.multipleStandardItemIdentifiers.length > 0) {
|
|
128575
|
-
const details = [
|
|
128576
|
-
reviewMappingIssueSummary("missing or invalid associations", issues.missingOrInvalidItemIdentifiers),
|
|
128577
|
-
reviewMappingIssueSummary("multiple associations", issues.multipleStandardItemIdentifiers)
|
|
128578
|
-
].filter((detail) => detail !== null);
|
|
128579
|
-
throw new ValidationError(`Every review question must have exactly one valid standard/node association. ${details.join("; ")}.`);
|
|
128580
|
-
}
|
|
128581
|
-
const manifest = await buildReviewBankManifest(bank);
|
|
128582
|
-
return {
|
|
128583
|
-
update: {
|
|
128584
|
-
...buildQtiTestUpdateInput(test, test.title),
|
|
128585
|
-
metadata: {
|
|
128586
|
-
...test.metadata,
|
|
128587
|
-
[PLAYCADEMY_REVIEW_BANK_MANIFEST_METADATA_KEY]: manifest
|
|
128588
|
-
}
|
|
128589
|
-
},
|
|
128590
|
-
summary: {
|
|
128591
|
-
itemCount: bank.items.length,
|
|
128592
|
-
standardCount: Object.keys(manifest.itemsByStandard).length,
|
|
128593
|
-
sourceFingerprint: manifest.sourceFingerprint
|
|
128594
|
-
}
|
|
128595
|
-
};
|
|
128596
|
-
}
|
|
128597
|
-
var init_timeback_review_mapping_util = __esm(() => {
|
|
128598
|
-
init_assessment_runtime2();
|
|
128599
|
-
init_errors2();
|
|
128600
|
-
init_timeback_assessment_runtime_util();
|
|
128601
|
-
init_timeback_qti_authoring_util();
|
|
128602
|
-
});
|
|
128603
128959
|
function databaseConstraintName(error88) {
|
|
128604
128960
|
if (typeof error88 !== "object" || error88 === null) {
|
|
128605
128961
|
return;
|
|
@@ -128607,15 +128963,6 @@ function databaseConstraintName(error88) {
|
|
|
128607
128963
|
const name3 = error88.constraint_name;
|
|
128608
128964
|
return typeof name3 === "string" ? name3 : undefined;
|
|
128609
128965
|
}
|
|
128610
|
-
function diagnosticRoutingCapabilities(assessment, scoring) {
|
|
128611
|
-
return assessment.items.map((item) => ({
|
|
128612
|
-
itemIdentifier: item.identifier,
|
|
128613
|
-
supportsDeterminateBinaryGrading: item.interactions.length > 0 && item.interactions.every((interaction) => {
|
|
128614
|
-
const responseIdentifier = interaction.responseIdentifier;
|
|
128615
|
-
return Object.hasOwn(scoring.correctResponses[item.identifier] ?? {}, responseIdentifier) || (scoring.responseAreas?.[item.identifier]?.[responseIdentifier]?.length ?? 0) > 0;
|
|
128616
|
-
})
|
|
128617
|
-
}));
|
|
128618
|
-
}
|
|
128619
128966
|
|
|
128620
128967
|
class TimebackAssessmentsService {
|
|
128621
128968
|
deps;
|
|
@@ -128679,6 +129026,7 @@ class TimebackAssessmentsService {
|
|
|
128679
129026
|
metadata: {
|
|
128680
129027
|
ownerSystem: PLAYCADEMY_QTI_OWNER_SYSTEM,
|
|
128681
129028
|
ownerGameSlug: ownership.gameSlug,
|
|
129029
|
+
assessmentKey: input.assessmentKey,
|
|
128682
129030
|
integrationId,
|
|
128683
129031
|
subject: integration.subject,
|
|
128684
129032
|
grade: String(integration.grade),
|
|
@@ -128688,6 +129036,7 @@ class TimebackAssessmentsService {
|
|
|
128688
129036
|
try {
|
|
128689
129037
|
const [row] = await this.deps.db.insert(gameTimebackAssessmentTests).values({
|
|
128690
129038
|
integrationId,
|
|
129039
|
+
assessmentKey: input.assessmentKey,
|
|
128691
129040
|
qtiTestIdentifier: input.qtiTestIdentifier,
|
|
128692
129041
|
purpose: input.purpose,
|
|
128693
129042
|
status: "draft",
|
|
@@ -128744,12 +129093,33 @@ class TimebackAssessmentsService {
|
|
|
128744
129093
|
const existingRows = await this.deps.db.query.gameTimebackAssessmentTests.findMany({
|
|
128745
129094
|
where: eq(gameTimebackAssessmentTests.integrationId, integrationId)
|
|
128746
129095
|
});
|
|
129096
|
+
const existingByKey = new Map(existingRows.flatMap((row) => row.assessmentKey ? [[row.assessmentKey, row]] : []));
|
|
128747
129097
|
const existingByIdentifier = new Map(existingRows.map((row) => [row.qtiTestIdentifier, row]));
|
|
128748
|
-
const validated = await
|
|
129098
|
+
const validated = await this.validateAssessmentImportEntries(client2, entries, manifest.targetStatus);
|
|
129099
|
+
const results = [];
|
|
129100
|
+
const pendingInserts = this.planAssessmentImports({
|
|
129101
|
+
validated,
|
|
129102
|
+
existingByKey,
|
|
129103
|
+
existingByIdentifier,
|
|
129104
|
+
gameSlug,
|
|
129105
|
+
results
|
|
129106
|
+
});
|
|
129107
|
+
const liveReviewFailures = liveReviewAssessmentImportFailures(manifest.targetStatus, pendingInserts.map(({ entry: entry2 }) => entry2), existingRows);
|
|
129108
|
+
this.recordLiveReviewImportFailures(pendingInserts, liveReviewFailures, gameSlug, results);
|
|
129109
|
+
const attachablePending = pendingInserts.filter(({ entry: entry2 }) => !liveReviewFailures.has(entry2.qtiTestIdentifier));
|
|
129110
|
+
await this.insertImportedAssessments(integrationId, attachablePending, manifest.targetStatus, gameSlug, results);
|
|
129111
|
+
setAttribute("app.assessment.operation", "bulk_attach_existing");
|
|
129112
|
+
setAttribute("app.assessment.bulk_attach.target_status", manifest.targetStatus);
|
|
129113
|
+
setAttribute("app.assessment.bulk_attach.created", results.filter((result) => result.status === "created").length);
|
|
129114
|
+
return { results };
|
|
129115
|
+
}
|
|
129116
|
+
async validateAssessmentImportEntries(client2, entries, targetStatus) {
|
|
129117
|
+
return runWithConcurrency(entries, QTI_HYDRATION_CONCURRENCY, async (entry2) => {
|
|
128749
129118
|
try {
|
|
128750
129119
|
const loaded = await loadHydratedQtiTest(client2, entry2.qtiTestIdentifier);
|
|
129120
|
+
assertManagedAssessmentIdentity(loaded.test, entry2.assessmentKey);
|
|
128751
129121
|
assertAssessmentHasQuestions(loaded.questions.questions);
|
|
128752
|
-
assertPlayableAssessmentImportQuestions(
|
|
129122
|
+
assertPlayableAssessmentImportQuestions(targetStatus, loaded.questions.questions.map(({ question }) => question));
|
|
128753
129123
|
if (entry2.purpose === "review") {
|
|
128754
129124
|
assertReviewAssessmentHasStandards(loaded.questions.questions.map(({ question }) => reviewRoutingStandardRefsFromMetadata(question.metadata, {
|
|
128755
129125
|
customOnly: true
|
|
@@ -128760,77 +129130,86 @@ class TimebackAssessmentsService {
|
|
|
128760
129130
|
return { entry: entry2, error: error88 };
|
|
128761
129131
|
}
|
|
128762
129132
|
});
|
|
128763
|
-
|
|
128764
|
-
|
|
128765
|
-
|
|
129133
|
+
}
|
|
129134
|
+
associationImportResultBase(entry2, test, gameSlug) {
|
|
129135
|
+
return {
|
|
129136
|
+
assessmentKey: entry2.assessmentKey,
|
|
129137
|
+
qtiTestIdentifier: entry2.qtiTestIdentifier,
|
|
129138
|
+
...test ? { title: test.title } : {},
|
|
129139
|
+
purpose: entry2.purpose,
|
|
129140
|
+
...entry2.standard ? { standard: entry2.standard } : {},
|
|
129141
|
+
sourceSortOrder: entry2.sortOrder,
|
|
129142
|
+
...test && gameSlug ? { editable: isQtiTestOwnedByGame(test, gameSlug) } : {}
|
|
129143
|
+
};
|
|
129144
|
+
}
|
|
129145
|
+
planAssessmentImports(input) {
|
|
129146
|
+
const pendingInserts = [];
|
|
129147
|
+
for (const validation of input.validated) {
|
|
128766
129148
|
const { entry: entry2 } = validation;
|
|
128767
|
-
const
|
|
128768
|
-
const base = {
|
|
128769
|
-
qtiTestIdentifier: entry2.qtiTestIdentifier,
|
|
128770
|
-
purpose: entry2.purpose,
|
|
128771
|
-
...entry2.standard ? { standard: entry2.standard } : {},
|
|
128772
|
-
sourceSortOrder: entry2.sortOrder
|
|
128773
|
-
};
|
|
129149
|
+
const base = this.associationImportResultBase(entry2, "test" in validation ? validation.test : undefined, input.gameSlug);
|
|
128774
129150
|
if ("error" in validation) {
|
|
128775
|
-
results[
|
|
129151
|
+
input.results[entry2.index] = {
|
|
128776
129152
|
...base,
|
|
128777
129153
|
status: "failed",
|
|
128778
129154
|
message: `QTI validation failed: ${errorMessage2(validation.error)}`
|
|
128779
129155
|
};
|
|
128780
129156
|
} else {
|
|
128781
|
-
const
|
|
128782
|
-
const
|
|
128783
|
-
|
|
128784
|
-
|
|
128785
|
-
pending.push({ entry: entry2, test: validation.test });
|
|
129157
|
+
const existingForKey = input.existingByKey.get(entry2.assessmentKey);
|
|
129158
|
+
const decision = planAssessmentAssociationImport(entry2, existingForKey, input.existingByIdentifier.get(entry2.qtiTestIdentifier));
|
|
129159
|
+
if (decision.kind === "insert") {
|
|
129160
|
+
pendingInserts.push({ entry: entry2, test: validation.test });
|
|
128786
129161
|
} else {
|
|
128787
|
-
|
|
128788
|
-
|
|
128789
|
-
...entry2.standard ? { standard: entry2.standard } : {}
|
|
128790
|
-
}, manifest.targetStatus);
|
|
128791
|
-
results[index2] = {
|
|
128792
|
-
...hydratedBase,
|
|
129162
|
+
input.results[entry2.index] = {
|
|
129163
|
+
...base,
|
|
128793
129164
|
status: decision.status,
|
|
128794
|
-
association: this.associationImportSummary(
|
|
129165
|
+
...existingForKey ? { association: this.associationImportSummary(existingForKey) } : {},
|
|
128795
129166
|
message: decision.message
|
|
128796
129167
|
};
|
|
128797
129168
|
}
|
|
128798
129169
|
}
|
|
128799
129170
|
}
|
|
128800
|
-
|
|
129171
|
+
return pendingInserts;
|
|
129172
|
+
}
|
|
129173
|
+
recordLiveReviewImportFailures(pending, failures, gameSlug, results) {
|
|
128801
129174
|
for (const { entry: entry2, test } of pending) {
|
|
128802
|
-
const message =
|
|
129175
|
+
const message = failures.get(entry2.qtiTestIdentifier);
|
|
128803
129176
|
if (message) {
|
|
128804
129177
|
results[entry2.index] = {
|
|
128805
|
-
|
|
128806
|
-
title: test.title,
|
|
128807
|
-
purpose: entry2.purpose,
|
|
128808
|
-
...entry2.standard ? { standard: entry2.standard } : {},
|
|
128809
|
-
sourceSortOrder: entry2.sortOrder,
|
|
128810
|
-
editable: isQtiTestOwnedByGame(test, gameSlug),
|
|
129178
|
+
...this.associationImportResultBase(entry2, test, gameSlug),
|
|
128811
129179
|
status: "failed",
|
|
128812
129180
|
message
|
|
128813
129181
|
};
|
|
128814
129182
|
}
|
|
128815
129183
|
}
|
|
128816
|
-
|
|
128817
|
-
|
|
128818
|
-
|
|
128819
|
-
const
|
|
128820
|
-
|
|
128821
|
-
|
|
128822
|
-
|
|
128823
|
-
|
|
128824
|
-
|
|
128825
|
-
|
|
128826
|
-
|
|
129184
|
+
}
|
|
129185
|
+
async concurrentAssessmentImportDecision(integrationId, entry2) {
|
|
129186
|
+
try {
|
|
129187
|
+
const [byKey, byIdentifier] = await Promise.all([
|
|
129188
|
+
this.deps.db.query.gameTimebackAssessmentTests.findFirst({
|
|
129189
|
+
where: and(eq(gameTimebackAssessmentTests.integrationId, integrationId), eq(gameTimebackAssessmentTests.assessmentKey, entry2.assessmentKey))
|
|
129190
|
+
}),
|
|
129191
|
+
this.deps.db.query.gameTimebackAssessmentTests.findFirst({
|
|
129192
|
+
where: and(eq(gameTimebackAssessmentTests.integrationId, integrationId), eq(gameTimebackAssessmentTests.qtiTestIdentifier, entry2.qtiTestIdentifier))
|
|
129193
|
+
})
|
|
129194
|
+
]);
|
|
129195
|
+
return {
|
|
129196
|
+
byKey,
|
|
129197
|
+
decision: planAssessmentAssociationImport(entry2, byKey, byIdentifier)
|
|
128827
129198
|
};
|
|
129199
|
+
} catch {
|
|
129200
|
+
return null;
|
|
129201
|
+
}
|
|
129202
|
+
}
|
|
129203
|
+
async insertImportedAssessments(integrationId, pending, targetStatus, gameSlug, results) {
|
|
129204
|
+
for (const { entry: entry2, test } of pending) {
|
|
129205
|
+
const base = this.associationImportResultBase(entry2, test, gameSlug);
|
|
128828
129206
|
try {
|
|
128829
129207
|
const [row] = await this.deps.db.insert(gameTimebackAssessmentTests).values({
|
|
128830
129208
|
integrationId,
|
|
129209
|
+
assessmentKey: entry2.assessmentKey,
|
|
128831
129210
|
qtiTestIdentifier: entry2.qtiTestIdentifier,
|
|
128832
129211
|
purpose: entry2.purpose,
|
|
128833
|
-
status:
|
|
129212
|
+
status: targetStatus,
|
|
128834
129213
|
sortOrder: entry2.sortOrder,
|
|
128835
129214
|
standardFramework: entry2.standard?.framework,
|
|
128836
129215
|
standardIdentifier: entry2.standard?.identifier
|
|
@@ -128838,51 +129217,37 @@ class TimebackAssessmentsService {
|
|
|
128838
129217
|
if (!row) {
|
|
128839
129218
|
throw new Error("Assessment association create returned no row");
|
|
128840
129219
|
}
|
|
128841
|
-
results[
|
|
129220
|
+
results[entry2.index] = {
|
|
128842
129221
|
...base,
|
|
128843
129222
|
status: "created",
|
|
128844
129223
|
association: this.associationImportSummary(row),
|
|
128845
|
-
message: attachedAssessmentImportMessage(
|
|
129224
|
+
message: attachedAssessmentImportMessage(targetStatus, base.editable ?? false)
|
|
128846
129225
|
};
|
|
128847
129226
|
} catch (error88) {
|
|
128848
|
-
results[
|
|
129227
|
+
results[entry2.index] = {
|
|
128849
129228
|
...base,
|
|
128850
129229
|
status: "failed",
|
|
128851
129230
|
message: `Association attach failed: ${errorMessage2(error88)}`
|
|
128852
129231
|
};
|
|
128853
129232
|
if (isUniqueViolation(error88)) {
|
|
128854
|
-
|
|
128855
|
-
|
|
128856
|
-
|
|
128857
|
-
where: and(eq(gameTimebackAssessmentTests.integrationId, integrationId), eq(gameTimebackAssessmentTests.qtiTestIdentifier, entry2.qtiTestIdentifier))
|
|
128858
|
-
});
|
|
128859
|
-
} catch {}
|
|
128860
|
-
if (concurrentAssociation) {
|
|
128861
|
-
const decision = classifyExistingAssessmentImport(concurrentAssociation, {
|
|
128862
|
-
purpose: entry2.purpose,
|
|
128863
|
-
...entry2.standard ? { standard: entry2.standard } : {}
|
|
128864
|
-
}, manifest.targetStatus);
|
|
128865
|
-
results[index2] = {
|
|
129233
|
+
const concurrent = await this.concurrentAssessmentImportDecision(integrationId, entry2);
|
|
129234
|
+
if (concurrent?.decision.kind === "skip" || concurrent?.decision.kind === "fail") {
|
|
129235
|
+
results[entry2.index] = {
|
|
128866
129236
|
...base,
|
|
128867
|
-
status: decision.status,
|
|
128868
|
-
association: this.associationImportSummary(
|
|
128869
|
-
message: decision.message
|
|
129237
|
+
status: concurrent.decision.status,
|
|
129238
|
+
...concurrent.byKey ? { association: this.associationImportSummary(concurrent.byKey) } : {},
|
|
129239
|
+
message: concurrent.decision.message
|
|
128870
129240
|
};
|
|
128871
129241
|
}
|
|
128872
129242
|
}
|
|
128873
129243
|
}
|
|
128874
129244
|
}
|
|
128875
|
-
setAttribute("app.assessment.operation", "bulk_attach_existing");
|
|
128876
|
-
setAttribute("app.assessment.bulk_attach.target_status", manifest.targetStatus);
|
|
128877
|
-
setAttribute("app.assessment.bulk_attach.created", results.filter((result) => result.status === "created").length);
|
|
128878
|
-
return { results };
|
|
128879
129245
|
}
|
|
128880
129246
|
async updateAssessment(integrationId, qtiTestIdentifier, input) {
|
|
128881
129247
|
try {
|
|
128882
129248
|
return await this.withLockedAssessmentAssociations(integrationId, qtiTestIdentifier, async (row, tx, associations) => {
|
|
128883
129249
|
const nextPurpose = input.purpose ?? row.purpose;
|
|
128884
|
-
|
|
128885
|
-
assertDiagnosticPurposeChangeDraft(row, nextPurpose);
|
|
129250
|
+
assertPurposeChangeDraft(row, nextPurpose);
|
|
128886
129251
|
const requestedStandard = input.standard ? this.canonicalAssessmentStandard(input.standard) : undefined;
|
|
128887
129252
|
const nextStandard = requestedStandard ?? (nextPurpose === "mastery" ? assessmentStandardForRow(row) : null);
|
|
128888
129253
|
this.assertPurposeStandard(nextPurpose, nextStandard);
|
|
@@ -128907,7 +129272,10 @@ class TimebackAssessmentsService {
|
|
|
128907
129272
|
}
|
|
128908
129273
|
if (!publishing && !activatingReview && nextPurpose === "review" && row.purpose !== "review") {
|
|
128909
129274
|
const ownership = await this.requireQtiTestOwnershipContext(integrationId, tx);
|
|
128910
|
-
await this.rebuildReviewMapping(this.requireClient(), qtiTestIdentifier,
|
|
129275
|
+
await this.rebuildReviewMapping(this.requireClient(), qtiTestIdentifier, {
|
|
129276
|
+
kind: "owned",
|
|
129277
|
+
gameSlug: ownership.gameSlug
|
|
129278
|
+
});
|
|
128911
129279
|
}
|
|
128912
129280
|
if (input.title !== undefined) {
|
|
128913
129281
|
assertDraftAssessment(row);
|
|
@@ -128919,16 +129287,24 @@ class TimebackAssessmentsService {
|
|
|
128919
129287
|
await client2.qtiApi.assessmentTests.update(qtiTestIdentifier, buildQtiTestUpdateInput(test, input.title));
|
|
128920
129288
|
}
|
|
128921
129289
|
if (publishing || activatingReview) {
|
|
128922
|
-
if (nextPurpose === "diagnostic") {
|
|
128923
|
-
|
|
128924
|
-
|
|
128925
|
-
|
|
128926
|
-
|
|
129290
|
+
if (nextPurpose === "diagnostic" && !nextDiagnostic) {
|
|
129291
|
+
throw new ValidationError("Platform-routed diagnostics require a diagnostic key and routing manifest before publication.");
|
|
129292
|
+
}
|
|
129293
|
+
const loaded = await loadHydratedQtiTest(this.requireClient(), qtiTestIdentifier);
|
|
129294
|
+
if (nextPurpose === "diagnostic" && nextDiagnostic) {
|
|
129295
|
+
nextDiagnostic = await preparePublishedDiagnosticDefinition(nextDiagnostic, loaded);
|
|
128927
129296
|
updates.diagnosticKey = nextDiagnostic.diagnosticKey;
|
|
128928
129297
|
updates.diagnosticRoutingManifest = nextDiagnostic.routingManifest;
|
|
128929
129298
|
} else {
|
|
128930
129299
|
const reviewOwnership = nextPurpose === "review" && nextStatus === "live" ? await this.requireQtiTestOwnershipContext(integrationId, tx) : undefined;
|
|
128931
|
-
await this.validateAssessmentHasQuestions(
|
|
129300
|
+
await this.validateAssessmentHasQuestions(loaded, reviewOwnership?.gameSlug);
|
|
129301
|
+
}
|
|
129302
|
+
if (publishing) {
|
|
129303
|
+
const publication = await this.publishManagedAssessment(row, nextPurpose, nextDiagnostic?.routingManifest ?? null, loaded);
|
|
129304
|
+
updates.qtiTestIdentifier = publication.qtiTestIdentifier;
|
|
129305
|
+
if (nextPurpose === "diagnostic") {
|
|
129306
|
+
updates.diagnosticRoutingManifest = publication.diagnosticRoutingManifest;
|
|
129307
|
+
}
|
|
128932
129308
|
}
|
|
128933
129309
|
}
|
|
128934
129310
|
let updated = row;
|
|
@@ -129024,13 +129400,14 @@ class TimebackAssessmentsService {
|
|
|
129024
129400
|
return { ...result, questions };
|
|
129025
129401
|
}
|
|
129026
129402
|
async updateReviewMapping(integrationId, qtiTestIdentifier) {
|
|
129027
|
-
return this.withLockedAssessmentAssociations(integrationId, qtiTestIdentifier, async (
|
|
129403
|
+
return this.withLockedAssessmentAssociations(integrationId, qtiTestIdentifier, async (_row, _tx, associations) => {
|
|
129028
129404
|
if (!associations.some((association) => association.purpose === "review")) {
|
|
129029
129405
|
throw new ValidationError("Review mapping is available only for standards-review assessments.");
|
|
129030
129406
|
}
|
|
129031
129407
|
const client2 = this.requireClient();
|
|
129032
|
-
const
|
|
129033
|
-
|
|
129408
|
+
const result = await this.rebuildReviewMapping(client2, qtiTestIdentifier, {
|
|
129409
|
+
kind: "live-review-repair"
|
|
129410
|
+
});
|
|
129034
129411
|
setAttribute("app.assessment.operation", "update_review_mapping");
|
|
129035
129412
|
setAttribute("app.assessment.review_mapping_item_count", result.itemCount);
|
|
129036
129413
|
return result;
|
|
@@ -129046,7 +129423,14 @@ class TimebackAssessmentsService {
|
|
|
129046
129423
|
await this.requireIntegration(integrationId);
|
|
129047
129424
|
return this.listQtiLibrary(params, async (listParams) => await client2.qtiApi.assessmentTests.list(listParams));
|
|
129048
129425
|
}
|
|
129049
|
-
async copyAssessment(integrationId,
|
|
129426
|
+
async copyAssessment(integrationId, input) {
|
|
129427
|
+
const {
|
|
129428
|
+
sourceTestIdentifier,
|
|
129429
|
+
targetTestIdentifier,
|
|
129430
|
+
assessmentKey,
|
|
129431
|
+
purpose,
|
|
129432
|
+
standard: standardInput
|
|
129433
|
+
} = input;
|
|
129050
129434
|
if (purpose === "diagnostic") {
|
|
129051
129435
|
throw new ValidationError("Adaptive diagnostics must be created by importing assessment JSON with a routing sidecar.");
|
|
129052
129436
|
}
|
|
@@ -129073,6 +129457,7 @@ class TimebackAssessmentsService {
|
|
|
129073
129457
|
...source.metadata,
|
|
129074
129458
|
ownerSystem: PLAYCADEMY_QTI_OWNER_SYSTEM,
|
|
129075
129459
|
ownerGameSlug: ownership.gameSlug,
|
|
129460
|
+
assessmentKey,
|
|
129076
129461
|
integrationId,
|
|
129077
129462
|
subject: integration.subject,
|
|
129078
129463
|
grade: String(integration.grade),
|
|
@@ -129095,6 +129480,7 @@ class TimebackAssessmentsService {
|
|
|
129095
129480
|
associationCreationAttempted = true;
|
|
129096
129481
|
const [row] = await this.deps.db.insert(gameTimebackAssessmentTests).values({
|
|
129097
129482
|
integrationId,
|
|
129483
|
+
assessmentKey,
|
|
129098
129484
|
qtiTestIdentifier: targetTestIdentifier,
|
|
129099
129485
|
purpose,
|
|
129100
129486
|
status: "draft",
|
|
@@ -129389,6 +129775,50 @@ class TimebackAssessmentsService {
|
|
|
129389
129775
|
qtiItemHref(client2, itemIdentifier) {
|
|
129390
129776
|
return `${client2.getQtiBaseUrl()}/assessment-items/${itemIdentifier}`;
|
|
129391
129777
|
}
|
|
129778
|
+
async ensureImmutableQtiResource(get, create) {
|
|
129779
|
+
try {
|
|
129780
|
+
return await get();
|
|
129781
|
+
} catch (error88) {
|
|
129782
|
+
if (!isApiError(error88) || error88.statusCode !== 404) {
|
|
129783
|
+
throw error88;
|
|
129784
|
+
}
|
|
129785
|
+
}
|
|
129786
|
+
try {
|
|
129787
|
+
await create();
|
|
129788
|
+
} catch (error88) {
|
|
129789
|
+
if (!isApiError(error88) || error88.statusCode !== 409) {
|
|
129790
|
+
throw error88;
|
|
129791
|
+
}
|
|
129792
|
+
}
|
|
129793
|
+
return get();
|
|
129794
|
+
}
|
|
129795
|
+
async ensureManagedQtiPublication(client2, plan) {
|
|
129796
|
+
await runWithConcurrency(plan.items, QTI_HYDRATION_CONCURRENCY, async (plannedItem) => {
|
|
129797
|
+
const existing = await this.ensureImmutableQtiResource(async () => await client2.qtiApi.assessmentItems.get(plannedItem.qtiItemIdentifier), () => client2.course.createAssessmentItemXml({
|
|
129798
|
+
xml: plannedItem.xml,
|
|
129799
|
+
metadata: plannedItem.metadata
|
|
129800
|
+
}));
|
|
129801
|
+
await assertManagedAssessmentItem(existing, plannedItem);
|
|
129802
|
+
});
|
|
129803
|
+
const existingTest = await this.ensureImmutableQtiResource(async () => await client2.qtiApi.assessmentTests.get(plan.qtiTestIdentifier), () => client2.qtiApi.assessmentTests.create(plan.testInput));
|
|
129804
|
+
assertManagedAssessmentPublication(existingTest, plan);
|
|
129805
|
+
}
|
|
129806
|
+
async publishManagedAssessment(row, purpose, diagnosticRoutingManifest, loaded) {
|
|
129807
|
+
if (!row.assessmentKey) {
|
|
129808
|
+
throw new ValidationError("This unmanaged assessment cannot be published. Republish it with an explicit assessment key.");
|
|
129809
|
+
}
|
|
129810
|
+
const client2 = this.requireClient();
|
|
129811
|
+
const plan = await prepareManagedAssessmentPublication({
|
|
129812
|
+
assessmentKey: row.assessmentKey,
|
|
129813
|
+
purpose,
|
|
129814
|
+
test: loaded.test,
|
|
129815
|
+
questions: loaded.questions,
|
|
129816
|
+
diagnosticRoutingManifest,
|
|
129817
|
+
itemHref: (identifier) => this.qtiItemHref(client2, identifier)
|
|
129818
|
+
});
|
|
129819
|
+
await this.ensureManagedQtiPublication(client2, plan);
|
|
129820
|
+
return plan;
|
|
129821
|
+
}
|
|
129392
129822
|
async cleanupQtiAssessmentCopy(client2, testIdentifier, itemIdentifiers) {
|
|
129393
129823
|
let testDeleted = !testIdentifier;
|
|
129394
129824
|
if (testIdentifier) {
|
|
@@ -129424,6 +129854,7 @@ class TimebackAssessmentsService {
|
|
|
129424
129854
|
return {
|
|
129425
129855
|
id: row.id,
|
|
129426
129856
|
integrationId: row.integrationId,
|
|
129857
|
+
assessmentKey: row.assessmentKey,
|
|
129427
129858
|
qtiTestIdentifier: row.qtiTestIdentifier,
|
|
129428
129859
|
purpose: row.purpose,
|
|
129429
129860
|
status: row.status,
|
|
@@ -129465,20 +129896,14 @@ class TimebackAssessmentsService {
|
|
|
129465
129896
|
const plan = buildQtiLibraryListPlan(params);
|
|
129466
129897
|
return list(plan.params);
|
|
129467
129898
|
}
|
|
129468
|
-
async validateAssessmentHasQuestions(
|
|
129469
|
-
|
|
129470
|
-
const
|
|
129471
|
-
client2.qtiApi.assessmentTests.get(qtiTestIdentifier),
|
|
129472
|
-
client2.qtiApi.assessmentTests.getQuestions(qtiTestIdentifier)
|
|
129473
|
-
]);
|
|
129474
|
-
assertAssessmentHasQuestions(result.questions);
|
|
129475
|
-
const hydrated = await hydrateQtiTestQuestions(client2, result);
|
|
129476
|
-
for (const { question } of hydrated.questions) {
|
|
129899
|
+
async validateAssessmentHasQuestions(loaded, reviewGameSlug) {
|
|
129900
|
+
assertAssessmentHasQuestions(loaded.questions.questions);
|
|
129901
|
+
for (const { question } of loaded.questions.questions) {
|
|
129477
129902
|
assertPlayableQtiQuestion(question);
|
|
129478
129903
|
}
|
|
129479
129904
|
if (reviewGameSlug) {
|
|
129480
|
-
assertQtiTestOwnedByGame(test, reviewGameSlug);
|
|
129481
|
-
await this.writeReviewMapping(
|
|
129905
|
+
assertQtiTestOwnedByGame(loaded.test, reviewGameSlug);
|
|
129906
|
+
await this.writeReviewMapping(this.requireClient(), loaded.test, loaded.questions);
|
|
129482
129907
|
}
|
|
129483
129908
|
}
|
|
129484
129909
|
async prepareDiagnosticAssociationChanges(row, nextPurpose, requested) {
|
|
@@ -129495,7 +129920,7 @@ class TimebackAssessmentsService {
|
|
|
129495
129920
|
if (requested === null || nextPurpose !== "diagnostic") {
|
|
129496
129921
|
nextDiagnostic = null;
|
|
129497
129922
|
} else if (requested) {
|
|
129498
|
-
nextDiagnostic =
|
|
129923
|
+
nextDiagnostic = prepareDiagnosticDefinition(requested);
|
|
129499
129924
|
}
|
|
129500
129925
|
if (requested === undefined && nextPurpose === row.purpose) {
|
|
129501
129926
|
return { nextDiagnostic, updates: {} };
|
|
@@ -129508,47 +129933,14 @@ class TimebackAssessmentsService {
|
|
|
129508
129933
|
}
|
|
129509
129934
|
};
|
|
129510
129935
|
}
|
|
129511
|
-
|
|
129512
|
-
const diagnosticKey = input.diagnosticKey.trim();
|
|
129513
|
-
if (!diagnosticKey || diagnosticKey.length > 200) {
|
|
129514
|
-
throw new ValidationError("Diagnostic key must contain between 1 and 200 characters");
|
|
129515
|
-
}
|
|
129516
|
-
const validation = validateDiagnosticRoutingManifest(input.routingManifest, undefined, {
|
|
129517
|
-
analyzeBounds: false
|
|
129518
|
-
});
|
|
129519
|
-
if (!validation.valid || !validation.manifest) {
|
|
129520
|
-
const details = validation.errors.slice(0, 3).map((issue7) => issue7.message).join(" ");
|
|
129521
|
-
throw new ValidationError(`Diagnostic routing manifest is invalid.${details ? ` ${details}` : ""}`);
|
|
129522
|
-
}
|
|
129523
|
-
return {
|
|
129524
|
-
diagnosticKey,
|
|
129525
|
-
routingManifest: validation.manifest
|
|
129526
|
-
};
|
|
129527
|
-
}
|
|
129528
|
-
async preparePublishedDiagnosticDefinition(qtiTestIdentifier, definition) {
|
|
129529
|
-
const loaded = await loadHydratedQtiTest(this.requireClient(), qtiTestIdentifier);
|
|
129530
|
-
assertAssessmentHasQuestions(loaded.questions.questions);
|
|
129531
|
-
for (const { question } of loaded.questions.questions) {
|
|
129532
|
-
assertPlayableQtiQuestion(question);
|
|
129533
|
-
}
|
|
129534
|
-
const assessment = await buildPlayableAssessment(loaded.test, loaded.questions);
|
|
129535
|
-
const scoring = assessmentFixtureScoringKeys(assessment, loaded.questions);
|
|
129536
|
-
const validation = validateDiagnosticRoutingManifest(definition.routingManifest, diagnosticRoutingCapabilities(assessment, scoring));
|
|
129537
|
-
if (!validation.valid || !validation.manifest) {
|
|
129538
|
-
const details = validation.errors.slice(0, 3).map((issue7) => issue7.message).join(" ");
|
|
129539
|
-
throw new ValidationError(`Diagnostic routing manifest cannot be published.${details ? ` ${details}` : ""}`);
|
|
129540
|
-
}
|
|
129541
|
-
return {
|
|
129542
|
-
diagnosticKey: definition.diagnosticKey,
|
|
129543
|
-
routingManifest: validation.manifest
|
|
129544
|
-
};
|
|
129545
|
-
}
|
|
129546
|
-
async rebuildReviewMapping(client2, qtiTestIdentifier, gameSlug) {
|
|
129936
|
+
async rebuildReviewMapping(client2, qtiTestIdentifier, scope) {
|
|
129547
129937
|
const [test, references] = await Promise.all([
|
|
129548
129938
|
client2.qtiApi.assessmentTests.get(qtiTestIdentifier),
|
|
129549
129939
|
client2.qtiApi.assessmentTests.getQuestions(qtiTestIdentifier)
|
|
129550
129940
|
]);
|
|
129551
|
-
|
|
129941
|
+
if (scope.kind === "owned") {
|
|
129942
|
+
assertQtiTestOwnedByGame(test, scope.gameSlug);
|
|
129943
|
+
}
|
|
129552
129944
|
return this.writeReviewMapping(client2, test, await hydrateQtiTestQuestions(client2, references));
|
|
129553
129945
|
}
|
|
129554
129946
|
async writeReviewMapping(client2, test, questions) {
|
|
@@ -129565,9 +129957,10 @@ var init_timeback_assessments_service = __esm(async () => {
|
|
|
129565
129957
|
init_assessment_runtime2();
|
|
129566
129958
|
init_timeback3();
|
|
129567
129959
|
init_errors2();
|
|
129960
|
+
init_timeback_assessment_diagnostic_util();
|
|
129568
129961
|
init_timeback_assessment_import_util();
|
|
129962
|
+
init_timeback_assessment_publication_util();
|
|
129569
129963
|
init_timeback_assessment_rules_util();
|
|
129570
|
-
init_timeback_assessment_runtime_util();
|
|
129571
129964
|
init_timeback_qti_authoring_util();
|
|
129572
129965
|
init_timeback_qti_hydration_util();
|
|
129573
129966
|
init_timeback_review_mapping_util();
|
|
@@ -192875,6 +193268,7 @@ var init_timeback_controller = __esm(() => {
|
|
|
192875
193268
|
const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
|
|
192876
193269
|
const qtiTestIdentifier = newPlaycademyTestIdentifier();
|
|
192877
193270
|
return ctx.services.timebackAssessments.createAssessment(integrationId, {
|
|
193271
|
+
assessmentKey: body2.assessmentKey,
|
|
192878
193272
|
title: body2.title,
|
|
192879
193273
|
purpose: body2.purpose,
|
|
192880
193274
|
standard: body2.standard,
|
|
@@ -192969,7 +193363,13 @@ var init_timeback_controller = __esm(() => {
|
|
|
192969
193363
|
const body2 = await parseRequestBody(ctx.request, CopyAssessmentRequestSchema);
|
|
192970
193364
|
const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
|
|
192971
193365
|
const targetTestIdentifier = newPlaycademyTestIdentifier();
|
|
192972
|
-
return ctx.services.timebackAssessments.copyAssessment(integrationId,
|
|
193366
|
+
return ctx.services.timebackAssessments.copyAssessment(integrationId, {
|
|
193367
|
+
sourceTestIdentifier: body2.testIdentifier,
|
|
193368
|
+
targetTestIdentifier,
|
|
193369
|
+
assessmentKey: body2.assessmentKey,
|
|
193370
|
+
purpose: body2.purpose,
|
|
193371
|
+
standard: body2.standard
|
|
193372
|
+
});
|
|
192973
193373
|
});
|
|
192974
193374
|
createQuestion = requireDeveloper(async (ctx) => {
|
|
192975
193375
|
const { gameId, courseId, testIdentifier } = ctx.params;
|