DEV Community

Cover image for Build a Keyboard-Driven Test Grader in Plain JavaScript
QuickGradeCalc
QuickGradeCalc

Posted on

Build a Keyboard-Driven Test Grader in Plain JavaScript

Grading a stack of tests is repetitive: count the misses, convert to a percentage, look up the letter, repeat. It's a nice small project because the logic is tiny, but a few details decide whether the tool feels good to use.

In this tutorial we'll build a grader in one HTML file with no dependencies. You set the question count once, tap a button (or press a key) for each wrong answer, and the percentage and letter update instantly.

1. The grading function

Keep the logic in one pure function, separate from the page:

const DEFAULT_SCALE = [
  { letter: 'A', min: 90 },
  { letter: 'B', min: 80 },
  { letter: 'C', min: 70 },
  { letter: 'D', min: 60 },
];

function gradeTest(total, wrong, scale = DEFAULT_SCALE) {
  if (!Number.isInteger(total) || total <= 0) {
    throw new RangeError('total must be a positive integer');
  }
  const missed = Math.min(Math.max(wrong, 0), total);
  const correct = total - missed;
  const sorted = [...scale].sort((a, b) => b.min - a.min);
  const hit = sorted.find(({ min }) => correct * 100 >= min * total);
  return {
    correct,
    percent: (correct * 100) / total,
    letter: hit ? hit.letter : 'F',
  };
}
Enter fullscreen mode Exit fullscreen mode

Three choices are worth explaining:

  • Clamping. wrong is forced into the range 0..total, so a stray extra tap can never produce a negative score.
  • Cross-multiplying. Instead of testing correct / total * 100 >= min, the code tests correct * 100 >= min * total. That stays in integers, so a score sitting exactly on a cutoff is never decided by floating-point rounding. When I compared the two approaches for tests of up to 200 questions, the division version happened to agree everywhere, but the integer version means you never have to wonder. It works as long as your cutoffs are whole numbers.
  • Sorting the scale. Sorting from the highest cutoff down means a custom scale works even if someone enters it out of order.

2. The page

<label>Questions <input id="total" type="number" min="1" value="20"></label>
<p class="result"><span id="percent">100%</span> <span id="letter">A</span></p>
<p>Wrong: <span id="wrong">0</span></p>

<div class="controls">
  <button id="add">+1 wrong</button>
  <button id="undo">Undo</button>
  <button id="reset">Reset</button>
  <p><small>Keys: W +1 wrong · E undo · R reset</small></p>
</div>

<h2>Quick chart</h2>
<table id="chart"></table>
Enter fullscreen mode Exit fullscreen mode

The state is one number, wrong. The total comes from the input, so there is nothing else to keep in sync.

3. Rendering

const $ = (id) => document.getElementById(id);
let wrong = 0;

function render() {
  const total = Math.max(1, Math.floor(Number($('total').value) || 1));
  wrong = Math.min(wrong, total);
  const { percent, letter } = gradeTest(total, wrong);

  $('wrong').textContent = wrong;
  $('percent').textContent = `${Math.round(percent * 10) / 10}%`;
  $('letter').textContent = letter;

  $('chart').innerHTML =
    '<tr><th>Wrong</th><th>Score</th><th>Letter</th></tr>' +
    Array.from({ length: total + 1 }, (_, w) => {
      const r = gradeTest(total, w);
      return `<tr><td>${w}</td><td>${Math.round(r.percent * 10) / 10}%</td><td>${r.letter}</td></tr>`;
    }).join('');
}
Enter fullscreen mode Exit fullscreen mode

If someone clears the input or types a decimal, the Math.max(1, Math.floor(...)) line turns it into a valid integer, so gradeTest never throws. The table doubles as a quick chart of every possible score.

4. Buttons and keyboard shortcuts

const add = () => { wrong++; render(); };
const undo = () => { wrong = Math.max(0, wrong - 1); render(); };
const reset = () => { wrong = 0; render(); };

$('add').addEventListener('click', add);
$('undo').addEventListener('click', undo);
$('reset').addEventListener('click', reset);
$('total').addEventListener('input', render);

document.addEventListener('keydown', (e) => {
  if (e.ctrlKey || e.metaKey || e.altKey) return;        // keep Ctrl+R, Cmd+W, etc.
  if (e.target instanceof HTMLInputElement) return;      // don't hijack typing
  ({ w: add, e: undo, r: reset })[e.key.toLowerCase()]?.();
});

render();
Enter fullscreen mode Exit fullscreen mode

Single-letter shortcuts have two classic bugs, and both are handled above:

  1. Browser shortcuts. R on its own should reset the count, but Ctrl+R or Cmd+R should still reload the page. Returning early when a modifier key is held fixes that.
  2. Typing in the input. Without the instanceof HTMLInputElement check, typing a w into a field would trigger a tap.

The lookup object { w: add, e: undo, r: reset } keeps the key map in one place, and the ?.() quietly ignores every other key.

5. Make the chart printable

A printed chart beside the papers beats flipping back to the screen. A few lines of CSS hide the buttons when printing:

@media print {
  .controls { display: none; }
}
Enter fullscreen mode Exit fullscreen mode

6. Test the logic

Because gradeTest is a pure function, tests are trivial. Move it to grade.js with export, then run this with Node:

import assert from 'node:assert/strict';
import { gradeTest } from './grade.js';

assert.equal(gradeTest(30, 3).letter, 'A');         // 27/30 = 90%, exactly on the cutoff
assert.equal(gradeTest(20, 3).percent, 85);
assert.equal(gradeTest(25, 5).letter, 'B');         // 80%, exactly on the cutoff
assert.equal(gradeTest(10, 15).letter, 'F');        // clamped to 0 correct
assert.equal(gradeTest(3, 1).letter, 'D');          // 66.67%
console.log('all good');
Enter fullscreen mode Exit fullscreen mode

Always test the exact cutoff values, since those are where grading tools go wrong.

Where to take it next

  • Let users edit the cutoffs and pass the result into gradeTest as scale.
  • Support tests where questions have different point values (switch to points earned over points possible).
  • Save the question count and scale in localStorage so they survive a refresh.

Everything runs in the browser, with no backend, so student scores never leave the page.

This tutorial is a simplified version of the idea behind QuickGradeCalc, a free grading tool I run for teachers. If you'd rather use a finished version than build one, it works the same way: set the question count, then tap +1 wrong.

What would you add first: custom cutoffs, per-question points, or saved settings?

Top comments (1)

Collapse
 
quickgradecalc profile image
QuickGradeCalc •

Want to try it before reading the code? Save the full file as an .html page and open it in your browser. Tell me what you’d change, and I’ll cover it in a follow-up post.