Tests Custom Reports

Custom Report Recipes

Tested building blocks for custom reports: safe percentages, matching the test's own pass mark and performance levels, parent and child dimensions, band texts, answers by Reporting Name, sten bars and reusable macros.

Published 2026/10/15

Short, tested building blocks. Function snippets go inside function anonymous(data) { … }; template snippets go in the Nunjucks template. Field names are explained in the Report Input Reference.

Function recipes

Percentage without dividing by zero

function pct(point, total) {
  return total ? Math.round((point / total) * 100) : 0;
}

Pass / fail with the test's own pass mark

function verdictOf(percent, passFail) {
  if (!passFail || !passFail.active) return null;
  const threshold = passFail.pass.min.score * 100;   // stored as 0-1
  const passed = passFail.pass.min.inclusive ? percent >= threshold : percent > threshold;
  return { passed: passed, content: passed ? passFail.pass.content : passFail.fail.content };
}
const verdict = verdictOf(percent, data.testReporting && data.testReporting.passFail);

The test's own performance level

function levelOf(percent, level) {
  if (!level || !level.active) return null;
  for (let i = 0; i < level.items.length; i++) {
    const it = level.items[i];
    const aboveFrom = it.from.inclusive ? percent >= it.from.value : percent > it.from.value;
    const belowTo = it.to.inclusive ? percent <= it.to.value : percent < it.to.value;
    if (aboveFrom && belowTo) return it.content;   // { label, description, color }
  }
  return null;
}
const level = levelOf(percent, data.testReporting && data.testReporting.level);

Pass rounded percentages (as from pct above) to get the same verdict and level as the Standard report.

Top-level and child dimensions

const names = Object.keys(data.result.dimensions);
const topLevel = names.filter(function (n) { return n.indexOf("/") === -1; });
function childrenOf(parent) {
  const depth = parent.split("/").length + 1;
  return names.filter(function (n) {
    return n.indexOf(parent + "/") === 0 && n.split("/").length === depth;
  });
}
// childrenOf("Aptitude") → ["Aptitude/Verbal", "Aptitude/Numerical"]

Band texts from one lookup

const TEXTS = {
  low:  "Shows foundational understanding; targeted practice is recommended.",
  mid:  "Performs reliably in most situations.",
  high: "Performs at an advanced level and could coach others."
};
const description = TEXTS[band.key];

Keeping every text in one object also makes it easy to find the longest one when you check that pages fit A4.

Time spent

const secs = data.result.serveStats.timeSpent || 0;
const duration = Math.floor(secs / 60) + " min " + (secs % 60) + " s";

Copy an object with an extra field (instead of object spread)

const row = Object.assign({}, base, { percent: 72 });

Not-ready guard

// function
if (!data.result) return { ready: false };

{# template #}
{% if not ready %}<p>Results are not available yet.</p>{% else %} … {% endif %}

Template recipes

An answer by Reporting Name

<p>Motivation: {{ result.extracted["Q_MOTIVATION"] | default("—") }}</p>

An access-form field

Employee no: {{ initiator.roomForm["Employee No"] }}

Sten bar (1–10)

{% set s = normy.normativeResult.dimensions["Communication"].sten %}
{% for i in range(1, 11) %}
  <span class="cell {{ 'on' if i <= s else '' }}"></span>
{% endfor %}

Reusable chip (macro)

{% macro chip(text, color) %}<span class="chip" style="background:{{ color }}">{{ text }}</span>{% endmacro %}

{{ chip(overall.band.label, overall.band.color) }}

Score bar

<div class="bar"><span style="width: {{ d.percent }}%; background: {{ d.band.color }}"></span></div>

Loop over a map

{% for name, dim in result.dimensions %}
  {{ name }}: {{ dim.stats.point }} / {{ dim.stats.totalPoint }}
{% endfor %}