wzender Claude Opus 5.5 commited on
Commit
aada0ab
·
1 Parent(s): 3ab5f6d

Board review: the steps of a running review (boards found, sent, the AI busy) on the student's and the teacher's screens

Browse files
mathly2/boardreview.py CHANGED
@@ -111,7 +111,7 @@ class GeminiReviewer:
111
  return None
112
  return pick_model(names, self.model)
113
 
114
- def review(self, items: list[dict]) -> dict:
115
  body = json.dumps({"contents": [{"role": "user", "parts": self._parts(items)}],
116
  "generationConfig": {"temperature": 0.2, "responseMimeType": "application/json", "responseSchema": SCHEMA}}).encode()
117
  with self._lock:
@@ -122,7 +122,10 @@ class GeminiReviewer:
122
  except urllib.error.HTTPError as exc:
123
  detail = exc.read().decode("utf-8", "replace")[:500]
124
  if exc.code == 429 and attempt < RETRIES:
125
- self.sleep(_retry_after(detail, 20 * (attempt + 1)))
 
 
 
126
  continue
127
  if exc.code == 404 and not getattr(self, "_switched", False):
128
  other = self._another_model()
@@ -206,15 +209,27 @@ def _items(app, row: dict) -> tuple[list[dict], dict]:
206
 
207
 
208
  def run(app, attempt_id: str) -> None:
209
- """Review one handed-in attempt and store the result on it. Never raises: a failure is stored as {"state": "failed"}."""
 
210
  store = app.state.store
 
 
 
 
 
 
 
 
 
211
  try:
212
  row = store.get_attempt(attempt_id)
213
  items, keys = _items(app, row)
 
214
  if not items:
215
  result = {"state": "done", "summary": None, "questions": {}}
216
  else:
217
- got = app.state.reviewer.review(items)
 
218
  questions = {}
219
  for q in got.get("questions", []):
220
  key = keys.get(q.get("n"))
@@ -271,6 +286,8 @@ def student_view(row: dict) -> dict | None:
271
  review = row.get("review")
272
  if not review:
273
  return None
 
 
274
  if review["state"] != "done":
275
  return {"state": review["state"]}
276
  return {"state": "done", "summary": review.get("summary"), "questions": review.get("questions", {})}
 
111
  return None
112
  return pick_model(names, self.model)
113
 
114
+ def review(self, items: list[dict], on_event=lambda step, **info: None) -> dict:
115
  body = json.dumps({"contents": [{"role": "user", "parts": self._parts(items)}],
116
  "generationConfig": {"temperature": 0.2, "responseMimeType": "application/json", "responseSchema": SCHEMA}}).encode()
117
  with self._lock:
 
122
  except urllib.error.HTTPError as exc:
123
  detail = exc.read().decode("utf-8", "replace")[:500]
124
  if exc.code == 429 and attempt < RETRIES:
125
+ wait = _retry_after(detail, 20 * (attempt + 1))
126
+ on_event("busy", seconds=round(wait))
127
+ self.sleep(wait)
128
+ on_event("sent")
129
  continue
130
  if exc.code == 404 and not getattr(self, "_switched", False):
131
  other = self._another_model()
 
209
 
210
 
211
  def run(app, attempt_id: str) -> None:
212
+ """Review one handed-in attempt and store the result on it. Never raises: a failure is stored as {"state": "failed"}.
213
+ While it runs, its steps are stored on the pending review (`events`: boards found, sent, the AI busy and waiting), for the screens."""
214
  store = app.state.store
215
+ started = clock.now().isoformat()
216
+ events: list[dict] = []
217
+
218
+ def event(step: str, **info) -> None:
219
+ events.append({"step": step, "at": clock.now().isoformat(), **info})
220
+ try:
221
+ store.save_attempt(attempt_id, review={"state": "pending", "at": started, "events": list(events)})
222
+ except Exception: # the steps are a courtesy: never fail the review for them
223
+ log.warning("could not save a review step")
224
  try:
225
  row = store.get_attempt(attempt_id)
226
  items, keys = _items(app, row)
227
+ event("boards", count=len(items), total=len(row["questions"]))
228
  if not items:
229
  result = {"state": "done", "summary": None, "questions": {}}
230
  else:
231
+ event("sent")
232
+ got = app.state.reviewer.review(items, on_event=event)
233
  questions = {}
234
  for q in got.get("questions", []):
235
  key = keys.get(q.get("n"))
 
286
  review = row.get("review")
287
  if not review:
288
  return None
289
+ if review["state"] == "pending":
290
+ return {"state": "pending", "at": review.get("at"), "events": review.get("events", [])}
291
  if review["state"] != "done":
292
  return {"state": review["state"]}
293
  return {"state": "done", "summary": review.get("summary"), "questions": review.get("questions", {})}
mathly2/boardwork.py CHANGED
@@ -150,7 +150,8 @@ def _ai_state(request: Request, row: dict) -> dict:
150
  """The AI review as the teacher sees it: can it run here (a key is set), its state, and why it failed."""
151
  review = row.get("review") or {}
152
  return {"available": getattr(request.app.state, "reviewer", None) is not None, "state": review.get("state"),
153
- "reason": review.get("reason"), "summary": review.get("summary"), "model": review.get("model")}
 
154
 
155
 
156
  @router.get("/teaching/work/{attempt_id}")
 
150
  """The AI review as the teacher sees it: can it run here (a key is set), its state, and why it failed."""
151
  review = row.get("review") or {}
152
  return {"available": getattr(request.app.state, "reviewer", None) is not None, "state": review.get("state"),
153
+ "reason": review.get("reason"), "summary": review.get("summary"), "model": review.get("model"),
154
+ "at": review.get("at"), "events": review.get("events", [])}
155
 
156
 
157
  @router.get("/teaching/work/{attempt_id}")
mathly2/static/app.css CHANGED
@@ -692,8 +692,16 @@ body:has(#lesson-map) p:has(> #to-subjects) { margin-block: 0 4px; }
692
  .qz-review-list li p { margin: 4px 0; }
693
  .qz-review-list li.v-correct .tag { border-color: var(--ok, #15803d); }
694
  .qz-review-list li.v-wrong .tag { border-color: var(--danger); }
695
- .qz-review .spin { display: inline-block; width: 12px; height: 12px; border-radius: 50%; border: 2px solid var(--line); border-top-color: var(--accent); animation: spin 1s linear infinite; vertical-align: middle; }
696
  @keyframes spin { to { transform: rotate(360deg); } }
697
  .work-ai { margin: 4px 0 8px; }
698
  .work-ai-panel p { margin: 4px 0; }
699
  .work-ai-panel button { margin-top: 6px; }
 
 
 
 
 
 
 
 
 
692
  .qz-review-list li p { margin: 4px 0; }
693
  .qz-review-list li.v-correct .tag { border-color: var(--ok, #15803d); }
694
  .qz-review-list li.v-wrong .tag { border-color: var(--danger); }
695
+ .qz-review .spin, .rv-steps .spin { display: inline-block; width: 12px; height: 12px; border-radius: 50%; border: 2px solid var(--line); border-top-color: var(--accent); animation: spin 1s linear infinite; vertical-align: middle; }
696
  @keyframes spin { to { transform: rotate(360deg); } }
697
  .work-ai { margin: 4px 0 8px; }
698
  .work-ai-panel p { margin: 4px 0; }
699
  .work-ai-panel button { margin-top: 6px; }
700
+
701
+ /* the steps of an AI review while it runs */
702
+ .rv-steps { margin: 6px 0; }
703
+ .rv-steps li { display: flex; gap: 8px; align-items: center; padding: 3px 0; }
704
+ .rv-steps li.rv-done { color: var(--muted); }
705
+ .rv-steps li.rv-now { font-weight: 600; }
706
+ .rv-steps .rv-ok { color: var(--ok, #15803d); }
707
+ .rv-time { margin: 2px 0 0; font-size: .9em; }
mathly2/static/quiz.js CHANGED
@@ -7,6 +7,7 @@ import { figures } from "./figures.js";
7
  import { esc, fmt } from "./math.js";
8
  import { pathHref, shuffled } from "./ui.js";
9
  import { hasWork, sendWork } from "./boardwork.js";
 
10
 
11
  const LIVE_AT = new Set([300, 60, 10, 0]); // seconds left at which the timer is announced to a screen reader
12
 
@@ -202,7 +203,7 @@ function reviewHtml(r) {
202
  const review = r.review;
203
  if (!review) return "";
204
  if (review.state === "pending") return `<section class="qz-review" id="qz-review" data-state="pending"><h4>משוב על הדרך</h4>
205
- <p class="muted"><span class="spin" aria-hidden="true"></span> בודקים את הדרך שכתבתם בלוח… זה לוקח כדקה.</p></section>`;
206
  if (review.state === "failed") return `<section class="qz-review" id="qz-review" data-state="failed"><h4>משוב על הדרך</h4><p class="muted">לא הצלחנו לבדוק את הדרך הפעם. המורה יראו את הלוחות.</p></section>`;
207
  const rows = (r.keys || []).map((key, i) => [i + 1, review.questions[key]]).filter(([, q]) => q);
208
  return `<section class="qz-review" id="qz-review" data-state="done"><h4>משוב על הדרך</h4>
 
7
  import { esc, fmt } from "./math.js";
8
  import { pathHref, shuffled } from "./ui.js";
9
  import { hasWork, sendWork } from "./boardwork.js";
10
+ import { stepsHtml } from "./reviewsteps.js";
11
 
12
  const LIVE_AT = new Set([300, 60, 10, 0]); // seconds left at which the timer is announced to a screen reader
13
 
 
203
  const review = r.review;
204
  if (!review) return "";
205
  if (review.state === "pending") return `<section class="qz-review" id="qz-review" data-state="pending"><h4>משוב על הדרך</h4>
206
+ <p class="muted">ה־AI עובר על הדרך שכתבתם בלוח. אפשר להמשיך לקרוא את התוצאה, המשוב יופיע כאן.</p>${stepsHtml(review)}</section>`;
207
  if (review.state === "failed") return `<section class="qz-review" id="qz-review" data-state="failed"><h4>משוב על הדרך</h4><p class="muted">לא הצלחנו לבדוק את הדרך הפעם. המורה יראו את הלוחות.</p></section>`;
208
  const rows = (r.keys || []).map((key, i) => [i + 1, review.questions[key]]).filter(([, q]) => q);
209
  return `<section class="qz-review" id="qz-review" data-state="done"><h4>משוב על הדרך</h4>
mathly2/static/reviewsteps.js ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // The steps of an AI review while it runs (board-review): what the server has done so far, as a short list that grows, and the time since
2
+ // it started. Shown on the student's result and on the teacher's "בדיקת דרך" screen; both ask the server again every few seconds.
3
+ import { esc } from "./math.js";
4
+
5
+ const boards = n => n === 1 ? "לוח אחד" : `${n} לוחות`;
6
+
7
+ /** One line per step; the last one is the one happening now. */
8
+ function lines(review) {
9
+ const events = review.events || [];
10
+ if (!events.length) return [["now", "⏳", "מתחילים…"]];
11
+ return events.map((e, i) => {
12
+ const last = i === events.length - 1;
13
+ if (e.step === "boards") return ["done", "📋", `נמצאו ${boards(e.count)} עם דרך (מתוך ${e.total} שאלות)`];
14
+ if (e.step === "busy") return [last ? "now" : "done", "☕", last ? `ה־AI עמוס כרגע. מנסים שוב בעוד ${e.seconds} שניות` : "ה־AI היה עמוס, ניסינו שוב"];
15
+ if (e.step === "sent") return [last ? "now" : "done", last ? "🔍" : "📤", last ? "ה־AI קורא את הלוחות ובודק את הדרך, שאלה אחרי שאלה…" : "נשלח ל־AI"];
16
+ return ["done", "•", esc(e.step)];
17
+ });
18
+ }
19
+
20
+ const since = at => Math.max(0, Math.round((Date.now() - new Date(at)) / 1000));
21
+ const elapsed = secs => secs < 60 ? `${secs} שניות` : `${Math.floor(secs / 60)}:${String(secs % 60).padStart(2, "0")} דקות`;
22
+
23
+ /** The steps as HTML: a list, then "running for … (usually under a minute)". */
24
+ export function stepsHtml(review) {
25
+ const at = review.at;
26
+ return `<ol class="plain rv-steps" aria-live="polite">${lines(review).map(([state, icon, text]) =>
27
+ `<li class="rv-${state}"><span aria-hidden="true">${state === "now" ? `<span class="spin"></span>` : icon}</span> ${text}${state === "done" ? ' <span class="rv-ok" aria-hidden="true">✓</span>' : ""}</li>`).join("")}</ol>
28
+ ${at ? `<p class="muted rv-time">כבר <span class="rv-elapsed" data-since="${esc(at)}">${elapsed(since(at))}</span> · בדרך כלל פחות מדקה</p>` : ""}`;
29
+ }
30
+
31
+ setInterval(() => document.querySelectorAll(".rv-elapsed[data-since]").forEach(el => { el.textContent = elapsed(since(el.dataset.since)); }), 1000);
mathly2/static/teacher_boardwork.js CHANGED
@@ -4,6 +4,7 @@ import { api } from "./api.js";
4
  import { figures } from "./figures.js";
5
  import { esc, fmt } from "./math.js";
6
  import { pictureUrl } from "./media.js";
 
7
 
8
  const KINDS = { quiz: "בוחן", exercise_test: "מבחן תרגול", simulation: "סימולציה" };
9
  const POINTS = [[null, "אוטומטי"], [0, "0"], [0.5, "½"], [1, "מלא"]];
@@ -24,7 +25,7 @@ function aiPanel(data) {
24
  const ai = data.ai || {};
25
  if (!ai.available) return `<div class="card work-ai-panel" id="work-ai"><p class="muted">🤖 בדיקה עם AI לא מוגדרת בשרת: חסר GEMINI_API_KEY.</p></div>`;
26
  const button = label => `<button type="button" id="work-ai-run">${label}</button>`;
27
- const body = ai.state === "pending" ? `<p role="status">🤖 ה־AI בודק את הדרך… (עד דקה בערך)</p>`
28
  : ai.state === "failed" ? `<p class="error" role="alert">🤖 הבדיקה עם AI נכשלה. ${esc(REASONS[ai.reason] || "")}${ai.model ? ` <span dir="ltr">(${esc(ai.model)})</span>` : ""}</p>${button("לנסות שוב")}`
29
  : ai.state === "done" ? `<p>🤖 <b>סיכום ה־AI:</b> ${ai.summary ? fmt(ai.summary) : "לא היו לוחות לבדוק."}</p><p class="muted">הצעה בלבד: הנקודות שלכם קובעות.</p>${button("לבדוק שוב עם AI")}`
30
  : `<p class="muted">🤖 עוד לא נבדק עם AI.</p>${button("בדיקה עם AI")}`;
@@ -89,7 +90,10 @@ async function open(id, note = "", given = null, keep = null) {
89
  let now;
90
  try { now = await api(`/api/teaching/work/${encodeURIComponent(id)}`); } catch { return wait(); }
91
  if (document.getElementById("work-check")?.dataset.attempt !== data.id) return;
92
- if (now.ai?.state === "pending") return wait();
 
 
 
93
  open(id, "", now, chosen);
94
  }, 4000); };
95
  wait();
 
4
  import { figures } from "./figures.js";
5
  import { esc, fmt } from "./math.js";
6
  import { pictureUrl } from "./media.js";
7
+ import { stepsHtml } from "./reviewsteps.js";
8
 
9
  const KINDS = { quiz: "בוחן", exercise_test: "מבחן תרגול", simulation: "סימולציה" };
10
  const POINTS = [[null, "אוטומטי"], [0, "0"], [0.5, "½"], [1, "מלא"]];
 
25
  const ai = data.ai || {};
26
  if (!ai.available) return `<div class="card work-ai-panel" id="work-ai"><p class="muted">🤖 בדיקה עם AI לא מוגדרת בשרת: חסר GEMINI_API_KEY.</p></div>`;
27
  const button = label => `<button type="button" id="work-ai-run">${label}</button>`;
28
+ const body = ai.state === "pending" ? `<p><b>🤖 בדיקה עם AI</b></p>${stepsHtml(ai)}`
29
  : ai.state === "failed" ? `<p class="error" role="alert">🤖 הבדיקה עם AI נכשלה. ${esc(REASONS[ai.reason] || "")}${ai.model ? ` <span dir="ltr">(${esc(ai.model)})</span>` : ""}</p>${button("לנסות שוב")}`
30
  : ai.state === "done" ? `<p>🤖 <b>סיכום ה־AI:</b> ${ai.summary ? fmt(ai.summary) : "לא היו לוחות לבדוק."}</p><p class="muted">הצעה בלבד: הנקודות שלכם קובעות.</p>${button("לבדוק שוב עם AI")}`
31
  : `<p class="muted">🤖 עוד לא נבדק עם AI.</p>${button("בדיקה עם AI")}`;
 
90
  let now;
91
  try { now = await api(`/api/teaching/work/${encodeURIComponent(id)}`); } catch { return wait(); }
92
  if (document.getElementById("work-check")?.dataset.attempt !== data.id) return;
93
+ if (now.ai?.state === "pending") { // the new steps, in the box only
94
+ document.getElementById("work-ai")?.replaceWith(Object.assign(document.createElement("div"), { innerHTML: aiPanel(now) }).firstElementChild);
95
+ return wait();
96
+ }
97
  open(id, "", now, chosen);
98
  }, 4000); };
99
  wait();
mathly2/tests/test_boardreview_api.py CHANGED
@@ -21,7 +21,7 @@ class FakeReviewer:
21
  def __init__(self, fail=None):
22
  self.calls, self.fail = [], fail
23
 
24
- def review(self, items):
25
  self.calls.append(items)
26
  if self.fail:
27
  raise boardreview.ReviewFailure(self.fail)
@@ -174,7 +174,7 @@ def test_the_teacher_sees_the_ai_state_and_can_run_it_again_or_on_a_quiz():
174
  client, store, bank, test, _ = make(fake)
175
  attempt, _ = hand_in(client, bank, test)
176
  view = client.get(f"/api/teaching/work/{attempt['id']}", headers=auth(TEACHER)).json()
177
- assert view["ai"] == {"available": True, "state": "failed", "reason": "rate_limited", "summary": None, "model": None}
178
  assert client.get(f"/api/teaching/work/{attempt['id']}", headers=auth(TEACHER)).json()["ai"]["state"] == "failed" # opening does not retry
179
  fake.fail = None
180
  again = client.post(f"/api/teaching/work/{attempt['id']}/review", headers=auth(TEACHER)).json()
@@ -223,3 +223,34 @@ def test_a_model_not_found_falls_back_to_a_flash_model_the_key_has():
223
  def test_pick_model_prefers_stable_full_and_new():
224
  assert boardreview.pick_model(["gemini-3-flash-preview", "gemini-2.5-flash-lite", "gemini-2.5-flash"]) == "gemini-2.5-flash"
225
  assert boardreview.pick_model(["gemini-2.5-pro", "gemini-2.5-flash-preview-tts"]) is None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  def __init__(self, fail=None):
22
  self.calls, self.fail = [], fail
23
 
24
+ def review(self, items, on_event=lambda step, **info: None):
25
  self.calls.append(items)
26
  if self.fail:
27
  raise boardreview.ReviewFailure(self.fail)
 
174
  client, store, bank, test, _ = make(fake)
175
  attempt, _ = hand_in(client, bank, test)
176
  view = client.get(f"/api/teaching/work/{attempt['id']}", headers=auth(TEACHER)).json()
177
+ assert {k: view["ai"][k] for k in ("available", "state", "reason", "summary")} == {"available": True, "state": "failed", "reason": "rate_limited", "summary": None}
178
  assert client.get(f"/api/teaching/work/{attempt['id']}", headers=auth(TEACHER)).json()["ai"]["state"] == "failed" # opening does not retry
179
  fake.fail = None
180
  again = client.post(f"/api/teaching/work/{attempt['id']}/review", headers=auth(TEACHER)).json()
 
223
  def test_pick_model_prefers_stable_full_and_new():
224
  assert boardreview.pick_model(["gemini-3-flash-preview", "gemini-2.5-flash-lite", "gemini-2.5-flash"]) == "gemini-2.5-flash"
225
  assert boardreview.pick_model(["gemini-2.5-pro", "gemini-2.5-flash-preview-tts"]) is None
226
+
227
+
228
+ def test_while_it_runs_the_review_stores_its_steps_for_the_screens():
229
+ seen = []
230
+
231
+ class Watching(FakeReviewer):
232
+ def review(self, items, on_event=lambda step, **info: None):
233
+ seen.append(boardreview.student_view(store.get_attempt(attempt_id)))
234
+ on_event("busy", seconds=8)
235
+ seen.append(boardreview.student_view(store.get_attempt(attempt_id)))
236
+ on_event("sent")
237
+ return super().review(items, on_event)
238
+ fake = Watching()
239
+ client, store, bank, test, _ = make(None)
240
+ attempt, _ = hand_in(client, bank, test, boards=2)
241
+ attempt_id = attempt["id"]
242
+ client.app.state.reviewer = fake
243
+ client.get(f"/api/attempts/{attempt_id}", headers=auth(KID))
244
+ assert [e["step"] for e in seen[0]["events"]] == ["boards", "sent"] and seen[0]["events"][0]["count"] == 2
245
+ assert seen[0]["state"] == "pending" and seen[0]["at"]
246
+ assert seen[1]["events"][-1] == {**seen[1]["events"][-1], "step": "busy", "seconds": 8}
247
+ assert store.get_attempt(attempt_id)["review"]["state"] == "done"
248
+
249
+
250
+ def test_gemini_says_when_it_waits_for_the_free_tier():
251
+ steps = []
252
+ reviewer = boardreview.GeminiReviewer("key", sleep=lambda s: None)
253
+ replies = [http_error(429, json.dumps({"error": {"details": [{"retryDelay": "7s"}]}}).encode()), answer({"summary": "s", "questions": []})]
254
+ reviewer._call = lambda body: (lambda r: (_ for _ in ()).throw(r) if isinstance(r, Exception) else r)(replies.pop(0))
255
+ reviewer.review(items(), on_event=lambda step, **info: steps.append((step, info)))
256
+ assert steps == [("busy", {"seconds": 8}), ("sent", {})]
mathly2/tests/test_boardwork_browser.py CHANGED
@@ -184,3 +184,47 @@ def test_the_teacher_sees_why_the_ai_failed_and_runs_it_again(browser, bank):
184
  assert "סיכום ה־AI" in teacher.inner_text("#work-ai") and teacher.locator(".work-q .work-ai").count() == 1
185
  assert teacher.get_attribute(f".work-q[data-key='{second}'] [data-points='0.5']", "aria-pressed") == "true" # the pick stayed
186
  assert teacher.errors == []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
  assert "סיכום ה־AI" in teacher.inner_text("#work-ai") and teacher.locator(".work-q .work-ai").count() == 1
185
  assert teacher.get_attribute(f".work-q[data-key='{second}'] [data-points='0.5']", "aria-pressed") == "true" # the pick stayed
186
  assert teacher.errors == []
187
+
188
+
189
+ def test_while_the_ai_works_the_student_and_the_teacher_see_its_steps(browser, bank):
190
+ import threading
191
+ from datetime import timedelta
192
+ from mathly2 import clock
193
+ from mathly2.tests.test_boardreview_api import FakeReviewer
194
+ release = threading.Event()
195
+
196
+ class Slow(FakeReviewer):
197
+ def review(self, items, on_event=lambda step, **info: None):
198
+ on_event("busy", seconds=30)
199
+ release.wait(30)
200
+ return super().review(items, on_event)
201
+ lessons = [l["id"] for l in bank.lessons][:2]
202
+ store = InMemoryStore()
203
+ klass = store.create_class("setup", "A", lessons=lessons, teacher_email=TEACHER)
204
+ store.add_member(klass["id"], KID)
205
+ test = store.create_test(klass["id"], clock.school_day() + timedelta(days=9), lessons, TEACHER, created_at=clock.now() - timedelta(days=20))
206
+ app = create_app(Settings(teachers=frozenset({TEACHER})), bank=bank, store=store, reviewer=Slow())
207
+ with running(app) as url:
208
+ page = sign_in(browser, url, f"#/exam/{test['id']}/1/all")
209
+ page.wait_for_selector("#qz-q")
210
+ page.click("[data-board-toggle]")
211
+ page.wait_for_selector(".mx-board.full canvas")
212
+ draw_line(page)
213
+ page.click(".mx-board .mx-board-back")
214
+ page.locator(".qz-choice").first.click()
215
+ page.wait_for_selector(".qz-choice.picked")
216
+ page.click("#qz-submit")
217
+ page.click("#qz-yes")
218
+ page.wait_for_selector("#qz-review[data-state='pending'] .rv-steps li.rv-now:has-text('עמוס')", timeout=15000)
219
+ assert "נמצאו לוח אחד" in page.inner_text("#qz-review") and page.locator(".rv-elapsed").count() == 1
220
+ attempt = store.attempts_of(KID_ID, "exercise_test")[0]
221
+
222
+ teacher = teacher_page(browser, url)
223
+ teacher.click("#teacher-tabs [data-tab='work']")
224
+ teacher.click(f"[data-open='{attempt['id']}']")
225
+ teacher.wait_for_selector("#work-ai[data-state='pending'] .rv-steps")
226
+ assert "עמוס" in teacher.inner_text("#work-ai")
227
+ release.set()
228
+ page.wait_for_selector("#qz-review[data-state='done']", timeout=15000)
229
+ teacher.wait_for_selector("#work-ai[data-state='done']", timeout=15000)
230
+ assert page.errors == [] and teacher.errors == []