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 +29 -40
- mathly2/boardwork.py +2 -2
- mathly2/clock.py +1 -1
- mathly2/countdown.py +0 -87
- mathly2/exam.py +10 -1
- mathly2/progress.py +5 -2
- mathly2/quizzes.py +10 -3
- mathly2/readiness.py +41 -37
- mathly2/realtests.py +125 -117
- mathly2/static/app.css +57 -1
- mathly2/static/grades.js +1 -1
- mathly2/static/home.js +61 -62
- mathly2/static/quiz.js +26 -7
- mathly2/static/student.js +3 -1
- mathly2/static/teacher.js +2 -2
- mathly2/static/teacher_progress.js +3 -2
- mathly2/static/testcard.js +85 -41
- mathly2/tests/test_behind_home_api.py +0 -40
- mathly2/tests/test_countdown.py +0 -95
- mathly2/tests/test_home_browser.py +73 -226
- mathly2/tests/test_lesson_map_browser.py +2 -2
- mathly2/tests/test_permissions.py +1 -0
- mathly2/tests/test_readiness.py +21 -35
- mathly2/tests/test_realtests_api.py +95 -174
- openspec/changes/board-work/proposal.md +1 -1
- openspec/changes/tests-on-demand/proposal.md +44 -0
- openspec/changes/tests-on-demand/specs/real-tests/spec.md +27 -0
- openspec/changes/tests-on-demand/tasks.md +16 -0
|
@@ -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
|
| 302 |
-
|
| 303 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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.**
|
| 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
|
| 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
|
| 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**,
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
- **RDY-3
|
| 359 |
-
- **RDY-4 The number explains itself.**
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 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.
|
|
@@ -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
|
| 93 |
-
name = ("
|
| 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 |
|
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
"""Server time. Every rule that depends on days (mastery, the
|
| 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 |
|
|
@@ -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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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
|
| 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()
|
|
@@ -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 |
-
|
| 119 |
-
|
|
|
|
|
|
|
|
|
|
| 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}
|
|
@@ -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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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"]))
|
|
@@ -1,42 +1,46 @@
|
|
| 1 |
-
"""The readiness number for a real test
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
-
|
| 5 |
-
|
| 6 |
|
| 7 |
|
| 8 |
def label(number: int) -> str:
|
| 9 |
-
if number
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
return "
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
def
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
if
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
"
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
def
|
| 36 |
-
"""
|
| 37 |
-
|
| 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"]}
|
|
|
|
|
|
|
|
|
|
@@ -1,5 +1,6 @@
|
|
| 1 |
-
"""Real tests: a teacher sets one for a class; every student of the class
|
| 2 |
-
|
|
|
|
| 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,
|
| 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
|
| 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
|
| 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 |
-
|
| 86 |
-
for
|
| 87 |
-
|
| 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
|
| 105 |
-
for
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
"
|
| 110 |
-
"
|
| 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
|
| 116 |
-
|
| 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
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
|
| 126 |
|
| 127 |
-
def
|
| 128 |
-
return
|
| 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
|
|
|
|
| 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 |
-
|
| 138 |
-
for
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 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 |
-
"
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
"
|
|
|
|
| 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,
|
| 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,
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 321 |
|
| 322 |
-
def
|
| 323 |
-
"""Resume
|
| 324 |
from .quizzes import view
|
| 325 |
-
|
| 326 |
facts = facts_for(request, user["id"], test, clock.school_day())
|
| 327 |
-
number =
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
|
|
|
|
|
|
| 333 |
if not facts["focus_skills"]:
|
| 334 |
-
raise HTTPException(409, "ืืื ืืจืืข ื ืืฉ
|
| 335 |
-
drawn = exam.draw_focus(bank, facts["focus_skills"], used=facts["used"], rng=
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
drawn = exam.
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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="
|
| 351 |
-
|
|
|
|
|
|
|
| 352 |
raise HTTPException(404, "ืืืื ืืชืจืืื ืื ื ืืฆื")
|
| 353 |
-
|
| 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="
|
|
|
|
|
|
|
|
|
|
| 363 |
test = _student_test(request, user, test_id)
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
|
|
|
|
|
|
|
|
| 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)
|
|
@@ -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:
|
| 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; }
|
|
@@ -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.
|
| 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>
|
|
@@ -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),
|
| 3 |
-
// left, the readiness number
|
|
|
|
| 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,
|
| 8 |
-
|
| 9 |
-
/*
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
/** The
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 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 |
-
/**
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
}
|
|
|
|
| 56 |
|
| 57 |
-
/** The nearest test
|
| 58 |
-
*
|
| 59 |
-
function testStrip(test
|
| 60 |
-
const
|
| 61 |
return `<section class="strip test-strip" id="nearest-test" data-test="${test.id}">
|
| 62 |
-
<
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"
|
| 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>
|
|
|
|
| 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
|
| 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
|
| 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 |
}
|
|
@@ -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`
|
| 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
|
| 50 |
-
const names = a => a.kind === "exercise_test" ?
|
| 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>
|
|
|
|
| 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}">๐ฏ ืืืื ืชืจืืื ืขื ื
|
| 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 |
-
:
|
|
|
|
|
|
|
| 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) :
|
|
|
|
| 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 |
}
|
|
@@ -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 |
-
|
|
|
|
| 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);
|
|
@@ -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>
|
| 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.
|
| 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 |
}
|
|
@@ -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) {
|
|
@@ -1,63 +1,107 @@
|
|
| 1 |
-
// One real test:
|
|
|
|
|
|
|
| 2 |
import { api } from "./api.js";
|
| 3 |
import { esc, fmt } from "./math.js";
|
| 4 |
-
import {
|
| 5 |
import { daysText, forSubject, homeHref, setSubject, shortDate, tabs } from "./ui.js";
|
| 6 |
|
| 7 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
}
|
| 16 |
|
| 17 |
-
function
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
}
|
| 22 |
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 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 |
-
|
| 35 |
-
|
| 36 |
-
const
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
}
|
| 44 |
|
| 45 |
-
|
|
|
|
| 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)}` : ""}<
|
| 53 |
-
${
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 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 |
}
|
|
@@ -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 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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("#
|
| 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 "ืืืื ืชืจืืื
|
| 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 "ืชืืฆืืช ืืืื ืชืจืืื
|
| 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").
|
| 305 |
-
page.click("
|
| 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.
|
| 406 |
-
page.
|
|
|
|
| 407 |
page.wait_for_selector("#qz-q")
|
| 408 |
-
assert "
|
| 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 "
|
| 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 "
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 548 |
app, store, klass, lessons = world(bank)
|
| 549 |
-
|
| 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 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 609 |
|
| 610 |
-
def
|
| 611 |
app, store, klass, lessons = world(bank)
|
| 612 |
-
|
|
|
|
|
|
|
| 613 |
with running(app) as url:
|
| 614 |
-
page =
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
assert page.locator("
|
| 618 |
-
page.
|
| 619 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|
|
@@ -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
|
| 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 #
|
| 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")
|
|
@@ -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",
|
|
@@ -1,44 +1,30 @@
|
|
| 1 |
-
"""The readiness number
|
| 2 |
-
import pytest
|
| 3 |
-
|
| 4 |
from mathly2 import readiness
|
| 5 |
|
| 6 |
|
| 7 |
-
def
|
| 8 |
-
|
| 9 |
-
assert
|
| 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
|
| 31 |
-
|
| 32 |
-
|
|
|
|
|
|
|
| 33 |
|
| 34 |
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
assert
|
| 38 |
-
assert
|
|
|
|
|
|
|
| 39 |
|
| 40 |
|
| 41 |
-
def
|
| 42 |
-
|
| 43 |
-
assert
|
| 44 |
-
assert readiness.
|
|
|
|
| 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
|
|
@@ -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"]["
|
| 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
|
| 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"],
|
| 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["
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 494 |
client, store, lessons = world
|
| 495 |
bank = client.app.state.bank
|
| 496 |
test = set_test(client, lessons, days=14).json()
|
| 497 |
-
client.
|
| 498 |
-
|
| 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 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
assert
|
| 507 |
-
assert
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
@@ -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
|
| 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
|
|
@@ -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.
|
|
@@ -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
|
|
@@ -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
|