teach/test/pupil-levels.test.js
Ramon e7bb76b76d
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 1m9s
feat: klassenmanagement - niveaugroep + niveau per leerling per vakgebied (v0.4.34-beta)
- Grootste openstaande roadmap-punt. Nieuwe tabel pupil_levels
  (db/019_pupil_levels.sql, zelfde class_id/pupil_id-vorm als
  assignments) legt per vakgebied (taal/rekenen/world) een niveaugroep
  vast (groep 1-8, gebaseerd op de leerjaren van de basisschool waaraan
  de leerdoelen gekoppeld zijn) + niveau (1-3, fijnmazige verfijning
  BINNEN die groep). Klas-standaard met individuele leerling-overrides,
  net zo laagdrempelig instelbaar als de standaard - het speciaal-
  onderwijs-geval waar dat vanaf het begin de norm is.
- Server: GET/PUT/DELETE /levels/class/:id en /levels/pupil/:id
  (src/api.js) hergebruiken exact het assignments-patroon (upsert via
  partial-unique-index, pupil-eerst-dan-klas-resolutie), plus
  GET /my/level voor de leerling-kant. Zelfde permissie
  (assignments.manage) en sameSchool/teacherOnly/pupilAccessible-
  guards als de bestaande toewijzingsroutes.
- Nieuwe admin-tab "Klassenmanagement": klas + vakgebied kiezen, een
  klas-standaard instellen, en per leerling de effectieve groep/niveau
  zien (overerft van de klas tenzij een eigen afwijking is ingesteld)
  met bewerk- en terug-naar-standaard-knoppen.
- ECHTE inhoudskoppeling, niet alleen een standaardwaarde: rekenen is
  het eerste vakgebied waarvan de SLO/TULE-leerlijn direct in
  getalgrenzen te vertalen is (groep 3 t/m 20, groep 4 t/m 100, groep 5
  t/m 1000, groep 6 t/m 10.000). Nieuwe SLO_MATH_RANGES/mathRangeFor()
  in data.js; math.js's gen() gebruikt dit voor de daadwerkelijke
  getallen zodra een leerling een groep heeft (S.yearGroup), met het
  bestaande niveau 1-3 als verfijning daarbinnen. Zonder groep blijft
  het oude vaste 10/20/100-gedrag exact bestaan.
- pupil.js geeft groep/niveau door aan taal/rekenen-widgets bij het
  mounten: niveau is een standaardwaarde (een al opgeslagen bord-niveau
  blijft leidend), groep wordt voor rekenen-widgets altijd meegegeven
  (er is geen "oud" groep-concept om te respecteren). Terzijde ook de
  stille .catch() op de nieuwe async renderPupilWidgets() opgelost.
- Taal/wereldoriëntatie krijgen deze ronde nog geen eigen
  inhoudskoppeling (vraagt een eigen contentbron per groep, nog niet
  uitgezocht) - eerlijk benoemd als vervolgstap, geen loze belofte.
- Tests: test/pupil-levels.test.js (10 tests) dekt de server-routes
  (mocked-pool-patroon) én - via dezelfde vm-techniek als elders in
  test/widgets.test.js voor MONEY_LEVELS - een ECHTE (niet regex-)test
  van mathRangeFor die bevestigt dat elke groep de juiste getalgrens
  oplevert en niveau nooit boven de groep-grens uitkomt. Twee bugs
  gevonden en gefixt tijdens het schrijven van deze tests: null-yearGroup
  werd per ongeluk als groep 1 behandeld i.p.v. "geen groep", en de
  testrol had geen klas-eigenaarschap waardoor 403 in plaats van 200
  terugkwam. Volledige testsuite: 128/128.
- Live geverifieerd in de sandbox: klas-standaard + leerling-override
  instellen in de nieuwe admin-tab, en drie scenario's in de
  leerling-omgeving die bevestigen dat de daadwerkelijk gegenereerde
  sommen binnen de juiste groep-grens blijven (geen klassenmanagement →
  ongewijzigd oud gedrag; groep zonder eigen bord-niveau → volledige
  groep-grens; groep mét een al opgeslagen bord-niveau → dat niveau
  blijft leidend, geschaald naar de juiste groep-grens).
- public/js/data.js bevatte al niet-gerelateerde, nog niet gecommitte
  wijzigingen van vóór deze sessie (extra taalthema's/woorden) - alleen
  de SLO_MATH_RANGES/mathRangeFor-hunk is hier meegenomen (git add -p),
  de rest blijft bewust ongemoeid staan, net als test/language-library.test.js.

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

146 lines
7.6 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { runInNewContext } from 'node:vm';
import Fastify from 'fastify';
import cookie from '@fastify/cookie';
import rateLimit from '@fastify/rate-limit';
import api from '../src/api.js';
/* Klassenmanagement: niveaugroep (groep 1-8) + niveau (1-3) per leerling per
vakgebied. Zelfde soort dekking als test/progress-logging.test.js voor het
server-pad, plus een echte (niet regex-)test van de SLO-getalgrenzen die
math.js gebruikt zodra een leerling een groep heeft. */
function makeApp(user, { classRow = { id: 100, school_id: 2, name: 'Groep 4' }, classDefault = null, pupilOverride = null, classPupilOverrides = [] } = {}) {
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.startsWith('SELECT * FROM classes WHERE id')) return { rows: classRow ? [classRow] : [] };
if (sql.startsWith('SELECT * FROM users WHERE id')) return { rows: [user] };
if (sql === 'SELECT * FROM pupil_levels WHERE class_id = $1 AND subject = $2') return { rows: classDefault ? [classDefault] : [] };
if (sql === 'SELECT * FROM pupil_levels WHERE pupil_id = $1 AND subject = $2') return { rows: pupilOverride ? [pupilOverride] : [] };
if (sql.includes('FROM pupil_levels pl JOIN users u')) return { rows: classPupilOverrides };
if (sql.startsWith('INSERT INTO pupil_levels')) return { rows: [] };
if (sql.startsWith('DELETE FROM pupil_levels')) 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 teacher = { id: 3, username: 'juf', role: 'teacher', school_id: 2, class_id: null, data: {}, data_rev: 0 };
const admin = { id: 4, username: 'beheer', role: 'admin', school_id: 2, class_id: null, data: {}, data_rev: 0 };
const pupil = { id: 9, username: 'lena', role: 'pupil', school_id: 2, class_id: 100, data: {}, data_rev: 0 };
test('klassenmanagement: klas-standaard opslaan doet de juiste upsert-query', async () => {
const { app, calls } = await makeApp(admin);
const res = await app.inject({
method: 'PUT', url: '/api/levels/class/100', cookies,
payload: { subject: 'rekenen', yearGroup: 4, level: 2 },
});
assert.equal(res.statusCode, 200, res.body);
const ins = calls.find(c => c.sql.startsWith('INSERT INTO pupil_levels'));
assert.ok(ins);
assert.deepEqual(ins.params, [100, 'rekenen', 4, 2]);
});
test('klassenmanagement: ongeldig vakgebied geeft 400', async () => {
const { app } = await makeApp(teacher);
const res = await app.inject({
method: 'PUT', url: '/api/levels/class/100', cookies,
payload: { subject: 'gym', yearGroup: 4, level: 2 },
});
assert.equal(res.statusCode, 400, res.body);
});
test('klassenmanagement: groep buiten 1-8 geeft 400', async () => {
const { app } = await makeApp(teacher);
const res = await app.inject({
method: 'PUT', url: '/api/levels/class/100', cookies,
payload: { subject: 'rekenen', yearGroup: 12, level: 2 },
});
assert.equal(res.statusCode, 400, res.body);
});
test('klassenmanagement: GET class levert klas-standaard én leerling-overrides in één keer', async () => {
const { app } = await makeApp(admin, {
classDefault: { year_group: 4, level: 2 },
classPupilOverrides: [{ pupil_id: 9, year_group: 6, level: 3 }],
});
const res = await app.inject({ method: 'GET', url: '/api/levels/class/100?subject=rekenen', cookies });
assert.equal(res.statusCode, 200, res.body);
const body = res.json();
assert.deepEqual(body.classDefault, { yearGroup: 4, level: 2 });
assert.deepEqual(body.pupilOverrides, [{ pupilId: 9, yearGroup: 6, level: 3 }]);
});
test('klassenmanagement: /my/level - leerling-override wint van klas-standaard', async () => {
const { app } = await makeApp(pupil, { pupilOverride: { year_group: 6, level: 3 } });
const res = await app.inject({ method: 'GET', url: '/api/my/level?subject=rekenen', cookies });
assert.equal(res.statusCode, 200, res.body);
assert.deepEqual(res.json(), { yearGroup: 6, level: 3 });
});
test('klassenmanagement: /my/level - valt terug op de klas-standaard zonder eigen override', async () => {
const { app } = await makeApp(pupil, { classDefault: { year_group: 4, level: 2 } });
const res = await app.inject({ method: 'GET', url: '/api/my/level?subject=rekenen', cookies });
assert.equal(res.statusCode, 200, res.body);
assert.deepEqual(res.json(), { yearGroup: 4, level: 2 });
});
test('klassenmanagement: /my/level - niets ingesteld geeft de bestaande widget-standaardwaarde', async () => {
const { app } = await makeApp(pupil);
const res = await app.inject({ method: 'GET', url: '/api/my/level?subject=rekenen', cookies });
assert.equal(res.statusCode, 200, res.body);
assert.deepEqual(res.json(), { yearGroup: null, level: 1 });
});
test('klassenmanagement: leerkracht-rol wordt geweigerd op /my/level (alleen leerlingen)', async () => {
const { app } = await makeApp(teacher);
const res = await app.inject({ method: 'GET', url: '/api/my/level?subject=rekenen', cookies });
assert.equal(res.statusCode, 403, res.body);
});
/* Echte (niet regex-)test van de SLO-getalgrenzen: data.js's SLO_MATH_RANGES/
mathRangeFor zijn pure functies zonder DOM-afhankelijkheid, dus rechtstreeks
uitvoerbaar in een geïsoleerde VM-context - zelfde techniek als elders in
test/widgets.test.js voor MONEY_LEVELS/createMoneyTask. */
test('SLO-rekengrenzen: groep bepaalt de daadwerkelijke getalgrens, niet meer de vaste 10/20/100-klem', async () => {
const source = await readFile('public/js/data.js', 'utf8'), context = {};
runInNewContext(source + ';globalThis.__mathRange = mathRangeFor', context);
const mathRangeFor = context.__mathRange;
assert.equal(mathRangeFor(3, 3).addSub, 20, 'groep 3 t/m 20');
assert.equal(mathRangeFor(4, 3).addSub, 100, 'groep 4 t/m 100');
assert.equal(mathRangeFor(5, 3).addSub, 1000, 'groep 5 t/m 1000');
assert.equal(mathRangeFor(6, 3).addSub, 10000, 'groep 6 t/m 10.000');
// niveau is een verfijning BINNEN de groep: nooit hoger dan de groep-grens
for (let g = 1; g <= 8; g++) {
const r1 = mathRangeFor(g, 1), r2 = mathRangeFor(g, 2), r3 = mathRangeFor(g, 3);
assert.ok(r1.addSub <= r2.addSub && r2.addSub <= r3.addSub, `groep ${g}: niveau moet oplopen`);
assert.ok(r1.addSub >= 10, `groep ${g} niveau 1 nooit onder de kleinste bestaande standaardwaarde`);
}
// zonder groep (geen klassenmanagement ingesteld) geeft mathRangeFor niets terug -
// math.js valt dan terug op zijn eigen oude vaste logica
assert.equal(mathRangeFor(null, 2), null);
assert.equal(mathRangeFor(undefined, 2), null);
});
test('math.js: gen() gebruikt mathRangeFor als er een groep is, anders de oude vaste klem', async () => {
const source = await readFile('public/js/widgets/math.js', 'utf8');
assert.match(source, /const slo = mathRangeFor\(S\.yearGroup, L\)/);
assert.match(source, /const max = slo \? slo\.addSub : \(L===1 \? 10 : L===2 \? 20 : 100\)/);
assert.match(source, /const tmax = slo \? slo\.timesTable : \(L===1 \? 5 : L===2 \? 10 : 12\)/);
assert.ok(source.includes('S.yearGroup = initState.yearGroup || null'));
});