All files / src/utils/components/question-bank/question-display/graph graphAnswerUtils.js

89.61% Statements 69/77
89.18% Branches 66/74
100% Functions 10/10
95.52% Lines 64/67

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 217 218 219 220 221 222 223                                  1032x 564x     468x 170x 170x 8x   162x 159x       301x 260x 260x   41x 35x 2x   33x 33x   1x     6x                       9x 9x                                             5x       31x                             106x 106x 102x       4x 4x       4x   4x 106x 106x   3x 12x 12x     3x 3x 3x 3x   3x   3x                     32x 32x 32x 62x       32x 30x 32x 30x       31x                                                           66x 66x     63x 39x 86x   86x         24x 24x       7x   17x               17x   66x 62x 62x 25x          
import { GRAPH2D_QUESTION_BANK_CANVAS_SIZE } from "./graphConstants";
 
/**
 * Helpers for Graph2D import/display only.
 *
 * Grading of drawing-graph and Graph-Multiple-Select answers is **API-only**
 * (`check_graph_data` / `grade_interactive_dots_answer`). Do not compare
 * Graph2D export strings in the browser — checksum and exportedAt differ
 * between saves.
 *
 * graph2d-react `importAnswer` requires a JSON **string** (see `populateArraysFromEncoded`).
 * API responses may deserialize `correctAnswer.answers` to an object or `{ answer }` entry.
 *
 * @param {unknown} answers - Stored graph export string, parsed object, or answer entry array
 * @returns {string | null}
 */
export function normalizeGraphAnswerForImport(answers) {
    if (answers == null || answers === "") {
        return null;
    }
 
    if (Array.isArray(answers)) {
        const first = answers[0];
        if (first != null && typeof first === "object" && "answer" in first) {
            return normalizeGraphAnswerForImport(first.answer);
        }
        if (answers.length === 1) {
            return normalizeGraphAnswerForImport(answers[0]);
        }
    }
 
    if (typeof answers === "string") {
        const t = answers.trim();
        return t || null;
    }
    if (typeof answers === "object") {
        if ("answer" in answers && typeof answers.answer === "string") {
            return normalizeGraphAnswerForImport(answers.answer);
        }
        try {
            return JSON.stringify(answers);
        } catch {
            return null;
        }
    }
    return null;
}
 
/**
 * Empty graph payload for clearing a slot; **width/height** must match
 * {@link GRAPH2D_QUESTION_BANK_CANVAS_SIZE}.
 *
 * Keep this as Graph2D's legacy plain-data shape. A versioned envelope requires
 * a checksum generated by Graph2D itself; a hard-coded checksum becomes invalid
 * whenever the canvas attributes change and triggers false corruption warnings.
 */
export function getEmptyGraphDataObject() {
    const s = GRAPH2D_QUESTION_BANK_CANVAS_SIZE;
    return {
        dots: [],
        labeldots: [],
        pointValueDots: [],
        odots: [],
        lines: [],
        tplines: [],
        ineqlines: [],
        canvasTexts: [],
        attributes: {
            yMin: -10,
            yMax: 10,
            xMin: -10,
            xMax: 10,
            steps: 1,
            borderPixel: 5,
            width: s,
            height: s,
        },
    };
}
 
export function getEmptyGraphJsonString() {
    return JSON.stringify(getEmptyGraphDataObject());
}
 
/** Cartesian plane range used when a saved answer has no (or an invalid) custom range. */
export const DEFAULT_GRAPH_AXIS_BOUNDS = { xMin: -10, xMax: 10, yMin: -10, yMax: 10 };
 
/**
 * Recovers the axis range (`{xMin, xMax, yMin, yMax}`) a graph answer was drawn against.
 *
 * Graph2D stamps the canvas's current range onto every export as `data.attributes` (see
 * `getDataAsObject` in graph2d-react), so a question authored with a non-default cartesian
 * plane carries its range along with the answer itself — no separate field needed. Falls
 * back to {@link DEFAULT_GRAPH_AXIS_BOUNDS} for legacy `#o#`-encoded answers (predate
 * per-question ranges) or anything unreadable.
 *
 * @param {unknown} answers - Stored graph export string, parsed object, or answer entry array
 * @returns {{xMin: number, xMax: number, yMin: number, yMax: number}}
 */
export function parseGraphAxisBounds(answers) {
    const encoded = normalizeGraphAnswerForImport(answers);
    if (!encoded || encoded.includes("#o#")) {
        return { ...DEFAULT_GRAPH_AXIS_BOUNDS };
    }
 
    let parsed;
    try {
        parsed = JSON.parse(encoded);
    } catch {
        return { ...DEFAULT_GRAPH_AXIS_BOUNDS };
    }
    Iif (!parsed || typeof parsed !== "object") return { ...DEFAULT_GRAPH_AXIS_BOUNDS };
 
    const content = parsed.data && typeof parsed.data === "object" ? parsed.data : parsed;
    const attributes = content?.attributes;
    if (!attributes || typeof attributes !== "object") return { ...DEFAULT_GRAPH_AXIS_BOUNDS };
 
    const toFiniteNumber = (value, fallback) => {
        const num = Number(value);
        return Number.isFinite(num) ? num : fallback;
    };
 
    const xMin = toFiniteNumber(attributes.xMin, DEFAULT_GRAPH_AXIS_BOUNDS.xMin);
    const xMax = toFiniteNumber(attributes.xMax, DEFAULT_GRAPH_AXIS_BOUNDS.xMax);
    const yMin = toFiniteNumber(attributes.yMin, DEFAULT_GRAPH_AXIS_BOUNDS.yMin);
    const yMax = toFiniteNumber(attributes.yMax, DEFAULT_GRAPH_AXIS_BOUNDS.yMax);
 
    Iif (xMin >= xMax || yMin >= yMax) return { ...DEFAULT_GRAPH_AXIS_BOUNDS };
 
    return { xMin, xMax, yMin, yMax };
}
 
/**
 * Authored cartesian window for take-assignment / preview.
 * Reads the student prompt (`correctAnswer.answers`) or the answer-key envelope.
 *
 * @param {object} [question]
 * @returns {{xMin: number, xMax: number, yMin: number, yMax: number}}
 */
export function parseGraphAxisBoundsFromQuestion(question) {
    const correct = question?.correctAnswer;
    const fromAnswers = parseGraphAxisBounds(correct?.answers);
    const isDefault = (bounds) =>
        bounds.xMin === DEFAULT_GRAPH_AXIS_BOUNDS.xMin &&
        bounds.xMax === DEFAULT_GRAPH_AXIS_BOUNDS.xMax &&
        bounds.yMin === DEFAULT_GRAPH_AXIS_BOUNDS.yMin &&
        bounds.yMax === DEFAULT_GRAPH_AXIS_BOUNDS.yMax;
    if (!isDefault(fromAnswers)) return fromAnswers;
    const fromContent = parseGraphAxisBounds(correct?.content);
    Iif (!isDefault(fromContent)) return fromContent;
    return parseGraphAxisBounds(correct);
}
 
/** The drawable collections. `attributes` describes the axes, not anything drawn on them. */
const GRAPH_CONTENT_KEYS = [
    "dots",
    "labeldots",
    "labeldotstypes",
    "pointValueDots",
    "odots",
    "lines",
    "tplines",
    "ineqlines",
    "canvasTexts",
    "interactiveDots",
];
 
/**
 * Has anything actually been drawn on this graph?
 *
 * Added by Allan Ninal. An untouched canvas does NOT serialize to an empty string — it
 * comes back fully populated with empty collections, in one of two encodings:
 *
 *   legacy:  lines#o#{}&dots#o#{}&labeldots#o#{}&…&ineqlines#o#{}
 *   json:    {"dots":[],"lines":[],…,"attributes":{…}}
 *
 * which is why a `answer.trim() !== ""` test can never detect one. Anything that needs
 * to know whether a teacher drew a correct answer, or a student drew anything at all,
 * has to look inside.
 *
 * @param {unknown} answers - serialized graph answer, in either encoding
 * @returns {boolean} true when there is nothing drawn (including unreadable input)
 */
export function isEmptyGraphAnswer(answers) {
    const serialized = normalizeGraphAnswerForImport(answers);
    if (!serialized) return true;
 
    // Legacy `key#o#value` pairs joined by `&`.
    if (serialized.includes("#o#")) {
        return serialized.split("&").every((pair) => {
            const value = pair.slice(pair.indexOf("#o#") + 3).trim();
            // `{}` / `[]` / nothing at all all mean "this collection holds nothing".
            return value === "" || value === "{}" || value === "[]";
        });
    }
 
    let parsed;
    try {
        parsed = JSON.parse(serialized);
    } catch {
        // Not something this function can read into. Treating an unreadable answer as
        // drawn would let it through the very checks that call this.
        return true;
    }
    Iif (!parsed || typeof parsed !== "object") return true;
 
    // Modified by Allan Ninal — the CURRENT Graph2D serialisation is an envelope,
    // `{format:"Graph2D", version:"2.0", data:{pointValueDots:[…]}}`, and the drawing
    // lives under `data`. Reading the content keys off the top level found nothing
    // there and called every such answer EMPTY — so a graph a student had actually
    // drawn on read as blank. Older payloads put the collections at the top level, so
    // look at whichever object holds them.
    const content = parsed.data && typeof parsed.data === "object" ? parsed.data : parsed;
 
    return GRAPH_CONTENT_KEYS.every((key) => {
        const value = content[key];
        if (value == null) return true;
        Eif (Array.isArray(value)) return value.length === 0;
        if (typeof value === "object") return Object.keys(value).length === 0;
        return false;
    });
}