Verbeter anatomie en plaatsing van avataraccessoires (v0.4.46-beta) #1

Open
bes-r wants to merge 192 commits from bes-r/avatar-realism into main AGit
8 changed files with 260 additions and 26 deletions
Showing only changes of commit 8ccfa5dc8c - Show all commits

View file

@ -1 +1 @@
0.4.34-beta 0.4.35-beta

View file

@ -0,0 +1,7 @@
-- Voortgang: optioneel detail per moment (welke som/opdracht, gegeven
-- antwoord, juist antwoord) - tot nu toe werd alleen een totaaltelling per
-- afgerond blok bewaard (bv. math.js: één rij per 10 sommen), waardoor het
-- onmogelijk was te zien WELKE opdracht fout ging. Nullable en puur
-- additief: bestaande rijen en widgets die geen detail meegeven blijven
-- gewoon werken met detail = null.
ALTER TABLE progress_events ADD COLUMN IF NOT EXISTS detail JSONB;

View file

@ -1449,6 +1449,9 @@
font:inherit; font-weight:700; font-size:12.5px; cursor:pointer; color:var(--ink); line-height:1; font:inherit; font-weight:700; font-size:12.5px; cursor:pointer; color:var(--ink); line-height:1;
} }
.am-btn:hover,.am-copy:hover{background:var(--surface-3);} .am-btn:hover,.am-copy:hover{background:var(--surface-3);}
/* actieve knop in een keuzerij (vakgebied-tabs in Klassenmanagement/Voortgang) -
ontbrak nog, waardoor niet te zien was welk vakgebied geselecteerd was. */
.am-btn.active{border-color:var(--accent); background:var(--accent-soft); color:var(--accent-ink);}
.am-del{ .am-del{
border:1px solid var(--line); background:var(--red-soft); border-radius:var(--radius); border:1px solid var(--line); background:var(--red-soft); border-radius:var(--radius);
width:var(--control-h); height:var(--control-h); cursor:pointer; font-size:13px; flex:none; width:var(--control-h); height:var(--control-h); cursor:pointer; font-size:13px; flex:none;
@ -2243,6 +2246,23 @@ body.hc #settingsModal .am-inp, body.hc #settingsModal .am-sel{border-color:#000
/* compacte subrij binnen een kaart */ /* compacte subrij binnen een kaart */
#settingsModal .am-card-sub{font-size:12.5px; padding:5px 12px 5px 30px; flex-wrap:wrap;} #settingsModal .am-card-sub{font-size:12.5px; padding:5px 12px 5px 30px; flex-wrap:wrap;}
#settingsModal .am-card-sub .am-name{font-size:12.5px; font-weight:550; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;} #settingsModal .am-card-sub .am-name{font-size:12.5px; font-weight:550; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;}
/* Voortgang-tab: klassenmanagement-koppeling + grafiek (v0.4.35-beta e.v.) */
#settingsModal .progress-level-badge{
display:inline-flex; align-self:flex-start; padding:4px 10px; margin-bottom:10px;
border-radius:999px; background:var(--accent-soft); color:var(--accent-ink);
font-size:12.5px; font-weight:700;
}
#settingsModal .progress-summary{
display:flex; align-items:center; gap:16px; padding:14px; margin-bottom:12px;
border:1px solid var(--line); border-radius:var(--radius-s); background:var(--surface);
}
#settingsModal .progress-ring{width:64px; height:64px; flex:none;}
#settingsModal .progress-ring-pct{font-size:22px; font-weight:800; fill:var(--ink);}
#settingsModal .progress-summary-text{flex:none; min-width:110px;}
#settingsModal .progress-summary-count{font-weight:700; font-size:14px; color:var(--ink);}
#settingsModal .progress-summary-hint{font-size:11.5px; color:var(--muted);}
#settingsModal .progress-bars{flex:1; min-width:0; height:56px;}
#settingsModal .am-card-sub .am-role{font-size:11.5px; margin-left:auto;} #settingsModal .am-card-sub .am-role{font-size:11.5px; margin-left:auto;}
#settingsModal .am-card-sub .am-edit{flex:none;} #settingsModal .am-card-sub .am-edit{flex:none;}
#settingsModal .am-card-sub .am-row-tools{width:100%;} #settingsModal .am-card-sub .am-row-tools{width:100%;}

View file

@ -759,6 +759,75 @@
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
/* eenvoudige, met de hand getekende SVG-grafiekjes voor de Voortgang-tab -
geen library nodig (zelfde aanpak als avatar.js dit sessie), puur
client-side berekend uit de al opgehaalde events. */
const SVG_NS = "http://www.w3.org/2000/svg";
function svgEl(tag, attrs){
const el = document.createElementNS(SVG_NS, tag);
Object.entries(attrs).forEach(([k,v])=>el.setAttribute(k,v));
return el;
}
function progressAccuracyRing(correct, total){
const pct = total ? Math.round((correct/total)*100) : 0;
const r = 40, c = 2*Math.PI*r, offset = c*(1-pct/100);
const svg = svgEl("svg", {viewBox:"0 0 100 100", class:"progress-ring"});
svg.appendChild(svgEl("circle", {cx:50, cy:50, r, fill:"none", stroke:"var(--surface-3)", "stroke-width":10}));
if(total){
const color = pct>=80 ? "var(--green)" : pct>=50 ? "var(--yellow)" : "var(--red)";
const arc = svgEl("circle", {cx:50, cy:50, r, fill:"none", stroke:color,
"stroke-width":10, "stroke-dasharray":c, "stroke-dashoffset":offset, "stroke-linecap":"round"});
arc.setAttribute("transform", "rotate(-90 50 50)");
svg.appendChild(arc);
}
const text = svgEl("text", {x:50, y:56, "text-anchor":"middle", class:"progress-ring-pct"});
text.textContent = total ? pct+"%" : "";
svg.appendChild(text);
return svg;
}
/* goed/fout per dag, laatste 14 dagen - attempts/correct i.p.v. een simpele
goed/fout-vlag per event, zodat dit ook klopt voor oudere geaggregeerde
events (bv. een pre-upgrade rekenblok van 10 sommen: attempts:10,
correct:7 telt als 7 goed + 3 fout, niet als één enkel "fout"-moment). */
function progressDailyBars(events){
const days = [];
const byDay = new Map();
const now = new Date();
for(let i=13; i>=0; i--){
const d = new Date(now); d.setDate(d.getDate()-i);
const key = d.toISOString().slice(0,10);
byDay.set(key, {correct:0, wrong:0});
days.push(key);
}
events.forEach(ev=>{
const key = new Date(ev.createdAt).toISOString().slice(0,10);
const bucket = byDay.get(key);
if(!bucket) return;
bucket.correct += ev.correct || 0;
bucket.wrong += Math.max(0, (ev.attempts||0) - (ev.correct||0));
});
const w = 280, h = 70, barW = w/days.length;
const max = Math.max(1, ...days.map(k=>{ const b = byDay.get(k); return b.correct+b.wrong; }));
const svg = svgEl("svg", {viewBox:`0 0 ${w} ${h}`, class:"progress-bars"});
days.forEach((key,i)=>{
const b = byDay.get(key);
const total = b.correct+b.wrong;
const totalH = (total/max)*(h-4);
const correctH = total ? (b.correct/total)*totalH : 0;
const wrongH = totalH-correctH;
const x = i*barW+barW*0.22, bw = barW*0.56;
if(wrongH>0) svg.appendChild(svgEl("rect", {x, y:h-totalH, width:bw, height:wrongH, fill:"var(--red)", rx:1}));
if(correctH>0) svg.appendChild(svgEl("rect", {x, y:h-totalH+wrongH, width:bw, height:correctH, fill:"var(--green)", rx:1}));
});
return svg;
}
/* welk vakgebied hoort bij een widgetType - zelfde categorieën als
REGISTRY's cat in board.js, ook gebruikt door klassenmanagement. */
function widgetSubject(widgetType){
const def = REGISTRY.find(d=>d.id===widgetType);
return def ? def.cat : null;
}
/* één regel in de voortgangstijdlijn van een leerling: widgettype, score en tijdstip */ /* één regel in de voortgangstijdlijn van een leerling: widgettype, score en tijdstip */
function progressRow(ev){ function progressRow(ev){
const row = h("div","am-row"); const row = h("div","am-row");
@ -771,9 +840,11 @@
return row; return row;
} }
/* voortgang: leerkracht kiest een leerling (zelfde groepering per klas als /* voortgang: leerkracht kiest een leerling en een vakgebied, ziet meteen
Toewijzingen) en ziet de volledige tijdlijn van diens afgeronde het toegewezen niveau (klassenmanagement-koppeling), een grafiek
widget-momenten (ronde sommen, voltooid woord, geknapte ballon). */ (nauwkeurigheid + goed/fout per dag) en de recentste fouten mét welke
opdracht het was (waar een widget dat detail meegeeft) - de volledige
ruwe tijdlijn blijft eronder beschikbaar, niets verdwijnt. */
function renderVoortgangPanel(panel){ function renderVoortgangPanel(panel){
const schoolChosen = currentUser.role!=="super" || !!selSchool; const schoolChosen = currentUser.role!=="super" || !!selSchool;
if(!schoolChosen){ if(!schoolChosen){
@ -784,20 +855,56 @@
const pupilSel = h("select","am-sel"); const pupilSel = h("select","am-sel");
fillPupilSelect(pupilSel, pupils); fillPupilSelect(pupilSel, pupils);
panel.appendChild(pupilSel); panel.appendChild(pupilSel);
const subjectRow = h("div","am-inline");
panel.appendChild(subjectRow);
const body = h("div");
panel.appendChild(body);
let allEvents = [];
let pvSubject = "rekenen";
function renderBody(){
body.innerHTML = "";
const events = allEvents.filter(ev=>widgetSubject(ev.widgetType)===pvSubject);
if(!events.length){ body.appendChild(h("div","guestnote", T("amProgressEmpty"))); return; }
const totals = events.reduce((acc,ev)=>({
correct: acc.correct + (ev.correct||0),
attempts: acc.attempts + (ev.attempts||0),
}), {correct:0, attempts:0});
const summary = h("div","progress-summary");
summary.appendChild(progressAccuracyRing(totals.correct, totals.attempts));
const summaryText = h("div","progress-summary-text");
summaryText.appendChild(h("div","progress-summary-count",
`${totals.correct} / ${totals.attempts} ${T("mGoed")}`));
summaryText.appendChild(h("div","progress-summary-hint", T("amProgressLast14Days")));
summary.append(summaryText, progressDailyBars(events));
body.appendChild(summary);
const mistakes = events.filter(ev=>ev.detail && ev.detail.correct===false).slice(0, 10);
if(mistakes.length){
body.appendChild(h("div","am-group", T("amProgressMistakes")));
const list = h("div","am-list");
mistakes.forEach(ev=>{
const row = h("div","am-row");
row.appendChild(h("span","am-name", ev.detail.question||""));
row.appendChild(h("span","am-code",
T("amProgressGiven").replace("{answer}", ev.detail.answer) + " · " +
T("amProgressExpected").replace("{answer}", ev.detail.expected)));
row.appendChild(h("span","am-code", new Date(ev.createdAt).toLocaleString(LANG==="nl" ? "nl-NL" : "en-GB")));
list.appendChild(row);
});
body.appendChild(list);
}
body.appendChild(h("div","am-group", T("amProgressTimeline")));
const eventsHolder = h("div","am-list"); const eventsHolder = h("div","am-list");
panel.appendChild(eventsHolder); body.appendChild(eventsHolder);
const moreBtn = h("button","am-btn", T("amShowMore")); const moreBtn = h("button","am-btn", T("amShowMore"));
moreBtn.type = "button"; moreBtn.type = "button";
moreBtn.style.display = "none"; body.appendChild(moreBtn);
panel.appendChild(moreBtn);
pupilSel.addEventListener("change", async ()=>{
eventsHolder.innerHTML = "";
moreBtn.style.display = "none";
if(!pupilSel.value) return;
let events = [];
try{ events = (await api("/progress/pupil/"+pupilSel.value)).events || []; }
catch(e){ msg(e.message); return; }
if(!events.length){ eventsHolder.appendChild(h("div","guestnote", T("amProgressEmpty"))); return; }
/* in porties van 30: de tijdlijn kan 200 regels lang zijn */ /* in porties van 30: de tijdlijn kan 200 regels lang zijn */
let shown = 0; let shown = 0;
const showMore = ()=>{ const showMore = ()=>{
@ -806,9 +913,45 @@
moreBtn.style.display = shown < events.length ? "" : "none"; moreBtn.style.display = shown < events.length ? "" : "none";
moreBtn.textContent = `${T("amShowMore")} (${events.length - shown})`; moreBtn.textContent = `${T("amShowMore")} (${events.length - shown})`;
}; };
moreBtn.onclick = showMore;
showMore(); showMore();
moreBtn.onclick = showMore;
}
async function renderLevelBadge(){
if(!pupilSel.value) return;
let lvl = null;
try{ lvl = (await api(`/levels/pupil/${pupilSel.value}?subject=${pvSubject}`)).level; }catch(e){}
const old = body.querySelector(".progress-level-badge");
if(old) old.remove();
const badge = h("div","progress-level-badge",
lvl ? T("amYearGroupN").replace("{n}", lvl.yearGroup) + " · " + T("lvl"+lvl.level) : T("amLevelNone"));
body.prepend(badge);
}
async function loadPupil(){
body.innerHTML = "";
subjectRow.innerHTML = "";
if(!pupilSel.value) return;
KM_SUBJECTS.forEach(s=>{
const b = h("button","am-btn", T("amSubject_"+s));
b.type = "button";
b.classList.toggle("active", s===pvSubject);
b.addEventListener("click", ()=>{
pvSubject = s;
subjectRow.querySelectorAll("button").forEach(x=>x.classList.remove("active"));
b.classList.add("active");
renderBody();
renderLevelBadge();
}); });
subjectRow.appendChild(b);
});
body.appendChild(h("div","guestnote", "…"));
try{ allEvents = (await api("/progress/pupil/"+pupilSel.value)).events || []; }
catch(e){ body.innerHTML = ""; body.appendChild(h("div","am-msg", e.message)); return; }
renderBody();
renderLevelBadge();
}
pupilSel.addEventListener("change", loadPupil);
panel.appendChild(h("div","am-group", T("amExportClass"))); panel.appendChild(h("div","am-group", T("amExportClass")));
const classSelExport = classSel(); const classSelExport = classSel();

View file

@ -2,7 +2,7 @@
"use strict"; "use strict";
/* version shown until /api/version resolves (or if the fetch fails, e.g. offline). /* version shown until /api/version resolves (or if the fetch fails, e.g. offline).
Kept in sync by hand with the VERSION file at the repo root on every release. */ Kept in sync by hand with the VERSION file at the repo root on every release. */
const VERSION = "0.4.34-beta"; const VERSION = "0.4.35-beta";
(function(){ (function(){
const tag = document.getElementById("verTag"); const tag = document.getElementById("verTag");
tag.textContent = "v"+VERSION; tag.textContent = "v"+VERSION;
@ -349,6 +349,9 @@ const I18N = {
castNoClasses:"Geen klassen in deze school.", castNoPupils:"Geen leerlingen in deze school.", castNoClasses:"Geen klassen in deze school.", castNoPupils:"Geen leerlingen in deze school.",
castActive:"✓ Actief", castActive:"✓ Actief",
amProgress:"Voortgang", amProgressEmpty:"Nog geen voortgang voor deze leerling.", amProgress:"Voortgang", amProgressEmpty:"Nog geen voortgang voor deze leerling.",
amProgressLast14Days:"Laatste 14 dagen", amProgressMistakes:"Recente fouten",
amProgressTimeline:"Volledige tijdlijn",
amProgressGiven:"Gegeven: {answer}", amProgressExpected:"Juist: {answer}",
amExportClass:"Exporteren per klas", amExportCsv:"Exporteer als CSV", amPickClassHint:"Kies eerst een klas.", amExportClass:"Exporteren per klas", amExportCsv:"Exporteer als CSV", amPickClassHint:"Kies eerst een klas.",
amExportColPupil:"Leerling", amExportColWidget:"Widget", amExportColAttempts:"Pogingen", amExportColPupil:"Leerling", amExportColWidget:"Widget", amExportColAttempts:"Pogingen",
amExportColCorrect:"Goed", amExportColStars:"Sterren", amExportColDate:"Datum", amExportColCorrect:"Goed", amExportColStars:"Sterren", amExportColDate:"Datum",
@ -694,6 +697,9 @@ const I18N = {
castNoClasses:"No classes in this school.", castNoPupils:"No pupils in this school.", castNoClasses:"No classes in this school.", castNoPupils:"No pupils in this school.",
castActive:"✓ Active", castActive:"✓ Active",
amProgress:"Progress", amProgressEmpty:"No progress for this pupil yet.", amProgress:"Progress", amProgressEmpty:"No progress for this pupil yet.",
amProgressLast14Days:"Last 14 days", amProgressMistakes:"Recent mistakes",
amProgressTimeline:"Full timeline",
amProgressGiven:"Given: {answer}", amProgressExpected:"Correct: {answer}",
amExportClass:"Export by class", amExportCsv:"Export as CSV", amPickClassHint:"Pick a class first.", amExportClass:"Export by class", amExportCsv:"Export as CSV", amPickClassHint:"Pick a class first.",
amExportColPupil:"Pupil", amExportColWidget:"Widget", amExportColAttempts:"Attempts", amExportColPupil:"Pupil", amExportColWidget:"Widget", amExportColAttempts:"Attempts",
amExportColCorrect:"Correct", amExportColStars:"Stars", amExportColDate:"Date", amExportColCorrect:"Correct", amExportColStars:"Stars", amExportColDate:"Date",

View file

@ -223,7 +223,6 @@ function mountMath(root, initState, mode, opts){
doneEl.querySelector(".dg").textContent = S.good>=8 ? "🌟" : S.good>=5 ? "👍" : "💪"; doneEl.querySelector(".dg").textContent = S.good>=8 ? "🌟" : S.good>=5 ? "👍" : "💪";
doneEl.classList.add("show"); doneEl.classList.add("show");
if(S.good>=8) play(sndWin); if(S.good>=8) play(sndWin);
if(onProgress) onProgress({ attempts: ROUND, correct: S.good, stars: 0 });
} }
function next(){ function next(){
S.done++; S.done++;
@ -239,17 +238,25 @@ function mountMath(root, initState, mode, opts){
showGoodFeedback(); showGoodFeedback();
play(sndGood); play(sndGood);
confetti(el(".mt-card")); confetti(el(".mt-card"));
/* per som loggen (niet meer één keer per blok van 10 in endRound()) -
zo levert een sessie ook echt een groep aparte momenten op in de
voortgang, en weet de leerkracht welke som het was. */
if(onProgress) onProgress({ attempts: S.tries+1, correct: 1, stars: 0,
detail: { question: S.q.txt, answer: +S.input, expected: S.q.ans, correct: true } });
setTimeout(next, 900); setTimeout(next, 900);
}else{ }else{
S.tries++; S.tries++;
aEl.classList.add("shake"); aEl.classList.add("shake");
setTimeout(()=>aEl.classList.remove("shake"), 400); setTimeout(()=>aEl.classList.remove("shake"), 400);
play(sndBad); play(sndBad);
const wrongAnswer = S.input;
S.input = ""; aEl.textContent = ""; S.input = ""; aEl.textContent = "";
if(S.tries >= 3){ if(S.tries >= 3){
S.busy = true; S.busy = true;
fbEl.textContent = `${T("mAnsWas")} ${S.q.ans}`; fbEl.textContent = `${T("mAnsWas")} ${S.q.ans}`;
fbEl.classList.add("ans"); fbEl.classList.add("ans");
if(onProgress) onProgress({ attempts: S.tries, correct: 0, stars: 0,
detail: { question: S.q.txt, answer: +wrongAnswer, expected: S.q.ans, correct: false } });
setTimeout(next, 1800); setTimeout(next, 1800);
} }
} }

View file

@ -1041,10 +1041,22 @@ export default async function api(app) {
// voortgang onder een andere leerkracht kan wegschrijven. // voortgang onder een andere leerkracht kan wegschrijven.
const PROGRESS_LIMIT = 200; const PROGRESS_LIMIT = 200;
const clampCount = (n) => Math.max(0, Math.min(1000, Number(n) || 0)); const clampCount = (n) => Math.max(0, Math.min(1000, Number(n) || 0));
// Optioneel detail (welke opdracht - som/woord - en het gegeven/juiste
// antwoord): decoratief, nooit een reden om de hele log-poging te laten
// falen. Alleen bewaard als het een plain object is en niet absurd groot;
// anders stilzwijgend genegeerd (detail = null), de rest van de melding
// (attempts/correct/stars) wordt gewoon gelogd.
const sanitizeProgressDetail = (detail) => {
if (!detail || typeof detail !== 'object' || Array.isArray(detail)) return null;
try {
if (JSON.stringify(detail).length > 1000) return null;
} catch { return null; }
return detail;
};
app.post('/my/progress', async (req, reply) => { app.post('/my/progress', async (req, reply) => {
need(req, reply); need(req, reply);
if (req.user.role !== 'pupil') return fail(reply, 403, 'alleen voor leerlingen'); if (req.user.role !== 'pupil') return fail(reply, 403, 'alleen voor leerlingen');
const { widgetId, widgetType, attempts, correct, stars } = req.body ?? {}; const { widgetId, widgetType, attempts, correct, stars, detail } = req.body ?? {};
if (!widgetId || typeof widgetId !== 'string' || widgetId.length > 100) return fail(reply, 400, 'ongeldige widgetId'); if (!widgetId || typeof widgetId !== 'string' || widgetId.length > 100) return fail(reply, 400, 'ongeldige widgetId');
if (!widgetType || typeof widgetType !== 'string' || widgetType.length > 40) return fail(reply, 400, 'ongeldige widgetType'); if (!widgetType || typeof widgetType !== 'string' || widgetType.length > 40) return fail(reply, 400, 'ongeldige widgetType');
let a = (await pool.query('SELECT * FROM assignments WHERE pupil_id = $1', [req.user.id])).rows[0]; let a = (await pool.query('SELECT * FROM assignments WHERE pupil_id = $1', [req.user.id])).rows[0];
@ -1062,9 +1074,9 @@ export default async function api(app) {
} }
if (!a) return fail(reply, 400, 'geen toewijzing'); if (!a) return fail(reply, 400, 'geen toewijzing');
await pool.query( await pool.query(
`INSERT INTO progress_events (pupil_id, teacher_id, board_id, widget_id, widget_type, attempts, correct, stars) `INSERT INTO progress_events (pupil_id, teacher_id, board_id, widget_id, widget_type, attempts, correct, stars, detail)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`, VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
[req.user.id, a.teacher_id, a.board_id, widgetId, widgetType, clampCount(attempts), clampCount(correct), clampCount(stars)]); [req.user.id, a.teacher_id, a.board_id, widgetId, widgetType, clampCount(attempts), clampCount(correct), clampCount(stars), sanitizeProgressDetail(detail)]);
return { ok: true }; return { ok: true };
}); });
const describeProgressEvent = (e) => ({ const describeProgressEvent = (e) => ({
@ -1073,6 +1085,7 @@ export default async function api(app) {
attempts: e.attempts, attempts: e.attempts,
correct: e.correct, correct: e.correct,
stars: e.stars, stars: e.stars,
detail: e.detail || null,
createdAt: e.created_at, createdAt: e.created_at,
}); });
// Een gekoppelde ouder mag de voortgang van het eigen kind zien; verder // Een gekoppelde ouder mag de voortgang van het eigen kind zien; verder

View file

@ -101,3 +101,41 @@ test('voortgang: ontbrekende widgetId/widgetType levert 400 op, niet een kapotte
assert.equal(res.statusCode, 400, res.body); assert.equal(res.statusCode, 400, res.body);
assert.ok(!insertCall(calls)); assert.ok(!insertCall(calls));
}); });
/* "1 opdracht i.p.v. een groep" - de fix zelf zit in math.js (per som loggen
i.p.v. per blok van 10), maar het datamodel/de server moeten het optionele
detail (welke som/welk antwoord) wel kunnen bewaren zonder de rest van de
voortgang-logging te breken bij rommelige/te grote input. */
test('voortgang: detail (welke opdracht) wordt meegegeven aan de insert', async () => {
const pupil = { id: 9, username: 'lena', role: 'pupil', school_id: 2, class_id: 50, data: {}, data_rev: 0 };
const { app, calls } = await makeApp(pupil, { assignmentRows: { byPupil: { teacher_id: 3, board_id: 'bord-a' } } });
const detail = { question: '7 + 8 =', answer: 14, expected: 15, correct: false };
const res = await app.inject({ method: 'POST', url: '/api/my/progress', cookies, payload: { ...validBody, detail } });
assert.equal(res.statusCode, 200, res.body);
assert.deepEqual(insertCall(calls).params[8], detail);
});
test('voortgang: een te grote detail wordt genegeerd zonder de hele log-poging te laten falen', async () => {
const pupil = { id: 9, username: 'lena', role: 'pupil', school_id: 2, class_id: 50, data: {}, data_rev: 0 };
const { app, calls } = await makeApp(pupil, { assignmentRows: { byPupil: { teacher_id: 3, board_id: 'bord-a' } } });
const detail = { question: 'x'.repeat(2000) };
const res = await app.inject({ method: 'POST', url: '/api/my/progress', cookies, payload: { ...validBody, detail } });
assert.equal(res.statusCode, 200, res.body);
assert.equal(insertCall(calls).params[8], null);
});
test('voortgang: een detail dat geen plain object is (array/string) wordt genegeerd', async () => {
const pupil = { id: 9, username: 'lena', role: 'pupil', school_id: 2, class_id: 50, data: {}, data_rev: 0 };
const { app, calls } = await makeApp(pupil, { assignmentRows: { byPupil: { teacher_id: 3, board_id: 'bord-a' } } });
const res = await app.inject({ method: 'POST', url: '/api/my/progress', cookies, payload: { ...validBody, detail: ['niet', 'plain'] } });
assert.equal(res.statusCode, 200, res.body);
assert.equal(insertCall(calls).params[8], null);
});
test('voortgang: zonder detail-veld blijft alles zoals voorheen (null, geen fout)', async () => {
const pupil = { id: 9, username: 'lena', role: 'pupil', school_id: 2, class_id: 50, data: {}, data_rev: 0 };
const { app, calls } = await makeApp(pupil, { assignmentRows: { byPupil: { teacher_id: 3, board_id: 'bord-a' } } });
const res = await app.inject({ method: 'POST', url: '/api/my/progress', cookies, payload: validBody });
assert.equal(res.statusCode, 200, res.body);
assert.equal(insertCall(calls).params[8], null);
});