wzender Claude Opus 5.5 commited on
Commit
6958fef
·
1 Parent(s): 4a13aaf

After a quiz, exercise test or the simulation: answer the wrong questions again

Browse files

A mistakes round (kind mistakes_round) over the questions the handed-in attempt got wrong, in the lesson round's sheet: instant feedback,
never the right answer, counted as practice, the grade unchanged. Needs migration 20261122090000_m2_mistakes_round.sql.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

mathly2/rounds.py CHANGED
@@ -6,7 +6,11 @@ ROUND_SIZE from all of them. The questions are answered in order, each once, and
6
  for mastery and the daily goal; a wrong one goes to the review list). After the last answer the round is graded and closed.
7
 
8
  A lesson's stars: 3 when every skill is mastered, else 2 when a finished round had at least TWO_STARS percent right, else 1 when a round was
9
- finished, else 0. Nothing here ever says which choice was right."""
 
 
 
 
10
  from __future__ import annotations
11
 
12
  import logging
@@ -27,6 +31,8 @@ KIND = "lesson_round"
27
  ROUND_SIZE = 10
28
  PER_SKILL = 3 # three right in a row is what mastery needs, so a skill never takes more of a round
29
  TWO_STARS = 80 # percent right in a finished round for the second star
 
 
30
 
31
 
32
  class RoundAnswer(BaseModel):
@@ -102,10 +108,16 @@ def _lesson_stars(request: Request, uid: str, lesson_id: str, rows: list[dict])
102
  def view(request: Request, row: dict) -> dict:
103
  """An open round: its size, a result per answered question and the next question (without the right answer). A finished round: its result."""
104
  bank, store = request.app.state.bank, request.app.state.store
105
- lesson = next((l for l in bank.lessons if l["id"] == row["scope"]), {"id": row["scope"], "name": row["scope"]})
 
 
 
 
 
 
106
  done = _graded(bank, row)
107
- base = {"id": row["id"], "lesson": {"id": lesson["id"], "name": lesson["name"]}, "subject_id": lesson.get("subject_id", "math"), "total": len(row["questions"]),
108
- "results": [right for _, right, _ in done]}
109
  if attempts.is_open(row):
110
  nxt = row["questions"][len(done)] if len(done) < len(row["questions"]) else None
111
  served = bank.practice_question(nxt["skill_id"], nxt["key"]) if nxt else None
@@ -134,19 +146,50 @@ def view(request: Request, row: dict) -> dict:
134
  served = bank.practice_question(q["skill_id"], q["key"])
135
  missed.append({"question": served["question"] if served else "", "skill_name": (bank.skill(q["skill_id"]) or {}).get("name", ""),
136
  "mistake": {"name": mistake["name"], "tip": mistake["tip"]} if mistake else None})
137
- all_rows = store.attempts_of(uid, KIND, row["scope"])
138
  return {**base, "state": "done", "question": None,
139
- "result": {"right": sum(base["results"]), "total": base["total"], "grade": row["grade"], "stars": _lesson_stars(request, uid, row["scope"], all_rows),
140
  "mastered": mastered, "almost": almost, "missed": missed}}
141
 
142
 
143
  def _mine(request: Request, user: dict, round_id: str) -> dict:
144
  row = request.app.state.store.get_attempt(round_id)
145
- if row is None or row["user_id"] != user["id"] or row["kind"] != KIND:
146
  raise HTTPException(404, "הסבב לא נמצא")
147
  return row
148
 
149
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  @router.post("/lessons/{lesson_id}/round/start")
151
  def start(lesson_id: str, request: Request, user: dict = Depends(learner)) -> dict:
152
  """Resume the lesson's open round, else draw a new one."""
 
6
  for mastery and the daily goal; a wrong one goes to the review list). After the last answer the round is graded and closed.
7
 
8
  A lesson's stars: 3 when every skill is mastered, else 2 when a finished round had at least TWO_STARS percent right, else 1 when a round was
9
+ finished, else 0. Nothing here ever says which choice was right.
10
+
11
+ A mistakes round (חזרה על הטעויות, kind `mistakes_round`, scope: the handed-in attempt's id) is the same sheet over the questions a handed-in
12
+ quiz, exercise test or simulation got wrong (or left empty): answered again one by one with instant feedback, as practice. It has no stars and
13
+ never changes the attempt's grade."""
14
  from __future__ import annotations
15
 
16
  import logging
 
31
  ROUND_SIZE = 10
32
  PER_SKILL = 3 # three right in a row is what mastery needs, so a skill never takes more of a round
33
  TWO_STARS = 80 # percent right in a finished round for the second star
34
+ RETRY = "mistakes_round"
35
+ RETRY_OF = ("quiz", "exercise_test", "simulation")
36
 
37
 
38
  class RoundAnswer(BaseModel):
 
108
  def view(request: Request, row: dict) -> dict:
109
  """An open round: its size, a result per answered question and the next question (without the right answer). A finished round: its result."""
110
  bank, store = request.app.state.bank, request.app.state.store
111
+ source = _source_info(request, row) if row["kind"] == RETRY else None
112
+ if source:
113
+ first = bank.skill(row["questions"][0]["skill_id"]) if row["questions"] else None
114
+ lesson = next((l for l in bank.lessons if first and l["id"] == first["unit"]), {})
115
+ lesson = {"id": row["scope"], "name": source["title"], "subject_id": lesson.get("subject_id", "math")}
116
+ else:
117
+ lesson = next((l for l in bank.lessons if l["id"] == row["scope"]), {"id": row["scope"], "name": row["scope"]})
118
  done = _graded(bank, row)
119
+ base = {"id": row["id"], "kind": row["kind"], "lesson": {"id": lesson["id"], "name": lesson["name"]}, "subject_id": lesson.get("subject_id", "math"),
120
+ "total": len(row["questions"]), "results": [right for _, right, _ in done], "source": source}
121
  if attempts.is_open(row):
122
  nxt = row["questions"][len(done)] if len(done) < len(row["questions"]) else None
123
  served = bank.practice_question(nxt["skill_id"], nxt["key"]) if nxt else None
 
146
  served = bank.practice_question(q["skill_id"], q["key"])
147
  missed.append({"question": served["question"] if served else "", "skill_name": (bank.skill(q["skill_id"]) or {}).get("name", ""),
148
  "mistake": {"name": mistake["name"], "tip": mistake["tip"]} if mistake else None})
149
+ stars_now = None if source else _lesson_stars(request, uid, row["scope"], store.attempts_of(uid, KIND, row["scope"]))
150
  return {**base, "state": "done", "question": None,
151
+ "result": {"right": sum(base["results"]), "total": base["total"], "grade": row["grade"], "stars": stars_now,
152
  "mastered": mastered, "almost": almost, "missed": missed}}
153
 
154
 
155
  def _mine(request: Request, user: dict, round_id: str) -> dict:
156
  row = request.app.state.store.get_attempt(round_id)
157
+ if row is None or row["user_id"] != user["id"] or row["kind"] not in (KIND, RETRY):
158
  raise HTTPException(404, "הסבב לא נמצא")
159
  return row
160
 
161
 
162
+ def _source_info(request: Request, row: dict) -> dict:
163
+ """The handed-in attempt a mistakes round repeats: its kind, its test (if any) and a title."""
164
+ source = request.app.state.store.get_attempt(row["scope"])
165
+ kind = source["kind"] if source else "quiz"
166
+ test_id = int(source["scope"].split(":")[0]) if source and kind != "quiz" and source["scope"].split(":")[0].isdigit() else None
167
+ name = {"quiz": "הבוחן", "exercise_test": "מבחן התרגול", "simulation": "מבחן הסימולציה"}[kind]
168
+ return {"id": row["scope"], "kind": kind, "test_id": test_id, "lesson_id": source["scope"] if source and kind == "quiz" else None,
169
+ "title": f"חזרה על הטעויות ב{name}"}
170
+
171
+
172
+ @router.post("/attempts/{attempt_id}/retry/start")
173
+ def start_retry(attempt_id: str, request: Request, user: dict = Depends(learner)) -> dict:
174
+ """A mistakes round over the questions this handed-in attempt got wrong (or left empty): resume the open one, else a new one."""
175
+ store, bank, uid = request.app.state.store, request.app.state.bank, user["id"]
176
+ source = store.get_attempt(attempt_id)
177
+ if source is None or source["user_id"] != uid or source["kind"] not in RETRY_OF:
178
+ raise HTTPException(404, "הבוחן לא נמצא")
179
+ if attempts.is_open(source):
180
+ raise HTTPException(409, "קודם מגישים, ואז חוזרים על הטעויות")
181
+ for row in store.attempts_of(uid, RETRY, attempt_id):
182
+ if attempts.is_open(row):
183
+ return view(request, row)
184
+ wrong = [{"skill_id": q["skill_id"], "key": q["key"]} for q in source["questions"]
185
+ if bank.practice_question(q["skill_id"], q["key"]) and not (bank.grade(q["skill_id"], q["key"], source["answers"].get(q["key"]))
186
+ if isinstance(source["answers"].get(q["key"]), int) else {"correct": False}).get("correct")]
187
+ if not wrong:
188
+ raise HTTPException(409, "אין שאלות לא נכונות לחזור עליהן")
189
+ now = clock.now()
190
+ return view(request, store.create_attempt(uid, RETRY, attempt_id, now, now + timedelta(days=365), wrong))
191
+
192
+
193
  @router.post("/lessons/{lesson_id}/round/start")
194
  def start(lesson_id: str, request: Request, user: dict = Depends(learner)) -> dict:
195
  """Resume the lesson's open round, else draw a new one."""
mathly2/static/quiz.js CHANGED
@@ -242,7 +242,7 @@ function renderResult() {
242
  ${m.picked ? `<p class="muted">בחרתם: <span dir="auto">${fmt(m.picked)}</span></p>` : `<p class="muted">לא ענו על השאלה.</p>`}
243
  ${m.mistake ? `<p><b>${esc(m.mistake.name)}</b>${m.mistake.tip ? `<br>${fmt(m.mistake.tip)}` : ""}</p>` : ""}</li>`).join("")}</ol>` : ""}
244
  ${reviewHtml(r)}
245
- <p class="qz-actions">${names(r).again ? `<button type="button" class="primary" id="qz-retake">${names(r).again}</button> ` : ""}<button type="button" data-close>${r.kind === "quiz" ? "חזרה למסלול" : "חזרה למבחן"}</button></p>`;
246
  if (r.review?.state === "pending") waitForReview(r.id);
247
  const best = r.kind === "quiz" ? api(`/api/lessons/${encodeURIComponent(r.lesson.id)}/quiz`).then(f => f.best === null ? "" : `הציון של השיעור: ${f.best}`)
248
  : r.kind === "exercise_test" ? api(`/api/tests/${encodeURIComponent(r.test.id)}`).then(t => `הציון הטוב במבחן התרגול: ${t.exercise_tests[r.test.number - 1].best ?? ""}`) : Promise.resolve("");
 
242
  ${m.picked ? `<p class="muted">בחרתם: <span dir="auto">${fmt(m.picked)}</span></p>` : `<p class="muted">לא ענו על השאלה.</p>`}
243
  ${m.mistake ? `<p><b>${esc(m.mistake.name)}</b>${m.mistake.tip ? `<br>${fmt(m.mistake.tip)}` : ""}</p>` : ""}</li>`).join("")}</ol>` : ""}
244
  ${reviewHtml(r)}
245
+ <p class="qz-actions">${r.missed.length ? `<a class="button primary" id="qz-retry" href="#/retry/${esc(r.id)}">🔁 חזרה על השאלות שטעיתי (${r.missed.length})</a> ` : ""}${names(r).again ? `<button type="button" class="${r.missed.length ? "" : "primary"}" id="qz-retake">${names(r).again}</button> ` : ""}<button type="button" data-close>${r.kind === "quiz" ? "חזרה למסלול" : "חזרה למבחן"}</button></p>`;
246
  if (r.review?.state === "pending") waitForReview(r.id);
247
  const best = r.kind === "quiz" ? api(`/api/lessons/${encodeURIComponent(r.lesson.id)}/quiz`).then(f => f.best === null ? "" : `הציון של השיעור: ${f.best}`)
248
  : r.kind === "exercise_test" ? api(`/api/tests/${encodeURIComponent(r.test.id)}`).then(t => `הציון הטוב במבחן התרגול: ${t.exercise_tests[r.test.number - 1].best ?? ""}`) : Promise.resolve("");
mathly2/static/round.js CHANGED
@@ -1,10 +1,11 @@
1
  // A lesson round (סבב שיעור), opened from the home's map: a short round of the lesson's skills, one question at a time, with instant feedback
2
  // like the exercise screen. A wrong answer names the mistake and never marks the right choice. The last answer shows the lesson's stars.
 
3
  import { api, ApiError } from "./api.js";
4
  import { figures } from "./figures.js";
5
  import { esc, fmt } from "./math.js";
6
  import { loadBoard } from "./exercise.js";
7
- import { homeHref, shuffled } from "./ui.js";
8
  import { sendWorkQuietly } from "./boardwork.js";
9
 
10
  const COUNTDOWN = 2; // seconds before the next question after a right answer
@@ -20,6 +21,13 @@ export const isOpen = () => !!layer;
20
  /** Start the lesson's round (or resume the open one). Returns the round, so the router knows its subject. */
21
  export const start = lesson => api(`/api/lessons/${encodeURIComponent(lesson)}/round/start`, { method: "POST" });
22
 
 
 
 
 
 
 
 
23
  /** Show a round from `start` as a sheet over the home. */
24
  export async function show(data) {
25
  stop();
@@ -45,7 +53,7 @@ export function close() {
45
  layer = round = null;
46
  }
47
 
48
- const heading = () => `<div class="mx-shtop"><div><span class="ex-chip">סבב שיעור</span><h3 id="rd-title">${fmt(round.lesson.name)}</h3></div>
49
  <div class="mx-shacts">${window.MathlyBoard?.button() || ""}<button type="button" class="mx-x" data-close aria-label="סגירה">✕</button></div></div>`;
50
 
51
  const dots = () => Array.from({ length: round.total }, (_, i) => {
@@ -66,7 +74,21 @@ function questionHtml() {
66
  }
67
 
68
  /** The round's result: the stars big, the score, what was mastered, what is one good day away, and the mistakes (never the right answers). */
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  function resultHtml() {
 
70
  const r = round.result;
71
  const names = list => list.map(s => fmt(s.name)).join(", ");
72
  const title = r.stars === 3 ? "👑 השיעור מושלם!" : r.right === r.total ? "מושלם! כל התשובות נכונות" : r.right * 10 >= r.total * 8 ? "כל הכבוד!" : "סיימתם סבב!";
@@ -165,7 +187,7 @@ async function again() {
165
  const button = q("#rd-again");
166
  button.disabled = true;
167
  try {
168
- round = await start(round.lesson.id);
169
  streak = 0;
170
  render();
171
  } catch (error) {
@@ -176,7 +198,7 @@ async function again() {
176
 
177
  function onClick(event) {
178
  const target = event.target;
179
- if (target.closest("[data-close]")) { location.hash = homeHref(); return; }
180
  if (target.closest("[data-board-toggle]")) { window.MathlyBoard?.toggle(); return; }
181
  if (target.closest("#rd-again")) { again(); return; }
182
  const choice = target.closest(".ex-choice");
@@ -186,5 +208,5 @@ function onClick(event) {
186
  window.addEventListener("mathly:leave", close);
187
 
188
  document.addEventListener("keydown", event => {
189
- if (event.key === "Escape" && layer && !document.querySelector(".mx-board.full")) location.hash = homeHref();
190
  });
 
1
  // A lesson round (סבב שיעור), opened from the home's map: a short round of the lesson's skills, one question at a time, with instant feedback
2
  // like the exercise screen. A wrong answer names the mistake and never marks the right choice. The last answer shows the lesson's stars.
3
+ // The same sheet plays a mistakes round (חזרה על הטעויות): the questions a handed-in quiz, exercise test or simulation got wrong, with no stars.
4
  import { api, ApiError } from "./api.js";
5
  import { figures } from "./figures.js";
6
  import { esc, fmt } from "./math.js";
7
  import { loadBoard } from "./exercise.js";
8
+ import { homeHref, pathHref, shuffled } from "./ui.js";
9
  import { sendWorkQuietly } from "./boardwork.js";
10
 
11
  const COUNTDOWN = 2; // seconds before the next question after a right answer
 
21
  /** Start the lesson's round (or resume the open one). Returns the round, so the router knows its subject. */
22
  export const start = lesson => api(`/api/lessons/${encodeURIComponent(lesson)}/round/start`, { method: "POST" });
23
 
24
+ /** Start (or resume) the mistakes round of a handed-in attempt. */
25
+ export const startRetry = attemptId => api(`/api/attempts/${encodeURIComponent(attemptId)}/retry/start`, { method: "POST" });
26
+
27
+ const isRetry = () => !!round?.source;
28
+ /** Where closing leads: the home's map, or for a mistakes round its test's card (a quiz: the path). */
29
+ const leaveTo = () => round?.source ? (round.source.test_id ? `#/test/${round.source.test_id}` : pathHref()) : homeHref();
30
+
31
  /** Show a round from `start` as a sheet over the home. */
32
  export async function show(data) {
33
  stop();
 
53
  layer = round = null;
54
  }
55
 
56
+ const heading = () => `<div class="mx-shtop"><div><span class="ex-chip">${isRetry() ? "חזרה על הטעויות" : "סבב שיעור"}</span><h3 id="rd-title">${fmt(round.lesson.name)}</h3></div>
57
  <div class="mx-shacts">${window.MathlyBoard?.button() || ""}<button type="button" class="mx-x" data-close aria-label="סגירה">✕</button></div></div>`;
58
 
59
  const dots = () => Array.from({ length: round.total }, (_, i) => {
 
74
  }
75
 
76
  /** The round's result: the stars big, the score, what was mastered, what is one good day away, and the mistakes (never the right answers). */
77
+ function retryResultHtml() {
78
+ const r = round.result, all = r.right === r.total;
79
+ return `<section class="rd-result" id="rd-result" data-kind="retry">
80
+ <p class="rd-bigstars" aria-hidden="true">${all ? "🎉" : "💪"}</p>
81
+ <h4 class="rd-title">${all ? "כל הטעויות תוקנו!" : "סיימתם לחזור על הטעויות"}</h4>
82
+ <p class="ex-score" id="rd-score"><b>${r.right}</b> מתוך ${r.total} נכונות הפעם</p>
83
+ ${r.mastered.length ? `<p class="rd-mastered" id="rd-mastered">🎉 נשלטו: ${r.mastered.map(s => fmt(s.name)).join(", ")}</p>` : ""}
84
+ ${r.missed.length ? `<h4>עדיין כדאי לחזק</h4><ol class="ex-missed" id="rd-missed">${r.missed.map(m => `<li><p class="muted">${fmt(m.skill_name)}</p><p class="ex-text" dir="auto">${fmt(m.question)}</p>
85
+ ${m.mistake ? `<p><b>${esc(m.mistake.name)}</b>${m.mistake.tip ? `<br>${fmt(m.mistake.tip)}` : ""}</p>` : ""}</li>`).join("")}</ol>` : ""}
86
+ <p class="muted">הציון במבחן לא משתנה. התרגול נספר לשליטה במיומנויות.</p>
87
+ <p class="ex-actions">${all ? "" : `<button type="button" class="primary" id="rd-again">שוב על הטעויות</button> `}<button type="button" data-close>${round.source.test_id ? "חזרה למבחן" : "חזרה למסלול"}</button></p></section>`;
88
+ }
89
+
90
  function resultHtml() {
91
+ if (isRetry()) return retryResultHtml();
92
  const r = round.result;
93
  const names = list => list.map(s => fmt(s.name)).join(", ");
94
  const title = r.stars === 3 ? "👑 השיעור מושלם!" : r.right === r.total ? "מושלם! כל התשובות נכונות" : r.right * 10 >= r.total * 8 ? "כל הכבוד!" : "סיימתם סבב!";
 
187
  const button = q("#rd-again");
188
  button.disabled = true;
189
  try {
190
+ round = await (isRetry() ? startRetry(round.source.id) : start(round.lesson.id));
191
  streak = 0;
192
  render();
193
  } catch (error) {
 
198
 
199
  function onClick(event) {
200
  const target = event.target;
201
+ if (target.closest("[data-close]")) { location.hash = leaveTo(); return; }
202
  if (target.closest("[data-board-toggle]")) { window.MathlyBoard?.toggle(); return; }
203
  if (target.closest("#rd-again")) { again(); return; }
204
  const choice = target.closest(".ex-choice");
 
208
  window.addEventListener("mathly:leave", close);
209
 
210
  document.addEventListener("keydown", event => {
211
+ if (event.key === "Escape" && layer && !document.querySelector(".mx-board.full")) location.hash = leaveTo();
212
  });
mathly2/static/student.js CHANGED
@@ -24,7 +24,7 @@ export async function show(me, view) {
24
  phone.stopComputer(); // a page that was waiting for the phone's photo is left
25
  const raw = location.hash.replace(/^#/, "");
26
  const where = raw.startsWith("/") ? raw : "/"; // no address, or the sign-in service's "#access_token=…": the first screen
27
- if (!where.startsWith("/round/")) lessonRound.close();
28
  try {
29
  if (where.startsWith("/round/")) { // a lesson's round over the home (a link or a reload: the home first)
30
  exercise.close(); quiz.close();
@@ -32,6 +32,13 @@ export async function show(me, view) {
32
  setSubject(data.subject_id);
33
  if (!document.getElementById("lesson-map")) await home.show(view);
34
  await lessonRound.show(data);
 
 
 
 
 
 
 
35
  } else if (where.startsWith("/skill/")) {
36
  quiz.close();
37
  lastSkill = decodeURIComponent(where.slice(7));
 
24
  phone.stopComputer(); // a page that was waiting for the phone's photo is left
25
  const raw = location.hash.replace(/^#/, "");
26
  const where = raw.startsWith("/") ? raw : "/"; // no address, or the sign-in service's "#access_token=…": the first screen
27
+ if (!where.startsWith("/round/") && !where.startsWith("/retry/")) lessonRound.close();
28
  try {
29
  if (where.startsWith("/round/")) { // a lesson's round over the home (a link or a reload: the home first)
30
  exercise.close(); quiz.close();
 
32
  setSubject(data.subject_id);
33
  if (!document.getElementById("lesson-map")) await home.show(view);
34
  await lessonRound.show(data);
35
+ } else if (where.startsWith("/retry/")) { // the mistakes of a handed-in attempt, over its test's card (a quiz: the path)
36
+ exercise.close(); quiz.close();
37
+ const data = await lessonRound.startRetry(decodeURIComponent(where.slice(7)));
38
+ setSubject(data.subject_id);
39
+ if (data.source.test_id) { if (!document.getElementById("test-card")) await (await import("./testcard.js")).show(view, data.source.test_id); }
40
+ else if (!document.getElementById("path")) await path.show(view);
41
+ await lessonRound.show(data);
42
  } else if (where.startsWith("/skill/")) {
43
  quiz.close();
44
  lastSkill = decodeURIComponent(where.slice(7));
mathly2/supabase/migrations/20261122090000_m2_mistakes_round.sql ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ -- Mathly 2: a mistakes round (חזרה על הטעויות) after a quiz, an exercise test or the simulation: the questions it got wrong, answered again
2
+ -- with instant feedback as practice. Kept as m2_attempts rows of kind mistakes_round (scope: the handed-in attempt's id). One widened check.
3
+ -- Rollback: delete from public.m2_attempts where kind = 'mistakes_round';
4
+ -- alter table public.m2_attempts drop constraint m2_attempts_kind_check;
5
+ -- alter table public.m2_attempts add constraint m2_attempts_kind_check check (kind in ('quiz', 'exercise_test', 'simulation', 'lesson_round'));
6
+ begin;
7
+ alter table public.m2_attempts drop constraint m2_attempts_kind_check;
8
+ alter table public.m2_attempts add constraint m2_attempts_kind_check check (kind in ('quiz', 'exercise_test', 'simulation', 'lesson_round', 'mistakes_round'));
9
+ commit;
mathly2/tests/test_home_browser.py CHANGED
@@ -374,6 +374,34 @@ def test_the_simulation_with_nothing_practiced_offers_a_bypass_on_all_the_materi
374
  assert len(store.attempts_of(KID_ID, "simulation")) == 1
375
 
376
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
377
  # --- 3.5 the grades screen has exercise tests and simulations ---
378
 
379
  def test_exercise_tests_and_the_simulation_appear_on_the_grades_screen(browser, bank):
 
374
  assert len(store.attempts_of(KID_ID, "simulation")) == 1
375
 
376
 
377
+ def test_after_the_simulation_the_wrong_questions_are_answered_again_in_a_round(browser, bank):
378
+ app, store, klass, lessons = world(bank)
379
+ started_skills(store, bank, lessons)
380
+ test = add_test(store, klass, lessons, 4, questions=6, minutes=45)
381
+ with running(app) as url:
382
+ page = sign_in(browser, url, f"#/sim/{test['id']}")
383
+ page.wait_for_selector("#qz-q")
384
+ page.click("#qz-submit") # nothing answered: six mistakes
385
+ page.click("#qz-yes")
386
+ page.wait_for_selector("#qz-retry")
387
+ assert "(6)" in page.inner_text("#qz-retry")
388
+ page.click("#qz-retry")
389
+ page.wait_for_selector(".round-layer #ex-q")
390
+ assert "חזרה על הטעויות" in page.inner_text(".round-layer .ex-chip") and "מתוך 6" in page.inner_text("#rd-count")
391
+ for n in range(6):
392
+ page.locator(".round-layer .ex-choice").first.click()
393
+ page.wait_for_selector(".round-layer .ex-ok, .round-layer .ex-bad")
394
+ page.click("#rd-next")
395
+ if n < 5:
396
+ page.wait_for_function(f"document.querySelector('#rd-count').textContent.includes('{n + 2} מתוך')")
397
+ page.wait_for_selector("#rd-result[data-kind='retry']")
398
+ assert "הציון במבחן לא משתנה" in page.inner_text("#rd-result") and page.locator(".rd-bigstars span").count() == 0
399
+ assert store.attempts_of(KID_ID, "simulation")[0]["grade"] == 0
400
+ page.click("#rd-result [data-close]")
401
+ page.wait_for_selector("#test-card")
402
+ assert page.locator(".round-layer").count() == 0 and page.errors == []
403
+
404
+
405
  # --- 3.5 the grades screen has exercise tests and simulations ---
406
 
407
  def test_exercise_tests_and_the_simulation_appear_on_the_grades_screen(browser, bank):
mathly2/tests/test_permissions.py CHANGED
@@ -54,6 +54,7 @@ NEEDS = { # (method, path) -> permission
54
  ("POST", "/api/teaching/work/{attempt_id}"): "class.teach",
55
  ("POST", "/api/teaching/work/{attempt_id}/review"): "class.teach",
56
  ("POST", "/api/rounds/{round_id}/answer"): "path.use",
 
57
  ("POST", "/api/tests/{test_id}/exercise/{number}/start"): "path.use",
58
  ("POST", "/api/tests/{test_id}/simulation/start"): "path.use",
59
  ("GET", "/api/tests/{test_id}"): "path.use",
 
54
  ("POST", "/api/teaching/work/{attempt_id}"): "class.teach",
55
  ("POST", "/api/teaching/work/{attempt_id}/review"): "class.teach",
56
  ("POST", "/api/rounds/{round_id}/answer"): "path.use",
57
+ ("POST", "/api/attempts/{attempt_id}/retry/start"): "path.use",
58
  ("POST", "/api/tests/{test_id}/exercise/{number}/start"): "path.use",
59
  ("POST", "/api/tests/{test_id}/simulation/start"): "path.use",
60
  ("GET", "/api/tests/{test_id}"): "path.use",
mathly2/tests/test_realtests_api.py CHANGED
@@ -451,3 +451,37 @@ def test_another_students_grades_are_not_mine_and_an_unfinished_attempt_is_not_l
451
  start_ex(client, test["id"]) # mine is open, not finished
452
  assert client.get("/api/grades", headers=auth(KID)).json()["exercise_tests"] == []
453
  assert len(client.get("/api/grades", headers=auth(KID2)).json()["exercise_tests"]) == 1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
451
  start_ex(client, test["id"]) # mine is open, not finished
452
  assert client.get("/api/grades", headers=auth(KID)).json()["exercise_tests"] == []
453
  assert len(client.get("/api/grades", headers=auth(KID2)).json()["exercise_tests"]) == 1
454
+
455
+
456
+ def test_after_the_simulation_its_wrong_questions_can_be_answered_again_without_changing_the_grade(world):
457
+ client, store, lessons = world
458
+ bank = client.app.state.bank
459
+ test = set_test(client, lessons, days=14).json()
460
+ client.set_time(MON + 12 * DAY)
461
+ sim = client.post(f"/api/tests/{test['id']}/simulation/start?all_skills=true", headers=auth(KID)).json()
462
+ early = client.post(f"/api/attempts/{sim['id']}/retry/start", headers=auth(KID))
463
+ assert early.status_code == 409 # not handed in yet
464
+ done = answer_all(client, bank, sim, [True] * 8 + [False] * 4)
465
+ assert done["grade"] == 67 and len(done["missed"]) == 4
466
+ rnd = client.post(f"/api/attempts/{sim['id']}/retry/start", headers=auth(KID)).json()
467
+ assert rnd["kind"] == "mistakes_round" and rnd["total"] == 4 and rnd["source"]["test_id"] == test["id"]
468
+ assert [q["key"] for q in store.get_attempt(rnd["id"])["questions"]] == [q["key"] for q in sim["questions"][8:]]
469
+ assert '"correct"' not in json.dumps(rnd)
470
+ assert client.post(f"/api/attempts/{sim['id']}/retry/start", headers=auth(KID)).json()["id"] == rnd["id"] # an open one resumes
471
+ for ok in [True, False, True, True]:
472
+ key = rnd["question"]["key"]
473
+ got = client.post(f"/api/rounds/{rnd['id']}/answer", headers=auth(KID), json={"key": key, "choice": right_index(bank, key, ok)})
474
+ assert got.status_code == 200 and got.json()["correct"] is ok
475
+ rnd = got.json()["round"]
476
+ assert rnd["state"] == "done" and rnd["result"]["right"] == 3 and rnd["result"]["stars"] is None and len(rnd["result"]["missed"]) == 1
477
+ assert store.get_attempt(sim["id"])["grade"] == 67 # the simulation's grade stays
478
+ assert client.post(f"/api/attempts/{sim['id']}/retry/start", headers=auth(KID)).json()["id"] != rnd["id"] # again: a new round
479
+ assert client.post(f"/api/attempts/{sim['id']}/retry/start", headers=auth(TEACHER)).status_code == 404 # only the student's own
480
+
481
+
482
+ def test_a_test_with_no_mistakes_has_nothing_to_retry(world):
483
+ client, store, lessons = world
484
+ test = set_test(client, lessons, days=14, questions=6).json()
485
+ started(client, store, lessons)
486
+ done = answer_all(client, client.app.state.bank, start_ex(client, test["id"]).json(), [True] * 6)
487
+ assert client.post(f"/api/attempts/{done['id']}/retry/start", headers=auth(KID)).status_code == 409