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

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.18",
1127
1127
  description: "Local development server for Playcademy game development",
1128
1128
  type: "module",
1129
1129
  exports: {
@@ -9490,7 +9490,7 @@ function assessmentReviewBankShortage(fulfillment) {
9490
9490
  };
9491
9491
  }
9492
9492
  function assessmentFlowForPurpose(purpose) {
9493
- return purpose === "review" || purpose === "mastery" ? "item-submit" : "attempt-submit";
9493
+ return purpose === "review" ? "item-submit" : "attempt-submit";
9494
9494
  }
9495
9495
  function isObject(value) {
9496
9496
  return typeof value === "object" && value !== null;
@@ -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,15 @@ 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 previousItemIdentifier = state.itemSubmissions.at(-1)?.itemIdentifier;
9753
+ const previousItemIndex = state.assessment.items.findIndex((item) => item.identifier === previousItemIdentifier);
9754
+ if (itemIndex === -1 || itemIndex <= previousItemIndex) {
9749
9755
  return {
9750
9756
  action: "reject",
9751
- failure: assessmentFlowViolation("Items must be submitted once and in presentation order.", {
9757
+ failure: assessmentFlowViolation("Items must be submitted once and in candidate order.", {
9752
9758
  itemIdentifier: input.itemIdentifier,
9753
- expectedItemIdentifier: nextItem?.identifier ?? null,
9759
+ previousItemIdentifier: previousItemIdentifier ?? null,
9754
9760
  flow
9755
9761
  })
9756
9762
  };
@@ -9773,10 +9779,11 @@ function prepareAssessmentItemSubmission(state, input) {
9773
9779
  answered: Object.keys(responses[input.itemIdentifier] ?? {}).length > 0
9774
9780
  };
9775
9781
  }
9776
- function completeAssessmentItemSubmission(state, input, prepared, scoring) {
9782
+ function completeAssessmentItemSubmission(state, input, prepared, scoring, submittedAt) {
9777
9783
  const submission = {
9778
9784
  submissionId: input.submissionId,
9779
9785
  itemIdentifier: input.itemIdentifier,
9786
+ submittedAt,
9780
9787
  responseVersion: prepared.responseVersion,
9781
9788
  answered: prepared.answered,
9782
9789
  score: scoring.score,
@@ -9790,8 +9797,8 @@ function completeAssessmentItemSubmission(state, input, prepared, scoring) {
9790
9797
  };
9791
9798
  }
9792
9799
  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));
9800
+ const administered = administeredItemIdentifiers(itemSubmissions);
9801
+ const committedUpdate = Object.keys(update).find((identifier) => administered.has(identifier));
9795
9802
  return committedUpdate ? assessmentFlowViolation("A submitted item response cannot be edited.", {
9796
9803
  itemIdentifier: committedUpdate,
9797
9804
  flow: assessmentFlowForPurpose(purpose)
@@ -9799,11 +9806,17 @@ function assessmentResponseUpdateFlowFailure(purpose, itemSubmissions, update) {
9799
9806
  }
9800
9807
  function assessmentCompletionFlowFailure(purpose, itemSubmissions, itemCount) {
9801
9808
  const flow = assessmentFlowForPurpose(purpose);
9802
- return flow === "item-submit" && itemSubmissions.length !== itemCount ? assessmentFlowViolation("Every item must be submitted before the attempt can be completed.", {
9809
+ if (flow !== "item-submit") {
9810
+ return null;
9811
+ }
9812
+ if (itemSubmissions.length > 0 && itemSubmissions.length <= itemCount) {
9813
+ return null;
9814
+ }
9815
+ return assessmentFlowViolation("At least one candidate must be administered before the review can be completed.", {
9803
9816
  flow,
9804
9817
  submittedItemCount: itemSubmissions.length,
9805
9818
  itemCount
9806
- }) : null;
9819
+ });
9807
9820
  }
9808
9821
  function selectAssessmentCourseCandidate(candidates, subjectSpecified) {
9809
9822
  if (candidates.length === 0) {
@@ -9979,15 +9992,15 @@ function canonicalRequestStandards(standards) {
9979
9992
  return [...canonical.values()].toSorted((left, right) => compareCodeUnits(assessmentStandardRefKey(left), assessmentStandardRefKey(right)));
9980
9993
  }
9981
9994
  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}`);
9995
+ const candidateItemsPerStandard = request.candidateItemsPerStandard ?? 1;
9996
+ if (!Number.isInteger(candidateItemsPerStandard) || candidateItemsPerStandard <= 0 || candidateItemsPerStandard > TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.candidateItemsPerStandard) {
9997
+ throw new RangeError(`candidateItemsPerStandard must be a positive integer at most ${TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.candidateItemsPerStandard}`);
9985
9998
  }
9986
9999
  const standards = canonicalRequestStandards(request.standards);
9987
10000
  if (standards.length === 0) {
9988
10001
  throw new Error("At least one review standard is required");
9989
10002
  }
9990
- return { standards, itemsPerStandard };
10003
+ return { standards, candidateItemsPerStandard };
9991
10004
  }
9992
10005
  function reviewRequestFingerprint(request, policy) {
9993
10006
  const normalized = normalizeReviewRequest(request);
@@ -9996,7 +10009,7 @@ function reviewRequestFingerprint(request, policy) {
9996
10009
  policy: {
9997
10010
  version: policy.version
9998
10011
  },
9999
- itemsPerStandard: normalized.itemsPerStandard,
10012
+ candidateItemsPerStandard: normalized.candidateItemsPerStandard,
10000
10013
  standardKeys: normalized.standards.map(assessmentStandardRefKey)
10001
10014
  });
10002
10015
  }
@@ -10096,7 +10109,7 @@ function rankedCandidatesByStandard(bank, exposures) {
10096
10109
  return byStandard;
10097
10110
  }
10098
10111
  function reviewSlots(request) {
10099
- return request.standards.flatMap((standard) => Array.from({ length: request.itemsPerStandard }, () => ({
10112
+ return request.standards.flatMap((standard) => Array.from({ length: request.candidateItemsPerStandard }, () => ({
10100
10113
  standard,
10101
10114
  standardKey: assessmentStandardRefKey(standard)
10102
10115
  })));
@@ -10200,7 +10213,7 @@ function preferredDistinctMatching(slots, candidatesByStandard) {
10200
10213
  return new Map(slotItemEdges.filter((candidate) => candidate.edge.capacity === 0).map((candidate) => [candidate.slotIndex, candidate.itemIdentifier]));
10201
10214
  }
10202
10215
  function fulfillment(request, candidatesByStandard, selections) {
10203
- const requestedItemCount = request.standards.length * request.itemsPerStandard;
10216
+ const requestedItemCount = request.standards.length * request.candidateItemsPerStandard;
10204
10217
  const selectedByStandard = new Map;
10205
10218
  for (const selection of selections) {
10206
10219
  const key = assessmentStandardRefKey(selection.standard);
@@ -10209,10 +10222,10 @@ function fulfillment(request, candidatesByStandard, selections) {
10209
10222
  const shortages = request.standards.flatMap((standard) => {
10210
10223
  const key = assessmentStandardRefKey(standard);
10211
10224
  const selected = selectedByStandard.get(key) ?? 0;
10212
- return selected < request.itemsPerStandard ? [
10225
+ return selected < request.candidateItemsPerStandard ? [
10213
10226
  {
10214
10227
  standard,
10215
- requested: request.itemsPerStandard,
10228
+ requested: request.candidateItemsPerStandard,
10216
10229
  selected,
10217
10230
  eligible: candidatesByStandard.get(key)?.length ?? 0
10218
10231
  }
@@ -10254,9 +10267,9 @@ function projectReviewAssessment(assessment, bank, selections) {
10254
10267
  throw new Error(`Review bank ${bank.bankRevision} was built from content revision ${bank.sourceContentRevision}, not ${assessment.contentRevision}`);
10255
10268
  }
10256
10269
  const selectedIdentifiers = new Set(selections.map((selection) => selection.itemIdentifier));
10257
- const knownIdentifiers = new Set(assessment.items.map((item) => item.identifier));
10270
+ const itemByIdentifier = new Map(assessment.items.map((item) => [item.identifier, item]));
10258
10271
  const indexedItems = new Map(bank.items.map((item) => [item.itemIdentifier, item]));
10259
- const missingIdentifiers = [...selectedIdentifiers].filter((identifier) => !knownIdentifiers.has(identifier) || !indexedItems.has(identifier));
10272
+ const missingIdentifiers = [...selectedIdentifiers].filter((identifier) => !itemByIdentifier.has(identifier) || !indexedItems.has(identifier));
10260
10273
  if (missingIdentifiers.length > 0) {
10261
10274
  throw new Error(`Selected review items are absent from the pinned bank: ${missingIdentifiers.join(", ")}`);
10262
10275
  }
@@ -10272,7 +10285,7 @@ function projectReviewAssessment(assessment, bank, selections) {
10272
10285
  return structuredClone({
10273
10286
  ...assessment,
10274
10287
  contentRevision: bank.bankRevision,
10275
- items: assessment.items.filter((item) => selectedIdentifiers.has(item.identifier))
10288
+ items: selections.map((selection) => itemByIdentifier.get(selection.itemIdentifier))
10276
10289
  });
10277
10290
  }
10278
10291
  function assessmentItemCorrectness(verdicts) {
@@ -10331,10 +10344,10 @@ function isReviewItemOutcome(value) {
10331
10344
  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
10345
  }
10333
10346
  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");
10347
+ 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
10348
  }
10336
10349
  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);
10350
+ 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
10351
  }
10339
10352
  function isMasteryAttemptMetadata(value) {
10340
10353
  return isRecord(value) && typeof value.requestFingerprint === "string" && isAssessmentStandardRef(value.standard);
@@ -10644,7 +10657,7 @@ var init_assessment_runtime = __esm(() => {
10644
10657
  StartAssessmentBaseSchema.extend({
10645
10658
  purpose: exports_external.literal("review"),
10646
10659
  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()
10660
+ candidateItemsPerStandard: exports_external.number().int().positive().max(TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.candidateItemsPerStandard).optional()
10648
10661
  }),
10649
10662
  StartAssessmentBaseSchema.extend({
10650
10663
  purpose: exports_external.literal("mastery"),
@@ -90938,6 +90951,10 @@ var init_timeback4 = __esm(() => {
90938
90951
 
90939
90952
  // ../api-core/src/utils/timeback-assessment-runtime.util.ts
90940
90953
  function buildReviewItemResultMetadata(input) {
90954
+ const administration = itemAdministration(input.metadata.itemSubmissions, input.selection.itemIdentifier);
90955
+ if (!administration) {
90956
+ throw new Error(`Cannot create a review result for unadministered candidate ${input.selection.itemIdentifier}`);
90957
+ }
90941
90958
  return {
90942
90959
  version: PLAYCADEMY_ASSESSMENT_ITEM_RESULT_METADATA_VERSION,
90943
90960
  parentAttemptId: input.parentAttemptId,
@@ -90947,7 +90964,7 @@ function buildReviewItemResultMetadata(input) {
90947
90964
  bankRevision: input.metadata.review.bankRevision,
90948
90965
  itemIdentifier: input.selection.itemIdentifier,
90949
90966
  standard: input.selection.standard,
90950
- deliveredAt: input.metadata.startedAt,
90967
+ deliveredAt: administration.submittedAt,
90951
90968
  responseVersion: input.responseVersion ?? input.metadata.responseVersion,
90952
90969
  responses: input.metadata.responses[input.selection.itemIdentifier] ?? {},
90953
90970
  ...input.submissionId ? { submissionId: input.submissionId } : {},
@@ -91002,7 +91019,8 @@ function resolveReviewChildResult(input) {
91002
91019
  }
91003
91020
  const childMetadata = playcademyAssessmentItemResultMetadata(input.childResult.metadata);
91004
91021
  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)) {
91022
+ const administration = itemAdministration(input.parentMetadata.itemSubmissions, input.selection.itemIdentifier);
91023
+ 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
91024
  return { action: "restore", reason: "incompatible" };
91007
91025
  }
91008
91026
  return input.childResult.status === "active" ? { action: "reuse", metadata: childMetadata } : { action: "restore", reason: "inactive" };
@@ -91693,7 +91711,7 @@ class TimebackAssessmentRuntimeService {
91693
91711
  requestFingerprint,
91694
91712
  bankRevision: source.bank.bankRevision,
91695
91713
  standards: [...normalizedRequest.standards],
91696
- itemsPerStandard: normalizedRequest.itemsPerStandard,
91714
+ candidateItemsPerStandard: normalizedRequest.candidateItemsPerStandard,
91697
91715
  selections: [...selected.selections],
91698
91716
  fulfillment: selected.fulfillment
91699
91717
  }
@@ -91825,9 +91843,6 @@ class TimebackAssessmentRuntimeService {
91825
91843
  updatedAt: new Date().toISOString()
91826
91844
  };
91827
91845
  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
91846
  return {
91832
91847
  attemptId: attempt.result.sourcedId,
91833
91848
  responseVersion: metadata2.responseVersion,
@@ -91895,13 +91910,14 @@ class TimebackAssessmentRuntimeService {
91895
91910
  if (!preview.scoring) {
91896
91911
  throw new Error("Prepared item submission is missing its score");
91897
91912
  }
91898
- const completed = completeAssessmentItemSubmission(attempt.metadata, input, transition, preview.scoring);
91913
+ const submittedAt = new Date().toISOString();
91914
+ const completed = completeAssessmentItemSubmission(attempt.metadata, input, transition, preview.scoring, submittedAt);
91899
91915
  const metadata2 = {
91900
91916
  ...attempt.metadata,
91901
91917
  responses: completed.responses,
91902
91918
  responseVersion: completed.responseVersion,
91903
91919
  itemSubmissions: completed.itemSubmissions,
91904
- updatedAt: new Date().toISOString()
91920
+ updatedAt: submittedAt
91905
91921
  };
91906
91922
  await this.putResultMetadata(attempt.result, metadata2);
91907
91923
  return {
@@ -92006,12 +92022,13 @@ class TimebackAssessmentRuntimeService {
92006
92022
  async scoreSubmission(attempt) {
92007
92023
  const assessment = await this.loadAttemptAssessment(attempt.metadata);
92008
92024
  validateAssessmentResponses(assessment, attempt.metadata.responses);
92009
- const itemResults = await this.scoreItems(assessment, attempt.metadata.responses);
92025
+ const administeredItems = attempt.metadata.purpose === "review" ? administeredItemIdentifiers(attempt.metadata.itemSubmissions) : undefined;
92026
+ const itemResults = await this.scoreItems(assessment, attempt.metadata.responses, administeredItems);
92010
92027
  return {
92011
92028
  responseVersion: attempt.metadata.responseVersion,
92012
92029
  assessment,
92013
92030
  itemResults,
92014
- score: aggregateAssessmentScore(itemResults.map((result) => result.score.earned), assessmentPossibleScore(assessment)),
92031
+ score: aggregateAssessmentScore(itemResults.map((result) => result.score.earned), itemResults.reduce((total, result) => total + result.score.possible, 0)),
92015
92032
  correctQuestions: countCorrectQuestions(itemResults.map((result) => result.isCorrect))
92016
92033
  };
92017
92034
  }
@@ -92104,7 +92121,7 @@ class TimebackAssessmentRuntimeService {
92104
92121
  submissionId: input.submissionId,
92105
92122
  timestamp: timestamp6,
92106
92123
  testName: assessment.title,
92107
- totalQuestions: assessment.items.length,
92124
+ totalQuestions: itemResults.length,
92108
92125
  correctQuestions,
92109
92126
  ...input.session ? { session: input.session } : {},
92110
92127
  ...reviewOutcomes ? { itemOutcomes: reviewOutcomes } : {}
@@ -92135,7 +92152,7 @@ class TimebackAssessmentRuntimeService {
92135
92152
  testIdentifier: assessment.identifier,
92136
92153
  testName: assessment.title,
92137
92154
  score,
92138
- totalQuestions: assessment.items.length,
92155
+ totalQuestions: itemResults.length,
92139
92156
  correctQuestions,
92140
92157
  submissionId: input.submissionId,
92141
92158
  gameId: params.gameId,
@@ -92679,11 +92696,21 @@ class TimebackAssessmentRuntimeService {
92679
92696
  if (physicalItems.size !== input.metadata.review.selections.length) {
92680
92697
  throw new Error("MVP review selection must use each physical item at most once");
92681
92698
  }
92699
+ const administeredItems = administeredItemIdentifiers(input.metadata.itemSubmissions);
92682
92700
  await runWithConcurrency(input.metadata.review.selections, TimebackAssessmentRuntimeService.SCORING_CONCURRENCY, async (selection) => {
92683
92701
  const item = itemByIdentifier.get(selection.itemIdentifier);
92684
92702
  if (!item) {
92685
92703
  throw new Error(`Review assessment is missing selected item ${selection.itemIdentifier}`);
92686
92704
  }
92705
+ if (!administeredItems.has(selection.itemIdentifier)) {
92706
+ await this.ensureReviewQuestionLineItem({
92707
+ parentLineItemId: input.result.assessmentLineItem.sourcedId,
92708
+ integration: input.integration,
92709
+ bank: input.source.bank,
92710
+ item
92711
+ });
92712
+ return;
92713
+ }
92687
92714
  const childLineItemId = await reviewQuestionLineItemId(input.result.assessmentLineItem.sourcedId, item.identifier);
92688
92715
  const childResultId = await reviewQuestionResultId(input.result.sourcedId, childLineItemId);
92689
92716
  const streamedChildResult = input.existingChildResults.get(childResultId);
@@ -92730,33 +92757,35 @@ class TimebackAssessmentRuntimeService {
92730
92757
  });
92731
92758
  });
92732
92759
  }
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
- });
92760
+ async putReviewChildResponse(result, metadata2, itemIdentifier) {
92761
+ const selection = metadata2.review.selections.find((candidate) => candidate.itemIdentifier === itemIdentifier);
92762
+ if (!selection) {
92763
+ return;
92764
+ }
92765
+ try {
92766
+ const childLineItemId = await reviewQuestionLineItemId(result.assessmentLineItem.sourcedId, selection.itemIdentifier);
92767
+ const childResultId = await reviewQuestionResultId(result.sourcedId, childLineItemId);
92768
+ const childMetadata = buildReviewItemResultMetadata({
92769
+ metadata: metadata2,
92770
+ selection,
92771
+ parentAttemptId: result.sourcedId
92772
+ });
92773
+ await this.requireClient().api.oneroster.assessmentResults.upsert(childResultId, buildOpenReviewChildResult({
92774
+ childLineItemId,
92775
+ student: result.student,
92776
+ metadata: childMetadata
92777
+ }));
92778
+ } catch (error88) {
92779
+ addEvent("assessment.review_child_response_projection_failed", {
92780
+ "app.assessment.attempt_id": result.sourcedId,
92781
+ "app.assessment.qti_item_identifier": selection.itemIdentifier,
92782
+ "exception.type": errorType(error88),
92783
+ "app.error.message": errorMessage(error88)
92784
+ });
92785
+ }
92757
92786
  }
92758
92787
  async projectReviewItemResponse(params, projection) {
92759
- await this.putReviewChildResponses(projection.result, projection.metadata, new Set([projection.itemIdentifier]));
92788
+ await this.putReviewChildResponse(projection.result, projection.metadata, projection.itemIdentifier);
92760
92789
  try {
92761
92790
  await this.crossAttemptLockBarrier(params.attemptId);
92762
92791
  const latest = await this.peekAttempt(params);
@@ -92835,7 +92864,8 @@ class TimebackAssessmentRuntimeService {
92835
92864
  }
92836
92865
  async finalizeReviewChildResults(input) {
92837
92866
  const resultByItem = new Map(input.itemResults.map((itemResult) => [itemResult.itemIdentifier, itemResult]));
92838
- return runWithConcurrency(input.metadata.review.selections, TimebackAssessmentRuntimeService.SCORING_CONCURRENCY, async (selection) => {
92867
+ const administeredItems = administeredItemIdentifiers(input.metadata.itemSubmissions);
92868
+ return runWithConcurrency(input.metadata.review.selections.filter((selection) => administeredItems.has(selection.itemIdentifier)), TimebackAssessmentRuntimeService.SCORING_CONCURRENCY, async (selection) => {
92839
92869
  const itemResult = resultByItem.get(selection.itemIdentifier);
92840
92870
  if (!itemResult) {
92841
92871
  throw new Error(`Scoring omitted selected review item ${selection.itemIdentifier}`);
@@ -92951,7 +92981,7 @@ class TimebackAssessmentRuntimeService {
92951
92981
  kind: "standards-review",
92952
92982
  purpose: metadata2.purpose,
92953
92983
  standards: [...metadata2.review.standards],
92954
- itemsPerStandard: metadata2.review.itemsPerStandard,
92984
+ candidateItemsPerStandard: metadata2.review.candidateItemsPerStandard,
92955
92985
  selections: [...metadata2.review.selections],
92956
92986
  fulfillment: metadata2.review.fulfillment
92957
92987
  };
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.18",
1126
1126
  description: "Local development server for Playcademy game development",
1127
1127
  type: "module",
1128
1128
  exports: {
@@ -9489,7 +9489,7 @@ function assessmentReviewBankShortage(fulfillment) {
9489
9489
  };
9490
9490
  }
9491
9491
  function assessmentFlowForPurpose(purpose) {
9492
- return purpose === "review" || purpose === "mastery" ? "item-submit" : "attempt-submit";
9492
+ return purpose === "review" ? "item-submit" : "attempt-submit";
9493
9493
  }
9494
9494
  function isObject(value) {
9495
9495
  return typeof value === "object" && value !== null;
@@ -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,15 @@ 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 previousItemIdentifier = state.itemSubmissions.at(-1)?.itemIdentifier;
9752
+ const previousItemIndex = state.assessment.items.findIndex((item) => item.identifier === previousItemIdentifier);
9753
+ if (itemIndex === -1 || itemIndex <= previousItemIndex) {
9748
9754
  return {
9749
9755
  action: "reject",
9750
- failure: assessmentFlowViolation("Items must be submitted once and in presentation order.", {
9756
+ failure: assessmentFlowViolation("Items must be submitted once and in candidate order.", {
9751
9757
  itemIdentifier: input.itemIdentifier,
9752
- expectedItemIdentifier: nextItem?.identifier ?? null,
9758
+ previousItemIdentifier: previousItemIdentifier ?? null,
9753
9759
  flow
9754
9760
  })
9755
9761
  };
@@ -9772,10 +9778,11 @@ function prepareAssessmentItemSubmission(state, input) {
9772
9778
  answered: Object.keys(responses[input.itemIdentifier] ?? {}).length > 0
9773
9779
  };
9774
9780
  }
9775
- function completeAssessmentItemSubmission(state, input, prepared, scoring) {
9781
+ function completeAssessmentItemSubmission(state, input, prepared, scoring, submittedAt) {
9776
9782
  const submission = {
9777
9783
  submissionId: input.submissionId,
9778
9784
  itemIdentifier: input.itemIdentifier,
9785
+ submittedAt,
9779
9786
  responseVersion: prepared.responseVersion,
9780
9787
  answered: prepared.answered,
9781
9788
  score: scoring.score,
@@ -9789,8 +9796,8 @@ function completeAssessmentItemSubmission(state, input, prepared, scoring) {
9789
9796
  };
9790
9797
  }
9791
9798
  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));
9799
+ const administered = administeredItemIdentifiers(itemSubmissions);
9800
+ const committedUpdate = Object.keys(update).find((identifier) => administered.has(identifier));
9794
9801
  return committedUpdate ? assessmentFlowViolation("A submitted item response cannot be edited.", {
9795
9802
  itemIdentifier: committedUpdate,
9796
9803
  flow: assessmentFlowForPurpose(purpose)
@@ -9798,11 +9805,17 @@ function assessmentResponseUpdateFlowFailure(purpose, itemSubmissions, update) {
9798
9805
  }
9799
9806
  function assessmentCompletionFlowFailure(purpose, itemSubmissions, itemCount) {
9800
9807
  const flow = assessmentFlowForPurpose(purpose);
9801
- return flow === "item-submit" && itemSubmissions.length !== itemCount ? assessmentFlowViolation("Every item must be submitted before the attempt can be completed.", {
9808
+ if (flow !== "item-submit") {
9809
+ return null;
9810
+ }
9811
+ if (itemSubmissions.length > 0 && itemSubmissions.length <= itemCount) {
9812
+ return null;
9813
+ }
9814
+ return assessmentFlowViolation("At least one candidate must be administered before the review can be completed.", {
9802
9815
  flow,
9803
9816
  submittedItemCount: itemSubmissions.length,
9804
9817
  itemCount
9805
- }) : null;
9818
+ });
9806
9819
  }
9807
9820
  function selectAssessmentCourseCandidate(candidates, subjectSpecified) {
9808
9821
  if (candidates.length === 0) {
@@ -9978,15 +9991,15 @@ function canonicalRequestStandards(standards) {
9978
9991
  return [...canonical.values()].toSorted((left, right) => compareCodeUnits(assessmentStandardRefKey(left), assessmentStandardRefKey(right)));
9979
9992
  }
9980
9993
  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}`);
9994
+ const candidateItemsPerStandard = request.candidateItemsPerStandard ?? 1;
9995
+ if (!Number.isInteger(candidateItemsPerStandard) || candidateItemsPerStandard <= 0 || candidateItemsPerStandard > TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.candidateItemsPerStandard) {
9996
+ throw new RangeError(`candidateItemsPerStandard must be a positive integer at most ${TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.candidateItemsPerStandard}`);
9984
9997
  }
9985
9998
  const standards = canonicalRequestStandards(request.standards);
9986
9999
  if (standards.length === 0) {
9987
10000
  throw new Error("At least one review standard is required");
9988
10001
  }
9989
- return { standards, itemsPerStandard };
10002
+ return { standards, candidateItemsPerStandard };
9990
10003
  }
9991
10004
  function reviewRequestFingerprint(request, policy) {
9992
10005
  const normalized = normalizeReviewRequest(request);
@@ -9995,7 +10008,7 @@ function reviewRequestFingerprint(request, policy) {
9995
10008
  policy: {
9996
10009
  version: policy.version
9997
10010
  },
9998
- itemsPerStandard: normalized.itemsPerStandard,
10011
+ candidateItemsPerStandard: normalized.candidateItemsPerStandard,
9999
10012
  standardKeys: normalized.standards.map(assessmentStandardRefKey)
10000
10013
  });
10001
10014
  }
@@ -10095,7 +10108,7 @@ function rankedCandidatesByStandard(bank, exposures) {
10095
10108
  return byStandard;
10096
10109
  }
10097
10110
  function reviewSlots(request) {
10098
- return request.standards.flatMap((standard) => Array.from({ length: request.itemsPerStandard }, () => ({
10111
+ return request.standards.flatMap((standard) => Array.from({ length: request.candidateItemsPerStandard }, () => ({
10099
10112
  standard,
10100
10113
  standardKey: assessmentStandardRefKey(standard)
10101
10114
  })));
@@ -10199,7 +10212,7 @@ function preferredDistinctMatching(slots, candidatesByStandard) {
10199
10212
  return new Map(slotItemEdges.filter((candidate) => candidate.edge.capacity === 0).map((candidate) => [candidate.slotIndex, candidate.itemIdentifier]));
10200
10213
  }
10201
10214
  function fulfillment(request, candidatesByStandard, selections) {
10202
- const requestedItemCount = request.standards.length * request.itemsPerStandard;
10215
+ const requestedItemCount = request.standards.length * request.candidateItemsPerStandard;
10203
10216
  const selectedByStandard = new Map;
10204
10217
  for (const selection of selections) {
10205
10218
  const key = assessmentStandardRefKey(selection.standard);
@@ -10208,10 +10221,10 @@ function fulfillment(request, candidatesByStandard, selections) {
10208
10221
  const shortages = request.standards.flatMap((standard) => {
10209
10222
  const key = assessmentStandardRefKey(standard);
10210
10223
  const selected = selectedByStandard.get(key) ?? 0;
10211
- return selected < request.itemsPerStandard ? [
10224
+ return selected < request.candidateItemsPerStandard ? [
10212
10225
  {
10213
10226
  standard,
10214
- requested: request.itemsPerStandard,
10227
+ requested: request.candidateItemsPerStandard,
10215
10228
  selected,
10216
10229
  eligible: candidatesByStandard.get(key)?.length ?? 0
10217
10230
  }
@@ -10253,9 +10266,9 @@ function projectReviewAssessment(assessment, bank, selections) {
10253
10266
  throw new Error(`Review bank ${bank.bankRevision} was built from content revision ${bank.sourceContentRevision}, not ${assessment.contentRevision}`);
10254
10267
  }
10255
10268
  const selectedIdentifiers = new Set(selections.map((selection) => selection.itemIdentifier));
10256
- const knownIdentifiers = new Set(assessment.items.map((item) => item.identifier));
10269
+ const itemByIdentifier = new Map(assessment.items.map((item) => [item.identifier, item]));
10257
10270
  const indexedItems = new Map(bank.items.map((item) => [item.itemIdentifier, item]));
10258
- const missingIdentifiers = [...selectedIdentifiers].filter((identifier) => !knownIdentifiers.has(identifier) || !indexedItems.has(identifier));
10271
+ const missingIdentifiers = [...selectedIdentifiers].filter((identifier) => !itemByIdentifier.has(identifier) || !indexedItems.has(identifier));
10259
10272
  if (missingIdentifiers.length > 0) {
10260
10273
  throw new Error(`Selected review items are absent from the pinned bank: ${missingIdentifiers.join(", ")}`);
10261
10274
  }
@@ -10271,7 +10284,7 @@ function projectReviewAssessment(assessment, bank, selections) {
10271
10284
  return structuredClone({
10272
10285
  ...assessment,
10273
10286
  contentRevision: bank.bankRevision,
10274
- items: assessment.items.filter((item) => selectedIdentifiers.has(item.identifier))
10287
+ items: selections.map((selection) => itemByIdentifier.get(selection.itemIdentifier))
10275
10288
  });
10276
10289
  }
10277
10290
  function assessmentItemCorrectness(verdicts) {
@@ -10330,10 +10343,10 @@ function isReviewItemOutcome(value) {
10330
10343
  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
10344
  }
10332
10345
  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");
10346
+ 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
10347
  }
10335
10348
  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);
10349
+ 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
10350
  }
10338
10351
  function isMasteryAttemptMetadata(value) {
10339
10352
  return isRecord(value) && typeof value.requestFingerprint === "string" && isAssessmentStandardRef(value.standard);
@@ -10643,7 +10656,7 @@ var init_assessment_runtime = __esm(() => {
10643
10656
  StartAssessmentBaseSchema.extend({
10644
10657
  purpose: exports_external.literal("review"),
10645
10658
  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()
10659
+ candidateItemsPerStandard: exports_external.number().int().positive().max(TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.candidateItemsPerStandard).optional()
10647
10660
  }),
10648
10661
  StartAssessmentBaseSchema.extend({
10649
10662
  purpose: exports_external.literal("mastery"),
@@ -90937,6 +90950,10 @@ var init_timeback4 = __esm(() => {
90937
90950
 
90938
90951
  // ../api-core/src/utils/timeback-assessment-runtime.util.ts
90939
90952
  function buildReviewItemResultMetadata(input) {
90953
+ const administration = itemAdministration(input.metadata.itemSubmissions, input.selection.itemIdentifier);
90954
+ if (!administration) {
90955
+ throw new Error(`Cannot create a review result for unadministered candidate ${input.selection.itemIdentifier}`);
90956
+ }
90940
90957
  return {
90941
90958
  version: PLAYCADEMY_ASSESSMENT_ITEM_RESULT_METADATA_VERSION,
90942
90959
  parentAttemptId: input.parentAttemptId,
@@ -90946,7 +90963,7 @@ function buildReviewItemResultMetadata(input) {
90946
90963
  bankRevision: input.metadata.review.bankRevision,
90947
90964
  itemIdentifier: input.selection.itemIdentifier,
90948
90965
  standard: input.selection.standard,
90949
- deliveredAt: input.metadata.startedAt,
90966
+ deliveredAt: administration.submittedAt,
90950
90967
  responseVersion: input.responseVersion ?? input.metadata.responseVersion,
90951
90968
  responses: input.metadata.responses[input.selection.itemIdentifier] ?? {},
90952
90969
  ...input.submissionId ? { submissionId: input.submissionId } : {},
@@ -91001,7 +91018,8 @@ function resolveReviewChildResult(input) {
91001
91018
  }
91002
91019
  const childMetadata = playcademyAssessmentItemResultMetadata(input.childResult.metadata);
91003
91020
  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)) {
91021
+ const administration = itemAdministration(input.parentMetadata.itemSubmissions, input.selection.itemIdentifier);
91022
+ 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
91023
  return { action: "restore", reason: "incompatible" };
91006
91024
  }
91007
91025
  return input.childResult.status === "active" ? { action: "reuse", metadata: childMetadata } : { action: "restore", reason: "inactive" };
@@ -91692,7 +91710,7 @@ class TimebackAssessmentRuntimeService {
91692
91710
  requestFingerprint,
91693
91711
  bankRevision: source.bank.bankRevision,
91694
91712
  standards: [...normalizedRequest.standards],
91695
- itemsPerStandard: normalizedRequest.itemsPerStandard,
91713
+ candidateItemsPerStandard: normalizedRequest.candidateItemsPerStandard,
91696
91714
  selections: [...selected.selections],
91697
91715
  fulfillment: selected.fulfillment
91698
91716
  }
@@ -91824,9 +91842,6 @@ class TimebackAssessmentRuntimeService {
91824
91842
  updatedAt: new Date().toISOString()
91825
91843
  };
91826
91844
  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
91845
  return {
91831
91846
  attemptId: attempt.result.sourcedId,
91832
91847
  responseVersion: metadata2.responseVersion,
@@ -91894,13 +91909,14 @@ class TimebackAssessmentRuntimeService {
91894
91909
  if (!preview.scoring) {
91895
91910
  throw new Error("Prepared item submission is missing its score");
91896
91911
  }
91897
- const completed = completeAssessmentItemSubmission(attempt.metadata, input, transition, preview.scoring);
91912
+ const submittedAt = new Date().toISOString();
91913
+ const completed = completeAssessmentItemSubmission(attempt.metadata, input, transition, preview.scoring, submittedAt);
91898
91914
  const metadata2 = {
91899
91915
  ...attempt.metadata,
91900
91916
  responses: completed.responses,
91901
91917
  responseVersion: completed.responseVersion,
91902
91918
  itemSubmissions: completed.itemSubmissions,
91903
- updatedAt: new Date().toISOString()
91919
+ updatedAt: submittedAt
91904
91920
  };
91905
91921
  await this.putResultMetadata(attempt.result, metadata2);
91906
91922
  return {
@@ -92005,12 +92021,13 @@ class TimebackAssessmentRuntimeService {
92005
92021
  async scoreSubmission(attempt) {
92006
92022
  const assessment = await this.loadAttemptAssessment(attempt.metadata);
92007
92023
  validateAssessmentResponses(assessment, attempt.metadata.responses);
92008
- const itemResults = await this.scoreItems(assessment, attempt.metadata.responses);
92024
+ const administeredItems = attempt.metadata.purpose === "review" ? administeredItemIdentifiers(attempt.metadata.itemSubmissions) : undefined;
92025
+ const itemResults = await this.scoreItems(assessment, attempt.metadata.responses, administeredItems);
92009
92026
  return {
92010
92027
  responseVersion: attempt.metadata.responseVersion,
92011
92028
  assessment,
92012
92029
  itemResults,
92013
- score: aggregateAssessmentScore(itemResults.map((result) => result.score.earned), assessmentPossibleScore(assessment)),
92030
+ score: aggregateAssessmentScore(itemResults.map((result) => result.score.earned), itemResults.reduce((total, result) => total + result.score.possible, 0)),
92014
92031
  correctQuestions: countCorrectQuestions(itemResults.map((result) => result.isCorrect))
92015
92032
  };
92016
92033
  }
@@ -92103,7 +92120,7 @@ class TimebackAssessmentRuntimeService {
92103
92120
  submissionId: input.submissionId,
92104
92121
  timestamp: timestamp6,
92105
92122
  testName: assessment.title,
92106
- totalQuestions: assessment.items.length,
92123
+ totalQuestions: itemResults.length,
92107
92124
  correctQuestions,
92108
92125
  ...input.session ? { session: input.session } : {},
92109
92126
  ...reviewOutcomes ? { itemOutcomes: reviewOutcomes } : {}
@@ -92134,7 +92151,7 @@ class TimebackAssessmentRuntimeService {
92134
92151
  testIdentifier: assessment.identifier,
92135
92152
  testName: assessment.title,
92136
92153
  score,
92137
- totalQuestions: assessment.items.length,
92154
+ totalQuestions: itemResults.length,
92138
92155
  correctQuestions,
92139
92156
  submissionId: input.submissionId,
92140
92157
  gameId: params.gameId,
@@ -92678,11 +92695,21 @@ class TimebackAssessmentRuntimeService {
92678
92695
  if (physicalItems.size !== input.metadata.review.selections.length) {
92679
92696
  throw new Error("MVP review selection must use each physical item at most once");
92680
92697
  }
92698
+ const administeredItems = administeredItemIdentifiers(input.metadata.itemSubmissions);
92681
92699
  await runWithConcurrency(input.metadata.review.selections, TimebackAssessmentRuntimeService.SCORING_CONCURRENCY, async (selection) => {
92682
92700
  const item = itemByIdentifier.get(selection.itemIdentifier);
92683
92701
  if (!item) {
92684
92702
  throw new Error(`Review assessment is missing selected item ${selection.itemIdentifier}`);
92685
92703
  }
92704
+ if (!administeredItems.has(selection.itemIdentifier)) {
92705
+ await this.ensureReviewQuestionLineItem({
92706
+ parentLineItemId: input.result.assessmentLineItem.sourcedId,
92707
+ integration: input.integration,
92708
+ bank: input.source.bank,
92709
+ item
92710
+ });
92711
+ return;
92712
+ }
92686
92713
  const childLineItemId = await reviewQuestionLineItemId(input.result.assessmentLineItem.sourcedId, item.identifier);
92687
92714
  const childResultId = await reviewQuestionResultId(input.result.sourcedId, childLineItemId);
92688
92715
  const streamedChildResult = input.existingChildResults.get(childResultId);
@@ -92729,33 +92756,35 @@ class TimebackAssessmentRuntimeService {
92729
92756
  });
92730
92757
  });
92731
92758
  }
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
- });
92759
+ async putReviewChildResponse(result, metadata2, itemIdentifier) {
92760
+ const selection = metadata2.review.selections.find((candidate) => candidate.itemIdentifier === itemIdentifier);
92761
+ if (!selection) {
92762
+ return;
92763
+ }
92764
+ try {
92765
+ const childLineItemId = await reviewQuestionLineItemId(result.assessmentLineItem.sourcedId, selection.itemIdentifier);
92766
+ const childResultId = await reviewQuestionResultId(result.sourcedId, childLineItemId);
92767
+ const childMetadata = buildReviewItemResultMetadata({
92768
+ metadata: metadata2,
92769
+ selection,
92770
+ parentAttemptId: result.sourcedId
92771
+ });
92772
+ await this.requireClient().api.oneroster.assessmentResults.upsert(childResultId, buildOpenReviewChildResult({
92773
+ childLineItemId,
92774
+ student: result.student,
92775
+ metadata: childMetadata
92776
+ }));
92777
+ } catch (error88) {
92778
+ addEvent("assessment.review_child_response_projection_failed", {
92779
+ "app.assessment.attempt_id": result.sourcedId,
92780
+ "app.assessment.qti_item_identifier": selection.itemIdentifier,
92781
+ "exception.type": errorType(error88),
92782
+ "app.error.message": errorMessage(error88)
92783
+ });
92784
+ }
92756
92785
  }
92757
92786
  async projectReviewItemResponse(params, projection) {
92758
- await this.putReviewChildResponses(projection.result, projection.metadata, new Set([projection.itemIdentifier]));
92787
+ await this.putReviewChildResponse(projection.result, projection.metadata, projection.itemIdentifier);
92759
92788
  try {
92760
92789
  await this.crossAttemptLockBarrier(params.attemptId);
92761
92790
  const latest = await this.peekAttempt(params);
@@ -92834,7 +92863,8 @@ class TimebackAssessmentRuntimeService {
92834
92863
  }
92835
92864
  async finalizeReviewChildResults(input) {
92836
92865
  const resultByItem = new Map(input.itemResults.map((itemResult) => [itemResult.itemIdentifier, itemResult]));
92837
- return runWithConcurrency(input.metadata.review.selections, TimebackAssessmentRuntimeService.SCORING_CONCURRENCY, async (selection) => {
92866
+ const administeredItems = administeredItemIdentifiers(input.metadata.itemSubmissions);
92867
+ return runWithConcurrency(input.metadata.review.selections.filter((selection) => administeredItems.has(selection.itemIdentifier)), TimebackAssessmentRuntimeService.SCORING_CONCURRENCY, async (selection) => {
92838
92868
  const itemResult = resultByItem.get(selection.itemIdentifier);
92839
92869
  if (!itemResult) {
92840
92870
  throw new Error(`Scoring omitted selected review item ${selection.itemIdentifier}`);
@@ -92950,7 +92980,7 @@ class TimebackAssessmentRuntimeService {
92950
92980
  kind: "standards-review",
92951
92981
  purpose: metadata2.purpose,
92952
92982
  standards: [...metadata2.review.standards],
92953
- itemsPerStandard: metadata2.review.itemsPerStandard,
92983
+ candidateItemsPerStandard: metadata2.review.candidateItemsPerStandard,
92954
92984
  selections: [...metadata2.review.selections],
92955
92985
  fulfillment: metadata2.review.fulfillment
92956
92986
  };
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.18",
4
4
  "description": "Local development server for Playcademy game development",
5
5
  "type": "module",
6
6
  "exports": {