Newer
Older
veriq / frontend / assets / test.js
/* VerIQ test engine (vanilla JS, no deps) */
(function () {
  "use strict";

  var state = {
    token: null,
    tasks: [],
    timeLimitS: 0,
    startedAt: 0,
    current: 0,
    answers: {},   // task_id -> choice index
    timerId: null,
    finished: false
  };

  var $ = function (id) { return document.getElementById(id); };

  function show(id) {
    ["loading", "testArea", "submitArea", "errorArea"].forEach(function (s) {
      $(s).style.display = (s === id) ? "" : "none";
    });
  }

  function showError(msg) {
    $("errorArea").textContent = msg;
    show("errorArea");
  }

  function fmtTime(s) {
    s = Math.max(0, Math.floor(s));
    var m = Math.floor(s / 60), r = s % 60;
    return (m < 10 ? "0" : "") + m + ":" + (r < 10 ? "0" : "") + r;
  }

  function startTimer() {
    var tick = function () {
      var elapsed = (Date.now() - state.startedAt) / 1000;
      var left = state.timeLimitS - elapsed;
      var el = $("timer");
      el.textContent = fmtTime(left);
      el.className = "timer" + (left < 120 ? " low" : "");
      if (left <= 0 && !state.finished) {
        // auto-submit with current answers
        submit(true);
      }
    };
    state.timerId = setInterval(tick, 500);
    tick();
  }

  function renderTask() {
    var t = state.tasks[state.current];
    if (!t) return;
    $("qCounter").textContent = "Task " + (state.current + 1) + " / " + state.tasks.length;
    $("qCategory").textContent = t.category;
    $("qText").textContent = t.prompt.text;
    var svgBox = $("qSvg");
    svgBox.innerHTML = t.prompt.svg || "";
    svgBox.style.display = t.prompt.svg ? "" : "none";

    var box = $("choices");
    box.innerHTML = "";
    t.choices.forEach(function (c, i) {
      var div = document.createElement("div");
      div.className = "choice" + (state.answers[t.id] === i ? " sel" : "");
      var letter = document.createElement("span");
      letter.className = "letter";
      letter.textContent = String.fromCharCode(65 + i);
      div.appendChild(letter);
      if (c.svg) {
        var sv = document.createElement("span");
        sv.innerHTML = c.svg;
        div.appendChild(sv);
      }
      var txt = document.createElement("span");
      txt.textContent = c.text || "";
      div.appendChild(txt);
      div.addEventListener("click", function () {
        state.answers[t.id] = i;
        renderTask();
        updateAnswered();
      });
      box.appendChild(div);
    });

    $("progressBar").style.width =
      Math.round((state.current / state.tasks.length) * 100) + "%";
    $("prevBtn").disabled = state.current === 0;
    $("nextBtn").textContent =
      state.current === state.tasks.length - 1 ? "Finish ✓" : "Next →";
  }

  function updateAnswered() {
    $("answeredCount").textContent = Object.keys(state.answers).length;
  }

  function next() {
    if (state.current < state.tasks.length - 1) {
      state.current++;
      renderTask();
    } else {
      openSubmit();
    }
  }

  function prev() {
    if (state.current > 0) {
      state.current--;
      renderTask();
    }
  }

  function openSubmit() {
    var answered = Object.keys(state.answers).length;
    var elapsed = Math.round((Date.now() - state.startedAt) / 1000);
    $("finishSummary").textContent =
      "Answered " + answered + " of " + state.tasks.length +
      " tasks in " + fmtTime(elapsed) + ". Unanswered tasks count as wrong. " +
      "You can go back and review before submitting.";
    show("submitArea");
  }

  function submit(auto) {
    if (state.finished) return;
    state.finished = true;
    clearInterval(state.timerId);
    var elapsed = (Date.now() - state.startedAt) / 1000;
    var payload = {
      session_token: state.token,
      answers: state.answers,
      total_time_s: Math.round(elapsed * 10) / 10
    };
    $("finishBtn").disabled = true;
    $("finishBtn").textContent = auto ? "Time is up — submitting…" : "Submitting…";
    fetch("/api/finish", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(payload)
    }).then(function (r) {
      if (!r.ok) return r.json().then(function (e) { throw new Error(e.detail || r.status); });
      return r.json();
    }).then(function (data) {
      try { localStorage.removeItem("veriq_session"); } catch (e) {}
      window.location.href = "/result/" + data.result_id;
    }).catch(function (e) {
      state.finished = false;
      $("finishBtn").disabled = false;
      $("finishBtn").textContent = "Submit & get my signed result";
      showError("Submit failed: " + e.message);
    });
  }

  function init() {
    // resume session if page reloaded mid-test
    var saved = null;
    try { saved = JSON.parse(localStorage.getItem("veriq_session") || "null"); } catch (e) {}
    if (saved && saved.token && saved.tasks && saved.tasks.length &&
        Date.now() - saved.startedAt < saved.timeLimitS * 1000) {
      state.token = saved.token;
      state.tasks = saved.tasks;
      state.timeLimitS = saved.timeLimitS;
      state.startedAt = saved.startedAt;
      state.answers = saved.answers || {};
      state.current = saved.current || 0;
      $("sessionInfo").textContent = "session restored";
      show("testArea");
      renderTask();
      updateAnswered();
      startTimer();
      return;
    }

    fetch("/api/start", { method: "POST" }).then(function (r) {
      if (!r.ok) return r.json().then(function (e) { throw new Error(e.detail || r.status); });
      return r.json();
    }).then(function (data) {
      state.token = data.session_token;
      state.tasks = data.tasks;
      state.timeLimitS = data.time_limit_s;
      state.startedAt = Date.now();
      state.current = 0;
      state.answers = {};
      try {
        localStorage.setItem("veriq_session", JSON.stringify({
          token: state.token, tasks: state.tasks,
          timeLimitS: state.timeLimitS, startedAt: state.startedAt,
          answers: {}, current: 0
        }));
      } catch (e) {}
      $("sessionInfo").textContent = "40 tasks · one attempt";
      show("testArea");
      renderTask();
      updateAnswered();
      startTimer();
    }).catch(function (e) {
      showError("Could not start the test: " + e.message +
        (String(e.message).indexOf("429") >= 0 ? " — too many attempts from your network, try again later." : ""));
    });
  }

  // persist state on navigation within test
  window.addEventListener("beforeunload", function () {
    if (state.token && !state.finished) {
      try {
        var s = JSON.parse(localStorage.getItem("veriq_session") || "{}");
        s.answers = state.answers;
        s.current = state.current;
        localStorage.setItem("veriq_session", JSON.stringify(s));
      } catch (e) {}
    }
  });

  $("nextBtn").addEventListener("click", next);
  $("prevBtn").addEventListener("click", prev);
  $("finishBtn").addEventListener("click", function () { submit(false); });
  $("reviewBtn").addEventListener("click", function () {
    state.current = 0;
    show("testArea");
    renderTask();
  });

  init();
})();