@openstax/ts-utils 1.37.2 → 1.38.0-beta

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.
@@ -1,22 +1,27 @@
1
1
  import { LrsGateway, UXapiStatement } from '.';
2
+ export declare const EXT_PENDING_SCORING = "https://openstax.org/xapi/extensions/pending-scoring";
3
+ export type AttemptEntry = {
4
+ attempt: UXapiStatement;
5
+ completed?: UXapiStatement;
6
+ scored?: UXapiStatement;
7
+ };
2
8
  export type ActivityState = {
3
9
  attempts: number;
4
- bestCompletedAttempt?: UXapiStatement;
5
- bestCompletedAttemptCompleted?: UXapiStatement;
6
10
  completedAttempts: number;
7
- currentAttempt?: UXapiStatement;
8
- currentAttemptCompleted?: UXapiStatement;
9
11
  currentAttemptStatements: UXapiStatement[];
10
- mostRecentAttemptWithCompleted?: UXapiStatement;
11
- mostRecentAttemptWithCompletedCompleted?: UXapiStatement;
12
+ current?: AttemptEntry;
13
+ best?: AttemptEntry;
14
+ mostRecentWithCompleted?: AttemptEntry;
12
15
  };
13
16
  export declare const matchAttempt: (statement: UXapiStatement) => boolean;
14
17
  export declare const matchAttemptCompleted: (attempt: UXapiStatement) => (statement: UXapiStatement) => boolean;
18
+ export declare const matchAttemptScored: (attempt: UXapiStatement) => (statement: UXapiStatement) => boolean;
15
19
  export declare const resolveAttempts: (statements: UXapiStatement[], options?: {
16
20
  activityIRI?: string;
17
21
  parentActivityAttempt?: string;
18
22
  }) => UXapiStatement[];
19
23
  export declare const resolveCompletedForAttempt: (statements: UXapiStatement[], attempt: UXapiStatement, activityIRI?: string) => UXapiStatement | undefined;
24
+ export declare const resolveScoredForAttempt: (statements: UXapiStatement[], attempt: UXapiStatement, activityIRI?: string) => UXapiStatement | undefined;
20
25
  export declare const oldestStatement: (statements: UXapiStatement[]) => UXapiStatement | undefined;
21
26
  export declare const mostRecentStatement: (statements: UXapiStatement[]) => UXapiStatement | undefined;
22
27
  export declare const resolveAttemptInfo: (statements: UXapiStatement[], options?: {
@@ -70,3 +75,7 @@ export declare const createAttemptActivityStatement: (attemptStatement: UXapiSta
70
75
  export declare const putAttemptActivityStatement: (gateway: LrsGateway, attemptStatement: UXapiStatement, verb: UXapiStatement["verb"], result?: UXapiStatement["result"]) => Promise<import(".").EagerXapiStatement>;
71
76
  export declare const createCompletedStatement: (attemptStatement: UXapiStatement, result?: UXapiStatement["result"]) => Pick<UXapiStatement, "object" | "verb" | "context" | "result">;
72
77
  export declare const putCompletedStatement: (gateway: LrsGateway, attemptStatement: UXapiStatement, result: UXapiStatement["result"]) => Promise<import(".").EagerXapiStatement>;
78
+ export declare const createCompletedPendingScoringStatement: (attemptStatement: UXapiStatement) => Pick<UXapiStatement, "object" | "verb" | "context" | "result">;
79
+ export declare const putCompletedPendingScoringStatement: (gateway: LrsGateway, attemptStatement: UXapiStatement) => Promise<import(".").EagerXapiStatement>;
80
+ export declare const createScoredStatement: (attemptStatement: UXapiStatement, result: UXapiStatement["result"]) => Pick<UXapiStatement, "object" | "verb" | "context" | "result">;
81
+ export declare const putScoredStatement: (gateway: LrsGateway, attemptStatement: UXapiStatement, result: UXapiStatement["result"]) => Promise<import(".").EagerXapiStatement>;
@@ -15,7 +15,9 @@ var Verb;
15
15
  (function (Verb) {
16
16
  Verb["Attempted"] = "http://adlnet.gov/expapi/verbs/attempted";
17
17
  Verb["Completed"] = "http://adlnet.gov/expapi/verbs/completed";
18
+ Verb["Scored"] = "http://adlnet.gov/expapi/verbs/scored";
18
19
  })(Verb || (Verb = {}));
20
+ export const EXT_PENDING_SCORING = 'https://openstax.org/xapi/extensions/pending-scoring';
19
21
  export const matchAttempt = (statement) => statement.verb.id === Verb.Attempted;
20
22
  export const matchAttemptCompleted = (attempt) => (statement) => {
21
23
  var _a, _b;
@@ -24,6 +26,13 @@ export const matchAttemptCompleted = (attempt) => (statement) => {
24
26
  && ((_a = statement.context.statement) === null || _a === void 0 ? void 0 : _a.id) === attempt.id
25
27
  && statement.context.registration === ((_b = attempt.context) === null || _b === void 0 ? void 0 : _b.registration);
26
28
  };
29
+ export const matchAttemptScored = (attempt) => (statement) => {
30
+ var _a, _b;
31
+ return statement.verb.id === Verb.Scored
32
+ && statement.context !== undefined
33
+ && ((_a = statement.context.statement) === null || _a === void 0 ? void 0 : _a.id) === attempt.id
34
+ && statement.context.registration === ((_b = attempt.context) === null || _b === void 0 ? void 0 : _b.registration);
35
+ };
27
36
  export const resolveAttempts = (statements, options) => statements.filter(statement => {
28
37
  var _a;
29
38
  return matchAttempt(statement)
@@ -32,6 +41,18 @@ export const resolveAttempts = (statements, options) => statements.filter(statem
32
41
  });
33
42
  export const resolveCompletedForAttempt = (statements, attempt, activityIRI) => statements.find(statement => matchAttemptCompleted(attempt)(statement)
34
43
  && (!activityIRI || statement.object.id === activityIRI));
44
+ export const resolveScoredForAttempt = (statements, attempt, activityIRI) => {
45
+ // Prefer the most recent Scored
46
+ const scored = mostRecentStatement(statements.filter(s => matchAttemptScored(attempt)(s) && (!activityIRI || s.object.id === activityIRI)));
47
+ if (scored)
48
+ return scored;
49
+ // if no scored found, return most recent Completed with a score for the attempt.
50
+ const completedWithScore = mostRecentStatement(statements.filter(s => matchAttemptCompleted(attempt)(s) &&
51
+ (!activityIRI || s.object.id === activityIRI) && s.result &&
52
+ s.result.score &&
53
+ (s.result.score.scaled !== undefined)));
54
+ return completedWithScore;
55
+ };
35
56
  export const oldestStatement = (statements) => statements.reduce((result, statement) => result && isBefore(parseISO('stored' in result && result.stored ? result.stored : result.timestamp), parseISO(statement.timestamp)) ? result : statement, statements[0]);
36
57
  export const mostRecentStatement = (statements) => statements.reduce((result, statement) => result && isAfter(parseISO('stored' in result && result.stored ? result.stored : result.timestamp), parseISO(statement.timestamp)) ? result : statement, statements[0]);
37
58
  export const resolveAttemptInfo = (statements, options) => {
@@ -45,65 +66,81 @@ export const resolveAttemptInfo = (statements, options) => {
45
66
  return acc;
46
67
  }, []);
47
68
  const completedPairs = resolveAttemptsWithCompleted(statements, attempts, options === null || options === void 0 ? void 0 : options.activityIRI);
48
- const completedAttempts = completedPairs.map(p => p.attempt);
69
+ const completedAttempts = completedPairs.length;
49
70
  /* the last attempt sorted by timestamp */
50
71
  const currentAttempt = (options === null || options === void 0 ? void 0 : options.currentAttempt)
51
72
  ? attempts.find(attempt => attempt.id === options.currentAttempt)
52
73
  : (options === null || options === void 0 ? void 0 : options.currentPreference) === 'oldest'
53
74
  ? oldestStatement(attempts)
54
75
  : mostRecentStatement(attempts);
55
- /* all statements for the current attempt (doesn't include the attempt or completed statements) */
56
- const currentAttemptStatements = currentAttempt ? statements.filter(statement => {
57
- var _a;
58
- return (!(options === null || options === void 0 ? void 0 : options.activityIRI) || statement.object.id === options.activityIRI)
59
- && ((_a = statement.context) === null || _a === void 0 ? void 0 : _a.registration) === currentAttempt.id;
60
- }) : [];
61
- const currentAttemptCompleted = currentAttempt && resolveCompletedForAttempt(statements, currentAttempt, options === null || options === void 0 ? void 0 : options.activityIRI);
76
+ const current = currentAttempt
77
+ ? {
78
+ attempt: currentAttempt,
79
+ completed: resolveCompletedForAttempt(statements, currentAttempt, options === null || options === void 0 ? void 0 : options.activityIRI),
80
+ scored: resolveScoredForAttempt(statements, currentAttempt, options === null || options === void 0 ? void 0 : options.activityIRI),
81
+ }
82
+ : undefined;
83
+ const currentAttemptStatements = currentAttempt
84
+ ? statements.filter(statement => {
85
+ var _a;
86
+ return (!(options === null || options === void 0 ? void 0 : options.activityIRI) || statement.object.id === options.activityIRI) &&
87
+ ((_a = statement.context) === null || _a === void 0 ? void 0 : _a.registration) === currentAttempt.id;
88
+ })
89
+ : [];
62
90
  const mostRecentPair = completedPairs.reduce((cur, pair) => {
63
91
  if (!cur)
64
92
  return pair;
65
93
  return isAfter(parseISO(cur.attempt.timestamp), parseISO(pair.attempt.timestamp)) ? cur : pair;
66
94
  }, undefined);
67
- /*
68
- * the structure allows for the possibility of multiple incomplete attempts.
69
- * the implementation can choose at its discretion to ignore the currentAttempt
70
- * and instead make a new one, for instance if the implementation desires
71
- * an attempt timeout feature
72
- */
73
- const hasScaledScore = (p) => {
95
+ const mostRecentWithCompleted = mostRecentPair
96
+ ? {
97
+ attempt: mostRecentPair.attempt,
98
+ completed: resolveCompletedForAttempt(statements, mostRecentPair.attempt, options === null || options === void 0 ? void 0 : options.activityIRI),
99
+ scored: resolveScoredForAttempt(statements, mostRecentPair.attempt, options === null || options === void 0 ? void 0 : options.activityIRI),
100
+ }
101
+ : undefined;
102
+ const scaledForAttempt = (attempt) => {
74
103
  var _a;
75
- return p.completed.result !== undefined &&
76
- typeof ((_a = p.completed.result.score) === null || _a === void 0 ? void 0 : _a.scaled) === 'number';
104
+ // resolveScoredForAttempt may return a Scored, or fallback to a Completed that has a score
105
+ const picked = resolveScoredForAttempt(statements, attempt, options === null || options === void 0 ? void 0 : options.activityIRI);
106
+ const score = (_a = picked === null || picked === void 0 ? void 0 : picked.result) === null || _a === void 0 ? void 0 : _a.score;
107
+ if (!score)
108
+ return undefined;
109
+ // Prefer scaled, otherwise normalize raw/max if available
110
+ if (score.scaled !== undefined)
111
+ return score.scaled;
112
+ if (score.raw !== undefined && score.max !== undefined && score.max !== 0) {
113
+ return score.raw / score.max;
114
+ }
115
+ return undefined;
77
116
  };
78
- // Find best scored pair
79
- const bestScoredPair = completedPairs
80
- .filter(hasScaledScore)
81
- .reduce((best, pair) => {
82
- if (!best)
83
- return pair;
84
- return pair.completed.result.score.scaled > best.completed.result.score.scaled ? pair : best;
117
+ let bestAttempt = completedPairs.reduce((best, { attempt }) => {
118
+ const bScaled = best !== undefined ? scaledForAttempt(best) : undefined;
119
+ const aScaled = scaledForAttempt(attempt);
120
+ if (bScaled === undefined)
121
+ return aScaled === undefined ? best : attempt;
122
+ if (aScaled === undefined)
123
+ return best;
124
+ return aScaled > bScaled ? attempt : best;
85
125
  }, undefined);
86
- // if no scored pair get newest completed without a score
87
- const bestDefaultPair = !bestScoredPair && completedPairs.length > 0
88
- ? completedPairs.reduce((best, pair) => {
89
- if (!best)
90
- return pair;
91
- return isAfter(parseISO(pair.attempt.timestamp), parseISO(best.attempt.timestamp))
92
- ? pair
93
- : best;
94
- }, undefined)
126
+ // Fallback if no attempt had a score, use newest completed as best
127
+ if (!bestAttempt && mostRecentPair) {
128
+ bestAttempt = mostRecentPair.attempt;
129
+ }
130
+ const best = bestAttempt
131
+ ? {
132
+ attempt: bestAttempt,
133
+ completed: resolveCompletedForAttempt(statements, bestAttempt, options && options.activityIRI),
134
+ scored: resolveScoredForAttempt(statements, bestAttempt, options && options.activityIRI),
135
+ }
95
136
  : undefined;
96
- const bestPair = bestScoredPair !== null && bestScoredPair !== void 0 ? bestScoredPair : bestDefaultPair;
97
137
  return {
98
138
  attempts: attempts.length,
99
- bestCompletedAttempt: bestPair === null || bestPair === void 0 ? void 0 : bestPair.attempt,
100
- bestCompletedAttemptCompleted: bestPair === null || bestPair === void 0 ? void 0 : bestPair.completed,
101
- completedAttempts: completedAttempts.length,
102
- currentAttempt,
103
- currentAttemptCompleted,
139
+ completedAttempts,
104
140
  currentAttemptStatements,
105
- mostRecentAttemptWithCompleted: mostRecentPair === null || mostRecentPair === void 0 ? void 0 : mostRecentPair.attempt,
106
- mostRecentAttemptWithCompletedCompleted: mostRecentPair === null || mostRecentPair === void 0 ? void 0 : mostRecentPair.completed,
141
+ current,
142
+ best,
143
+ mostRecentWithCompleted,
107
144
  };
108
145
  };
109
146
  /*
@@ -269,3 +306,42 @@ export const createCompletedStatement = (attemptStatement, result) => {
269
306
  export const putCompletedStatement = async (gateway, attemptStatement, result) => {
270
307
  return (await gateway.putXapiStatements([createCompletedStatement(attemptStatement, result)]))[0];
271
308
  };
309
+ // pending score statement for when the open written response has been graded.
310
+ export const createCompletedPendingScoringStatement = (attemptStatement) => createCompletedStatement(attemptStatement, {
311
+ extensions: { [EXT_PENDING_SCORING]: 'true' },
312
+ });
313
+ export const putCompletedPendingScoringStatement = async (gateway, attemptStatement) => {
314
+ return (await gateway.putXapiStatements([
315
+ createCompletedPendingScoringStatement(attemptStatement),
316
+ ]))[0];
317
+ };
318
+ // scored statement for when the open written response has been graded.
319
+ export const createScoredStatement = (attemptStatement, result) => {
320
+ var _a, _b;
321
+ return {
322
+ context: {
323
+ ...(((_a = attemptStatement.context) === null || _a === void 0 ? void 0 : _a.contextActivities) ? {
324
+ contextActivities: attemptStatement.context.contextActivities,
325
+ } : {}),
326
+ ...(((_b = attemptStatement.context) === null || _b === void 0 ? void 0 : _b.registration) ? {
327
+ registration: attemptStatement.context.registration,
328
+ } : {}),
329
+ statement: {
330
+ objectType: 'StatementRef',
331
+ id: attemptStatement.id,
332
+ }
333
+ },
334
+ object: attemptStatement.object,
335
+ verb: {
336
+ display: { 'en-US': 'Scored' },
337
+ id: Verb.Scored,
338
+ },
339
+ result: {
340
+ // no duration here, we treat Scored as a pure grading event
341
+ ...result,
342
+ }
343
+ };
344
+ };
345
+ export const putScoredStatement = async (gateway, attemptStatement, result) => {
346
+ return (await gateway.putXapiStatements([createScoredStatement(attemptStatement, result)]))[0];
347
+ };
@@ -1,6 +1,6 @@
1
1
  import partition from 'lodash/fp/partition';
2
2
  import { roundToPrecision } from '../..';
3
- import { resolveAttemptInfo } from './attempt-utils';
3
+ import { EXT_PENDING_SCORING, resolveAttemptInfo } from './attempt-utils';
4
4
  export const getRegistrationAttemptInfo = async (lrs, registration, options) => {
5
5
  const { currentPreference, ...xapiOptions } = options !== null && options !== void 0 ? options : {};
6
6
  const allStatements = await lrs.getAllXapiStatements({ ...xapiOptions, registration });
@@ -51,31 +51,32 @@ export const getScoreGrade = (score, options) => {
51
51
  };
52
52
  };
53
53
  // These methods assign 0's to incomplete activities
54
- const pickAttemptAndCompleted = (state, gradePreference) => {
55
- if (gradePreference === 'best' && state.bestCompletedAttempt) {
56
- return {
57
- attempt: state.bestCompletedAttempt,
58
- completed: state.bestCompletedAttemptCompleted,
59
- };
60
- }
61
- // return current attempt if gradePreference is current
54
+ const pickAttemptAndCompletedOrScored = (state, gradePreference) => {
55
+ var _a;
56
+ const entry = (gradePreference === 'best' ? state.best : state.current);
62
57
  return {
63
- attempt: state.currentAttempt,
64
- completed: state.currentAttemptCompleted,
58
+ attempt: entry === null || entry === void 0 ? void 0 : entry.attempt,
59
+ completedOrScored: (_a = entry === null || entry === void 0 ? void 0 : entry.scored) !== null && _a !== void 0 ? _a : entry === null || entry === void 0 ? void 0 : entry.completed,
60
+ completed: entry === null || entry === void 0 ? void 0 : entry.completed,
65
61
  };
66
62
  };
67
63
  const getCompletedActivityStateGradeAndProgress = ({ name, scoreMaximum, state, userId, gradePreference = 'current' }) => {
68
- var _a;
69
- const { attempt, completed } = pickAttemptAndCompleted(state, gradePreference);
64
+ var _a, _b, _c;
65
+ const { attempt, completedOrScored, completed } = pickAttemptAndCompletedOrScored(state, gradePreference);
66
+ const grade = getScoreGrade(((_a = completedOrScored === null || completedOrScored === void 0 ? void 0 : completedOrScored.result) === null || _a === void 0 ? void 0 : _a.score) || {}, {
67
+ maxScore: scoreMaximum,
68
+ startedAt: attempt === null || attempt === void 0 ? void 0 : attempt.timestamp,
69
+ submittedAt: completed === null || completed === void 0 ? void 0 : completed.timestamp,
70
+ userId,
71
+ });
72
+ // If this Completed is explicitly "pending scoring", surface that in the grade.
73
+ if (((_c = (_b = completedOrScored === null || completedOrScored === void 0 ? void 0 : completedOrScored.result) === null || _b === void 0 ? void 0 : _b.extensions) === null || _c === void 0 ? void 0 : _c[EXT_PENDING_SCORING]) === 'true') {
74
+ grade.gradingProgress = 'PendingManual';
75
+ }
70
76
  return {
71
- grade: getScoreGrade(((_a = completed === null || completed === void 0 ? void 0 : completed.result) === null || _a === void 0 ? void 0 : _a.score) || {}, {
72
- maxScore: scoreMaximum,
73
- startedAt: attempt === null || attempt === void 0 ? void 0 : attempt.timestamp,
74
- submittedAt: completed === null || completed === void 0 ? void 0 : completed.timestamp,
75
- userId,
76
- }),
77
+ grade,
77
78
  progress: {
78
- scaled: completed ? 1 : 0,
79
+ scaled: completedOrScored ? 1 : 0,
79
80
  },
80
81
  name,
81
82
  };
@@ -106,7 +107,7 @@ export const getCurrentGrade = async (services, registration, options) => {
106
107
  gradePreference,
107
108
  });
108
109
  }
109
- if (userInfo.currentAttemptCompleted || !incompleteAttemptCallback) {
110
+ if (userInfo.current && userInfo.current.completed || !incompleteAttemptCallback) {
110
111
  return getCompletedActivityStateGradeAndProgress({
111
112
  name,
112
113
  scoreMaximum,
@@ -127,8 +128,8 @@ export const getAssignmentGrades = async (services, assignmentIRI, registration,
127
128
  }
128
129
  // Partition based on whether the chosen preference has a completed attempt
129
130
  const [incompleteInfo, completedInfo] = partition((info) => {
130
- const { completed } = pickAttemptAndCompleted(info.data, gradePreference);
131
- return completed === undefined;
131
+ const { completedOrScored } = pickAttemptAndCompletedOrScored(info.data, gradePreference);
132
+ return completedOrScored === undefined;
132
133
  })(mappedInfo);
133
134
  return getCompletedUserInfosGradeAndProgress(completedInfo, scoreMaximum, gradePreference).concat(await incompleteAttemptsCallback(incompleteInfo));
134
135
  };