wzender Claude Opus 5.5 commited on
Commit
82cae90
ยท
1 Parent(s): 2067656

Tests on request and a readiness number that only tests raise

Browse files

- Practice tests on request in three modes (balanced, focused on the hard skills, manual pick) and simulations on request
(all the material, timed, no choice, repeatable). No plan, no window, no one-time rule. Old exercise tests 1-2 count as balanced, 3 as focused.
- Readiness moves only with handed-in tests, only up: number += GAIN x (grade - number), GAIN 0.6 simulation, 0.3 practice,
0.15 manual. Mastery and deepening no longer count. Levels, points to the next level, what a perfect test adds.
- Home: one compact block (number, level, days left; practice and simulation buttons with +N), still one screen.
The test page: how it goes up, simulation, practice modes, the trail of gains. A result opens with its gain and a level up.
- Teacher tabs show readiness, test counts and bests. countdown.py removed. OpenSpec change tests-on-demand; PRD updated.

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

docs/tasks/prd-mathly.md CHANGED
@@ -298,50 +298,34 @@ The exercise screen is where a student practices one skill.
298
  - **TST-1 Setting a test.** The teacher sets a test: date + the lessons it covers (+ an optional note), plus the real test's **number of questions** and **time limit**.
299
  A test covers lessons of **one subject** the class studies (SUB-4): a class with several subjects picks the subject first, and the test shows only on that subject's home.
300
  These start with defaults (12 questions, 45 minutes) that the teacher can change.
301
- - **TST-2 The countdown plan.** From the moment a test is set, the system shows the student a plan that depends on the days left:
302
-
303
- | Days before the test | What the app asks the student to do |
304
- |---|---|
305
- | until 9 | Finish the path for every covered lesson (bring every covered skill to "mastered") |
306
- | ~9 | Exercise test 1 |
307
- | ~7 | Exercise test 2 |
308
- | ~5 | Simulation test (required) |
309
- | 4 โ†’ 1 | Review the skills that failed in the simulation; light mixed review the day before |
310
-
311
- The day numbers are **recommendations**: the student can start each step earlier or later.
312
- If the test is set less than 9 days ahead, the system keeps the same order and shortens the gaps evenly.
313
- - **TST-3 Home screen reminder.** The home screen shows, for the nearest test: days left, the readiness meter (RDY-1), and **one task for today** taken from the plan.
314
- When several tests are coming up, "today's task" comes from the **closest** one.
315
 
316
  ### 4.11 Exercise tests (ืžื‘ื—ืŸ ืชืจื’ื•ืœ) (EXT)
317
 
318
- - **EXT-1 Created by the app.** When the teacher sets a test, the system creates 2 exercise tests for every student in the class and places them on the student's countdown.
 
 
 
 
 
319
  - **EXT-2 Teacher can assign one.** The teacher can send an exercise test to the whole class as an assignment (built with the assignments, stage 6): it shows in "ื”ืžืฉื™ืžื•ืช ืฉืœื™", opens the exercise test and counts as done when the student has taken it; it is not handed in as a picture.
320
- - **EXT-3 Retakes.** The student can start or retake an exercise test at any time. Each retake draws new questions; the best grade counts.
321
  - **EXT-4 Like the simulation.** Exercise tests have the same number of questions as the simulation (that is, as the real test, TST-1) but no time limit,
322
  mix questions from all covered lessons, and do not show the skill name. Feedback comes **only at the end**, not after each question.
323
- - **EXT-5 Question mix.** The system picks questions in these proportions:
324
-
325
- | Exercise test | Weak skills | Other covered skills |
326
- |---|---|---|
327
- | 1 | 60% | 40% |
328
- | 2 | 30% | 70% |
329
-
330
- "Weak skill" = a skill in the "learning" state, or one the student answered wrong in a previous exercise test.
331
- Skills in the "not started" state do not appear; instead, the card lists them as "practice these first".
332
- If the student has fewer weak skills than needed, the rest comes from the other covered skills.
333
  - **EXT-6 Result links back.** After an exercise test, the system shows the weak skills it found and links each one to its star on the path.
334
  - **EXT-7 Grades on the server.** Exercise test grades are computed and kept on the server.
335
 
336
  ### 4.12 Simulation test (ืžื‘ื—ืŸ ืกื™ืžื•ืœืฆื™ื”) (SIM)
337
 
338
- - **SIM-1 Required, one attempt.** Every student takes one simulation test per real test. It can be taken only once.
339
  - **SIM-2 Like the real test.** The simulation covers all the lessons of the real test, with the number of questions and the time limit the teacher set for it (TST-1),
340
  no skill names, and no feedback until the end.
341
- - **SIM-3 Window.** The simulation becomes available ~5 days before the test and stays available until the test day.
342
- It is shown as **required** on the student's home screen until it is taken.
343
  - **SIM-4 Server timer.** The timer is kept on the server, so refreshing or closing the app does not reset it. The grade is stored on the server.
344
- - **SIM-5 After the simulation,** the system lists the skills that failed and turns them into the review tasks of the last days (TST-2).
345
 
346
  ### 4.13 Grades and readiness meter (GRD, RDY)
347
 
@@ -349,14 +333,19 @@ The exercise screen is where a student practices one skill.
349
  Teachers' grades for assignments are on the same scale.
350
  - **GRD-2 When time ends.** Only the simulation is timed. When its timer ends it is submitted as it stands, and unanswered questions count as wrong.
351
  Leaving the screen does not stop the timer.
352
- - **RDY-1 Readiness meter.** For each upcoming test, the system shows the student (and the teacher) one **readiness number from 0 to 100**, drawn as a level bar, with its three parts under it:
353
- - **mastery (ืฉืœื™ื˜ื”)**: the % of the covered skills that are mastered;
354
- - **practice tests (ืžื‘ื—ื ื™ ืชืจื’ื•ืœ)**: the average of the best grade of each exercise test taken;
355
- - **the simulation (ืกื™ืžื•ืœืฆื™ื”)**: the simulation test grade (once taken).
356
- Each part is a row in plain Hebrew with a short line saying what its number is and its value ("โ€”" when none yet); the letters M, E, S are internal and not shown.
357
- - **RDY-2 How the number is made.** It is the weighted average of the parts that exist, with weights M 50, E 20, S 30; a part that does not exist yet (no exercise test taken, no simulation) is left out and the others are scaled up.
358
- - **RDY-3 No false confidence.** Until the simulation is taken the number is capped at 80, and the bar says so. The bar's label by number is: 0โ€“39 "ืžืชื—ื™ืœื™ื", 40โ€“69 "ื‘ื“ืจืš", 70โ€“84 "ื›ืžืขื˜ ืฉื", 85โ€“100 "ืžื•ื›ื ื™ื". "ืžื•ื›ื ื™ื" can only show after the simulation.
359
- - **RDY-4 The number explains itself.** An info button "โ“˜" beside the number opens a short tooltip (hover, focus or tap; closes on a second tap, a tap elsewhere or Escape): the number is made of mastery (50%), practice tests (20%) and the simulation (30%); a part with no grade yet is left out; without the simulation it cannot pass 80.
 
 
 
 
 
360
 
361
  ### 4.14 Handwritten answers and phone photos (ANS, PSH)
362
 
@@ -402,7 +391,7 @@ The exercise screen is where a student practices one skill.
402
 
403
  A tab bar at the bottom of the screen leads between them: home, path (named "ื”ืขืžืงื”": going deeper, skill by skill), submissions (STU-7), grades, collection and review (STU-8).
404
 
405
- - **STU-1 Home** is one screen (it fits 360ร—800 with every lesson), like a game's level select: the stars collected (three per lesson), the lessons perfected, the streak and the daily goal; one big **play** button for the next lesson (the one with a round in progress, else the earliest behind the curriculum, else the first with fewer than three stars); the nearest test in one strip (the days left and the readiness number, which open the test's card with the readiness bar, the plan and the practice tests, and today's task, TST-3, as a button); small buttons for a required simulation, the new assignments (their number, opening "ื”ื’ืฉื•ืช") and photo help with the latest request's state (AI-1); and every lesson as a small tile: its number (a crown when perfect), its stars (โ˜… a lesson round finished, โ˜…โ˜… a round with 8 of 10 or more, โ˜…โ˜…โ˜… every skill of the lesson mastered), "โฐ" when behind the curriculum (PTH-11) and "โ—" when a round is open. Tapping a tile opens the lesson's round (STU-9). The path is reached from its tab ("ื”ืขืžืงื”").
406
  When the curriculum gives the lessons dates, the current lesson (the first whose date is today or later) is shown on its own with its date, and the lessons whose date has passed (with how many are behind) and the coming ones are folded under it, each with its count.
407
  - **STU-9 Lesson round (ืกื‘ื‘ ืฉื™ืขื•ืจ).** At most 10 questions from the lesson's skills that are not mastered, at most 3 from each (a lesson with one skill left is a round of 3); a lesson with every skill mastered draws 10 from all of them. Instant feedback as on the exercise screen (EX); every answer is a practice answer of its skill (mastery, daily goal, review list). The result shows the score, the lesson's stars, the skills mastered in the round and those one good day away, and each mistake's name and tip; never the right answers.
408
  - **STU-10 The way on the board (ื”ื“ืจืš).** Every answer (practice, lesson rounds, lesson quizzes, exercise tests, the simulation) keeps a picture of the question's board when something is written on it; in quizzes and tests a choice needs the way on the board first. The teacher's "ื‘ื“ื™ืงืช ื“ืจืš" tab lists the handed-in quizzes and tests of their students; each question shows the choices (the right one marked), the student's choice and board, and points for the way (automatic, 0, ยฝ, full) that recompute the grade. Work pictures are private to the student and their teachers and follow the answer pictures' retention. After a practice test or the simulation, an AI (Gemini, one request per test, no names) reviews the way per question and the result shows "ืžืฉื•ื‘ ืขืœ ื”ื“ืจืš" (a verdict, a feedback line and a tip, never the right answer); the teacher sees it as a suggestion.
@@ -434,7 +423,7 @@ A tab bar at the bottom of the screen leads between them: home, path (named "ื”
434
  - **TCH-4 Inbox and checking.** One inbox lists everything the teacher's students handed in that waits for them, oldest first: assignment submissions not checked yet (a returned one waits for the student)
435
  and open requests to look again (AI-5). The teacher opens each, sees the AI's suggested score and comment (AI-4) if there is one, gives a grade (0โ€“100) and a note, or sends it back for correction.
436
  - **TCH-5 AI queue.** The teacher sees their classes' AI requests (AI-1), with the picture, and sends or declines each, or sends a whole batch (AI-7). It is an **"AI" tab** on the teacher's screen with the number of waiting requests, and it also has the teacher's own photo help (AI-2) and what is being worked on, what failed and why (AI-8). A student asking to look again (AI-5) appears in the inbox (TCH-4), not here.
437
- - **TCH-6 Tests tab.** The teacher sets a test (TST-1) and sees, for each test, which students have taken the simulation test and which have not.
438
  - **TCH-7 Class progress, by lesson.** The teacher sees progress **per lesson, never per skill**.
439
  - Rows: students. Columns: lessons.
440
  - Each cell is short and shows three things only: a **progress bar** with the percent of the lesson's skills mastered written on it; the student's best quiz (ื‘ื•ื—ืŸ) grade as a **bare number** (a dash for none, without the word); and, in the current period, a **warning mark** (โš ) when the lesson is behind the curriculum (PTH-11) and not completed. The lesson's state in words (ื”ื•ืฉืœื PTH-10, ื‘ืœืžื™ื“ื”, ืœื ื”ืชื—ื™ืœื•, ื‘ืคื™ื’ื•ืจ) is the cell's accessible name, and a completed lesson's bar is gold.
 
298
  - **TST-1 Setting a test.** The teacher sets a test: date + the lessons it covers (+ an optional note), plus the real test's **number of questions** and **time limit**.
299
  A test covers lessons of **one subject** the class studies (SUB-4): a class with several subjects picks the subject first, and the test shows only on that subject's home.
300
  These start with defaults (12 questions, 45 minutes) that the teacher can change.
301
+ - **TST-2 Tests on request.** From the moment a test is set, the student can ask, at any time until the test, for a **practice test** (EXT) or a **simulation test** (SIM), as many as they like. There is no fixed plan and no window: the readiness number (RDY-1) is what the student works to raise.
302
+ - **TST-3 Home screen.** The home screen shows the nearest test in one compact block. It has the readiness number with its level and the days left (a link to the test's page), a thin bar, and two buttons, **๐Ÿ“ ืžื‘ื—ืŸ ืชืจื’ื•ืœ** and **โฑ ืžื‘ื—ืŸ ืกื™ืžื•ืœืฆื™ื”**. Each button shows the most it can add to the number now ("+30", "+60").
303
+ When several tests are coming up, the block is the **closest** one; the others are on its page.
 
 
 
 
 
 
 
 
 
 
 
304
 
305
  ### 4.11 Exercise tests (ืžื‘ื—ืŸ ืชืจื’ื•ืœ) (EXT)
306
 
307
+ - **EXT-1 On request, in three modes.** The student asks for a practice test on the test's page and picks a mode:
308
+ - **balanced (ืžืื•ื–ืŸ)**: all the covered material evenly;
309
+ - **focused (ืžืžื•ืงื“)**: 15 questions only from the "weak" skills;
310
+ - **manual (ื‘ื—ื™ืจื” ื™ื“ื ื™ืช)**: the skills the student checks.
311
+
312
+ An open practice test of the same mode resumes; a manual one is always new.
313
  - **EXT-2 Teacher can assign one.** The teacher can send an exercise test to the whole class as an assignment (built with the assignments, stage 6): it shows in "ื”ืžืฉื™ืžื•ืช ืฉืœื™", opens the exercise test and counts as done when the student has taken it; it is not handed in as a picture.
314
+ - **EXT-3 Retakes.** The student can take as many practice tests as they like. Each draws the least-used questions first.
315
  - **EXT-4 Like the simulation.** Exercise tests have the same number of questions as the simulation (that is, as the real test, TST-1) but no time limit,
316
  mix questions from all covered lessons, and do not show the skill name. Feedback comes **only at the end**, not after each question.
317
+ - **EXT-5 Question mix.** A balanced test spreads its questions over all covered skills, not-started ones included. A focused test takes them only from the "weak" skills, one per skill round after round. A "weak" skill is one that lost points in this test's practice tests or simulations, or one in the "learning" state.
318
+ A manual test takes them only from the skills the student picked.
 
 
 
 
 
 
 
 
319
  - **EXT-6 Result links back.** After an exercise test, the system shows the weak skills it found and links each one to its star on the path.
320
  - **EXT-7 Grades on the server.** Exercise test grades are computed and kept on the server.
321
 
322
  ### 4.12 Simulation test (ืžื‘ื—ืŸ ืกื™ืžื•ืœืฆื™ื”) (SIM)
323
 
324
+ - **SIM-1 On request, no choice.** The student can ask for a simulation at any time, as many times as they like (an open one resumes). The student has no control over its material.
325
  - **SIM-2 Like the real test.** The simulation covers all the lessons of the real test, with the number of questions and the time limit the teacher set for it (TST-1),
326
  no skill names, and no feedback until the end.
 
 
327
  - **SIM-4 Server timer.** The timer is kept on the server, so refreshing or closing the app does not reset it. The grade is stored on the server.
328
+ - **SIM-5 After the simulation,** the result lists the skills that lost points (they feed the focused practice test, EXT-5), offers to answer the wrong questions again, and shows what the simulation added to the readiness number (RDY-5).
329
 
330
  ### 4.13 Grades and readiness meter (GRD, RDY)
331
 
 
333
  Teachers' grades for assignments are on the same scale.
334
  - **GRD-2 When time ends.** Only the simulation is timed. When its timer ends it is submitted as it stands, and unanswered questions count as wrong.
335
  Leaving the screen does not stop the timer.
336
+ - **RDY-1 Readiness meter.** For each upcoming test, the system shows the student (and the teacher) one **readiness number from 0 to 100**, with its level and a bar. Levels: 0โ€“39 "ืžืชื—ื™ืœื™ื", 40โ€“69 "ื‘ื“ืจืš", 70โ€“84 "ื›ืžืขื˜ ืฉื", 85โ€“100 "ืžื•ื›ื ื™ื". Beside it, the app shows the points to the next level.
337
+ - **RDY-2 Only tests move it.** The number changes only when a practice test or a simulation is handed in, never from the path, deepening or mastery. For each test, oldest first, when its grade is above the number:
338
+
339
+ `number = number + GAIN ร— (grade โˆ’ number)`
340
+
341
+ GAIN is 0.6 for a simulation, 0.3 for a practice test and 0.15 for a manual practice test.
342
+ - **RDY-3 Never down, never above the grades.** A grade at or below the number changes nothing, so the number never drops, and it can only approach the grades actually earned.
343
+ - **RDY-4 The number explains itself.** The test's page says how to raise it: a practice test closes 30% of the gap, a simulation 60%, a manual one 15%; a lower grade does not lower it; only tests count.
344
+ - **RDY-5 Make raising it rewarding.**
345
+ - The number counts up from the last visit with a "+N" badge.
346
+ - Every button to a test shows the most it can add now.
347
+ - A test's result opens with its gain (a big "+N" and the bar growing), "๐ŸŽ‰ ืจืžื” ื—ื“ืฉื”" when the level changed, the points to the next level, and buttons to another simulation or practice test.
348
+ - The test's page lists every test handed in with its grade and what it added.
349
 
350
  ### 4.14 Handwritten answers and phone photos (ANS, PSH)
351
 
 
391
 
392
  A tab bar at the bottom of the screen leads between them: home, path (named "ื”ืขืžืงื”": going deeper, skill by skill), submissions (STU-7), grades, collection and review (STU-8).
393
 
394
+ - **STU-1 Home** is one screen (it fits 360ร—800 with every lesson), like a game's level select: the stars collected (three per lesson), the lessons perfected, the streak and the daily goal; one big **play** button for the next lesson (the one with a round in progress, else the earliest behind the curriculum, else the first with fewer than three stars); the nearest test in one compact block (TST-3: the readiness number, its level and the days left, which open the test's page; the practice test and simulation buttons); small buttons beside the stats for the new assignments (their number, opening "ื”ื’ืฉื•ืช") and photo help with the latest request's state (AI-1); and every lesson as a small tile: its number (a crown when perfect), its stars (โ˜… a lesson round finished, โ˜…โ˜… a round with 8 of 10 or more, โ˜…โ˜…โ˜… every skill of the lesson mastered), "โฐ" when behind the curriculum (PTH-11) and "โ—" when a round is open. Tapping a tile opens the lesson's round (STU-9). The path is reached from its tab ("ื”ืขืžืงื”").
395
  When the curriculum gives the lessons dates, the current lesson (the first whose date is today or later) is shown on its own with its date, and the lessons whose date has passed (with how many are behind) and the coming ones are folded under it, each with its count.
396
  - **STU-9 Lesson round (ืกื‘ื‘ ืฉื™ืขื•ืจ).** At most 10 questions from the lesson's skills that are not mastered, at most 3 from each (a lesson with one skill left is a round of 3); a lesson with every skill mastered draws 10 from all of them. Instant feedback as on the exercise screen (EX); every answer is a practice answer of its skill (mastery, daily goal, review list). The result shows the score, the lesson's stars, the skills mastered in the round and those one good day away, and each mistake's name and tip; never the right answers.
397
  - **STU-10 The way on the board (ื”ื“ืจืš).** Every answer (practice, lesson rounds, lesson quizzes, exercise tests, the simulation) keeps a picture of the question's board when something is written on it; in quizzes and tests a choice needs the way on the board first. The teacher's "ื‘ื“ื™ืงืช ื“ืจืš" tab lists the handed-in quizzes and tests of their students; each question shows the choices (the right one marked), the student's choice and board, and points for the way (automatic, 0, ยฝ, full) that recompute the grade. Work pictures are private to the student and their teachers and follow the answer pictures' retention. After a practice test or the simulation, an AI (Gemini, one request per test, no names) reviews the way per question and the result shows "ืžืฉื•ื‘ ืขืœ ื”ื“ืจืš" (a verdict, a feedback line and a tip, never the right answer); the teacher sees it as a suggestion.
 
423
  - **TCH-4 Inbox and checking.** One inbox lists everything the teacher's students handed in that waits for them, oldest first: assignment submissions not checked yet (a returned one waits for the student)
424
  and open requests to look again (AI-5). The teacher opens each, sees the AI's suggested score and comment (AI-4) if there is one, gives a grade (0โ€“100) and a note, or sends it back for correction.
425
  - **TCH-5 AI queue.** The teacher sees their classes' AI requests (AI-1), with the picture, and sends or declines each, or sends a whole batch (AI-7). It is an **"AI" tab** on the teacher's screen with the number of waiting requests, and it also has the teacher's own photo help (AI-2) and what is being worked on, what failed and why (AI-8). A student asking to look again (AI-5) appears in the inbox (TCH-4), not here.
426
+ - **TCH-6 Tests tab.** The teacher sets a test (TST-1) and sees, for each test, which students have taken a simulation and which have not, and each student's readiness with how many practice tests and simulations they took and their best grades.
427
  - **TCH-7 Class progress, by lesson.** The teacher sees progress **per lesson, never per skill**.
428
  - Rows: students. Columns: lessons.
429
  - Each cell is short and shows three things only: a **progress bar** with the percent of the lesson's skills mastered written on it; the student's best quiz (ื‘ื•ื—ืŸ) grade as a **bare number** (a dash for none, without the word); and, in the current period, a **warning mark** (โš ) when the lesson is behind the curriculum (PTH-11) and not completed. The lesson's state in words (ื”ื•ืฉืœื PTH-10, ื‘ืœืžื™ื“ื”, ืœื ื”ืชื—ื™ืœื•, ื‘ืคื™ื’ื•ืจ) is the cell's accessible name, and a completed lesson's bar is gold.
mathly2/boardwork.py CHANGED
@@ -89,8 +89,8 @@ def _title(request: Request, row: dict) -> str:
89
  test_id, _, number = row["scope"].partition(":")
90
  test = store.get_test(int(test_id)) if test_id.isdigit() else None
91
  when = f" ({test['date'].day}.{test['date'].month})" if test else ""
92
- from .exam import FOCUS
93
- name = ("ืžื‘ื—ืŸ ืชืจื’ื•ืœ: ื ื•ืฉืื™ื ืœื—ื™ื–ื•ืง" if number == str(FOCUS) else f"ืžื‘ื—ืŸ ืชืจื’ื•ืœ {number}") if row["kind"] == "exercise_test" else "ืžื‘ื—ืŸ ืกื™ืžื•ืœืฆื™ื”"
94
  return name + when
95
 
96
 
 
89
  test_id, _, number = row["scope"].partition(":")
90
  test = store.get_test(int(test_id)) if test_id.isdigit() else None
91
  when = f" ({test['date'].day}.{test['date'].month})" if test else ""
92
+ from .exam import TITLES, mode_of_scope
93
+ name = TITLES[mode_of_scope(row["scope"])] if row["kind"] == "exercise_test" else "ืžื‘ื—ืŸ ืกื™ืžื•ืœืฆื™ื”"
94
  return name + when
95
 
96
 
mathly2/clock.py CHANGED
@@ -1,4 +1,4 @@
1
- """Server time. Every rule that depends on days (mastery, the countdown, the simulation window) uses this,
2
  never the device's clock."""
3
  from __future__ import annotations
4
 
 
1
+ """Server time. Every rule that depends on days (mastery, the days left to a test) uses this,
2
  never the device's clock."""
3
  from __future__ import annotations
4
 
mathly2/countdown.py DELETED
@@ -1,87 +0,0 @@
1
- """The countdown plan for a real test, and today's one task (PRD TST-2, TST-3). Pure functions: the server gathers the facts.
2
-
3
- The plan, for a test set 9 or more days ahead (days left): until 9 finish the path; ~9 exercise test 1; ~7 exercise test 2; ~5 the
4
- simulation (open from then until the test day); 4 to 1 review the skills that failed in the simulation, the last day a light mixed
5
- review. For a test set `n < 9` days ahead the same order is kept and every day is scaled by n/9, rounded half up. The days are
6
- recommendations: everything but the simulation's window may be started any time."""
7
- from __future__ import annotations
8
-
9
- from datetime import date
10
-
11
- FULL = 9 # days of the full plan
12
- BASE = {"ex1": 9, "ex2": 7, "sim": 5, "review_from": 4}
13
-
14
-
15
- def _round(x: float) -> int:
16
- return int(x + 0.5)
17
-
18
-
19
- def plan(test_day: date, set_day: date) -> dict:
20
- """The recommended days (days left) of each step. `set_day` is the day the teacher set the test, so the plan does not shift as days pass."""
21
- n = max(0, (test_day - set_day).days)
22
- scale = 1.0 if n >= FULL else n / FULL
23
- days = {step: _round(day * scale) if scale < 1 else day for step, day in BASE.items()}
24
- days["review_from"] = max(days["review_from"], 1)
25
- return {"n": n, **days}
26
-
27
-
28
- def step_for(days_left: int, p: dict) -> str:
29
- """The step the plan recommends on a day: path, ex1, ex2, sim or review. It is the step whose recommended day is the nearest one
30
- still ahead (days left at or above today's), the earlier step first when days coincide; before all of them, the path."""
31
- steps = [("ex1", p["ex1"]), ("ex2", p["ex2"]), ("sim", p["sim"]), ("review", p["review_from"])]
32
- ahead = [(day, i, name) for i, (name, day) in enumerate(steps) if day >= days_left]
33
- return min(ahead)[2] if ahead else "path"
34
-
35
-
36
- def sim_open(days_left: int, p: dict) -> bool:
37
- """The simulation is open from `sim` days before the test until the test day."""
38
- return 0 <= days_left <= p["sim"]
39
-
40
-
41
- def outline(p: dict, facts: dict) -> list[dict]:
42
- """The plan as steps with their recommended day (days left) and whether they are done, for the test's card."""
43
- return [{"id": "path", "day": None, "until": p["ex1"], "done": facts["path_done"]},
44
- {"id": "ex1", "day": p["ex1"], "done": facts["ex1_done"]},
45
- {"id": "ex2", "day": p["ex2"], "done": facts["ex2_done"]},
46
- {"id": "sim", "day": p["sim"], "done": facts["sim_taken"]},
47
- {"id": "review", "day": p["review_from"], "done": facts["sim_taken"] and not facts["review_left"]}]
48
-
49
-
50
- def task(days_left: int, p: dict, facts: dict) -> dict:
51
- """The one task for today. `facts`: path_done, next_path_skill, ex1_done, ex2_done, sim_taken, review_left (failed skills not
52
- practised since), stale (covered skills, the one answered longest ago first). Returns {"kind": ...} with kind one of path, ex1, ex2,
53
- sim, review, mixed_review, continue. The recommended step comes first; when it is done, the next later step that can be started."""
54
- done = {"path": facts["path_done"], "ex1": facts["ex1_done"], "ex2": facts["ex2_done"], "sim": facts["sim_taken"], "review": False}
55
- order = ["path", "ex1", "ex2", "sim", "review"]
56
-
57
- def can(step: str) -> bool:
58
- if step == "sim":
59
- return sim_open(days_left, p) # the simulation cannot start before its window
60
- return step != "review" or facts["sim_taken"] # a review needs the simulation's result
61
-
62
- def make(step: str) -> dict:
63
- if step == "path":
64
- return {"kind": "path", "skill": facts["next_path_skill"]}
65
- if step == "review":
66
- if days_left == 1 or not facts["review_left"]:
67
- return {"kind": "mixed_review", "skill": facts["stale"][0] if facts["stale"] else None}
68
- return {"kind": "review", "skill": facts["review_left"][0]}
69
- return {"kind": step}
70
-
71
- if days_left < 0:
72
- return {"kind": "continue"}
73
- rec = step_for(days_left, p)
74
- if rec == "review" and not facts["sim_taken"]: # a review needs the simulation first
75
- rec = "sim"
76
- if days_left == 0 and facts["sim_taken"]: # the test day: nothing left to plan
77
- return {"kind": "continue"}
78
- for step in order[order.index(rec):]: # the recommended step, then later ones, early
79
- if not done[step] and can(step):
80
- return make(step)
81
- return {"kind": "continue"}
82
-
83
-
84
- def closest(tests: list[dict], today: date) -> dict | None:
85
- """The nearest test that has not passed (its day is today or later)."""
86
- coming = [t for t in tests if t["date"] >= today]
87
- return min(coming, key=lambda t: (t["date"], t["id"])) if coming else None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
mathly2/exam.py CHANGED
@@ -7,6 +7,15 @@ import random
7
  EXERCISE_SHARE = {1: 0.6, 2: 0.3} # the share of questions that come from weak skills in exercise test 1 and 2; the simulation has none
8
  FOCUS = 3 # the exercise test on the weak topics: only the skills that lost points or are still being learned
9
  FOCUS_SIZE = 15
 
 
 
 
 
 
 
 
 
10
 
11
 
12
  def _round(x: float) -> int:
@@ -60,7 +69,7 @@ def draw(bank, lessons: list[str], states: dict[str, str], n: int, *, share: flo
60
 
61
 
62
  def draw_focus(bank, weak: list[str], n: int = FOCUS_SIZE, used: dict[str, int] | None = None, rng: random.Random | None = None) -> list[dict]:
63
- """`n` questions from the weak skills only, one per skill round after round (least-used questions first)."""
64
  used, rng = used or {}, rng or random.Random()
65
  pools = {i: sorted(bank.question_keys(i), key=lambda key: (used.get(key, 0), rng.random())) for i in weak if bank.question_keys(i)}
66
  order, taken, seen = rng.sample(list(pools), len(pools)), [], set()
 
7
  EXERCISE_SHARE = {1: 0.6, 2: 0.3} # the share of questions that come from weak skills in exercise test 1 and 2; the simulation has none
8
  FOCUS = 3 # the exercise test on the weak topics: only the skills that lost points or are still being learned
9
  FOCUS_SIZE = 15
10
+ BALANCED, MANUAL = 1, 4 # practice tests by mode (the number in the attempt's scope "<test>:<n>"); 2 is an older balanced one
11
+ MODE_OF = {1: "balanced", 2: "balanced", FOCUS: "focus", MANUAL: "manual"}
12
+ NUMBER_OF = {"balanced": BALANCED, "focus": FOCUS, "manual": MANUAL}
13
+ TITLES = {"balanced": "ืžื‘ื—ืŸ ืชืจื’ื•ืœ ืžืื•ื–ืŸ", "focus": "ืžื‘ื—ืŸ ืชืจื’ื•ืœ ืžืžื•ืงื“", "manual": "ืžื‘ื—ืŸ ืชืจื’ื•ืœ ื‘ื‘ื—ื™ืจื”"}
14
+
15
+
16
+ def mode_of_scope(scope: str) -> str:
17
+ _, _, number = scope.partition(":")
18
+ return MODE_OF.get(int(number), "balanced") if number.isdigit() else "balanced"
19
 
20
 
21
  def _round(x: float) -> int:
 
69
 
70
 
71
  def draw_focus(bank, weak: list[str], n: int = FOCUS_SIZE, used: dict[str, int] | None = None, rng: random.Random | None = None) -> list[dict]:
72
+ """`n` questions from these skills only (the weak ones, or the ones the student picked), one per skill round after round (least-used first)."""
73
  used, rng = used or {}, rng or random.Random()
74
  pools = {i: sorted(bank.question_keys(i), key=lambda key: (used.get(key, 0), rng.random())) for i in weak if bank.question_keys(i)}
75
  order, taken, seen = rng.sample(list(pools), len(pools)), [], set()
mathly2/progress.py CHANGED
@@ -115,8 +115,11 @@ def class_progress(class_id: int, request: Request, period: int | None = None, u
115
  for m in members:
116
  facts = facts_for(request, m["user_id"], nearest, today)
117
  ready = readiness_for(facts)
118
- rows.append({"email": m["email"], "signed_in": bool(m["user_id"]), "readiness": ready, "mastery": ready["parts"]["M"],
119
- "practice": ready["parts"]["E"], "simulation": ready["parts"]["S"]})
 
 
 
120
  names = {l["id"]: l["name"] for l in bank.lessons}
121
  test = {"id": nearest["id"], "date": nearest["date"].isoformat(), "days_left": (nearest["date"] - today).days,
122
  "lessons": [names.get(i, i) for i in nearest["lessons"]], "students": rows}
 
115
  for m in members:
116
  facts = facts_for(request, m["user_id"], nearest, today)
117
  ready = readiness_for(facts)
118
+ covered = len(facts["covered"])
119
+ rows.append({"email": m["email"], "signed_in": bool(m["user_id"]), "readiness": ready,
120
+ "mastery": round(100 * facts["mastered"] / covered) if covered else None, # shown beside it; not part of it
121
+ "practice": ready["practice_best"], "simulation": ready["simulation_best"],
122
+ "practices": ready["practices"], "simulations": ready["simulations"]})
123
  names = {l["id"]: l["name"] for l in bank.lessons}
124
  test = {"id": nearest["id"], "date": nearest["date"].isoformat(), "days_left": (nearest["date"] - today).days,
125
  "lessons": [names.get(i, i) for i in nearest["lessons"]], "students": rows}
mathly2/quizzes.py CHANGED
@@ -41,7 +41,10 @@ def _test_info(request: Request, row: dict) -> dict:
41
  test_id, _, number = row["scope"].partition(":")
42
  test = request.app.state.store.get_test(int(test_id)) if test_id.isdigit() else None
43
  names = [l["name"] for l in request.app.state.bank.lessons if test and l["id"] in test["lessons"]]
 
 
44
  return {"id": int(test_id) if test_id.isdigit() else None, "number": int(number) if number.isdigit() else None,
 
45
  "date": test["date"].isoformat() if test else None, "lessons": names, "minutes": test["minutes"] if test else None}
46
 
47
 
@@ -63,8 +66,9 @@ def view(request: Request, row: dict) -> dict:
63
  row = boardreview.ensure(request.app, row) # a practice test or the simulation: the AI reviews the way
64
  result = attempts.result(bank, row)
65
  result.pop("lesson", None)
 
66
  return {**base, "state": "submitted", **result, "submitted_at": _iso(row["submitted_at"]), "review": boardreview.student_view(row),
67
- "keys": [q["key"] for q in row["questions"]]}
68
 
69
 
70
  _view = view
@@ -157,7 +161,9 @@ def grades(request: Request, user: dict = Depends(learner), subject: str = Query
157
  for row in here.attempts_of(uid, kind):
158
  row = attempts.settle(store, bank, row, now)
159
  if not attempts.is_open(row):
160
- groups.setdefault((kind, row["scope"]), []).append(row)
 
 
161
  exercise, simulations = [], []
162
  for (kind, scope), rows in groups.items():
163
  test_id, _, number = scope.partition(":")
@@ -167,7 +173,8 @@ def grades(request: Request, user: dict = Depends(learner), subject: str = Query
167
  item = {"test": _test_ref(request, test), "best": max(r["grade"] for r in rows), "attempts": len(rows),
168
  "last": _iso(max(r["submitted_at"] for r in rows))}
169
  if kind == "exercise_test":
170
- exercise.append({**item, "number": int(number)})
 
171
  else:
172
  simulations.append(item)
173
  exercise.sort(key=lambda g: (g["test"]["date"], g["test"]["id"], g["number"]))
 
41
  test_id, _, number = row["scope"].partition(":")
42
  test = request.app.state.store.get_test(int(test_id)) if test_id.isdigit() else None
43
  names = [l["name"] for l in request.app.state.bank.lessons if test and l["id"] in test["lessons"]]
44
+ from .exam import TITLES, mode_of_scope
45
+ mode = mode_of_scope(row["scope"]) if row["kind"] == "exercise_test" else None
46
  return {"id": int(test_id) if test_id.isdigit() else None, "number": int(number) if number.isdigit() else None,
47
+ "mode": mode, "title": TITLES[mode] if mode else "ืžื‘ื—ืŸ ืกื™ืžื•ืœืฆื™ื”",
48
  "date": test["date"].isoformat() if test else None, "lessons": names, "minutes": test["minutes"] if test else None}
49
 
50
 
 
66
  row = boardreview.ensure(request.app, row) # a practice test or the simulation: the AI reviews the way
67
  result = attempts.result(bank, row)
68
  result.pop("lesson", None)
69
+ from .realtests import readiness_change
70
  return {**base, "state": "submitted", **result, "submitted_at": _iso(row["submitted_at"]), "review": boardreview.student_view(row),
71
+ "keys": [q["key"] for q in row["questions"]], "readiness": readiness_change(request, row)}
72
 
73
 
74
  _view = view
 
161
  for row in here.attempts_of(uid, kind):
162
  row = attempts.settle(store, bank, row, now)
163
  if not attempts.is_open(row):
164
+ from .exam import mode_of_scope
165
+ key = row["scope"] if kind == "simulation" else f"{row['scope'].split(':')[0]}:{mode_of_scope(row['scope'])}" # by mode
166
+ groups.setdefault((kind, key), []).append(row)
167
  exercise, simulations = [], []
168
  for (kind, scope), rows in groups.items():
169
  test_id, _, number = scope.partition(":")
 
173
  item = {"test": _test_ref(request, test), "best": max(r["grade"] for r in rows), "attempts": len(rows),
174
  "last": _iso(max(r["submitted_at"] for r in rows))}
175
  if kind == "exercise_test":
176
+ from .exam import NUMBER_OF, TITLES
177
+ exercise.append({**item, "number": NUMBER_OF[number], "mode": number, "title": TITLES[number]})
178
  else:
179
  simulations.append(item)
180
  exercise.sort(key=lambda g: (g["test"]["date"], g["test"]["id"], g["number"]))
mathly2/readiness.py CHANGED
@@ -1,42 +1,46 @@
1
- """The readiness number for a real test (PRD RDY-1 to RDY-3): one 0-100 number from mastery, exercise tests and the simulation."""
 
 
 
 
 
 
 
2
  from __future__ import annotations
3
 
4
- WEIGHTS = {"M": 50, "E": 20, "S": 30}
5
- CAP = 80 # the most the number can show before the simulation is taken
6
 
7
 
8
  def label(number: int) -> str:
9
- if number < 40:
10
- return "ืžืชื—ื™ืœื™ื"
11
- if number < 70:
12
- return "ื‘ื“ืจืš"
13
- if number < 85:
14
- return "ื›ืžืขื˜ ืฉื"
15
- return "ืžื•ื›ื ื™ื"
16
-
17
-
18
- def compute(m: float | None, e: float | None, s: float | None) -> dict:
19
- """M: % of the covered skills mastered. E: average of the best grade of each exercise test taken. S: the simulation grade.
20
- A part that does not exist is None and is left out; the others are scaled up. Without S the number is capped at 80."""
21
- parts = {"M": m, "E": e, "S": s}
22
- have = {k: v for k, v in parts.items() if v is not None}
23
- if not have:
24
- number = 0
25
- else:
26
- number = int(sum(WEIGHTS[k] * v for k, v in have.items()) / sum(WEIGHTS[k] for k in have) + 0.5)
27
- capped = s is None and number > CAP
28
- if s is None:
29
- number = min(number, CAP)
30
- number = max(0, min(100, number))
31
- return {"number": number, "label": label(number), "capped": capped, "waiting_for_simulation": s is None,
32
- "parts": {k: (None if v is None else int(v + 0.5)) for k, v in parts.items()}}
33
-
34
-
35
- def mastery_percent(mastered: int, covered: int) -> float | None:
36
- """M: None when the test covers no skill with questions."""
37
- return None if covered <= 0 else 100 * mastered / covered
38
-
39
-
40
- def exercise_average(best_grades: list[int]) -> float | None:
41
- """E: the average of the best grade of each exercise test taken; None when none was taken."""
42
- return sum(best_grades) / len(best_grades) if best_grades else None
 
1
+ """The readiness number for a real test: one 0-100 number that only tests move (practice tests and simulations), never the path.
2
+
3
+ Every handed-in test pulls the number toward its grade, and only up: a grade above the number closes part of the gap, a grade at or below
4
+ it changes nothing. A simulation closes more of the gap than a practice test; a practice test on skills the student picked closes less
5
+ (it can skip the hard ones). So the number is never above the best grades, and the way up is more good tests, best of all a simulation.
6
+
7
+ number' = number + GAIN[kind] * (grade - number) when grade > number
8
+ """
9
  from __future__ import annotations
10
 
11
+ GAIN = {"simulation": 0.6, "practice": 0.3, "manual": 0.15}
12
+ LEVELS = [(0, "ืžืชื—ื™ืœื™ื"), (40, "ื‘ื“ืจืš"), (70, "ื›ืžืขื˜ ืฉื"), (85, "ืžื•ื›ื ื™ื")]
13
 
14
 
15
  def label(number: int) -> str:
16
+ return [name for at, name in LEVELS if number >= at][-1]
17
+
18
+
19
+ def next_level(number: int) -> dict | None:
20
+ """The next level above the number: {"at", "name", "left"} (points left), or None at the top."""
21
+ above = [(at, name) for at, name in LEVELS if at > number]
22
+ return {"at": above[0][0], "name": above[0][1], "left": above[0][0] - number} if above else None
23
+
24
+
25
+ def step(value: float, kind: str, grade: float) -> float:
26
+ return value + GAIN[kind] * (grade - value) if grade > value else value
27
+
28
+
29
+ def compute(results: list[tuple[str, int]]) -> dict:
30
+ """`results`: the handed-in tests of one real test, oldest first, as (kind, grade) with kind simulation, practice or manual."""
31
+ value = 0.0
32
+ for kind, grade in results:
33
+ value = step(value, kind, grade)
34
+ number = max(0, min(100, int(value + 0.5)))
35
+ best = lambda kinds: max((g for k, g in results if k in kinds), default=None)
36
+ return {"number": number, "label": label(number), "next": next_level(number),
37
+ "practices": sum(1 for k, _ in results if k != "simulation"), "simulations": sum(1 for k, _ in results if k == "simulation"),
38
+ "practice_best": best(("practice", "manual")), "simulation_best": best(("simulation",)),
39
+ "can_add": {k: max(0, int(GAIN[k] * (100 - value) + 0.5)) for k in GAIN}} # what a perfect test of each kind would add now
40
+
41
+
42
+ def change(before: list[tuple[str, int]], result: tuple[str, int]) -> dict:
43
+ """What one handed-in test did to the number: before, after, the gain, and whether it reached a new level."""
44
+ old, new = compute(before), compute([*before, result])
45
+ return {"before": old["number"], "after": new["number"], "gain": new["number"] - old["number"], "label": new["label"],
46
+ "level_up": new["label"] != old["label"], "next": new["next"]}
 
 
 
mathly2/realtests.py CHANGED
@@ -1,5 +1,6 @@
1
- """Real tests: a teacher sets one for a class; every student of the class gets a countdown, two exercise tests, one simulation and a
2
- readiness number from it. Nothing is created per student: it is all derived from the test, the student's skill states and their attempts."""
 
3
  from __future__ import annotations
4
 
5
  from datetime import date, timedelta
@@ -8,7 +9,7 @@ from datetime import date as Day
8
  from fastapi import APIRouter, Depends, HTTPException, Query, Request
9
  from pydantic import BaseModel, Field
10
 
11
- from . import attempts, clock, countdown, exam, readiness, reads
12
  from .access import allowed, learner, need
13
  from .learning import MASTERED, next_skill, skill_state
14
 
@@ -68,95 +69,89 @@ def _student_reads(user_id: str) -> list[tuple]:
68
  return [("progress_of", user_id), ("last_answered", user_id), ("attempts_of", user_id, "exercise_test"), ("attempts_of", user_id, "simulation")]
69
 
70
 
 
 
 
 
 
 
 
71
  def facts_for(request: Request, user_id: str | None, test: dict, today: date) -> dict:
72
- """Everything the plan, the readiness meter and the draws need about one student and one test."""
73
  store, bank, now, here = request.app.state.store, request.app.state.bank, clock.now(), reads.of(request)
74
  covered = _covered(bank, test)
75
  if user_id:
76
- here.prefetch(*_student_reads(user_id)) # the four reads together (done once per request: a second test reuses them)
77
  states = {p["skill_id"]: skill_state(p) for p in here.progress_of(user_id)} if user_id else {}
78
- last = here.last_answered(user_id) if user_id else {}
79
  tid = str(test["id"])
80
 
81
  def mine(kind: str) -> list[dict]:
82
  rows = here.attempts_of(user_id, kind) if user_id else []
83
  return [attempts.settle(store, bank, r, now) for r in rows if r["scope"] == tid or r["scope"].startswith(tid + ":")]
84
 
85
- exercise, focus = {1: [], 2: []}, []
86
- for row in mine("exercise_test"):
87
- number = int(row["scope"].split(":")[1])
88
- (focus if number == exam.FOCUS else exercise[number]).append(row)
89
- simulation = next(iter(mine("simulation")), None)
90
- submitted = lambda rows: [r for r in rows if not attempts.is_open(r)]
91
- best = {n: max((r["grade"] for r in submitted(rows)), default=None) for n, rows in exercise.items()}
92
- weak_before = {s["id"] for rows in exercise.values() for r in submitted(rows) for s in attempts.result(bank, r)["lost_skills"]}
93
- failed: list[str] = []
94
- review_left: list[str] = []
95
- if simulation and not attempts.is_open(simulation):
96
- failed = [s["id"] for s in attempts.result(bank, simulation)["lost_skills"]]
97
- sim_day = clock.school_day(simulation["submitted_at"])
98
- review_left = [i for i in failed if last.get(i) is None or last[i] <= sim_day] # not practised after the simulation
99
- mastered = [i for i in covered if states.get(i) == MASTERED]
100
- unmastered = [i for i in covered if states.get(i) != MASTERED]
101
- sim_taken = bool(simulation and not attempts.is_open(simulation))
102
- stale = sorted(covered, key=lambda i: (last.get(i) is not None, last.get(i) or date.min))
103
  used: dict[str, int] = {}
104
- for rows in list(exercise.values()) + [focus, [simulation] if simulation else []]:
105
- for r in rows:
106
- for q in r["questions"]:
107
- used[q["key"]] = used.get(q["key"], 0) + 1
108
- return {"covered": covered, "states": states, "mastered": len(mastered), "path_done": bool(covered) and not unmastered,
109
- "next_path_skill": unmastered[0] if unmastered else None, "exercise": exercise, "best": best, "simulation": simulation,
110
- "sim_taken": sim_taken, "ex1_done": bool(submitted(exercise[1])), "ex2_done": bool(submitted(exercise[2])),
111
- "weak_before": weak_before, "failed": failed, "review_left": review_left, "stale": stale, "used": used, "focus": focus,
112
- "focus_skills": _focus_skills(bank, covered, states, weak_before | set(failed) | {s["id"] for r in submitted(focus) for s in attempts.result(bank, r)["lost_skills"]})}
113
 
114
 
115
- def _focus_skills(bank, covered: list[str], states: dict[str, str], lost: set[str]) -> list[str]:
116
- """The weak topics of a test: covered skills that lost points in its exercise tests or simulation, or are still being learned."""
117
- return [i for i in covered if (i in lost or states.get(i) == "learning") and bank.question_keys(i)]
118
 
119
 
120
- def readiness_for(facts: dict) -> dict:
121
- m = readiness.mastery_percent(facts["mastered"], len(facts["covered"]))
122
- e = readiness.exercise_average([g for g in facts["best"].values() if g is not None])
123
- s = facts["simulation"]["grade"] if facts["sim_taken"] else None
124
- return readiness.compute(m, e, s)
 
 
 
 
 
 
125
 
126
 
127
- def plan_for(test: dict) -> dict:
128
- return countdown.plan(test["date"], clock.school_day(test["created_at"]))
129
 
130
 
131
  def card(request: Request, user_id: str | None, test: dict, today: date) -> dict:
132
- """A test as its student sees it: the plan, readiness, both exercise tests, the simulation, and today's task."""
 
133
  bank = request.app.state.bank
134
  facts = facts_for(request, user_id, test, today)
135
- p = plan_for(test)
136
  days_left = (test["date"] - today).days
137
- exercises = []
138
- for n in (1, 2):
139
- rows = facts["exercise"][n]
140
- open_ = next((r for r in rows if attempts.is_open(r)), None)
141
- exercises.append({"number": n, "best": facts["best"][n], "attempts": len([r for r in rows if not attempts.is_open(r)]),
142
- "open": bool(open_), "due_at": open_["due_at"].isoformat() if open_ and attempts.is_timed(open_) else None})
143
- sim = facts["simulation"]
144
- opens_in = max(0, days_left - p["sim"])
145
- focus_open = next((r for r in facts["focus"] if attempts.is_open(r)), None)
146
- focus_done = [r for r in facts["focus"] if not attempts.is_open(r)]
147
- focus = {"number": exam.FOCUS, "size": exam.FOCUS_SIZE, "skills": [{"id": i, "name": bank.skill(i)["name"]} for i in facts["focus_skills"]],
148
- "best": max((r["grade"] for r in focus_done), default=None), "attempts": len(focus_done), "open": bool(focus_open)}
149
  return {"id": test["id"], "date": test["date"].isoformat(), "days_left": days_left, "note": test["note"], "subject_id": test.get("subject_id", "math"),
150
  "lessons": [{"id": i, "name": n} for i, n in zip(test["lessons"], lesson_names(request, test["lessons"]))],
151
- "questions": test["questions"], "minutes": test["minutes"],
152
- "plan": countdown.outline(p, facts), "task": countdown.task(days_left, p, facts), "readiness": readiness_for(facts),
153
- "exercise_tests": exercises, "focus": focus,
154
- "practice_first": [{"id": i, "name": bank.skill(i)["name"]} for i in facts["covered"] if facts["states"].get(i, "not_started") == "not_started"],
155
- "simulation": {"open_now": countdown.sim_open(days_left, p), "opens_in_days": opens_in,
156
- "opens_on": (test["date"] - timedelta(days=p["sim"])).isoformat(), "required": countdown.sim_open(days_left, p) and not facts["sim_taken"],
157
- "taken": facts["sim_taken"], "grade": sim["grade"] if facts["sim_taken"] else None,
158
- "attempt": sim["id"] if sim else None, "in_progress": bool(sim and attempts.is_open(sim)),
159
- "review": [{"id": i, "name": bank.skill(i)["name"]} for i in facts["failed"] if bank.skill(i)]}}
 
160
 
161
 
162
  # ---- the teacher: set, change, remove, and see who took the simulation ----
@@ -201,7 +196,7 @@ def teacher_view(request: Request, test: dict, klass: dict, today: date) -> dict
201
  for m in members:
202
  facts = facts_for(request, m["user_id"], test, today)
203
  students.append({"email": m["email"], "signed_in": bool(m["user_id"]), "simulation_taken": facts["sim_taken"],
204
- "readiness": readiness_for(facts)})
205
  taken = sum(1 for s in students if s["simulation_taken"])
206
  return {"id": test["id"], "class": {"id": klass["id"], "name": klass["name"]}, "date": test["date"].isoformat(), "note": test["note"],
207
  "subject_id": test.get("subject_id", "math"),
@@ -263,11 +258,6 @@ def remove_test(test_id: int, request: Request, user: dict = Depends(need("class
263
 
264
  # ---- the student: home and a test's card ----
265
 
266
- def _with_skill_name(request: Request, task: dict) -> dict:
267
- skill = request.app.state.bank.skill(task["skill"]) if task.get("skill") else None
268
- return {**task, "skill_name": skill["name"] if skill else None}
269
-
270
-
271
  def _next_path_skill(request: Request, user: dict, subject: str = "math") -> str | None:
272
  """The suggested next skill of the student's path in this subject (the same rule as the path's owl)."""
273
  from .skills import RECENT, visible_lesson_ids
@@ -281,8 +271,7 @@ def _next_path_skill(request: Request, user: dict, subject: str = "math") -> str
281
 
282
  @router.get("/home")
283
  def home(request: Request, user: dict = Depends(learner), subject: str = Query("math", max_length=40)) -> dict:
284
- """The student's home: the upcoming tests (nearest first) with their readiness, today's one task from the nearest test,
285
- the required simulation, and the next skill of the path."""
286
  here, today, uid = reads.of(request), clock.school_day(), user["id"]
287
  class_ids = [c["id"] for c in here.classes_of_student(user["email"])]
288
  from . import rounds
@@ -291,15 +280,11 @@ def home(request: Request, user: dict = Depends(learner), subject: str = Query("
291
  here.prefetch(("tests_of_classes", class_ids), ("recent_skills", uid, RECENT), ("attempts_of", uid, "quiz"), ("attempts_of", uid, rounds.KIND), ("curriculum",), ("daily_correct", uid), *_student_reads(uid)) # the tests and the student's facts: one wait
292
  tests = [t for t in here.tests_of_classes(class_ids) if t["date"] >= today and t.get("subject_id", "math") == subject]
293
  cards = [card(request, user["id"], t, today) for t in tests]
294
- nearest = cards[0] if cards else None
295
  next_id = _next_path_skill(request, user, subject)
296
  next_skill_row = request.app.state.bank.skill(next_id) if next_id else None
297
- return {"tests": cards, "task": {"test_id": nearest["id"], **_with_skill_name(request, nearest["task"])} if nearest else None,
298
- "required_simulations": [{"test_id": c["id"], "date": c["date"], "days_left": c["days_left"]} for c in cards if c["simulation"]["required"]],
299
  "next_skill": {"id": next_id, "name": next_skill_row["name"]} if next_skill_row else None,
300
- "behind": behind_lessons(request, user, subject), "subject": subject, "map": lesson_map(request, user, subject),
301
- "weak_tests": [{"test_id": c["id"], "date": c["date"], "days_left": c["days_left"], "number": e["number"], "best": e["best"], "open": e["open"]}
302
- for c in cards[:1] for e in c["exercise_tests"] if e["best"] is not None and e["best"] < attempts.PASS_GRADE]} # the nearest test's card only
303
 
304
 
305
  def _student_test(request: Request, user: dict, test_id: int) -> dict:
@@ -317,54 +302,77 @@ def test_card(test_id: int, request: Request, user: dict = Depends(learner)) ->
317
  return card(request, user["id"], _student_test(request, user, test_id), clock.school_day())
318
 
319
 
320
- # ---- starting an exercise test or the simulation (reading, answering and submitting use the /api/attempts routes) ----
 
 
 
 
 
 
 
 
 
 
 
321
 
322
- def _start(request: Request, user: dict, test: dict, kind: str, scope: str, share: float, include_unstarted: bool = False) -> dict:
323
- """Resume the student's open attempt of this scope, else draw a new one (questions weighted toward weak skills when `share`)."""
324
  from .quizzes import view
325
- store, bank, now = request.app.state.store, request.app.state.bank, clock.now()
326
  facts = facts_for(request, user["id"], test, clock.school_day())
327
- number = int(scope.split(":")[1]) if kind == "exercise_test" else None
328
- mine = facts["focus"] if number == exam.FOCUS else facts["exercise"][number] if kind == "exercise_test" else [facts["simulation"]] if facts["simulation"] else []
329
- for row in mine:
330
- if attempts.is_open(row) or kind == "simulation": # open: resume; the simulation is taken once, so its result comes back
331
- return view(request, row)
332
- if number == exam.FOCUS: # the weak topics only, FOCUS_SIZE questions, untimed like the others
 
 
333
  if not facts["focus_skills"]:
334
- raise HTTPException(409, "ืื™ืŸ ื›ืจื’ืข ื ื•ืฉืื™ื ืœื—ื™ื–ื•ืง ื‘ืžื‘ื—ืŸ ื”ื–ื”. ื›ืœ ื”ื›ื‘ื•ื“!")
335
- drawn = exam.draw_focus(bank, facts["focus_skills"], used=facts["used"], rng=getattr(request.app.state, "quiz_rng", None))
336
- return view(request, store.create_attempt(user["id"], kind, scope, now, now + timedelta(minutes=test["minutes"]), drawn))
337
- drawn = exam.draw(bank, test["lessons"], facts["states"], test["questions"], share=share, weak_before=facts["weak_before"], used=facts["used"],
338
- include_unstarted=include_unstarted)
339
- if not drawn["questions"] and kind == "exercise_test" and not include_unstarted: # nothing started yet: all the material, like the simulation
340
- drawn = exam.draw(bank, test["lessons"], facts["states"], test["questions"], share=0.0, weak_before=facts["weak_before"],
341
- used=facts["used"], include_unstarted=True)
342
- if not drawn["questions"]:
343
- raise HTTPException(409, "ืขื•ื“ ืœื ื”ืชื—ืœืชื ืžื™ื•ืžื ื•ื™ื•ืช ื‘ืฉื™ืขื•ืจื™ื ืฉืœ ื”ืžื‘ื—ืŸ. ืงื•ื“ื ืžืชืจื’ืœื™ื ื‘ืžืกืœื•ืœ.")
344
- row = store.create_attempt(user["id"], kind, scope, now, now + timedelta(minutes=test["minutes"]), drawn["questions"])
345
- return view(request, row)
 
 
 
 
 
 
 
346
 
347
 
348
  @router.post("/tests/{test_id}/exercise/{number}/start")
349
  def start_exercise(test_id: int, number: int, request: Request, user: dict = Depends(learner),
350
- all_skills: bool = Query(False, description="the simulation's material: every skill of the test's lessons, started or not, evenly")) -> dict:
351
- if number not in exam.EXERCISE_SHARE and number != exam.FOCUS:
 
 
352
  raise HTTPException(404, "ืžื‘ื—ืŸ ื”ืชืจื’ื•ืœ ืœื ื ืžืฆื")
353
- test = _student_test(request, user, test_id)
354
- if number == exam.FOCUS:
355
- return _start(request, user, test, "exercise_test", f"{test_id}:{number}", 1.0)
356
- share = 0.0 if all_skills else exam.EXERCISE_SHARE[number] # all_skills: drawn like the simulation (an even spread, no weak share)
357
- return _start(request, user, test, "exercise_test", f"{test_id}:{number}", share, all_skills)
358
 
359
 
360
  @router.post("/tests/{test_id}/simulation/start")
361
  def start_simulation(test_id: int, request: Request, user: dict = Depends(learner),
362
- all_skills: bool = Query(False, description="a bypass: every skill of the test's lessons, started or not")) -> dict:
 
 
 
363
  test = _student_test(request, user, test_id)
364
- today = clock.school_day()
365
- p = plan_for(test)
366
- existing = facts_for(request, user["id"], test, today)["simulation"]
367
- if not existing and not countdown.sim_open(( test["date"] - today).days, p):
368
- opens = test["date"] - timedelta(days=p["sim"])
369
- raise HTTPException(409, f"ืžื‘ื—ืŸ ื”ืกื™ืžื•ืœืฆื™ื” ื™ื™ืคืชื— ื‘ึพ{opens.day}.{opens.month}" if today < opens else "ืžื‘ื—ืŸ ื”ืกื™ืžื•ืœืฆื™ื” ื ืกื’ืจ")
370
- return _start(request, user, test, "simulation", str(test_id), 0.0, all_skills)
 
 
 
1
+ """Real tests: a teacher sets one for a class; every student of the class can ask for practice tests (balanced, focused on the weak skills,
2
+ or on skills they pick) and simulations (all the material, timed, as many as they like), and has a readiness number that only these tests
3
+ move (readiness.py). Nothing is created per student: it is all derived from the test, the student's skill states and their attempts."""
4
  from __future__ import annotations
5
 
6
  from datetime import date, timedelta
 
9
  from fastapi import APIRouter, Depends, HTTPException, Query, Request
10
  from pydantic import BaseModel, Field
11
 
12
+ from . import attempts, clock, exam, readiness, reads
13
  from .access import allowed, learner, need
14
  from .learning import MASTERED, next_skill, skill_state
15
 
 
69
  return [("progress_of", user_id), ("last_answered", user_id), ("attempts_of", user_id, "exercise_test"), ("attempts_of", user_id, "simulation")]
70
 
71
 
72
+ def _kind_of(row: dict) -> str:
73
+ """How a handed-in test moves readiness: simulation, practice, or manual (a practice test on skills the student picked)."""
74
+ if row["kind"] == "simulation":
75
+ return "simulation"
76
+ return "manual" if exam.mode_of_scope(row["scope"]) == "manual" else "practice"
77
+
78
+
79
  def facts_for(request: Request, user_id: str | None, test: dict, today: date) -> dict:
80
+ """Everything the card, the readiness number and the draws need about one student and one test."""
81
  store, bank, now, here = request.app.state.store, request.app.state.bank, clock.now(), reads.of(request)
82
  covered = _covered(bank, test)
83
  if user_id:
84
+ here.prefetch(*_student_reads(user_id)) # the reads together (done once per request: a second test reuses them)
85
  states = {p["skill_id"]: skill_state(p) for p in here.progress_of(user_id)} if user_id else {}
 
86
  tid = str(test["id"])
87
 
88
  def mine(kind: str) -> list[dict]:
89
  rows = here.attempts_of(user_id, kind) if user_id else []
90
  return [attempts.settle(store, bank, r, now) for r in rows if r["scope"] == tid or r["scope"].startswith(tid + ":")]
91
 
92
+ practice, simulations = mine("exercise_test"), mine("simulation")
93
+ done = sorted((r for r in practice + simulations if not attempts.is_open(r)), key=lambda r: r["submitted_at"])
94
+ lost = {s["id"] for r in done for s in attempts.result(bank, r)["lost_skills"]}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  used: dict[str, int] = {}
96
+ for r in practice + simulations:
97
+ for q in r["questions"]:
98
+ used[q["key"]] = used.get(q["key"], 0) + 1
99
+ return {"covered": covered, "states": states, "mastered": sum(1 for i in covered if states.get(i) == MASTERED),
100
+ "practice": practice, "simulations": simulations, "done": done, "results": [(_kind_of(r), r["grade"]) for r in done],
101
+ "sim_taken": any(r["kind"] == "simulation" for r in done), "used": used,
102
+ "focus_skills": [i for i in covered if (i in lost or states.get(i) == "learning") and bank.question_keys(i)]}
 
 
103
 
104
 
105
+ def readiness_for(facts: dict) -> dict:
106
+ return readiness.compute(facts["results"])
 
107
 
108
 
109
+ def readiness_change(request: Request, row: dict) -> dict | None:
110
+ """For a handed-in practice test or simulation: what it did to its test's readiness number (before, after, gain, a new level)."""
111
+ if row["kind"] not in ("exercise_test", "simulation") or attempts.is_open(row):
112
+ return None
113
+ test_id = row["scope"].split(":")[0]
114
+ test = request.app.state.store.get_test(int(test_id)) if test_id.isdigit() else None
115
+ if test is None:
116
+ return None
117
+ facts = facts_for(request, row["user_id"], test, clock.school_day())
118
+ before = [(_kind_of(r), r["grade"]) for r in facts["done"] if r["submitted_at"] < row["submitted_at"]]
119
+ return readiness.change(before, (_kind_of(row), row["grade"]))
120
 
121
 
122
+ def _title(row: dict) -> str:
123
+ return "ืžื‘ื—ืŸ ืกื™ืžื•ืœืฆื™ื”" if row["kind"] == "simulation" else exam.TITLES[exam.mode_of_scope(row["scope"])]
124
 
125
 
126
  def card(request: Request, user_id: str | None, test: dict, today: date) -> dict:
127
+ """A test as its student sees it: the readiness number and what moves it, the practice tests by mode, the simulations, and the trail
128
+ of handed-in tests with what each added."""
129
  bank = request.app.state.bank
130
  facts = facts_for(request, user_id, test, today)
 
131
  days_left = (test["date"] - today).days
132
+ skill_row = lambda i: {"id": i, "name": bank.skill(i)["name"]}
133
+ open_of = lambda rows: next((r["id"] for r in reversed(rows) if attempts.is_open(r)), None)
134
+ by_mode = {m: [r for r in facts["practice"] if exam.mode_of_scope(r["scope"]) == m] for m in exam.TITLES}
135
+ trail, before = [], []
136
+ for row in facts["done"]:
137
+ moved = readiness.change(before, (_kind_of(row), row["grade"]))
138
+ before.append((_kind_of(row), row["grade"]))
139
+ trail.append({"attempt": row["id"], "kind": row["kind"], "title": _title(row), "grade": row["grade"], "gain": moved["gain"],
140
+ "number": int(row["scope"].split(":")[1]) if row["kind"] == "exercise_test" else None,
141
+ "after": moved["after"], "at": row["submitted_at"].isoformat()})
142
+ lessons = {l["id"]: l["name"] for l in bank.lessons}
 
143
  return {"id": test["id"], "date": test["date"].isoformat(), "days_left": days_left, "note": test["note"], "subject_id": test.get("subject_id", "math"),
144
  "lessons": [{"id": i, "name": n} for i, n in zip(test["lessons"], lesson_names(request, test["lessons"]))],
145
+ "questions": test["questions"], "minutes": test["minutes"], "readiness": readiness_for(facts),
146
+ "practice": {"balanced": {"number": exam.BALANCED, "size": test["questions"], "open": open_of(by_mode["balanced"])},
147
+ "focus": {"number": exam.FOCUS, "size": exam.FOCUS_SIZE, "open": open_of(by_mode["focus"]),
148
+ "skills": [skill_row(i) for i in facts["focus_skills"]]},
149
+ "manual": {"number": exam.MANUAL, "size": test["questions"], "open": open_of(by_mode["manual"]),
150
+ "skills": [{**skill_row(i), "lesson": lessons.get(bank.skill(i)["unit"], ""),
151
+ "state": facts["states"].get(i, "not_started")} for i in facts["covered"]]}},
152
+ "simulation": {"open": open_of(facts["simulations"]), "taken": sum(1 for r in facts["done"] if r["kind"] == "simulation"),
153
+ "best": readiness_for(facts)["simulation_best"]},
154
+ "trail": trail[::-1]}
155
 
156
 
157
  # ---- the teacher: set, change, remove, and see who took the simulation ----
 
196
  for m in members:
197
  facts = facts_for(request, m["user_id"], test, today)
198
  students.append({"email": m["email"], "signed_in": bool(m["user_id"]), "simulation_taken": facts["sim_taken"],
199
+ "readiness": readiness_for(facts)}) # readiness: the number, and how many practice tests and simulations
200
  taken = sum(1 for s in students if s["simulation_taken"])
201
  return {"id": test["id"], "class": {"id": klass["id"], "name": klass["name"]}, "date": test["date"].isoformat(), "note": test["note"],
202
  "subject_id": test.get("subject_id", "math"),
 
258
 
259
  # ---- the student: home and a test's card ----
260
 
 
 
 
 
 
261
  def _next_path_skill(request: Request, user: dict, subject: str = "math") -> str | None:
262
  """The suggested next skill of the student's path in this subject (the same rule as the path's owl)."""
263
  from .skills import RECENT, visible_lesson_ids
 
271
 
272
  @router.get("/home")
273
  def home(request: Request, user: dict = Depends(learner), subject: str = Query("math", max_length=40)) -> dict:
274
+ """The student's home: the upcoming tests (nearest first) with their readiness, the lesson map, and the next skill of the path."""
 
275
  here, today, uid = reads.of(request), clock.school_day(), user["id"]
276
  class_ids = [c["id"] for c in here.classes_of_student(user["email"])]
277
  from . import rounds
 
280
  here.prefetch(("tests_of_classes", class_ids), ("recent_skills", uid, RECENT), ("attempts_of", uid, "quiz"), ("attempts_of", uid, rounds.KIND), ("curriculum",), ("daily_correct", uid), *_student_reads(uid)) # the tests and the student's facts: one wait
281
  tests = [t for t in here.tests_of_classes(class_ids) if t["date"] >= today and t.get("subject_id", "math") == subject]
282
  cards = [card(request, user["id"], t, today) for t in tests]
 
283
  next_id = _next_path_skill(request, user, subject)
284
  next_skill_row = request.app.state.bank.skill(next_id) if next_id else None
285
+ return {"tests": cards,
 
286
  "next_skill": {"id": next_id, "name": next_skill_row["name"]} if next_skill_row else None,
287
+ "behind": behind_lessons(request, user, subject), "subject": subject, "map": lesson_map(request, user, subject)}
 
 
288
 
289
 
290
  def _student_test(request: Request, user: dict, test_id: int) -> dict:
 
302
  return card(request, user["id"], _student_test(request, user, test_id), clock.school_day())
303
 
304
 
305
+ # ---- starting a practice test or a simulation (reading, answering and submitting use the /api/attempts routes) ----
306
+
307
+ class PracticeBody(BaseModel):
308
+ mode: str = Field(pattern="^(balanced|focus|manual)$")
309
+ skills: list[str] = Field(default_factory=list, max_length=200) # manual: the skills to practise
310
+
311
+
312
+ def _new(request: Request, user: dict, test: dict, kind: str, scope: str, questions: list[dict]) -> dict:
313
+ from .quizzes import view
314
+ now = clock.now()
315
+ return view(request, request.app.state.store.create_attempt(user["id"], kind, scope, now, now + timedelta(minutes=test["minutes"]), questions))
316
+
317
 
318
+ def _practice(request: Request, user: dict, test: dict, mode: str, skills: list[str] | None = None, number: int | None = None) -> dict:
319
+ """Resume this mode's open practice test (not a manual one: a new choice is a new test), else draw a new one."""
320
  from .quizzes import view
321
+ bank = request.app.state.bank
322
  facts = facts_for(request, user["id"], test, clock.school_day())
323
+ number = number or exam.NUMBER_OF[mode]
324
+ scope = f"{test['id']}:{number}"
325
+ if mode != "manual":
326
+ for row in facts["practice"]:
327
+ if row["scope"] == scope and attempts.is_open(row):
328
+ return view(request, row)
329
+ rng = getattr(request.app.state, "quiz_rng", None)
330
+ if mode == "focus":
331
  if not facts["focus_skills"]:
332
+ raise HTTPException(409, "ืื™ืŸ ื›ืจื’ืข ืžื™ื•ืžื ื•ื™ื•ืช ืฉื”ืชืงืฉื™ืชื ื‘ื”ืŸ. ื ืกื• ืžื‘ื—ืŸ ืžืื•ื–ืŸ ืื• ืกื™ืžื•ืœืฆื™ื”.")
333
+ drawn = exam.draw_focus(bank, facts["focus_skills"], used=facts["used"], rng=rng)
334
+ elif mode == "manual":
335
+ chosen = [i for i in dict.fromkeys(skills or []) if i in facts["covered"]]
336
+ if not chosen:
337
+ raise HTTPException(422, "ื‘ื—ืจื• ืœืคื—ื•ืช ืžื™ื•ืžื ื•ืช ืื—ืช ืžื”ื—ื•ืžืจ ืฉืœ ื”ืžื‘ื—ืŸ")
338
+ drawn = exam.draw_focus(bank, chosen, n=test["questions"], used=facts["used"], rng=rng)
339
+ else:
340
+ drawn = exam.draw(bank, test["lessons"], facts["states"], test["questions"], used=facts["used"], include_unstarted=True, rng=rng)["questions"]
341
+ if not drawn:
342
+ raise HTTPException(409, "ืื™ืŸ ืขื“ื™ื™ืŸ ืฉืืœื•ืช ื‘ื—ื•ืžืจ ืฉืœ ื”ืžื‘ื—ืŸ")
343
+ return _new(request, user, test, "exercise_test", scope, drawn)
344
+
345
+
346
+ @router.post("/tests/{test_id}/practice/start")
347
+ def start_practice(test_id: int, body: PracticeBody, request: Request, user: dict = Depends(learner)) -> dict:
348
+ """A practice test: balanced (all the material, evenly), focus (the skills that lost points or are still being learned, 15 questions),
349
+ or manual (the skills the student picked)."""
350
+ return _practice(request, user, _student_test(request, user, test_id), body.mode, body.skills)
351
 
352
 
353
  @router.post("/tests/{test_id}/exercise/{number}/start")
354
  def start_exercise(test_id: int, number: int, request: Request, user: dict = Depends(learner),
355
+ all_skills: bool = Query(False, description="kept for old links: every practice test now draws from all the material")) -> dict:
356
+ """An older address of a practice test by number (1, 2: balanced; 3: focus), as in a teacher's assignment."""
357
+ mode = exam.MODE_OF.get(number)
358
+ if mode is None or mode == "manual":
359
  raise HTTPException(404, "ืžื‘ื—ืŸ ื”ืชืจื’ื•ืœ ืœื ื ืžืฆื")
360
+ return _practice(request, user, _student_test(request, user, test_id), mode, number=number)
 
 
 
 
361
 
362
 
363
  @router.post("/tests/{test_id}/simulation/start")
364
  def start_simulation(test_id: int, request: Request, user: dict = Depends(learner),
365
+ all_skills: bool = Query(False, description="kept for old links: a simulation always draws from all the material")) -> dict:
366
+ """A simulation: all the test's material evenly, the test's length and time, no choice. As many as the student asks for; an open one
367
+ resumes."""
368
+ from .quizzes import view
369
  test = _student_test(request, user, test_id)
370
+ facts = facts_for(request, user["id"], test, clock.school_day())
371
+ for row in facts["simulations"]:
372
+ if attempts.is_open(row):
373
+ return view(request, row)
374
+ drawn = exam.draw(request.app.state.bank, test["lessons"], facts["states"], test["questions"], used=facts["used"], include_unstarted=True,
375
+ rng=getattr(request.app.state, "quiz_rng", None))["questions"]
376
+ if not drawn:
377
+ raise HTTPException(409, "ืื™ืŸ ืขื“ื™ื™ืŸ ืฉืืœื•ืช ื‘ื—ื•ืžืจ ืฉืœ ื”ืžื‘ื—ืŸ")
378
+ return _new(request, user, test, "simulation", str(test_id), drawn)
mathly2/static/app.css CHANGED
@@ -633,7 +633,7 @@ body:has(#lesson-map) p:has(> #to-subjects) { margin-block: 0 4px; }
633
  .today-btn { flex: 0 1 auto; max-width: 58%; min-height: 44px; display: flex; align-items: center; gap: 4px; padding: 6px 12px; border-radius: 12px; text-decoration: none; font-weight: 700; font-size: 14px;
634
  color: var(--accent-text); background: var(--accent); }
635
  .today-btn small { font-weight: 600; opacity: .8; }
636
- .hchips { display: flex; flex-wrap: wrap; gap: 6px; margin-block: 8px; }
637
  .hchips-ai { display: contents; }
638
  .hchip { margin: 0; display: inline-flex; align-items: center; gap: 4px; min-height: 40px; padding: 6px 12px; border-radius: 999px; border: 1px solid var(--line); background: var(--surface);
639
  color: var(--text); text-decoration: none; font-size: 14px; font-weight: 600; }
@@ -711,3 +711,59 @@ body:has(#lesson-map) p:has(> #to-subjects) { margin-block: 0 4px; }
711
  .rv-bar { height: 6px; border-radius: 3px; background: var(--line); overflow: hidden; margin: 6px 0; }
712
  .rv-bar i { display: block; height: 100%; background: var(--accent); transition: width .4s; }
713
  .work-ai-run { font-size: .9em; padding: 4px 10px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
633
  .today-btn { flex: 0 1 auto; max-width: 58%; min-height: 44px; display: flex; align-items: center; gap: 4px; padding: 6px 12px; border-radius: 12px; text-decoration: none; font-weight: 700; font-size: 14px;
634
  color: var(--accent-text); background: var(--accent); }
635
  .today-btn small { font-weight: 600; opacity: .8; }
636
+ .hchips { display: flex; flex-wrap: wrap; gap: 6px; margin-block: 4px; }
637
  .hchips-ai { display: contents; }
638
  .hchip { margin: 0; display: inline-flex; align-items: center; gap: 4px; min-height: 40px; padding: 6px 12px; border-radius: 999px; border: 1px solid var(--line); background: var(--surface);
639
  color: var(--text); text-decoration: none; font-size: 14px; font-weight: 600; }
 
711
  .rv-bar { height: 6px; border-radius: 3px; background: var(--line); overflow: hidden; margin: 6px 0; }
712
  .rv-bar i { display: block; height: 100%; background: var(--accent); transition: width .4s; }
713
  .work-ai-run { font-size: .9em; padding: 4px 10px; }
714
+
715
+ /* ---------- readiness: the home's test block, the test's page, the gain on a result ---------- */
716
+ .test-strip { flex-direction: column; align-items: stretch; gap: 4px; padding: 6px 10px; margin-block: 4px; }
717
+ .test-strip .strip-main { flex-direction: row; align-items: center; gap: 10px; }
718
+ .test-strip .strip-main .grow { display: flex; flex-direction: column; min-width: 0; line-height: 1.25; }
719
+ .test-strip .strip-main small { font-size: 12px; }
720
+ .strip-more { font-size: 18px; color: var(--accent); }
721
+ .test-strip .readiness { margin: 0; }
722
+ .test-strip .rd-num { font-size: 30px; line-height: 1; min-width: 1.4em; text-align: center; }
723
+ .test-strip .rd-bar { height: 5px; margin: 3px 0 0; }
724
+ .rd-top { align-items: center; }
725
+ .rd-next { font-size: 13px; }
726
+ .rd-up { display: inline-block; margin-inline-start: 6px; padding: 0 6px; border-radius: 999px; background: var(--sky-gold); color: #111; font-weight: 800; font-size: 13px;
727
+ animation: rd-pop .6s cubic-bezier(.2, 1.6, .4, 1) .9s both; }
728
+ .rd-bar { position: relative; }
729
+ .rd-bar i { transition: width .9s cubic-bezier(.2, .8, .2, 1); }
730
+ .rd-bar s { position: absolute; top: -3px; bottom: -3px; width: 2px; background: var(--text); opacity: .35; text-decoration: none; }
731
+ .readiness.grew .rd-num { animation: rd-pop .5s cubic-bezier(.2, 1.6, .4, 1); }
732
+ .rd-how-line { margin: 0; font-size: 13px; }
733
+ .test-btns { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
734
+ .test-btn { display: flex; align-items: center; justify-content: center; gap: 4px; min-height: 44px; padding: 4px 6px; border-radius: 12px; text-decoration: none; white-space: nowrap; font-size: 14px;
735
+ font-weight: 800; border: 1px solid var(--line); background: var(--surface); color: var(--text); }
736
+ .test-btn.sim { background: var(--accent); border-color: var(--accent); color: var(--on-accent, #fff); }
737
+ .test-btn small { font-weight: 800; font-size: 12px; padding: 0 6px; border-radius: 999px; background: color-mix(in srgb, var(--sky-gold) 30%, transparent); }
738
+ .gain-hint { display: block; font-size: 12px; font-weight: 700; opacity: .85; }
739
+ .big-btn { min-height: 52px; min-width: 220px; display: inline-flex; flex-direction: column; align-items: center; justify-content: center; }
740
+ .rd-how summary { cursor: pointer; color: var(--accent); font-weight: 700; margin-top: 6px; }
741
+ .rd-how ul { margin: 6px 0 0; padding-inline-start: 20px; font-size: 14px; }
742
+ .modes { display: grid; gap: 8px; margin-block: 8px; }
743
+ .mode { display: flex; gap: 10px; align-items: flex-start; padding: 10px; border: 1px solid var(--line); border-radius: 12px; cursor: pointer; }
744
+ .mode.on { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
745
+ .mode.off { opacity: .55; cursor: default; }
746
+ .mode input { margin-top: 4px; }
747
+ #focus-skills { display: flex; flex-wrap: wrap; gap: 6px; margin-block: 6px; }
748
+ #focus-skills[hidden], #manual-skills[hidden] { display: none; }
749
+ #manual-skills { border: 1px solid var(--line); border-radius: 12px; padding: 8px 10px; margin-block: 6px; }
750
+ .pick-lesson { margin: 8px 0 2px; font-weight: 700; }
751
+ .pick { display: flex; gap: 6px; align-items: center; min-height: 40px; }
752
+ .trail li a { display: flex; gap: 8px; align-items: center; padding: 8px 0; text-decoration: none; color: var(--text); border-bottom: 1px solid var(--line); }
753
+ .trail .grow { flex: 1; min-width: 0; }
754
+ .gain { min-width: 44px; text-align: center; font-weight: 800; color: var(--sky-gold); }
755
+ .gain.zero { color: var(--muted); font-weight: 600; }
756
+ .qz-ready { text-align: center; margin-block: 12px; padding: 12px; border-radius: 14px; border: 1px solid var(--line); background: color-mix(in srgb, var(--sky-gold) 8%, var(--surface)); }
757
+ .qz-ready p { margin: 4px 0; }
758
+ .qz-gain { font-size: 48px; line-height: 1; font-weight: 900; color: var(--sky-gold); text-shadow: 0 0 18px color-mix(in srgb, var(--sky-gold) 60%, transparent);
759
+ animation: rd-pop .7s cubic-bezier(.2, 1.6, .4, 1) .2s both; }
760
+ .qz-level { font-size: 18px; font-weight: 800; color: var(--sky-gold); animation: rd-pop .6s cubic-bezier(.2, 1.6, .4, 1) .9s both; }
761
+ .qz-more { display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; }
762
+ @media (prefers-reduced-motion: reduce) { .rd-up, .qz-gain, .qz-level, .readiness.grew .rd-num { animation: none; } .rd-bar i { transition: none; } }
763
+ .quest-stats .hchips { display: contents; }
764
+ .quest-stats .hchip, .quest-stats .hchips-ai .hchip { min-height: 30px; padding: 3px 8px; font-size: 14px; font-weight: 700; border-radius: 999px; }
765
+ .quest-stats { gap: 4px; }
766
+ .quest-stats span { padding: 3px 8px; }
767
+ .quest-stats .hchips { padding: 0; border: 0; background: none; }
768
+ .mode > span { flex: 1; min-width: 0; }
769
+ .mode input { flex: none; width: 20px; height: 20px; }
mathly2/static/grades.js CHANGED
@@ -25,7 +25,7 @@ function draw(view, { quizzes, exercise_tests: exercises = [], simulations = [],
25
  ${quizzes.length ? `<h3>ื‘ื—ื ื™ื</h3><ul class="plain grades" id="quiz-grades">${quizzes.map(g => row({ attrs: `data-lesson="${esc(g.lesson.id)}"`, href: `#/quiz/${esc(g.lesson.id)}`,
26
  title: fmt(g.lesson.name), best: g.best, attempts: g.attempts, last: g.last })).join("")}</ul>` : ""}
27
  ${exercises.length ? `<h3>ืžื‘ื—ื ื™ ืชืจื’ื•ืœ</h3><ul class="plain grades" id="exercise-grades">${exercises.map(g => row({ attrs: `data-test="${g.test.id}" data-number="${g.number}"`,
28
- href: `#/test/${g.test.id}`, title: testTitle(g.test, g.number === 3 ? "ืžื‘ื—ืŸ ืชืจื’ื•ืœ: ื ื•ืฉืื™ื ืœื—ื™ื–ื•ืง" : `ืžื‘ื—ืŸ ืชืจื’ื•ืœ ${g.number}`), best: g.best, attempts: g.attempts, last: g.last })).join("")}</ul>` : ""}
29
  ${assignments.length ? `<h3>ืžืฉื™ืžื•ืช</h3><ul class="plain grades" id="assignment-grades">${assignments.map(g => `<li data-assignment="${g.assignment.id}">
30
  <a href="#/assignment/${g.assignment.id}"><b>${fmt(g.assignment.title)}</b>${g.assignment.removed ? " (ื”ื•ืกืจื”)" : ""}</a>
31
  <span class="gr-num" aria-label="ื”ืฆื™ื•ืŸ ${g.grade}">${g.grade}</span>
 
25
  ${quizzes.length ? `<h3>ื‘ื—ื ื™ื</h3><ul class="plain grades" id="quiz-grades">${quizzes.map(g => row({ attrs: `data-lesson="${esc(g.lesson.id)}"`, href: `#/quiz/${esc(g.lesson.id)}`,
26
  title: fmt(g.lesson.name), best: g.best, attempts: g.attempts, last: g.last })).join("")}</ul>` : ""}
27
  ${exercises.length ? `<h3>ืžื‘ื—ื ื™ ืชืจื’ื•ืœ</h3><ul class="plain grades" id="exercise-grades">${exercises.map(g => row({ attrs: `data-test="${g.test.id}" data-number="${g.number}"`,
28
+ href: `#/test/${g.test.id}`, title: testTitle(g.test, g.title || `ืžื‘ื—ืŸ ืชืจื’ื•ืœ ${g.number}`), best: g.best, attempts: g.attempts, last: g.last })).join("")}</ul>` : ""}
29
  ${assignments.length ? `<h3>ืžืฉื™ืžื•ืช</h3><ul class="plain grades" id="assignment-grades">${assignments.map(g => `<li data-assignment="${g.assignment.id}">
30
  <a href="#/assignment/${g.assignment.id}"><b>${fmt(g.assignment.title)}</b>${g.assignment.removed ? " (ื”ื•ืกืจื”)" : ""}</a>
31
  <span class="gr-num" aria-label="ื”ืฆื™ื•ืŸ ${g.grade}">${g.grade}</span>
mathly2/static/home.js CHANGED
@@ -1,66 +1,65 @@
1
  // The student's home for a subject: one screen, like a game's level select. The stars collected and a big play button for the next lesson,
2
- // every lesson as a small tile with its stars (a round finished, 8 of 10, every skill mastered), then the nearest test in one strip (the days
3
- // left, the readiness number, today's task) and small buttons for a required simulation, the new assignments and photo help.
 
4
  import { swr } from "./api.js";
5
  import * as aiSheet from "./ai_sheet.js";
6
  import { esc, fmt } from "./math.js";
7
- import { backToSubjects, daysText, forSubject, pathHref, shortDate, tabs } from "./ui.js";
8
-
9
- /** One part of the readiness number as a row in plain Hebrew: its name, what the number is, and its value ("โ€”" when there is none yet). */
10
- const part = (id, name, what, value, unit = "") => `<li class="rd-part" data-part="${id}"><span class="grow"><b>${name}</b> <span class="muted">${what}</span></span><b class="rd-val">${value === null ? "โ€”" : value + unit}</b></li>`;
11
-
12
- /** The readiness number as a level bar with its three parts under it; before the simulation it says the bar is capped. */
13
- const HOW = ["ื”ืžื“ื“ ืžื•ืจื›ื‘ ืžืฉืœื™ื˜ื” (50%), ืžื‘ื—ื ื™ ืชืจื’ื•ืœ (20%) ื•ืกื™ืžื•ืœืฆื™ื” (30%).", "ื—ืœืง ืฉืขื•ื“ ืื™ืŸ ืœื• ืฆื™ื•ืŸ ืœื ื ืกืคืจ.", "ื‘ืœื™ ืกื™ืžื•ืœืฆื™ื” ื”ืžื“ื“ ืœื ืขื•ื‘ืจ 80."];
14
-
15
- /** The info button beside the readiness number and its tooltip: hover, focus or tap opens it; a second tap, a tap elsewhere or Escape closes it. */
16
- const howTip = () => `<span class="info-tip"><button type="button" class="info" data-info aria-label="ืื™ืš ื”ืžื“ื“ ืžื—ื•ืฉื‘" aria-expanded="false">โ“˜</button>
17
- <span class="tip" role="tooltip" hidden>${HOW.map(l => `<span>${l}</span>`).join("")}</span></span>`;
18
-
19
- const closeTips = except => document.querySelectorAll(".info-tip.open").forEach(t => {
20
- if (t === except) return;
21
- t.classList.remove("open");
22
- t.querySelector("[data-info]").setAttribute("aria-expanded", "false");
23
- t.querySelector(".tip").hidden = true;
24
- });
25
- document.addEventListener("click", event => {
26
- const tip = event.target.closest(".info-tip");
27
- closeTips(tip);
28
- if (!tip || !event.target.closest("[data-info]")) return;
29
- const open = !tip.classList.contains("open");
30
- tip.classList.toggle("open", open);
31
- tip.querySelector("[data-info]").setAttribute("aria-expanded", String(open));
32
- tip.querySelector(".tip").hidden = !open;
33
- });
34
- document.addEventListener("keydown", event => { if (event.key === "Escape") { closeTips(null); document.activeElement?.closest?.(".info-tip") && document.activeElement.blur(); } });
35
-
36
- export function readinessBar(r) {
37
- return `<div class="readiness" data-readiness>
38
- <p class="rd-top"><b class="rd-num" aria-hidden="true">${r.number}</b><span class="rd-label">${esc(r.label)}</span>${howTip()}</p>
39
- <div class="bar rd-bar" role="progressbar" aria-label="ืžื•ื›ื ื•ืช ืœืžื‘ื—ืŸ: ${r.number} ืžืชื•ืš 100, ${esc(r.label)}" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${r.number}"><i style="width:${r.number}%"></i></div>
40
- <ul class="plain rd-parts">${part("M", "ืฉืœื™ื˜ื”", "ืื—ื•ื– ื”ืžื™ื•ืžื ื•ื™ื•ืช ืฉื ืฉืœื˜ื•", r.parts.M, "%")}${part("E", "ืžื‘ื—ื ื™ ืชืจื’ื•ืœ", "ืžืžื•ืฆืข ื”ืฆื™ื•ื ื™ื ื”ื˜ื•ื‘ื™ื ื‘ื™ื•ืชืจ", r.parts.E)}${part("S", "ืกื™ืžื•ืœืฆื™ื”", "ื”ืฆื™ื•ืŸ ื‘ืžื‘ื—ืŸ ื”ืกื™ืžื•ืœืฆื™ื”", r.parts.S)}</ul>
41
- ${r.waiting_for_simulation ? `<p class="rd-note">ืขื•ื“ ืœื ืขืฉื™ืชื ืžื‘ื—ืŸ ืกื™ืžื•ืœืฆื™ื”, ื•ืœื›ืŸ ื”ืžื“ื“ ืœื ืขื•ื‘ืจ ืืช ${80}.</p>` : ""}</div>`;
42
  }
43
 
44
- /** Today's task as {label, href}. */
45
- function taskLink(task, test, next) {
46
- const skill = id => `#/skill/${encodeURIComponent(id)}`;
47
- switch (task.kind) {
48
- case "path": return { label: task.skill ? `ืœืกื™ื™ื ืืช ื”ืžืกืœื•ืœ: ${task.skill_name}` : "ืœืกื™ื™ื ืืช ื”ืžืกืœื•ืœ", href: task.skill ? skill(task.skill) : pathHref() };
49
- case "ex1": case "ex2": return { label: `ืžื‘ื—ืŸ ืชืจื’ื•ืœ ${task.kind === "ex1" ? 1 : 2}`, href: `#/exam/${test.id}/${task.kind === "ex1" ? 1 : 2}` };
50
- case "sim": return { label: "ืžื‘ื—ืŸ ืกื™ืžื•ืœืฆื™ื”", href: `#/sim/${test.id}` };
51
- case "review": return { label: `ื—ื–ืจื” ืขืœ ${task.skill_name}`, href: skill(task.skill) };
52
- case "mixed_review": return task.skill ? { label: `ื—ื–ืจื” ืงืœื”: ${task.skill_name}`, href: skill(task.skill) } : { label: "ื—ื–ืจื” ืงืœื” ื‘ืžืกืœื•ืœ", href: pathHref() };
53
- default: return next ? { label: `ืœื”ืžืฉื™ืš ื‘ืžืกืœื•ืœ: ${next.name}`, href: skill(next.id) } : { label: "ืœืžืกืœื•ืœ", href: pathHref() };
54
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  }
 
56
 
57
- /** The nearest test, in one short strip: the days left and the readiness number (it opens the test's card, which has the bar, the plan and
58
- * the practice tests), and today's one task as a button. */
59
- function testStrip(test, task, next) {
60
- const t = taskLink(task, test, next);
61
  return `<section class="strip test-strip" id="nearest-test" data-test="${test.id}">
62
- <a class="strip-main" href="#/test/${test.id}"><h2>๐Ÿ“… ืžื‘ื—ืŸ ${daysText(test.days_left)}</h2><span class="muted">ืžื•ื›ื ื•ืช <b>${test.readiness.number}</b> โ€น</span></a>
63
- <a class="today-btn" id="today-task" href="${esc(t.href)}"><small>ื”ื™ื•ื:</small> ${esc(t.label)}</a></section>`;
 
 
 
 
 
 
64
  }
65
 
66
  /** An assignment's state in a symbol and a word (never colour alone). */
@@ -111,7 +110,7 @@ function tile(l, next) {
111
  }
112
 
113
  /** The top: the stars collected, the lessons perfected, the streak and the daily goal, then the big play button for the next lesson. */
114
- function quest(map) {
115
  const next = map.lessons.find(l => l.id === map.next);
116
  const play = next ? `<a class="play" id="play" href="${roundHref(next.id)}"><span class="play-icon" aria-hidden="true">โ–ถ</span>
117
  <span class="grow"><small>${next.round_open ? "ืœื”ืžืฉื™ืš ืืช ื”ืกื‘ื‘" : next.behind ? "ืœื”ืฉืœื™ื" : next.stars ? "ืœืขื•ื“ ื›ื•ื›ื‘" : "ื”ืฉื™ืขื•ืจ ื”ื‘ื"}</small><b>${fmt(next.name)}</b></span>
@@ -119,8 +118,9 @@ function quest(map) {
119
  : `<p class="all-perfect" id="play">๐Ÿ‘‘ ื›ืœ ื”ืฉื™ืขื•ืจื™ื ืžื•ืฉืœืžื™ื! ืืคืฉืจ ืœื”ืขืžื™ืง ื‘ืœืฉื•ื ื™ืช ื”ืขืžืงื”.</p>`;
120
  const t = map.today;
121
  return `<section class="quest" id="quest" aria-label="ื”ืžืกืข ืฉืœื™">
122
- <p class="quest-stats"><span id="stars-count">โญ ${map.stars} ืžืชื•ืš ${map.max_stars}</span><span id="perfect-count">๐Ÿ‘‘ ${map.perfect}</span>
123
- ${map.streak ? `<span>๐Ÿ”ฅ ${map.streak}</span>` : ""}<span id="goal" class="goal-chip${t.reached ? " done" : ""}" title="ื™ืขื“ ื™ื•ืžื™">๐ŸŽฏ ${Math.min(t.done, t.goal)}/${t.goal}${t.reached ? " โœ“" : ""}</span></p>
 
124
  ${play}</section>`;
125
  }
126
 
@@ -146,13 +146,12 @@ export const show = view => swr([forSubject("/api/home"), "/api/assignments", "/
146
 
147
  function draw(view, data, assignments, ai) {
148
  const [first] = data.tests;
149
- const required = data.tests.filter(t => t.simulation.required);
150
- const chips = required.map(t => `<a class="hchip required" href="#/sim/${t.id}" data-required="${t.id}"><b>ื—ื•ื‘ื”</b> ืกื™ืžื•ืœืฆื™ื”</a>`).join("") + assignmentsChip(assignments) + aiChips(ai);
151
  view(`${tabs("home")}${backToSubjects()}
152
- ${quest(data.map)}
153
- ${first ? testStrip(first, data.task, data.next_skill) : ""}
154
- ${chips ? `<div class="hchips" id="home-actions">${chips}</div>` : ""}
155
  ${board(data.map)}`);
 
156
  document.getElementById("ai-new")?.addEventListener("click", () => aiSheet.open());
157
  document.querySelectorAll("[data-ai-open]").forEach(b => b.addEventListener("click", () => aiSheet.open({ id: b.dataset.aiOpen })));
158
  }
 
1
  // The student's home for a subject: one screen, like a game's level select. The stars collected and a big play button for the next lesson,
2
+ // every lesson as a small tile with its stars (a round finished, 8 of 10, every skill mastered), the nearest test in one block (the days
3
+ // left, the readiness number and the two buttons that raise it: a practice test and a simulation), and small buttons for the new
4
+ // assignments and photo help.
5
  import { swr } from "./api.js";
6
  import * as aiSheet from "./ai_sheet.js";
7
  import { esc, fmt } from "./math.js";
8
+ import { backToSubjects, daysText, forSubject, shortDate, tabs } from "./ui.js";
9
+
10
+ /* ---------- the nearest test: the readiness number, and the two ways to raise it ---------- */
11
+
12
+ const SEEN = "mathly2.readiness.";
13
+ /** The number the student saw last time for this test (to count up from it), and remember the new one. */
14
+ function lastSeen(test) {
15
+ let before = null;
16
+ try { before = Number(localStorage.getItem(SEEN + test.id)); localStorage.setItem(SEEN + test.id, String(test.readiness.number)); } catch { /* private window */ }
17
+ return Number.isFinite(before) && before !== null && before < test.readiness.number ? before : null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  }
19
 
20
+ /** The readiness number as a level meter: the number (counting up when it grew since the last visit), its level, the bar, and the
21
+ * points to the next level. */
22
+ export function readinessMeter(test) {
23
+ const r = test.readiness, from = lastSeen(test);
24
+ return `<div class="readiness" data-readiness data-from="${from ?? ""}" data-to="${r.number}">
25
+ <p class="rd-top"><b class="rd-num" aria-hidden="true">${from ?? r.number}</b><span><span class="rd-label">ืžื•ื›ื ื•ืช ืœืžื‘ื—ืŸ ยท <b>${esc(r.label)}</b></span>
26
+ ${from !== null ? `<span class="rd-up" aria-hidden="true">+${r.number - from}</span>` : ""}
27
+ <br><span class="muted rd-next">${r.next ? `ืขื•ื“ ${r.next.left} ืœืจืžื” "${esc(r.next.name)}"` : "ื”ื’ืขืชื ืœืจืžื” ื”ื’ื‘ื•ื”ื” ื‘ื™ื•ืชืจ! ๐Ÿ†"}</span></span></p>
28
+ <div class="bar rd-bar" role="progressbar" aria-label="ืžื•ื›ื ื•ืช ืœืžื‘ื—ืŸ: ${r.number} ืžืชื•ืš 100, ${esc(r.label)}" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${r.number}">
29
+ <i style="width:${from ?? r.number}%"></i>${r.next ? `<s style="inset-inline-start:${r.next.at}%" aria-hidden="true"></s>` : ""}</div></div>`;
30
+ }
31
+
32
+ /** Count the number up from the last visit's and grow the bar with it. */
33
+ function countUp(root = document) {
34
+ root.querySelectorAll(".readiness[data-from]:not([data-from=''])").forEach(meter => {
35
+ const from = Number(meter.dataset.from), to = Number(meter.dataset.to), num = meter.querySelector(".rd-num"), bar = meter.querySelector(".rd-bar i");
36
+ meter.dataset.from = "";
37
+ if (matchMedia("(prefers-reduced-motion: reduce)").matches) { num.textContent = to; bar.style.width = `${to}%`; return; }
38
+ const start = performance.now(), time = 900;
39
+ const frame = now => {
40
+ const k = Math.min(1, (now - start) / time), v = Math.round(from + (to - from) * (1 - (1 - k) ** 3));
41
+ num.textContent = v;
42
+ bar.style.width = `${v}%`;
43
+ if (k < 1) requestAnimationFrame(frame); else meter.classList.add("grew");
44
+ };
45
+ requestAnimationFrame(frame);
46
+ });
47
  }
48
+ export { countUp };
49
 
50
+ /** The nearest test in one compact block (the home is one screen): the readiness number with its level and the days left (a link to the
51
+ * test's page, with all its tests and grades), the bar, and the two buttons that raise it, each with the most it can add now. */
52
+ function testStrip(test) {
53
+ const r = test.readiness, add = r.can_add, sim = test.simulation.open, from = lastSeen(test);
54
  return `<section class="strip test-strip" id="nearest-test" data-test="${test.id}">
55
+ <div class="readiness" data-readiness data-from="${from ?? ""}" data-to="${r.number}">
56
+ <a class="strip-main" href="#/test/${test.id}"><b class="rd-num" aria-hidden="true">${from ?? r.number}</b>
57
+ <span class="grow"><span class="rd-label">ืžื•ื›ื ื•ืช ืœืžื‘ื—ืŸ ยท <b>${esc(r.label)}</b>${from !== null ? ` <span class="rd-up" aria-hidden="true">+${r.number - from}</span>` : ""}</span>
58
+ <small class="muted">๐Ÿ“… <span class="days">ืžื‘ื—ืŸ ${daysText(test.days_left)}</span>${r.next ? ` ยท ืขื•ื“ ${r.next.left} ืœืจืžื” "${esc(r.next.name)}"` : " ยท ๐Ÿ†"}</small></span>
59
+ <span class="strip-more" aria-hidden="true">โ€น</span></a>
60
+ <div class="bar rd-bar" role="progressbar" aria-label="ืžื•ื›ื ื•ืช ืœืžื‘ื—ืŸ: ${r.number} ืžืชื•ืš 100, ${esc(r.label)}" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${r.number}"><i style="width:${from ?? r.number}%"></i></div></div>
61
+ <div class="test-btns"><a class="test-btn" id="home-practice" href="#/test/${test.id}/practice">๐Ÿ“ ืžื‘ื—ืŸ ืชืจื’ื•ืœ${add.practice ? ` <small>+${add.practice}</small>` : ""}</a>
62
+ <a class="test-btn sim" id="home-sim" href="#/sim/${test.id}${sim ? `/${esc(sim)}` : ""}">โฑ ${sim ? "ืœื”ืžืฉื™ืš ืกื™ืžื•ืœืฆื™ื”" : "ืžื‘ื—ืŸ ืกื™ืžื•ืœืฆื™ื”"}${add.simulation && !sim ? ` <small>+${add.simulation}</small>` : ""}</a></div></section>`;
63
  }
64
 
65
  /** An assignment's state in a symbol and a word (never colour alone). */
 
110
  }
111
 
112
  /** The top: the stars collected, the lessons perfected, the streak and the daily goal, then the big play button for the next lesson. */
113
+ function quest(map, chips = "") {
114
  const next = map.lessons.find(l => l.id === map.next);
115
  const play = next ? `<a class="play" id="play" href="${roundHref(next.id)}"><span class="play-icon" aria-hidden="true">โ–ถ</span>
116
  <span class="grow"><small>${next.round_open ? "ืœื”ืžืฉื™ืš ืืช ื”ืกื‘ื‘" : next.behind ? "ืœื”ืฉืœื™ื" : next.stars ? "ืœืขื•ื“ ื›ื•ื›ื‘" : "ื”ืฉื™ืขื•ืจ ื”ื‘ื"}</small><b>${fmt(next.name)}</b></span>
 
118
  : `<p class="all-perfect" id="play">๐Ÿ‘‘ ื›ืœ ื”ืฉื™ืขื•ืจื™ื ืžื•ืฉืœืžื™ื! ืืคืฉืจ ืœื”ืขืžื™ืง ื‘ืœืฉื•ื ื™ืช ื”ืขืžืงื”.</p>`;
119
  const t = map.today;
120
  return `<section class="quest" id="quest" aria-label="ื”ืžืกืข ืฉืœื™">
121
+ <p class="quest-stats"><span id="stars-count" aria-label="${map.stars} ื›ื•ื›ื‘ื™ื ืžืชื•ืš ${map.max_stars}">โญ ${map.stars}/${map.max_stars}</span><span id="perfect-count">๐Ÿ‘‘ ${map.perfect}</span>
122
+ ${map.streak ? `<span>๐Ÿ”ฅ ${map.streak}</span>` : ""}<span id="goal" class="goal-chip${t.reached ? " done" : ""}" title="ื™ืขื“ ื™ื•ืžื™">๐ŸŽฏ ${Math.min(t.done, t.goal)}/${t.goal}${t.reached ? " โœ“" : ""}</span>
123
+ ${chips ? `<span class="hchips" id="home-actions">${chips}</span>` : ""}</p>
124
  ${play}</section>`;
125
  }
126
 
 
146
 
147
  function draw(view, data, assignments, ai) {
148
  const [first] = data.tests;
149
+ const chips = assignmentsChip(assignments) + aiChips(ai);
 
150
  view(`${tabs("home")}${backToSubjects()}
151
+ ${quest(data.map, chips)}
152
+ ${first ? testStrip(first) : ""}
 
153
  ${board(data.map)}`);
154
+ countUp();
155
  document.getElementById("ai-new")?.addEventListener("click", () => aiSheet.open());
156
  document.querySelectorAll("[data-ai-open]").forEach(b => b.addEventListener("click", () => aiSheet.open({ id: b.dataset.aiOpen })));
157
  }
mathly2/static/quiz.js CHANGED
@@ -39,16 +39,31 @@ export async function startExam(testId, number, allSkills = false) {
39
  location.replace(`#/exam/${encodeURIComponent(testId)}/${encodeURIComponent(number)}/${encodeURIComponent(data.id)}`);
40
  }
41
 
42
- /** `allSkills` (address `#/sim/<test>/all`): the bypass when no skill of the test is started yet, drawn from all the material. */
43
  export async function startSimulation(testId, allSkills = false) {
44
  const data = await api(`/api/tests/${encodeURIComponent(testId)}/simulation/start${allSkills ? "?all_skills=true" : ""}`, { method: "POST" });
45
  location.replace(`#/sim/${encodeURIComponent(testId)}/${encodeURIComponent(data.id)}`);
46
  }
47
 
48
  /** What the sheet is called, who it is about and where closing it leads, by kind. */
49
- const FOCUS = 3; // the exercise test on the weak topics
50
- const names = a => a.kind === "exercise_test" ? (a.test.number === FOCUS ? { title: "ืžื‘ื—ืŸ ืชืจื’ื•ืœ: ื ื•ืฉืื™ื ืœื—ื™ื–ื•ืง", again: "ืขื•ื“ ืžื‘ื—ืŸ ืขืœ ื”ื ื•ืฉืื™ื" } : { title: `ืžื‘ื—ืŸ ืชืจื’ื•ืœ ${a.test.number}`, again: "ืžื‘ื—ืŸ ื—ื•ื–ืจ" })
51
  : a.kind === "simulation" ? { title: "ืžื‘ื—ืŸ ืกื™ืžื•ืœืฆื™ื”", again: "" } : { title: "ื‘ื•ื—ืŸ", again: "ื‘ื•ื—ืŸ ื—ื•ื–ืจ" };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  const chip = a => a.kind === "quiz" ? fmt(a.lesson.name) : a.test.lessons.map(fmt).join(", ");
53
  const leaveTo = () => !attempt || attempt.kind === "quiz" ? pathHref() : `#/test/${attempt.test.id}`;
54
 
@@ -241,11 +256,12 @@ function renderResult() {
241
  q(".mx-sheet").innerHTML = `${heading(`ืชื•ืฆืืช ${names(r).title}`)}
242
  <div class="qz-result"><p class="qz-grade" id="qz-grade" aria-label="ื”ืฆื™ื•ืŸ ${r.grade}"><b>${r.grade}</b><span>ืžืชื•ืš 100</span></p>
243
  <p>${r.right === 1 ? "ืชืฉื•ื‘ื” ื ื›ื•ื ื” ืื—ืช" : `${r.right} ืชืฉื•ื‘ื•ืช ื ื›ื•ื ื•ืช`} ืžืชื•ืš ${r.total}${r.timed_out ? ". ื”ื–ืžืŸ ื ื’ืžืจ ื•ื”ื‘ื•ื—ืŸ ื”ื•ื’ืฉ ื›ืžื• ืฉื”ื•ื." : ""}</p>
244
- <p class="muted" id="qz-best"></p>${r.kind === "simulation" ? `<p class="muted">ืžื‘ื—ืŸ ืกื™ืžื•ืœืฆื™ื” ืืคืฉืจ ืœืขืฉื•ืช ืคืขื ืื—ืช.</p>` : ""}</div>
 
245
  ${r.lost_skills.length ? `<h4>ืื™ืคื” ืืคืฉืจ ืœื”ืฉืชืคืจ</h4><ul class="plain" id="qz-lost">${r.lost_skills.map(s =>
246
  `<li><a href="#/skill/${esc(s.id)}">${fmt(s.name)}</a><span class="muted">${s.missed} ${s.missed === 1 ? "ืฉืืœื” ืฉื’ื•ื™ื”" : "ืฉืืœื•ืช ืฉื’ื•ื™ื•ืช"}</span></li>`).join("")}</ul>
247
  <p class="muted">ืืคืฉืจ ืœืชืจื’ืœ ืืช ื”ืžื™ื•ืžื ื•ืช ื•ืื– ืœื ืกื•ืช ืฉื•ื‘.</p>
248
- ${r.kind !== "quiz" && r.test.number !== FOCUS ? `<p><a class="button" id="qz-focus" href="#/exam/${esc(r.test.id)}/${FOCUS}">๐ŸŽฏ ืžื‘ื—ืŸ ืชืจื’ื•ืœ ืขืœ ื”ื ื•ืฉืื™ื ืœื—ื™ื–ื•ืง (15 ืฉืืœื•ืช)</a></p>` : ""}` : `<p id="qz-perfect">ื›ืœ ื”ืชืฉื•ื‘ื•ืช ื ื›ื•ื ื•ืช. ื›ืœ ื”ื›ื‘ื•ื“!</p>`}
249
  ${r.missed.length ? `<h4>ื”ื˜ืขื•ื™ื•ืช</h4><ol class="qz-missed" id="qz-missed">${r.missed.map(m => `<li><p class="ex-text" dir="auto">${fmt(m.question)}</p>
250
  ${m.picked ? `<p class="muted">ื‘ื—ืจืชื: <span dir="auto">${fmt(m.picked)}</span></p>` : `<p class="muted">ืœื ืขื ื• ืขืœ ื”ืฉืืœื”.</p>`}
251
  ${m.mistake ? `<p><b>${esc(m.mistake.name)}</b>${m.mistake.tip ? `<br>${fmt(m.mistake.tip)}` : ""}</p>` : ""}</li>`).join("")}</ol>` : ""}
@@ -253,7 +269,9 @@ function renderResult() {
253
  <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>`;
254
  if (r.review?.state === "pending") waitForReview(r.id);
255
  const best = r.kind === "quiz" ? api(`/api/lessons/${encodeURIComponent(r.lesson.id)}/quiz`).then(f => f.best === null ? "" : `ื”ืฆื™ื•ืŸ ืฉืœ ื”ืฉื™ืขื•ืจ: ${f.best}`)
256
- : r.kind === "exercise_test" ? api(`/api/tests/${encodeURIComponent(r.test.id)}`).then(t => `ื”ืฆื™ื•ืŸ ื”ื˜ื•ื‘ ื‘ืžื‘ื—ืŸ ื”ืชืจื’ื•ืœ: ${t.exercise_tests[r.test.number - 1].best ?? ""}`) : Promise.resolve("");
 
 
257
  best.then(text => { if (text && q("#qz-best")) q("#qz-best").textContent = text; }).catch(() => {});
258
  }
259
 
@@ -272,7 +290,8 @@ function onClick(event) {
272
  if (target.closest("#qz-back")) { q("#qz-warn").innerHTML = ""; warned = false; return; }
273
  } else if (target.closest("#qz-retake")) {
274
  at = 0;
275
- const again = attempt.kind === "quiz" ? start(attempt.lesson.id) : startExam(attempt.test.id, attempt.test.number);
 
276
  again.catch(error => { q(".qz-actions").insertAdjacentHTML("afterend", `<p class="error" role="alert">${esc(error.message)}</p>`); });
277
  }
278
  }
 
39
  location.replace(`#/exam/${encodeURIComponent(testId)}/${encodeURIComponent(number)}/${encodeURIComponent(data.id)}`);
40
  }
41
 
42
+ /** A simulation on request (an open one resumes). `allSkills` is kept for old addresses (`#/sim/<test>/all`). */
43
  export async function startSimulation(testId, allSkills = false) {
44
  const data = await api(`/api/tests/${encodeURIComponent(testId)}/simulation/start${allSkills ? "?all_skills=true" : ""}`, { method: "POST" });
45
  location.replace(`#/sim/${encodeURIComponent(testId)}/${encodeURIComponent(data.id)}`);
46
  }
47
 
48
  /** What the sheet is called, who it is about and where closing it leads, by kind. */
49
+ const FOCUS = 3; // the practice test on the hard skills
50
+ const names = a => a.kind === "exercise_test" ? { title: a.test.title || "ืžื‘ื—ืŸ ืชืจื’ื•ืœ", again: "" } // a test: the readiness block offers the next one
51
  : a.kind === "simulation" ? { title: "ืžื‘ื—ืŸ ืกื™ืžื•ืœืฆื™ื”", again: "" } : { title: "ื‘ื•ื—ืŸ", again: "ื‘ื•ื—ืŸ ื—ื•ื–ืจ" };
52
+
53
+ /** What the test did to the readiness number: the gain big, the bar growing from before to after, a new level, the next one, and the
54
+ * two ways to go on raising it. */
55
+ function readinessHtml(r) {
56
+ const x = r.readiness;
57
+ if (!x) return "";
58
+ const test = r.test.id;
59
+ return `<section class="qz-ready${x.gain ? " up" : ""}" id="qz-ready" data-gain="${x.gain}">
60
+ ${x.gain ? `<p class="qz-gain" aria-hidden="true">+${x.gain}</p><p><b>ื”ืžื•ื›ื ื•ืช ืœืžื‘ื—ืŸ ืขืœืชื” ืœึพ${x.after}!</b></p>`
61
+ : `<p><b>ื”ืžื•ื›ื ื•ืช ื ืฉืืจื” ${x.after}.</b> <span class="muted">ืฆื™ื•ืŸ ืžืขืœ ${x.after} ื™ืขืœื” ืื•ืชื”.</span></p>`}
62
+ ${x.level_up ? `<p class="qz-level">๐ŸŽ‰ ืจืžื” ื—ื“ืฉื”: ${esc(x.label)}!</p>` : ""}
63
+ <div class="bar rd-bar" role="progressbar" aria-label="ืžื•ื›ื ื•ืช ${x.after} ืžืชื•ืš 100" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${x.after}"><i style="width:${x.before}%" data-to="${x.after}"></i></div>
64
+ <p class="muted">${x.next ? `ืขื•ื“ ${x.next.left} ืœืจืžื” "${esc(x.next.name)}"` : "ื”ืจืžื” ื”ื’ื‘ื•ื”ื” ื‘ื™ื•ืชืจ! ๐Ÿ†"}</p>
65
+ <p class="qz-more"><a class="button primary" id="qz-more-sim" href="#/sim/${test}">โฑ ืขื•ื“ ืกื™ืžื•ืœืฆื™ื”</a> <a class="button" id="qz-more-practice" href="#/test/${test}/practice">๐Ÿ“ ืžื‘ื—ืŸ ืชืจื’ื•ืœ</a></p></section>`;
66
+ }
67
  const chip = a => a.kind === "quiz" ? fmt(a.lesson.name) : a.test.lessons.map(fmt).join(", ");
68
  const leaveTo = () => !attempt || attempt.kind === "quiz" ? pathHref() : `#/test/${attempt.test.id}`;
69
 
 
256
  q(".mx-sheet").innerHTML = `${heading(`ืชื•ืฆืืช ${names(r).title}`)}
257
  <div class="qz-result"><p class="qz-grade" id="qz-grade" aria-label="ื”ืฆื™ื•ืŸ ${r.grade}"><b>${r.grade}</b><span>ืžืชื•ืš 100</span></p>
258
  <p>${r.right === 1 ? "ืชืฉื•ื‘ื” ื ื›ื•ื ื” ืื—ืช" : `${r.right} ืชืฉื•ื‘ื•ืช ื ื›ื•ื ื•ืช`} ืžืชื•ืš ${r.total}${r.timed_out ? ". ื”ื–ืžืŸ ื ื’ืžืจ ื•ื”ื‘ื•ื—ืŸ ื”ื•ื’ืฉ ื›ืžื• ืฉื”ื•ื." : ""}</p>
259
+ <p class="muted" id="qz-best"></p></div>
260
+ ${readinessHtml(r)}
261
  ${r.lost_skills.length ? `<h4>ืื™ืคื” ืืคืฉืจ ืœื”ืฉืชืคืจ</h4><ul class="plain" id="qz-lost">${r.lost_skills.map(s =>
262
  `<li><a href="#/skill/${esc(s.id)}">${fmt(s.name)}</a><span class="muted">${s.missed} ${s.missed === 1 ? "ืฉืืœื” ืฉื’ื•ื™ื”" : "ืฉืืœื•ืช ืฉื’ื•ื™ื•ืช"}</span></li>`).join("")}</ul>
263
  <p class="muted">ืืคืฉืจ ืœืชืจื’ืœ ืืช ื”ืžื™ื•ืžื ื•ืช ื•ืื– ืœื ืกื•ืช ืฉื•ื‘.</p>
264
+ ${r.kind !== "quiz" && r.test.number !== FOCUS ? `<p><a class="button" id="qz-focus" href="#/exam/${esc(r.test.id)}/${FOCUS}">๐ŸŽฏ ืžื‘ื—ืŸ ืชืจื’ื•ืœ ืžืžื•ืงื“ ืขืœ ืžื” ืฉื”ื™ื” ืงืฉื” (15 ืฉืืœื•ืช)</a></p>` : ""}` : `<p id="qz-perfect">ื›ืœ ื”ืชืฉื•ื‘ื•ืช ื ื›ื•ื ื•ืช. ื›ืœ ื”ื›ื‘ื•ื“!</p>`}
265
  ${r.missed.length ? `<h4>ื”ื˜ืขื•ื™ื•ืช</h4><ol class="qz-missed" id="qz-missed">${r.missed.map(m => `<li><p class="ex-text" dir="auto">${fmt(m.question)}</p>
266
  ${m.picked ? `<p class="muted">ื‘ื—ืจืชื: <span dir="auto">${fmt(m.picked)}</span></p>` : `<p class="muted">ืœื ืขื ื• ืขืœ ื”ืฉืืœื”.</p>`}
267
  ${m.mistake ? `<p><b>${esc(m.mistake.name)}</b>${m.mistake.tip ? `<br>${fmt(m.mistake.tip)}` : ""}</p>` : ""}</li>`).join("")}</ol>` : ""}
 
269
  <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>`;
270
  if (r.review?.state === "pending") waitForReview(r.id);
271
  const best = r.kind === "quiz" ? api(`/api/lessons/${encodeURIComponent(r.lesson.id)}/quiz`).then(f => f.best === null ? "" : `ื”ืฆื™ื•ืŸ ืฉืœ ื”ืฉื™ืขื•ืจ: ${f.best}`)
272
+ : Promise.resolve("");
273
+ const grow = q("#qz-ready .rd-bar i");
274
+ if (grow) setTimeout(() => { grow.style.width = `${grow.dataset.to}%`; }, 300);
275
  best.then(text => { if (text && q("#qz-best")) q("#qz-best").textContent = text; }).catch(() => {});
276
  }
277
 
 
290
  if (target.closest("#qz-back")) { q("#qz-warn").innerHTML = ""; warned = false; return; }
291
  } else if (target.closest("#qz-retake")) {
292
  at = 0;
293
+ const again = attempt.kind === "quiz" ? start(attempt.lesson.id) : attempt.kind === "simulation" ? startSimulation(attempt.test.id)
294
+ : startExam(attempt.test.id, attempt.test.number);
295
  again.catch(error => { q(".qz-actions").insertAdjacentHTML("afterend", `<p class="error" role="alert">${esc(error.message)}</p>`); });
296
  }
297
  }
mathly2/static/student.js CHANGED
@@ -69,11 +69,13 @@ export async function show(me, view) {
69
  const attempt = allSkills ? undefined : kind === "exam" ? third : second;
70
  if (!document.getElementById("path") && !document.getElementById("test-card")) await (await import("./testcard.js")).show(view, test); // a link or a reload: the test's card first
71
  if (attempt) await quiz.open(attempt);
 
72
  else if (kind === "exam") await quiz.startExam(test, second, allSkills);
73
  else await quiz.startSimulation(test, allSkills);
74
  } else if (where.startsWith("/test/")) {
75
  exercise.close(); quiz.close();
76
- await (await import("./testcard.js")).show(view, decodeURIComponent(where.slice(6)));
 
77
  } else if (where === "/review") {
78
  exercise.close(); quiz.close();
79
  await (await import("./review.js")).show(view);
 
69
  const attempt = allSkills ? undefined : kind === "exam" ? third : second;
70
  if (!document.getElementById("path") && !document.getElementById("test-card")) await (await import("./testcard.js")).show(view, test); // a link or a reload: the test's card first
71
  if (attempt) await quiz.open(attempt);
72
+ else if (kind === "exam" && second === "4") location.replace(`#/test/${encodeURIComponent(test)}/practice`); // picked skills: choose them first
73
  else if (kind === "exam") await quiz.startExam(test, second, allSkills);
74
  else await quiz.startSimulation(test, allSkills);
75
  } else if (where.startsWith("/test/")) {
76
  exercise.close(); quiz.close();
77
+ const [testId, part] = where.slice(6).split("/").map(decodeURIComponent); // "/test/<id>/practice": straight to the practice test's choice
78
+ await (await import("./testcard.js")).show(view, testId, part === "practice" ? "practice" : "");
79
  } else if (where === "/review") {
80
  exercise.close(); quiz.close();
81
  await (await import("./review.js")).show(view);
mathly2/static/teacher.js CHANGED
@@ -124,9 +124,9 @@ function testCard(c, t) {
124
  <p class="muted">${t.lessons.map(l => fmt(l.name)).join(", ")}${t.note ? ` ยท ${esc(t.note)}` : ""} ยท ${t.questions} ืฉืืœื•ืช ยท ${t.minutes} ื“ืงื•ืช</p>
125
  ${t.simulation.missing.length ? `<p class="sim-missing"><b>ืขื•ื“ ืœื ืขืฉื• ืกื™ืžื•ืœืฆื™ื”:</b> ${t.simulation.missing.map(e => `<span dir="ltr">${esc(e)}</span>`).join(", ")}</p>`
126
  : t.simulation.total ? `<p class="sim-missing">ื›ื•ืœื ืขืฉื• ืืช ืžื‘ื—ืŸ ื”ืกื™ืžื•ืœืฆื™ื”.</p>` : ""}
127
- ${t.students.length ? `<div class="tablewrap"><table class="data readiness-table"><thead><tr><th>ืชืœืžื™ื“</th><th>ืžื•ื›ื ื•ืช</th><th>ืฉืœื™ื˜ื”</th><th>ืชืจื’ื•ืœ</th><th>ืกื™ืžื•ืœืฆื™ื”</th></tr></thead>
128
  <tbody>${t.students.map(s => `<tr><td dir="ltr">${esc(s.email)}</td><td>${s.readiness.number} ยท ${esc(s.readiness.label)}</td>
129
- <td>${s.readiness.parts.M === null ? "โ€”" : s.readiness.parts.M + "%"}</td><td>${s.readiness.parts.E ?? "โ€”"}</td><td>${s.readiness.parts.S ?? "โ€”"}</td></tr>`).join("")}</tbody></table></div>` : ""}
130
  <p class="test-actions">${open ? `<button type="button" data-edit="${t.id}">ืฉื™ื ื•ื™</button> ` : ""}<button type="button" data-remove-test="${t.id}">ื”ืกืจื”</button></p>
131
  <div class="edit-slot" data-slot="${t.id}"></div></article>`;
132
  }
 
124
  <p class="muted">${t.lessons.map(l => fmt(l.name)).join(", ")}${t.note ? ` ยท ${esc(t.note)}` : ""} ยท ${t.questions} ืฉืืœื•ืช ยท ${t.minutes} ื“ืงื•ืช</p>
125
  ${t.simulation.missing.length ? `<p class="sim-missing"><b>ืขื•ื“ ืœื ืขืฉื• ืกื™ืžื•ืœืฆื™ื”:</b> ${t.simulation.missing.map(e => `<span dir="ltr">${esc(e)}</span>`).join(", ")}</p>`
126
  : t.simulation.total ? `<p class="sim-missing">ื›ื•ืœื ืขืฉื• ืืช ืžื‘ื—ืŸ ื”ืกื™ืžื•ืœืฆื™ื”.</p>` : ""}
127
+ ${t.students.length ? `<div class="tablewrap"><table class="data readiness-table"><thead><tr><th>ืชืœืžื™ื“</th><th>ืžื•ื›ื ื•ืช</th><th>ืžื‘ื—ื ื™ ืชืจื’ื•ืœ</th><th>ืกื™ืžื•ืœืฆื™ื•ืช</th></tr></thead>
128
  <tbody>${t.students.map(s => `<tr><td dir="ltr">${esc(s.email)}</td><td>${s.readiness.number} ยท ${esc(s.readiness.label)}</td>
129
+ <td>${s.readiness.practices ? `${s.readiness.practices} ยท ื”ื˜ื•ื‘ ${s.readiness.practice_best}` : "โ€”"}</td><td>${s.readiness.simulations ? `${s.readiness.simulations} ยท ื”ื˜ื•ื‘ ${s.readiness.simulation_best}` : "โ€”"}</td></tr>`).join("")}</tbody></table></div>` : ""}
130
  <p class="test-actions">${open ? `<button type="button" data-edit="${t.id}">ืฉื™ื ื•ื™</button> ` : ""}<button type="button" data-remove-test="${t.id}">ื”ืกืจื”</button></p>
131
  <div class="edit-slot" data-slot="${t.id}"></div></article>`;
132
  }
mathly2/static/teacher_progress.js CHANGED
@@ -27,9 +27,10 @@ function lessonsView(data) {
27
  function readiness(data) {
28
  const t = data.test;
29
  if (!t) return `<p class="muted" id="no-test">ืขื•ื“ ืœื ื ืงื‘ืข ืžื‘ื—ืŸ ืงืจื•ื‘ ืœื›ื™ืชื” ื”ื–ื•.</p>`;
30
- const rows = t.students.map(s => `<tr data-student="${esc(s.email)}">${who(s)}<td><b>${s.readiness.number}</b> ยท ${esc(s.readiness.label)}</td><td>${dash(s.mastery)}${s.mastery === null ? "" : "%"}</td><td>${dash(s.practice)}</td><td>${dash(s.simulation)}</td></tr>`).join("");
31
  return `<p>ืžื‘ื—ืŸ ื‘ึพ${shortDate(t.date)} (${t.days_left === 0 ? "ื”ื™ื•ื" : `ื‘ืขื•ื“ ${t.days_left} ื™ืžื™ื`}): ${t.lessons.map(esc).join(", ")}</p>
32
- <div class="tablewrap" tabindex="0" role="region" aria-label="ืžื•ื›ื ื•ืช"><table class="hm"><thead><tr><th class="sticky">ืชืœืžื™ื“</th><th>ืžื•ื›ื ื•ืช</th><th>ืฉืœื™ื˜ื”</th><th>ืชืจื’ื•ืœ</th><th>ืกื™ืžื•ืœืฆื™ื”</th></tr></thead><tbody>${rows}</tbody></table></div>`;
 
33
  }
34
 
35
  function page(ctx, classes, data) {
 
27
  function readiness(data) {
28
  const t = data.test;
29
  if (!t) return `<p class="muted" id="no-test">ืขื•ื“ ืœื ื ืงื‘ืข ืžื‘ื—ืŸ ืงืจื•ื‘ ืœื›ื™ืชื” ื”ื–ื•.</p>`;
30
+ const rows = t.students.map(s => `<tr data-student="${esc(s.email)}">${who(s)}<td><b>${s.readiness.number}</b> ยท ${esc(s.readiness.label)}</td><td>${dash(s.mastery)}${s.mastery === null ? "" : "%"}</td><td>${dash(s.practice)}${s.practices ? ` <span class="muted">(${s.practices})</span>` : ""}</td><td>${dash(s.simulation)}${s.simulations ? ` <span class="muted">(${s.simulations})</span>` : ""}</td></tr>`).join("");
31
  return `<p>ืžื‘ื—ืŸ ื‘ึพ${shortDate(t.date)} (${t.days_left === 0 ? "ื”ื™ื•ื" : `ื‘ืขื•ื“ ${t.days_left} ื™ืžื™ื`}): ${t.lessons.map(esc).join(", ")}</p>
32
+ <div class="tablewrap" tabindex="0" role="region" aria-label="ืžื•ื›ื ื•ืช"><table class="hm"><thead><tr><th class="sticky">ืชืœืžื™ื“</th><th>ืžื•ื›ื ื•ืช</th><th>ืฉืœื™ื˜ื”</th><th>ืชืจื’ื•ืœ: ื”ื˜ื•ื‘ (ื›ืžื”)</th><th>ืกื™ืžื•ืœืฆื™ื”: ื”ื˜ื•ื‘ (ื›ืžื”)</th></tr></thead><tbody>${rows}</tbody></table></div>
33
+ <p class="muted">ื”ืžื•ื›ื ื•ืช ืขื•ืœื” ืจืง ืžืžื‘ื—ื ื™ ืชืจื’ื•ืœ ื•ืกื™ืžื•ืœืฆื™ื•ืช. ื”ืฉืœื™ื˜ื” ื‘ืžืกืœื•ืœ ืžื•ืฆื’ืช ืœื™ื“ื” ื•ืœื ื ื›ืœืœืช ื‘ื”.</p>`;
34
  }
35
 
36
  function page(ctx, classes, data) {
mathly2/static/testcard.js CHANGED
@@ -1,63 +1,107 @@
1
- // One real test: its plan with the recommended days, the readiness bar, the two exercise tests, the simulation and what to practise first.
 
 
2
  import { api } from "./api.js";
3
  import { esc, fmt } from "./math.js";
4
- import { readinessBar } from "./home.js";
5
  import { daysText, forSubject, homeHref, setSubject, shortDate, tabs } from "./ui.js";
6
 
7
- const STEP_NAMES = { path: "ืœืกื™ื™ื ืืช ื”ืžืกืœื•ืœ ืฉืœ ื”ืฉื™ืขื•ืจื™ื", ex1: "ืžื‘ื—ืŸ ืชืจื’ื•ืœ 1", ex2: "ืžื‘ื—ืŸ ืชืจื’ื•ืœ 2", sim: "ืžื‘ื—ืŸ ืกื™ืžื•ืœืฆื™ื”", review: "ื—ื–ืจื” ืขืœ ืžื” ืฉื ืคืœ ื‘ืกื™ืžื•ืœืฆื™ื”" };
 
 
 
 
 
8
 
9
- function planList(test) {
10
- return `<ol class="plan" id="plan">${test.plan.map(step => {
11
- const when = step.id === "path" ? `ืขื“ ${step.until} ื™ืžื™ื ืœืคื ื™` : step.id === "review" ? `${step.day} ื™ืžื™ื ืœืคื ื™ ื•ืขื“ ื”ืžื‘ื—ืŸ` : `ื‘ืขืจืš ${step.day} ื™ืžื™ื ืœืคื ื™`;
12
- return `<li class="${step.done ? "done" : ""}" data-step="${step.id}"><span class="mark" aria-hidden="true">${step.done ? "โœ“" : "โ—‹"}</span>
13
- <span><b>${STEP_NAMES[step.id]}</b><br><span class="muted">${when} ยท ${step.done ? "ื‘ื•ืฆืข" : "ืขื•ื“ ืœื"}</span></span></li>`;
14
- }).join("")}</ol><p class="muted">ื”ื™ืžื™ื ื”ื ื”ืžืœืฆื”: ืืคืฉืจ ืœื”ืชื—ื™ืœ ื›ืœ ืฉืœื‘ ืงื•ื“ื ืื• ืื—ืจ ื›ืš.</p>`;
 
 
 
 
 
 
 
15
  }
16
 
17
- function exercises(test) {
18
- return `<ul class="plain" id="exercises">${test.exercise_tests.map(e => `<li data-exercise="${e.number}">
19
- <span><b>ืžื‘ื—ืŸ ืชืจื’ื•ืœ ${e.number}</b><br><span class="muted">${e.best === null ? "ืขื•ื“ ืœื ื ืขืฉื”" : `ื”ืฆื™ื•ืŸ ื”ื˜ื•ื‘: ${e.best} ยท ${e.attempts} ${e.attempts === 1 ? "ื ื™ืกื™ื•ืŸ" : "ื ื™ืกื™ื•ื ื•ืช"}`}</span></span>
20
- <a class="button" href="#/exam/${test.id}/${e.number}">${e.open ? "ืœื”ืžืฉื™ืš" : e.attempts ? "ืฉื•ื‘" : "ื”ืชื—ืœื”"}</a></li>`).join("")}</ul>`;
 
 
 
 
 
 
 
 
 
 
 
 
21
  }
22
 
23
- /** The practice test on the weak topics: 15 questions from the skills that lost points (or are still being learned). */
24
- function focus(test) {
25
- const f = test.focus;
26
- if (!f) return "";
27
- if (!f.skills.length) return `<div id="focus" data-ready="false"><p class="muted">๐ŸŽฏ ${f.attempts ? "ืื™ืŸ ื›ืจื’ืข ื ื•ืฉืื™ื ืœื—ื™ื–ื•ืง. ื›ืœ ื”ื›ื‘ื•ื“!" : "ืื—ืจื™ ืžื‘ื—ืŸ ืชืจื’ื•ืœ ืื• ืกื™ืžื•ืœืฆื™ื” ื™ื•ืคื™ืข ื›ืืŸ ืžื‘ื—ืŸ ืขืœ ื”ื ื•ืฉืื™ื ืฉืฆืจื™ืš ืœื—ื–ืง."}</p></div>`;
28
- return `<div id="focus" data-ready="true"><p><b>๐ŸŽฏ ืžื‘ื—ืŸ ืชืจื’ื•ืœ ืขืœ ื”ื ื•ืฉืื™ื ืœื—ื™ื–ื•ืง</b> <span class="muted">ยท ${f.size} ืฉืืœื•ืช</span></p>
29
- <p class="focus-skills">${f.skills.map(s => `<a class="ex-chip link" href="#/skill/${esc(s.id)}">${fmt(s.name)}</a>`).join(" ")}</p>
30
- ${f.best === null ? "" : `<p class="muted">ื”ืฆื™ื•ืŸ ื”ื˜ื•ื‘: ${f.best} ยท ${f.attempts} ${f.attempts === 1 ? "ื ื™ืกื™ื•ืŸ" : "ื ื™ืกื™ื•ื ื•ืช"}</p>`}
31
- <p><a class="button primary" id="focus-start" href="#/exam/${test.id}/${f.number}">${f.open ? "ืœื”ืžืฉื™ืš" : f.attempts ? "ืขื•ื“ ืžื‘ื—ืŸ ืขืœ ื”ื ื•ืฉืื™ื" : "ื”ืชื—ืœื”"}</a></p></div>`;
32
  }
33
 
34
- function simulation(test) {
35
- const s = test.simulation;
36
- const lead = s.taken ? `<p>ื”ืฆื™ื•ืŸ: <b id="sim-grade">${s.grade}</b></p>${s.review.length ? `<p>ืžื™ื•ืžื ื•ื™ื•ืช ืœื—ื–ืจื”:</p><ul class="plain" id="sim-review">${s.review.map(r =>
37
- `<li><a href="#/skill/${esc(r.id)}">${fmt(r.name)}</a></li>`).join("")}</ul>` : `<p class="muted">ืื™ืŸ ืžื™ื•ืžื ื•ื™ื•ืช ืœื—ื–ืจื”. ื›ืœ ื”ื›ื‘ื•ื“!</p>`}
38
- <p><a class="button" href="#/sim/${test.id}">ืœืชื•ืฆืื”</a></p>`
39
- : s.open_now ? `<p><b class="req">ื—ื•ื‘ื”</b> ื”ืžื‘ื—ืŸ ืคืชื•ื— ืขื“ ื™ื•ื ื”ืžื‘ื—ืŸ. ืืคืฉืจ ืœืขืฉื•ืช ืื•ืชื• ืคืขื ืื—ืช.</p>
40
- <p><a class="button primary" id="sim-start" href="#/sim/${test.id}">${s.in_progress ? "ืœื”ืžืฉื™ืš" : "ื”ืชื—ืœื”"}</a></p>`
41
- : `<p id="sim-closed">ืžื‘ื—ืŸ ื”ืกื™ืžื•ืœืฆื™ื” ื™ื™ืคืชื— ื‘ึพ${shortDate(s.opens_on)} (ื‘ืขื•ื“ ${s.opens_in_days} ื™ืžื™ื). ืืคืฉืจ ืœืขืฉื•ืช ืื•ืชื• ืคืขื ืื—ืช.</p>`;
42
- return `<div id="simulation" data-taken="${s.taken}">${lead}</div>`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  }
44
 
45
- export async function show(view, id) {
 
46
  const test = await api(`/api/tests/${encodeURIComponent(id)}`);
47
  setSubject(test.subject_id); // a test belongs to one subject: its home and tab bar follow it
48
  const home = await api(forSubject("/api/home"));
49
  const others = home.tests.filter(t => t.id !== test.id);
50
  view(`${tabs("home")}<p><a class="back" href="${homeHref()}">โ€น ื”ื‘ื™ืช</a></p>
51
  <section class="card" id="test-card" data-test="${test.id}"><h2>ืžื‘ื—ืŸ ื‘ึพ${shortDate(test.date)} <span class="muted">(${daysText(test.days_left)})</span></h2>
52
- <p class="muted">${test.lessons.map(l => fmt(l.name)).join(", ")}${test.note ? ` ยท ${esc(test.note)}` : ""}<br>${test.questions} ืฉืืœื•ืช ยท ${test.minutes} ื“ืงื•ืช</p>
53
- ${readinessBar(test.readiness)}</section>
54
- <section class="card"><h3>ื”ืชื•ื›ื ื™ืช</h3>${planList(test)}</section>
55
- <section class="card"><h3>ืžื‘ื—ื ื™ ืชืจื’ื•ืœ</h3>${exercises(test)}${focus(test)}
56
- ${test.practice_first.length ? `<p id="practice-first"><b>ื›ื“ืื™ ืœืชืจื’ืœ ืงื•ื“ื:</b> ${test.practice_first.map(s => `<a class="ex-chip link" href="#/skill/${esc(s.id)}">${fmt(s.name)}</a>`).join(" ")}</p>
57
- <p class="muted">ืืœื” ืžื™ื•ืžื ื•ื™ื•ืช ืฉืขื•ื“ ืœื ื”ืชื—ืœืชื, ื•ืœื›ืŸ ืื™ืŸ ืœื”ืŸ ืฉืืœื•ืช ื‘ืžื‘ื—ื ื™ ื”ืชืจื’ื•ืœ.</p>
58
- <p id="all-skills">${test.exercise_tests.map(e => `<a class="button" href="#/exam/${test.id}/${e.number}/all">ืžื‘ื—ืŸ ืชืจื’ื•ืœ ${e.number} ืขืœ ื›ืœ ื”ื—ื•ืžืจ</a>`).join(" ")}</p>
59
- <p class="muted">"ืขืœ ื›ืœ ื”ื—ื•ืžืจ": ืฉืืœื•ืช ืžื›ืœ ื”ื ื•ืฉืื™ื ืฉืœ ื”ืžื‘ื—ืŸ, ื›ืžื• ื‘ืžื‘ื—ืŸ ื”ืกื™ืžื•ืœืฆื™ื”, ื’ื ืžืžื™ื•ืžื ื•ื™ื•ืช ืฉืขื•ื“ ืœื ื”ืชื—ืœืชื.</p>` : ""}</section>
60
- <section class="card"><h3>ืžื‘ื—ืŸ ืกื™ืžื•ืœืฆื™ื”</h3>${simulation(test)}</section>
61
  ${others.length ? `<section class="card"><h3>ืžื‘ื—ื ื™ื ื ื•ืกืคื™ื</h3><ul class="plain" id="other-tests">${others.map(t => `<li><a href="#/test/${t.id}">${shortDate(t.date)} ยท ${t.lessons.map(l => fmt(l.name)).join(", ")}</a>
62
  <span class="muted">${daysText(t.days_left)} ยท ืžื•ื›ื ื•ืช ${t.readiness.number}</span></li>`).join("")}</ul></section>` : ""}`);
 
 
 
63
  }
 
1
+ // One real test: the readiness number and what moves it, a simulation on request (all the material, timed, no choice), a practice test on
2
+ // request in one of three modes (balanced, focused on the skills that were hard, or skills picked by hand), and the trail of the tests
3
+ // handed in with what each one added.
4
  import { api } from "./api.js";
5
  import { esc, fmt } from "./math.js";
6
+ import { countUp, readinessMeter } from "./home.js";
7
  import { daysText, forSubject, homeHref, setSubject, shortDate, tabs } from "./ui.js";
8
 
9
+ const MODES = [
10
+ ["balanced", "โš–๏ธ ืžืื•ื–ืŸ", "ืฉืืœื•ืช ืžื›ืœ ื”ื—ื•ืžืจ ืฉืœ ื”ืžื‘ื—ืŸ, ื‘ืื•ืคืŸ ืฉื•ื•ื”."],
11
+ ["focus", "๐ŸŽฏ ืžืžื•ืงื“", "15 ืฉืืœื•ืช ืจืง ืžื”ืžื™ื•ืžื ื•ื™ื•ืช ืฉื”ืชืงืฉื™ืชื ื‘ื”ืŸ ื‘ืžื‘ื—ื ื™ื."],
12
+ ["manual", "โœ‹ ื‘ื—ื™ืจื” ื™ื“ื ื™ืช", "ืืชื ื‘ื•ื—ืจื™ื ืืช ื”ืžื™ื•ืžื ื•ื™ื•ืช."],
13
+ ];
14
+ const STATE = { mastered: "๐Ÿ‘‘", learning: "โ—", not_started: "โ—‹" };
15
 
16
+ /** How the number moves, in plain words. */
17
+ const HOW = `<details class="rd-how" id="rd-how"><summary>ืื™ืš ืžืขืœื™ื ืืช ื”ืžื•ื›ื ื•ืช?</summary><ul>
18
+ <li>ื›ืœ ืžื‘ื—ืŸ ืฉื”ืฆื™ื•ืŸ ื‘ื• ื’ื‘ื•ื” ืžื”ืžื•ื›ื ื•ืช ืžืขืœื” ืื•ืชื”: ืžื‘ื—ืŸ ืชืจื’ื•ืœ ืกื•ื’ืจ 30% ืžื”ืคืขืจ, <b>ืžื‘ื—ืŸ ืกื™ืžื•ืœืฆื™ื” ืกื•ื’ืจ 60%</b>, ื•ืžื‘ื—ืŸ ื‘ื‘ื—ื™ืจื” ื™ื“ื ื™ืช 15%.</li>
19
+ <li>ืฆื™ื•ืŸ ื ืžื•ืš ืžื”ืžื•ื›ื ื•ืช ืœื ืžื•ืจื™ื“ ืื•ืชื”.</li>
20
+ <li>ืจืง ืžื‘ื—ื ื™ื ื ืกืคืจื™ื. ืชืจื’ื•ืœ ื‘ืžืกืœื•ืœ ื•ื‘ื”ืขืžืงื” ืขื•ื–ืจ ืœื”ืฆืœื™ื— ื‘ื”ื, ืื‘ืœ ืœื ืžืฉื ื” ืืช ื”ืžืกืคืจ.</li></ul></details>`;
21
+
22
+ function simulationCard(test) {
23
+ const s = test.simulation, gain = test.readiness.can_add.simulation;
24
+ return `<section class="card test-action" id="simulation"><h3>โฑ ืžื‘ื—ืŸ ืกื™ืžื•ืœืฆื™ื”</h3>
25
+ <p class="muted">ื›ืžื• ื”ืžื‘ื—ืŸ ื”ืืžื™ืชื™: ${test.questions} ืฉืืœื•ืช ืžื›ืœ ื”ื—ื•ืžืจ, ${test.minutes} ื“ืงื•ืช. ืืช ื”ืฉืืœื•ืช ื‘ื•ื—ืจืช ื”ืืคืœื™ืงืฆื™ื”.</p>
26
+ <p><a class="button primary big-btn" id="sim-start" href="#/sim/${test.id}${s.open ? `/${esc(s.open)}` : ""}">${s.open ? "ืœื”ืžืฉื™ืš ืืช ื”ืกื™ืžื•ืœืฆื™ื”" : "ื‘ืงืฉืช ืžื‘ื—ืŸ ืกื™ืžื•ืœืฆื™ื”"}
27
+ ${s.open || !gain ? "" : `<small class="gain-hint">ืขื“ +${gain} ืžื•ื›ื ื•ืช</small>`}</a></p>
28
+ ${s.taken ? `<p class="muted" id="sim-done">ืขืฉื™ืชื ${s.taken === 1 ? "ืกื™ืžื•ืœืฆื™ื” ืื—ืช" : `${s.taken} ืกื™ืžื•ืœืฆื™ื•ืช`} ยท ื”ืฆื™ื•ืŸ ื”ื˜ื•ื‘: <b id="sim-best">${s.best}</b></p>` : ""}</section>`;
29
  }
30
 
31
+ function practiceCard(test) {
32
+ const p = test.practice, gain = test.readiness.can_add;
33
+ const lessons = [...new Set(p.manual.skills.map(s => s.lesson))];
34
+ const radio = ([mode, title, what]) => {
35
+ const off = mode === "focus" && !p.focus.skills.length;
36
+ return `<label class="mode${off ? " off" : ""}" data-mode="${mode}"><input type="radio" name="mode" value="${mode}"${mode === "balanced" ? " checked" : ""}${off ? " disabled" : ""}>
37
+ <span><b>${title}</b>${p[mode].open ? ` <span class="tag">ื‘ืืžืฆืข</span>` : ""}<br><span class="muted">${off ? "ื™ื•ืคื™ืข ืื—ืจื™ ืžื‘ื—ืŸ ืฉื‘ื• ื”ื™ื• ื˜ืขื•ื™ื•ืช." : what}</span></span></label>`;
38
+ };
39
+ return `<section class="card test-action" id="practice"><h3>๐Ÿ“ ืžื‘ื—ืŸ ืชืจื’ื•ืœ</h3>
40
+ <p class="muted">ืืชื ื‘ื•ื—ืจื™ื ืขืœ ืžื” ืœื”ืชืืžืŸ. ื‘ืœื™ ืฉืขื•ืŸ.</p>
41
+ <div class="modes" role="radiogroup" aria-label="ืกื•ื’ ืžื‘ื—ืŸ ื”ืชืจื’ื•ืœ">${MODES.map(radio).join("")}</div>
42
+ <div id="focus-skills" class="chips" hidden>${p.focus.skills.map(s => `<span class="ex-chip">${fmt(s.name)}</span>`).join(" ")}</div>
43
+ <fieldset id="manual-skills" hidden><legend>ื‘ื—ืจื• ืžื™ื•ืžื ื•ื™ื•ืช</legend>${lessons.map(lesson => `<p class="muted pick-lesson">${fmt(lesson)}</p>
44
+ ${p.manual.skills.filter(s => s.lesson === lesson).map(s => `<label class="pick"><input type="checkbox" value="${esc(s.id)}"> <span aria-hidden="true">${STATE[s.state] || ""}</span> ${fmt(s.name)}</label>`).join("")}`).join("")}</fieldset>
45
+ <p><button type="button" class="primary big-btn" id="practice-start">ื‘ืงืฉืช ืžื‘ื—ืŸ ืชืจื’ื•ืœ <small class="gain-hint">ืขื“ +${gain.practice} ืžื•ื›ื ื•ืช</small></button></p>
46
+ <p class="error" id="practice-error" role="alert" hidden></p></section>`;
47
  }
48
 
49
+ function trailCard(test) {
50
+ if (!test.trail.length) return "";
51
+ return `<section class="card" id="trail"><h3>ื”ืžื‘ื—ื ื™ื ืฉืขืฉื™ืชื</h3><ol class="plain trail">${test.trail.map(t => `<li data-kind="${t.kind}">
52
+ <a href="${t.kind === "simulation" ? `#/sim/${test.id}/${esc(t.attempt)}` : `#/exam/${test.id}/${t.number}/${esc(t.attempt)}`}"><span class="grow"><b>${esc(t.title)}</b>
53
+ <span class="muted"> ยท ${shortDate(t.at.slice(0, 10))}</span></span><span class="tag">ืฆื™ื•ืŸ ${t.grade}</span>
54
+ <span class="gain${t.gain ? "" : " zero"}">${t.gain ? `+${t.gain}` : "ยฑ0"}</span></a></li>`).join("")}</ol></section>`;
 
 
 
55
  }
56
 
57
+ /** The mode's extra part (the hard skills, the skills to pick) and what the button says; the button asks for the test. */
58
+ function wire(test) {
59
+ const card = document.getElementById("practice");
60
+ const mode = () => card.querySelector("input[name=mode]:checked").value;
61
+ const sync = () => {
62
+ const m = mode(), resume = m !== "manual" && test.practice[m].open;
63
+ card.querySelector("#focus-skills").hidden = m !== "focus";
64
+ card.querySelector("#manual-skills").hidden = m !== "manual";
65
+ card.querySelectorAll(".mode").forEach(l => l.classList.toggle("on", l.dataset.mode === m));
66
+ const gain = test.readiness.can_add[m === "manual" ? "manual" : "practice"];
67
+ document.getElementById("practice-start").innerHTML = resume ? "ืœื”ืžืฉื™ืš ืืช ื”ืžื‘ื—ืŸ"
68
+ : `ื‘ืงืฉืช ืžื‘ื—ืŸ ืชืจื’ื•ืœ${gain ? ` <small class="gain-hint">ืขื“ +${gain} ืžื•ื›ื ื•ืช</small>` : ""}`;
69
+ };
70
+ card.addEventListener("change", sync);
71
+ sync();
72
+ document.getElementById("practice-start").addEventListener("click", async event => {
73
+ const button = event.currentTarget, error = document.getElementById("practice-error"), m = mode();
74
+ const skills = [...card.querySelectorAll("#manual-skills input:checked")].map(i => i.value);
75
+ if (m === "manual" && !skills.length) { error.textContent = "ื‘ื—ืจื• ืœืคื—ื•ืช ืžื™ื•ืžื ื•ืช ืื—ืช."; error.hidden = false; return; }
76
+ button.disabled = true;
77
+ error.hidden = true;
78
+ try {
79
+ const data = await api(`/api/tests/${test.id}/practice/start`, { method: "POST", body: { mode: m, skills } });
80
+ location.hash = `#/exam/${test.id}/${data.test.number}/${encodeURIComponent(data.id)}`;
81
+ } catch (e) {
82
+ button.disabled = false;
83
+ error.textContent = e.message || "ืžืฉื”ื• ื”ืฉืชื‘ืฉ";
84
+ error.hidden = false;
85
+ }
86
+ });
87
  }
88
 
89
+ /** `focus`: a section to scroll to ("practice" from the home's button). */
90
+ export async function show(view, id, focus = "") {
91
  const test = await api(`/api/tests/${encodeURIComponent(id)}`);
92
  setSubject(test.subject_id); // a test belongs to one subject: its home and tab bar follow it
93
  const home = await api(forSubject("/api/home"));
94
  const others = home.tests.filter(t => t.id !== test.id);
95
  view(`${tabs("home")}<p><a class="back" href="${homeHref()}">โ€น ื”ื‘ื™ืช</a></p>
96
  <section class="card" id="test-card" data-test="${test.id}"><h2>ืžื‘ื—ืŸ ื‘ึพ${shortDate(test.date)} <span class="muted">(${daysText(test.days_left)})</span></h2>
97
+ <p class="muted">${test.lessons.map(l => fmt(l.name)).join(", ")}${test.note ? ` ยท ${esc(test.note)}` : ""}</p>
98
+ ${readinessMeter(test)}${HOW}</section>
99
+ ${simulationCard(test)}
100
+ ${practiceCard(test)}
101
+ ${trailCard(test)}
 
 
 
 
102
  ${others.length ? `<section class="card"><h3>ืžื‘ื—ื ื™ื ื ื•ืกืคื™ื</h3><ul class="plain" id="other-tests">${others.map(t => `<li><a href="#/test/${t.id}">${shortDate(t.date)} ยท ${t.lessons.map(l => fmt(l.name)).join(", ")}</a>
103
  <span class="muted">${daysText(t.days_left)} ยท ืžื•ื›ื ื•ืช ${t.readiness.number}</span></li>`).join("")}</ul></section>` : ""}`);
104
+ wire(test);
105
+ countUp();
106
+ if (focus) document.getElementById(focus)?.scrollIntoView({ block: "start" });
107
  }
mathly2/tests/test_behind_home_api.py CHANGED
@@ -60,43 +60,3 @@ def test_the_path_and_the_home_agree_on_what_is_behind(world):
60
  path = client.get("/api/path", headers=auth()).json()
61
  assert {l["id"] for l in path["lessons"] if l["behind"]} == {r["id"] for r in behind(client)} == {"t", "t2"} and path["behind"] == 2
62
 
63
-
64
- def weak(client):
65
- return client.get("/api/home", headers=auth()).json()["weak_tests"]
66
-
67
-
68
- def exercise(store, test, number, grade, when_offset=0):
69
- row = store.create_attempt(KID_ID, "exercise_test", f"{test['id']}:{number}", MON, MON + timedelta(minutes=45), [])
70
- store.save_attempt(row["id"], submitted_at=MON + timedelta(minutes=10 + when_offset), grade=grade)
71
-
72
-
73
- def test_practice_tests_below_87_are_listed_and_not_taken_or_good_ones_are_not(world):
74
- client, store, bank = world
75
- test = store.create_test(1, clock.school_day() + timedelta(days=10), ["t"], "t@x.test", created_at=clock.now() - timedelta(days=20))
76
- assert weak(client) == [] # none taken
77
- exercise(store, test, 1, 25)
78
- got = weak(client)
79
- assert got == [{"test_id": test["id"], "date": test["date"].isoformat(), "days_left": 10, "number": 1, "best": 25, "open": False}]
80
- exercise(store, test, 2, 87) # 13 of 15: good enough
81
- assert [(w["number"], w["best"]) for w in weak(client)] == [(1, 25)]
82
- exercise(store, test, 1, 86, 1) # a retake still below: the best of the two
83
- assert weak(client)[0]["best"] == 86
84
- exercise(store, test, 1, 90, 2)
85
- assert weak(client) == [] # reached: no longer listed
86
-
87
-
88
- def test_a_past_test_is_not_listed(world):
89
- client, store, bank = world
90
- old = store.create_test(1, clock.school_day() - timedelta(days=3), ["t"], "t@x.test", created_at=clock.now() - timedelta(days=30))
91
- exercise(store, old, 1, 10)
92
- assert weak(client) == []
93
-
94
-
95
- def test_only_the_nearest_tests_weak_practice_tests_are_on_home(world):
96
- client, store, bank = world
97
- near = store.create_test(1, clock.school_day() + timedelta(days=4), ["t"], "t@x.test", created_at=clock.now() - timedelta(days=20))
98
- far = store.create_test(1, clock.school_day() + timedelta(days=12), ["t2"], "t@x.test", created_at=clock.now() - timedelta(days=20))
99
- exercise(store, far, 1, 10)
100
- assert weak(client) == [] # only the farther test has one
101
- exercise(store, near, 2, 40)
102
- assert [(w["test_id"], w["number"]) for w in weak(client)] == [(near["id"], 2)]
 
60
  path = client.get("/api/path", headers=auth()).json()
61
  assert {l["id"] for l in path["lessons"] if l["behind"]} == {r["id"] for r in behind(client)} == {"t", "t2"} and path["behind"] == 2
62
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
mathly2/tests/test_countdown.py DELETED
@@ -1,95 +0,0 @@
1
- """The countdown plan and today's task (add-test-readiness 1.2)."""
2
- from datetime import date, timedelta
3
-
4
- from mathly2 import countdown as c
5
-
6
- TEST = date(2026, 11, 30)
7
- SET_LONG = TEST - timedelta(days=14)
8
-
9
-
10
- def facts(**kw):
11
- base = {"path_done": False, "next_path_skill": "s1", "ex1_done": False, "ex2_done": False, "sim_taken": False,
12
- "review_left": [], "stale": ["old", "older"]}
13
- return {**base, **kw}
14
-
15
-
16
- def on(days_left, plan_for=SET_LONG, **kw):
17
- return c.task(days_left, c.plan(TEST, plan_for), facts(**kw))
18
-
19
-
20
- def test_the_plan_for_two_weeks_has_the_prds_days():
21
- assert c.plan(TEST, SET_LONG) == {"n": 14, "ex1": 9, "ex2": 7, "sim": 5, "review_from": 4}
22
- assert c.plan(TEST, TEST - timedelta(days=9))["ex1"] == 9
23
-
24
-
25
- def test_twelve_days_ahead_the_plan_asks_to_finish_the_path():
26
- assert on(12) == {"kind": "path", "skill": "s1"}
27
- assert on(10) == {"kind": "path", "skill": "s1"}
28
-
29
-
30
- def test_nine_seven_and_five_days_ahead_are_exercise_test_1_2_and_the_simulation():
31
- assert on(9, path_done=True) == {"kind": "ex1"}
32
- assert on(8, path_done=True) == {"kind": "ex1"}
33
- assert on(7, path_done=True, ex1_done=True) == {"kind": "ex2"}
34
- assert on(5, path_done=True, ex1_done=True, ex2_done=True) == {"kind": "sim"}
35
-
36
-
37
- def test_the_recommended_step_comes_first_even_when_an_earlier_one_was_missed():
38
- assert on(5) == {"kind": "sim"} # exercise tests missed: the required simulation still comes first
39
- assert on(7) == {"kind": "ex2"}
40
-
41
-
42
- def test_a_done_step_moves_to_the_next_one_early_but_never_to_a_closed_simulation():
43
- assert on(12, path_done=True) == {"kind": "ex1"} # the path is done: exercise test 1 early
44
- assert on(12, path_done=True, ex1_done=True) == {"kind": "ex2"}
45
- assert on(10, path_done=True, ex1_done=True, ex2_done=True) == {"kind": "continue"} # the simulation is not open yet
46
- assert on(9, path_done=False, ex1_done=True) == {"kind": "ex2"}
47
-
48
-
49
- def test_after_the_simulation_the_last_days_review_a_failed_skill_and_the_day_before_is_mixed():
50
- done = dict(path_done=True, ex1_done=True, ex2_done=True, sim_taken=True)
51
- assert on(3, review_left=["f1", "f2"], **done) == {"kind": "review", "skill": "f1"}
52
- assert on(2, review_left=["f2"], **done) == {"kind": "review", "skill": "f2"}
53
- assert on(4, review_left=[], **done) == {"kind": "mixed_review", "skill": "old"} # nothing failed is left: mixed review
54
- assert on(1, review_left=["f2"], **done) == {"kind": "mixed_review", "skill": "old"} # the day before is always the light mixed review
55
- assert on(0, review_left=["f2"], **done) == {"kind": "continue"}
56
-
57
-
58
- def test_the_simulation_is_asked_for_in_the_last_days_if_it_was_not_taken():
59
- assert on(4, path_done=True, ex1_done=True, ex2_done=True) == {"kind": "sim"}
60
- assert on(0) == {"kind": "sim"} # open until the test day
61
-
62
-
63
- def test_a_test_set_six_days_ahead_keeps_the_order_within_six_days():
64
- p = c.plan(TEST, TEST - timedelta(days=6))
65
- assert p == {"n": 6, "ex1": 6, "ex2": 5, "sim": 3, "review_from": 3}
66
- days = [p["ex1"], p["ex2"], p["sim"]]
67
- assert days == sorted(days, reverse=True) and p["ex1"] <= 6
68
- sequence = [c.step_for(d, p) for d in range(6, 0, -1)]
69
- assert sequence == ["ex1", "ex2", "ex2", "sim", "sim", "sim"] # path, then ex1, ex2, simulation, then review (after the simulation)
70
- after = {**facts(path_done=True, ex1_done=True, ex2_done=True, sim_taken=True, review_left=["f"])}
71
- assert c.task(2, p, after) == {"kind": "review", "skill": "f"}
72
-
73
-
74
- def test_a_very_short_notice_keeps_every_step_startable_in_order():
75
- p = c.plan(TEST, TEST - timedelta(days=2))
76
- assert p["ex1"] >= p["ex2"] >= p["sim"] >= p["review_from"] >= 1
77
- assert c.task(2, p, facts(path_done=True)) == {"kind": "ex1"}
78
- assert c.task(2, p, facts(path_done=True, ex1_done=True)) == {"kind": "ex2"}
79
-
80
-
81
- def test_the_closest_test_gives_the_task():
82
- soon = {"id": 1, "date": date(2026, 11, 14)}
83
- later = {"id": 2, "date": date(2026, 11, 20)}
84
- today = date(2026, 11, 10)
85
- assert c.closest([later, soon], today)["id"] == 1 # tests in 4 and in 10 days: the one in 4
86
- assert c.closest([{"id": 3, "date": date(2026, 11, 9)}], today) is None
87
- assert c.closest([{"id": 4, "date": today}], today)["id"] == 4 # the test day itself is still upcoming
88
- assert c.closest([], today) is None
89
-
90
-
91
- def test_the_outline_lists_the_steps_with_their_days_and_done_marks():
92
- p = c.plan(TEST, SET_LONG)
93
- steps = c.outline(p, facts(path_done=True, ex1_done=True, sim_taken=True, review_left=["x"]))
94
- assert [(s["id"], s["done"]) for s in steps] == [("path", True), ("ex1", True), ("ex2", False), ("sim", True), ("review", False)]
95
- assert [s["day"] for s in steps[1:]] == [9, 7, 5, 4]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
mathly2/tests/test_home_browser.py CHANGED
@@ -131,61 +131,8 @@ def test_with_no_test_home_shows_the_map_and_a_link_to_the_path(browser, bank):
131
  no_sideways_scroll(page)
132
 
133
 
134
- def test_nine_days_before_the_test_the_button_opens_exercise_test_1_with_the_readiness_bar(browser, bank):
135
- app, store, klass, lessons = world(bank)
136
- skills = covered(bank, lessons)
137
- for i in skills[:6]:
138
- store.save_progress(KID_ID, i, started_at="then", mastered_at="then") # M = 6 of 8
139
- test = add_test(store, klass, lessons, 9)
140
- with running(app) as url:
141
- page = sign_in(browser, url)
142
- to_home(page)
143
- assert "ื‘ืขื•ื“ 9 ื™ืžื™ื" in page.inner_text("#nearest-test h2") and "ืžื•ื›ื ื•ืช 75" in page.inner_text("#nearest-test") # one strip on home
144
- assert page.get_attribute("#today-task", "href") == f"#/exam/{test['id']}/1" and "ืžื‘ื—ืŸ ืชืจื’ื•ืœ 1" in page.inner_text("#today-task")
145
- page.click("#nearest-test .strip-main") # the bar is on the test's card
146
- page.wait_for_selector("#test-card")
147
- bar = page.locator("#test-card [role=progressbar]")
148
- assert bar.get_attribute("aria-valuenow") == "75" and "75" in bar.get_attribute("aria-label")
149
- part = lambda p: page.locator(f".rd-part[data-part={p}]")
150
- assert part("M").inner_text().split() == ["ืฉืœื™ื˜ื”", "ืื—ื•ื–", "ื”ืžื™ื•ืžื ื•ื™ื•ืช", "ืฉื ืฉืœื˜ื•", "75%"]
151
- assert "ืžื‘ื—ื ื™ ืชืจื’ื•ืœ" in part("E").inner_text() and "ืžืžื•ืฆืข ื”ืฆื™ื•ื ื™ื ื”ื˜ื•ื‘ื™ื ื‘ื™ื•ืชืจ" in part("E").inner_text() and part("E").locator(".rd-val").inner_text() == "โ€”"
152
- assert "ืกื™ืžื•ืœืฆื™ื”" in part("S").inner_text() and "ื”ืฆื™ื•ืŸ ื‘ืžื‘ื—ืŸ ื”ืกื™ืžื•ืœืฆื™ื”" in part("S").inner_text() and part("S").locator(".rd-val").inner_text() == "โ€”"
153
- assert not any(f" {letter} " in f" {page.inner_text('.rd-parts')} " for letter in "MES") # no letters
154
- assert "ืขื•ื“ ืœื ืขืฉื™ืชื ืžื‘ื—ืŸ ืกื™ืžื•ืœืฆื™ื”" in page.inner_text(".rd-note")
155
- no_sideways_scroll(page)
156
-
157
-
158
- def test_four_days_before_the_test_the_simulation_is_required_on_home(browser, bank):
159
- app, store, klass, lessons = world(bank)
160
- test = add_test(store, klass, lessons, 4)
161
- with running(app) as url:
162
- page = sign_in(browser, url)
163
- to_home(page)
164
- required = page.locator(f".required[data-required='{test['id']}']")
165
- assert required.count() == 1 and "ื—ื•ื‘ื”" in required.inner_text() and required.get_attribute("href") == f"#/sim/{test['id']}"
166
- assert page.get_attribute("#today-task", "href") == f"#/sim/{test['id']}"
167
 
168
 
169
- def test_the_readiness_bar_shows_every_part_and_loses_the_cap_after_the_simulation(browser, bank):
170
- app, store, klass, lessons = world(bank)
171
- for i in covered(bank, lessons):
172
- store.save_progress(KID_ID, i, started_at="then", mastered_at="then")
173
- test = add_test(store, klass, lessons, 3)
174
- now = clock.now()
175
- for scope, kind, grade in ((f"{test['id']}:1", "exercise_test", 60), (f"{test['id']}:2", "exercise_test", 80), (str(test["id"]), "simulation", 90)):
176
- row = store.create_attempt(KID_ID, kind, scope, now - timedelta(days=1), now - timedelta(days=1) + timedelta(minutes=45), [])
177
- store.save_attempt(row["id"], submitted_at=now - timedelta(days=1) + timedelta(minutes=5), grade=grade)
178
- with running(app) as url:
179
- page = sign_in(browser, url)
180
- to_home(page)
181
- page.wait_for_selector("#nearest-test")
182
- assert page.locator(".required").count() == 0 # the simulation is taken
183
- page.click("#nearest-test .strip-main")
184
- page.wait_for_selector("#test-card .rd-part")
185
- vals = {p: page.locator(f".rd-part[data-part={p}] .rd-val").inner_text() for p in "MES"}
186
- assert vals == {"M": "100%", "E": "70", "S": "90"}
187
- assert page.locator(".rd-note").count() == 0 and "ืžื•ื›ื ื™ื" in page.inner_text(".rd-label")
188
-
189
 
190
  def test_home_shows_only_the_closest_test_and_the_other_tests_are_on_its_card(browser, bank):
191
  app, store, klass, lessons = world(bank)
@@ -194,7 +141,7 @@ def test_home_shows_only_the_closest_test_and_the_other_tests_are_on_its_card(br
194
  with running(app) as url:
195
  page = sign_in(browser, url)
196
  to_home(page)
197
- assert page.get_attribute("#nearest-test", "data-test") == str(near["id"]) and page.get_attribute("#today-task", "href") == f"#/exam/{near['id']}/1"
198
  assert page.locator("#other-tests").count() == 0 and page.locator("#notifications").count() == 0 # only what is critical
199
  page.click("#nearest-test .strip-main")
200
  page.wait_for_selector("#other-tests")
@@ -215,55 +162,7 @@ def finished(store, scope, kind, grade, questions=None, answers=None):
215
  return store.save_attempt(row["id"], submitted_at=now - timedelta(days=1) + timedelta(minutes=9), grade=grade, answers=answers or {})
216
 
217
 
218
- def test_a_fresh_card_shows_the_plan_the_exercise_tests_what_to_practise_first_and_when_the_simulation_opens(browser, bank):
219
- app, store, klass, lessons = world(bank)
220
- skills = covered(bank, lessons)
221
- store.save_progress(KID_ID, skills[0], started_at="then", mastered_at="then")
222
- test = add_test(store, klass, lessons, 14, questions=10, minutes=30)
223
- with running(app) as url:
224
- page = card_page(browser, url, test["id"])
225
- assert "10 ืฉืืœื•ืช ยท 30 ื“ืงื•ืช" in page.inner_text("#test-card") and "ื‘ืขื•ื“ 14 ื™ืžื™ื" in page.inner_text("#test-card h2")
226
- steps = page.locator("#plan li")
227
- assert steps.count() == 5 and [steps.nth(i).get_attribute("data-step") for i in range(5)] == ["path", "ex1", "ex2", "sim", "review"]
228
- assert "ืขื“ 9 ื™ืžื™ื ืœืคื ื™" in steps.nth(0).inner_text() and "ื‘ืขืจืš 9 ื™ืžื™ื ืœืคื ื™" in steps.nth(1).inner_text() and "ื‘ืขืจืš 5 ื™ืžื™ื ืœืคื ื™" in steps.nth(3).inner_text()
229
- assert page.locator("#plan li.done").count() == 0
230
- assert page.locator("#exercises li").count() == 2 and "ืขื•ื“ ืœื ื ืขืฉื”" in page.inner_text("#exercises li >> nth=0")
231
- assert page.get_attribute("#exercises li[data-exercise='2'] a", "href") == f"#/exam/{test['id']}/2" and "ื”ืชื—ืœื”" in page.inner_text("#exercises li >> nth=1")
232
- assert page.locator("#practice-first a").count() == 7 # eight covered skills, one started
233
- assert "ื™ื™ืคืชื— ื‘ึพ" in page.inner_text("#sim-closed") and page.locator("#sim-start").count() == 0
234
- no_sideways_scroll(page)
235
-
236
-
237
- def test_exercise_tests_show_their_best_grade_and_offer_a_retake_or_to_continue(browser, bank):
238
- app, store, klass, lessons = world(bank)
239
- test = add_test(store, klass, lessons, 10)
240
- finished(store, f"{test['id']}:1", "exercise_test", 55)
241
- finished(store, f"{test['id']}:1", "exercise_test", 80)
242
- now = clock.now()
243
- store.create_attempt(KID_ID, "exercise_test", f"{test['id']}:2", now, now + timedelta(minutes=45), []) # exercise test 2 is open
244
- with running(app) as url:
245
- page = card_page(browser, url, test["id"])
246
- one, two = page.locator("#exercises li[data-exercise='1']"), page.locator("#exercises li[data-exercise='2']")
247
- assert "ื”ืฆื™ื•ืŸ ื”ื˜ื•ื‘: 80" in one.inner_text() and "2 ื ื™ืกื™ื•ื ื•ืช" in one.inner_text() and "ืฉื•ื‘" in one.locator("a").inner_text()
248
- assert "ืœื”ืžืฉื™ืš" in two.locator("a").inner_text()
249
- assert page.locator("#plan li[data-step='ex1'].done").count() == 1 and page.locator("#plan li[data-step='ex2'].done").count() == 0
250
-
251
 
252
- def test_an_open_simulation_is_required_and_a_taken_one_shows_its_grade_and_review_skills(browser, bank):
253
- app, store, klass, lessons = world(bank)
254
- skills = covered(bank, lessons)
255
- test = add_test(store, klass, lessons, 4)
256
- with running(app) as url:
257
- page = card_page(browser, url, test["id"])
258
- assert page.locator("#simulation .req").count() == 1 and page.get_attribute("#sim-start", "href") == f"#/sim/{test['id']}"
259
- assert "ื”ืชื—ืœื”" in page.inner_text("#sim-start")
260
- questions = [{"skill_id": skills[0], "key": bank.question_keys(skills[0])[0]}, {"skill_id": skills[1], "key": bank.question_keys(skills[1])[0]}]
261
- finished(store, str(test["id"]), "simulation", 0, questions=questions) # nothing answered: both skills failed
262
- page.reload()
263
- page.wait_for_selector("#simulation[data-taken='true']")
264
- assert page.inner_text("#sim-grade") == "0" and page.locator("#sim-start").count() == 0
265
- assert [a.get_attribute("href") for a in page.locator("#sim-review a").all()] == [f"#/skill/{skills[0]}", f"#/skill/{skills[1]}"]
266
- assert page.locator("#plan li[data-step='sim'].done").count() == 1
267
 
268
 
269
  def test_a_test_of_another_class_or_a_missing_one_shows_an_error(browser, bank):
@@ -291,47 +190,22 @@ def test_an_exercise_test_runs_with_the_tests_length_and_no_timer_and_links_its_
291
  page = sign_in(browser, url, f"#/exam/{test['id']}/1")
292
  page.wait_for_selector("#qz-q")
293
  assert page.evaluate("location.hash").startswith(f"#/exam/{test['id']}/1/") # the address names the attempt
294
- assert "ืžื‘ื—ืŸ ืชืจื’ื•ืœ 1" in page.inner_text(".mx-sheet") and page.locator(".qz-dot").count() == 6
295
  assert page.locator("#qz-timer").count() == 0 # only the simulation is timed
296
  sheet = page.inner_text(".mx-sheet")
297
  assert not any(bank.skill(i)["name"] in sheet for i in skills) # no skill names, no feedback
298
  page.click("#qz-submit")
299
  page.click("#qz-yes")
300
  page.wait_for_selector("#qz-grade")
301
- assert page.inner_text("#qz-grade b") == "0" and "ืชื•ืฆืืช ืžื‘ื—ืŸ ืชืจื’ื•ืœ 1" in page.inner_text(".mx-sheet")
302
  links = [a.get_attribute("href") for a in page.locator("#qz-lost a").all()]
303
  assert links and all(h.startswith("#/skill/") and h[8:] in skills for h in links) # weak skills link to their stars
304
- assert page.locator("#qz-retake").inner_text() == "ืžื‘ื—ืŸ ื—ื•ื–ืจ"
305
- page.click("#qz-retake") # a retake is a new attempt
306
- page.wait_for_selector("#qz-q")
307
- assert len(store.attempts_of(KID_ID, "exercise_test", f"{test['id']}:1")) == 2
308
- page.click(".mx-x")
309
  page.wait_for_selector("#test-card") # closing leads back to the test's card
310
  assert page.evaluate("location.hash") == f"#/test/{test['id']}"
311
 
312
 
313
- def test_the_simulation_can_be_taken_once_and_a_second_visit_shows_the_result(browser, bank):
314
- app, store, klass, lessons = world(bank)
315
- started_skills(store, bank, lessons)
316
- test = add_test(store, klass, lessons, 4, questions=6, minutes=45)
317
- with running(app) as url:
318
- page = sign_in(browser, url, f"#/sim/{test['id']}")
319
- page.wait_for_selector("#qz-q")
320
- assert "ืžื‘ื—ืŸ ืกื™ืžื•ืœืฆื™ื”" in page.inner_text(".mx-sheet") and page.locator(".qz-dot").count() == 6
321
- assert page.inner_text("#qz-timer").startswith("44:") or page.inner_text("#qz-timer") == "45:00" # the test's minutes
322
- page.click("#qz-submit")
323
- page.click("#qz-yes")
324
- page.wait_for_selector("#qz-grade")
325
- assert page.locator("#qz-retake").count() == 0 and "ืคืขื ืื—ืช" in page.inner_text(".qz-result")
326
- page.goto(url + f"#/sim/{test['id']}") # opening it again
327
- page.reload()
328
- page.wait_for_selector("#qz-grade")
329
- assert page.inner_text("#qz-grade b") == "0" and page.locator("#qz-q").count() == 0
330
- assert len(store.attempts_of(KID_ID, "simulation")) == 1
331
- page.click(".qz-actions [data-close]")
332
- page.wait_for_selector("#test-card")
333
- assert page.inner_text("#sim-grade") == "0"
334
-
335
 
336
  def test_when_the_simulations_countdown_reaches_zero_it_is_handed_in_as_it_stands(browser, bank):
337
  from mathly2 import quiz
@@ -350,28 +224,6 @@ def test_when_the_simulations_countdown_reaches_zero_it_is_handed_in_as_it_stand
350
  assert page.inner_text("#qz-grade b") == "0" and "ื”ื–ืžืŸ ื ื’ืžืจ" in page.inner_text(".qz-result")
351
 
352
 
353
- def test_the_simulation_before_its_window_says_when_it_opens_and_does_not_start(browser, bank):
354
- app, store, klass, lessons = world(bank)
355
- started_skills(store, bank, lessons)
356
- test = add_test(store, klass, lessons, 14)
357
- with running(app) as url:
358
- page = sign_in(browser, url, f"#/sim/{test['id']}")
359
- page.wait_for_selector("[role=alert]")
360
- assert "ื™ื™ืคืชื—" in page.inner_text("[role=alert]") and page.locator("#qz-q").count() == 0
361
- assert store.attempts_of(KID_ID, "simulation") == []
362
-
363
-
364
- def test_the_simulation_with_nothing_practiced_offers_a_bypass_on_all_the_material(browser, bank):
365
- app, store, klass, lessons = world(bank)
366
- test = add_test(store, klass, lessons, 2, questions=6, minutes=45) # two days left, nothing started
367
- with running(app) as url:
368
- page = sign_in(browser, url, f"#/sim/{test['id']}")
369
- page.wait_for_selector("#sim-bypass")
370
- assert "ืขื•ื“ ืœื ื”ืชื—ืœืชื" in page.inner_text("[role=alert]") and store.attempts_of(KID_ID, "simulation") == []
371
- page.click("#sim-bypass")
372
- page.wait_for_selector("#qz-q")
373
- assert "ืžื‘ื—ืŸ ืกื™ืžื•ืœืฆื™ื”" in page.inner_text(".mx-sheet") and page.locator(".qz-dot").count() == 6
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):
@@ -402,12 +254,13 @@ def test_after_the_simulation_the_wrong_questions_are_answered_again_in_a_round(
402
  page.click("#rd-result [data-close]")
403
  page.wait_for_selector("#test-card")
404
  assert page.locator(".round-layer").count() == 0 and page.errors == []
405
- page.wait_for_selector("#focus[data-ready='true']") # the weak topics now have their own test
406
- page.click("#focus-start")
 
407
  page.wait_for_selector("#qz-q")
408
- assert "ื ื•ืฉืื™ื ืœื—ื™ื–ื•ืง" in page.inner_text(".mx-sheet h3") and 0 < page.locator(".qz-dot").count() <= 15
409
  focus = store.attempts_of(KID_ID, "exercise_test")[0]
410
- lost = {q["skill_id"] for q in store.attempts_of(KID_ID, "simulation")[0]["questions"]}
411
  assert focus["scope"].endswith(":3") and {q["skill_id"] for q in focus["questions"]} <= lost and page.errors == []
412
 
413
 
@@ -427,8 +280,7 @@ def test_exercise_tests_and_the_simulation_appear_on_the_grades_screen(browser,
427
  page = sign_in(browser, url, "#/grades")
428
  page.wait_for_selector("#exercise-grades")
429
  one = page.locator(f"#exercise-grades li[data-test='{test['id']}'][data-number='1']")
430
- assert one.locator(".gr-num").inner_text() == "80" and "2 ื ื™ืกื™ื•ื ื•ืช" in one.inner_text() and "ืžื‘ื—ืŸ ืชืจื’ื•ืœ 1" in one.inner_text()
431
- assert page.locator(f"#exercise-grades li[data-test='{test['id']}'][data-number='2'] .gr-num").inner_text() == "70"
432
  assert page.locator(f"#simulation-grades li[data-test='{test['id']}'] .gr-num").inner_text() == "65"
433
  gone = page.locator(f"#exercise-grades li[data-test='{removed['id']}']")
434
  assert gone.locator(".gr-num").inner_text() == "90" and "ื”ื•ืกืจ" in gone.inner_text() # a removed test keeps its grade and name
@@ -505,7 +357,7 @@ def test_the_tab_shows_who_has_not_taken_the_simulation_and_each_students_readin
505
  assert "a@school.test" in missing and "b@school.test" in missing and KID not in missing
506
  assert page.locator(".readiness-table tbody tr").count() == 3
507
  mine = page.locator(f".readiness-table tr:has-text('{KID}')")
508
- assert "28" in mine.inner_text() and "75" in mine.inner_text() and "0%" in mine.inner_text() # M 0 and S 75: (30*75) / 80
509
 
510
 
511
  def test_a_refused_test_shows_why_and_keeps_what_was_typed(browser, bank):
@@ -544,76 +396,71 @@ def answer_in_browser(page, bank, store, kind, scope, rights):
544
  return refs
545
 
546
 
547
- def test_the_whole_countdown_from_setting_a_test_to_the_review(browser, bank, monkeypatch):
 
 
 
 
 
 
 
548
  app, store, klass, lessons = world(bank)
549
- moment = {"now": clock.now()}
550
- monkeypatch.setattr(clock, "now", lambda: moment["now"])
551
- advance = lambda days: moment.update(now=moment["now"] + timedelta(days=days))
552
- skills = covered(bank, lessons)
553
  with running(app) as url:
554
- teacher = teacher_page(browser, url) # the teacher sets a test 12 days ahead, 6 questions
555
- teacher.click(".set-test summary")
556
- teacher.fill("[name=date]", iso(12))
557
- teacher.fill("[name=questions]", "6")
558
- teacher.click(".set-test button[type=submit]")
559
- teacher.wait_for_selector(".test-row")
560
- test = store.tests_of_classes([klass["id"]])[0]
561
- student = sign_in(browser, url)
562
- to_home(student)
563
- task = lambda: student.get_attribute("#today-task", "href")
564
- reload_home = lambda: (student.goto(url + "#/subject/math"), student.reload(), student.wait_for_selector(".tabs"), student.wait_for_selector("#today-task"))
565
-
566
- def on_card(read): # the readiness is on the test's card
567
- student.click("#nearest-test .strip-main")
568
- student.wait_for_selector("#test-card .rd-part")
569
- got = read()
570
- reload_home()
571
- return got
572
-
573
- assert task() == f"#/skill/{skills[0]}" and "ืœืกื™ื™ื ืืช ื”ืžืกืœื•ืœ" in student.inner_text("#today-task") # 12 days: finish the path
574
- assert "ืกื™ืžื•ืœืฆื™ื”" in on_card(lambda: student.inner_text(".rd-note")) # the bar says it is capped until the simulation
575
- for i in skills: # the path is finished
576
- store.save_progress(KID_ID, i, started_at="then", mastered_at="then")
577
- reload_home()
578
- assert task() == f"#/exam/{test['id']}/1" # next: exercise test 1, early
579
- student.click("#today-task")
580
- answer_in_browser(student, bank, store, "exercise_test", f"{test['id']}:1", [True] * 6)
581
- assert student.inner_text("#qz-grade b") == "100"
582
- advance(3) # 9 days left
583
- reload_home()
584
- assert task() == f"#/exam/{test['id']}/2" # exercise test 2
585
- student.click("#today-task")
586
- answer_in_browser(student, bank, store, "exercise_test", f"{test['id']}:2", [True] * 6)
587
- advance(4) # 5 days left: the simulation opens
588
- reload_home()
589
- assert task() == f"#/sim/{test['id']}" and student.locator(f".required[data-required='{test['id']}']").count() == 1
590
- vals = lambda: {p: student.locator(f".rd-part[data-part={p}] .rd-val").inner_text() for p in "MES"}
591
- got = on_card(vals)
592
- assert got["E"] == "100" and got["S"] == "โ€”"
593
- student.click("#today-task")
594
- answer_in_browser(student, bank, store, "simulation", str(test["id"]), [True, True, True, True, False, False])
595
- assert student.inner_text("#qz-grade b") == "67"
596
- failed = [a.get_attribute("href") for a in student.locator("#qz-lost a").all()]
597
- assert failed
598
- advance(2) # 3 days left: review what failed
599
- reload_home()
600
- assert task() in failed and student.locator(".required").count() == 0 # a failed skill is today's review
601
- assert on_card(lambda: (vals()["S"], student.locator(".rd-note").count())) == ("67", 0) # the cap is gone after the simulation
602
- teacher.reload()
603
- teacher.click("#teacher-tabs [data-tab='tests']")
604
- teacher.wait_for_selector(".test-row")
605
- assert "1 ืžืชื•ืš 1 ืขืฉื• ืกื™ืžื•ืœืฆื™ื”" in teacher.inner_text(".sim-count")
606
 
607
 
608
- # --- readiness-tooltip: the number explains itself ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
609
 
610
- def test_the_tooltip_is_on_the_tests_card_too_and_opens_on_hover_with_a_mouse(browser, bank):
611
  app, store, klass, lessons = world(bank)
612
- test = add_test(store, klass, lessons, 7)
 
 
613
  with running(app) as url:
614
- page = sign_in(browser, url, f"#/test/{test['id']}")
615
- to_home_card = page.wait_for_selector("[data-info]")
616
- page.hover("[data-info]")
617
- assert page.locator(".tip").first.is_visible() and "50%" in page.locator(".tip").first.inner_text()
618
- page.mouse.move(5, 5)
619
- assert page.locator(".tip").first.is_hidden()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
  no_sideways_scroll(page)
132
 
133
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
 
135
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
 
137
  def test_home_shows_only_the_closest_test_and_the_other_tests_are_on_its_card(browser, bank):
138
  app, store, klass, lessons = world(bank)
 
141
  with running(app) as url:
142
  page = sign_in(browser, url)
143
  to_home(page)
144
+ assert page.get_attribute("#nearest-test", "data-test") == str(near["id"]) and page.get_attribute("#home-sim", "href") == f"#/sim/{near['id']}"
145
  assert page.locator("#other-tests").count() == 0 and page.locator("#notifications").count() == 0 # only what is critical
146
  page.click("#nearest-test .strip-main")
147
  page.wait_for_selector("#other-tests")
 
162
  return store.save_attempt(row["id"], submitted_at=now - timedelta(days=1) + timedelta(minutes=9), grade=grade, answers=answers or {})
163
 
164
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
 
167
 
168
  def test_a_test_of_another_class_or_a_missing_one_shows_an_error(browser, bank):
 
190
  page = sign_in(browser, url, f"#/exam/{test['id']}/1")
191
  page.wait_for_selector("#qz-q")
192
  assert page.evaluate("location.hash").startswith(f"#/exam/{test['id']}/1/") # the address names the attempt
193
+ assert "ืžื‘ื—ืŸ ืชืจื’ื•ืœ ืžืื•ื–ืŸ" in page.inner_text(".mx-sheet") and page.locator(".qz-dot").count() == 6
194
  assert page.locator("#qz-timer").count() == 0 # only the simulation is timed
195
  sheet = page.inner_text(".mx-sheet")
196
  assert not any(bank.skill(i)["name"] in sheet for i in skills) # no skill names, no feedback
197
  page.click("#qz-submit")
198
  page.click("#qz-yes")
199
  page.wait_for_selector("#qz-grade")
200
+ assert page.inner_text("#qz-grade b") == "0" and "ืชื•ืฆืืช ืžื‘ื—ืŸ ืชืจื’ื•ืœ ืžืื•ื–ืŸ" in page.inner_text(".mx-sheet")
201
  links = [a.get_attribute("href") for a in page.locator("#qz-lost a").all()]
202
  assert links and all(h.startswith("#/skill/") and h[8:] in skills for h in links) # weak skills link to their stars
203
+ assert "ื”ืžื•ื›ื ื•ืช ื ืฉืืจื” 0" in page.inner_text("#qz-ready") and page.locator("#qz-retake").count() == 0
204
+ page.click(".qz-actions [data-close]")
 
 
 
205
  page.wait_for_selector("#test-card") # closing leads back to the test's card
206
  assert page.evaluate("location.hash") == f"#/test/{test['id']}"
207
 
208
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
 
210
  def test_when_the_simulations_countdown_reaches_zero_it_is_handed_in_as_it_stands(browser, bank):
211
  from mathly2 import quiz
 
224
  assert page.inner_text("#qz-grade b") == "0" and "ื”ื–ืžืŸ ื ื’ืžืจ" in page.inner_text(".qz-result")
225
 
226
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
 
228
 
229
  def test_after_the_simulation_the_wrong_questions_are_answered_again_in_a_round(browser, bank):
 
254
  page.click("#rd-result [data-close]")
255
  page.wait_for_selector("#test-card")
256
  assert page.locator(".round-layer").count() == 0 and page.errors == []
257
+ page.check("#practice input[value='focus']") # the hard skills now have their own test
258
+ assert page.locator("#focus-skills .ex-chip").count() > 0
259
+ page.click("#practice-start")
260
  page.wait_for_selector("#qz-q")
261
+ assert "ืžื‘ื—ืŸ ืชืจื’ื•ืœ ืžืžื•ืงื“" in page.inner_text(".mx-sheet h3") and 0 < page.locator(".qz-dot").count() <= 15
262
  focus = store.attempts_of(KID_ID, "exercise_test")[0]
263
+ lost = {q["skill_id"] for q in store.attempts_of(KID_ID, "simulation")[0]["questions"]} | set(covered(bank, lessons)[:5]) # or still learning
264
  assert focus["scope"].endswith(":3") and {q["skill_id"] for q in focus["questions"]} <= lost and page.errors == []
265
 
266
 
 
280
  page = sign_in(browser, url, "#/grades")
281
  page.wait_for_selector("#exercise-grades")
282
  one = page.locator(f"#exercise-grades li[data-test='{test['id']}'][data-number='1']")
283
+ assert one.locator(".gr-num").inner_text() == "80" and "3 ื ื™ืกื™ื•ื ื•ืช" in one.inner_text() and "ืžื‘ื—ืŸ ืชืจื’ื•ืœ ืžืื•ื–ืŸ" in one.inner_text() # 1 and the old 2: balanced
 
284
  assert page.locator(f"#simulation-grades li[data-test='{test['id']}'] .gr-num").inner_text() == "65"
285
  gone = page.locator(f"#exercise-grades li[data-test='{removed['id']}']")
286
  assert gone.locator(".gr-num").inner_text() == "90" and "ื”ื•ืกืจ" in gone.inner_text() # a removed test keeps its grade and name
 
357
  assert "a@school.test" in missing and "b@school.test" in missing and KID not in missing
358
  assert page.locator(".readiness-table tbody tr").count() == 3
359
  mine = page.locator(f".readiness-table tr:has-text('{KID}')")
360
+ assert "45 ยท " in mine.inner_text() and "1 ยท ื”ื˜ื•ื‘ 75" in mine.inner_text() # one simulation of 75: 60% of the way
361
 
362
 
363
  def test_a_refused_test_shows_why_and_keeps_what_was_typed(browser, bank):
 
396
  return refs
397
 
398
 
399
+
400
+ # --- readiness-tooltip: the number explains itself ---
401
+
402
+
403
+
404
+ # --- readiness: the home's block, the test's page, practice tests by mode and simulations on request ---
405
+
406
+ def test_home_shows_the_readiness_and_two_clear_buttons_that_raise_it(browser, bank):
407
  app, store, klass, lessons = world(bank)
408
+ test = add_test(store, klass, lessons, 2)
 
 
 
409
  with running(app) as url:
410
+ page = sign_in(browser, url)
411
+ to_home(page)
412
+ strip = page.locator("#nearest-test")
413
+ assert "ืžื‘ื—ืŸ ื‘ืขื•ื“ 2 ื™ืžื™ื" in strip.inner_text() and "ืžื•ื›ื ื•ืช ืœืžื‘ื—ืŸ" in strip.inner_text()
414
+ assert strip.locator(".rd-num").inner_text() == "0" and "ืžืชื—ื™ืœื™ื" in strip.inner_text() and 'ืขื•ื“ 40 ืœืจืžื” "ื‘ื“ืจืš"' in strip.inner_text()
415
+ assert page.get_attribute("#nearest-test .strip-main", "href") == f"#/test/{test['id']}"
416
+ assert "ืžื‘ื—ืŸ ืชืจื’ื•ืœ" in page.inner_text("#home-practice") and "+30" in page.inner_text("#home-practice") and "+60" in page.inner_text("#home-sim")
417
+ assert page.get_attribute("#home-practice", "href") == f"#/test/{test['id']}/practice"
418
+ page.click("#home-practice") # straight to choosing a practice test
419
+ page.wait_for_selector("#practice .modes")
420
+ assert page.evaluate("document.getElementById('practice').getBoundingClientRect().top") < 200
421
+ no_sideways_scroll(page)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
422
 
423
 
424
+ def test_a_simulation_on_request_raises_readiness_with_a_level_up_and_another_can_follow(browser, bank):
425
+ app, store, klass, lessons = world(bank)
426
+ test = add_test(store, klass, lessons, 30, questions=4, minutes=20) # a month ahead: no window
427
+ with running(app) as url:
428
+ page = sign_in(browser, url)
429
+ to_home(page)
430
+ page.click("#home-sim")
431
+ page.wait_for_selector("#qz-timer")
432
+ assert "ืžื‘ื—ืŸ ืกื™ืžื•ืœืฆื™ื”" in page.inner_text(".mx-sheet") and page.locator(".qz-dot").count() == 4
433
+ answer_in_browser(page, bank, store, "simulation", str(test["id"]), [True] * 4)
434
+ page.wait_for_selector("#qz-ready.up")
435
+ assert page.inner_text(".qz-gain") == "+60" and "ืขืœืชื” ืœึพ60" in page.inner_text("#qz-ready") and "ืจืžื” ื—ื“ืฉื”: ื‘ื“ืจืš" in page.inner_text("#qz-ready")
436
+ page.wait_for_function("document.querySelector('#qz-ready .rd-bar i').style.width === '60%'") # the bar grows to the new number
437
+ page.click("#qz-more-sim") # another one, on request
438
+ page.wait_for_selector("#qz-timer")
439
+ assert len(store.attempts_of(KID_ID, "simulation")) == 2 and page.errors == []
440
+
441
 
442
+ def test_the_tests_page_has_three_practice_modes_and_the_trail_of_gains(browser, bank):
443
  app, store, klass, lessons = world(bank)
444
+ skills = covered(bank, lessons)
445
+ test = add_test(store, klass, lessons, 10, questions=4)
446
+ finished(store, f"{test['id']}:1", "exercise_test", 50)
447
  with running(app) as url:
448
+ page = card_page(browser, url, test["id"])
449
+ page.wait_for_function("document.querySelector('#test-card .rd-num').textContent === '15'")
450
+ assert [m.get_attribute("data-mode") for m in page.locator("#practice .mode").all()] == ["balanced", "focus", "manual"]
451
+ assert page.locator("#practice input[value='focus']").is_disabled() # nothing hard yet (no questions in that one)
452
+ assert "+15" in page.inner_text("#trail") and "ืžื‘ื—ืŸ ืชืจื’ื•ืœ ืžืื•ื–ืŸ" in page.inner_text("#trail")
453
+ page.check("#practice input[value='manual']")
454
+ assert page.locator("#manual-skills").is_visible() and page.locator("#manual-skills input").count() == len(skills)
455
+ page.click("#practice-start")
456
+ assert "ื‘ื—ืจื• ืœืคื—ื•ืช" in page.inner_text("#practice-error")
457
+ page.check(f"#manual-skills input[value='{skills[0]}']")
458
+ page.click("#practice-start")
459
+ page.wait_for_selector("#qz-q")
460
+ row = store.attempts_of(KID_ID, "exercise_test", f"{test['id']}:4")[0]
461
+ assert "ืžื‘ื—ืŸ ืชืจื’ื•ืœ ื‘ื‘ื—ื™ืจื”" in page.inner_text(".mx-sheet h3") and {q["skill_id"] for q in row["questions"]} == {skills[0]}
462
+ page.click(".mx-x")
463
+ page.wait_for_selector("#practice")
464
+ page.click("#rd-how summary")
465
+ assert "ืกื™ืžื•ืœืฆื™ื” ืกื•ื’ืจ 60%" in page.inner_text("#rd-how") and page.errors == []
466
+ no_sideways_scroll(page)
mathly2/tests/test_lesson_map_browser.py CHANGED
@@ -67,7 +67,7 @@ def test_home_is_a_map_of_the_lessons_with_stars_and_a_play_button(browser, bank
67
  first, late = page.locator(f".lnode[data-lesson='{lessons[0]}']"), page.locator(f".lnode[data-lesson='{lessons[2]}']")
68
  assert first.get_attribute("data-stars") == "3" and "๐Ÿ‘‘" in first.text_content()
69
  assert "โฐ" in late.text_content()
70
- assert page.locator("#stars-count").inner_text() == f"โญ 3 ืžืชื•ืš {3 * len(lessons)}"
71
  assert page.locator("#play").get_attribute("href") == f"#/round/{lessons[2]}" # the behind lesson comes first
72
  assert page.locator(".lnode.next").get_attribute("data-lesson") == lessons[2]
73
  assert page.locator(".tabs a").nth(1).inner_text() == "ื”ืขืžืงื”" and page.locator("#behind-card").count() == 0
@@ -131,7 +131,7 @@ def test_home_is_one_screen_with_every_lesson_a_test_and_an_assignment(browser,
131
  to_home(page)
132
  page.wait_for_selector("#lesson-map")
133
  page.wait_for_selector("#assignments")
134
- assert page.locator(".lnode").count() == len(lessons) and page.locator("#nearest-test #today-task").count() == 1
135
  assert page.evaluate("document.documentElement.scrollHeight <= window.innerHeight") # nothing to scroll
136
  no_sideways_scroll(page)
137
  shot(page, "home-one-screen")
 
67
  first, late = page.locator(f".lnode[data-lesson='{lessons[0]}']"), page.locator(f".lnode[data-lesson='{lessons[2]}']")
68
  assert first.get_attribute("data-stars") == "3" and "๐Ÿ‘‘" in first.text_content()
69
  assert "โฐ" in late.text_content()
70
+ assert page.locator("#stars-count").inner_text() == f"โญ 3/{3 * len(lessons)}"
71
  assert page.locator("#play").get_attribute("href") == f"#/round/{lessons[2]}" # the behind lesson comes first
72
  assert page.locator(".lnode.next").get_attribute("data-lesson") == lessons[2]
73
  assert page.locator(".tabs a").nth(1).inner_text() == "ื”ืขืžืงื”" and page.locator("#behind-card").count() == 0
 
131
  to_home(page)
132
  page.wait_for_selector("#lesson-map")
133
  page.wait_for_selector("#assignments")
134
+ assert page.locator(".lnode").count() == len(lessons) and page.locator("#nearest-test #home-sim").count() == 1
135
  assert page.evaluate("document.documentElement.scrollHeight <= window.innerHeight") # nothing to scroll
136
  no_sideways_scroll(page)
137
  shot(page, "home-one-screen")
mathly2/tests/test_permissions.py CHANGED
@@ -57,6 +57,7 @@ NEEDS = { # (method, path) -> permission
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",
61
  ("GET", "/api/lessons/{lesson_id}/quiz"): "path.use",
62
  ("POST", "/api/lessons/{lesson_id}/quiz/start"): "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
+ ("POST", "/api/tests/{test_id}/practice/start"): "path.use",
61
  ("GET", "/api/tests/{test_id}"): "path.use",
62
  ("GET", "/api/lessons/{lesson_id}/quiz"): "path.use",
63
  ("POST", "/api/lessons/{lesson_id}/quiz/start"): "path.use",
mathly2/tests/test_readiness.py CHANGED
@@ -1,44 +1,30 @@
1
- """The readiness number (add-test-readiness 1.3)."""
2
- import pytest
3
-
4
  from mathly2 import readiness
5
 
6
 
7
- def test_only_mastery_so_far_gives_that_percent():
8
- got = readiness.compute(60, None, None)
9
- assert got["number"] == 60 and got["capped"] is False and got["parts"] == {"M": 60, "E": None, "S": None}
10
-
11
-
12
- def test_all_three_parts_are_weighted_50_20_30():
13
- assert readiness.compute(80, 70, 90)["number"] == 81
14
- assert readiness.compute(100, 100, 100)["number"] == 100 and readiness.compute(0, 0, 0)["number"] == 0
15
-
16
-
17
- def test_a_missing_part_is_left_out_and_the_others_are_scaled_up():
18
- assert readiness.compute(80, 60, None)["number"] == 74 # (50*80 + 20*60) / 70, rounded
19
- assert readiness.compute(50, None, 70)["number"] == 58 # (50*50 + 30*70) / 80
20
- assert readiness.compute(None, None, 40)["number"] == 40
21
-
22
-
23
- def test_before_the_simulation_the_number_is_capped_at_80_and_says_so():
24
- got = readiness.compute(100, 95, None)
25
- assert got["number"] == 80 and got["capped"] is True and got["waiting_for_simulation"] is True
26
- assert readiness.compute(100, 95, 100)["number"] == 99 and readiness.compute(100, 95, 100)["waiting_for_simulation"] is False
27
- assert readiness.compute(79, None, None)["capped"] is False
28
 
29
 
30
- def test_labels_at_their_edges():
31
- for number, name in ((0, "ืžืชื—ื™ืœื™ื"), (39, "ืžืชื—ื™ืœื™ื"), (40, "ื‘ื“ืจืš"), (69, "ื‘ื“ืจืš"), (70, "ื›ืžืขื˜ ืฉื"), (84, "ื›ืžืขื˜ ืฉื"), (85, "ืžื•ื›ื ื™ื"), (100, "ืžื•ื›ื ื™ื")):
32
- assert readiness.label(number) == name
 
 
33
 
34
 
35
- @pytest.mark.parametrize("m,e", [(100, 100), (100, 0), (90, 95), (85, 85)])
36
- def test_ready_never_shows_without_the_simulation(m, e):
37
- assert readiness.compute(m, e, None)["label"] != "ืžื•ื›ื ื™ื"
38
- assert readiness.compute(100, 100, 100)["label"] == "ืžื•ื›ื ื™ื"
 
 
39
 
40
 
41
- def test_nothing_known_is_zero_and_the_parts_helpers():
42
- assert readiness.compute(None, None, None)["number"] == 0
43
- assert readiness.mastery_percent(3, 4) == 75 and readiness.mastery_percent(0, 0) is None
44
- assert readiness.exercise_average([60, 80]) == 70 and readiness.exercise_average([]) is None
 
1
+ """The readiness number: only tests move it, only up, a simulation more than a practice test, a picked one less."""
 
 
2
  from mathly2 import readiness
3
 
4
 
5
+ def test_nothing_taken_is_zero_and_a_good_test_pulls_it_toward_its_grade():
6
+ assert readiness.compute([])["number"] == 0
7
+ assert readiness.compute([("practice", 80)])["number"] == 24 # 30% of the way to 80
8
+ assert readiness.compute([("simulation", 80)])["number"] == 48 # 60%: a simulation counts more
9
+ assert readiness.compute([("manual", 80)])["number"] == 12 # 15%: the student picked the skills
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
 
12
+ def test_a_grade_below_the_number_never_lowers_it_and_the_number_never_passes_the_grades():
13
+ up = readiness.compute([("simulation", 90), ("simulation", 90)])["number"]
14
+ assert up == 76 and readiness.compute([("simulation", 90), ("simulation", 90), ("practice", 30)])["number"] == up
15
+ many = readiness.compute([("simulation", 90)] * 20)["number"]
16
+ assert many == 90
17
 
18
 
19
+ def test_levels_next_level_and_what_a_perfect_test_would_add():
20
+ r = readiness.compute([("practice", 100)])
21
+ assert r["number"] == 30 and r["label"] == "ืžืชื—ื™ืœื™ื" and r["next"] == {"at": 40, "name": "ื‘ื“ืจืš", "left": 10}
22
+ assert r["can_add"] == {"simulation": 42, "practice": 21, "manual": 11}
23
+ assert readiness.compute([("simulation", 100)] * 5)["next"] is None
24
+ assert r["practices"] == 1 and r["simulations"] == 0 and r["practice_best"] == 100 and r["simulation_best"] is None
25
 
26
 
27
+ def test_change_says_the_gain_and_a_new_level():
28
+ moved = readiness.change([("practice", 100)], ("simulation", 90))
29
+ assert moved == {**moved, "before": 30, "after": 66, "gain": 36, "label": "ื‘ื“ืจืš", "level_up": True}
30
+ assert readiness.change([("simulation", 90)], ("practice", 20))["gain"] == 0
mathly2/tests/test_realtests_api.py CHANGED
@@ -143,7 +143,7 @@ def test_the_tests_tab_shows_who_took_the_simulation_and_names_those_who_did_not
143
  assert listed["simulation"]["taken"] == 22 and listed["simulation"]["total"] == 25
144
  assert sorted(listed["simulation"]["missing"]) == sorted([KID, KID2, "s22@school.test"])
145
  taken = next(s for s in listed["students"] if s["email"] == "s0@school.test")
146
- assert taken["simulation_taken"] is True and taken["readiness"]["parts"]["S"] == 70
147
  assert [c["class"]["name"] for c in tab] == ["A"] and tab[0]["class"]["lessons"]
148
 
149
 
@@ -166,77 +166,10 @@ def finished(store, test_id, kind, scope, grade, who=KID, questions=None):
166
  def test_with_no_test_home_has_only_the_next_skill(world):
167
  client, _, _ = world
168
  home = client.get("/api/home", headers=auth(KID)).json()
169
- assert home["tests"] == [] and home["task"] is None and home["required_simulations"] == []
170
  assert home["next_skill"]["id"] and home["next_skill"]["name"]
171
 
172
 
173
- def test_nine_days_before_the_test_todays_task_is_exercise_test_1(world):
174
- client, _, lessons = world
175
- test = set_test(client, lessons, days=9).json()
176
- home = client.get("/api/home", headers=auth(KID)).json()
177
- assert home["task"]["kind"] == "ex1" and home["task"]["test_id"] == test["id"]
178
- assert home["tests"][0]["days_left"] == 9 and home["tests"][0]["exercise_tests"][0]["best"] is None
179
-
180
-
181
- def test_twelve_days_ahead_the_task_is_finishing_the_path_with_the_next_covered_skill(world):
182
- client, _, lessons = world
183
- set_test(client, lessons, days=12)
184
- task = client.get("/api/home", headers=auth(KID)).json()["task"]
185
- assert task["kind"] == "path" and task["skill"] and task["skill_name"]
186
-
187
-
188
- def test_four_days_before_the_simulation_is_required_until_it_is_taken(world):
189
- client, store, lessons = world
190
- set_test_ = set_test(client, lessons, days=14).json()
191
- client.set_time(MON + 10 * DAY) # four days left
192
- home = client.get("/api/home", headers=auth(KID)).json()
193
- assert home["task"]["kind"] == "sim" and [r["test_id"] for r in home["required_simulations"]] == [set_test_["id"]]
194
- assert home["tests"][0]["simulation"]["required"] is True and home["tests"][0]["simulation"]["open_now"] is True
195
- finished(store, set_test_["id"], "simulation", str(set_test_["id"]), 70)
196
- home = client.get("/api/home", headers=auth(KID)).json()
197
- assert home["required_simulations"] == [] and home["tests"][0]["simulation"]["taken"] is True and home["tests"][0]["simulation"]["grade"] == 70
198
-
199
-
200
- def test_two_tests_give_the_task_of_the_closer_one(world):
201
- client, _, lessons = world
202
- far = set_test(client, lessons, days=10).json()
203
- near = set_test(client, lessons, days=4).json()
204
- home = client.get("/api/home", headers=auth(KID)).json()
205
- assert [t["id"] for t in home["tests"]] == [near["id"], far["id"]]
206
- assert home["task"]["test_id"] == near["id"] and home["task"]["kind"] == "ex1" # set 4 days ahead: the short plan starts with exercise test 1 today
207
-
208
-
209
- def test_readiness_shows_the_parts_that_exist_and_caps_before_the_simulation(world):
210
- client, store, lessons = world
211
- test = set_test(client, lessons, days=14).json()
212
- skills = covered(client.app.state.bank, lessons[:2])
213
- master(store, skills[:6]) # M = 6 of 8 = 75
214
- home = client.get("/api/home", headers=auth(KID)).json()["tests"][0]["readiness"]
215
- assert home["number"] == 75 and home["parts"] == {"M": 75, "E": None, "S": None} and home["waiting_for_simulation"] is True
216
- finished(store, test["id"], "exercise_test", f"{test['id']}:1", 60)
217
- finished(store, test["id"], "exercise_test", f"{test['id']}:2", 80)
218
- ready = client.get("/api/home", headers=auth(KID)).json()["tests"][0]["readiness"]
219
- assert ready["parts"] == {"M": 75, "E": 70, "S": None} and ready["number"] == 74 # (50*75 + 20*70) / 70
220
- master(store, skills[6:])
221
- finished(store, test["id"], "simulation", str(test["id"]), 90)
222
- full = client.get("/api/home", headers=auth(KID)).json()["tests"][0]["readiness"]
223
- assert full["parts"] == {"M": 100, "E": 70, "S": 90} and full["number"] == 91 and full["capped"] is False and full["label"] == "ืžื•ื›ื ื™ื"
224
-
225
-
226
- def test_the_card_has_the_plan_the_exercise_tests_and_what_to_practise_first(world):
227
- client, store, lessons = world
228
- test = set_test(client, lessons, days=14).json()
229
- skills = covered(client.app.state.bank, lessons[:2])
230
- master(store, skills[:2])
231
- store.save_progress(uid(KID), skills[2], started_at="then") # learning; the other five are not started
232
- card = client.get(f"/api/tests/{test['id']}", headers=auth(KID)).json()
233
- assert [s["id"] for s in card["plan"]] == ["path", "ex1", "ex2", "sim", "review"] and card["plan"][1]["day"] == 9
234
- assert [e["number"] for e in card["exercise_tests"]] == [1, 2] and card["questions"] == 12 and card["minutes"] == 45
235
- assert [s["id"] for s in card["practice_first"]] == skills[3:] and all(s["name"] for s in card["practice_first"])
236
- sim = card["simulation"]
237
- assert sim["open_now"] is False and sim["opens_in_days"] == 9 and sim["opens_on"] == (clock.now() + 9 * DAY).date().isoformat() and sim["taken"] is False
238
-
239
-
240
  def test_a_student_of_another_class_and_a_missing_test_give_404(world):
241
  client, store, lessons = world
242
  test = set_test(client, lessons).json()
@@ -310,24 +243,6 @@ def test_an_exercise_test_can_start_early_and_resumes_while_open(world):
310
  assert start_ex(client, test["id"], 2).json()["id"] != first["id"] # exercise test 2 is its own attempt
311
 
312
 
313
- def test_a_retake_draws_new_questions_and_the_best_grade_counts(world):
314
- client, store, lessons = world
315
- test = set_test(client, lessons, days=14, questions=6).json()
316
- started(client, store, lessons)
317
- first = start_ex(client, test["id"]).json()
318
- keys = {q["key"] for q in first["questions"]}
319
- low = answer_all(client, client.app.state.bank, first, [True, True, False, False, False, False])
320
- assert low["grade"] == 33 and low["kind"] == "exercise_test"
321
- second = start_ex(client, test["id"]).json()
322
- assert second["id"] != first["id"] and not keys & {q["key"] for q in second["questions"]}
323
- high = answer_all(client, client.app.state.bank, second, [True] * 6)
324
- assert high["grade"] == 100
325
- card = client.get(f"/api/tests/{test['id']}", headers=auth(KID)).json()
326
- assert card["exercise_tests"][0]["best"] == 100 and card["exercise_tests"][0]["attempts"] == 2
327
- answer_all(client, client.app.state.bank, start_ex(client, test["id"]).json(), [False] * 6) # a worse third try
328
- assert client.get(f"/api/tests/{test['id']}", headers=auth(KID)).json()["exercise_tests"][0]["best"] == 100
329
-
330
-
331
  def test_the_result_links_the_weak_skills_found_without_right_answers(world):
332
  client, store, lessons = world
333
  test = set_test(client, lessons, days=14, questions=6).json()
@@ -344,75 +259,6 @@ def test_an_exercise_test_with_nothing_started_draws_all_the_material_and_a_weak
344
  assert start_ex(client, test["id"], 4).status_code == 404
345
 
346
 
347
- def test_an_exercise_test_with_all_skills_starts_even_when_nothing_is_started(world):
348
- client, store, lessons = world
349
- test = set_test(client, lessons, days=14, questions=6).json()
350
- got = client.post(f"/api/tests/{test['id']}/exercise/1/start?all_skills=true", headers=auth(KID))
351
- assert got.status_code == 200 and len(got.json()["questions"]) == 6
352
- bank, lessons_of_test = client.app.state.bank, set(lessons[:2])
353
- assert all(bank.skill(q["skill_id"])["unit"] in lessons_of_test for q in store.get_attempt(got.json()["id"])["questions"])
354
-
355
-
356
- def test_the_simulation_is_refused_8_days_before_with_its_opening_date(world):
357
- client, store, lessons = world
358
- test = set_test(client, lessons, days=14).json()
359
- started(client, store, lessons)
360
- client.set_time(MON + 6 * DAY) # 8 days left; it opens 5 days before the test
361
- early = start_sim(client, test["id"])
362
- assert early.status_code == 409 and "ื™ื™ืคืชื—" in early.json()["detail"]
363
- assert store.attempts_of(uid(KID), "simulation") == [] # nothing was started
364
-
365
-
366
- def test_the_simulation_with_nothing_started_is_refused_and_all_skills_is_the_bypass(world):
367
- client, store, lessons = world
368
- test = set_test(client, lessons, days=14).json()
369
- client.set_time(MON + 12 * DAY) # two days left: open, and nothing practiced
370
- refused = start_sim(client, test["id"])
371
- assert refused.status_code == 409 and "ืขื•ื“ ืœื ื”ืชื—ืœืชื" in refused.json()["detail"]
372
- got = client.post(f"/api/tests/{test['id']}/simulation/start?all_skills=true", headers=auth(KID))
373
- assert got.status_code == 200 and got.json()["kind"] == "simulation" and len(got.json()["questions"]) == 12
374
- assert start_sim(client, test["id"]).json()["id"] == got.json()["id"] # then it resumes like any simulation
375
-
376
-
377
- def test_the_simulation_has_the_tests_length_can_be_taken_once_and_shows_its_result_again(world):
378
- client, store, lessons = world
379
- test = set_test(client, lessons, days=14).json() # 12 questions, 45 minutes
380
- started(client, store, lessons)
381
- client.set_time(MON + 10 * DAY) # four days left: open
382
- sim = start_sim(client, test["id"]).json()
383
- assert sim["kind"] == "simulation" and len(sim["questions"]) == 12
384
- assert datetime.fromisoformat(sim["due_at"]) - datetime.fromisoformat(sim["now"]) == timedelta(minutes=45)
385
- assert start_sim(client, test["id"]).json()["id"] == sim["id"] # open: it resumes
386
- done = answer_all(client, client.app.state.bank, sim, [True] * 8 + [False] * 4)
387
- assert done["grade"] == 67
388
- again = start_sim(client, test["id"]).json()
389
- assert again["state"] == "submitted" and again["id"] == sim["id"] and again["grade"] == 67 # a second try shows the result
390
- assert len(store.attempts_of(uid(KID), "simulation")) == 1
391
-
392
-
393
- def test_the_skills_the_simulation_failed_are_the_review_tasks_of_the_last_days(world):
394
- client, store, lessons = world
395
- test = set_test(client, lessons, days=14).json()
396
- started(client, store, lessons)
397
- client.set_time(MON + 10 * DAY)
398
- sim = start_sim(client, test["id"]).json()
399
- result = answer_all(client, client.app.state.bank, sim, [False] * 12)
400
- failed = [s["id"] for s in result["lost_skills"]]
401
- assert failed
402
- client.set_time(MON + 11 * DAY) # three days left
403
- card = client.get(f"/api/tests/{test['id']}", headers=auth(KID)).json()
404
- assert [s["id"] for s in card["simulation"]["review"]] == failed and card["simulation"]["taken"] is True
405
- task = client.get("/api/home", headers=auth(KID)).json()["task"]
406
- assert task["kind"] == "review" and task["skill"] == failed[0] and task["skill_name"]
407
- # practising that skill after the simulation takes it off the list: the next failed one is the task
408
- store.add_answer(uid(KID), failed[0], "k", True, True, None, clock.school_day())
409
- after = client.get("/api/home", headers=auth(KID)).json()["task"]
410
- if len(failed) > 1:
411
- assert after["kind"] == "review" and after["skill"] == failed[1]
412
- else:
413
- assert after["kind"] == "mixed_review"
414
-
415
-
416
  # --- 2.4 the grades ---
417
 
418
  def test_the_grades_list_each_tests_best_exercise_grades_and_the_simulation(world):
@@ -423,11 +269,11 @@ def test_the_grades_list_each_tests_best_exercise_grades_and_the_simulation(worl
423
  bank = client.app.state.bank
424
  answer_all(client, bank, start_ex(client, test["id"]).json(), [True] * 3 + [False] * 3) # 50
425
  answer_all(client, bank, start_ex(client, test["id"]).json(), [True] * 5 + [False]) # 83
426
- answer_all(client, bank, start_ex(client, test["id"], 2).json(), [True] * 6) # 100
427
  client.set_time(MON + 10 * DAY)
428
  answer_all(client, bank, start_sim(client, test["id"]).json(), [True] * 3 + [False] * 3) # 50 (6 questions)
429
  got = client.get("/api/grades", headers=auth(KID)).json()
430
- assert [(g["number"], g["best"], g["attempts"]) for g in got["exercise_tests"]] == [(1, 83, 2), (2, 100, 1)]
431
  assert [(g["best"], g["attempts"]) for g in got["simulations"]] == [(50, 1)]
432
  assert got["exercise_tests"][0]["test"]["id"] == test["id"] and got["exercise_tests"][0]["test"]["lessons"] and got["quizzes"] == []
433
 
@@ -490,24 +336,99 @@ def test_a_test_with_no_mistakes_has_nothing_to_retry(world):
490
  assert client.post(f"/api/attempts/{done['id']}/retry/start", headers=auth(KID)).status_code == 409
491
 
492
 
493
- def test_the_weak_topics_test_has_15_questions_only_from_the_skills_that_lost_points(world):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
494
  client, store, lessons = world
495
  bank = client.app.state.bank
496
  test = set_test(client, lessons, days=14).json()
497
- client.set_time(MON + 12 * DAY)
498
- assert client.get(f"/api/tests/{test['id']}", headers=auth(KID)).json()["focus"]["skills"] == []
499
- assert start_ex(client, test["id"], 3).status_code == 409 # nothing weak yet
500
- sim = client.post(f"/api/tests/{test['id']}/simulation/start?all_skills=true", headers=auth(KID)).json()
501
  done = answer_all(client, bank, sim, [True] * 8 + [False] * 4)
502
  lost = {s["id"] for s in done["lost_skills"]}
503
- card = client.get(f"/api/tests/{test['id']}", headers=auth(KID)).json()
504
- assert {s["id"] for s in card["focus"]["skills"]} == lost and card["focus"]["size"] == 15 and card["focus"]["best"] is None
505
- focus = start_ex(client, test["id"], 3).json()
506
- assert focus["kind"] == "exercise_test" and focus["test"]["number"] == 3 and len(focus["questions"]) == 15
507
- assert {q["skill_id"] for q in store.get_attempt(focus["id"])["questions"]} <= lost
508
- assert len({q["key"] for q in store.get_attempt(focus["id"])["questions"]}) == 15 # no question twice
509
- assert start_ex(client, test["id"], 3).json()["id"] == focus["id"] # open: it resumes
510
- answer_all(client, bank, focus, [True] * 15)
511
- card = client.get(f"/api/tests/{test['id']}", headers=auth(KID)).json()
512
- assert card["focus"]["best"] == 100 and card["focus"]["attempts"] == 1
513
- assert [e["number"] for e in card["exercise_tests"]] == [1, 2] and all(e["best"] is None for e in card["exercise_tests"]) # not exercise test 1 or 2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  assert listed["simulation"]["taken"] == 22 and listed["simulation"]["total"] == 25
144
  assert sorted(listed["simulation"]["missing"]) == sorted([KID, KID2, "s22@school.test"])
145
  taken = next(s for s in listed["students"] if s["email"] == "s0@school.test")
146
+ assert taken["simulation_taken"] is True and taken["readiness"]["simulation_best"] == 70 and taken["readiness"]["number"] == 42
147
  assert [c["class"]["name"] for c in tab] == ["A"] and tab[0]["class"]["lessons"]
148
 
149
 
 
166
  def test_with_no_test_home_has_only_the_next_skill(world):
167
  client, _, _ = world
168
  home = client.get("/api/home", headers=auth(KID)).json()
169
+ assert home["tests"] == [] and "task" not in home
170
  assert home["next_skill"]["id"] and home["next_skill"]["name"]
171
 
172
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
  def test_a_student_of_another_class_and_a_missing_test_give_404(world):
174
  client, store, lessons = world
175
  test = set_test(client, lessons).json()
 
243
  assert start_ex(client, test["id"], 2).json()["id"] != first["id"] # exercise test 2 is its own attempt
244
 
245
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
246
  def test_the_result_links_the_weak_skills_found_without_right_answers(world):
247
  client, store, lessons = world
248
  test = set_test(client, lessons, days=14, questions=6).json()
 
259
  assert start_ex(client, test["id"], 4).status_code == 404
260
 
261
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
262
  # --- 2.4 the grades ---
263
 
264
  def test_the_grades_list_each_tests_best_exercise_grades_and_the_simulation(world):
 
269
  bank = client.app.state.bank
270
  answer_all(client, bank, start_ex(client, test["id"]).json(), [True] * 3 + [False] * 3) # 50
271
  answer_all(client, bank, start_ex(client, test["id"]).json(), [True] * 5 + [False]) # 83
272
+ answer_all(client, bank, start_ex(client, test["id"], 3).json(), [True] * 6) # 6 of 15: 40, the focus mode
273
  client.set_time(MON + 10 * DAY)
274
  answer_all(client, bank, start_sim(client, test["id"]).json(), [True] * 3 + [False] * 3) # 50 (6 questions)
275
  got = client.get("/api/grades", headers=auth(KID)).json()
276
+ assert [(g["title"], g["best"], g["attempts"]) for g in got["exercise_tests"]] == [("ืžื‘ื—ืŸ ืชืจื’ื•ืœ ืžืื•ื–ืŸ", 83, 2), ("ืžื‘ื—ืŸ ืชืจื’ื•ืœ ืžืžื•ืงื“", 40, 1)]
277
  assert [(g["best"], g["attempts"]) for g in got["simulations"]] == [(50, 1)]
278
  assert got["exercise_tests"][0]["test"]["id"] == test["id"] and got["exercise_tests"][0]["test"]["lessons"] and got["quizzes"] == []
279
 
 
336
  assert client.post(f"/api/attempts/{done['id']}/retry/start", headers=auth(KID)).status_code == 409
337
 
338
 
339
+
340
+ # --- readiness, practice tests by mode and simulations on demand ---
341
+
342
+ def practice(client, test_id, mode, skills=None, who=KID):
343
+ return client.post(f"/api/tests/{test_id}/practice/start", json={"mode": mode, "skills": skills or []}, headers=auth(who))
344
+
345
+
346
+ def card_of(client, test_id, who=KID):
347
+ return client.get(f"/api/tests/{test_id}", headers=auth(who)).json()
348
+
349
+
350
+ def test_readiness_moves_only_with_tests_never_with_the_path(world):
351
+ client, store, lessons = world
352
+ test = set_test(client, lessons, days=14).json()
353
+ master(store, covered(client.app.state.bank, lessons[:2])) # the whole material mastered on the path
354
+ ready = card_of(client, test["id"])["readiness"]
355
+ assert ready["number"] == 0 and ready["label"] == "ืžืชื—ื™ืœื™ื" and ready["can_add"]["simulation"] == 60
356
+ finished(store, test["id"], "exercise_test", f"{test['id']}:1", 80)
357
+ assert card_of(client, test["id"])["readiness"]["number"] == 24
358
+ finished(store, test["id"], "simulation", str(test["id"]), 90)
359
+ ready = card_of(client, test["id"])["readiness"]
360
+ assert ready["number"] == 64 and ready["practice_best"] == 80 and ready["simulation_best"] == 90 and ready["simulations"] == 1
361
+
362
+
363
+ def test_the_card_offers_three_practice_modes_simulations_and_the_trail_of_gains(world):
364
+ client, store, lessons = world
365
+ test = set_test(client, lessons, days=14).json()
366
+ card = card_of(client, test["id"])
367
+ assert set(card["practice"]) == {"balanced", "focus", "manual"} and card["practice"]["focus"]["skills"] == []
368
+ assert len(card["practice"]["manual"]["skills"]) == len(covered(client.app.state.bank, lessons[:2])) and card["simulation"]["taken"] == 0
369
+ assert "plan" not in card and "task" not in card and card["trail"] == []
370
+ finished(store, test["id"], "exercise_test", f"{test['id']}:1", 50)
371
+ finished(store, test["id"], "simulation", str(test["id"]), 10) # below the number after the first: no gain
372
+ trail = card_of(client, test["id"])["trail"]
373
+ assert [(t["kind"], t["gain"]) for t in trail] == [("simulation", 0), ("exercise_test", 15)] and trail[1]["title"] == "ืžื‘ื—ืŸ ืชืจื’ื•ืœ ืžืื•ื–ืŸ"
374
+
375
+
376
+ def test_a_balanced_practice_test_draws_all_the_material_even_unstarted_and_resumes(world):
377
+ client, store, lessons = world
378
+ test = set_test(client, lessons, days=14, questions=6).json()
379
+ first = practice(client, test["id"], "balanced")
380
+ assert first.status_code == 200 and len(first.json()["questions"]) == 6 and first.json()["test"]["mode"] == "balanced"
381
+ assert practice(client, test["id"], "balanced").json()["id"] == first.json()["id"]
382
+ assert card_of(client, test["id"])["practice"]["balanced"]["open"] == first.json()["id"]
383
+
384
+
385
+ def test_a_manual_practice_test_has_only_the_picked_skills_and_moves_readiness_less(world):
386
+ client, store, lessons = world
387
+ bank = client.app.state.bank
388
+ test = set_test(client, lessons, days=14, questions=6).json()
389
+ picked = covered(bank, lessons[:2])[:2]
390
+ got = practice(client, test["id"], "manual", picked + ["not-a-skill"])
391
+ assert got.status_code == 200 and {q["skill_id"] for q in store.get_attempt(got.json()["id"])["questions"]} <= set(picked)
392
+ assert practice(client, test["id"], "manual", []).status_code == 422
393
+ done = answer_all(client, bank, got.json(), [True] * 6)
394
+ assert done["readiness"] == {**done["readiness"], "before": 0, "after": 15, "gain": 15} # 15% of the gap, a balanced one 30%
395
+
396
+
397
+ def test_a_focus_practice_test_is_15_questions_from_the_skills_that_lost_points(world):
398
  client, store, lessons = world
399
  bank = client.app.state.bank
400
  test = set_test(client, lessons, days=14).json()
401
+ assert practice(client, test["id"], "focus").status_code == 409 # nothing weak yet
402
+ sim = client.post(f"/api/tests/{test['id']}/simulation/start", headers=auth(KID)).json()
 
 
403
  done = answer_all(client, bank, sim, [True] * 8 + [False] * 4)
404
  lost = {s["id"] for s in done["lost_skills"]}
405
+ assert {s["id"] for s in card_of(client, test["id"])["practice"]["focus"]["skills"]} == lost
406
+ focus = practice(client, test["id"], "focus").json()
407
+ rows = store.get_attempt(focus["id"])["questions"]
408
+ assert len(rows) == 15 and {q["skill_id"] for q in rows} <= lost and len({q["key"] for q in rows}) == 15
409
+ assert focus["test"]["title"] == "ืžื‘ื—ืŸ ืชืจื’ื•ืœ ืžืžื•ืงื“"
410
+
411
+
412
+ def test_simulations_are_on_demand_timed_all_the_material_and_count_more(world):
413
+ client, store, lessons = world
414
+ bank = client.app.state.bank
415
+ test = set_test(client, lessons, days=30).json() # a month ahead: still open
416
+ sim = client.post(f"/api/tests/{test['id']}/simulation/start", headers=auth(KID)).json()
417
+ assert sim["kind"] == "simulation" and len(sim["questions"]) == 12
418
+ assert datetime.fromisoformat(sim["due_at"]) - datetime.fromisoformat(sim["now"]) == timedelta(minutes=45)
419
+ assert client.post(f"/api/tests/{test['id']}/simulation/start", headers=auth(KID)).json()["id"] == sim["id"] # open: resumes
420
+ done = answer_all(client, bank, sim, [True] * 12)
421
+ assert done["readiness"] == {**done["readiness"], "before": 0, "after": 60, "gain": 60, "level_up": True}
422
+ second = client.post(f"/api/tests/{test['id']}/simulation/start", headers=auth(KID)).json()
423
+ assert second["id"] != sim["id"] and second["state"] == "open" # another one on request
424
+ assert not {q["key"] for q in sim["questions"]} & {q["key"] for q in second["questions"]} or len(covered(bank, lessons[:2])) < 12
425
+ card = card_of(client, test["id"])
426
+ assert card["simulation"]["open"] == second["id"] and card["simulation"]["taken"] == 1 and card["simulation"]["best"] == 100
427
+
428
+
429
+ def test_old_practice_addresses_still_start_a_balanced_or_focus_test(world):
430
+ client, store, lessons = world
431
+ test = set_test(client, lessons, days=14, questions=6).json()
432
+ assert start_ex(client, test["id"], 1).json()["test"]["mode"] == "balanced"
433
+ assert start_ex(client, test["id"], 2).json()["test"]["mode"] == "balanced"
434
+ assert start_ex(client, test["id"], 4).status_code == 404 and start_ex(client, test["id"], 9).status_code == 404
openspec/changes/board-work/proposal.md CHANGED
@@ -9,7 +9,7 @@ Multiple-choice answers show only the final answer. The teacher (and the parent)
9
  - **The board is saved with every answer**: practice, lesson rounds, lesson quizzes, exercise tests and the simulation. A picture of the question's board goes to the server when the student answers (and again before handing in if it changed). An empty board sends nothing. One picture is kept per student, attempt (or practice) and question.
10
  - **In quizzes and tests the way is required**: a choice is refused until something is written on the board for that question (or a board for it is already saved), with a prompt and a button that opens the board. Practice and lesson rounds stay free.
11
  - **The teacher grades the way**: a "ื‘ื“ื™ืงืช ื“ืจืš" tab lists the handed-in quizzes and tests of the teacher's students (not checked first). Opening one shows every question with its choices (the right one marked), the student's choice and board, and points buttons: automatic, 0, ยฝ, full. Saving recomputes the grade (in halves), so a lesson quiz can become passed or not. The tab counts the attempts not checked yet.
12
- - **An AI review of the way in tests**: after a practice test or the simulation is handed in, Gemini (a free-tier model, `GEMINI_MODEL`, default `gemini-2.5-flash`) gets all its boards in one request and writes, per question, a verdict and a short feedback line and tip in Hebrew that never reveal the right answer. The student sees it on the result ("ืžืฉื•ื‘ ืขืœ ื”ื“ืจืš"); the teacher sees it beside each board as a suggestion. Without `GEMINI_API_KEY` there is none.
13
  - Work pictures are private: the student and the teachers of the student's classes (and developers) read them. They follow the answer pictures' retention (removed after the school year) and a replaced one is cleaned up as an orphan.
14
 
15
  ## Capabilities
 
9
  - **The board is saved with every answer**: practice, lesson rounds, lesson quizzes, exercise tests and the simulation. A picture of the question's board goes to the server when the student answers (and again before handing in if it changed). An empty board sends nothing. One picture is kept per student, attempt (or practice) and question.
10
  - **In quizzes and tests the way is required**: a choice is refused until something is written on the board for that question (or a board for it is already saved), with a prompt and a button that opens the board. Practice and lesson rounds stay free.
11
  - **The teacher grades the way**: a "ื‘ื“ื™ืงืช ื“ืจืš" tab lists the handed-in quizzes and tests of the teacher's students (not checked first). Opening one shows every question with its choices (the right one marked), the student's choice and board, and points buttons: automatic, 0, ยฝ, full. Saving recomputes the grade (in halves), so a lesson quiz can become passed or not. The tab counts the attempts not checked yet.
12
+ - **An AI review of the way in tests**: after a practice test or the simulation is handed in, Gemini (a free-tier model, `GEMINI_MODEL`, default `gemini-2.5-flash`) gets each board in its own request (paced for the free tier, a summary at the end) and writes, per question, a verdict and a short feedback line and tip in Hebrew that never reveal the right answer. The student sees it on the result ("ืžืฉื•ื‘ ืขืœ ื”ื“ืจืš"); the teacher sees it beside each board as a suggestion. Without `GEMINI_API_KEY` there is none.
13
  - Work pictures are private: the student and the teachers of the student's classes (and developers) read them. They follow the answer pictures' retention (removed after the school year) and a replaced one is cleaned up as an orphan.
14
 
15
  ## Capabilities
openspec/changes/tests-on-demand/proposal.md ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Proposal
2
+
3
+ ## Why
4
+
5
+ A student two days before a real test was blocked. The plan of two numbered exercise tests and one simulation opened on certain days and could be taken once. The readiness number mixed in mastery of the path, so the student could not tell which action would raise it. On the home screen it was not clear that tapping the test strip leads to the tests.
6
+
7
+ The readiness number should be the one thing the app pushes the student to raise, and the student should always have a test to take.
8
+
9
+ ## What Changes
10
+
11
+ - **Tests on request.** The student asks for:
12
+ - a **simulation**: all the test's material, its length and time, no choice, as many as they like (an open one resumes);
13
+ - a **practice test** in one of three modes:
14
+ - *balanced*: all the material evenly;
15
+ - *focused*: 15 questions from the skills that lost points in this test's tests, or are still being learned;
16
+ - *manual*: the skills the student picks.
17
+
18
+ There are no windows, no one-time rule and no "start the skills first" refusal. Every test draws from the whole material, unstarted skills included.
19
+ - **Readiness moves only with tests, never with the path or deepening.** Each handed-in test pulls the number toward its grade, and only up:
20
+
21
+ `number += GAIN ร— (grade โˆ’ number)` when the grade is above the number.
22
+
23
+ GAIN is 0.6 for a simulation, 0.3 for a practice test and 0.15 for a manual one (picked skills can skip the hard ones). Levels: ืžืชื—ื™ืœื™ื, ื‘ื“ืจืš (40), ื›ืžืขื˜ ืฉื (70), ืžื•ื›ื ื™ื (85).
24
+ - **Making the number feel like a game:**
25
+ - The home block and the test page show the level and the points to the next level.
26
+ - The number counts up from the last visit with a "+N" badge.
27
+ - Each button shows the most it can add now ("+30", "+60").
28
+ - A result opens with the gain (big "+N", the bar growing, "๐ŸŽ‰ ืจืžื” ื—ื“ืฉื”"), and buttons for another simulation or practice test.
29
+ - The test page lists every test handed in with what it added.
30
+ - **Home:** one compact block with the number, the level and the days left (a link to the test's page), a thin bar, and two buttons, **๐Ÿ“ ืžื‘ื—ืŸ ืชืจื’ื•ืœ** and **โฑ ืžื‘ื—ืŸ ืกื™ืžื•ืœืฆื™ื”**. The countdown plan, today's task, the "required simulation" chip and the weak practice tests list are gone. The home still fits one screen.
31
+ - **Teacher:** the tests tab and the progress tab show readiness, how many practice tests and simulations were taken, and the best grade of each. Mastery is shown beside readiness, not inside it.
32
+
33
+ ## Capabilities
34
+
35
+ ### Modified Capabilities
36
+ - `real-tests`: on-demand practice tests (3 modes) and simulations; readiness from tests only.
37
+ - `student-home`: the test block with two buttons.
38
+
39
+ ## Impact
40
+
41
+ - `readiness.py` (rewritten), `realtests.py` (facts, card, starting tests; new `POST /api/tests/{id}/practice/start`), `exam.py` (modes), `quizzes.py` (a result's `readiness` change; grades by mode), `progress.py`, `boardwork.py` (titles).
42
+ - `countdown.py` and its tests are removed.
43
+ - Frontend: `home.js`, `testcard.js`, `quiz.js`, `student.js` (`#/test/<id>/practice`), `teacher.js`, `teacher_progress.js`, `grades.js`, `app.css`.
44
+ - No database change. Old attempts keep counting: exercise tests 1 and 2 are balanced practice tests, 3 is focused.
openspec/changes/tests-on-demand/specs/real-tests/spec.md ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ## MODIFIED Requirements
2
+
3
+ ### Requirement: Practice tests and simulations on request
4
+ A student SHALL be able to request, for any coming test of their class, at any time:
5
+ - a simulation with all the test's material, its question count and its time limit;
6
+ - a practice test in a mode they choose: balanced, focused (on skills that lost points or are still being learned) or manual (on skills they pick).
7
+
8
+ An open test of the same kind and mode SHALL resume. A manual test SHALL always be new.
9
+
10
+ #### Scenario: A simulation two days before the test with nothing practised
11
+ - **WHEN** a student who has started no skill asks for a simulation
12
+ - **THEN** a timed simulation of the test's length starts, drawn from all the material
13
+
14
+ #### Scenario: A focused practice test with nothing weak
15
+ - **WHEN** no skill lost points and none is being learned
16
+ - **THEN** the focused mode is offered as unavailable, and asking for it is refused with a message
17
+
18
+ ### Requirement: Readiness from tests only
19
+ The readiness number SHALL change only when a practice test or a simulation is handed in. For each one, oldest first, when its grade is above the number, the number SHALL become `number + GAIN ร— (grade โˆ’ number)`, with GAIN 0.6 for a simulation, 0.3 for a practice test and 0.15 for a manual one. Mastery of skills SHALL NOT change it.
20
+
21
+ #### Scenario: A simulation counts more
22
+ - **WHEN** a student with readiness 0 hands in a simulation with 80
23
+ - **THEN** the readiness is 48, where a practice test with 80 would make it 24
24
+
25
+ #### Scenario: A bad grade never lowers it
26
+ - **WHEN** a student with readiness 76 hands in a practice test with 30
27
+ - **THEN** the readiness stays 76
openspec/changes/tests-on-demand/tasks.md ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ## 1. Server
2
+ - [x] 1.1 `readiness.py`: tests-only number with GAIN per kind, levels, next level, what a perfect test adds, a test's change
3
+ - [x] 1.2 `realtests.py`: facts from all practice tests and simulations; the card's practice modes, simulations and trail; remove the plan and today's task
4
+ - [x] 1.3 `POST /api/tests/{id}/practice/start` (balanced, focus, manual); simulations on request; old `/exercise/{n}/start` kept
5
+ - [x] 1.4 A result carries its readiness change; grades grouped by mode; teacher tabs show counts and bests
6
+ - [x] 1.5 Remove `countdown.py`
7
+
8
+ ## 2. Screens
9
+ - [x] 2.1 Home: one compact block with the number, the level, the days left and two buttons with "+N"; still one screen
10
+ - [x] 2.2 Test page: the readiness meter and how it goes up, simulation on request, practice test with the three modes, the trail of gains
11
+ - [x] 2.3 Result: the gain, the bar growing, a new level, buttons for the next test
12
+ - [x] 2.4 Teacher tests tab and progress tab
13
+
14
+ ## 3. Tests
15
+ - [x] 3.1 Readiness unit tests; API tests for modes, simulations on demand, readiness change, grades
16
+ - [x] 3.2 Browser tests: home block, simulation with a level up, test page modes and trail; home fits one screen