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'; /* Voortgang bij taal. math.js logt sinds v0.4.35-beta per som mét detail; de taalwidgets leverden alleen een kale telling op, waardoor de leerkracht bij taal nooit zag WELK woord moeite kostte. De payloadvorm zit nu in één pure functie, die hier echt uitgevoerd wordt (niet alleen op tekst gecontroleerd), zelfde vm-techniek als de SLO-getalgrenzen in test/pupil-levels.test.js. */ async function loadPayloadHelper() { const source = await readFile('public/js/learning-progressions.js', 'utf8'); const cut = source.indexOf('let SELF_LEARNING_LEVELS'); // daaronder hangt alles aan de DOM/api const context = { LANG: 'nl', document: undefined }; runInNewContext(`${source.slice(0, cut)};globalThis.__payload = languageProgressPayload`, context); // objecten uit een vm-context delen onze prototypes niet: platslaan zodat // deepEqual over de wáárden gaat en niet over de realm return (options) => JSON.parse(JSON.stringify(context.__payload(options))); } test('taalvoortgang: een foutloos woord levert één schone poging op', async () => { const payload = await loadPayloadHelper(); const result = payload({ word: 'boom', mistakes: [], solved: true, stars: 1 }); assert.deepEqual(result, { attempts: 1, correct: 1, stars: 1, detail: { question: 'boom', expected: 'boom', answer: 'boom', correct: true }, }); assert.ok(!('mistakes' in result.detail), 'zonder missers geen mistakes-lijst'); }); test('taalvoortgang: goed na fouten telt de pogingen en bewaart welke letter fout ging', async () => { const payload = await loadPayloadHelper(); const result = payload({ word: 'boom', stars: 1, solved: true, mistakes: [{ expected: 'o', given: 'a' }, { expected: 'm', given: 'n' }], }); assert.equal(result.attempts, 3, '1 goede plaatsing + 2 missers'); assert.equal(result.correct, 1); // de grafiek rekent wrong = attempts - correct: 2 fouten, 1 goed assert.equal(result.attempts - result.correct, 2); assert.equal(result.detail.correct, false, 'niet foutloos: hoort in de foutenlijst'); assert.deepEqual(result.detail.mistakes, [ { expected: 'o', given: 'a' }, { expected: 'm', given: 'n' }, ]); assert.equal(result.detail.answer, 'a', 'eerste misser vult het gegeven antwoord'); assert.equal(result.detail.expected, 'o'); }); test('taalvoortgang: een verloren ballonwoord logt een echte fout', async () => { const payload = await loadPayloadHelper(); const result = payload({ word: 'bloem', solved: false, expected: 'bloem', answer: 'b_o__', mistakes: ['k', 's', 'r', 'a', 'd', 'p'].map((given) => ({ given, expected: 'bloem' })), }); assert.equal(result.correct, 0); assert.equal(result.stars, 0, 'geen ster voor een verloren woord'); assert.equal(result.attempts, 6); assert.equal(result.detail.correct, false); assert.equal(result.detail.answer, 'b_o__', 'wat de leerling nog open had staan'); assert.equal(result.detail.expected, 'bloem'); }); test('taalvoortgang: een zelfcheck zonder ingetypt antwoord blijft één poging', async () => { const payload = await loadPayloadHelper(); const nietGelukt = payload({ word: 'schaap', solved: false, selfCheck: true }); assert.equal(nietGelukt.attempts, 1, 'ook zonder missers telt dit als één poging'); assert.equal(nietGelukt.correct, 0); assert.equal(nietGelukt.detail.answer, null, 'er is geen antwoord om te tonen'); assert.equal(nietGelukt.detail.selfCheck, true); const gelukt = payload({ word: 'schaap', solved: true, stars: 1, selfCheck: true }); assert.equal(gelukt.correct, 1); assert.equal(gelukt.detail.correct, true); }); test('taalvoortgang: het detail blijft binnen de servergrens van 1000 tekens', async () => { const payload = await loadPayloadHelper(); const result = payload({ word: 'w'.repeat(300), solved: true, stars: 1, mistakes: Array.from({ length: 20 }, (_, i) => ({ expected: 'x'.repeat(80), given: String(i) })), }); assert.equal(result.detail.mistakes.length, 6, 'hooguit zes missers bewaren'); assert.equal(result.detail.question.length, 80, 'woord afgekapt'); assert.ok(JSON.stringify(result.detail).length < 1000, 'anders gooit sanitizeProgressDetail het hele detail weg'); }); /* De vier taalwidgets moeten de gedeelde helper ook echt gebruiken; de werking in de widget zelf (drag & drop, ballonnen, zelfcheck) is in de browser nagelopen met echte interactie. */ test('taalwidgets gebruiken de gedeelde payload en ontvangen opts', async () => { const [letters, ball, flits, hak] = await Promise.all([ readFile('public/js/widgets/letters.js', 'utf8'), readFile('public/js/widgets/ball.js', 'utf8'), readFile('public/js/widgets/flits.js', 'utf8'), readFile('public/js/widgets/hak.js', 'utf8'), ]); for (const [name, source] of [['letters', letters], ['ball', ball], ['flits', flits], ['hak', hak]]) { assert.match(source, /onProgress\(languageProgressPayload\(/, `${name} logt via de gedeelde vorm`); assert.doesNotMatch(source, /onProgress\(\s*\{\s*attempts/, `${name} heeft geen losse payload meer`); } assert.match(flits, /function mountFlits\(root, initState, opts\)/); assert.match(hak, /function mountHak\(root, initState, opts\)/); // ballon logt op twee plekken: gewonnen én verloren woord assert.equal((ball.match(/languageProgressPayload\(/g) || []).length, 2); // letterblokken telt missers in beide invoerwijzen (toetsenbord en blokken) assert.equal((letters.match(/S\.wordMistakes\.push\(/g) || []).length, 2); }); test('reeks: een flitsstap wacht nu op een echt afgerond moment', async () => { const teacher = { id: 3, username: 'juf', role: 'teacher', school_id: 2, class_id: null, data: {}, data_rev: 0, }; const calls = []; const query = async (sql, params = []) => { calls.push({ sql, params }); if (sql.includes('FROM sessions s JOIN users u')) return { rows: [teacher] }; if (sql.includes('FROM user_roles')) return { rows: [] }; if (sql.startsWith('SELECT * FROM classes WHERE id')) return { rows: [{ id: 50, school_id: 2 }] }; if (sql.includes('FROM class_teachers')) return { rows: [{ class_id: 50 }] }; if (sql.includes('INSERT INTO assignments')) return { rows: [{ id: 90 }] }; return { rows: [] }; }; const app = Fastify({ trustProxy: 2 }); await app.register(cookie); await app.register(rateLimit, { global: false }); app.decorate('pg', { query }); await app.register(api, { prefix: '/api' }); await app.ready(); const response = await app.inject({ method: 'PUT', url: '/api/assignments/class/50', cookies: { teach_session: 'x'.repeat(64) }, payload: { title: 'Taalroute', sequence: [{ widgetType: 'flits', mode: 'werken' }, { widgetType: 'hak', mode: 'werken' }], }, }); assert.equal(response.statusCode, 200, response.body); const insert = calls.find((call) => call.sql.includes('INSERT INTO assignments')); const sequence = JSON.parse(insert.params[6]); assert.equal(sequence[0].requiresProgress, true, 'flits heeft nu een zelfcheck als bewijs'); assert.equal(sequence[1].requiresProgress, true, 'hakken & plakken ook'); await app.close(); });