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 +1 -0
- static/math-board.js +379 -0
- static/math.css +50 -0
- static/math.js +18 -6
- tests/test_board_browser.py +262 -0
- tests/test_unit_quiz.py +53 -0
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=()=>
|
| 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
|
|
|
|
| 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()
|