teach/test/progress-logging.test.js
Ramon 8ccfa5dc8c
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 1m17s
feat: voortgang flinke UI-upgrade + bug (1 opdracht i.p.v. groep) + grafiek (v0.4.35-beta)
- Bug gevonden en bevestigd (niet blind aangenomen): math.js riep
  onProgress maar op ÉÉN plek aan, in endRound(), NA een heel blok van
  10 sommen, met alleen het geaggregeerde totaal. Eén sessie van 10
  sommen - "een groep opdrachten" - leverde dus precies 1 rij op in
  progress_events, niet 10, en maakte "welke som ging fout" onmogelijk
  te zien (alleen een totaaltelling per blok).
- Fix: onProgress verplaatst naar check(), bij elke afgeronde som (goed
  op de eerste keer, of opgeven na 3 pogingen) - een sessie van 10
  sommen levert nu 10 progress_events-rijen op, elk met een nieuw
  optioneel detail-veld (welke som, gegeven antwoord, juist antwoord).
  letters.js/ball.js blijven deze ronde ongemoeid (hun granulariteit
  veroorzaakte niet deze bug).
- Nieuwe kolom progress_events.detail (JSONB, nullable, puur additief -
  db/020_progress_detail.sql). POST /my/progress accepteert het
  optioneel; te groot/geen plain object wordt stilzwijgend genegeerd
  zonder de rest van de log-poging te laten falen (detail is decoratief,
  geen kernfunctionaliteit).
- Voortgang-tab (admin.js) herontworpen, zelfde ontwerptaal als de
  eerdere School-navigator-herbouw: vakgebied-tabs, een
  klassenmanagement-infobadge (Groep {n} · Niveau {n}, opgehaald via de
  bestaande /levels/pupil/:id-route) zodat resultaten meteen tegen het
  toegewezen niveau afgezet kunnen worden, een met de hand getekende
  SVG-nauwkeurigheidsring + staafdiagram (goed/fout per dag, laatste 14
  dagen - geen library, zelfde aanpak als avatar.js) en een "recente
  fouten"-lijst die precies toont welke som fout ging. De bestaande
  ruwe tijdlijn blijft eronder staan, niets is verwijderd.
- Terzijde gevonden en gefixt: .am-btn.active bestond nergens in de
  CSS, waardoor de vakgebied-tabs (hier én in Klassenmanagement,
  v0.4.34-beta) geen enkele visuele aanduiding hadden welke actief was.
- Tests: test/progress-logging.test.js uitgebreid met 4 tests voor het
  detail-veld (opgeslagen, te groot genegeerd, geen plain object
  genegeerd, afwezig blijft null). Volledige testsuite: 132/132.
- Live geverifieerd: een math.js-sessie met een mix van goede/foute
  sommen doorlopen en bevestigd dat er 3 aparte POST /my/progress-
  aanroepen gebeuren voor 3 sommen (niet 1 aggregaat), elk met het
  juiste detail; de herbouwde Voortgang-tab geopend met gemockte data
  en met screenshots bevestigd dat de nauwkeurigheidsring (38% = 3/8,
  rekenkundig geverifieerd), het klassenmanagement-infobadge, de
  staafdiagram en de "recente fouten"-lijst (met de daadwerkelijke som)
  correct tonen, én dat de active-tab-fix ook op de Klassenmanagement-
  tab zichtbaar is.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014EPxzBVRXZZnBPvSAaPAbJ
2026-07-19 15:56:14 +02:00

141 lines
7.9 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import Fastify from 'fastify';
import cookie from '@fastify/cookie';
import rateLimit from '@fastify/rate-limit';
import api from '../src/api.js';
/* "de voortgangen werken niet" (gebruikersklacht) - dit pad had nog GEEN enkele
test (mocked-pool of anders), ondanks dat het de kern van de voortgangs-
feature is: een leerling logt een afgerond moment, en het datamodel bepaalt
zelf teacher_id/board_id uit de toewijzing (nooit van de client). Dekt de
drie manieren waarop een toewijzing gevonden kan worden (leerling-eigen,
klasbreed, thuiskind-via-ouder) plus de foutpaden. */
function makeApp(user, { assignmentRows = {}, parentRows = [] } = {}) {
const calls = [];
const query = async (sql, params = []) => {
calls.push({ sql, params });
if (sql.includes('FROM sessions s JOIN users u')) return { rows: [user] };
if (sql.includes('FROM user_roles')) return { rows: [] };
if (sql === 'SELECT * FROM assignments WHERE pupil_id = $1') return { rows: assignmentRows.byPupil ? [assignmentRows.byPupil] : [] };
if (sql === 'SELECT * FROM assignments WHERE class_id = $1') return { rows: assignmentRows.byClass ? [assignmentRows.byClass] : [] };
if (sql.startsWith('SELECT parent_id FROM parent_children')) return { rows: parentRows };
if (sql.startsWith('INSERT INTO progress_events')) return { rows: [] };
return { rows: [] };
};
const pool = { query, async connect() { return { query, release() {} }; } };
return (async () => {
const app = Fastify({ trustProxy: 2 });
await app.register(cookie);
await app.register(rateLimit, { global: false });
app.decorate('pg', pool);
await app.register(api, { prefix: '/api' });
await app.ready();
return { app, calls };
})();
}
const cookies = { teach_session: 'x'.repeat(64) };
const validBody = { widgetId: 'letters:0', widgetType: 'letters', attempts: 1, correct: 1, stars: 1 };
function insertCall(calls) {
return calls.find(c => c.sql.startsWith('INSERT INTO progress_events'));
}
test('voortgang: leerling-eigen toewijzing wint en levert de juiste teacher_id/board_id op', async () => {
const pupil = { id: 9, username: 'lena', role: 'pupil', school_id: 2, class_id: 50, data: {}, data_rev: 0 };
const { app, calls } = await makeApp(pupil, {
assignmentRows: {
byPupil: { teacher_id: 3, board_id: 'bord-a' },
byClass: { teacher_id: 4, board_id: 'bord-b' }, // zou genegeerd moeten worden
},
});
const res = await app.inject({ method: 'POST', url: '/api/my/progress', cookies, payload: validBody });
assert.equal(res.statusCode, 200, res.body);
const ins = insertCall(calls);
assert.ok(ins, 'INSERT verwacht');
assert.equal(ins.params[1], 3);
assert.equal(ins.params[2], 'bord-a');
});
test('voortgang: valt terug op de klasbrede toewijzing als er geen eigen toewijzing is', async () => {
const pupil = { id: 9, username: 'lena', role: 'pupil', school_id: 2, class_id: 50, data: {}, data_rev: 0 };
const { app, calls } = await makeApp(pupil, {
assignmentRows: { byClass: { teacher_id: 4, board_id: 'bord-b' } },
});
const res = await app.inject({ method: 'POST', url: '/api/my/progress', cookies, payload: validBody });
assert.equal(res.statusCode, 200, res.body);
const ins = insertCall(calls);
assert.equal(ins.params[1], 4);
assert.equal(ins.params[2], 'bord-b');
});
test('voortgang: thuiskind zonder school valt terug op de gekoppelde ouder als "leerkracht"', async () => {
const homeChild = { id: 11, username: 'timo', role: 'pupil', school_id: null, class_id: null, data: {}, data_rev: 0 };
const { app, calls } = await makeApp(homeChild, { parentRows: [{ parent_id: 7 }] });
const res = await app.inject({ method: 'POST', url: '/api/my/progress', cookies, payload: validBody });
assert.equal(res.statusCode, 200, res.body);
const ins = insertCall(calls);
assert.equal(ins.params[1], 7);
assert.equal(ins.params[2], 'thuis');
});
test('voortgang: leerling op school zonder enige toewijzing krijgt 400, geen stille silent-fail insert', async () => {
const pupil = { id: 9, username: 'lena', role: 'pupil', school_id: 2, class_id: 50, data: {}, data_rev: 0 };
const { app, calls } = await makeApp(pupil, {});
const res = await app.inject({ method: 'POST', url: '/api/my/progress', cookies, payload: validBody });
assert.equal(res.statusCode, 400, res.body);
assert.ok(!insertCall(calls), 'er mag geen INSERT gebeurd zijn');
});
test('voortgang: niet-leerling (bv. leerkracht) krijgt 403', async () => {
const teacher = { id: 3, username: 'juf', role: 'teacher', school_id: 2, class_id: null, data: {}, data_rev: 0 };
const { app } = await makeApp(teacher, {});
const res = await app.inject({ method: 'POST', url: '/api/my/progress', cookies, payload: validBody });
assert.equal(res.statusCode, 403, res.body);
});
test('voortgang: ontbrekende widgetId/widgetType levert 400 op, niet een kapotte insert', async () => {
const pupil = { id: 9, username: 'lena', role: 'pupil', school_id: 2, class_id: 50, data: {}, data_rev: 0 };
const { app, calls } = await makeApp(pupil, { assignmentRows: { byPupil: { teacher_id: 3, board_id: 'bord-a' } } });
const res = await app.inject({ method: 'POST', url: '/api/my/progress', cookies, payload: { attempts: 1, correct: 1 } });
assert.equal(res.statusCode, 400, res.body);
assert.ok(!insertCall(calls));
});
/* "1 opdracht i.p.v. een groep" - de fix zelf zit in math.js (per som loggen
i.p.v. per blok van 10), maar het datamodel/de server moeten het optionele
detail (welke som/welk antwoord) wel kunnen bewaren zonder de rest van de
voortgang-logging te breken bij rommelige/te grote input. */
test('voortgang: detail (welke opdracht) wordt meegegeven aan de insert', async () => {
const pupil = { id: 9, username: 'lena', role: 'pupil', school_id: 2, class_id: 50, data: {}, data_rev: 0 };
const { app, calls } = await makeApp(pupil, { assignmentRows: { byPupil: { teacher_id: 3, board_id: 'bord-a' } } });
const detail = { question: '7 + 8 =', answer: 14, expected: 15, correct: false };
const res = await app.inject({ method: 'POST', url: '/api/my/progress', cookies, payload: { ...validBody, detail } });
assert.equal(res.statusCode, 200, res.body);
assert.deepEqual(insertCall(calls).params[8], detail);
});
test('voortgang: een te grote detail wordt genegeerd zonder de hele log-poging te laten falen', async () => {
const pupil = { id: 9, username: 'lena', role: 'pupil', school_id: 2, class_id: 50, data: {}, data_rev: 0 };
const { app, calls } = await makeApp(pupil, { assignmentRows: { byPupil: { teacher_id: 3, board_id: 'bord-a' } } });
const detail = { question: 'x'.repeat(2000) };
const res = await app.inject({ method: 'POST', url: '/api/my/progress', cookies, payload: { ...validBody, detail } });
assert.equal(res.statusCode, 200, res.body);
assert.equal(insertCall(calls).params[8], null);
});
test('voortgang: een detail dat geen plain object is (array/string) wordt genegeerd', async () => {
const pupil = { id: 9, username: 'lena', role: 'pupil', school_id: 2, class_id: 50, data: {}, data_rev: 0 };
const { app, calls } = await makeApp(pupil, { assignmentRows: { byPupil: { teacher_id: 3, board_id: 'bord-a' } } });
const res = await app.inject({ method: 'POST', url: '/api/my/progress', cookies, payload: { ...validBody, detail: ['niet', 'plain'] } });
assert.equal(res.statusCode, 200, res.body);
assert.equal(insertCall(calls).params[8], null);
});
test('voortgang: zonder detail-veld blijft alles zoals voorheen (null, geen fout)', async () => {
const pupil = { id: 9, username: 'lena', role: 'pupil', school_id: 2, class_id: 50, data: {}, data_rev: 0 };
const { app, calls } = await makeApp(pupil, { assignmentRows: { byPupil: { teacher_id: 3, board_id: 'bord-a' } } });
const res = await app.inject({ method: 'POST', url: '/api/my/progress', cookies, payload: validBody });
assert.equal(res.statusCode, 200, res.body);
assert.equal(insertCall(calls).params[8], null);
});