teach/test/live-classroom.test.js
Ramon 131ffc2f38
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 47s
feat: breid live klasinteractie grafisch uit
2026-07-24 13:40:18 +02:00

208 lines
8.9 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import Fastify from 'fastify';
import cookie from '@fastify/cookie';
import rateLimit from '@fastify/rate-limit';
import api from '../src/api.js';
const cookies = { teach_session: 'x'.repeat(64) };
const teacher = {
id: 3, username: 'juf', display_name: 'Juf', role: 'teacher',
school_id: 2, class_id: null, data: {}, data_rev: 0,
};
const pupil = {
id: 9, username: 'lena', display_name: 'Lena', role: 'pupil',
school_id: 2, class_id: 50, data: {}, data_rev: 0,
};
async function makeApp(user, query) {
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();
return app;
}
function auth(user, sql) {
if (sql.includes('FROM sessions s JOIN users u')) return { rows: [user] };
if (sql.includes('FROM user_roles')) return { rows: [] };
return null;
}
test('leerkracht kan een actieve leerling direct pauzeren', async () => {
const calls = [];
const query = async (sql, params = []) => {
calls.push({ sql, params });
const login = auth(teacher, sql);
if (login) return login;
if (sql.startsWith('SELECT * FROM classes')) return { rows: [{ id: 50, school_id: 2 }] };
if (sql.startsWith('SELECT 1 FROM class_teachers')) return { rows: [{ ok: 1 }] };
if (sql.startsWith('SELECT * FROM users WHERE id = $1 AND role = $2')) return { rows: [pupil] };
if (sql.includes('UPDATE live_learning_sessions SET paused_at = now()')) return { rows: [{ id: 101 }] };
if (sql.includes('INSERT INTO teacher_pupil_actions')) return { rows: [{
id: 301, class_id: 50, pupil_id: 9, teacher_id: 3, kind: 'pause',
message: null, payload: {}, created_at: new Date(),
}] };
return { rows: [] };
};
const app = await makeApp(teacher, query);
const response = await app.inject({
method: 'POST',
url: '/api/progress/live/class/50/pupil/9/action',
cookies,
payload: { kind: 'pause' },
});
assert.equal(response.statusCode, 200, response.body);
assert.equal(response.json().action.kind, 'pause');
assert.ok(calls.some((call) => call.sql.includes("last_seen_at >= now() - interval '45 seconds'")));
const insert = calls.find((call) => call.sql.includes('INSERT INTO teacher_pupil_actions'));
assert.deepEqual(insert.params.slice(0, 5), [50, 9, 3, 'pause', null]);
await app.close();
});
test('directe actie wordt geweigerd voor een leerling buiten de gekozen klas', async () => {
let inserted = false;
const query = async (sql) => {
const login = auth(teacher, sql);
if (login) return login;
if (sql.startsWith('SELECT * FROM classes')) return { rows: [{ id: 50, school_id: 2 }] };
if (sql.startsWith('SELECT 1 FROM class_teachers')) return { rows: [{ ok: 1 }] };
if (sql.startsWith('SELECT * FROM users WHERE id = $1 AND role = $2'))
return { rows: [{ ...pupil, class_id: 51 }] };
if (sql.includes('INSERT INTO teacher_pupil_actions')) inserted = true;
return { rows: [] };
};
const app = await makeApp(teacher, query);
const response = await app.inject({
method: 'POST',
url: '/api/progress/live/class/50/pupil/9/action',
cookies,
payload: { kind: 'hint', message: 'Probeer het woord hardop te zeggen.' },
});
assert.equal(response.statusCode, 403);
assert.equal(inserted, false);
await app.close();
});
test('eenvoudiger maakt veilig een individuele niveau-override', async () => {
const calls = [];
const query = async (sql, params = []) => {
calls.push({ sql, params });
const login = auth(teacher, sql);
if (login) return login;
if (sql.startsWith('SELECT * FROM classes')) return { rows: [{ id: 50, school_id: 2 }] };
if (sql.startsWith('SELECT 1 FROM class_teachers')) return { rows: [{ ok: 1 }] };
if (sql.startsWith('SELECT * FROM users WHERE id = $1 AND role = $2')) return { rows: [pupil] };
if (sql.startsWith('SELECT * FROM pupil_levels WHERE pupil_id')) return { rows: [] };
if (sql.startsWith('SELECT * FROM pupil_levels WHERE class_id'))
return { rows: [{ class_id: 50, subject: 'rekenen', year_group: 5, level: 2 }] };
if (sql.includes('INSERT INTO pupil_levels')) return { rows: [] };
if (sql.includes('INSERT INTO teacher_pupil_actions')) return { rows: [{
id: 302, kind: 'easier', message: null,
payload: { subject: 'rekenen', yearGroup: 5, level: 1 }, created_at: new Date(),
}] };
return { rows: [] };
};
const app = await makeApp(teacher, query);
const response = await app.inject({
method: 'POST',
url: '/api/progress/live/class/50/pupil/9/action',
cookies,
payload: { kind: 'easier', subject: 'rekenen' },
});
assert.equal(response.statusCode, 200, response.body);
const upsert = calls.find((call) => call.sql.includes('INSERT INTO pupil_levels'));
assert.deepEqual(upsert.params, [9, 'rekenen', 5, 1]);
assert.deepEqual(response.json().action.payload,
{ subject: 'rekenen', yearGroup: 5, level: 1 });
await app.close();
});
test('gepauzeerde sessie accepteert geen nieuwe voortgang', async () => {
let progressInserted = false;
const assignment = {
id: 80, teacher_id: 3, board_id: 'board-live', sequence: [],
};
const query = async (sql) => {
const login = auth(pupil, sql);
if (login) return login;
if (sql === 'SELECT * FROM assignments WHERE pupil_id = $1') return { rows: [assignment] };
if (sql.includes('SELECT paused_at FROM live_learning_sessions'))
return { rows: [{ paused_at: new Date() }] };
if (sql.includes('INSERT INTO progress_events')) progressInserted = true;
return { rows: [] };
};
const app = await makeApp(pupil, query);
const response = await app.inject({
method: 'POST', url: '/api/my/progress', cookies,
payload: {
widgetId: 'math-1', widgetType: 'madd', attempts: 2, correct: 2,
liveSessionId: 101,
},
});
assert.equal(response.statusCode, 409, response.body);
assert.equal(progressInserted, false);
await app.close();
});
test('leerling ontvangt duurzame acties en ziet de actuele pauzestatus', async () => {
const query = async (sql) => {
const login = auth(pupil, sql);
if (login) return login;
if (sql.includes('FROM teacher_pupil_actions')) return { rows: [{
id: 401, kind: 'hint', message: 'Splits het woord in klanken.',
payload: {}, created_at: new Date(),
}] };
if (sql.includes('SELECT id, paused_at FROM live_learning_sessions'))
return { rows: [{ id: 101, paused_at: new Date() }] };
return { rows: [] };
};
const app = await makeApp(pupil, query);
const response = await app.inject({
method: 'GET', url: '/api/my/live-actions', cookies,
});
assert.equal(response.statusCode, 200, response.body);
assert.equal(response.json().paused, true);
assert.equal(response.json().actions[0].message, 'Splits het woord in klanken.');
await app.close();
});
test('grafisch overzicht bevat raster, signalen, funnel, grafieken, tijdlijn en acties', async () => {
const [migration, server, dashboard, pupilActions, styles, i18n, admin, html, version, core] =
await Promise.all([
readFile('db/023_live_classroom_interaction.sql', 'utf8'),
readFile('src/live-classroom.js', 'utf8'),
readFile('public/js/live-classroom-dashboard.js', 'utf8'),
readFile('public/js/pupil-live-actions.js', 'utf8'),
readFile('public/css/live-classroom.css', 'utf8'),
readFile('public/js/live-classroom-i18n.js', 'utf8'),
readFile('public/js/admin.js', 'utf8'),
readFile('public/index.html', 'utf8'),
readFile('VERSION', 'utf8'),
readFile('public/js/core.js', 'utf8'),
]);
for (const value of ['teacher_pupil_actions', 'last_progress_at', 'paused_at'])
assert.ok(migration.includes(value), value);
assert.match(server, /HELP_IDLE_MS/);
assert.match(server, /correct \/ attempts < 0\.6/);
assert.match(server, /pupilStreams/);
assert.match(server, /current_date - interval '6 days'/);
assert.match(server, /ACTION_KINDS/);
for (const value of ['live-class-grid', 'renderLiveFunnel', 'renderLiveTrend',
'renderLiveTimeline', 'amActionEasier', 'amActionPause']) {
assert.ok(dashboard.includes(value), value);
}
assert.match(pupilActions, /new EventSource\("\/api\/my\/live-actions\/stream"\)/);
assert.match(pupilActions, /\/ack/);
assert.match(styles, /pupil-session-paused/);
assert.match(styles, /live-graphics-grid/);
assert.match(admin, /pupil\/'\+pupilId\+'\/action/);
assert.ok(html.indexOf('live-classroom-i18n.js') < html.indexOf('pupil-live-actions.js'));
assert.ok(html.includes('css/live-classroom.css'));
for (const key of ['amFunnelTitle', 'amTimelineTitle', 'amActionHint', 'pupilPausedTitle'])
assert.equal((i18n.match(new RegExp(key + ':', 'g')) || []).length, 2, key);
assert.ok(core.includes('const VERSION = "' + version.trim() + '"'));
});