teach/test/language-progress.test.js
Ramon 50b9f32e91
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 1m56s
feat: voeg voortgangdetail toe aan de taalwidgets (v0.4.63-beta)
Gewijzigde functionaliteiten:

- Letterblokken logt per afgerond woord hoeveel pogingen het kostte en welke
  letter op welke plek fout ging (beide invoerwijzen: blokken slepen en typen).

- Ballonnenwoord logt nu ook een verloren woord; dat leverde tot nu toe geen
  enkele voortgang op. Een gewonnen woord telt de fout geraden letters mee.

- Flitswoorden stuurt de bestaande zelfcheck ("Gelukt" / "Nog oefenen") door
  naar de voortgang; die uitkomst werd eerder weggegooid.

- Hakken & plakken krijgt dezelfde zelfcheck en daarmee als laatste taalwidget
  een goed/fout-moment; de oefening zelf verandert niet.

- Voortgang-tab toont de fouten van taal: naast fout afgesloten opdrachten ook
  "na N pogingen goed" (letterblokken kent geen faalmoment) en zelfchecks.

- Reeksstappen met flitswoorden of hakken & plakken wachten nu op een echt
  afgerond moment in plaats van meteen te completeren.

Tests: 192 geslaagd (7 nieuw), plus live nagelopen in de leerlingweergave met
echte interactie per widget en een screenshot van de Voortgang-tab.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0126hjBPD2ggZ2LEoDYZSW7S
2026-07-26 03:16:28 +02:00

147 lines
7.3 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';
/* 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();
});