@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.
Files changed (2) hide show
  1. package/dist/index.js +1638 -1238
  2. 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.27",
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.25",
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 exposedTestIds = new Set(attempts.filter((attempt) => isAssessmentAttemptOpen(attempt) || isAssessmentAttemptCompleted(attempt)).map((attempt) => attempt.selectedTestIdentifier));
35716
- const unattempted = orderedTests.find((test) => !exposedTestIds.has(test.qtiTestIdentifier));
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.qtiTestIdentifier === latestCompleted.selectedTestIdentifier) : -1;
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 seen = new Set;
71964
+ const seenIdentifiers = new Set;
71965
+ const seenKeys = new Set;
71949
71966
  input.assessments.forEach((assessment, index2) => {
71950
- if (seen.has(assessment.qtiTestIdentifier)) {
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
- seen.add(assessment.qtiTestIdentifier);
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 validateAssessmentStatusTransition(current, next) {
124177
- if (current === next) {
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 allowed = current === "draft" && next === "live" || current === "live" && next === "archived" || current === "archived" && next === "live";
124181
- if (!allowed) {
124182
- throw new ValidationError(`Assessment status cannot change from ${current} to ${next}`);
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 isAssessmentPublicationTransition(current, next) {
124186
- return current !== "live" && next === "live";
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 assertDraftAssessment(row) {
124189
- if (row.status !== "draft") {
124190
- throw new ValidationError("Only draft assessments can change QTI content or question membership");
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 assertMasteryPurposeChangeDraft(row, nextPurpose) {
124194
- if (row.purpose !== nextPurpose && (row.purpose === "mastery" || nextPurpose === "mastery") && row.status !== "draft") {
124195
- throw new ValidationError("Only draft assessments can change to or from mastery");
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 assertDiagnosticPurposeChangeDraft(row, nextPurpose) {
124199
- if (row.purpose !== nextPurpose && (row.purpose === "diagnostic" || nextPurpose === "diagnostic") && row.status !== "draft") {
124200
- throw new ValidationError("Only draft assessments can change to or from diagnostic");
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 diagnosticDefinitionForRow(row) {
124204
- return row.diagnosticKey && row.diagnosticRoutingManifest ? {
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 assertAllAssessmentAssociationsDraft(rows) {
124210
- if (rows.some((row) => row.status !== "draft")) {
124211
- throw new ValidationError("QTI content cannot change while any associated assessment is live or archived");
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 assertAssessmentHasQuestions(questions) {
124215
- if (questions.length === 0) {
124216
- throw new ValidationError("An assessment must contain at least one question to publish");
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 assertReviewAssessmentHasStandards(standardCounts) {
124220
- if (standardCounts.some((count) => count !== 1)) {
124221
- throw new ValidationError("Every question in a review assessment must have exactly one standards alignment");
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 planAssessmentRemoval(status) {
124225
- if (status === "draft") {
124226
- return { kind: "delete", action: "discarded", operation: "discard_draft" };
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 (status === "live") {
124229
- return { kind: "archive", action: "archived", operation: "archive" };
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
- return { kind: "none", action: "archived" };
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 buildAssessmentAssociationUpdates(row, input) {
124234
- const updates = {};
124235
- if (input.purpose !== undefined) {
124236
- updates.purpose = input.purpose;
124237
- if (input.purpose !== "mastery" && (row.purpose === "mastery" || row.standardFramework || row.standardIdentifier)) {
124238
- updates.standardFramework = null;
124239
- updates.standardIdentifier = null;
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
- if (row.status === "live" && input.purpose !== row.purpose) {
124242
- updates.sortOrder = null;
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 (input.standard !== undefined) {
124246
- updates.standardFramework = input.standard.framework;
124247
- updates.standardIdentifier = input.standard.identifier;
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 (input.status !== undefined) {
124250
- updates.status = input.status;
124251
- if (input.status === "archived") {
124252
- updates.sortOrder = null;
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 updates;
124507
+ return attributes2;
124256
124508
  }
124257
- function assessmentStandardForRow(row) {
124258
- return row.standardFramework && row.standardIdentifier ? { framework: row.standardFramework, identifier: row.standardIdentifier } : null;
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 validateUniqueAssessmentIdentifiers(testIdentifiers) {
124261
- if (new Set(testIdentifiers).size !== testIdentifiers.length) {
124262
- throw new ValidationError("Assessment order must contain unique identifiers");
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 assertAssessmentOrderUpdateSucceeded(updatedRow) {
124266
- if (!updatedRow) {
124267
- throw new ValidationError("Assessment order changed while it was being saved. Refresh and try again.");
124540
+ function escapeXmlAttribute(value, quote) {
124541
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(quote, quote === '"' ? "&quot;" : "&apos;");
124542
+ }
124543
+ function rewriteQtiItemIdentity(xml, identifier, title) {
124544
+ if (!xml.trim() || !identifier.trim() || !title.trim()) {
124545
+ return invalidQtiCopyXml();
124268
124546
  }
124269
- return updatedRow;
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 lockOrderAssessmentRows(rows) {
124272
- return rows.toSorted((left, right) => left.id.localeCompare(right.id));
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 orderLiveAssessmentRows(liveRows, purpose, testIdentifiers) {
124275
- const rowsByIdentifier = new Map(liveRows.map((row) => [row.qtiTestIdentifier, row]));
124276
- if (liveRows.length !== testIdentifiers.length || testIdentifiers.some((identifier) => !rowsByIdentifier.has(identifier))) {
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
- var init_timeback_assessment_rules_util = __esm(() => {
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: 6,
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
- attempts.set(result.sourcedId, {
126292
- result,
126293
- metadata: metadata2,
126294
- selection: {
126295
- attemptId: result.sourcedId,
126296
- selectedTestIdentifier: metadata2.selectedTest.identifier,
126297
- inProgress: result.inProgress ?? "",
126298
- scoreStatus: result.scoreStatus,
126299
- scoreDate: result.scoreDate,
126300
- updatedAt: result.dateLastModified ?? metadata2.updatedAt,
126301
- resumable: metadata2.enrollmentId === scope.enrollmentId
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.diagnosticKey || !row.diagnosticRoutingManifest) {
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
- return row;
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 recordValue(value) {
127505
- return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
127506
- }
127507
- function persistableAuthoringDocument(value) {
127508
- const document2 = recordValue(value);
127509
- const item = recordValue(document2?.item);
127510
- if (!document2 || !item) {
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 buildOwnedQtiQuestionMetadata(input, ownership, existingMetadata = {}) {
127530
- const inputMetadata = recordValue(input.metadata) ?? {};
127531
- const metadata2 = {
127532
- ...existingMetadata,
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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(quote, quote === '"' ? "&quot;" : "&apos;");
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
- return sourceIdentifiers.map((sourceIdentifier) => {
128298
- const targetIdentifier = createIdentifier(targetTestIdentifier);
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
- function buildQtiTestStructureInput(test, targetTestIdentifier, itemCopies) {
128307
- const outcomeDeclarations = test["qti-outcome-declaration"];
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 resolveQtiQuestionSection(test, qtiTestIdentifier, itemIdentifier, expectedItemIdentifiers) {
128382
- let selectedPart;
128383
- let selectedSection;
128384
- for (const part of qtiTestParts(test)) {
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
- if (expectedItemIdentifiers) {
128400
- const sectionIdentifiers = selectedSection["qti-assessment-item-ref"]?.map((item) => item.identifier) ?? [];
128401
- const expectedIdentifiers = new Set(expectedItemIdentifiers);
128402
- if (expectedIdentifiers.size !== sectionIdentifiers.length || sectionIdentifiers.some((identifier) => !expectedIdentifiers.has(identifier))) {
128403
- throw new ValidationError("Questions can only be reordered within one complete assessment section");
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 PLAYCADEMY_QTI_AUTHORING_METADATA_KEY = "playcademyAuthoring";
128412
- var AUTHORING_DOCUMENT_QUESTION_TYPE = "authoring-document";
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
- QTI_AUTHORING_FIELDS = [
128420
- "type",
128421
- "document",
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 targetStatusSatisfied(row, targetStatus) {
128463
- return row.status === targetStatus || targetStatus === "draft" && row.status === "live";
128464
- }
128465
- function classifyExistingAssessmentImport(row, entry2, targetStatus) {
128466
- if (!associationMetadataMatches(row, entry2)) {
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: "Already attached with different purpose or standard metadata."
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 (targetStatusSatisfied(row, targetStatus)) {
128473
- return { status: "skipped", message: `Already attached as ${row.status}.` };
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: "Already attached as archived and cannot be imported again."
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 (targetStatus === "live" && row.status === "draft") {
128882
+ if (!associationMetadataMatches(existingByKey, entry2)) {
128482
128883
  return {
128884
+ kind: "fail",
128483
128885
  status: "failed",
128484
- message: "Already attached as a draft. Publish it from the assessment editor before retrying."
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: `Already attached as ${row.status}, which does not match the requested ${targetStatus} status.`
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 runWithConcurrency(entries, QTI_HYDRATION_CONCURRENCY, async (entry2) => {
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(manifest.targetStatus, loaded.questions.questions.map(({ question }) => question));
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
- const results = [];
128764
- const pending = [];
128765
- for (const validation of validated) {
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 { index: index2 } = entry2;
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[index2] = {
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 existing = existingByIdentifier.get(entry2.qtiTestIdentifier);
128782
- const editable = isQtiTestOwnedByGame(validation.test, gameSlug);
128783
- const hydratedBase = { ...base, title: validation.test.title, editable };
128784
- if (!existing) {
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
- const decision = classifyExistingAssessmentImport(existing, {
128788
- purpose: entry2.purpose,
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(existing),
129165
+ ...existingForKey ? { association: this.associationImportSummary(existingForKey) } : {},
128795
129166
  message: decision.message
128796
129167
  };
128797
129168
  }
128798
129169
  }
128799
129170
  }
128800
- const liveReviewFailures = liveReviewAssessmentImportFailures(manifest.targetStatus, pending.map(({ entry: entry2 }) => entry2), existingRows);
129171
+ return pendingInserts;
129172
+ }
129173
+ recordLiveReviewImportFailures(pending, failures, gameSlug, results) {
128801
129174
  for (const { entry: entry2, test } of pending) {
128802
- const message = liveReviewFailures.get(entry2.qtiTestIdentifier);
129175
+ const message = failures.get(entry2.qtiTestIdentifier);
128803
129176
  if (message) {
128804
129177
  results[entry2.index] = {
128805
- qtiTestIdentifier: entry2.qtiTestIdentifier,
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
- const attachablePending = pending.filter(({ entry: entry2 }) => !liveReviewFailures.has(entry2.qtiTestIdentifier));
128817
- for (const validation of attachablePending) {
128818
- const { entry: entry2, test } = validation;
128819
- const { index: index2 } = entry2;
128820
- const base = {
128821
- qtiTestIdentifier: entry2.qtiTestIdentifier,
128822
- title: test.title,
128823
- purpose: entry2.purpose,
128824
- ...entry2.standard ? { standard: entry2.standard } : {},
128825
- sourceSortOrder: entry2.sortOrder,
128826
- editable: isQtiTestOwnedByGame(test, gameSlug)
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: manifest.targetStatus,
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[index2] = {
129220
+ results[entry2.index] = {
128842
129221
  ...base,
128843
129222
  status: "created",
128844
129223
  association: this.associationImportSummary(row),
128845
- message: attachedAssessmentImportMessage(manifest.targetStatus, base.editable)
129224
+ message: attachedAssessmentImportMessage(targetStatus, base.editable ?? false)
128846
129225
  };
128847
129226
  } catch (error88) {
128848
- results[index2] = {
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
- let concurrentAssociation;
128855
- try {
128856
- concurrentAssociation = await this.deps.db.query.gameTimebackAssessmentTests.findFirst({
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(concurrentAssociation),
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
- assertMasteryPurposeChangeDraft(row, nextPurpose);
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, ownership.gameSlug);
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
- if (!nextDiagnostic) {
128924
- throw new ValidationError("Platform-routed diagnostics require a diagnostic key and routing manifest before publication.");
128925
- }
128926
- nextDiagnostic = await this.preparePublishedDiagnosticDefinition(qtiTestIdentifier, nextDiagnostic);
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(qtiTestIdentifier, reviewOwnership?.gameSlug);
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 (row, tx, associations) => {
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 ownership = await this.requireQtiTestOwnershipContext(integrationId, tx);
129033
- const result = await this.rebuildReviewMapping(client2, qtiTestIdentifier, ownership.gameSlug);
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, sourceTestIdentifier, targetTestIdentifier, purpose, standardInput) {
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(qtiTestIdentifier, reviewGameSlug) {
129469
- const client2 = this.requireClient();
129470
- const [test, result] = await Promise.all([
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(client2, test, hydrated);
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 = await this.prepareDiagnosticDefinition(requested);
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
- prepareDiagnosticDefinition(input) {
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
- assertQtiTestOwnedByGame(test, gameSlug);
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, body2.testIdentifier, targetTestIdentifier, body2.purpose, body2.standard);
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;