fix math
Browse files- app/answer_routes.py +1 -1
- app/main.py +4 -1
- app/math_algebra9.json +0 -0
- app/math_skills.py +55 -30
- static/math.css +18 -0
- static/math.js +92 -41
- tests/test_answer_browser.py +4 -3
- tests/test_answer_scans.py +1 -0
- tests/test_math_skills.py +36 -6
app/answer_routes.py
CHANGED
|
@@ -63,7 +63,7 @@ def install_answer_routes(app, signed_in):
|
|
| 63 |
@app.post("/api/math/answer-attempts")
|
| 64 |
def resume_attempt(body: dict, user=Depends(student)):
|
| 65 |
question = next((s for s in algebra9()["skills"] if s["id"] == body.get("question_id")), None)
|
| 66 |
-
if not question or question
|
| 67 |
raise ScanError(404, "not_found")
|
| 68 |
sample = question["sample"]
|
| 69 |
version = question_version(sample)
|
|
|
|
| 63 |
@app.post("/api/math/answer-attempts")
|
| 64 |
def resume_attempt(body: dict, user=Depends(student)):
|
| 65 |
question = next((s for s in algebra9()["skills"] if s["id"] == body.get("question_id")), None)
|
| 66 |
+
if not question or question.get("sample", {}).get("type") != "open-ended":
|
| 67 |
raise ScanError(404, "not_found")
|
| 68 |
sample = question["sample"]
|
| 69 |
version = question_version(sample)
|
app/main.py
CHANGED
|
@@ -33,8 +33,11 @@ load_dotenv(ROOT / ".env")
|
|
| 33 |
|
| 34 |
|
| 35 |
def units_of(row: dict) -> list[str]:
|
| 36 |
-
|
|
|
|
|
|
|
| 37 |
selected = set(row.get("lessons") or known)
|
|
|
|
| 38 |
return [unit for unit in known if unit in selected]
|
| 39 |
|
| 40 |
|
|
|
|
| 33 |
|
| 34 |
|
| 35 |
def units_of(row: dict) -> list[str]:
|
| 36 |
+
"""A class's units. A unit split off an older one (its `from`) comes with it, so saved classes keep their content."""
|
| 37 |
+
units = algebra9()["units"]
|
| 38 |
+
known = [unit["id"] for unit in units]
|
| 39 |
selected = set(row.get("lessons") or known)
|
| 40 |
+
selected |= {unit["id"] for unit in units if selected & set(unit.get("from", []))}
|
| 41 |
return [unit for unit in known if unit in selected]
|
| 42 |
|
| 43 |
|
app/math_algebra9.json
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
app/math_skills.py
CHANGED
|
@@ -1,11 +1,12 @@
|
|
| 1 |
"""The 9th-grade algebra skill graph for the math path (`math_algebra9.json`).
|
| 2 |
|
| 3 |
-
Each skill names its prerequisites and
|
| 4 |
-
open-ended samples name their type and revision and accept a photographed answer.
|
| 5 |
A mistake sends the student back to the skill it comes from (`back`), or keeps them on the same skill (`null`).
|
| 6 |
-
The units follow the Ministry of Education's 9th-grade teaching schedule for 2026/27 (`docs/prisa_9A.md`)
|
| 7 |
-
|
| 8 |
-
|
|
|
|
| 9 |
|
| 10 |
`problems` is run by the tests and on loading: a broken graph is refused before a student meets it.
|
| 11 |
"""
|
|
@@ -26,6 +27,43 @@ def question_version(sample: dict) -> str:
|
|
| 26 |
separators=(",", ":")).encode()).hexdigest()
|
| 27 |
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
def problems(graph: dict) -> list[str]:
|
| 30 |
"""What is wrong with a graph: unknown units, skills or mistakes, a cycle, a link implied by another,
|
| 31 |
a question without exactly one right answer, or a mistake that sends the student to a skill that is not before it."""
|
|
@@ -53,32 +91,16 @@ def problems(graph: dict) -> list[str]:
|
|
| 53 |
return [f"cycle: {cycle}"]
|
| 54 |
for s in skills.values():
|
| 55 |
out += [f"{s['id']}: {p} is implied by {q}" for p in s["prereqs"] for q in s["prereqs"] if p != q and p in below[q]]
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
if kind not in ("open-ended", "multiple-choice"):
|
| 59 |
-
out.append(f"{s['id']}: unknown question type {kind}")
|
| 60 |
continue
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
if not isinstance(sample.get("question"), str) or not sample["question"].strip():
|
| 65 |
-
out.append(f"{s['id']}: question text is required")
|
| 66 |
-
if type(sample.get("version")) is not int or sample["version"] < 1:
|
| 67 |
-
out.append(f"{s['id']}: positive question version is required")
|
| 68 |
continue
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
if len({c["text"] for c in choices}) != len(choices):
|
| 73 |
-
out.append(f"{s['id']}: two choices read the same")
|
| 74 |
-
for c in choices:
|
| 75 |
-
m = c.get("mistake")
|
| 76 |
-
if c.get("correct"):
|
| 77 |
-
continue
|
| 78 |
-
if m not in mistakes:
|
| 79 |
-
out.append(f"{s['id']}: unknown mistake {m}")
|
| 80 |
-
elif (back := mistakes[m]["back"]) and back != s["id"] and back not in below[s["id"]]:
|
| 81 |
-
out.append(f"{s['id']}: mistake {m} sends back to {back}, which is not before it")
|
| 82 |
out += [f"mistake {k}: unknown skill {m['back']}" for k, m in mistakes.items() if m["back"] and m["back"] not in skills]
|
| 83 |
return out
|
| 84 |
|
|
@@ -98,9 +120,12 @@ def practice_graph(answer_scans_enabled: bool) -> dict:
|
|
| 98 |
The original choice sample is served when authenticated storage is disabled.
|
| 99 |
"""
|
| 100 |
graph = copy.deepcopy(algebra9())
|
|
|
|
| 101 |
for skill in graph["skills"]:
|
|
|
|
| 102 |
fallback = skill.pop("fallback_sample", None)
|
| 103 |
if not answer_scans_enabled and fallback is not None:
|
| 104 |
skill["sample"] = fallback
|
| 105 |
-
|
|
|
|
| 106 |
return graph
|
|
|
|
| 1 |
"""The 9th-grade algebra skill graph for the math path (`math_algebra9.json`).
|
| 2 |
|
| 3 |
+
Each skill names its prerequisites and either one `sample` question or a list of multiple-choice `questions`.
|
| 4 |
+
Choice samples identify mistakes; open-ended samples name their type and revision and accept a photographed answer.
|
| 5 |
A mistake sends the student back to the skill it comes from (`back`), or keeps them on the same skill (`null`).
|
| 6 |
+
The units follow the Ministry of Education's 9th-grade teaching schedule for 2026/27 (`docs/prisa_9A.md`).
|
| 7 |
+
Units already split by the schedule's topics (ืืื ืืงื ืืืืืจืืช, ืคืื ืงืฆืื ืจืืืืขืืช) have ten questions per skill;
|
| 8 |
+
the other units still have ten skills with one question each. `month` is when the schedule teaches a skill.
|
| 9 |
+
The questions are written for the app, and every skill, link and mistake still needs a teacher's review.
|
| 10 |
|
| 11 |
`problems` is run by the tests and on loading: a broken graph is refused before a student meets it.
|
| 12 |
"""
|
|
|
|
| 27 |
separators=(",", ":")).encode()).hexdigest()
|
| 28 |
|
| 29 |
|
| 30 |
+
def questions(skill: dict) -> list[dict]:
|
| 31 |
+
"""A skill's questions: its list of questions, or its one sample."""
|
| 32 |
+
return skill["questions"] if "questions" in skill else [skill["sample"]]
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def sample_problems(where: str, sid: str, sample: dict, listed: bool, mistakes: dict, below: dict) -> list[str]:
|
| 36 |
+
kind = sample.get("type", "multiple-choice")
|
| 37 |
+
if kind not in ("open-ended", "multiple-choice"):
|
| 38 |
+
return [f"{where}: unknown question type {kind}"]
|
| 39 |
+
if kind == "open-ended":
|
| 40 |
+
if listed:
|
| 41 |
+
return [f"{where}: a question list holds multiple-choice questions only"]
|
| 42 |
+
out = []
|
| 43 |
+
if "choices" in sample:
|
| 44 |
+
out.append(f"{where}: open-ended questions cannot have choices")
|
| 45 |
+
if not isinstance(sample.get("question"), str) or not sample["question"].strip():
|
| 46 |
+
out.append(f"{where}: question text is required")
|
| 47 |
+
if type(sample.get("version")) is not int or sample["version"] < 1:
|
| 48 |
+
out.append(f"{where}: positive question version is required")
|
| 49 |
+
return out
|
| 50 |
+
out = []
|
| 51 |
+
choices = sample.get("choices", [])
|
| 52 |
+
if sum(bool(c.get("correct")) for c in choices) != 1:
|
| 53 |
+
out.append(f"{where}: needs exactly one correct choice")
|
| 54 |
+
if len({c["text"] for c in choices}) != len(choices):
|
| 55 |
+
out.append(f"{where}: two choices read the same")
|
| 56 |
+
for c in choices:
|
| 57 |
+
m = c.get("mistake")
|
| 58 |
+
if c.get("correct"):
|
| 59 |
+
continue
|
| 60 |
+
if m not in mistakes:
|
| 61 |
+
out.append(f"{where}: unknown mistake {m}")
|
| 62 |
+
elif (back := mistakes[m]["back"]) and back != sid and back not in below[sid]:
|
| 63 |
+
out.append(f"{where}: mistake {m} sends back to {back}, which is not before it")
|
| 64 |
+
return out
|
| 65 |
+
|
| 66 |
+
|
| 67 |
def problems(graph: dict) -> list[str]:
|
| 68 |
"""What is wrong with a graph: unknown units, skills or mistakes, a cycle, a link implied by another,
|
| 69 |
a question without exactly one right answer, or a mistake that sends the student to a skill that is not before it."""
|
|
|
|
| 91 |
return [f"cycle: {cycle}"]
|
| 92 |
for s in skills.values():
|
| 93 |
out += [f"{s['id']}: {p} is implied by {q}" for p in s["prereqs"] for q in s["prereqs"] if p != q and p in below[q]]
|
| 94 |
+
if ("questions" in s) == ("sample" in s):
|
| 95 |
+
out.append(f"{s['id']}: needs a sample or a list of questions, not both")
|
|
|
|
|
|
|
| 96 |
continue
|
| 97 |
+
listed = "questions" in s
|
| 98 |
+
if listed and (not isinstance(s["questions"], list) or not s["questions"]):
|
| 99 |
+
out.append(f"{s['id']}: the question list is empty")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
continue
|
| 101 |
+
for n, sample in enumerate(questions(s), 1):
|
| 102 |
+
out += sample_problems(f"{s['id']} question {n}" if listed else s["id"], s["id"], sample, listed,
|
| 103 |
+
mistakes, below)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
out += [f"mistake {k}: unknown skill {m['back']}" for k, m in mistakes.items() if m["back"] and m["back"] not in skills]
|
| 105 |
return out
|
| 106 |
|
|
|
|
| 120 |
The original choice sample is served when authenticated storage is disabled.
|
| 121 |
"""
|
| 122 |
graph = copy.deepcopy(algebra9())
|
| 123 |
+
subject = {unit["id"]: unit["subject"] for unit in graph["units"]}
|
| 124 |
for skill in graph["skills"]:
|
| 125 |
+
skill["subject"] = subject[skill["unit"]] # a subunit belongs to its unit's subject
|
| 126 |
fallback = skill.pop("fallback_sample", None)
|
| 127 |
if not answer_scans_enabled and fallback is not None:
|
| 128 |
skill["sample"] = fallback
|
| 129 |
+
if "sample" in skill: # identifies an open-ended question's uploaded answers
|
| 130 |
+
skill["question_version"] = question_version(skill["sample"])
|
| 131 |
return graph
|
static/math.css
CHANGED
|
@@ -20,6 +20,9 @@ html.math-on, html.math-on body{overflow:hidden}
|
|
| 20 |
#mathView .u-a{--uc:var(--u-a)} #mathView .u-z{--uc:var(--u-z)} #mathView .u-r{--uc:var(--u-r)} #mathView .u-h{--uc:var(--u-h)}
|
| 21 |
#mathView .u-s{--uc:var(--u-s)} #mathView .u-m{--uc:var(--u-m)} #mathView .u-n{--uc:var(--u-n)} #mathView .u-p{--uc:var(--u-p)}
|
| 22 |
#mathView .u-w{--uc:var(--u-w)} #mathView .u-d{--uc:var(--u-d)}
|
|
|
|
|
|
|
|
|
|
| 23 |
#mathView h1,#mathView h2,#mathView h3{font-family:var(--f-display);font-weight:400;margin:0;text-wrap:balance}
|
| 24 |
#mathView button{font-family:var(--f-body)}
|
| 25 |
#mathView button:focus-visible{outline:3px solid var(--mx-accent);outline-offset:2px}
|
|
@@ -253,3 +256,18 @@ html.math-on, html.math-on body{overflow:hidden}
|
|
| 253 |
.mx-grade h4,.mx-grade p{margin:0}.mx-grade-correct{border-color:var(--ok);background:var(--ok-bg)}
|
| 254 |
.mx-grade summary{cursor:pointer;color:var(--mx-accent)}.mx-grade details p{margin-top:10px}
|
| 255 |
.mx-grade-solution{white-space:pre-wrap}.mx-grade small{color:var(--muted)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
#mathView .u-a{--uc:var(--u-a)} #mathView .u-z{--uc:var(--u-z)} #mathView .u-r{--uc:var(--u-r)} #mathView .u-h{--uc:var(--u-h)}
|
| 21 |
#mathView .u-s{--uc:var(--u-s)} #mathView .u-m{--uc:var(--u-m)} #mathView .u-n{--uc:var(--u-n)} #mathView .u-p{--uc:var(--u-p)}
|
| 22 |
#mathView .u-w{--uc:var(--u-w)} #mathView .u-d{--uc:var(--u-d)}
|
| 23 |
+
/* units split off an older unit keep its colour */
|
| 24 |
+
#mathView .u-i{--uc:var(--u-k)} #mathView .u-l,#mathView .u-o{--uc:var(--u-a)} #mathView .u-v{--uc:var(--u-e)}
|
| 25 |
+
#mathView .u-g{--uc:var(--u-z)} #mathView .u-b,#mathView .u-c{--uc:var(--u-m)}
|
| 26 |
#mathView h1,#mathView h2,#mathView h3{font-family:var(--f-display);font-weight:400;margin:0;text-wrap:balance}
|
| 27 |
#mathView button{font-family:var(--f-body)}
|
| 28 |
#mathView button:focus-visible{outline:3px solid var(--mx-accent);outline-offset:2px}
|
|
|
|
| 256 |
.mx-grade h4,.mx-grade p{margin:0}.mx-grade-correct{border-color:var(--ok);background:var(--ok-bg)}
|
| 257 |
.mx-grade summary{cursor:pointer;color:var(--mx-accent)}.mx-grade details p{margin-top:10px}
|
| 258 |
.mx-grade-solution{white-space:pre-wrap}.mx-grade small{color:var(--muted)}
|
| 259 |
+
.mx-qprog{display:flex;justify-content:space-between;align-items:center;gap:10px;font-size:13px;color:var(--muted)}
|
| 260 |
+
.mx-qdots{display:flex;gap:4px;flex-wrap:wrap}
|
| 261 |
+
.mx-qdots i{width:9px;height:9px;border-radius:50%;background:var(--line)}
|
| 262 |
+
.mx-qdots i.ok{background:var(--ok)}
|
| 263 |
+
.mx-qdots i.now{outline:2px solid var(--mx-accent);outline-offset:1px}
|
| 264 |
+
.mx-count{font:700 11px/1 var(--f-body);padding:4px 7px;border-radius:999px;background:var(--bg);color:var(--muted)}
|
| 265 |
+
.mx-content-subject{display:flex;flex-direction:column;gap:8px;margin-top:14px}
|
| 266 |
+
.mx-content-subject>h5{display:flex;align-items:baseline;gap:8px;margin:0;font-size:18px;color:var(--exam,var(--mx-accent))}
|
| 267 |
+
.mx-content-subject>h5 small{color:var(--muted);font-size:13px;font-weight:500}
|
| 268 |
+
.mx-content-unit{border:1px solid var(--line);border-radius:12px;background:var(--bg)}
|
| 269 |
+
.mx-content-unit summary{display:flex;flex-direction:column;gap:3px;padding:12px;cursor:pointer}
|
| 270 |
+
.mx-content-unit summary small,.mx-content-unit li small{color:var(--muted);font-size:12px}
|
| 271 |
+
.mx-content-unit ol{margin:0;padding:0 34px 12px 12px;display:flex;flex-direction:column;gap:8px}
|
| 272 |
+
.mx-content-unit li span{display:block}
|
| 273 |
+
.mx-pick-subject{margin:10px 0 6px;font-size:14px;color:var(--mx-accent)}
|
static/math.js
CHANGED
|
@@ -2,8 +2,10 @@
|
|
| 2 |
The skill graph comes from /api/math/skills (app/math_skills.py). A skill is open once every skill it needs is
|
| 3 |
done, and the first open one is where the student goes on. A student may still open a locked skill: without a
|
| 4 |
placement test, one who already knows the foundations should not have to answer them first.
|
| 5 |
-
|
| 6 |
-
|
|
|
|
|
|
|
| 7 |
(()=>{
|
| 8 |
const css=document.createElement("link"); css.rel="stylesheet"; css.href=document.currentScript.src.replace("math.js","math.css");
|
| 9 |
document.head.append(css);
|
|
@@ -23,7 +25,7 @@ const key=()=>"mathly.progress.v1:"+(ME_EMAIL||"guest");
|
|
| 23 |
const load=()=>{ try{ return JSON.parse(localStorage.getItem(key())||"{}"); }catch{ return {}; } };
|
| 24 |
const save=()=>{ try{ localStorage.setItem(key(),JSON.stringify(P)); }catch{} };
|
| 25 |
|
| 26 |
-
let G=null, BY={}, POST={},
|
| 27 |
let picker=null, PH=null; // the photo file picker, and the photographed problem on the sheet (see photo())
|
| 28 |
let advanceTimer=null;
|
| 29 |
let phonePair=null;
|
|
@@ -36,10 +38,16 @@ function cancelAdvance(){ clearTimeout(advanceTimer); advanceTimer=null; }
|
|
| 36 |
function index(graph){
|
| 37 |
G=graph; BY=Object.fromEntries(G.skills.map(s=>[s.id,s])); POST=Object.fromEntries(G.skills.map(s=>[s.id,[]]));
|
| 38 |
G.skills.forEach(s=>s.prereqs.forEach(p=>POST[p].push(s.id)));
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
}
|
| 44 |
// the units the student's class studies (its "lessons"); a teacher, or an account in no class, has every unit.
|
| 45 |
// A skill of a unit the class does not study counts as known: it does not lock the skills that need it.
|
|
@@ -50,6 +58,11 @@ const shownUnits=()=>{
|
|
| 50 |
let SHOWN=new Set();
|
| 51 |
const status=id=>P.done[id]?"done":BY[id].prereqs.every(p=>P.done[p]||!SHOWN.has(BY[p].unit))?"open":"locked";
|
| 52 |
const next=()=>ORDER.find(id=>SHOWN.has(BY[id].unit)&&status(id)==="open")||null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
async function show(){
|
| 55 |
document.documentElement.classList.add("math-on");
|
|
@@ -70,7 +83,7 @@ async function show(){
|
|
| 70 |
try{ const r=await fetch("/api/math/skills"); if(!r.ok) throw new Error(`ืืฉืจืช ืขื ื ${r.status}`); index(await r.json()); }
|
| 71 |
catch(e){ path.innerHTML=`<p class="mx-note">ืื ืืฆืืื ื ืืืขืื ืืช ืืืกืืื: ${esc(e.message)}. ืจืขื ื ื ืืช ืืขืืื ืื ืกื ืฉืื.</p>`; return; }
|
| 72 |
}
|
| 73 |
-
P=Object.assign({done:{},tries:{}},load()); SHOWN=new Set(shownUnits().map(u=>u.id));
|
| 74 |
subjectOptions().length?drawSubjectPicker():selectSubject("ืืชืืืืงื");
|
| 75 |
window.MathlyAnswers?.configure({api,account:CFG.userId,enabled:CFG.answerScans,root,onGraded:({questionId,grading})=>{
|
| 76 |
if(grading.result.verdict!=="correct"||P.done[questionId])return;
|
|
@@ -119,10 +132,10 @@ function draw(){
|
|
| 119 |
const ids=ORDER.filter(id=>BY[id].unit===u.id);
|
| 120 |
return `<section class="mx-unit u-${u.id}"><div class="mx-uhead"><h2>${esc(u.name)}</h2><span>${esc(u.source)}${u.when?" ยท "+esc(u.when):""}</span></div>
|
| 121 |
<div class="mx-trail">${ids.map((id,i)=>{
|
| 122 |
-
const s=BY[id], st=status(id), here=id===nxt;
|
| 123 |
return `<button class="mx-node st-${st}${here?" here":""}" data-id="${id}" aria-label="${esc(plain(s.name))}, ${st==="done"?"ืืืฉืืื":st==="open"?"ืคืชืืื":"ื ืขืืื"}">
|
| 124 |
<span class="mx-dot">${st==="done"?"โ":i+1}</span>
|
| 125 |
-
<span class="mx-lbl"><b>${fmt(s.name)}</b><small>${s.core?'<span class="mx-core">ืืืื 5 ืืืดื</span>':""}${here?'<span class="mx-go">ืืืฉืืืื ืืื</span>':""}</small></span></button>`;
|
| 126 |
}).join("")}</div></section>`;
|
| 127 |
}).join("");
|
| 128 |
path.innerHTML=`<header class="mx-top"><div><h1>ืืืกืืื ืฉืื</h1><p class="mx-sub">ืืืืืจื ืืืืืืืจืื ืืืชื ืืณ ยท 5 ืืืดื${cls}</p></div>
|
|
@@ -135,17 +148,19 @@ function draw(){
|
|
| 135 |
<button class="mx-photo" data-act="photo"><b>ืฆืืืื ืชืจืืื</b><small>ืฆืืื ืชืจืืื ืืืืืืจืช ืื ืืืืฃ, ืื ืขืืืจ ืืื ืืคืชืืจ ืืืชื ืฆืขื ืืืจ ืฆืขื</small></button>
|
| 136 |
<button class="mx-photo" data-act="phone"><b>ืฆืืืื ืืืืคืื, ืคืชืจืื ืืืืฉื</b><small>ืกืจืงื ืงืื ืืืืคืื, ืฆืืื ืชืจืืื ืืจืื ืืช ืืชืฉืืื ืืื ืืืืืืืืช</small></button>
|
| 137 |
${trail}
|
| 138 |
-
<p class="mx-note">
|
| 139 |
}
|
| 140 |
|
| 141 |
function chips(ids){
|
| 142 |
-
return ids.length?ids.map(id=>`<button class="mx-chip u-${BY[id].unit}${P.done[id]?" is-done":""}" data-id="${id}">${P.done[id]?"โ ":""}${
|
| 143 |
}
|
| 144 |
function open(id){
|
| 145 |
stopPhone();
|
| 146 |
cancelAdvance();
|
| 147 |
-
const s=BY[id], st=status(id), unit=G.units.find(u=>u.id===s.unit),
|
| 148 |
-
|
|
|
|
|
|
|
| 149 |
const choices=options.slice(rot).concat(options.slice(0,rot)), missing=s.prereqs.filter(p=>!P.done[p]&&SHOWN.has(BY[p].unit));
|
| 150 |
if(!cur) back=document.activeElement;
|
| 151 |
cur=id;
|
|
@@ -156,8 +171,9 @@ function open(id){
|
|
| 156 |
<button class="mx-x" data-act="close" aria-label="ืกืืืจื">โ</button></div>
|
| 157 |
<p>${fmt(s.description)}</p>
|
| 158 |
${st==="locked"?`<div class="mx-hint">ืืืืืฅ ืืขืืืจ ืงืืื ืขื: <span class="mx-chips">${chips(missing)}</span></div>`:""}
|
| 159 |
-
<div class="mx-q"><
|
| 160 |
-
|
|
|
|
| 161 |
<div class="mx-fb" aria-live="polite"></div></div>
|
| 162 |
<div class="mx-sec"><h4>ืืืจืฉืช</h4><div class="mx-chips">${chips(s.prereqs)}</div></div>
|
| 163 |
<div class="mx-sec"><h4>ืคืืชืืช</h4><div class="mx-chips">${chips(POST[id])}</div></div>
|
|
@@ -168,36 +184,54 @@ function open(id){
|
|
| 168 |
}
|
| 169 |
function closeSheet(){ stopPhone(); cancelAdvance(); if(sheet)sheet.hidden=true; cur=null; back?.focus?.({preventScroll:true}); back=null; }
|
| 170 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
function answer(btn){
|
| 172 |
if(btn.disabled) return;
|
| 173 |
-
const id=cur, m=btn.dataset.m, fb=sheet.querySelector(".mx-fb");
|
| 174 |
-
sheet.querySelectorAll("[data-choice]")
|
|
|
|
| 175 |
btn.classList.add(m?"wrong":"right");
|
| 176 |
P.tries[id]=(P.tries[id]||0)+1;
|
|
|
|
| 177 |
if(!m){
|
| 178 |
-
const
|
| 179 |
-
const
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
if(
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
if(cur!==id||sheet.hidden||root.hidden) return cancelAdvance();
|
| 188 |
-
if(seconds===0) return open(nxt);
|
| 189 |
-
countdown.textContent=`ืืชืจืืื ืืื ืืขืื ${seconds}โฆ`;
|
| 190 |
-
seconds--;
|
| 191 |
-
advanceTimer=setTimeout(tick,1000);
|
| 192 |
-
};
|
| 193 |
-
tick();
|
| 194 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
return;
|
| 196 |
}
|
| 197 |
save();
|
| 198 |
const M=G.mistakes[m], to=M.back&&M.back!==id?M.back:null;
|
|
|
|
|
|
|
| 199 |
fb.innerHTML=`<div class="mx-bad"><b>${fmt(M.name)}</b><span>${fmt(M.tip)}</span>
|
| 200 |
-
${
|
|
|
|
| 201 |
}
|
| 202 |
|
| 203 |
function onClick(e){
|
|
@@ -228,7 +262,7 @@ let MANAGED_CLASSES=[], MANAGE_MODE="classes", MANAGE_BUSY=false;
|
|
| 228 |
const manageTabs=[
|
| 229 |
["classes","ืืืชืืช","ืคืชืืื, ืขืจืืื ืืืืืงื"],
|
| 230 |
["students","ืชืืืืืื","ืจืฉืืื ืืืกื ื ืืืชืืช"],
|
| 231 |
-
["content","ืชืืื","
|
| 232 |
["flags","ื ืืืืจ","ืคืขืืืืช ืืืืืืื ืฆืืื ืื"],
|
| 233 |
["access","ืืจืฉืืืช","ืชืคืงืืืื ืืืืฉื"]
|
| 234 |
];
|
|
@@ -309,10 +343,7 @@ function showManageMode(mode){
|
|
| 309 |
box.innerHTML=`<div class="mx-manage-title"><h4>ืชืืืืืื</h4><span>${students.size} ืชืืืืืื ยท ${MANAGED_CLASSES.length} ืืืชืืช</span></div>${students.size?`<div class="mx-manage-list">${[...students].map(([email,classes])=>`<article><b dir="ltr">${esc(email)}</b><small>${esc(classes.join(" ยท "))}</small></article>`).join("")}</div>`:'<p class="mx-note">ืขืืืื ืื ื ืืกืคื ืชืืืืืื ืืืืชืืช.</p>'}`;
|
| 310 |
return;
|
| 311 |
}
|
| 312 |
-
if(mode==="content")
|
| 313 |
-
box.innerHTML=`<div class="mx-manage-title"><h4>ืชืืื ืืืืืื</h4><span>${G.units.length} ื ืืฉืืื ืืืกืืื</span></div><div class="mx-manage-list">${G.units.map(unit=>{const skills=G.skills.filter(skill=>skill.unit===unit.id);const count=MANAGED_CLASSES.filter(row=>(row.lessons||[]).includes(unit.id)).length;return `<article class="u-${unit.id}"><b>${esc(unit.name)}</b><small>${skills.length} ืืืืื ืืืืช ยท ืคืขืื ืึพ${count} ืืืชืืช</small></article>`}).join("")}</div>`;
|
| 314 |
-
return;
|
| 315 |
-
}
|
| 316 |
if(mode==="flags"){
|
| 317 |
const done=Object.keys(P.done||{}).length, tries=Object.values(P.tries||{}).reduce((sum,n)=>sum+(+n||0),0);
|
| 318 |
box.innerHTML=`<div class="mx-manage-title"><h4>ื ืืืืจ</h4><span>ืคืขืืืืช ืืืืืืื ืฆืืื ืื</span></div><div class="mx-manage-stats"><article><b>${MANAGED_CLASSES.length}</b><small>ืืืชืืช ืคืขืืืืช</small></article><article><b>${done}</b><small>ืืืืื ืืืืช ืฉืืืฉืืื ืืืืฉืืจ ืื</small></article><article><b>${tries}</b><small>ื ืืกืืื ืืช ืืืืฉืืจ ืื</small></article></div><p class="mx-note">ื ืชืื ื ืืชืงืืืืช ืืืชืชืืื ืืืคืืขื ืืื ืืืฉืจ ืฉืืจืืช ืืืืืื ืืฆืืื ืื ืืืืืจ.</p>`;
|
|
@@ -326,6 +357,26 @@ function showManageMode(mode){
|
|
| 326 |
box.innerHTML='<div class="mx-manage-title"><h4>ืืขืืื</h4><span>ืืืื ืืืืืงื ืืคืืชืื</span></div><p class="mx-note">ืืื ืืืขืืื ืืืชืืืืงื ืืชืืืกืคื ืืื. ืืคืฉืจ ืืืืืจ ืืื ืืืืจ ื ืืืื ืืืืจืืืกืืืช ืืืขืื.</p>';
|
| 327 |
}
|
| 328 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 329 |
function drawManagedClasses(box){
|
| 330 |
const classes=MANAGED_CLASSES;
|
| 331 |
box.innerHTML=`<div class="mx-manage-title"><h4>ืืืชืืช</h4><span>ืคืชืืื, ืขืจืืื ืืืืืงื</span></div>
|
|
@@ -354,7 +405,7 @@ function openClassEditor(row){
|
|
| 354 |
const selected=new Set(row?.lessons||G.units.map(unit=>unit.id));
|
| 355 |
box.innerHTML=`<div class="mx-manage-title"><h4>${creating?"ืืืชื ืืืฉื":`ืขืจืืืช ${esc(row.name)}`}</h4><button class="mx-btn" type="button" data-class-action="cancel">ืืืจื ืืืืชืืช</button></div>
|
| 356 |
<form class="mx-class-form"><label>ืฉื ืืืืชื<input name="name" required maxlength="80" autocomplete="off" placeholder="ืืืฉื: ืืณ 1" value="${esc(row?.name||"")}"></label>
|
| 357 |
-
<fieldset><legend>ืืืืืืช ืืืืืื<small>ืืชืืืืืื ืืจืื ืืืกืืื ืจืง ืืช ืืืืืื ืืืืช ืฉื ืืืืืืืช ืฉืกืืื ื. ืืงืฆืืข ืืืืืื ืงืืืข ืืืื ืืืื ืื ืืืฆืื ืืืกืืื.</small></legend><div class="mx-unit-picks">${G.units.map(unit=>`<label class="u-${unit.id}"><input type="checkbox" name="unit" value="${esc(unit.id)}"${selected.has(unit.id)?" checked":""}><span>${esc(unit.name)}<small>${
|
| 358 |
<label>ืืชืืืืืื<small>ืืชืืืช ืืืืดื ืืืช ืืื ืฉืืจื. ืชืืืื ืืืื ืืืฉืชืืื ืืืืชืจ ืืืืชื ืืืช.</small><textarea name="emails" dir="ltr" spellcheck="false" placeholder="student@example.com">${esc((row?.emails||[]).join("\n"))}</textarea></label>
|
| 359 |
<label>ืืืจื ืืืืชื<small>ืืชืืืช ืืืืดื ืืืช ืืื ืฉืืจื. ืืืืจืื ืชืืื ืืืฉื ืืืืชื ืืื.</small><textarea name="teachers" dir="ltr" spellcheck="false" placeholder="teacher@example.com">${esc((row?.teachers||[]).join("\n"))}</textarea></label>
|
| 360 |
<div class="mx-class-form-actions"><button class="mx-btn solid" type="submit">${creating?"ืคืชืืืช ืืืืชื":"ืฉืืืจืช ืืฉืื ืืืื"}</button><button class="mx-btn" type="button" data-class-action="cancel">ืืืืื</button><span role="status" aria-live="polite"></span></div></form>`;
|
|
|
|
| 2 |
The skill graph comes from /api/math/skills (app/math_skills.py). A skill is open once every skill it needs is
|
| 3 |
done, and the first open one is where the student goes on. A student may still open a locked skill: without a
|
| 4 |
placement test, one who already knows the foundations should not have to answer them first.
|
| 5 |
+
A skill has one sample question or a list of questions. It is done once every one of its questions has been
|
| 6 |
+
answered correctly; after each answer the student moves to the next unsolved question, so a missed question comes
|
| 7 |
+
back after the others. Progress is kept on this device, per account (mathly.progress.v1:<email>): `done` per
|
| 8 |
+
skill, and `solved` (indexes answered correctly) and `at` (the question to show) for skills with a list. */
|
| 9 |
(()=>{
|
| 10 |
const css=document.createElement("link"); css.rel="stylesheet"; css.href=document.currentScript.src.replace("math.js","math.css");
|
| 11 |
document.head.append(css);
|
|
|
|
| 25 |
const load=()=>{ try{ return JSON.parse(localStorage.getItem(key())||"{}"); }catch{ return {}; } };
|
| 26 |
const save=()=>{ try{ localStorage.setItem(key(),JSON.stringify(P)); }catch{} };
|
| 27 |
|
| 28 |
+
let G=null, BY={}, POST={}, ORDER=[], P={done:{},tries:{},solved:{},at:{}}, root=null, curQ=0, path=null, sheet=null, cur=null, back=null, ACTIVE_SUBJECT="";
|
| 29 |
let picker=null, PH=null; // the photo file picker, and the photographed problem on the sheet (see photo())
|
| 30 |
let advanceTimer=null;
|
| 31 |
let phonePair=null;
|
|
|
|
| 38 |
function index(graph){
|
| 39 |
G=graph; BY=Object.fromEntries(G.skills.map(s=>[s.id,s])); POST=Object.fromEntries(G.skills.map(s=>[s.id,[]]));
|
| 40 |
G.skills.forEach(s=>s.prereqs.forEach(p=>POST[p].push(s.id)));
|
| 41 |
+
// the path's order: unit by unit, and in a unit the order of the file (the teaching schedule's),
|
| 42 |
+
// except that a skill waits until the skills it needs in its unit are placed
|
| 43 |
+
ORDER=G.units.flatMap(u=>{
|
| 44 |
+
const left=G.skills.filter(s=>s.unit===u.id), placed=new Set(), out=[];
|
| 45 |
+
while(left.length){
|
| 46 |
+
const i=left.findIndex(s=>s.prereqs.every(p=>placed.has(p)||BY[p].unit!==u.id));
|
| 47 |
+
const [s]=left.splice(i,1); placed.add(s.id); out.push(s.id);
|
| 48 |
+
}
|
| 49 |
+
return out;
|
| 50 |
+
});
|
| 51 |
}
|
| 52 |
// the units the student's class studies (its "lessons"); a teacher, or an account in no class, has every unit.
|
| 53 |
// A skill of a unit the class does not study counts as known: it does not lock the skills that need it.
|
|
|
|
| 58 |
let SHOWN=new Set();
|
| 59 |
const status=id=>P.done[id]?"done":BY[id].prereqs.every(p=>P.done[p]||!SHOWN.has(BY[p].unit))?"open":"locked";
|
| 60 |
const next=()=>ORDER.find(id=>SHOWN.has(BY[id].unit)&&status(id)==="open")||null;
|
| 61 |
+
const QS=s=>s.questions||[s.sample];
|
| 62 |
+
const solvedOf=id=>new Set(P.solved[id]||[]);
|
| 63 |
+
// the question after `from` that is not solved yet; once all are solved, simply the next one
|
| 64 |
+
const nextQ=(id,from)=>{ const n=QS(BY[id]).length, got=solvedOf(id);
|
| 65 |
+
for(let k=1;k<=n;k++){ const j=(from+k)%n; if(!got.has(j)) return j; } return (from+1)%n; };
|
| 66 |
|
| 67 |
async function show(){
|
| 68 |
document.documentElement.classList.add("math-on");
|
|
|
|
| 83 |
try{ const r=await fetch("/api/math/skills"); if(!r.ok) throw new Error(`ืืฉืจืช ืขื ื ${r.status}`); index(await r.json()); }
|
| 84 |
catch(e){ path.innerHTML=`<p class="mx-note">ืื ืืฆืืื ื ืืืขืื ืืช ืืืกืืื: ${esc(e.message)}. ืจืขื ื ื ืืช ืืขืืื ืื ืกื ืฉืื.</p>`; return; }
|
| 85 |
}
|
| 86 |
+
P=Object.assign({done:{},tries:{},solved:{},at:{}},load()); SHOWN=new Set(shownUnits().map(u=>u.id));
|
| 87 |
subjectOptions().length?drawSubjectPicker():selectSubject("ืืชืืืืงื");
|
| 88 |
window.MathlyAnswers?.configure({api,account:CFG.userId,enabled:CFG.answerScans,root,onGraded:({questionId,grading})=>{
|
| 89 |
if(grading.result.verdict!=="correct"||P.done[questionId])return;
|
|
|
|
| 132 |
const ids=ORDER.filter(id=>BY[id].unit===u.id);
|
| 133 |
return `<section class="mx-unit u-${u.id}"><div class="mx-uhead"><h2>${esc(u.name)}</h2><span>${esc(u.source)}${u.when?" ยท "+esc(u.when):""}</span></div>
|
| 134 |
<div class="mx-trail">${ids.map((id,i)=>{
|
| 135 |
+
const s=BY[id], st=status(id), here=id===nxt, n=QS(s).length, k=solvedOf(id).size;
|
| 136 |
return `<button class="mx-node st-${st}${here?" here":""}" data-id="${id}" aria-label="${esc(plain(s.name))}, ${st==="done"?"ืืืฉืืื":st==="open"?"ืคืชืืื":"ื ืขืืื"}">
|
| 137 |
<span class="mx-dot">${st==="done"?"โ":i+1}</span>
|
| 138 |
+
<span class="mx-lbl"><b>${fmt(s.name)}</b><small>${s.core?'<span class="mx-core">ืืืื 5 ืืืดื</span>':""}${n>1&&st!=="done"&&k?`<span class="mx-count">${k}/${n}</span>`:""}${here?'<span class="mx-go">ืืืฉืืืื ืืื</span>':""}</small></span></button>`;
|
| 139 |
}).join("")}</div></section>`;
|
| 140 |
}).join("");
|
| 141 |
path.innerHTML=`<header class="mx-top"><div><h1>ืืืกืืื ืฉืื</h1><p class="mx-sub">ืืืืืจื ืืืืืืืจืื ืืืชื ืืณ ยท 5 ืืืดื${cls}</p></div>
|
|
|
|
| 148 |
<button class="mx-photo" data-act="photo"><b>ืฆืืืื ืชืจืืื</b><small>ืฆืืื ืชืจืืื ืืืืืืจืช ืื ืืืืฃ, ืื ืขืืืจ ืืื ืืคืชืืจ ืืืชื ืฆืขื ืืืจ ืฆืขื</small></button>
|
| 149 |
<button class="mx-photo" data-act="phone"><b>ืฆืืืื ืืืืคืื, ืคืชืจืื ืืืืฉื</b><small>ืกืจืงื ืงืื ืืืืคืื, ืฆืืื ืชืจืืื ืืจืื ืืช ืืชืฉืืื ืืื ืืืืืืืืช</small></button>
|
| 150 |
${trail}
|
| 151 |
+
<p class="mx-note">ืืืืื ืืช ืืกืืื ืช ืืืืฉืืื ืืฉืขืื ืื ื ืืื ืขื ืื ืืฉืืืืช ืฉืื. ืืืชืงืืืืช ื ืฉืืจืช ืืืืฉืืจ ืืื.</p>`;
|
| 152 |
}
|
| 153 |
|
| 154 |
function chips(ids){
|
| 155 |
+
return ids.length?ids.map(id=>`<button class="mx-chip u-${BY[id].unit}${P.done[id]?" is-done":""}" data-id="${id}">${P.done[id]?"โ ":""}${fmt(BY[id].short)}</button>`).join(""):'<em>ืืื</em>';
|
| 156 |
}
|
| 157 |
function open(id){
|
| 158 |
stopPhone();
|
| 159 |
cancelAdvance();
|
| 160 |
+
const s=BY[id], st=status(id), unit=G.units.find(u=>u.id===s.unit), all=QS(s), n=all.length;
|
| 161 |
+
curQ=Math.min(P.at[id]||0,n-1);
|
| 162 |
+
const q=all[curQ], openEnded=q.type==="open-ended", got=solvedOf(id);
|
| 163 |
+
const options=q.choices||[],rot=options.length?(ORDER.indexOf(id)+curQ)%options.length:0;
|
| 164 |
const choices=options.slice(rot).concat(options.slice(0,rot)), missing=s.prereqs.filter(p=>!P.done[p]&&SHOWN.has(BY[p].unit));
|
| 165 |
if(!cur) back=document.activeElement;
|
| 166 |
cur=id;
|
|
|
|
| 171 |
<button class="mx-x" data-act="close" aria-label="ืกืืืจื">โ</button></div>
|
| 172 |
<p>${fmt(s.description)}</p>
|
| 173 |
${st==="locked"?`<div class="mx-hint">ืืืืืฅ ืืขืืืจ ืงืืื ืขื: <span class="mx-chips">${chips(missing)}</span></div>`:""}
|
| 174 |
+
<div class="mx-q">${n>1?`<div class="mx-qprog"><span>ืฉืืื ${curQ+1} ืืชืื ${n}</span><span class="mx-qdots" aria-label="ื ืคืชืจื ${got.size} ืืชืื ${n}">${all.map((_,i)=>`<i class="${got.has(i)?"ok":""}${i===curQ?" now":""}"></i>`).join("")}</span></div>`:""}
|
| 175 |
+
<p>${fmt(q.question)}</p>${q.graph?plot(q.graph):""}${q.diagram?geometryDiagram(q.diagram):""}
|
| 176 |
+
${openEnded?'<div class="mx-answer"></div>':`<div class="mx-choices">${choices.map((c,i)=>`<button data-choice="${i}" data-m="${c.correct?"":esc(c.mistake)}"${c.correct?" data-right":""}>${fmt(c.text)}</button>`).join("")}</div>`}
|
| 177 |
<div class="mx-fb" aria-live="polite"></div></div>
|
| 178 |
<div class="mx-sec"><h4>ืืืจืฉืช</h4><div class="mx-chips">${chips(s.prereqs)}</div></div>
|
| 179 |
<div class="mx-sec"><h4>ืคืืชืืช</h4><div class="mx-chips">${chips(POST[id])}</div></div>
|
|
|
|
| 184 |
}
|
| 185 |
function closeSheet(){ stopPhone(); cancelAdvance(); if(sheet)sheet.hidden=true; cur=null; back?.focus?.({preventScroll:true}); back=null; }
|
| 186 |
|
| 187 |
+
// counts down, then opens `target` (a skill id); stops if the student moved on in the meantime
|
| 188 |
+
function autoAdvance(id,target){
|
| 189 |
+
let seconds=3;
|
| 190 |
+
const countdown=sheet.querySelector(".mx-countdown");
|
| 191 |
+
const tick=()=>{
|
| 192 |
+
if(cur!==id||sheet.hidden||root.hidden) return cancelAdvance();
|
| 193 |
+
if(seconds===0) return open(target);
|
| 194 |
+
countdown.textContent=target===id?`ืืฉืืื ืืืื ืืขืื ${seconds}โฆ`:`ืืชืจืืื ืืื ืืขืื ${seconds}โฆ`;
|
| 195 |
+
seconds--;
|
| 196 |
+
advanceTimer=setTimeout(tick,1000);
|
| 197 |
+
};
|
| 198 |
+
tick();
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
function answer(btn){
|
| 202 |
if(btn.disabled) return;
|
| 203 |
+
const id=cur, m=btn.dataset.m, fb=sheet.querySelector(".mx-fb"), n=QS(BY[id]).length, qi=curQ;
|
| 204 |
+
const buttons=sheet.querySelectorAll("[data-choice]");
|
| 205 |
+
buttons.forEach(b=>b.classList.remove("right","wrong"));
|
| 206 |
btn.classList.add(m?"wrong":"right");
|
| 207 |
P.tries[id]=(P.tries[id]||0)+1;
|
| 208 |
+
if(n>1) P.at[id]=nextQ(id,qi);
|
| 209 |
if(!m){
|
| 210 |
+
const got=solvedOf(id); got.add(qi); P.solved[id]=[...got].sort((a,b)=>a-b);
|
| 211 |
+
const finished=got.size>=n, first=finished&&!P.done[id];
|
| 212 |
+
if(finished) P.done[id]=P.done[id]||new Date().toISOString();
|
| 213 |
+
save(); draw();
|
| 214 |
+
buttons.forEach(b=>b.disabled=true);
|
| 215 |
+
if(!P.done[id]){
|
| 216 |
+
fb.innerHTML=`<div class="mx-ok"><b>ื ืืื!</b><span>ืคืชืจืชื ${got.size} ืืชืื ${n} ืฉืืืืช ืืืืืื ืืช ืืื.</span>
|
| 217 |
+
<span class="mx-countdown"></span><button class="mx-btn solid" data-id="${id}">ืืฉืืื ืืืื</button></div>`;
|
| 218 |
+
return autoAdvance(id,id);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
}
|
| 220 |
+
const nxt=next();
|
| 221 |
+
fb.innerHTML=`<div class="mx-ok"><b>ื ืืื!</b>${first?`<span>${n>1?`ืคืชืจืชื ืืช ืื ${n} ืืฉืืืืช. `:""}ืืืืืื ืืช ืกืืื ื ืืืืฉืืื.</span>`:""}
|
| 222 |
+
${nxt&&nxt!==id?`<span class="mx-countdown"></span><button class="mx-btn solid" data-id="${nxt}">ืืืฉื ืขืืฉืื: ${fmt(BY[nxt].short)}</button>`
|
| 223 |
+
:nxt?"":"<span>ืกืืืืชื ืืช ืื ืืืกืืื. ืื ืืืืื!</span>"}
|
| 224 |
+
${n>1?`<button class="mx-btn" data-id="${id}">ืขืื ืฉืืื ืืืืืื ืืช ืืื</button>`:""}</div>`;
|
| 225 |
+
if(nxt&&nxt!==id) autoAdvance(id,nxt);
|
| 226 |
return;
|
| 227 |
}
|
| 228 |
save();
|
| 229 |
const M=G.mistakes[m], to=M.back&&M.back!==id?M.back:null;
|
| 230 |
+
// with a list of questions, a missed question is shown later again; trying again now would not count
|
| 231 |
+
if(n>1) buttons.forEach(b=>{ b.disabled=true; if(b.dataset.right!=null) b.classList.add("right"); });
|
| 232 |
fb.innerHTML=`<div class="mx-bad"><b>${fmt(M.name)}</b><span>${fmt(M.tip)}</span>
|
| 233 |
+
${n>1?`<span class="mx-small">ืืฉืืื ืืื ืชืืืืจ ืืืจื ืฉืืจ ืืฉืืืืช.</span><button class="mx-btn solid" data-id="${id}">ืืฉืืื ืืืื</button>`:""}
|
| 234 |
+
${to?`<button class="mx-btn" data-id="${to}">ืืืจื ืื: ${fmt(BY[to].short)}</button>`:n>1?"":"<span class=\"mx-small\">ืืคืฉืจ ืืืืืจ ืชืฉืืื ืืืจืช.</span>"}</div>`;
|
| 235 |
}
|
| 236 |
|
| 237 |
function onClick(e){
|
|
|
|
| 262 |
const manageTabs=[
|
| 263 |
["classes","ืืืชืืช","ืคืชืืื, ืขืจืืื ืืืืืงื"],
|
| 264 |
["students","ืชืืืืืื","ืจืฉืืื ืืืกื ื ืืืชืืช"],
|
| 265 |
+
["content","ืชืืื","ืืงืฆืืขืืช, ืืืืืืช ืืชืชึพื ืืฉืืื"],
|
| 266 |
["flags","ื ืืืืจ","ืคืขืืืืช ืืืืืืื ืฆืืื ืื"],
|
| 267 |
["access","ืืจืฉืืืช","ืชืคืงืืืื ืืืืฉื"]
|
| 268 |
];
|
|
|
|
| 343 |
box.innerHTML=`<div class="mx-manage-title"><h4>ืชืืืืืื</h4><span>${students.size} ืชืืืืืื ยท ${MANAGED_CLASSES.length} ืืืชืืช</span></div>${students.size?`<div class="mx-manage-list">${[...students].map(([email,classes])=>`<article><b dir="ltr">${esc(email)}</b><small>${esc(classes.join(" ยท "))}</small></article>`).join("")}</div>`:'<p class="mx-note">ืขืืืื ืื ื ืืกืคื ืชืืืืืื ืืืืชืืช.</p>'}`;
|
| 344 |
return;
|
| 345 |
}
|
| 346 |
+
if(mode==="content") return drawContent(box);
|
|
|
|
|
|
|
|
|
|
| 347 |
if(mode==="flags"){
|
| 348 |
const done=Object.keys(P.done||{}).length, tries=Object.values(P.tries||{}).reduce((sum,n)=>sum+(+n||0),0);
|
| 349 |
box.innerHTML=`<div class="mx-manage-title"><h4>ื ืืืืจ</h4><span>ืคืขืืืืช ืืืืืืื ืฆืืื ืื</span></div><div class="mx-manage-stats"><article><b>${MANAGED_CLASSES.length}</b><small>ืืืชืืช ืคืขืืืืช</small></article><article><b>${done}</b><small>ืืืืื ืืืืช ืฉืืืฉืืื ืืืืฉืืจ ืื</small></article><article><b>${tries}</b><small>ื ืืกืืื ืืช ืืืืฉืืจ ืื</small></article></div><p class="mx-note">ื ืชืื ื ืืชืงืืืืช ืืืชืชืืื ืืืคืืขื ืืื ืืืฉืจ ืฉืืจืืช ืืืืืื ืืฆืืื ืื ืืืืืจ.</p>`;
|
|
|
|
| 357 |
box.innerHTML='<div class="mx-manage-title"><h4>ืืขืืื</h4><span>ืืืื ืืืืืงื ืืคืืชืื</span></div><p class="mx-note">ืืื ืืืขืืื ืืืชืืืืงื ืืชืืืกืคื ืืื. ืืคืฉืจ ืืืืืจ ืืื ืืืืจ ื ืืืื ืืืืจืืืกืืืช ืืืขืื.</p>';
|
| 358 |
}
|
| 359 |
|
| 360 |
+
// every subject a class meets: those with units, then those that so far only have exams
|
| 361 |
+
const contentSubjects=()=>[...new Set([...G.units.map(unit=>unit.subject),
|
| 362 |
+
...MANAGED_CLASSES.flatMap(row=>(row.exams||[]).map(exam=>exam.subject))].filter(Boolean))];
|
| 363 |
+
const unitSkills=id=>ORDER.filter(sid=>BY[sid].unit===id).map(sid=>BY[sid]);
|
| 364 |
+
const questionCount=skills=>skills.reduce((sum,skill)=>sum+QS(skill).length,0);
|
| 365 |
+
function drawContent(box){
|
| 366 |
+
const subjects=contentSubjects();
|
| 367 |
+
box.innerHTML=`<div class="mx-manage-title"><h4>ืชืืื ืืืืืื</h4><span>${subjects.length} ืืงืฆืืขืืช ยท ${G.units.length} ืืืืืืช ยท ${G.skills.length} ืชืชึพื ืืฉืืื ยท ${questionCount(G.skills)} ืฉืืืืช</span></div>
|
| 368 |
+
${subjects.map(subject=>{
|
| 369 |
+
const units=G.units.filter(unit=>unit.subject===subject), [icon,tone]=examLooks[subject]||["โ","other"];
|
| 370 |
+
return `<section class="mx-content-subject exam-${tone}"><h5><span aria-hidden="true">${icon}</span>${esc(subject)}<small>${units.length?`${units.length} ืืืืืืช`:"ืขืื ืืื ืืืืืืช ืืืกืืื"}</small></h5>
|
| 371 |
+
${units.map(unit=>{
|
| 372 |
+
const skills=unitSkills(unit.id), classes=MANAGED_CLASSES.filter(row=>(row.lessons||[]).includes(unit.id)).length;
|
| 373 |
+
return `<details class="mx-content-unit u-${unit.id}"><summary><b>${esc(unit.name)}</b>
|
| 374 |
+
<small>${unit.when?`${esc(unit.when)} ยท `:""}${skills.length} ืชืชึพื ืืฉืืื ยท ${questionCount(skills)} ืฉืืืืช ยท ืคืขืื ืึพ${classes} ืืืชืืช</small></summary>
|
| 375 |
+
<ol>${skills.map(skill=>`<li><span>${fmt(skill.name)}</span><small>${esc(skill.subject||subject)} โบ ${esc(unit.name)}${skill.month?` ยท ${esc(skill.month)}`:""} ยท ${QS(skill).length===1?"ืฉืืื ืืืช":`${QS(skill).length} ืฉืืืืช`}</small></li>`).join("")}</ol></details>`;
|
| 376 |
+
}).join("")}</section>`;
|
| 377 |
+
}).join("")}`;
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
function drawManagedClasses(box){
|
| 381 |
const classes=MANAGED_CLASSES;
|
| 382 |
box.innerHTML=`<div class="mx-manage-title"><h4>ืืืชืืช</h4><span>ืคืชืืื, ืขืจืืื ืืืืืงื</span></div>
|
|
|
|
| 405 |
const selected=new Set(row?.lessons||G.units.map(unit=>unit.id));
|
| 406 |
box.innerHTML=`<div class="mx-manage-title"><h4>${creating?"ืืืชื ืืืฉื":`ืขืจืืืช ${esc(row.name)}`}</h4><button class="mx-btn" type="button" data-class-action="cancel">ืืืจื ืืืืชืืช</button></div>
|
| 407 |
<form class="mx-class-form"><label>ืฉื ืืืืชื<input name="name" required maxlength="80" autocomplete="off" placeholder="ืืืฉื: ืืณ 1" value="${esc(row?.name||"")}"></label>
|
| 408 |
+
<fieldset><legend>ืืืืืืช ืืืืืื<small>ืืชืืืืืื ืืจืื ืืืกืืื ืจืง ืืช ืืืืืื ืืืืช ืฉื ืืืืืืืช ืฉืกืืื ื. ืืงืฆืืข ืืืืืื ืงืืืข ืืืื ืืืื ืื ืืืฆืื ืืืกืืื.</small></legend>${[...new Set(G.units.map(unit=>unit.subject))].map(subject=>`<h6 class="mx-pick-subject">${esc(subject)}</h6><div class="mx-unit-picks">${G.units.filter(unit=>unit.subject===subject).map(unit=>{const skills=unitSkills(unit.id);return `<label class="u-${unit.id}"><input type="checkbox" name="unit" value="${esc(unit.id)}"${selected.has(unit.id)?" checked":""}><span>${esc(unit.name)}<small>${skills.length} ืชืชึพื ืืฉืืื ยท ${questionCount(skills)} ืฉืืืืช</small></span></label>`}).join("")}</div>`).join("")}</fieldset>
|
| 409 |
<label>ืืชืืืืืื<small>ืืชืืืช ืืืืดื ืืืช ืืื ืฉืืจื. ืชืืืื ืืืื ืืืฉืชืืื ืืืืชืจ ืืืืชื ืืืช.</small><textarea name="emails" dir="ltr" spellcheck="false" placeholder="student@example.com">${esc((row?.emails||[]).join("\n"))}</textarea></label>
|
| 410 |
<label>ืืืจื ืืืืชื<small>ืืชืืืช ืืืืดื ืืืช ืืื ืฉืืจื. ืืืืจืื ืชืืื ืืืฉื ืืืืชื ืืื.</small><textarea name="teachers" dir="ltr" spellcheck="false" placeholder="teacher@example.com">${esc((row?.teachers||[]).join("\n"))}</textarea></label>
|
| 411 |
<div class="mx-class-form-actions"><button class="mx-btn solid" type="submit">${creating?"ืคืชืืืช ืืืืชื":"ืฉืืืจืช ืืฉืื ืืืื"}</button><button class="mx-btn" type="button" data-class-action="cancel">ืืืืื</button><span role="status" aria-live="polite"></span></div></form>`;
|
tests/test_answer_browser.py
CHANGED
|
@@ -109,7 +109,7 @@ def test_two_device_capture_retake_failure_retry_and_original_attempt(scan_serve
|
|
| 109 |
phone.locator('[data-submit]').click()
|
| 110 |
playwright.expect(computer.locator('.mx-answer [role=status]')).to_have_text('ืืขืืื ืืช ืืชืฉืืืโฆ', timeout=10000)
|
| 111 |
assert started.is_set()
|
| 112 |
-
computer.locator('.mx-x').click();computer.locator('.mx-node[data-id="
|
| 113 |
release.set()
|
| 114 |
playwright.expect(phone.locator('[data-status]')).to_contain_text('ืืืขืืชื ืืืฆืืื', timeout=10000)
|
| 115 |
assert computer.locator('.mx-answer-image').count() == 0
|
|
@@ -187,9 +187,10 @@ def test_success_response_loss_is_safe_to_retry(scan_server):
|
|
| 187 |
playwright.expect(phone.locator('[data-status]')).to_contain_text('ืืืขืืชื ืืืฆืืื')
|
| 188 |
with store.connect() as c:
|
| 189 |
assert c.execute('select count(*) from math_answer_attachments').fetchone()[0]==1
|
| 190 |
-
computer.locator('.mx-x').click();computer.locator('.mx-node[data-id="
|
| 191 |
computer.locator('.mx-choices button[data-m=""]').click()
|
| 192 |
-
|
|
|
|
| 193 |
finally:
|
| 194 |
browser.close()
|
| 195 |
|
|
|
|
| 109 |
phone.locator('[data-submit]').click()
|
| 110 |
playwright.expect(computer.locator('.mx-answer [role=status]')).to_have_text('ืืขืืื ืืช ืืชืฉืืืโฆ', timeout=10000)
|
| 111 |
assert started.is_set()
|
| 112 |
+
computer.locator('.mx-x').click();computer.locator('.mx-node[data-id="m_def"]').click()
|
| 113 |
release.set()
|
| 114 |
playwright.expect(phone.locator('[data-status]')).to_contain_text('ืืืขืืชื ืืืฆืืื', timeout=10000)
|
| 115 |
assert computer.locator('.mx-answer-image').count() == 0
|
|
|
|
| 187 |
playwright.expect(phone.locator('[data-status]')).to_contain_text('ืืืขืืชื ืืืฆืืื')
|
| 188 |
with store.connect() as c:
|
| 189 |
assert c.execute('select count(*) from math_answer_attachments').fetchone()[0]==1
|
| 190 |
+
computer.locator('.mx-x').click();computer.locator('.mx-node[data-id="m_def"]').click()
|
| 191 |
computer.locator('.mx-choices button[data-m=""]').click()
|
| 192 |
+
# one right answer of ten is recorded, but does not finish the skill
|
| 193 |
+
assert computer.evaluate("JSON.parse(localStorage.getItem('mathly.progress.v1:'+'%s@test.invalid')).solved.m_def.length===1" % OWNER)
|
| 194 |
finally:
|
| 195 |
browser.close()
|
| 196 |
|
tests/test_answer_scans.py
CHANGED
|
@@ -158,6 +158,7 @@ def test_scan_api_end_to_end_failure_retry_and_restart(answer_store, monkeypatch
|
|
| 158 |
import copy
|
| 159 |
from app.math_skills import algebra9, question_version
|
| 160 |
graph = copy.deepcopy(algebra9())
|
|
|
|
| 161 |
graph['skills'][0]['sample'] = {'type': 'open-ended', 'question': 'Draw a proof', 'version': 1}
|
| 162 |
monkeypatch.setattr('app.answer_routes.algebra9', lambda: graph)
|
| 163 |
monkeypatch.delenv('ANTHROPIC_API_KEY', raising=False)
|
|
|
|
| 158 |
import copy
|
| 159 |
from app.math_skills import algebra9, question_version
|
| 160 |
graph = copy.deepcopy(algebra9())
|
| 161 |
+
graph['skills'][0].pop('questions')
|
| 162 |
graph['skills'][0]['sample'] = {'type': 'open-ended', 'question': 'Draw a proof', 'version': 1}
|
| 163 |
monkeypatch.setattr('app.answer_routes.algebra9', lambda: graph)
|
| 164 |
monkeypatch.delenv('ANTHROPIC_API_KEY', raising=False)
|
tests/test_math_skills.py
CHANGED
|
@@ -9,6 +9,7 @@ from app.math_skills import algebra9, practice_graph, problems, question_version
|
|
| 9 |
|
| 10 |
def test_open_ended_samples_and_content_identity():
|
| 11 |
graph = copy.deepcopy(algebra9())
|
|
|
|
| 12 |
sample = graph["skills"][0]["sample"] = {"type": "open-ended", "version": 1,
|
| 13 |
"question": "Draw a triangle."}
|
| 14 |
assert problems(graph) == []
|
|
@@ -26,22 +27,37 @@ def test_open_ended_samples_and_content_identity():
|
|
| 26 |
|
| 27 |
def test_choice_samples_still_require_one_correct_choice():
|
| 28 |
graph = copy.deepcopy(algebra9())
|
| 29 |
-
graph["skills"][0]["
|
| 30 |
-
assert
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
|
| 33 |
def test_the_algebra_graph_is_whole():
|
| 34 |
graph = algebra9()
|
| 35 |
assert problems(graph) == []
|
| 36 |
-
assert len(graph["skills"]) ==
|
| 37 |
-
assert {unit["id"] for unit in graph["units"]} == set("
|
| 38 |
assert {unit["subject"] for unit in graph["units"]} == {"ืืชืืืืงื"}
|
| 39 |
|
| 40 |
|
| 41 |
def test_a_broken_graph_is_named():
|
| 42 |
graph = copy.deepcopy(algebra9())
|
| 43 |
skill = lambda sid: next(item for item in graph["skills"] if item["id"] == sid)
|
| 44 |
-
skill("t_ext")["prereqs"] = ["
|
| 45 |
assert problems(graph)[0].startswith("cycle:")
|
| 46 |
|
| 47 |
|
|
@@ -58,7 +74,7 @@ def test_math_is_the_only_page_and_api():
|
|
| 58 |
class FakeSolver:
|
| 59 |
def solve(self, image, media_type):
|
| 60 |
return {"readable": True, "problem": "$xยฒ = 9$", "steps": [{"hint": "h", "work": "w"}],
|
| 61 |
-
"answer": "$x = ยฑ3$", "skill": "
|
| 62 |
|
| 63 |
|
| 64 |
def test_a_photographed_problem_is_solved(monkeypatch):
|
|
@@ -83,3 +99,17 @@ def test_rollout_and_rollback_question_variants():
|
|
| 83 |
assert opened['question_version']==question_version(opened['sample'])
|
| 84 |
assert len(fallback['sample']['choices']) == 3
|
| 85 |
assert all('fallback_sample' not in s for s in enabled['skills'])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
def test_open_ended_samples_and_content_identity():
|
| 11 |
graph = copy.deepcopy(algebra9())
|
| 12 |
+
graph["skills"][0].pop("questions")
|
| 13 |
sample = graph["skills"][0]["sample"] = {"type": "open-ended", "version": 1,
|
| 14 |
"question": "Draw a triangle."}
|
| 15 |
assert problems(graph) == []
|
|
|
|
| 27 |
|
| 28 |
def test_choice_samples_still_require_one_correct_choice():
|
| 29 |
graph = copy.deepcopy(algebra9())
|
| 30 |
+
graph["skills"][0]["questions"][3]["choices"] = []
|
| 31 |
+
assert "t_ext question 4: needs exactly one correct choice" in problems(graph)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def test_question_lists():
|
| 35 |
+
graph = copy.deepcopy(algebra9())
|
| 36 |
+
skill = lambda sid: next(item for item in graph["skills"] if item["id"] == sid)
|
| 37 |
+
listed = [s for s in graph["skills"] if "questions" in s]
|
| 38 |
+
assert [s["id"] for s in graph["skills"] if "sample" in s] == ["a_30_side"] # the photographed answer
|
| 39 |
+
assert len(listed) == 95 and all(len(s["questions"]) == 10 for s in listed)
|
| 40 |
+
skill("t_ext")["sample"] = skill("t_ext")["questions"][0]
|
| 41 |
+
skill("t_gcf")["questions"] = []
|
| 42 |
+
skill("q_props")["questions"][0] = {"type": "open-ended", "version": 1, "question": "Sketch it."}
|
| 43 |
+
found = problems(graph)
|
| 44 |
+
assert "t_ext: needs a sample or a list of questions, not both" in found
|
| 45 |
+
assert "t_gcf: the question list is empty" in found
|
| 46 |
+
assert "q_props question 1: a question list holds multiple-choice questions only" in found
|
| 47 |
|
| 48 |
|
| 49 |
def test_the_algebra_graph_is_whole():
|
| 50 |
graph = algebra9()
|
| 51 |
assert problems(graph) == []
|
| 52 |
+
assert len(graph["skills"]) == 96
|
| 53 |
+
assert {unit["id"] for unit in graph["units"]} == set("tkiqalzevgrmhsnbcopwd")
|
| 54 |
assert {unit["subject"] for unit in graph["units"]} == {"ืืชืืืืงื"}
|
| 55 |
|
| 56 |
|
| 57 |
def test_a_broken_graph_is_named():
|
| 58 |
graph = copy.deepcopy(algebra9())
|
| 59 |
skill = lambda sid: next(item for item in graph["skills"] if item["id"] == sid)
|
| 60 |
+
skill("t_ext")["prereqs"] = ["t_formulas"]
|
| 61 |
assert problems(graph)[0].startswith("cycle:")
|
| 62 |
|
| 63 |
|
|
|
|
| 74 |
class FakeSolver:
|
| 75 |
def solve(self, image, media_type):
|
| 76 |
return {"readable": True, "problem": "$xยฒ = 9$", "steps": [{"hint": "h", "work": "w"}],
|
| 77 |
+
"answer": "$x = ยฑ3$", "skill": "e_way", "note": ""}
|
| 78 |
|
| 79 |
|
| 80 |
def test_a_photographed_problem_is_solved(monkeypatch):
|
|
|
|
| 99 |
assert opened['question_version']==question_version(opened['sample'])
|
| 100 |
assert len(fallback['sample']['choices']) == 3
|
| 101 |
assert all('fallback_sample' not in s for s in enabled['skills'])
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def test_each_subunit_names_its_units_subject():
|
| 105 |
+
graph = practice_graph(False)
|
| 106 |
+
subject = {unit["id"]: unit["subject"] for unit in graph["units"]}
|
| 107 |
+
assert all(skill["subject"] == subject[skill["unit"]] for skill in graph["skills"])
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def test_a_class_of_a_split_unit_keeps_its_content():
|
| 111 |
+
from app.main import units_of
|
| 112 |
+
assert units_of({"lessons": ["k"]}) == ["k", "i"] # ืืืชืื ืืืฉืืืฉ ืฉืืื ืฉืืงืืื
|
| 113 |
+
assert units_of({"lessons": ["m", "t"]}) == ["t", "m", "b", "c"] # ืืืื, ืืขืืื ืืจืืืืข
|
| 114 |
+
assert units_of({"lessons": ["i"]}) == ["i"]
|
| 115 |
+
assert units_of({}) == [unit["id"] for unit in algebra9()["units"]]
|