3v324v23 commited on
Commit
e2dec0b
ยท
1 Parent(s): a4bc651

Add sketch board functionality for unit quizzes and related tests

Browse files

- Implemented a new sketch board feature in static/math-board.js, allowing users to draw and submit answers for unit quizzes.
- Added tests in tests/test_board_browser.py to verify the functionality of the sketch board, including drawing, erasing, and submitting sketches.
- Enhanced existing unit tests in tests/test_unit_quiz.py to ensure proper integration of the sketch board with quiz questions.

static/index.html CHANGED
@@ -29,6 +29,7 @@
29
  <script src="/static/math-tex.js"></script>
30
  <script src="/static/math-sky.js"></script>
31
  <script src="/static/science.js"></script>
 
32
  <script src="/static/math.js"></script>
33
  </body>
34
  </html>
 
29
  <script src="/static/math-tex.js"></script>
30
  <script src="/static/math-sky.js"></script>
31
  <script src="/static/science.js"></script>
32
+ <script src="/static/math-board.js"></script>
33
  <script src="/static/math.js"></script>
34
  </body>
35
  </html>
static/math-board.js ADDED
@@ -0,0 +1,379 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Mathly's sketch board: a whiteboard beside an exercise, on a desktop screen only (see math.css).
2
+ Used by a skill's exercise, the practice exam and the unit quiz. The student turns it on from the sheet's header;
3
+ this browser remembers the choice. Each question keeps its own sketch for as long as the page is open.
4
+ Nothing is sent anywhere.
5
+
6
+ Everything on the board is an element that can be moved or erased: a pen stroke (with a pen's pressure),
7
+ a straight line, a circle (dragged from the center), and a copy of the question (its text and figures).
8
+ Line ends and circle centers snap to the ends and centers already drawn, so a triangle closes neatly;
9
+ lines that meet that way move together. The eraser removes the lines and circles it touches and cuts pen strokes;
10
+ it leaves the question's copy alone (select it and delete it instead).
11
+ The paper is endless downwards: the mouse wheel over the board scrolls it.
12
+ A sheet can let the board hand in a question's answer (the unit quiz): the sketch becomes a picture. */
13
+ (()=>{
14
+ const PREF="mathly.board";
15
+ const isOn=()=>{ try{ return localStorage.getItem(PREF)==="on"; }catch{ return false; } };
16
+ const setOn=on=>{ try{ localStorage.setItem(PREF,on?"on":"off"); }catch{} };
17
+
18
+ const INKS=[["#1B2140","ืฉื—ื•ืจ"],["#2563D9","ื›ื—ื•ืœ"],["#D3455B","ืื“ื•ื"]];
19
+ const ERASERS=[[16,"ืงื˜ืŸ"],[40,"ื‘ื™ื ื•ื ื™"],[90,"ื’ื“ื•ืœ"]];
20
+ const TOOLS=[["move","โœ‹","ื”ื–ื–ื”"],["pen","โœ๏ธ","ืขื˜"],["line","โ•ฑ","ืงื• ื™ืฉืจ"],["circle","โ—ฏ","ืขื™ื’ื•ืœ"],["eraser","๐Ÿงฝ","ืžื—ืง"]];
21
+ const PEN=2.6, SHAPE=2.4, SNAP=12, GRAB=9, STEP=2, HISTORY=100;
22
+ const PAPER_INK="#1B2140", PAPER_FAINT="#C5CBDF", SELECT="#2563D9";
23
+ const FONT='500 17px Rubik, system-ui, "Segoe UI", sans-serif', LINE_H=27, CARD_PAD=14;
24
+
25
+ /* sketch key -> {marks, past}: marks are drawn in order; past holds copies of marks for undo */
26
+ const sketches=new Map(), images=new Map();
27
+ let el=null, canvas=null, ctx=null, key="", base="", pages=0, page=0, layer=null, source=null, hand=null, onPage=null;
28
+ let tool="pen", ink=INKS[0][0], eraser=ERASERS[1][0];
29
+ let draft=null, drag=null, rub=null, selection=[];
30
+
31
+ const sketch=()=>{ if(!sketches.has(key)) sketches.set(key,{marks:[],past:[],y:0}); return sketches.get(key); }; // y: how far down the paper is scrolled
32
+ const marks=()=>sketch().marks;
33
+ const copy=list=>JSON.parse(JSON.stringify(list));
34
+ function remember(before=copy(marks())){ const s=sketch(); s.past.push(before); if(s.past.length>HISTORY) s.past.shift(); }
35
+ const shown=()=>!!el?.isConnected&&canvas.clientWidth>0;
36
+ const dist=(a,b)=>Math.hypot(a[0]-b[0],a[1]-b[1]);
37
+ const same=(a,b)=>dist(a,b)<.5;
38
+
39
+ function build(){
40
+ el=document.createElement("aside"); el.className="mx-board"; el.setAttribute("aria-label","ืœื•ื— ื˜ื™ื•ื˜ื”");
41
+ el.innerHTML=`<div class="mx-board-bar" role="toolbar" aria-label="ื›ืœื™ ื”ืœื•ื—">
42
+ <span class="mx-board-group">${TOOLS.map(([t,icon,name])=>`<button type="button" data-tool="${t}" aria-pressed="${t===tool}" title="${name}"><i aria-hidden="true">${icon}</i><span>${name}</span></button>`).join("")}</span>
43
+ <span class="mx-board-group mx-board-inks">${INKS.map(([c,name])=>`<button type="button" data-ink="${c}" aria-pressed="${c===ink}" aria-label="${name}" title="${name}" style="--dot:${c}"></button>`).join("")}</span>
44
+ <span class="mx-board-group mx-board-sizes">${ERASERS.map(([w,name])=>`<button type="button" data-eraser="${w}" aria-pressed="${w===eraser}" aria-label="ืžื—ืง ${name}" title="ืžื—ืง ${name}" style="--d:${Math.round(6+w/6)}px"></button>`).join("")}</span>
45
+ <span class="mx-board-gap"></span>
46
+ <button type="button" data-do="question" title="ื”ืขืชืงืช ื”ืฉืืœื” ืœืจืืฉ ื”ืœื•ื—"><i aria-hidden="true">๐Ÿ“‹</i><span>ื”ืฉืืœื”</span></button>
47
+ <button type="button" data-do="undo" title="ื‘ื™ื˜ื•ืœ (Ctrl+Z)"><i aria-hidden="true">โ†ถ</i></button>
48
+ <button type="button" data-do="clear" title="ื ื™ืงื•ื™ ื”ืœื•ื—"><i aria-hidden="true">๐Ÿ—‘</i></button>
49
+ <button type="button" data-do="hide" aria-label="ืกื’ื™ืจืช ื”ืœื•ื—" title="ืกื’ื™ืจืช ื”ืœื•ื—">โœ•</button>
50
+ </div><div class="mx-board-row"><div class="mx-board-pages" role="tablist" aria-label="ื”ื“ืฃ ืฉืœ ื›ืœ ืฉืืœื”"></div>
51
+ <button type="button" data-do="send" class="mx-board-send" hidden></button></div>
52
+ <div class="mx-board-stage"><canvas></canvas>
53
+ <button type="button" data-do="delete" class="mx-board-del" title="ืžื—ื™ืงืช ืžื” ืฉื ื‘ื—ืจ (Delete)" hidden>โœ• ืžื—ื™ืงื”</button>
54
+ <button type="button" data-do="top" class="mx-board-top" hidden>โ‡ก ืœืจืืฉ ื”ื“ืฃ</button></div>`;
55
+ canvas=el.querySelector("canvas"); ctx=canvas.getContext("2d");
56
+ el.addEventListener("click",e=>{
57
+ const b=e.target.closest("button"); if(!b) return;
58
+ if(b.dataset.tool) setTool(b.dataset.tool);
59
+ if(b.dataset.ink){ ink=b.dataset.ink; if(tool==="eraser"||tool==="move") setTool("pen"); pressed("[data-ink]",b); }
60
+ if(b.dataset.eraser){ eraser=+b.dataset.eraser; setTool("eraser"); pressed("[data-eraser]",b); }
61
+ if(b.dataset.page){ turn(+b.dataset.page); onPage?.(page); }
62
+ const act=b.dataset.do;
63
+ if(act==="undo") undo();
64
+ if(act==="clear"&&marks().length){ remember(); marks().length=0; select([]); paint(); drawPages(); }
65
+ if(act==="delete") removeSelection();
66
+ if(act==="question") copyQuestion();
67
+ if(act==="send") send();
68
+ if(act==="top"){ sketch().y=0; paint(); }
69
+ if(act==="hide") toggle();
70
+ });
71
+ canvas.addEventListener("pointerdown",down);
72
+ canvas.addEventListener("pointermove",move);
73
+ canvas.addEventListener("pointerup",up);
74
+ canvas.addEventListener("pointercancel",up);
75
+ canvas.addEventListener("contextmenu",e=>e.preventDefault());
76
+ canvas.addEventListener("wheel",e=>{ // endless paper: scrolls down as far as needed, never above the top
77
+ if(e.ctrlKey) return; // the browser's zoom
78
+ e.preventDefault(); const s=sketch(), dy=e.deltaMode===1?e.deltaY*24:e.deltaMode===2?e.deltaY*canvas.clientHeight:e.deltaY;
79
+ s.y=Math.max(0,s.y+dy); paint();
80
+ },{passive:false}); // the pen's side button: no browser menu over the board
81
+ new ResizeObserver(fit).observe(canvas);
82
+ setTool(tool);
83
+ }
84
+ function pressed(sel,b){ el.querySelectorAll(sel).forEach(x=>x.setAttribute("aria-pressed",x===b)); }
85
+ function reset(){ draft=drag=rub=null; select([]); }
86
+ function setTool(t){
87
+ tool=t; el.querySelectorAll("[data-tool]").forEach(x=>x.setAttribute("aria-pressed",x.dataset.tool===t));
88
+ el.dataset.tool=t; if(t!=="move"&&selection.length){ select([]); paint(); }
89
+ // the eraser's cursor is a ring its own size, so the student sees what it will wipe
90
+ const r=eraser/2, svg=`<svg xmlns='http://www.w3.org/2000/svg' width='${eraser+2}' height='${eraser+2}'><circle cx='${r+1}' cy='${r+1}' r='${r}' fill='rgba(255,255,255,.35)' stroke='%231B2140' stroke-width='1.2'/></svg>`;
91
+ canvas.style.cursor=t==="eraser"?`url("data:image/svg+xml,${svg.replace(/</g,"%3C").replace(/>/g,"%3E")}") ${r+1} ${r+1}, cell`:t==="move"?"default":"";
92
+ }
93
+ function select(list){ selection=list; }
94
+ function turn(p){ page=p; key=`${base}:${page}`; reset(); drawPages(); drawSend(); paint(); }
95
+ function drawSend(){
96
+ const b=el.querySelector(".mx-board-send"); b.hidden=!hand; el.querySelector(".mx-board-row").hidden=pages<2&&!hand; if(!hand) return;
97
+ b.disabled=false; b.innerHTML=`<i aria-hidden="true">๐Ÿ“ค</i><span>ื”ื’ืฉื” ืœืฉืืœื” ${page+1}</span>`; b.title=`ื”ื’ืฉืช ื”ืœื•ื— ื›ืชืฉื•ื‘ื” ืœืฉืืœื” ${page+1}`;
98
+ }
99
+ function drawPages(){
100
+ const box=el.querySelector(".mx-board-pages"); el.querySelector(".mx-board-row").hidden=pages<2&&!hand;
101
+ box.innerHTML=pages<2?"":`<span>ืฉืืœื”</span>${Array.from({length:pages},(_,i)=>{
102
+ const has=(sketches.get(`${base}:${i}`)?.marks||[]).length>0;
103
+ return `<button type="button" role="tab" data-page="${i}" aria-selected="${i===page}" class="${has?"has":""}">${i+1}</button>`; }).join("")}`;
104
+ }
105
+
106
+ /* ---------- drawing ---------- */
107
+ function fit(){
108
+ if(!shown()) return;
109
+ const d=devicePixelRatio||1, w=Math.round(canvas.clientWidth*d), h=Math.round(canvas.clientHeight*d);
110
+ if(canvas.width!==w||canvas.height!==h){ canvas.width=w; canvas.height=h; }
111
+ paint();
112
+ }
113
+ function paint(){
114
+ if(!ctx) return;
115
+ const d=devicePixelRatio||1, y=sketch().y; ctx.setTransform(1,0,0,1,0,0); ctx.clearRect(0,0,canvas.width,canvas.height); ctx.setTransform(d,0,0,d,0,-y*d);
116
+ canvas.style.backgroundPosition=`0 ${-y}px`; // the grid scrolls with the paper
117
+ el.querySelector(".mx-board-top").hidden=y<1;
118
+ marks().forEach(draw);
119
+ if(draft?.kind==="circle") guide(draft);
120
+ if(selection.length) outline(selection);
121
+ const del=el.querySelector(".mx-board-del"); del.hidden=!selection.length||!!drag?.before;
122
+ if(selection.length){ const [x0,y0]=edges(selection); del.style.left=`${Math.max(4,x0)}px`; const top=y0-y; del.style.top=`${top>40?top-36:Math.max(4,top+4)}px`; }
123
+ }
124
+ function draw(m){
125
+ ctx.save(); ctx.strokeStyle=m.color; ctx.lineCap="round"; ctx.lineJoin="round";
126
+ if(m.kind==="line"){ ctx.lineWidth=SHAPE; ctx.beginPath(); ctx.moveTo(...m.a); ctx.lineTo(...m.b); ctx.stroke(); }
127
+ else if(m.kind==="circle"){ ctx.lineWidth=SHAPE; ctx.beginPath(); ctx.arc(m.a[0],m.a[1],dist(m.a,m.b),0,Math.PI*2); ctx.stroke(); }
128
+ else if(m.kind==="card") card(m);
129
+ else{ for(let i=1;i<m.pts.length;i++) segment(m,i); if(m.pts.length===1) segment(m,0); }
130
+ ctx.restore();
131
+ }
132
+ // one piece of a pen stroke, from point i-1 to point i; a pen's pressure sets the width
133
+ function segment(m,i){
134
+ const a=m.pts[Math.max(0,i-1)], b=m.pts[i];
135
+ ctx.lineWidth=PEN*(.35+1.3*(a[2]+b[2])/2);
136
+ ctx.beginPath(); ctx.moveTo(a[0],a[1]); ctx.lineTo(b[0]+(i?0:.01),b[1]); ctx.stroke();
137
+ }
138
+ // while a circle is dragged: its center and radius, faintly, so it can be placed exactly
139
+ function guide(m){
140
+ ctx.save(); ctx.strokeStyle=ctx.fillStyle="rgb(27 33 64/.45)"; ctx.lineWidth=1; ctx.setLineDash([4,4]);
141
+ ctx.beginPath(); ctx.moveTo(...m.a); ctx.lineTo(...m.b); ctx.stroke();
142
+ ctx.setLineDash([]); ctx.beginPath(); ctx.arc(m.a[0],m.a[1],2.5,0,Math.PI*2); ctx.fill(); ctx.restore();
143
+ }
144
+ function card(m){
145
+ ctx.fillStyle="#EEF1FB"; ctx.strokeStyle=PAPER_FAINT; ctx.lineWidth=1;
146
+ ctx.beginPath(); ctx.roundRect(m.x,m.y,m.w,m.h,12); ctx.fill(); ctx.stroke();
147
+ ctx.fillStyle=PAPER_INK; ctx.font=FONT; ctx.direction="rtl"; ctx.textAlign="right"; ctx.textBaseline="top";
148
+ m.lines.forEach((line,i)=>ctx.fillText(line,m.x+m.w-CARD_PAD,m.y+CARD_PAD+i*LINE_H+4));
149
+ for(const f of m.figs){ const img=picture(f.src); if(img.complete&&img.naturalWidth) ctx.drawImage(img,m.x+f.x,m.y+f.y,f.w,f.h); }
150
+ }
151
+ function picture(src){
152
+ if(!images.has(src)){ const img=new Image(); img.onload=()=>paint(); img.src=src; images.set(src,img); }
153
+ return images.get(src);
154
+ }
155
+ function box(m){
156
+ if(m.kind==="card") return [m.x,m.y,m.x+m.w,m.y+m.h];
157
+ if(m.kind==="circle"){ const r=dist(m.a,m.b); return [m.a[0]-r,m.a[1]-r,m.a[0]+r,m.a[1]+r]; }
158
+ const pts=m.kind==="line"?[m.a,m.b]:m.pts, xs=pts.map(p=>p[0]), ys=pts.map(p=>p[1]);
159
+ return [Math.min(...xs),Math.min(...ys),Math.max(...xs),Math.max(...ys)];
160
+ }
161
+ // the box around the elements, a little outside their ink
162
+ function edges(list){ const bs=list.map(box); return [Math.min(...bs.map(b=>b[0]))-6,Math.min(...bs.map(b=>b[1]))-6,Math.max(...bs.map(b=>b[2]))+6,Math.max(...bs.map(b=>b[3]))+6]; }
163
+ function outline(list){
164
+ const [x0,y0,x1,y1]=edges(list);
165
+ ctx.save(); ctx.strokeStyle=SELECT; ctx.lineWidth=1.5; ctx.setLineDash([6,4]); ctx.strokeRect(x0,y0,x1-x0,y1-y0); ctx.restore();
166
+ }
167
+
168
+ /* ---------- geometry ---------- */
169
+ function toSegment(p,a,b){
170
+ const dx=b[0]-a[0], dy=b[1]-a[1], l=dx*dx+dy*dy, t=l?Math.max(0,Math.min(1,((p[0]-a[0])*dx+(p[1]-a[1])*dy)/l)):0;
171
+ return Math.hypot(p[0]-a[0]-t*dx,p[1]-a[1]-t*dy);
172
+ }
173
+ // how far p is from the element's ink
174
+ function reach(m,p){
175
+ if(m.kind==="line") return toSegment(p,m.a,m.b);
176
+ if(m.kind==="circle") return Math.min(Math.abs(dist(p,m.a)-dist(m.a,m.b)),dist(p,m.a));
177
+ if(m.kind==="card"){ const [x0,y0,x1,y1]=box(m); return p[0]>=x0&&p[0]<=x1&&p[1]>=y0&&p[1]<=y1?0:Infinity; }
178
+ if(m.pts.length===1) return dist(p,m.pts[0]);
179
+ let d=Infinity; for(let i=1;i<m.pts.length;i++) d=Math.min(d,toSegment(p,m.pts[i-1],m.pts[i])); return d;
180
+ }
181
+ const hit=p=>{ const all=marks(); for(let i=all.length-1;i>=0;i--) if(reach(all[i],p)<=GRAB) return all[i]; return null; };
182
+ const joints=m=>m.kind==="line"?[m.a,m.b]:m.kind==="circle"?[m.a]:[];
183
+ // an element and everything joined to it by a shared end or center: a triangle moves as one
184
+ function group(m){
185
+ const out=new Set([m]), todo=[m];
186
+ while(todo.length){ const cur=todo.pop();
187
+ for(const o of marks()) if(!out.has(o)&&joints(cur).some(p=>joints(o).some(q=>same(p,q)))){ out.add(o); todo.push(o); } }
188
+ return [...out];
189
+ }
190
+ // a line's ends and a circle's center pull to the ends and centers already on the board
191
+ function snap(p,skip){
192
+ let best=null, d=SNAP;
193
+ for(const m of marks()){ if(m===skip) continue; for(const q of joints(m)){ const k=dist(p,q); if(k<d){ d=k; best=q; } } }
194
+ return best?[best[0],best[1]]:[p[0],p[1]];
195
+ }
196
+ function shift(m,dx,dy){
197
+ const by=p=>{ p[0]+=dx; p[1]+=dy; };
198
+ if(m.kind==="card"){ m.x+=dx; m.y+=dy; } else if(m.kind==="free") m.pts.forEach(by); else { by(m.a); by(m.b); }
199
+ }
200
+ // the eraser at p: lines and circles it touches go; pen strokes are cut; the question's copy stays
201
+ function rubOut(p){
202
+ const r=eraser/2, all=marks(); let changed=false;
203
+ for(let i=all.length-1;i>=0;i--){ const m=all[i];
204
+ if(m.kind==="line"||m.kind==="circle"){ if(reach(m,p)<=r+SHAPE/2){ all.splice(i,1); changed=true; } continue; }
205
+ if(m.kind!=="free") continue;
206
+ const gone=m.pts.map(q=>dist(p,q)<=r+PEN); if(!gone.some(Boolean)) continue;
207
+ const runs=[]; let run=[]; m.pts.forEach((q,k)=>{ if(gone[k]){ if(run.length) runs.push(run); run=[]; } else run.push(q); }); if(run.length) runs.push(run);
208
+ all.splice(i,1,...runs.map(pts=>({kind:"free",color:m.color,pts}))); changed=true;
209
+ }
210
+ return changed;
211
+ }
212
+ // points along a to b, close enough that nothing between two of them is skipped
213
+ function along(a,b,step){ const n=Math.max(1,Math.ceil(dist(a,b)/step)), out=[]; for(let k=1;k<=n;k++) out.push([a[0]+(b[0]-a[0])*k/n,a[1]+(b[1]-a[1])*k/n,a[2]+((b[2]??a[2])-a[2])*k/n]); return out; }
214
+
215
+ /* ---------- the pointer ---------- */
216
+ const at=e=>{ const r=canvas.getBoundingClientRect(); return [e.clientX-r.left,e.clientY-r.top+sketch().y,e.pointerType==="pen"?e.pressure:.5]; }; // a point on the paper, not on the screen
217
+ function down(e){
218
+ if(e.button!==0&&e.button!==5) return; // 5: the pen's eraser end
219
+ const p=at(e), use=e.button===5||(e.buttons&32)>0?"eraser":tool;
220
+ canvas.setPointerCapture(e.pointerId); e.preventDefault();
221
+ if(use==="move"){
222
+ const m=hit(p);
223
+ if(!m){ select([]); return paint(); }
224
+ if(!selection.includes(m)) select(group(m));
225
+ drag={id:e.pointerId,last:p,before:null}; return paint();
226
+ }
227
+ if(use==="eraser"){ rub={id:e.pointerId,last:p,before:copy(marks()),changed:rubOut(p)}; return paint(); }
228
+ remember();
229
+ draft=use==="pen"?{kind:"free",color:ink,pts:[p]}:{kind:use,color:ink,a:snap(p),b:null};
230
+ if(draft.b===null) draft.b=[...draft.a];
231
+ draft.id=e.pointerId; marks().push(draft); paint();
232
+ }
233
+ function move(e){
234
+ if(tool==="move"&&!drag&&!e.buttons) canvas.style.cursor=hit(at(e))?"move":"default";
235
+ if(drag&&e.pointerId===drag.id){
236
+ const p=at(e), dx=p[0]-drag.last[0], dy=p[1]-drag.last[1]; if(!dx&&!dy) return;
237
+ if(!drag.before){ drag.before=true; remember(); }
238
+ selection.forEach(m=>shift(m,dx,dy)); drag.last=p; return paint();
239
+ }
240
+ if(rub&&e.pointerId===rub.id){
241
+ const p=at(e); for(const q of along(rub.last,p,eraser/4)) rub.changed=rubOut(q)||rub.changed;
242
+ rub.last=p; return paint();
243
+ }
244
+ if(!draft||e.pointerId!==draft.id) return;
245
+ if(draft.kind==="free"){
246
+ for(const c of e.getCoalescedEvents?.()||[e]){ const p=at(c); draft.pts.push(...along(draft.pts.at(-1),p,STEP)); }
247
+ ctx.save(); ctx.strokeStyle=draft.color; ctx.lineCap=ctx.lineJoin="round";
248
+ for(let i=Math.max(1,draft.drawn||1);i<draft.pts.length;i++) segment(draft,i);
249
+ draft.drawn=draft.pts.length; ctx.restore(); return;
250
+ }
251
+ const p=at(e); draft.b=draft.kind==="line"?snap(p,draft):[p[0],p[1]]; paint();
252
+ }
253
+ function up(e){
254
+ if(drag&&e.pointerId===drag.id){ drag=null; return paint(); }
255
+ if(rub&&e.pointerId===rub.id){ if(rub.changed) remember(rub.before); rub=null; paint(); return pages>1&&drawPages(); }
256
+ if(!draft||e.pointerId!==draft.id) return;
257
+ const m=draft; draft=null; delete m.id; delete m.drawn;
258
+ if(m.kind!=="free"&&dist(m.a,m.b)<3){ marks().pop(); sketch().past.pop(); } // a tap, not a shape
259
+ paint(); if(pages>1) drawPages();
260
+ }
261
+ function undo(){ const s=sketch(); if(!s.past.length) return; s.marks=s.past.pop(); reset(); paint(); if(pages>1) drawPages(); }
262
+ function removeSelection(){
263
+ if(!selection.length) return;
264
+ remember(); const all=marks(); for(const m of selection) all.splice(all.indexOf(m),1);
265
+ select([]); paint(); if(pages>1) drawPages();
266
+ }
267
+
268
+ /* ---------- the question's copy ---------- */
269
+ // the question's words, with each piece of math kept left to right and in one piece
270
+ function words(node){
271
+ const out=[]; let word="";
272
+ const flush=()=>{ if(word) out.push(word); word=""; };
273
+ (function walk(n){
274
+ for(const c of n.childNodes){
275
+ if(c.nodeType===3){ for(const ch of c.textContent){ if(/\s/.test(ch)) flush(); else word+=ch; } }
276
+ else if(c.nodeName==="BR"){ flush(); out.push("\n"); }
277
+ else if(c.classList?.contains("m")) word+="โฆ"+c.textContent.trim()+"โฉ"; // math kept left to right, stuck to the brackets and marks around it
278
+ else walk(c);
279
+ }
280
+ })(node);
281
+ flush(); return out;
282
+ }
283
+ function wrap(tokens,width){
284
+ ctx.save(); ctx.font=FONT; ctx.direction="rtl";
285
+ const lines=[]; let line="";
286
+ for(const t of tokens){
287
+ if(t==="\n"){ lines.push(line); line=""; continue; }
288
+ const next=line?line+" "+t:t;
289
+ if(line&&ctx.measureText(next).width>width){ lines.push(line); line=t; } else line=next;
290
+ }
291
+ if(line) lines.push(line); ctx.restore(); return lines;
292
+ }
293
+ // a figure drawn for the dark sheet, redrawn in ink for the white board
294
+ function paperColor(c){
295
+ const m=String(c).match(/rgba?\(([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?/); if(!m) return c;
296
+ if(m[4]!==undefined&&+m[4]===0) return "none";
297
+ const l=(.299*m[1]+.587*m[2]+.114*m[3])/255;
298
+ return l>.55?PAPER_INK:l<.3?PAPER_FAINT:c;
299
+ }
300
+ const STYLE=["fill","stroke","stroke-width","stroke-dasharray","stroke-linecap","stroke-linejoin","opacity","font-size","font-family","font-weight","font-style","text-anchor","dominant-baseline"];
301
+ function paperFigure(svg){
302
+ const r=svg.getBoundingClientRect(); if(!r.width||!r.height) return null;
303
+ const clone=svg.cloneNode(true), from=[svg,...svg.querySelectorAll("*")], to=[clone,...clone.querySelectorAll("*")];
304
+ from.forEach((n,i)=>{ const cs=getComputedStyle(n);
305
+ for(const prop of STYLE){ let v=cs.getPropertyValue(prop); if(prop==="fill"||prop==="stroke") v=paperColor(v); if(v) to[i].style.setProperty(prop,v); } });
306
+ clone.setAttribute("xmlns","http://www.w3.org/2000/svg"); clone.setAttribute("width",r.width); clone.setAttribute("height",r.height);
307
+ clone.removeAttribute("class"); clone.style.background="none";
308
+ return {src:"data:image/svg+xml;charset=utf-8,"+encodeURIComponent(new XMLSerializer().serializeToString(clone)),w:r.width,h:r.height};
309
+ }
310
+ function copyQuestion(){
311
+ const q=source?.(page); if(!q||!shown()) return;
312
+ const text=q.querySelector(".mx-uq-text")||q.querySelector(":scope > p");
313
+ const W=Math.min(canvas.clientWidth-32,600), inner=W-2*CARD_PAD;
314
+ const lines=text?wrap(words(text),inner):[];
315
+ let y=CARD_PAD+lines.length*LINE_H+(lines.length?6:0);
316
+ const figs=[];
317
+ for(const svg of q.querySelectorAll("svg")){
318
+ if(svg.closest("button")) continue; // the answers' buttons are not part of the question
319
+ const f=paperFigure(svg); if(!f) continue;
320
+ const s=Math.min(1,inner/f.w), w=f.w*s, h=f.h*s;
321
+ figs.push({src:f.src,x:(W-w)/2,y,w,h}); y+=h+8;
322
+ }
323
+ if(!lines.length&&!figs.length) return;
324
+ remember();
325
+ const m={kind:"card",x:canvas.clientWidth-16-W,y:sketch().y+16,w:W,h:y+CARD_PAD-(figs.length?8:0),lines,figs};
326
+ marks().push(m); setTool("move"); select([m]); paint(); if(pages>1) drawPages();
327
+ }
328
+
329
+ /* ---------- handing in ---------- */
330
+ // the whole sketch as a picture on white paper: as wide as the board, as tall as what is drawn
331
+ function snapshot(){
332
+ const all=marks(); if(!all.some(m=>m.kind!=="card")) return null;
333
+ const [,y0,,y1]=edges(all), top=Math.max(0,y0-24), W=canvas.clientWidth, H=Math.min(y1+24-top,8000), k=2;
334
+ const out=document.createElement("canvas"); out.width=W*k; out.height=Math.round(H*k);
335
+ const keep=ctx; ctx=out.getContext("2d");
336
+ try{ ctx.fillStyle="#fff"; ctx.fillRect(0,0,out.width,out.height); ctx.setTransform(k,0,0,k,0,-top*k); all.forEach(draw); }
337
+ finally{ ctx=keep; }
338
+ return new Promise(ok=>out.toBlob(ok,"image/png"));
339
+ }
340
+ async function send(){
341
+ const b=el.querySelector(".mx-board-send"), p=page, say=t=>{ if(page===p) b.innerHTML=t; };
342
+ const blob=await snapshot();
343
+ if(!blob){ say(`<span>ื”ืœื•ื— ืจื™ืง: ืื™ืŸ ืžื” ืœื”ื’ื™ืฉ</span>`); return setTimeout(()=>page===p&&drawSend(),2500); }
344
+ b.disabled=true; say(`<span>ืžื’ื™ืฉื™ืโ€ฆ</span>`);
345
+ let r; try{ r=await hand(p,blob); }catch(e){ r={error:e.message}; }
346
+ b.disabled=false; say(r?.error?`<span>โš  ${r.error}</span>`:`<i aria-hidden="true">โœ“</i><span>ื”ื•ื’ืฉื” ืœืฉืืœื” ${p+1}</span>`);
347
+ setTimeout(()=>page===p&&!b.disabled&&drawSend(),3000);
348
+ }
349
+
350
+ document.addEventListener("keydown",e=>{
351
+ if(!shown()||e.target.closest?.("input,textarea,[contenteditable]")) return;
352
+ if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==="z"&&!e.shiftKey){ e.preventDefault(); undo(); }
353
+ else if((e.key==="Delete"||e.key==="Backspace")&&selection.length){ e.preventDefault(); removeSelection(); }
354
+ });
355
+
356
+ /* Shows the board in the sheet's layer when the student has it on, and takes it away when not.
357
+ sketchKey names the sketch; with pages>1 (a sheet with several questions at once) the board has a tab per question.
358
+ question(page) gives the element the question is shown in, for the board's copy of it. */
359
+ function mount(sheetLayer,sketchKey,{pages:n=1,question=null,submit=null,turned=null}={}){
360
+ layer=sheetLayer; source=question; hand=submit; onPage=turned;
361
+ if(sketchKey!==base||n!==pages){ base=sketchKey; pages=n; page=0; }
362
+ key=pages>1?`${base}:${page}`:base;
363
+ const sheet=layer.querySelector(".mx-sheet"), on=isOn();
364
+ layer.querySelector("[data-board-toggle]")?.setAttribute("aria-pressed",on);
365
+ sheet?.classList.toggle("with-board",on);
366
+ if(on) sheet?.removeAttribute("aria-modal"); else sheet?.setAttribute("aria-modal","true"); // the board is outside the dialog: still reachable
367
+ if(!on){ el?.remove(); return; }
368
+ if(!el) build();
369
+ reset(); drawPages(); drawSend(); layer.append(el); fit();
370
+ }
371
+ const again=()=>mount(layer,base,{pages,question:source,submit:hand,turned:onPage});
372
+ function toggle(){ setOn(!isOn()); if(layer) again(); }
373
+ // opens the board on one question's page (the unit quiz's own button under each question)
374
+ function show(p){ setOn(true); if(!layer) return; again(); if(pages>1&&p!==page) turn(p); }
375
+ // the header's switch: shown on a desktop screen only (math.css)
376
+ const button=()=>`<button class="mx-board-btn" data-board-toggle aria-pressed="${isOn()}" title="ืœื•ื— ื˜ื™ื•ื˜ื” ืœื™ื“ ื”ืชืจื’ื™ืœ">โœ๏ธ ืœื•ื—</button>`;
377
+
378
+ window.MathlyBoard={button,mount,toggle,show,isOn};
379
+ })();
static/math.css CHANGED
@@ -657,3 +657,53 @@ button.sky-chip.flame{padding:6px 11px;color:var(--ink)}
657
  .mx-star.fix{box-shadow:0 0 0 3px var(--gold),0 0 14px color-mix(in srgb,var(--gold) 55%,transparent)}
658
  .mx-lbl.l-fix em{background:var(--gold);color:#2A1B00}
659
  .mx-pattern-note{display:block;margin-top:6px;padding:8px 10px;border-inline-start:3px solid var(--gold);border-radius:8px;background:color-mix(in srgb,var(--gold) 10%,transparent);color:var(--ink);font-size:13.5px}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
657
  .mx-star.fix{box-shadow:0 0 0 3px var(--gold),0 0 14px color-mix(in srgb,var(--gold) 55%,transparent)}
658
  .mx-lbl.l-fix em{background:var(--gold);color:#2A1B00}
659
  .mx-pattern-note{display:block;margin-top:6px;padding:8px 10px;border-inline-start:3px solid var(--gold);border-radius:8px;background:color-mix(in srgb,var(--gold) 10%,transparent);color:var(--ink);font-size:13.5px}
660
+
661
+ /* the sketch board beside a skill's exercise (static/math-board.js): a desktop screen with a mouse or a pen only */
662
+ .mx-shacts{display:flex;align-items:center;gap:6px}
663
+ .mx-board-btn,.mx-board,.mx-board-only{display:none}
664
+ .mx-board-btn{border:1px solid var(--line);background:none;color:var(--muted);font:700 14px/1 var(--f-body);padding:7px 12px;border-radius:999px;cursor:pointer}
665
+ .mx-board-btn[aria-pressed="true"]{border-color:var(--mx-accent);background:var(--mx-accent-soft);color:var(--ink)}
666
+ @media (min-width:1000px) and (any-pointer:fine){
667
+ .mx-board-btn,.mx-board-only{display:inline-block}
668
+ .mx-sheet.with-board{inset:16px 16px 16px auto;width:min(600px,42vw);max-height:none;border-radius:20px}
669
+ .mx-board{display:flex;flex-direction:column;position:fixed;z-index:62;top:16px;bottom:16px;left:16px;right:calc(min(600px,42vw) + 32px);
670
+ background:var(--surface);border:1px solid var(--line);border-radius:20px;overflow:hidden;box-shadow:0 6px 30px rgb(0 0 0/.25)}
671
+ }
672
+ .mx-board-bar{display:flex;align-items:center;gap:6px;padding:8px 10px;border-bottom:1px solid var(--line);flex-wrap:wrap}
673
+ .mx-board-bar button{display:inline-flex;align-items:center;gap:5px;border:1px solid transparent;background:none;color:var(--muted);
674
+ font:500 14px/1 var(--f-body);padding:7px 10px;border-radius:10px;cursor:pointer;min-height:34px}
675
+ .mx-board-bar button i{font-style:normal;font-size:16px;line-height:1}
676
+ .mx-board-bar button:hover{color:var(--ink);background:var(--surface-2)}
677
+ .mx-board-bar button[hidden]{display:none}
678
+ .mx-board-bar button[aria-pressed="true"]{color:var(--ink);border-color:var(--mx-accent);background:var(--mx-accent-soft)}
679
+ @media (max-width:1280px){.mx-board-bar button span{display:none}} /* a narrower board: icons only, names stay as tooltips */
680
+ .mx-board-group{display:inline-flex;align-items:center;gap:3px}
681
+ .mx-board-inks,.mx-board-sizes{padding-inline:6px;border-inline-start:1px solid var(--line)}
682
+ .mx-board-inks button,.mx-board-sizes button{width:34px;padding:0;justify-content:center}
683
+ .mx-board-inks button::before{content:"";width:16px;height:16px;border-radius:50%;background:var(--dot);box-shadow:0 0 0 2px #fff}
684
+ .mx-board-sizes button::before{content:"";width:var(--d);height:var(--d);border-radius:50%;border:1.5px solid currentColor}
685
+ .mx-board:not([data-tool="eraser"]) .mx-board-sizes{display:none} /* the sizes show with the eraser */
686
+ .mx-board-gap{flex:1}
687
+ .mx-board-row{display:flex;align-items:center;gap:8px;padding:6px 10px;border-bottom:1px solid var(--line)}
688
+ .mx-board-row[hidden]{display:none}
689
+ .mx-board-pages{display:flex;align-items:center;gap:4px;flex:1;color:var(--muted);font-size:13px}
690
+ .mx-board-pages span{margin-inline-end:4px}
691
+ .mx-board-pages button{min-width:30px;height:28px;border:1px solid var(--line);background:none;color:var(--muted);border-radius:8px;font:700 13px/1 var(--f-body);cursor:pointer}
692
+ .mx-board-pages button.has{color:var(--ink)}
693
+ .mx-board-pages button[aria-selected="true"]{border-color:var(--mx-accent);background:var(--mx-accent-soft);color:var(--ink)}
694
+ .mx-board-stage{position:relative;flex:1;min-height:0}
695
+ .mx-board canvas{position:absolute;inset:0;width:100%;height:100%;display:block;touch-action:none;cursor:crosshair;
696
+ background-color:#F8F7F2;background-image:linear-gradient(rgb(27 33 64/.07) 1px,transparent 1px),linear-gradient(90deg,rgb(27 33 64/.07) 1px,transparent 1px);
697
+ background-size:28px 28px}
698
+ .mx-board-del{position:absolute;z-index:1;border:1px solid #E7B3BB;background:#FFF1F3;color:#B4233A;font:700 13px/1 var(--f-body);
699
+ padding:7px 11px;border-radius:999px;cursor:pointer;box-shadow:0 2px 8px rgb(27 33 64/.15)}
700
+ .mx-board-del[hidden]{display:none}
701
+
702
+ .mx-board-send{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--mx-accent);background:var(--mx-accent);color:var(--mx-on-accent);
703
+ font:700 14px/1 var(--f-body);padding:8px 14px;border-radius:999px;cursor:pointer}
704
+ .mx-board-send[hidden]{display:none}
705
+ .mx-board-send:disabled{opacity:.7;cursor:progress}
706
+ .mx-board-top{position:absolute;z-index:1;top:8px;left:50%;transform:translateX(-50%);border:1px solid #C5CBDF;background:#fff;color:#1B2140;
707
+ font:700 13px/1 var(--f-body);padding:7px 12px;border-radius:999px;cursor:pointer;box-shadow:0 2px 8px rgb(27 33 64/.15)}
708
+ .mx-board-top[hidden]{display:none}
709
+ .mx-uq-q{scroll-margin-top:90px} /* a question scrolled to from the board's tabs shows below the quiz's sticky head */
static/math.js CHANGED
@@ -55,7 +55,7 @@ function mergeProgress(a,b){
55
  out.updated=Math.max(+a.updated||0,+b.updated||0);
56
  return out;
57
  }
58
- const synced=()=>false;
59
  let pushTimer=null;
60
  async function pullProgress(){
61
  if(!synced()) return null;
@@ -161,7 +161,8 @@ async function show(){
161
  }
162
  const remote=await pullProgress();
163
  P=window.MathlySky.ensure(Object.assign({done:{},tries:{},solved:{},at:{},set:{}},remote?mergeProgress(load(),remote):load()));
164
- if(remote){ try{ localStorage.setItem(key(),JSON.stringify(P)); }catch{} queuePush(); } // this device's own work goes up too if(!P.set||typeof P.set!=="object") P.set={}; SHOWN=new Set(shownUnits().map(u=>u.id));
 
165
  hasExams()?drawSubjectPicker():selectSubject("ืžืชืžื˜ื™ืงื”"); // no exams yet: straight to math, science is a tap away
166
  window.MathlyAnswers?.configure({api,account:CFG.userId,enabled:CFG.answerScans,root,onGraded:({questionId,grading})=>{
167
  if(grading.result.verdict!=="correct"||P.done[questionId])return;
@@ -433,7 +434,7 @@ function open(id){
433
  <div class="mx-sheet u-${s.unit}" role="dialog" aria-modal="true" aria-labelledby="mxTitle">
434
  <div class="mx-shtop"><div><span class="mx-uchip">${esc(unit.name)}</span>${s.core?' <span class="mx-core">ืœื™ื‘ื” 5 ื™ื—ืดืœ</span>':""}
435
  <h3 id="mxTitle">${fmt(s.name)}</h3><small class="mx-src">${s.month?`ื‘ืคืจื™ืกื”: ${esc(s.month)}`:esc(unit.source)}${st==="done"?" ยท ื”ื•ืฉืœืžื” โœ“":""}</small></div>
436
- <button class="mx-x" data-act="close" aria-label="ืกื’ื™ืจื”">โœ•</button></div>
437
  <p>${fmt(s.description)}</p>
438
  <div class="mx-q">${n>1?`<div class="mx-qprog"><span>ืฉืืœื” ${r.indexOf(curQ)+1} ืžืชื•ืš ${n}</span><span class="mx-qdots" aria-label="ื ืคืชืจื• ${gotN} ืžืชื•ืš ${n}">${r.map(i=>`<i class="${got.has(i)?"ok":""}${i===curQ?" now":""}"></i>`).join("")}</span></div>`:""}
439
  <p>${fmt(q.question)}</p>${figures(q)}
@@ -443,6 +444,7 @@ function open(id){
443
  <div class="mx-sec"><h4>ืคื•ืชื—ืช</h4><div class="mx-chips">${chips(POST[id])}</div></div>
444
  </div>`;
445
  sheet.hidden=false; sheet.querySelector(".mx-sheet").scrollTop=0;
 
446
  sheet.querySelector(".mx-x").focus({preventScroll:true});
447
  if(openEnded&&st!=="off")window.MathlyAnswers?.mountQuestion(sheet.querySelector(".mx-answer"),id,s.question_version);
448
  }
@@ -705,13 +707,14 @@ function quizShow(){
705
  <div class="mx-sheet mx-quiz-sheet" role="dialog" aria-modal="true" aria-labelledby="mxTitle">
706
  <div class="mx-shtop"><div><span class="mx-uchip">ืžื‘ื—ืŸ ืชืจื’ื•ืœ</span><h3 id="mxTitle">ืฉืืœื” ${i+1} ืžืชื•ืš ${items.length}</h3>
707
  <small class="mx-src">โฑ <span class="mx-quiz-clock">${mmss(Date.now()-QZ.started)}</span></small></div>
708
- <button class="mx-x" data-act="quiz-quit" aria-label="ื™ืฆื™ืื” ืžื”ืžื‘ื—ืŸ">โœ•</button></div>
709
  <div class="mx-quiz-dots">${items.map((x,k)=>`<button data-quiz-go="${k}" class="${x.pick!=null?"done":""}${k===i?" now":""}" aria-label="ืฉืืœื” ${k+1}${x.pick!=null?", ื ืขื ืชื”":""}">${k+1}</button>`).join("")}</div>
710
  <div class="mx-q"><p>${fmt(q.question)}</p>${figures(q)}
711
  <div class="mx-choices">${it.order.map(k=>{ const c=q.choices[k]; return `<button data-choice="${k}" data-m="${c.correct?"":esc(c.mistake)}"${c.correct?" data-right":""} aria-pressed="${it.pick===k}">${fmt(c.text)}</button>`; }).join("")}</div></div>
712
  <div class="mx-quiz-nav"><button class="mx-btn" data-act="quiz-prev"${i?"":" disabled"}>ื”ืงื•ื“ืžืช</button>
713
  ${last?`<button class="mx-btn solid" data-act="quiz-submit">ื”ื’ืฉื”</button>`:`<button class="mx-btn solid" data-act="quiz-next">ื”ื‘ืื”</button>`}</div></div>`;
714
  sheet.hidden=false; sheet.querySelector(".mx-sheet").scrollTop=0;
 
715
  }
716
  // a choice is only marked; it can be changed until the exam is handed in
717
  function quizPick(btn){
@@ -849,7 +852,7 @@ function uqOpen(uid){
849
  <div class="mx-sheet mx-uq-sheet mx-uq-paper u-${uid}" role="dialog" aria-modal="true" aria-labelledby="mxTitle">
850
  <div class="mx-uq-head"><div><span class="mx-uchip">${esc(u.name)}</span><h3 id="mxTitle">ื‘ื•ื—ืŸ ืžืกื›ื</h3></div>
851
  <div class="mx-uq-clock" role="timer" aria-live="off"></div>
852
- <button class="mx-x" data-act="close" aria-label="ื™ืฆื™ืื” (ื”ืฉืขื•ืŸ ืžืžืฉื™ืš)">โœ•</button></div>
853
  <p class="mx-uq-banner" aria-live="polite"></p>
854
  <ol class="mx-uq-qs">${u.quiz.questions.map((q,i)=>`<li class="mx-uq-q" id="uq-q${i}">
855
  <div class="mx-uq-qhead"><b>ืฉืืœื” ${i+1}</b><span>${q.points} ื ืงืณ</span></div>
@@ -858,6 +861,13 @@ function uqOpen(uid){
858
  <div class="mx-quiz-nav"><span class="mx-small" data-uq-count></span><button class="mx-btn solid" data-act="uq-submit">ื”ื’ืฉืช ื”ื‘ื•ื—ืŸ</button></div>
859
  <input type="file" accept="image/*" capture="environment" hidden data-uq-file></div>`;
860
  sheet.hidden=false; sheet.querySelector(".mx-sheet").scrollTop=0;
 
 
 
 
 
 
 
861
  sheet.querySelector("[data-uq-file]").onchange=e=>{ const f=e.target.files[0]; e.target.value=""; if(f&&UQ?.pick!=null) uqUpload(UQ.pick,f); };
862
  uqTick(); UQ.clock=setInterval(uqTick,1000);
863
  sit.attempts.forEach((_,i)=>uqRefresh(i));
@@ -894,7 +904,7 @@ function uqDrawAnswer(i){
894
  const phoneBtn=touch?"":window.MathlyAnswers.phoneLive(has?null:r?.scan,`data-uq-phone="${i}"`,`data-uq-cancel="${i}"`,has?"ืฆื™ืœื•ื ืžื—ื“ืฉ ื‘ื˜ืœืคื•ืŸ":"ืฆื™ืœื•ื ื‘ื˜ืœืคื•ืŸ",!!busy);
895
  box.innerHTML=`${has?`<img class="mx-uq-img" alt="ื”ืชืฉื•ื‘ื” ื”ืžืฆื•ืœืžืช ืœืฉืืœื” ${i+1}" src="${UQ.images[i]||""}">`:""}
896
  <p class="mx-small" role="status">${busy?esc(busy):UQ.msg[i]?esc(UQ.msg[i]):has?"โœ“ ื”ืชืฉื•ื‘ื” ื ืฉืžืจื”":phone?"":st==="declined"?"ื”ื‘ืงืฉื” ื ื“ื—ืชื” ื‘ื˜ืœืคื•ืŸ":st==="expired"?"ื‘ืงืฉืช ื”ืฆื™ืœื•ื ืคื’ื”":"ืขื•ื“ ืœื ืฆื•ืœืžื” ืชืฉื•ื‘ื”"}</p>
897
- <span class="mx-chips">${phoneBtn}<button class="mx-btn${has||!touch?"":" solid"}" data-uq-photo="${i}"${busy?" disabled":""}>${has&&touch?"ืฆื™ืœื•ื ืžื—ื“ืฉ":touch?"ืฆื™ืœื•ื ื”ืชืฉื•ื‘ื”":"ื”ืขืœืืช ืชืžื•ื ื” ืžื”ืžื—ืฉื‘"}</button></span>`;
898
  const done=Object.values(UQ.res).filter(x=>x?.attachment).length, n=uqState(UQ.uid).sitting.attempts.length;
899
  const c=sheet.querySelector("[data-uq-count]"); if(c) c.textContent=`ืฆื•ืœืžื• ${done} ืžืชื•ืš ${n} ืชืฉื•ื‘ื•ืช`;
900
  }
@@ -1008,6 +1018,7 @@ async function uqResult(uid,h,check=false){
1008
  function onClick(e){
1009
  const t=e.target.closest("button,[data-act]"); if(!t||!root.contains(t)) return;
1010
  if(t.dataset.choice!=null) return answer(t);
 
1011
  if(t.dataset.quizUnit) return quizToggle(t.dataset.quizUnit);
1012
  if(t.dataset.quizGo!=null) return quizGo(+t.dataset.quizGo);
1013
  if(t.dataset.uquiz) return uqIntro(t.dataset.uquiz); // a sitting in progress opens straight on its paper
@@ -1015,6 +1026,7 @@ function onClick(e){
1015
  if(t.dataset.uqPhoto!=null){ UQ.pick=+t.dataset.uqPhoto; return sheet.querySelector("[data-uq-file]").click(); }
1016
  if(t.dataset.uqPhone!=null) return uqPhone(+t.dataset.uqPhone);
1017
  if(t.dataset.uqCancel!=null) return uqCancel(+t.dataset.uqCancel);
 
1018
  if(t.dataset.subject) return selectSubject(t.dataset.subject);
1019
  if(t.dataset.view){ closeSheet(); return setView(t.dataset.view,t.dataset.unit); }
1020
  if(t.dataset.comet) return showComet(t.dataset.comet);
 
55
  out.updated=Math.max(+a.updated||0,+b.updated||0);
56
  return out;
57
  }
58
+ const synced=()=>!!(AUTH_URL&&CFG.token);
59
  let pushTimer=null;
60
  async function pullProgress(){
61
  if(!synced()) return null;
 
161
  }
162
  const remote=await pullProgress();
163
  P=window.MathlySky.ensure(Object.assign({done:{},tries:{},solved:{},at:{},set:{}},remote?mergeProgress(load(),remote):load()));
164
+ if(remote){ try{ localStorage.setItem(key(),JSON.stringify(P)); }catch{} queuePush(); } // this device's own work goes up too
165
+ if(!P.set||typeof P.set!=="object") P.set={}; SHOWN=new Set(shownUnits().map(u=>u.id));
166
  hasExams()?drawSubjectPicker():selectSubject("ืžืชืžื˜ื™ืงื”"); // no exams yet: straight to math, science is a tap away
167
  window.MathlyAnswers?.configure({api,account:CFG.userId,enabled:CFG.answerScans,root,onGraded:({questionId,grading})=>{
168
  if(grading.result.verdict!=="correct"||P.done[questionId])return;
 
434
  <div class="mx-sheet u-${s.unit}" role="dialog" aria-modal="true" aria-labelledby="mxTitle">
435
  <div class="mx-shtop"><div><span class="mx-uchip">${esc(unit.name)}</span>${s.core?' <span class="mx-core">ืœื™ื‘ื” 5 ื™ื—ืดืœ</span>':""}
436
  <h3 id="mxTitle">${fmt(s.name)}</h3><small class="mx-src">${s.month?`ื‘ืคืจื™ืกื”: ${esc(s.month)}`:esc(unit.source)}${st==="done"?" ยท ื”ื•ืฉืœืžื” โœ“":""}</small></div>
437
+ <div class="mx-shacts">${window.MathlyBoard?.button()||""}<button class="mx-x" data-act="close" aria-label="ืกื’ื™ืจื”">โœ•</button></div></div>
438
  <p>${fmt(s.description)}</p>
439
  <div class="mx-q">${n>1?`<div class="mx-qprog"><span>ืฉืืœื” ${r.indexOf(curQ)+1} ืžืชื•ืš ${n}</span><span class="mx-qdots" aria-label="ื ืคืชืจื• ${gotN} ืžืชื•ืš ${n}">${r.map(i=>`<i class="${got.has(i)?"ok":""}${i===curQ?" now":""}"></i>`).join("")}</span></div>`:""}
440
  <p>${fmt(q.question)}</p>${figures(q)}
 
444
  <div class="mx-sec"><h4>ืคื•ืชื—ืช</h4><div class="mx-chips">${chips(POST[id])}</div></div>
445
  </div>`;
446
  sheet.hidden=false; sheet.querySelector(".mx-sheet").scrollTop=0;
447
+ window.MathlyBoard?.mount(sheet,`${id}:${curQ}`,{question:()=>sheet.querySelector(".mx-q")}); // the sketch board beside the exercise, when the student has it on
448
  sheet.querySelector(".mx-x").focus({preventScroll:true});
449
  if(openEnded&&st!=="off")window.MathlyAnswers?.mountQuestion(sheet.querySelector(".mx-answer"),id,s.question_version);
450
  }
 
707
  <div class="mx-sheet mx-quiz-sheet" role="dialog" aria-modal="true" aria-labelledby="mxTitle">
708
  <div class="mx-shtop"><div><span class="mx-uchip">ืžื‘ื—ืŸ ืชืจื’ื•ืœ</span><h3 id="mxTitle">ืฉืืœื” ${i+1} ืžืชื•ืš ${items.length}</h3>
709
  <small class="mx-src">โฑ <span class="mx-quiz-clock">${mmss(Date.now()-QZ.started)}</span></small></div>
710
+ <div class="mx-shacts">${window.MathlyBoard?.button()||""}<button class="mx-x" data-act="quiz-quit" aria-label="ื™ืฆื™ืื” ืžื”ืžื‘ื—ืŸ">โœ•</button></div></div>
711
  <div class="mx-quiz-dots">${items.map((x,k)=>`<button data-quiz-go="${k}" class="${x.pick!=null?"done":""}${k===i?" now":""}" aria-label="ืฉืืœื” ${k+1}${x.pick!=null?", ื ืขื ืชื”":""}">${k+1}</button>`).join("")}</div>
712
  <div class="mx-q"><p>${fmt(q.question)}</p>${figures(q)}
713
  <div class="mx-choices">${it.order.map(k=>{ const c=q.choices[k]; return `<button data-choice="${k}" data-m="${c.correct?"":esc(c.mistake)}"${c.correct?" data-right":""} aria-pressed="${it.pick===k}">${fmt(c.text)}</button>`; }).join("")}</div></div>
714
  <div class="mx-quiz-nav"><button class="mx-btn" data-act="quiz-prev"${i?"":" disabled"}>ื”ืงื•ื“ืžืช</button>
715
  ${last?`<button class="mx-btn solid" data-act="quiz-submit">ื”ื’ืฉื”</button>`:`<button class="mx-btn solid" data-act="quiz-next">ื”ื‘ืื”</button>`}</div></div>`;
716
  sheet.hidden=false; sheet.querySelector(".mx-sheet").scrollTop=0;
717
+ window.MathlyBoard?.mount(sheet,`quiz:${QZ.started}:${i}`,{question:()=>sheet.querySelector(".mx-q")}); // a sketch per exam question
718
  }
719
  // a choice is only marked; it can be changed until the exam is handed in
720
  function quizPick(btn){
 
852
  <div class="mx-sheet mx-uq-sheet mx-uq-paper u-${uid}" role="dialog" aria-modal="true" aria-labelledby="mxTitle">
853
  <div class="mx-uq-head"><div><span class="mx-uchip">${esc(u.name)}</span><h3 id="mxTitle">ื‘ื•ื—ืŸ ืžืกื›ื</h3></div>
854
  <div class="mx-uq-clock" role="timer" aria-live="off"></div>
855
+ <div class="mx-shacts">${window.MathlyBoard?.button()||""}<button class="mx-x" data-act="close" aria-label="ื™ืฆื™ืื” (ื”ืฉืขื•ืŸ ืžืžืฉื™ืš)">โœ•</button></div></div>
856
  <p class="mx-uq-banner" aria-live="polite"></p>
857
  <ol class="mx-uq-qs">${u.quiz.questions.map((q,i)=>`<li class="mx-uq-q" id="uq-q${i}">
858
  <div class="mx-uq-qhead"><b>ืฉืืœื” ${i+1}</b><span>${q.points} ื ืงืณ</span></div>
 
861
  <div class="mx-quiz-nav"><span class="mx-small" data-uq-count></span><button class="mx-btn solid" data-act="uq-submit">ื”ื’ืฉืช ื”ื‘ื•ื—ืŸ</button></div>
862
  <input type="file" accept="image/*" capture="environment" hidden data-uq-file></div>`;
863
  sheet.hidden=false; sheet.querySelector(".mx-sheet").scrollTop=0;
864
+ // all the questions on one paper: the board has a page per question, and hands in that page as the question's answer
865
+ window.MathlyBoard?.mount(sheet,`uq:${uid}:${sit.started}`,{pages:u.quiz.questions.length,question:i=>sheet.querySelector(`#uq-q${i}`),
866
+ submit:async(i,picture)=>{
867
+ if(UQ?.uid!==uid||uqState(uid).sitting?.submitted) return {error:"ื”ื‘ื•ื—ืŸ ื›ื‘ืจ ื”ื•ื’ืฉ"};
868
+ await uqUpload(i,picture); return UQ?.msg[i]?{error:UQ.msg[i]}:{};
869
+ },
870
+ turned:i=>sheet.querySelector(`#uq-q${i}`)?.scrollIntoView({block:"start",behavior:"smooth"})});
871
  sheet.querySelector("[data-uq-file]").onchange=e=>{ const f=e.target.files[0]; e.target.value=""; if(f&&UQ?.pick!=null) uqUpload(UQ.pick,f); };
872
  uqTick(); UQ.clock=setInterval(uqTick,1000);
873
  sit.attempts.forEach((_,i)=>uqRefresh(i));
 
904
  const phoneBtn=touch?"":window.MathlyAnswers.phoneLive(has?null:r?.scan,`data-uq-phone="${i}"`,`data-uq-cancel="${i}"`,has?"ืฆื™ืœื•ื ืžื—ื“ืฉ ื‘ื˜ืœืคื•ืŸ":"ืฆื™ืœื•ื ื‘ื˜ืœืคื•ืŸ",!!busy);
905
  box.innerHTML=`${has?`<img class="mx-uq-img" alt="ื”ืชืฉื•ื‘ื” ื”ืžืฆื•ืœืžืช ืœืฉืืœื” ${i+1}" src="${UQ.images[i]||""}">`:""}
906
  <p class="mx-small" role="status">${busy?esc(busy):UQ.msg[i]?esc(UQ.msg[i]):has?"โœ“ ื”ืชืฉื•ื‘ื” ื ืฉืžืจื”":phone?"":st==="declined"?"ื”ื‘ืงืฉื” ื ื“ื—ืชื” ื‘ื˜ืœืคื•ืŸ":st==="expired"?"ื‘ืงืฉืช ื”ืฆื™ืœื•ื ืคื’ื”":"ืขื•ื“ ืœื ืฆื•ืœืžื” ืชืฉื•ื‘ื”"}</p>
907
+ <span class="mx-chips">${window.MathlyBoard?`<button class="mx-btn mx-board-only" data-uq-board="${i}"${busy?" disabled":""}>โœ๏ธ ืคืชืจื•ืŸ ื‘ืœื•ื—</button>`:""}${phoneBtn}<button class="mx-btn${has||!touch?"":" solid"}" data-uq-photo="${i}"${busy?" disabled":""}>${has&&touch?"ืฆื™ืœื•ื ืžื—ื“ืฉ":touch?"ืฆื™ืœื•ื ื”ืชืฉื•ื‘ื”":"ื”ืขืœืืช ืชืžื•ื ื” ืžื”ืžื—ืฉื‘"}</button></span>`;
908
  const done=Object.values(UQ.res).filter(x=>x?.attachment).length, n=uqState(UQ.uid).sitting.attempts.length;
909
  const c=sheet.querySelector("[data-uq-count]"); if(c) c.textContent=`ืฆื•ืœืžื• ${done} ืžืชื•ืš ${n} ืชืฉื•ื‘ื•ืช`;
910
  }
 
1018
  function onClick(e){
1019
  const t=e.target.closest("button,[data-act]"); if(!t||!root.contains(t)) return;
1020
  if(t.dataset.choice!=null) return answer(t);
1021
+ if(t.dataset.boardToggle!=null) return window.MathlyBoard?.toggle();
1022
  if(t.dataset.quizUnit) return quizToggle(t.dataset.quizUnit);
1023
  if(t.dataset.quizGo!=null) return quizGo(+t.dataset.quizGo);
1024
  if(t.dataset.uquiz) return uqIntro(t.dataset.uquiz); // a sitting in progress opens straight on its paper
 
1026
  if(t.dataset.uqPhoto!=null){ UQ.pick=+t.dataset.uqPhoto; return sheet.querySelector("[data-uq-file]").click(); }
1027
  if(t.dataset.uqPhone!=null) return uqPhone(+t.dataset.uqPhone);
1028
  if(t.dataset.uqCancel!=null) return uqCancel(+t.dataset.uqCancel);
1029
+ if(t.dataset.uqBoard!=null) return window.MathlyBoard?.show(+t.dataset.uqBoard);
1030
  if(t.dataset.subject) return selectSubject(t.dataset.subject);
1031
  if(t.dataset.view){ closeSheet(); return setView(t.dataset.view,t.dataset.unit); }
1032
  if(t.dataset.comet) return showComet(t.dataset.comet);
tests/test_board_browser.py ADDED
@@ -0,0 +1,262 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Browser checks of the sketch board beside a skill's exercise (static/math-board.js).
2
+ Optional, like the other browser tests: pip install playwright && playwright install chromium."""
3
+ import re
4
+ import socket
5
+ import threading
6
+ import time
7
+
8
+ import pytest
9
+ import uvicorn
10
+
11
+ from app.main import create_app
12
+
13
+ playwright = pytest.importorskip("playwright.sync_api")
14
+
15
+
16
+ @pytest.fixture(scope="module")
17
+ def server():
18
+ app = create_app()
19
+ with socket.socket() as sock:
20
+ sock.bind(("127.0.0.1", 0))
21
+ srv = uvicorn.Server(uvicorn.Config(app, log_level="error"))
22
+ thread = threading.Thread(target=srv.run, kwargs={"sockets": [sock]}, daemon=True)
23
+ thread.start()
24
+ try:
25
+ deadline = time.monotonic() + 5
26
+ while not srv.started:
27
+ assert thread.is_alive() and time.monotonic() < deadline
28
+ time.sleep(.01)
29
+ yield f"http://127.0.0.1:{sock.getsockname()[1]}"
30
+ finally:
31
+ srv.should_exit = True
32
+ thread.join(timeout=5)
33
+
34
+
35
+ @pytest.fixture(scope="module")
36
+ def browser():
37
+ with playwright.sync_playwright() as p:
38
+ b = p.chromium.launch()
39
+ yield b
40
+ b.close()
41
+
42
+
43
+ def open_skill(browser, server, width, height):
44
+ ctx = browser.new_context(viewport={"width": width, "height": height})
45
+ ctx.route("https://cdn.jsdelivr.net/**", lambda route: route.abort())
46
+ ctx.route("https://fonts.googleapis.com/**", lambda route: route.abort())
47
+ ctx.add_init_script("try{localStorage.setItem('mathly.sound','off')}catch{}")
48
+ page = ctx.new_page()
49
+ errors = []
50
+ page.on("pageerror", lambda e: errors.append(str(e)))
51
+ page.goto(server)
52
+ playwright.expect(page.locator(".mx-trail")).to_be_visible()
53
+ page.locator(".mx-star.st-next").click()
54
+ playwright.expect(page.locator(".mx-sheet")).to_be_visible()
55
+ return page, errors
56
+
57
+
58
+ def ink_pixels(page):
59
+ return page.evaluate("""()=>{ const c=document.querySelector('.mx-board canvas'), d=c.getContext('2d').getImageData(0,0,c.width,c.height).data;
60
+ let n=0; for(let i=3;i<d.length;i+=4) if(d[i]) n++; return n; }""")
61
+
62
+
63
+ def draw_line(page):
64
+ box = page.locator(".mx-board canvas").bounding_box()
65
+ x, y = box["x"] + 60, box["y"] + 60
66
+ page.mouse.move(x, y)
67
+ page.mouse.down()
68
+ for k in range(1, 12):
69
+ page.mouse.move(x + k * 15, y + k * 6)
70
+ page.mouse.up()
71
+
72
+
73
+ def test_board_off_by_default_and_hidden_on_a_phone(browser, server):
74
+ page, errors = open_skill(browser, server, 390, 844)
75
+ playwright.expect(page.locator("[data-board-toggle]")).to_be_hidden()
76
+ assert page.locator(".mx-board").count() == 0
77
+ assert errors == []
78
+
79
+
80
+ def test_board_turns_on_draws_undoes_and_is_remembered(browser, server):
81
+ page, errors = open_skill(browser, server, 1440, 900)
82
+ toggle = page.locator("[data-board-toggle]")
83
+ playwright.expect(toggle).to_be_visible()
84
+ playwright.expect(toggle).to_have_attribute("aria-pressed", "false")
85
+ toggle.click()
86
+ playwright.expect(page.locator(".mx-board")).to_be_visible()
87
+ playwright.expect(page.locator(".mx-sheet")).to_have_class(re.compile(r"\bwith-board\b"))
88
+
89
+ # the board and the exercise sit side by side, not on top of each other
90
+ board, sheet = page.locator(".mx-board").bounding_box(), page.locator(".mx-sheet").bounding_box()
91
+ assert board["x"] + board["width"] <= sheet["x"]
92
+
93
+ draw_line(page)
94
+ assert ink_pixels(page) > 50
95
+ page.keyboard.press("Control+z")
96
+ assert ink_pixels(page) == 0
97
+
98
+ draw_line(page)
99
+ page.locator(".mx-board [data-do=clear]").click()
100
+ assert ink_pixels(page) == 0
101
+ page.locator(".mx-board [data-do=undo]").click() # a clear can be undone
102
+ assert ink_pixels(page) > 50
103
+
104
+ # closed and opened again: the board is still on and the same question keeps its sketch
105
+ page.locator(".mx-x").click()
106
+ page.locator(".mx-star.st-next").click()
107
+ playwright.expect(page.locator(".mx-board")).to_be_visible()
108
+ assert ink_pixels(page) > 50
109
+
110
+ page.locator(".mx-board [data-do=hide]").click()
111
+ playwright.expect(page.locator(".mx-board")).to_have_count(0)
112
+ playwright.expect(page.locator("[data-board-toggle]")).to_have_attribute("aria-pressed", "false")
113
+ assert page.evaluate("localStorage.getItem('mathly.board')") == "off"
114
+ assert errors == []
115
+
116
+
117
+ def alpha_near(page, x, y):
118
+ """The most ink in the 3x3 board pixels around (x, y), in CSS pixels from the board's corner."""
119
+ return page.evaluate("""([x,y])=>{ const c=document.querySelector('.mx-board canvas'), d=devicePixelRatio||1;
120
+ const px=c.getContext('2d').getImageData(Math.round(x*d)-1,Math.round(y*d)-1,3,3).data; let m=0;
121
+ for(let i=3;i<px.length;i+=4) m=Math.max(m,px[i]); return m; }""", [x, y])
122
+
123
+
124
+ def drag(page, a, b, steps=10):
125
+ box = page.locator(".mx-board canvas").bounding_box()
126
+ page.mouse.move(box["x"] + a[0], box["y"] + a[1])
127
+ page.mouse.down()
128
+ for k in range(1, steps + 1):
129
+ page.mouse.move(box["x"] + a[0] + (b[0] - a[0]) * k / steps, box["y"] + a[1] + (b[1] - a[1]) * k / steps)
130
+ page.mouse.up()
131
+
132
+
133
+ def board_on(page):
134
+ page.locator("[data-board-toggle]").click()
135
+ playwright.expect(page.locator(".mx-board")).to_be_visible()
136
+
137
+
138
+ def test_lines_snap_circles_and_a_bigger_eraser(browser, server):
139
+ page, errors = open_skill(browser, server, 1440, 900)
140
+ board_on(page)
141
+
142
+ page.locator(".mx-board [data-tool=line]").click()
143
+ drag(page, (100, 100), (300, 100))
144
+ assert alpha_near(page, 200, 100) > 0
145
+ drag(page, (305, 108), (305, 300)) # starts near the first line's end: it starts exactly there
146
+ assert alpha_near(page, 300, 120) > 0 and alpha_near(page, 306, 120) == 0
147
+
148
+ page.locator(".mx-board [data-tool=circle]").click()
149
+ drag(page, (450, 300), (530, 300)) # from the center out to the radius
150
+ assert alpha_near(page, 450, 380) > 0 and alpha_near(page, 450, 300) == 0
151
+
152
+ # the eraser's sizes show only with the eraser; the big one wipes what the small one misses
153
+ playwright.expect(page.locator(".mx-board-sizes")).to_be_hidden()
154
+ page.locator(".mx-board [data-tool=eraser]").click()
155
+ playwright.expect(page.locator(".mx-board-sizes")).to_be_visible()
156
+ page.locator(".mx-board [data-eraser='16']").click()
157
+ drag(page, (150, 125), (250, 125))
158
+ assert alpha_near(page, 200, 100) > 0
159
+ page.locator(".mx-board [data-eraser='90']").click()
160
+ drag(page, (150, 125), (250, 125))
161
+ assert alpha_near(page, 200, 100) == 0
162
+ assert errors == []
163
+
164
+
165
+ def test_the_practice_exam_has_a_board_with_a_sketch_per_question(browser, server):
166
+ page, errors = open_skill(browser, server, 1440, 900)
167
+ page.locator(".mx-x").click()
168
+ page.locator(".mx-quiz-card").click()
169
+ page.locator("[data-act=quiz-start]").click()
170
+ playwright.expect(page.locator(".mx-quiz-sheet")).to_be_visible()
171
+ board_on(page)
172
+ draw_line(page)
173
+ assert ink_pixels(page) > 50
174
+ page.locator("[data-act=quiz-next]").click()
175
+ playwright.expect(page.locator(".mx-board")).to_be_visible() # still on for the next question, on a clean page
176
+ assert ink_pixels(page) == 0
177
+ page.locator("[data-act=quiz-prev]").click()
178
+ assert ink_pixels(page) > 50
179
+ assert errors == []
180
+
181
+
182
+ def test_elements_move_together_when_joined_and_can_be_deleted(browser, server):
183
+ page, errors = open_skill(browser, server, 1440, 900)
184
+ board_on(page)
185
+ page.locator(".mx-board [data-tool=line]").click()
186
+ drag(page, (100, 300), (300, 300))
187
+ drag(page, (302, 302), (200, 150)) # snaps to the first line's end
188
+ drag(page, (198, 148), (102, 298)) # and closes the triangle
189
+ page.locator(".mx-board [data-tool=circle]").click()
190
+ drag(page, (500, 250), (550, 250))
191
+
192
+ page.locator(".mx-board [data-tool=move]").click()
193
+ drag(page, (200, 300), (200, 400)) # grab the base: the whole triangle moves
194
+ assert alpha_near(page, 200, 300) == 0 and alpha_near(page, 200, 400) > 0
195
+ assert alpha_near(page, 200, 250) > 0 # the top corner came along (was at 150)
196
+ assert alpha_near(page, 550, 250) > 0 # the circle stayed
197
+
198
+ drag(page, (550, 250), (550, 450)) # the circle alone
199
+ assert alpha_near(page, 550, 250) == 0 and alpha_near(page, 550, 450) > 0
200
+ playwright.expect(page.locator(".mx-board-del")).to_be_visible()
201
+ page.keyboard.press("Delete")
202
+ assert alpha_near(page, 550, 450) == 0
203
+ page.keyboard.press("Control+z") # a delete can be undone, and so can a move
204
+ assert alpha_near(page, 550, 450) > 0
205
+ page.keyboard.press("Control+z")
206
+ assert alpha_near(page, 550, 250) > 0
207
+ assert errors == []
208
+
209
+
210
+ def test_the_eraser_cuts_a_pen_stroke_and_a_moved_shape_comes_whole(browser, server):
211
+ page, errors = open_skill(browser, server, 1440, 900)
212
+ board_on(page)
213
+ drag(page, (100, 200), (500, 200), steps=30) # a pen stroke
214
+ page.locator(".mx-board [data-tool=eraser]").click()
215
+ page.locator(".mx-board [data-eraser='40']").click()
216
+ drag(page, (300, 150), (300, 250)) # cut through the middle
217
+ assert alpha_near(page, 300, 200) == 0
218
+ assert alpha_near(page, 150, 200) > 0 and alpha_near(page, 450, 200) > 0
219
+
220
+ page.locator(".mx-board [data-tool=move]").click()
221
+ drag(page, (150, 200), (150, 400)) # the left piece is an element of its own
222
+ assert alpha_near(page, 150, 400) > 0 and alpha_near(page, 450, 200) > 0
223
+ assert errors == []
224
+
225
+
226
+ def test_the_question_is_copied_to_the_top_of_the_board(browser, server):
227
+ page, errors = open_skill(browser, server, 1440, 900)
228
+ board_on(page)
229
+ width = page.locator(".mx-board canvas").bounding_box()["width"]
230
+ assert alpha_near(page, width - 40, 30) == 0
231
+ page.locator(".mx-board [data-do=question]").click()
232
+ assert alpha_near(page, width - 40, 30) > 0 # a card at the top
233
+ playwright.expect(page.locator(".mx-board [data-tool=move]")).to_have_attribute("aria-pressed", "true")
234
+ drag(page, (width - 40, 30), (width - 40, 430)) # it moves like anything else
235
+ assert alpha_near(page, width - 40, 30) == 0 and alpha_near(page, width - 40, 430) > 0
236
+ page.locator(".mx-board-del").click()
237
+ assert ink_pixels(page) == 0
238
+ assert errors == []
239
+
240
+
241
+ def test_the_wheel_scrolls_an_endless_paper(browser, server):
242
+ page, errors = open_skill(browser, server, 1440, 900)
243
+ board_on(page)
244
+ drag(page, (100, 100), (300, 100))
245
+ box = page.locator(".mx-board canvas").bounding_box()
246
+ page.mouse.move(box["x"] + 300, box["y"] + 300)
247
+ page.mouse.wheel(0, -500) # never above the top
248
+ page.wait_for_timeout(100)
249
+ assert alpha_near(page, 200, 100) > 0
250
+ page.mouse.wheel(0, 2000)
251
+ page.wait_for_timeout(100)
252
+ assert alpha_near(page, 200, 100) == 0 # scrolled out of sight
253
+ playwright.expect(page.locator(".mx-board-top")).to_be_visible()
254
+ drag(page, (100, 300), (300, 300)) # drawn far down the paper
255
+ page.locator(".mx-board-top").click()
256
+ assert alpha_near(page, 200, 100) > 0 and alpha_near(page, 200, 300) == 0
257
+ page.mouse.move(box["x"] + 300, box["y"] + 300)
258
+ page.mouse.wheel(0, 2000)
259
+ page.wait_for_timeout(100)
260
+ assert alpha_near(page, 200, 300) > 0
261
+ playwright.expect(page.locator(".mx-board-send")).to_be_hidden() # handing in is the unit quiz's
262
+ assert errors == []
tests/test_unit_quiz.py CHANGED
@@ -216,3 +216,56 @@ def test_when_the_time_and_the_photo_minutes_are_over_the_quiz_is_handed_in(quiz
216
  assert len(calls) == 1 and not errors
217
  finally:
218
  browser.close()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
216
  assert len(calls) == 1 and not errors
217
  finally:
218
  browser.close()
219
+
220
+
221
+ def test_the_unit_quiz_has_a_board_with_a_tab_per_question(quiz_server):
222
+ from test_board_browser import draw_line, ink_pixels
223
+ url, _ = quiz_server
224
+ with playwright.sync_playwright() as p:
225
+ browser, page, errors = signed_in(p, url, mobile=False)
226
+ try:
227
+ page.set_viewport_size({"width": 1440, "height": 900})
228
+ page.locator('.mx-uquiz[data-uquiz="t"]').click()
229
+ page.locator("[data-act=uq-start]").click()
230
+ playwright.expect(page.locator(".mx-uq-paper .mx-uq-q")).to_have_count(4)
231
+ page.locator("[data-board-toggle]").click()
232
+ tabs = page.locator(".mx-board-pages [data-page]")
233
+ playwright.expect(tabs).to_have_count(4)
234
+ draw_line(page)
235
+ assert ink_pixels(page) > 50
236
+ tabs.nth(1).click()
237
+ assert ink_pixels(page) == 0
238
+ playwright.expect(tabs.nth(0)).to_have_class("has") # the tab with a sketch stands out
239
+ tabs.nth(0).click()
240
+ assert ink_pixels(page) > 50
241
+ assert errors == []
242
+ finally:
243
+ browser.close()
244
+
245
+
246
+ def test_a_unit_quiz_answer_is_handed_in_from_the_board(quiz_server):
247
+ from test_board_browser import draw_line
248
+ url, _ = quiz_server
249
+ with playwright.sync_playwright() as p:
250
+ browser, page, errors = signed_in(p, url, mobile=False)
251
+ try:
252
+ page.set_viewport_size({"width": 1440, "height": 900})
253
+ page.locator('.mx-uquiz[data-uquiz="t"]').click()
254
+ page.locator("[data-act=uq-start]").click()
255
+ page.locator('[data-uq-board="2"]').click() # each question opens the board on its own page
256
+ playwright.expect(page.locator(".mx-board")).to_be_visible()
257
+ playwright.expect(page.locator('.mx-board-pages [data-page="2"]')).to_have_attribute("aria-selected", "true")
258
+ send = page.locator(".mx-board-send")
259
+ playwright.expect(send).to_have_text("๐Ÿ“คื”ื’ืฉื” ืœืฉืืœื” 3")
260
+ send.click() # an empty page is not handed in
261
+ playwright.expect(send).to_contain_text("ื”ืœื•ื— ืจื™ืง")
262
+ draw_line(page)
263
+ page.wait_for_timeout(2600)
264
+ send.click()
265
+ playwright.expect(page.locator('[data-uq-i="2"] [role=status]')).to_have_text("โœ“ ื”ืชืฉื•ื‘ื” ื ืฉืžืจื”", timeout=10000)
266
+ playwright.expect(page.locator('[data-uq-i="2"] img.mx-uq-img')).to_be_visible()
267
+ playwright.expect(send).to_contain_text("ื”ื•ื’ืฉื” ืœืฉืืœื” 3")
268
+ playwright.expect(page.locator("[data-uq-count]")).to_have_text("ืฆื•ืœืžื• 1 ืžืชื•ืš 4 ืชืฉื•ื‘ื•ืช")
269
+ assert errors == []
270
+ finally:
271
+ browser.close()