All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 1m9s
- 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
146 lines
7.6 KiB
JavaScript
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'));
|
|
});
|