All files / src/app/student-page/page/assignments/take-assignment/components takeAssignmentTimer.js

100% Statements 46/46
100% Branches 39/39
100% Functions 7/7
100% Lines 34/34

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                                7x     7x                           297x   278x 278x   804x 268x   263x                         258x   258x 258x 258x 258x                                           96x     93x 93x   2x   91x     26x 26x   2x     24x 18x 12x   7x                     55x   47x 47x                               21x 21x                           49x   46x 46x   7x    
/**
 * Pure helpers for the take-assignment countdown timer.
 *
 * Extracted from Timer.jsx so the rules that decide HOW MUCH TIME A STUDENT GETS
 * are unit testable. The timer auto-submits the assignment when it reaches zero,
 * so an error here either cuts an assessment short or fails to end it.
 *
 * The saved value is a resume aid only. `remaining_time` from the server is
 * authoritative and is re-read on every fetch, so anything unrecognised in
 * storage is safe to ignore — that is why every reader below fails to null
 * rather than guessing.
 *
 * Developer: Allan Ninal
 */
 
/** Single key, matching the rest of the take-assignment session (see Main.jsx). */
export const TIMER_SESSION_KEY = "takeAssignmentRemainingSeconds";
 
/** Fallback when neither storage nor the server offers a remaining time. */
export const DEFAULT_SECONDS = 3600;
 
/**
 * Parses an "HH:MM:SS" duration into seconds.
 *
 * Returns null — never NaN — for anything malformed. NaN would propagate into
 * the countdown, render as "NaN:NaN:NaN", and make the tick's `prev > 1` and
 * `prev === 1` checks both false, so the timer would sit at zero and never
 * auto-submit.
 *
 * @param {string} timeStr
 * @returns {number|null} whole seconds, or null
 */
export function parseTimeStringToSeconds(timeStr) {
    if (typeof timeStr !== "string") return null;
 
    const parts = timeStr.trim().split(":");
    if (parts.length !== 3) return null;
 
    const [hours, minutes, seconds] = parts.map((part) => (/^\d+$/.test(part.trim()) ? Number(part) : NaN));
    if ([hours, minutes, seconds].some(Number.isNaN)) return null;
 
    return hours * 3600 + minutes * 60 + seconds;
}
 
/**
 * Formats seconds as "HH:MM:SS", clamping anything invalid to "00:00:00".
 *
 * Hours are NOT capped at 24 — a long-window assignment should read "30:00:00"
 * rather than wrapping to "06:00:00".
 *
 * @param {number} totalSeconds
 * @returns {string}
 */
export function formatSecondsToTime(totalSeconds) {
    const safe = Number.isFinite(totalSeconds) && totalSeconds > 0 ? Math.floor(totalSeconds) : 0;
 
    const hours = String(Math.floor(safe / 3600)).padStart(2, "0");
    const minutes = String(Math.floor((safe % 3600) / 60)).padStart(2, "0");
    const seconds = String(safe % 60).padStart(2, "0");
    return `${hours}:${minutes}:${seconds}`;
}
 
/**
 * Reads the saved remaining seconds, but ONLY if they belong to this assignment.
 *
 * The assignment id is stored alongside the count because the key is shared.
 * Without the check, a student who opens assignment A, leaves, then opens
 * assignment B inherits A's countdown: nothing overwrites the value on open, and
 * the `remainingTime` effect in Timer.jsx is guarded on the key being absent, so
 * the server's own figure never gets a chance to correct it. Two hours of a
 * two-hour exam can vanish that way.
 *
 * A bare number is the pre-fix format and is deliberately ignored: it carries no
 * assignment id, so it cannot be trusted, and discarding it just means the
 * server's remaining_time is used instead.
 *
 * @param {Storage} storage
 * @param {string} assignmentId
 * @returns {number|null}
 */
export function readSavedSeconds(storage, assignmentId) {
    if (!assignmentId) return null;
 
    let raw;
    try {
        raw = storage?.getItem?.(TIMER_SESSION_KEY);
    } catch {
        return null; // storage denied (private mode) — fall back to the server
    }
    if (raw === null || raw === undefined) return null;
 
    let parsed;
    try {
        parsed = JSON.parse(raw);
    } catch {
        return null;
    }
 
    if (!parsed || typeof parsed !== "object") return null;
    if (parsed.assignmentId !== assignmentId) return null;
    if (!Number.isInteger(parsed.seconds) || parsed.seconds < 0) return null;
 
    return parsed.seconds;
}
 
/**
 * Persists the remaining seconds against the assignment they belong to.
 *
 * @param {Storage} storage
 * @param {string} assignmentId
 * @param {number} seconds
 */
export function saveSeconds(storage, assignmentId, seconds) {
    if (!assignmentId || !Number.isFinite(seconds)) return;
 
    try {
        storage?.setItem?.(
            TIMER_SESSION_KEY,
            JSON.stringify({ assignmentId, seconds: Math.max(0, Math.floor(seconds)) })
        );
    } catch {
        /* storage denied — the countdown still runs, it just will not resume */
    }
}
 
/**
 * Clears the saved countdown. Called once the assignment has been submitted, so
 * a later attempt does not resume a finished one.
 *
 * @param {Storage} storage
 */
export function clearSavedSeconds(storage) {
    try {
        storage?.removeItem?.(TIMER_SESSION_KEY);
    } catch {
        /* nothing to do — a stale value is rejected by readSavedSeconds anyway */
    }
}
 
/**
 * Chooses the countdown's starting value: a saved resume point first, then the
 * server's remaining_time, then the default.
 *
 * @param {{saved?: number|null, remainingTime?: string|null}} sources
 * @returns {number}
 */
export function resolveInitialSeconds({ saved, remainingTime } = {}) {
    if (Number.isInteger(saved) && saved >= 0) return saved;
 
    const fromServer = parseTimeStringToSeconds(remainingTime);
    if (fromServer !== null) return fromServer;
 
    return DEFAULT_SECONDS;
}