All files / src/app/teacher-page/page/assignments/new-assignment assignmentRubricUtils.js

100% Statements 60/60
98.66% Branches 74/75
100% Functions 14/14
100% Lines 51/51

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 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216                                                        3x 3x                     5x               1x                                               29x 29x 29x                                     14x 8x     7x       10x       10x 10x 4x   10x         6x                                         8x 1x   7x 8x 7x 6x 10x   9x 8x 8x 7x                                                       30x 21x     9x 10x   10x 3x     10x 3x   7x 11x 11x 1x   11x 11x 1x 10x 1x   11x   11x 3x       10x     10x 9x   7x    
/**
 * Pure helper utilities for the teacher assignment rubric builder (EI-3182).
 *
 * All functions are stateless and side-effect-free so they can be tested
 * without rendering any React component.
 *
 * Ported from admin-staff-client Step1/assignmentRubricUtils.js and adapted
 * for the teacher new-assignment wizard in eruditiontx-client-mvp.
 *
 * Backend payload contract (POST /v1/teacher/assignment/create):
 *   instructions: string | undefined   — omit or empty string when blank
 *   rubric: { criteria: [...] } | null — null when no criteria were added
 *
 * Input constraints (EI-3182 finding D):
 *   - Criterion name: maxLength 200
 *   - Level label: maxLength 200
 *   - Level description: maxLength 2000 (omit from payload when blank)
 *   - Instructions: maxLength 5000
 *   - Criteria cap: 50
 *   - Levels-per-criterion cap: 20
 *
 * Developer: Allan Niñal
 */
 
// ---------------------------------------------------------------------------
// Caps
// ---------------------------------------------------------------------------
 
export const MAX_CRITERIA = 50;
export const MAX_LEVELS_PER_CRITERION = 20;
 
// ---------------------------------------------------------------------------
// Factory helpers
// ---------------------------------------------------------------------------
 
/**
 * Returns a fresh, blank criterion object.
 * @returns {{ name: string, levels: Array }}
 */
export function emptyCriterion() {
    return { name: "", levels: [] };
}
 
/**
 * Returns a fresh, blank performance-level object.
 * @returns {{ label: string, points: string, description: string }}
 */
export function emptyLevel() {
    return { label: "", points: "", description: "" };
}
 
// ---------------------------------------------------------------------------
// Payload builder
// ---------------------------------------------------------------------------
 
/**
 * EI-3182: resolve the Step 1 "bridge" fields (instructions + rubric) for
 * pre-population on mount.
 *
 * Step 1 normally restores from `assignmentData.step1`, but the reuse/import
 * flow (AssignmentSettings) carries `instructions` + `rubric` at the
 * `assignmentData` ROOT — only `title`/`description` go under `step1`. Reading
 * step1-only therefore restored neither, so Step 1 rebuilt an EMPTY rubric and
 * the rubric was dropped from the create POST (while instructions, read from
 * root by the payload builder, survived). Read step1 first, then fall back to
 * root, so both the edit flow (step1-populated) and the reuse flow
 * (root-populated) restore correctly.
 *
 * @param {object} assignmentData — the TeacherCreateAssignment.assignmentData slice
 * @returns {{ instructions: string, rubric: ({ criteria: Array } | null) }}
 */
export function resolveBridgeFields(assignmentData) {
    const data = assignmentData || {};
    const step1 = data.step1 || {};
    return {
        instructions: step1.instructions ?? data.instructions ?? "",
        rubric: step1.rubric ?? data.rubric ?? null,
    };
}
 
/**
 * Converts the local criteria state array into the backend-ready rubric
 * payload, or returns null when there are no criteria.
 *
 * Points are coerced to Number so the backend receives numeric values.
 * A level's `description` key is omitted entirely when the value is blank
 * or whitespace-only, so the backend stores null (canonical) rather than
 * an empty string.
 *
 * @param {Array} criteria — array of criterion objects from local state
 * @returns {{ criteria: Array } | null}
 */
export function buildRubricPayload(criteria) {
    if (!Array.isArray(criteria) || criteria.length === 0) {
        return null;
    }
 
    const builtCriteria = criteria.map((criterion) => ({
        name: criterion.name,
        levels: Array.isArray(criterion.levels)
            ? criterion.levels.map((level) => {
                  const levelObj = {
                      label: level.label,
                      points: Number(level.points),
                  };
                  const desc = level.description;
                  if (desc !== undefined && desc !== null && String(desc).trim() !== "") {
                      levelObj.description = desc;
                  }
                  return levelObj;
              })
            : [],
    }));
 
    return { criteria: builtCriteria };
}
 
// ---------------------------------------------------------------------------
// Validation
// ---------------------------------------------------------------------------
 
/**
 * Returns true when every criterion in the array satisfies the inline rules:
 *   - criterion has a non-empty name
 *   - criterion has at least one level
 *   - every level has a non-empty label
 *   - every level has a numeric, non-negative points value
 *
 * An empty array (no criteria added) is considered valid — instructions and
 * rubric are both optional fields.
 *
 * @param {Array} criteria
 * @returns {boolean}
 */
export function isRubricValid(criteria) {
    if (!Array.isArray(criteria) || criteria.length === 0) {
        return true;
    }
    return criteria.every((criterion) => {
        if (!criterion.name || !criterion.name.trim()) return false;
        if (!Array.isArray(criterion.levels) || criterion.levels.length === 0) return false;
        return criterion.levels.every((level) => {
            if (!level.label || !level.label.trim()) return false;
            // Treat empty / null / undefined as invalid (points is required)
            if (level.points === "" || level.points === null || level.points === undefined) return false;
            const pts = Number(level.points);
            if (isNaN(pts) || pts < 0) return false;
            return true;
        });
    });
}
 
/**
 * Returns a nested structure of inline error messages for the rubric builder.
 * Returns an empty object `{}` when the criteria array is empty or fully valid.
 *
 * Shape:
 * {
 *   criteria: [
 *     {
 *       name: "Criterion name is required",       // or undefined
 *       levels: "At least one level is required", // or undefined
 *       levelErrors: [
 *         { label: "Level label is required", points: "Points must be a non-negative number" },
 *         ...
 *       ]
 *     },
 *     ...
 *   ]
 * }
 *
 * @param {Array} criteria
 * @returns {Object}
 */
export function rubricValidationErrors(criteria) {
    if (!Array.isArray(criteria) || criteria.length === 0) {
        return {};
    }
 
    const criteriaErrors = criteria.map((criterion) => {
        const criterionError = {};
 
        if (!criterion.name || !criterion.name.trim()) {
            criterionError.name = "Criterion name is required";
        }
 
        if (!Array.isArray(criterion.levels) || criterion.levels.length === 0) {
            criterionError.levels = "At least one level is required";
        } else {
            const levelErrors = criterion.levels.map((level) => {
                const levelError = {};
                if (!level.label || !level.label.trim()) {
                    levelError.label = "Level label is required";
                }
                const pts = Number(level.points);
                if (level.points === "" || level.points === null || level.points === undefined) {
                    levelError.points = "Points is required";
                } else if (isNaN(pts) || pts < 0) {
                    levelError.points = "Points must be a non-negative number";
                }
                return levelError;
            });
            if (levelErrors.some((le) => Object.keys(le).length > 0)) {
                criterionError.levelErrors = levelErrors;
            }
        }
 
        return criterionError;
    });
 
    const hasAnyError = criteriaErrors.some((ce) => Object.keys(ce).length > 0);
    if (!hasAnyError) return {};
 
    return { criteria: criteriaErrors };
}