Skip to content

OA Simulator & Exam Integrity: OA Structure & Timer Logic

Part 1 of 5 in the OA Simulator & Exam Integrity series.

Task 1, Task 2, and Task 3 covered the infrastructure, the UI, and the judge. This series covers the one Ediky feature that is explicitly modeled on a real hiring gate: the OA (Online Assessment) Simulator. OA Simulator is the canonical page for why it exists and its three routes (OABuilder / OAExam / OAResult); this series goes deeper, and — because full proctoring is a desktop-only capability — leans on the desktop build (ediky-desktop) wherever the two diverge from the website.

Three timing modes, not one

The builder (OABuilder.jsx on the website, ported verbatim onto the local attempt engine as OABuilderPage.jsx on desktop) offers a timingMode choice of three shapes, not a single global countdown:

ModeBudget lives onWhat happens at zero
Whole OAOne clock for the entire attemptAttempt auto-seals
Per questionEach question gets its own budget; "it locks when spent"That question force-submits, next question opens
Per sectionEach section (DSA / CS / SQL / Aptitude…) shares a budgetThe open question force-submits, the section closes

Per-section mode adds two independent toggles: sectionNav (free or one-way — once checked, a finished section can never be revisited) and questionNav (free or sequential, labeled in the builder's own source comment as "D.E. Shaw style — advance only, no going back within the section"). OA Simulator states this D.E. Shaw shape was verified against the real format: three sequential coding problems with individual timers, 10 technical MCQs, 10 aptitude, 0.25 negative marking, no backtracking — the builder's primitives are general enough to reproduce it, not hard-coded to it. Company presets in @edikylab/oa-engine (COMPANY_PRESETS: Cisco, Amazon, Google, Microsoft, Uber, plus a pure-MCQ "DSA Technical Sprint") are soft weighting profiles over the question pool, not fixed section layouts — see Marking Schemes & Scoring for why they weight rather than filter.

diagram100%
rendering diagram…

drag to pan · ctrl / ⌘ + scroll to zoom

The timer model: one global clock, optional unit clocks alongside it

The global countdown is remainingMs = durationMs − elapsed − forfeited, computed in useOAAttempt (useAttempt in OAExamPage.jsx). When a per-question or per-section budget is also configured, that unit's own clock (unitTiming) runs alongside the global one, not instead of it — both tick down together, and whichever hits zero first drives the UI.

javascript
// Simplified real shape of the forfeit comment/logic (OAExamPage.jsx)
function leaveUnitEarly(slug) {
  // Skipping ahead — or the unit's own clock hitting zero — forfeits ITS
  // unused budget straight off the GLOBAL clock. Never banked, never carried
  // to the next question: a fast solve doesn't buy you extra time later.
  att.forfeitQuestionRemainder(slug);   // or forfeitSectionRemainder(section)
  att.markSubmitted(slug);
}

Two distinct triggers reach that same forfeit path: the candidate manually clicking "Next Question" / "Next Section" (via NextSectionConfirm / SkipQuestionConfirm, which exist specifically so forfeiting real time is never an accidental click), and the unit clock reaching zero on its own — in which case whatever is on screen is best-effort submitted first, then the same advance function runs. Natural expiry forfeits nothing extra beyond what elapsed, because the budget was already fully consumed; only early departure loses unused time.

Force-advance on expiry

javascript
// Simplified real shape of the per-unit expiry effect (OAExamPage.jsx)
useEffect(() => {
  if (!unitTiming?.expired) return;
  runnerRef.current?.submit?.().catch(logError);   // best-effort, whatever's on screen
  if (unitTiming.mode === "question") advanceSequential(unitTiming.key, "expired");
  else if (isOneWay && unitTiming.mode === "section") {
    completeSection(unitTiming.key);
    const next = firstUnlockedInNextSection();
    next ? setCurrentSlug(next.slug) : endAndSeal("expired");
  }
}, [unitTiming?.expired]);

// Separately: the GLOBAL clock hitting zero always auto-seals the attempt,
// regardless of unit mode.
useEffect(() => { if (expired) endAndSeal("expired"); }, [expired]);

Free-nav section mode (sectionNav: "free") is the deliberate exception: sections there aren't locked one-way, so an exhausted section budget triggers no forced movement — the global clock stays the only hard stop, and the candidate is trusted to move on voluntarily.

Timer integrity: why the clock survives a refresh

A countdown that lives only in React state resets on refresh — accidental or intentional. The website derives elapsed time from the server-recorded startedAt timestamp in Firestore on every load, which is also why Firebase Auth persistence there is indexedDBLocalPersistence (see Authentication): a refresh must not also log the candidate out mid-exam. The desktop build has no server to defer to, so startedAt is instead a value written once into the local SQLite-backed attempt document at createAttempt time (useOAAttempt.js) and never touched again — recomputing elapsed time from that fixed local timestamp closes the same gap without a network round trip. Per-question and per-section timers store their own started/locked markers on the same attempt document for the identical reason: a reload should never hand back banked time.

Self-assessment

Word count for this page: 887 words.


→ Next: Marking Schemes & Scoring

Ediky Workflow — internal engineering documentation.