@playcademy/sandbox 0.7.1-beta.16 → 0.7.1-beta.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -297,7 +297,7 @@ var init_timeback2 = __esm(() => {
297
297
  ASSESSMENT_PURPOSES = ["end_of_course", "diagnostic", "review", "mastery"];
298
298
  TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS = {
299
299
  standards: 20,
300
- itemsPerStandard: 5,
300
+ candidateItemsPerStandard: 2,
301
301
  standardFieldLength: 128
302
302
  };
303
303
  TIMEBACK_COURSE_DEFAULTS = {
@@ -1123,7 +1123,7 @@ var package_default;
1123
1123
  var init_package = __esm(() => {
1124
1124
  package_default = {
1125
1125
  name: "@playcademy/sandbox",
1126
- version: "0.7.1-beta.16",
1126
+ version: "0.7.1-beta.17",
1127
1127
  description: "Local development server for Playcademy game development",
1128
1128
  type: "module",
1129
1129
  exports: {
@@ -9692,6 +9692,12 @@ function interactionResponseValidationMessage(itemIdentifier, interaction, respo
9692
9692
  function invalidResponse(message) {
9693
9693
  return { code: "INVALID_RESPONSE", message };
9694
9694
  }
9695
+ function administeredItemIdentifiers(itemSubmissions) {
9696
+ return new Set(itemSubmissions.map((submission) => submission.itemIdentifier));
9697
+ }
9698
+ function itemAdministration(itemSubmissions, itemIdentifier) {
9699
+ return itemSubmissions.find((submission) => submission.itemIdentifier === itemIdentifier);
9700
+ }
9695
9701
  function responseValuesEqual(left, right) {
9696
9702
  if (left === undefined || right === undefined) {
9697
9703
  return left === right;
@@ -9742,15 +9748,20 @@ function prepareAssessmentItemSubmission(state, input) {
9742
9748
  failure: assessmentResponseVersionConflict(input.expectedResponseVersion, state.responseVersion)
9743
9749
  };
9744
9750
  }
9745
- const committed = new Set(state.itemSubmissions.map((submission) => submission.itemIdentifier));
9746
- const itemIndex = state.assessment.items.findIndex((item) => !committed.has(item.identifier));
9747
- const nextItem = state.assessment.items[itemIndex];
9748
- if (nextItem?.identifier !== input.itemIdentifier) {
9751
+ const itemIndex = state.assessment.items.findIndex((item) => item.identifier === input.itemIdentifier);
9752
+ const committed = administeredItemIdentifiers(state.itemSubmissions);
9753
+ const expectedItemIndex = state.assessment.items.findIndex((item) => !committed.has(item.identifier));
9754
+ const previousItemIdentifier = state.itemSubmissions.at(-1)?.itemIdentifier;
9755
+ const previousItemIndex = state.assessment.items.findIndex((item) => item.identifier === previousItemIdentifier);
9756
+ const validOrder = state.purpose === "review" ? itemIndex > previousItemIndex : itemIndex === expectedItemIndex;
9757
+ if (itemIndex === -1 || !validOrder) {
9749
9758
  return {
9750
9759
  action: "reject",
9751
- failure: assessmentFlowViolation("Items must be submitted once and in presentation order.", {
9760
+ failure: assessmentFlowViolation(state.purpose === "review" ? "Items must be submitted once and in candidate order." : "Items must be submitted once and in presentation order.", {
9752
9761
  itemIdentifier: input.itemIdentifier,
9753
- expectedItemIdentifier: nextItem?.identifier ?? null,
9762
+ ...state.purpose === "review" ? { previousItemIdentifier: previousItemIdentifier ?? null } : {
9763
+ expectedItemIdentifier: state.assessment.items[expectedItemIndex]?.identifier ?? null
9764
+ },
9754
9765
  flow
9755
9766
  })
9756
9767
  };
@@ -9773,10 +9784,11 @@ function prepareAssessmentItemSubmission(state, input) {
9773
9784
  answered: Object.keys(responses[input.itemIdentifier] ?? {}).length > 0
9774
9785
  };
9775
9786
  }
9776
- function completeAssessmentItemSubmission(state, input, prepared, scoring) {
9787
+ function completeAssessmentItemSubmission(state, input, prepared, scoring, submittedAt) {
9777
9788
  const submission = {
9778
9789
  submissionId: input.submissionId,
9779
9790
  itemIdentifier: input.itemIdentifier,
9791
+ submittedAt,
9780
9792
  responseVersion: prepared.responseVersion,
9781
9793
  answered: prepared.answered,
9782
9794
  score: scoring.score,
@@ -9790,8 +9802,8 @@ function completeAssessmentItemSubmission(state, input, prepared, scoring) {
9790
9802
  };
9791
9803
  }
9792
9804
  function assessmentResponseUpdateFlowFailure(purpose, itemSubmissions, update) {
9793
- const committed = new Set(itemSubmissions.map((submission) => submission.itemIdentifier));
9794
- const committedUpdate = Object.keys(update).find((identifier) => committed.has(identifier));
9805
+ const administered = administeredItemIdentifiers(itemSubmissions);
9806
+ const committedUpdate = Object.keys(update).find((identifier) => administered.has(identifier));
9795
9807
  return committedUpdate ? assessmentFlowViolation("A submitted item response cannot be edited.", {
9796
9808
  itemIdentifier: committedUpdate,
9797
9809
  flow: assessmentFlowForPurpose(purpose)
@@ -9799,11 +9811,18 @@ function assessmentResponseUpdateFlowFailure(purpose, itemSubmissions, update) {
9799
9811
  }
9800
9812
  function assessmentCompletionFlowFailure(purpose, itemSubmissions, itemCount) {
9801
9813
  const flow = assessmentFlowForPurpose(purpose);
9802
- return flow === "item-submit" && itemSubmissions.length !== itemCount ? assessmentFlowViolation("Every item must be submitted before the attempt can be completed.", {
9814
+ if (flow !== "item-submit") {
9815
+ return null;
9816
+ }
9817
+ const complete = purpose === "review" ? itemSubmissions.length > 0 && itemSubmissions.length <= itemCount : itemSubmissions.length === itemCount;
9818
+ if (complete) {
9819
+ return null;
9820
+ }
9821
+ return assessmentFlowViolation(purpose === "review" ? "At least one candidate must be administered before the review can be completed." : "Every item must be submitted before the attempt can be completed.", {
9803
9822
  flow,
9804
9823
  submittedItemCount: itemSubmissions.length,
9805
9824
  itemCount
9806
- }) : null;
9825
+ });
9807
9826
  }
9808
9827
  function selectAssessmentCourseCandidate(candidates, subjectSpecified) {
9809
9828
  if (candidates.length === 0) {
@@ -9979,15 +9998,15 @@ function canonicalRequestStandards(standards) {
9979
9998
  return [...canonical.values()].toSorted((left, right) => compareCodeUnits(assessmentStandardRefKey(left), assessmentStandardRefKey(right)));
9980
9999
  }
9981
10000
  function normalizeReviewRequest(request) {
9982
- const itemsPerStandard = request.itemsPerStandard ?? 1;
9983
- if (!Number.isInteger(itemsPerStandard) || itemsPerStandard <= 0 || itemsPerStandard > TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.itemsPerStandard) {
9984
- throw new RangeError(`itemsPerStandard must be a positive integer at most ${TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.itemsPerStandard}`);
10001
+ const candidateItemsPerStandard = request.candidateItemsPerStandard ?? 1;
10002
+ if (!Number.isInteger(candidateItemsPerStandard) || candidateItemsPerStandard <= 0 || candidateItemsPerStandard > TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.candidateItemsPerStandard) {
10003
+ throw new RangeError(`candidateItemsPerStandard must be a positive integer at most ${TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.candidateItemsPerStandard}`);
9985
10004
  }
9986
10005
  const standards = canonicalRequestStandards(request.standards);
9987
10006
  if (standards.length === 0) {
9988
10007
  throw new Error("At least one review standard is required");
9989
10008
  }
9990
- return { standards, itemsPerStandard };
10009
+ return { standards, candidateItemsPerStandard };
9991
10010
  }
9992
10011
  function reviewRequestFingerprint(request, policy) {
9993
10012
  const normalized = normalizeReviewRequest(request);
@@ -9996,7 +10015,7 @@ function reviewRequestFingerprint(request, policy) {
9996
10015
  policy: {
9997
10016
  version: policy.version
9998
10017
  },
9999
- itemsPerStandard: normalized.itemsPerStandard,
10018
+ candidateItemsPerStandard: normalized.candidateItemsPerStandard,
10000
10019
  standardKeys: normalized.standards.map(assessmentStandardRefKey)
10001
10020
  });
10002
10021
  }
@@ -10096,7 +10115,7 @@ function rankedCandidatesByStandard(bank, exposures) {
10096
10115
  return byStandard;
10097
10116
  }
10098
10117
  function reviewSlots(request) {
10099
- return request.standards.flatMap((standard) => Array.from({ length: request.itemsPerStandard }, () => ({
10118
+ return request.standards.flatMap((standard) => Array.from({ length: request.candidateItemsPerStandard }, () => ({
10100
10119
  standard,
10101
10120
  standardKey: assessmentStandardRefKey(standard)
10102
10121
  })));
@@ -10200,7 +10219,7 @@ function preferredDistinctMatching(slots, candidatesByStandard) {
10200
10219
  return new Map(slotItemEdges.filter((candidate) => candidate.edge.capacity === 0).map((candidate) => [candidate.slotIndex, candidate.itemIdentifier]));
10201
10220
  }
10202
10221
  function fulfillment(request, candidatesByStandard, selections) {
10203
- const requestedItemCount = request.standards.length * request.itemsPerStandard;
10222
+ const requestedItemCount = request.standards.length * request.candidateItemsPerStandard;
10204
10223
  const selectedByStandard = new Map;
10205
10224
  for (const selection of selections) {
10206
10225
  const key = assessmentStandardRefKey(selection.standard);
@@ -10209,10 +10228,10 @@ function fulfillment(request, candidatesByStandard, selections) {
10209
10228
  const shortages = request.standards.flatMap((standard) => {
10210
10229
  const key = assessmentStandardRefKey(standard);
10211
10230
  const selected = selectedByStandard.get(key) ?? 0;
10212
- return selected < request.itemsPerStandard ? [
10231
+ return selected < request.candidateItemsPerStandard ? [
10213
10232
  {
10214
10233
  standard,
10215
- requested: request.itemsPerStandard,
10234
+ requested: request.candidateItemsPerStandard,
10216
10235
  selected,
10217
10236
  eligible: candidatesByStandard.get(key)?.length ?? 0
10218
10237
  }
@@ -10254,9 +10273,9 @@ function projectReviewAssessment(assessment, bank, selections) {
10254
10273
  throw new Error(`Review bank ${bank.bankRevision} was built from content revision ${bank.sourceContentRevision}, not ${assessment.contentRevision}`);
10255
10274
  }
10256
10275
  const selectedIdentifiers = new Set(selections.map((selection) => selection.itemIdentifier));
10257
- const knownIdentifiers = new Set(assessment.items.map((item) => item.identifier));
10276
+ const itemByIdentifier = new Map(assessment.items.map((item) => [item.identifier, item]));
10258
10277
  const indexedItems = new Map(bank.items.map((item) => [item.itemIdentifier, item]));
10259
- const missingIdentifiers = [...selectedIdentifiers].filter((identifier) => !knownIdentifiers.has(identifier) || !indexedItems.has(identifier));
10278
+ const missingIdentifiers = [...selectedIdentifiers].filter((identifier) => !itemByIdentifier.has(identifier) || !indexedItems.has(identifier));
10260
10279
  if (missingIdentifiers.length > 0) {
10261
10280
  throw new Error(`Selected review items are absent from the pinned bank: ${missingIdentifiers.join(", ")}`);
10262
10281
  }
@@ -10272,7 +10291,7 @@ function projectReviewAssessment(assessment, bank, selections) {
10272
10291
  return structuredClone({
10273
10292
  ...assessment,
10274
10293
  contentRevision: bank.bankRevision,
10275
- items: assessment.items.filter((item) => selectedIdentifiers.has(item.identifier))
10294
+ items: selections.map((selection) => itemByIdentifier.get(selection.itemIdentifier))
10276
10295
  });
10277
10296
  }
10278
10297
  function assessmentItemCorrectness(verdicts) {
@@ -10331,10 +10350,10 @@ function isReviewItemOutcome(value) {
10331
10350
  return isRecord(value) && typeof value.itemIdentifier === "string" && value.itemIdentifier.length > 0 && isAssessmentStandardRef(value.standard) && typeof value.answered === "boolean" && isAssessmentScore(value.score) && (value.isCorrect === null || typeof value.isCorrect === "boolean");
10332
10351
  }
10333
10352
  function isAssessmentItemSubmission(value) {
10334
- return isRecord(value) && typeof value.submissionId === "string" && value.submissionId.length > 0 && typeof value.itemIdentifier === "string" && value.itemIdentifier.length > 0 && Number.isInteger(value.responseVersion) && typeof value.answered === "boolean" && isAssessmentScore(value.score) && (value.isCorrect === null || typeof value.isCorrect === "boolean");
10353
+ return isRecord(value) && typeof value.submissionId === "string" && value.submissionId.length > 0 && typeof value.itemIdentifier === "string" && value.itemIdentifier.length > 0 && typeof value.submittedAt === "string" && Number.isInteger(value.responseVersion) && typeof value.answered === "boolean" && isAssessmentScore(value.score) && (value.isCorrect === null || typeof value.isCorrect === "boolean");
10335
10354
  }
10336
10355
  function isReviewAttemptMetadata(value) {
10337
- return isRecord(value) && typeof value.requestFingerprint === "string" && typeof value.bankRevision === "string" && Array.isArray(value.standards) && value.standards.every(isAssessmentStandardRef) && Number.isInteger(value.itemsPerStandard) && Array.isArray(value.selections) && value.selections.every((selection) => isRecord(selection) && isAssessmentStandardRef(selection.standard) && typeof selection.itemIdentifier === "string") && isReviewFulfillment(value.fulfillment);
10356
+ return isRecord(value) && typeof value.requestFingerprint === "string" && typeof value.bankRevision === "string" && Array.isArray(value.standards) && value.standards.every(isAssessmentStandardRef) && Number.isInteger(value.candidateItemsPerStandard) && Array.isArray(value.selections) && value.selections.every((selection) => isRecord(selection) && isAssessmentStandardRef(selection.standard) && typeof selection.itemIdentifier === "string") && isReviewFulfillment(value.fulfillment);
10338
10357
  }
10339
10358
  function isMasteryAttemptMetadata(value) {
10340
10359
  return isRecord(value) && typeof value.requestFingerprint === "string" && isAssessmentStandardRef(value.standard);
@@ -10644,7 +10663,7 @@ var init_assessment_runtime = __esm(() => {
10644
10663
  StartAssessmentBaseSchema.extend({
10645
10664
  purpose: exports_external.literal("review"),
10646
10665
  standards: exports_external.array(AssessmentStandardRefSchema).min(1).max(TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.standards),
10647
- itemsPerStandard: exports_external.number().int().positive().max(TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.itemsPerStandard).optional()
10666
+ candidateItemsPerStandard: exports_external.number().int().positive().max(TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.candidateItemsPerStandard).optional()
10648
10667
  }),
10649
10668
  StartAssessmentBaseSchema.extend({
10650
10669
  purpose: exports_external.literal("mastery"),
@@ -90938,6 +90957,10 @@ var init_timeback4 = __esm(() => {
90938
90957
 
90939
90958
  // ../api-core/src/utils/timeback-assessment-runtime.util.ts
90940
90959
  function buildReviewItemResultMetadata(input) {
90960
+ const administration = itemAdministration(input.metadata.itemSubmissions, input.selection.itemIdentifier);
90961
+ if (!administration) {
90962
+ throw new Error(`Cannot create a review result for unadministered candidate ${input.selection.itemIdentifier}`);
90963
+ }
90941
90964
  return {
90942
90965
  version: PLAYCADEMY_ASSESSMENT_ITEM_RESULT_METADATA_VERSION,
90943
90966
  parentAttemptId: input.parentAttemptId,
@@ -90947,7 +90970,7 @@ function buildReviewItemResultMetadata(input) {
90947
90970
  bankRevision: input.metadata.review.bankRevision,
90948
90971
  itemIdentifier: input.selection.itemIdentifier,
90949
90972
  standard: input.selection.standard,
90950
- deliveredAt: input.metadata.startedAt,
90973
+ deliveredAt: administration.submittedAt,
90951
90974
  responseVersion: input.responseVersion ?? input.metadata.responseVersion,
90952
90975
  responses: input.metadata.responses[input.selection.itemIdentifier] ?? {},
90953
90976
  ...input.submissionId ? { submissionId: input.submissionId } : {},
@@ -91002,7 +91025,8 @@ function resolveReviewChildResult(input) {
91002
91025
  }
91003
91026
  const childMetadata = playcademyAssessmentItemResultMetadata(input.childResult.metadata);
91004
91027
  const expectedResponses = input.parentMetadata.responses[input.selection.itemIdentifier] ?? {};
91005
- if (input.childResult.assessmentLineItem.sourcedId !== input.childLineItemId || input.childResult.student.sourcedId !== input.parentResult.student.sourcedId || !childMetadata || childMetadata.parentAttemptId !== input.parentResult.sourcedId || childMetadata.activityId !== input.parentMetadata.activityId || childMetadata.integrationId !== input.parentMetadata.integrationId || childMetadata.enrollmentId !== input.parentMetadata.enrollmentId || childMetadata.bankRevision !== input.parentMetadata.review.bankRevision || childMetadata.itemIdentifier !== input.selection.itemIdentifier || childMetadata.standard.framework !== input.selection.standard.framework || childMetadata.standard.identifier !== input.selection.standard.identifier || childMetadata.deliveredAt !== input.parentMetadata.startedAt || childMetadata.responseVersion > input.parentMetadata.responseVersion || canonicalJson(childMetadata.responses) !== canonicalJson(expectedResponses)) {
91028
+ const administration = itemAdministration(input.parentMetadata.itemSubmissions, input.selection.itemIdentifier);
91029
+ if (input.childResult.assessmentLineItem.sourcedId !== input.childLineItemId || input.childResult.student.sourcedId !== input.parentResult.student.sourcedId || !childMetadata || childMetadata.parentAttemptId !== input.parentResult.sourcedId || childMetadata.activityId !== input.parentMetadata.activityId || childMetadata.integrationId !== input.parentMetadata.integrationId || childMetadata.enrollmentId !== input.parentMetadata.enrollmentId || childMetadata.bankRevision !== input.parentMetadata.review.bankRevision || childMetadata.itemIdentifier !== input.selection.itemIdentifier || childMetadata.standard.framework !== input.selection.standard.framework || childMetadata.standard.identifier !== input.selection.standard.identifier || childMetadata.deliveredAt !== administration?.submittedAt || childMetadata.responseVersion > input.parentMetadata.responseVersion || canonicalJson(childMetadata.responses) !== canonicalJson(expectedResponses)) {
91006
91030
  return { action: "restore", reason: "incompatible" };
91007
91031
  }
91008
91032
  return input.childResult.status === "active" ? { action: "reuse", metadata: childMetadata } : { action: "restore", reason: "inactive" };
@@ -91693,7 +91717,7 @@ class TimebackAssessmentRuntimeService {
91693
91717
  requestFingerprint,
91694
91718
  bankRevision: source.bank.bankRevision,
91695
91719
  standards: [...normalizedRequest.standards],
91696
- itemsPerStandard: normalizedRequest.itemsPerStandard,
91720
+ candidateItemsPerStandard: normalizedRequest.candidateItemsPerStandard,
91697
91721
  selections: [...selected.selections],
91698
91722
  fulfillment: selected.fulfillment
91699
91723
  }
@@ -91825,9 +91849,6 @@ class TimebackAssessmentRuntimeService {
91825
91849
  updatedAt: new Date().toISOString()
91826
91850
  };
91827
91851
  await this.putResultMetadata(attempt.result, metadata2);
91828
- if (metadata2.purpose === "review") {
91829
- await this.putReviewChildResponses(attempt.result, metadata2, new Set(Object.keys(input.responses)));
91830
- }
91831
91852
  return {
91832
91853
  attemptId: attempt.result.sourcedId,
91833
91854
  responseVersion: metadata2.responseVersion,
@@ -91895,13 +91916,14 @@ class TimebackAssessmentRuntimeService {
91895
91916
  if (!preview.scoring) {
91896
91917
  throw new Error("Prepared item submission is missing its score");
91897
91918
  }
91898
- const completed = completeAssessmentItemSubmission(attempt.metadata, input, transition, preview.scoring);
91919
+ const submittedAt = new Date().toISOString();
91920
+ const completed = completeAssessmentItemSubmission(attempt.metadata, input, transition, preview.scoring, submittedAt);
91899
91921
  const metadata2 = {
91900
91922
  ...attempt.metadata,
91901
91923
  responses: completed.responses,
91902
91924
  responseVersion: completed.responseVersion,
91903
91925
  itemSubmissions: completed.itemSubmissions,
91904
- updatedAt: new Date().toISOString()
91926
+ updatedAt: submittedAt
91905
91927
  };
91906
91928
  await this.putResultMetadata(attempt.result, metadata2);
91907
91929
  return {
@@ -92006,12 +92028,13 @@ class TimebackAssessmentRuntimeService {
92006
92028
  async scoreSubmission(attempt) {
92007
92029
  const assessment = await this.loadAttemptAssessment(attempt.metadata);
92008
92030
  validateAssessmentResponses(assessment, attempt.metadata.responses);
92009
- const itemResults = await this.scoreItems(assessment, attempt.metadata.responses);
92031
+ const administeredItems = attempt.metadata.purpose === "review" ? administeredItemIdentifiers(attempt.metadata.itemSubmissions) : undefined;
92032
+ const itemResults = await this.scoreItems(assessment, attempt.metadata.responses, administeredItems);
92010
92033
  return {
92011
92034
  responseVersion: attempt.metadata.responseVersion,
92012
92035
  assessment,
92013
92036
  itemResults,
92014
- score: aggregateAssessmentScore(itemResults.map((result) => result.score.earned), assessmentPossibleScore(assessment)),
92037
+ score: aggregateAssessmentScore(itemResults.map((result) => result.score.earned), itemResults.reduce((total, result) => total + result.score.possible, 0)),
92015
92038
  correctQuestions: countCorrectQuestions(itemResults.map((result) => result.isCorrect))
92016
92039
  };
92017
92040
  }
@@ -92104,7 +92127,7 @@ class TimebackAssessmentRuntimeService {
92104
92127
  submissionId: input.submissionId,
92105
92128
  timestamp: timestamp6,
92106
92129
  testName: assessment.title,
92107
- totalQuestions: assessment.items.length,
92130
+ totalQuestions: itemResults.length,
92108
92131
  correctQuestions,
92109
92132
  ...input.session ? { session: input.session } : {},
92110
92133
  ...reviewOutcomes ? { itemOutcomes: reviewOutcomes } : {}
@@ -92135,7 +92158,7 @@ class TimebackAssessmentRuntimeService {
92135
92158
  testIdentifier: assessment.identifier,
92136
92159
  testName: assessment.title,
92137
92160
  score,
92138
- totalQuestions: assessment.items.length,
92161
+ totalQuestions: itemResults.length,
92139
92162
  correctQuestions,
92140
92163
  submissionId: input.submissionId,
92141
92164
  gameId: params.gameId,
@@ -92679,11 +92702,21 @@ class TimebackAssessmentRuntimeService {
92679
92702
  if (physicalItems.size !== input.metadata.review.selections.length) {
92680
92703
  throw new Error("MVP review selection must use each physical item at most once");
92681
92704
  }
92705
+ const administeredItems = administeredItemIdentifiers(input.metadata.itemSubmissions);
92682
92706
  await runWithConcurrency(input.metadata.review.selections, TimebackAssessmentRuntimeService.SCORING_CONCURRENCY, async (selection) => {
92683
92707
  const item = itemByIdentifier.get(selection.itemIdentifier);
92684
92708
  if (!item) {
92685
92709
  throw new Error(`Review assessment is missing selected item ${selection.itemIdentifier}`);
92686
92710
  }
92711
+ if (!administeredItems.has(selection.itemIdentifier)) {
92712
+ await this.ensureReviewQuestionLineItem({
92713
+ parentLineItemId: input.result.assessmentLineItem.sourcedId,
92714
+ integration: input.integration,
92715
+ bank: input.source.bank,
92716
+ item
92717
+ });
92718
+ return;
92719
+ }
92687
92720
  const childLineItemId = await reviewQuestionLineItemId(input.result.assessmentLineItem.sourcedId, item.identifier);
92688
92721
  const childResultId = await reviewQuestionResultId(input.result.sourcedId, childLineItemId);
92689
92722
  const streamedChildResult = input.existingChildResults.get(childResultId);
@@ -92730,33 +92763,35 @@ class TimebackAssessmentRuntimeService {
92730
92763
  });
92731
92764
  });
92732
92765
  }
92733
- async putReviewChildResponses(result, metadata2, changedItemIdentifiers) {
92734
- await runWithConcurrency(metadata2.review.selections.filter((selection) => changedItemIdentifiers.has(selection.itemIdentifier)), TimebackAssessmentRuntimeService.SCORING_CONCURRENCY, async (selection) => {
92735
- try {
92736
- const childLineItemId = await reviewQuestionLineItemId(result.assessmentLineItem.sourcedId, selection.itemIdentifier);
92737
- const childResultId = await reviewQuestionResultId(result.sourcedId, childLineItemId);
92738
- const childMetadata = buildReviewItemResultMetadata({
92739
- metadata: metadata2,
92740
- selection,
92741
- parentAttemptId: result.sourcedId
92742
- });
92743
- await this.requireClient().api.oneroster.assessmentResults.upsert(childResultId, buildOpenReviewChildResult({
92744
- childLineItemId,
92745
- student: result.student,
92746
- metadata: childMetadata
92747
- }));
92748
- } catch (error88) {
92749
- addEvent("assessment.review_child_response_projection_failed", {
92750
- "app.assessment.attempt_id": result.sourcedId,
92751
- "app.assessment.qti_item_identifier": selection.itemIdentifier,
92752
- "exception.type": errorType(error88),
92753
- "app.error.message": errorMessage(error88)
92754
- });
92755
- }
92756
- });
92766
+ async putReviewChildResponse(result, metadata2, itemIdentifier) {
92767
+ const selection = metadata2.review.selections.find((candidate) => candidate.itemIdentifier === itemIdentifier);
92768
+ if (!selection) {
92769
+ return;
92770
+ }
92771
+ try {
92772
+ const childLineItemId = await reviewQuestionLineItemId(result.assessmentLineItem.sourcedId, selection.itemIdentifier);
92773
+ const childResultId = await reviewQuestionResultId(result.sourcedId, childLineItemId);
92774
+ const childMetadata = buildReviewItemResultMetadata({
92775
+ metadata: metadata2,
92776
+ selection,
92777
+ parentAttemptId: result.sourcedId
92778
+ });
92779
+ await this.requireClient().api.oneroster.assessmentResults.upsert(childResultId, buildOpenReviewChildResult({
92780
+ childLineItemId,
92781
+ student: result.student,
92782
+ metadata: childMetadata
92783
+ }));
92784
+ } catch (error88) {
92785
+ addEvent("assessment.review_child_response_projection_failed", {
92786
+ "app.assessment.attempt_id": result.sourcedId,
92787
+ "app.assessment.qti_item_identifier": selection.itemIdentifier,
92788
+ "exception.type": errorType(error88),
92789
+ "app.error.message": errorMessage(error88)
92790
+ });
92791
+ }
92757
92792
  }
92758
92793
  async projectReviewItemResponse(params, projection) {
92759
- await this.putReviewChildResponses(projection.result, projection.metadata, new Set([projection.itemIdentifier]));
92794
+ await this.putReviewChildResponse(projection.result, projection.metadata, projection.itemIdentifier);
92760
92795
  try {
92761
92796
  await this.crossAttemptLockBarrier(params.attemptId);
92762
92797
  const latest = await this.peekAttempt(params);
@@ -92835,7 +92870,8 @@ class TimebackAssessmentRuntimeService {
92835
92870
  }
92836
92871
  async finalizeReviewChildResults(input) {
92837
92872
  const resultByItem = new Map(input.itemResults.map((itemResult) => [itemResult.itemIdentifier, itemResult]));
92838
- return runWithConcurrency(input.metadata.review.selections, TimebackAssessmentRuntimeService.SCORING_CONCURRENCY, async (selection) => {
92873
+ const administeredItems = administeredItemIdentifiers(input.metadata.itemSubmissions);
92874
+ return runWithConcurrency(input.metadata.review.selections.filter((selection) => administeredItems.has(selection.itemIdentifier)), TimebackAssessmentRuntimeService.SCORING_CONCURRENCY, async (selection) => {
92839
92875
  const itemResult = resultByItem.get(selection.itemIdentifier);
92840
92876
  if (!itemResult) {
92841
92877
  throw new Error(`Scoring omitted selected review item ${selection.itemIdentifier}`);
@@ -92951,7 +92987,7 @@ class TimebackAssessmentRuntimeService {
92951
92987
  kind: "standards-review",
92952
92988
  purpose: metadata2.purpose,
92953
92989
  standards: [...metadata2.review.standards],
92954
- itemsPerStandard: metadata2.review.itemsPerStandard,
92990
+ candidateItemsPerStandard: metadata2.review.candidateItemsPerStandard,
92955
92991
  selections: [...metadata2.review.selections],
92956
92992
  fulfillment: metadata2.review.fulfillment
92957
92993
  };
package/dist/constants.js CHANGED
@@ -112,7 +112,7 @@ var init_timeback = __esm(() => {
112
112
  ASSESSMENT_PURPOSES = ["end_of_course", "diagnostic", "review", "mastery"];
113
113
  TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS = {
114
114
  standards: 20,
115
- itemsPerStandard: 5,
115
+ candidateItemsPerStandard: 2,
116
116
  standardFieldLength: 128
117
117
  };
118
118
  TIMEBACK_COURSE_DEFAULTS = {
package/dist/server.js CHANGED
@@ -296,7 +296,7 @@ var init_timeback2 = __esm(() => {
296
296
  ASSESSMENT_PURPOSES = ["end_of_course", "diagnostic", "review", "mastery"];
297
297
  TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS = {
298
298
  standards: 20,
299
- itemsPerStandard: 5,
299
+ candidateItemsPerStandard: 2,
300
300
  standardFieldLength: 128
301
301
  };
302
302
  TIMEBACK_COURSE_DEFAULTS = {
@@ -1122,7 +1122,7 @@ var package_default;
1122
1122
  var init_package = __esm(() => {
1123
1123
  package_default = {
1124
1124
  name: "@playcademy/sandbox",
1125
- version: "0.7.1-beta.16",
1125
+ version: "0.7.1-beta.17",
1126
1126
  description: "Local development server for Playcademy game development",
1127
1127
  type: "module",
1128
1128
  exports: {
@@ -9691,6 +9691,12 @@ function interactionResponseValidationMessage(itemIdentifier, interaction, respo
9691
9691
  function invalidResponse(message) {
9692
9692
  return { code: "INVALID_RESPONSE", message };
9693
9693
  }
9694
+ function administeredItemIdentifiers(itemSubmissions) {
9695
+ return new Set(itemSubmissions.map((submission) => submission.itemIdentifier));
9696
+ }
9697
+ function itemAdministration(itemSubmissions, itemIdentifier) {
9698
+ return itemSubmissions.find((submission) => submission.itemIdentifier === itemIdentifier);
9699
+ }
9694
9700
  function responseValuesEqual(left, right) {
9695
9701
  if (left === undefined || right === undefined) {
9696
9702
  return left === right;
@@ -9741,15 +9747,20 @@ function prepareAssessmentItemSubmission(state, input) {
9741
9747
  failure: assessmentResponseVersionConflict(input.expectedResponseVersion, state.responseVersion)
9742
9748
  };
9743
9749
  }
9744
- const committed = new Set(state.itemSubmissions.map((submission) => submission.itemIdentifier));
9745
- const itemIndex = state.assessment.items.findIndex((item) => !committed.has(item.identifier));
9746
- const nextItem = state.assessment.items[itemIndex];
9747
- if (nextItem?.identifier !== input.itemIdentifier) {
9750
+ const itemIndex = state.assessment.items.findIndex((item) => item.identifier === input.itemIdentifier);
9751
+ const committed = administeredItemIdentifiers(state.itemSubmissions);
9752
+ const expectedItemIndex = state.assessment.items.findIndex((item) => !committed.has(item.identifier));
9753
+ const previousItemIdentifier = state.itemSubmissions.at(-1)?.itemIdentifier;
9754
+ const previousItemIndex = state.assessment.items.findIndex((item) => item.identifier === previousItemIdentifier);
9755
+ const validOrder = state.purpose === "review" ? itemIndex > previousItemIndex : itemIndex === expectedItemIndex;
9756
+ if (itemIndex === -1 || !validOrder) {
9748
9757
  return {
9749
9758
  action: "reject",
9750
- failure: assessmentFlowViolation("Items must be submitted once and in presentation order.", {
9759
+ failure: assessmentFlowViolation(state.purpose === "review" ? "Items must be submitted once and in candidate order." : "Items must be submitted once and in presentation order.", {
9751
9760
  itemIdentifier: input.itemIdentifier,
9752
- expectedItemIdentifier: nextItem?.identifier ?? null,
9761
+ ...state.purpose === "review" ? { previousItemIdentifier: previousItemIdentifier ?? null } : {
9762
+ expectedItemIdentifier: state.assessment.items[expectedItemIndex]?.identifier ?? null
9763
+ },
9753
9764
  flow
9754
9765
  })
9755
9766
  };
@@ -9772,10 +9783,11 @@ function prepareAssessmentItemSubmission(state, input) {
9772
9783
  answered: Object.keys(responses[input.itemIdentifier] ?? {}).length > 0
9773
9784
  };
9774
9785
  }
9775
- function completeAssessmentItemSubmission(state, input, prepared, scoring) {
9786
+ function completeAssessmentItemSubmission(state, input, prepared, scoring, submittedAt) {
9776
9787
  const submission = {
9777
9788
  submissionId: input.submissionId,
9778
9789
  itemIdentifier: input.itemIdentifier,
9790
+ submittedAt,
9779
9791
  responseVersion: prepared.responseVersion,
9780
9792
  answered: prepared.answered,
9781
9793
  score: scoring.score,
@@ -9789,8 +9801,8 @@ function completeAssessmentItemSubmission(state, input, prepared, scoring) {
9789
9801
  };
9790
9802
  }
9791
9803
  function assessmentResponseUpdateFlowFailure(purpose, itemSubmissions, update) {
9792
- const committed = new Set(itemSubmissions.map((submission) => submission.itemIdentifier));
9793
- const committedUpdate = Object.keys(update).find((identifier) => committed.has(identifier));
9804
+ const administered = administeredItemIdentifiers(itemSubmissions);
9805
+ const committedUpdate = Object.keys(update).find((identifier) => administered.has(identifier));
9794
9806
  return committedUpdate ? assessmentFlowViolation("A submitted item response cannot be edited.", {
9795
9807
  itemIdentifier: committedUpdate,
9796
9808
  flow: assessmentFlowForPurpose(purpose)
@@ -9798,11 +9810,18 @@ function assessmentResponseUpdateFlowFailure(purpose, itemSubmissions, update) {
9798
9810
  }
9799
9811
  function assessmentCompletionFlowFailure(purpose, itemSubmissions, itemCount) {
9800
9812
  const flow = assessmentFlowForPurpose(purpose);
9801
- return flow === "item-submit" && itemSubmissions.length !== itemCount ? assessmentFlowViolation("Every item must be submitted before the attempt can be completed.", {
9813
+ if (flow !== "item-submit") {
9814
+ return null;
9815
+ }
9816
+ const complete = purpose === "review" ? itemSubmissions.length > 0 && itemSubmissions.length <= itemCount : itemSubmissions.length === itemCount;
9817
+ if (complete) {
9818
+ return null;
9819
+ }
9820
+ return assessmentFlowViolation(purpose === "review" ? "At least one candidate must be administered before the review can be completed." : "Every item must be submitted before the attempt can be completed.", {
9802
9821
  flow,
9803
9822
  submittedItemCount: itemSubmissions.length,
9804
9823
  itemCount
9805
- }) : null;
9824
+ });
9806
9825
  }
9807
9826
  function selectAssessmentCourseCandidate(candidates, subjectSpecified) {
9808
9827
  if (candidates.length === 0) {
@@ -9978,15 +9997,15 @@ function canonicalRequestStandards(standards) {
9978
9997
  return [...canonical.values()].toSorted((left, right) => compareCodeUnits(assessmentStandardRefKey(left), assessmentStandardRefKey(right)));
9979
9998
  }
9980
9999
  function normalizeReviewRequest(request) {
9981
- const itemsPerStandard = request.itemsPerStandard ?? 1;
9982
- if (!Number.isInteger(itemsPerStandard) || itemsPerStandard <= 0 || itemsPerStandard > TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.itemsPerStandard) {
9983
- throw new RangeError(`itemsPerStandard must be a positive integer at most ${TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.itemsPerStandard}`);
10000
+ const candidateItemsPerStandard = request.candidateItemsPerStandard ?? 1;
10001
+ if (!Number.isInteger(candidateItemsPerStandard) || candidateItemsPerStandard <= 0 || candidateItemsPerStandard > TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.candidateItemsPerStandard) {
10002
+ throw new RangeError(`candidateItemsPerStandard must be a positive integer at most ${TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.candidateItemsPerStandard}`);
9984
10003
  }
9985
10004
  const standards = canonicalRequestStandards(request.standards);
9986
10005
  if (standards.length === 0) {
9987
10006
  throw new Error("At least one review standard is required");
9988
10007
  }
9989
- return { standards, itemsPerStandard };
10008
+ return { standards, candidateItemsPerStandard };
9990
10009
  }
9991
10010
  function reviewRequestFingerprint(request, policy) {
9992
10011
  const normalized = normalizeReviewRequest(request);
@@ -9995,7 +10014,7 @@ function reviewRequestFingerprint(request, policy) {
9995
10014
  policy: {
9996
10015
  version: policy.version
9997
10016
  },
9998
- itemsPerStandard: normalized.itemsPerStandard,
10017
+ candidateItemsPerStandard: normalized.candidateItemsPerStandard,
9999
10018
  standardKeys: normalized.standards.map(assessmentStandardRefKey)
10000
10019
  });
10001
10020
  }
@@ -10095,7 +10114,7 @@ function rankedCandidatesByStandard(bank, exposures) {
10095
10114
  return byStandard;
10096
10115
  }
10097
10116
  function reviewSlots(request) {
10098
- return request.standards.flatMap((standard) => Array.from({ length: request.itemsPerStandard }, () => ({
10117
+ return request.standards.flatMap((standard) => Array.from({ length: request.candidateItemsPerStandard }, () => ({
10099
10118
  standard,
10100
10119
  standardKey: assessmentStandardRefKey(standard)
10101
10120
  })));
@@ -10199,7 +10218,7 @@ function preferredDistinctMatching(slots, candidatesByStandard) {
10199
10218
  return new Map(slotItemEdges.filter((candidate) => candidate.edge.capacity === 0).map((candidate) => [candidate.slotIndex, candidate.itemIdentifier]));
10200
10219
  }
10201
10220
  function fulfillment(request, candidatesByStandard, selections) {
10202
- const requestedItemCount = request.standards.length * request.itemsPerStandard;
10221
+ const requestedItemCount = request.standards.length * request.candidateItemsPerStandard;
10203
10222
  const selectedByStandard = new Map;
10204
10223
  for (const selection of selections) {
10205
10224
  const key = assessmentStandardRefKey(selection.standard);
@@ -10208,10 +10227,10 @@ function fulfillment(request, candidatesByStandard, selections) {
10208
10227
  const shortages = request.standards.flatMap((standard) => {
10209
10228
  const key = assessmentStandardRefKey(standard);
10210
10229
  const selected = selectedByStandard.get(key) ?? 0;
10211
- return selected < request.itemsPerStandard ? [
10230
+ return selected < request.candidateItemsPerStandard ? [
10212
10231
  {
10213
10232
  standard,
10214
- requested: request.itemsPerStandard,
10233
+ requested: request.candidateItemsPerStandard,
10215
10234
  selected,
10216
10235
  eligible: candidatesByStandard.get(key)?.length ?? 0
10217
10236
  }
@@ -10253,9 +10272,9 @@ function projectReviewAssessment(assessment, bank, selections) {
10253
10272
  throw new Error(`Review bank ${bank.bankRevision} was built from content revision ${bank.sourceContentRevision}, not ${assessment.contentRevision}`);
10254
10273
  }
10255
10274
  const selectedIdentifiers = new Set(selections.map((selection) => selection.itemIdentifier));
10256
- const knownIdentifiers = new Set(assessment.items.map((item) => item.identifier));
10275
+ const itemByIdentifier = new Map(assessment.items.map((item) => [item.identifier, item]));
10257
10276
  const indexedItems = new Map(bank.items.map((item) => [item.itemIdentifier, item]));
10258
- const missingIdentifiers = [...selectedIdentifiers].filter((identifier) => !knownIdentifiers.has(identifier) || !indexedItems.has(identifier));
10277
+ const missingIdentifiers = [...selectedIdentifiers].filter((identifier) => !itemByIdentifier.has(identifier) || !indexedItems.has(identifier));
10259
10278
  if (missingIdentifiers.length > 0) {
10260
10279
  throw new Error(`Selected review items are absent from the pinned bank: ${missingIdentifiers.join(", ")}`);
10261
10280
  }
@@ -10271,7 +10290,7 @@ function projectReviewAssessment(assessment, bank, selections) {
10271
10290
  return structuredClone({
10272
10291
  ...assessment,
10273
10292
  contentRevision: bank.bankRevision,
10274
- items: assessment.items.filter((item) => selectedIdentifiers.has(item.identifier))
10293
+ items: selections.map((selection) => itemByIdentifier.get(selection.itemIdentifier))
10275
10294
  });
10276
10295
  }
10277
10296
  function assessmentItemCorrectness(verdicts) {
@@ -10330,10 +10349,10 @@ function isReviewItemOutcome(value) {
10330
10349
  return isRecord(value) && typeof value.itemIdentifier === "string" && value.itemIdentifier.length > 0 && isAssessmentStandardRef(value.standard) && typeof value.answered === "boolean" && isAssessmentScore(value.score) && (value.isCorrect === null || typeof value.isCorrect === "boolean");
10331
10350
  }
10332
10351
  function isAssessmentItemSubmission(value) {
10333
- return isRecord(value) && typeof value.submissionId === "string" && value.submissionId.length > 0 && typeof value.itemIdentifier === "string" && value.itemIdentifier.length > 0 && Number.isInteger(value.responseVersion) && typeof value.answered === "boolean" && isAssessmentScore(value.score) && (value.isCorrect === null || typeof value.isCorrect === "boolean");
10352
+ return isRecord(value) && typeof value.submissionId === "string" && value.submissionId.length > 0 && typeof value.itemIdentifier === "string" && value.itemIdentifier.length > 0 && typeof value.submittedAt === "string" && Number.isInteger(value.responseVersion) && typeof value.answered === "boolean" && isAssessmentScore(value.score) && (value.isCorrect === null || typeof value.isCorrect === "boolean");
10334
10353
  }
10335
10354
  function isReviewAttemptMetadata(value) {
10336
- return isRecord(value) && typeof value.requestFingerprint === "string" && typeof value.bankRevision === "string" && Array.isArray(value.standards) && value.standards.every(isAssessmentStandardRef) && Number.isInteger(value.itemsPerStandard) && Array.isArray(value.selections) && value.selections.every((selection) => isRecord(selection) && isAssessmentStandardRef(selection.standard) && typeof selection.itemIdentifier === "string") && isReviewFulfillment(value.fulfillment);
10355
+ return isRecord(value) && typeof value.requestFingerprint === "string" && typeof value.bankRevision === "string" && Array.isArray(value.standards) && value.standards.every(isAssessmentStandardRef) && Number.isInteger(value.candidateItemsPerStandard) && Array.isArray(value.selections) && value.selections.every((selection) => isRecord(selection) && isAssessmentStandardRef(selection.standard) && typeof selection.itemIdentifier === "string") && isReviewFulfillment(value.fulfillment);
10337
10356
  }
10338
10357
  function isMasteryAttemptMetadata(value) {
10339
10358
  return isRecord(value) && typeof value.requestFingerprint === "string" && isAssessmentStandardRef(value.standard);
@@ -10643,7 +10662,7 @@ var init_assessment_runtime = __esm(() => {
10643
10662
  StartAssessmentBaseSchema.extend({
10644
10663
  purpose: exports_external.literal("review"),
10645
10664
  standards: exports_external.array(AssessmentStandardRefSchema).min(1).max(TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.standards),
10646
- itemsPerStandard: exports_external.number().int().positive().max(TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.itemsPerStandard).optional()
10665
+ candidateItemsPerStandard: exports_external.number().int().positive().max(TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.candidateItemsPerStandard).optional()
10647
10666
  }),
10648
10667
  StartAssessmentBaseSchema.extend({
10649
10668
  purpose: exports_external.literal("mastery"),
@@ -90937,6 +90956,10 @@ var init_timeback4 = __esm(() => {
90937
90956
 
90938
90957
  // ../api-core/src/utils/timeback-assessment-runtime.util.ts
90939
90958
  function buildReviewItemResultMetadata(input) {
90959
+ const administration = itemAdministration(input.metadata.itemSubmissions, input.selection.itemIdentifier);
90960
+ if (!administration) {
90961
+ throw new Error(`Cannot create a review result for unadministered candidate ${input.selection.itemIdentifier}`);
90962
+ }
90940
90963
  return {
90941
90964
  version: PLAYCADEMY_ASSESSMENT_ITEM_RESULT_METADATA_VERSION,
90942
90965
  parentAttemptId: input.parentAttemptId,
@@ -90946,7 +90969,7 @@ function buildReviewItemResultMetadata(input) {
90946
90969
  bankRevision: input.metadata.review.bankRevision,
90947
90970
  itemIdentifier: input.selection.itemIdentifier,
90948
90971
  standard: input.selection.standard,
90949
- deliveredAt: input.metadata.startedAt,
90972
+ deliveredAt: administration.submittedAt,
90950
90973
  responseVersion: input.responseVersion ?? input.metadata.responseVersion,
90951
90974
  responses: input.metadata.responses[input.selection.itemIdentifier] ?? {},
90952
90975
  ...input.submissionId ? { submissionId: input.submissionId } : {},
@@ -91001,7 +91024,8 @@ function resolveReviewChildResult(input) {
91001
91024
  }
91002
91025
  const childMetadata = playcademyAssessmentItemResultMetadata(input.childResult.metadata);
91003
91026
  const expectedResponses = input.parentMetadata.responses[input.selection.itemIdentifier] ?? {};
91004
- if (input.childResult.assessmentLineItem.sourcedId !== input.childLineItemId || input.childResult.student.sourcedId !== input.parentResult.student.sourcedId || !childMetadata || childMetadata.parentAttemptId !== input.parentResult.sourcedId || childMetadata.activityId !== input.parentMetadata.activityId || childMetadata.integrationId !== input.parentMetadata.integrationId || childMetadata.enrollmentId !== input.parentMetadata.enrollmentId || childMetadata.bankRevision !== input.parentMetadata.review.bankRevision || childMetadata.itemIdentifier !== input.selection.itemIdentifier || childMetadata.standard.framework !== input.selection.standard.framework || childMetadata.standard.identifier !== input.selection.standard.identifier || childMetadata.deliveredAt !== input.parentMetadata.startedAt || childMetadata.responseVersion > input.parentMetadata.responseVersion || canonicalJson(childMetadata.responses) !== canonicalJson(expectedResponses)) {
91027
+ const administration = itemAdministration(input.parentMetadata.itemSubmissions, input.selection.itemIdentifier);
91028
+ if (input.childResult.assessmentLineItem.sourcedId !== input.childLineItemId || input.childResult.student.sourcedId !== input.parentResult.student.sourcedId || !childMetadata || childMetadata.parentAttemptId !== input.parentResult.sourcedId || childMetadata.activityId !== input.parentMetadata.activityId || childMetadata.integrationId !== input.parentMetadata.integrationId || childMetadata.enrollmentId !== input.parentMetadata.enrollmentId || childMetadata.bankRevision !== input.parentMetadata.review.bankRevision || childMetadata.itemIdentifier !== input.selection.itemIdentifier || childMetadata.standard.framework !== input.selection.standard.framework || childMetadata.standard.identifier !== input.selection.standard.identifier || childMetadata.deliveredAt !== administration?.submittedAt || childMetadata.responseVersion > input.parentMetadata.responseVersion || canonicalJson(childMetadata.responses) !== canonicalJson(expectedResponses)) {
91005
91029
  return { action: "restore", reason: "incompatible" };
91006
91030
  }
91007
91031
  return input.childResult.status === "active" ? { action: "reuse", metadata: childMetadata } : { action: "restore", reason: "inactive" };
@@ -91692,7 +91716,7 @@ class TimebackAssessmentRuntimeService {
91692
91716
  requestFingerprint,
91693
91717
  bankRevision: source.bank.bankRevision,
91694
91718
  standards: [...normalizedRequest.standards],
91695
- itemsPerStandard: normalizedRequest.itemsPerStandard,
91719
+ candidateItemsPerStandard: normalizedRequest.candidateItemsPerStandard,
91696
91720
  selections: [...selected.selections],
91697
91721
  fulfillment: selected.fulfillment
91698
91722
  }
@@ -91824,9 +91848,6 @@ class TimebackAssessmentRuntimeService {
91824
91848
  updatedAt: new Date().toISOString()
91825
91849
  };
91826
91850
  await this.putResultMetadata(attempt.result, metadata2);
91827
- if (metadata2.purpose === "review") {
91828
- await this.putReviewChildResponses(attempt.result, metadata2, new Set(Object.keys(input.responses)));
91829
- }
91830
91851
  return {
91831
91852
  attemptId: attempt.result.sourcedId,
91832
91853
  responseVersion: metadata2.responseVersion,
@@ -91894,13 +91915,14 @@ class TimebackAssessmentRuntimeService {
91894
91915
  if (!preview.scoring) {
91895
91916
  throw new Error("Prepared item submission is missing its score");
91896
91917
  }
91897
- const completed = completeAssessmentItemSubmission(attempt.metadata, input, transition, preview.scoring);
91918
+ const submittedAt = new Date().toISOString();
91919
+ const completed = completeAssessmentItemSubmission(attempt.metadata, input, transition, preview.scoring, submittedAt);
91898
91920
  const metadata2 = {
91899
91921
  ...attempt.metadata,
91900
91922
  responses: completed.responses,
91901
91923
  responseVersion: completed.responseVersion,
91902
91924
  itemSubmissions: completed.itemSubmissions,
91903
- updatedAt: new Date().toISOString()
91925
+ updatedAt: submittedAt
91904
91926
  };
91905
91927
  await this.putResultMetadata(attempt.result, metadata2);
91906
91928
  return {
@@ -92005,12 +92027,13 @@ class TimebackAssessmentRuntimeService {
92005
92027
  async scoreSubmission(attempt) {
92006
92028
  const assessment = await this.loadAttemptAssessment(attempt.metadata);
92007
92029
  validateAssessmentResponses(assessment, attempt.metadata.responses);
92008
- const itemResults = await this.scoreItems(assessment, attempt.metadata.responses);
92030
+ const administeredItems = attempt.metadata.purpose === "review" ? administeredItemIdentifiers(attempt.metadata.itemSubmissions) : undefined;
92031
+ const itemResults = await this.scoreItems(assessment, attempt.metadata.responses, administeredItems);
92009
92032
  return {
92010
92033
  responseVersion: attempt.metadata.responseVersion,
92011
92034
  assessment,
92012
92035
  itemResults,
92013
- score: aggregateAssessmentScore(itemResults.map((result) => result.score.earned), assessmentPossibleScore(assessment)),
92036
+ score: aggregateAssessmentScore(itemResults.map((result) => result.score.earned), itemResults.reduce((total, result) => total + result.score.possible, 0)),
92014
92037
  correctQuestions: countCorrectQuestions(itemResults.map((result) => result.isCorrect))
92015
92038
  };
92016
92039
  }
@@ -92103,7 +92126,7 @@ class TimebackAssessmentRuntimeService {
92103
92126
  submissionId: input.submissionId,
92104
92127
  timestamp: timestamp6,
92105
92128
  testName: assessment.title,
92106
- totalQuestions: assessment.items.length,
92129
+ totalQuestions: itemResults.length,
92107
92130
  correctQuestions,
92108
92131
  ...input.session ? { session: input.session } : {},
92109
92132
  ...reviewOutcomes ? { itemOutcomes: reviewOutcomes } : {}
@@ -92134,7 +92157,7 @@ class TimebackAssessmentRuntimeService {
92134
92157
  testIdentifier: assessment.identifier,
92135
92158
  testName: assessment.title,
92136
92159
  score,
92137
- totalQuestions: assessment.items.length,
92160
+ totalQuestions: itemResults.length,
92138
92161
  correctQuestions,
92139
92162
  submissionId: input.submissionId,
92140
92163
  gameId: params.gameId,
@@ -92678,11 +92701,21 @@ class TimebackAssessmentRuntimeService {
92678
92701
  if (physicalItems.size !== input.metadata.review.selections.length) {
92679
92702
  throw new Error("MVP review selection must use each physical item at most once");
92680
92703
  }
92704
+ const administeredItems = administeredItemIdentifiers(input.metadata.itemSubmissions);
92681
92705
  await runWithConcurrency(input.metadata.review.selections, TimebackAssessmentRuntimeService.SCORING_CONCURRENCY, async (selection) => {
92682
92706
  const item = itemByIdentifier.get(selection.itemIdentifier);
92683
92707
  if (!item) {
92684
92708
  throw new Error(`Review assessment is missing selected item ${selection.itemIdentifier}`);
92685
92709
  }
92710
+ if (!administeredItems.has(selection.itemIdentifier)) {
92711
+ await this.ensureReviewQuestionLineItem({
92712
+ parentLineItemId: input.result.assessmentLineItem.sourcedId,
92713
+ integration: input.integration,
92714
+ bank: input.source.bank,
92715
+ item
92716
+ });
92717
+ return;
92718
+ }
92686
92719
  const childLineItemId = await reviewQuestionLineItemId(input.result.assessmentLineItem.sourcedId, item.identifier);
92687
92720
  const childResultId = await reviewQuestionResultId(input.result.sourcedId, childLineItemId);
92688
92721
  const streamedChildResult = input.existingChildResults.get(childResultId);
@@ -92729,33 +92762,35 @@ class TimebackAssessmentRuntimeService {
92729
92762
  });
92730
92763
  });
92731
92764
  }
92732
- async putReviewChildResponses(result, metadata2, changedItemIdentifiers) {
92733
- await runWithConcurrency(metadata2.review.selections.filter((selection) => changedItemIdentifiers.has(selection.itemIdentifier)), TimebackAssessmentRuntimeService.SCORING_CONCURRENCY, async (selection) => {
92734
- try {
92735
- const childLineItemId = await reviewQuestionLineItemId(result.assessmentLineItem.sourcedId, selection.itemIdentifier);
92736
- const childResultId = await reviewQuestionResultId(result.sourcedId, childLineItemId);
92737
- const childMetadata = buildReviewItemResultMetadata({
92738
- metadata: metadata2,
92739
- selection,
92740
- parentAttemptId: result.sourcedId
92741
- });
92742
- await this.requireClient().api.oneroster.assessmentResults.upsert(childResultId, buildOpenReviewChildResult({
92743
- childLineItemId,
92744
- student: result.student,
92745
- metadata: childMetadata
92746
- }));
92747
- } catch (error88) {
92748
- addEvent("assessment.review_child_response_projection_failed", {
92749
- "app.assessment.attempt_id": result.sourcedId,
92750
- "app.assessment.qti_item_identifier": selection.itemIdentifier,
92751
- "exception.type": errorType(error88),
92752
- "app.error.message": errorMessage(error88)
92753
- });
92754
- }
92755
- });
92765
+ async putReviewChildResponse(result, metadata2, itemIdentifier) {
92766
+ const selection = metadata2.review.selections.find((candidate) => candidate.itemIdentifier === itemIdentifier);
92767
+ if (!selection) {
92768
+ return;
92769
+ }
92770
+ try {
92771
+ const childLineItemId = await reviewQuestionLineItemId(result.assessmentLineItem.sourcedId, selection.itemIdentifier);
92772
+ const childResultId = await reviewQuestionResultId(result.sourcedId, childLineItemId);
92773
+ const childMetadata = buildReviewItemResultMetadata({
92774
+ metadata: metadata2,
92775
+ selection,
92776
+ parentAttemptId: result.sourcedId
92777
+ });
92778
+ await this.requireClient().api.oneroster.assessmentResults.upsert(childResultId, buildOpenReviewChildResult({
92779
+ childLineItemId,
92780
+ student: result.student,
92781
+ metadata: childMetadata
92782
+ }));
92783
+ } catch (error88) {
92784
+ addEvent("assessment.review_child_response_projection_failed", {
92785
+ "app.assessment.attempt_id": result.sourcedId,
92786
+ "app.assessment.qti_item_identifier": selection.itemIdentifier,
92787
+ "exception.type": errorType(error88),
92788
+ "app.error.message": errorMessage(error88)
92789
+ });
92790
+ }
92756
92791
  }
92757
92792
  async projectReviewItemResponse(params, projection) {
92758
- await this.putReviewChildResponses(projection.result, projection.metadata, new Set([projection.itemIdentifier]));
92793
+ await this.putReviewChildResponse(projection.result, projection.metadata, projection.itemIdentifier);
92759
92794
  try {
92760
92795
  await this.crossAttemptLockBarrier(params.attemptId);
92761
92796
  const latest = await this.peekAttempt(params);
@@ -92834,7 +92869,8 @@ class TimebackAssessmentRuntimeService {
92834
92869
  }
92835
92870
  async finalizeReviewChildResults(input) {
92836
92871
  const resultByItem = new Map(input.itemResults.map((itemResult) => [itemResult.itemIdentifier, itemResult]));
92837
- return runWithConcurrency(input.metadata.review.selections, TimebackAssessmentRuntimeService.SCORING_CONCURRENCY, async (selection) => {
92872
+ const administeredItems = administeredItemIdentifiers(input.metadata.itemSubmissions);
92873
+ return runWithConcurrency(input.metadata.review.selections.filter((selection) => administeredItems.has(selection.itemIdentifier)), TimebackAssessmentRuntimeService.SCORING_CONCURRENCY, async (selection) => {
92838
92874
  const itemResult = resultByItem.get(selection.itemIdentifier);
92839
92875
  if (!itemResult) {
92840
92876
  throw new Error(`Scoring omitted selected review item ${selection.itemIdentifier}`);
@@ -92950,7 +92986,7 @@ class TimebackAssessmentRuntimeService {
92950
92986
  kind: "standards-review",
92951
92987
  purpose: metadata2.purpose,
92952
92988
  standards: [...metadata2.review.standards],
92953
- itemsPerStandard: metadata2.review.itemsPerStandard,
92989
+ candidateItemsPerStandard: metadata2.review.candidateItemsPerStandard,
92954
92990
  selections: [...metadata2.review.selections],
92955
92991
  fulfillment: metadata2.review.fulfillment
92956
92992
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcademy/sandbox",
3
- "version": "0.7.1-beta.16",
3
+ "version": "0.7.1-beta.17",
4
4
  "description": "Local development server for Playcademy game development",
5
5
  "type": "module",
6
6
  "exports": {