Appearance
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:
| Mode | Budget lives on | What happens at zero |
|---|---|---|
| Whole OA | One clock for the entire attempt | Attempt auto-seals |
| Per question | Each question gets its own budget; "it locks when spent" | That question force-submits, next question opens |
| Per section | Each section (DSA / CS / SQL / Aptitude…) shares a budget | The 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.
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