All files / src/utils/components/submission-review submissionGradeDisplay.js

100% Statements 19/19
100% Branches 20/20
100% Functions 5/5
100% Lines 14/14

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107                                          4x     4x                                     351x 336x   330x 330x                         422x 210x 205x                   67x 58x                                 177x 177x                         13x    
/**
 * What to show in a submission's grade banner.
 *
 * The teacher's Individual Answers tab used to render
 * `Number(submission.grade).toFixed(2)` behind a `submission.grade &&` check. Two
 * things went wrong with that:
 *
 *   * the API returned the literal string "None" for an ungraded submission, which
 *     is truthy, so the banner rendered "NaN%" as the student's grade; and
 *   * the withhold check covered `reviewStatus === "pending"` only, so a REJECTED
 *     late submission displayed its provisional grade — which gradebook.py says
 *     must never be shown "on either view" (EI-1195 / EI-1210).
 *
 * The API side is fixed (it now returns null and a `gradeStatus`), but the display
 * rule is kept defensive on purpose: this renders a student's grade to their
 * teacher, and no input should be able to turn it into "NaN%".
 *
 * Developer: Allan Ninal
 */
 
/** Review states whose grade is provisional and must never be displayed. */
const WITHHELD_REVIEW_STATUSES = ["pending", "rejected"];
 
/** `gradeStatus` values from the API's canonical gradebook resolver. */
const STATUS_LABELS = {
    graded: "Graded",
    missed: "Not submitted",
    incomplete: "In progress",
    pending: "Pending review",
    rejected: "Rejected",
};
 
/**
 * Parse a grade percentage, rejecting anything that is not a real number.
 *
 * Returns null — never NaN — so a caller cannot accidentally format it. Note "" and
 * null are rejected, but so are "None", "nan" and "abc": `Number("")` is 0, which
 * would otherwise read as a legitimate 0%.
 *
 * @param {number|string|null|undefined} grade
 * @returns {number|null}
 */
export function parseGradePercent(grade) {
    if (grade === null || grade === undefined) return null;
    if (typeof grade === "string" && grade.trim() === "") return null;
 
    const parsed = Number(grade);
    return Number.isFinite(parsed) ? parsed : null;
}
 
/**
 * Is this submission's grade safe and permitted to display?
 *
 * A grade of 0 IS displayed — a student who scored nothing must be visibly
 * distinguishable from one who was never graded.
 *
 * @param {{grade?: unknown, reviewStatus?: string}|null|undefined} submission
 * @returns {boolean}
 */
export function canShowGrade(submission) {
    if (!submission) return false;
    if (WITHHELD_REVIEW_STATUSES.includes(submission.reviewStatus)) return false;
    return parseGradePercent(submission.grade) !== null;
}
 
/**
 * The grade as a fixed-2 percentage string, or null when it must not be shown.
 *
 * @param {{grade?: unknown, reviewStatus?: string}|null|undefined} submission
 * @returns {string|null} e.g. "85.50"
 */
export function formatGradePercent(submission) {
    if (!canShowGrade(submission)) return null;
    return parseGradePercent(submission.grade).toFixed(2);
}
 
/**
 * A clamped 0-100 value for the progress bar.
 *
 * Clamped because a points override can push a stored grade above 100, and MUI's
 * LinearProgress warns and overflows its track for values outside the range.
 *
 * @param {{grade?: unknown, reviewStatus?: string}|null|undefined} submission
 * @returns {number} 0-100
 */
export function gradeProgressValue(submission) {
    // No `?? 0` needed: canShowGrade() being true guarantees parseGradePercent
    // returns a number, and the ternary supplies 0 otherwise. A `?? 0` here read as
    // defensive but was unreachable — removing it killed no test, and coverage
    // flagged it as the module's only uncovered branch.
    const parsed = canShowGrade(submission) ? parseGradePercent(submission.grade) : 0;
    return Math.min(100, Math.max(0, parsed));
}
 
/**
 * Human label for why a grade is not being shown, from the API's `gradeStatus`.
 *
 * Falls back to a generic phrase rather than rendering a raw enum value, since an
 * unrecognised status would otherwise leak into the teacher's UI.
 *
 * @param {string|null|undefined} gradeStatus
 * @returns {string}
 */
export function gradeStatusLabel(gradeStatus) {
    return STATUS_LABELS[gradeStatus] ?? "Not graded yet";
}