docs: describe test mode

This commit is contained in:
Jakub Zych
2026-11-09 14:10:25 +01:00
parent 8405daf6a4
commit 174be3bcbd

View File

@@ -18,7 +18,7 @@ It owns five tables:
| Table | Holds |
|-------|-------|
| `golem15_quizzes_quizzes` | Quizzes: title, unique slug, description, context, published flag, time limit, passing score, question shuffling and round size. |
| `golem15_quizzes_quizzes` | Quizzes: title, unique slug, description, context, published flag, time limit, passing score, question shuffling, round size, and the test settings `mode`, `navigation` and `max_attempts`. |
| `golem15_quizzes_questions` | Questions of a quiz: text, type, category, model answer, explanation, sort order and points. |
| `golem15_quizzes_answers` | Answers of a single or multiple question, with `is_correct` and an explanation. |
| `golem15_quizzes_attempts` | One user's run through a quiz, or through one category of it, with the score once finished. |
@@ -34,9 +34,24 @@ Deleting a quiz deletes its questions, answers and attempts. Deleting a question
- **Attempts.** A user starts an attempt, answers questions in any order (answering again overwrites the earlier answer and returns fresh feedback) and finishes it. Answers are refused after the time limit; finishing is still allowed and counts the answers given in time.
- **Scoring.** A pure function in `classes` (`classes.Score`, `classes.Totals`) with optional partial credit for multiple questions and an optional penalty for wrong answers. See [Configuration](#configuration).
- **No solution leaks.** The quiz payload never contains `is_correct` or explanations, and `answer_text` only for `self_check` flashcards, which have nothing to hide. Feedback reveals the solution of the question just answered; a result reveals the solutions of answered questions, and of every question once the attempt is finished.
- **Test mode.** A quiz set to `test` withholds every solution until the attempt is finished, and the server enforces the attempt limit, the time limit and one-way navigation. See [Test mode](#test-mode).
- **Admin backend.** Quizzes, Questions (filtered by quiz, category and type) and Answers (filtered by quiz) under the `golem15.quizzes.access_quizzes` permission, and a read-only Attempts list under `golem15.quizzes.access_attempts`. Polish and English labels.
- **Importer.** `quizzes:import` loads a quiz from a JSON file, idempotent by slug. See [CLI commands](#cli-commands).
## Test mode
A quiz has a `mode`: `learning` (the default; every existing quiz) or `test`. Learning quizzes behave exactly as before test mode existed. Two more settings only apply to tests: `navigation` (`free`, the default, or `one_way`) and `max_attempts` (`null` for unlimited).
- **Withholding.** While a test attempt is open, no student response carries a solution: the answer feedback has `withheld: true` and `null` for `is_correct`, `points`, `correct_answer_ids`, `explanation` and `answer_text`; the result has `solutions_withheld: true` and `null` for `is_correct`, `points_earned`, `correct_answer_ids`, `explanation` and `answer_text` on every question, answered or not, and no answer carries `is_correct` or `explanation`. The student's own input (`answered`, `answer_ids`, `known`) stays. `GET /quizzes/{slug}` of a test lists no questions (`questions: []`, `question_count` counts them), so a timed test cannot be read before its clock starts; the test player reads the questions from `GET /attempts/{id}`.
- **Full review after finish.** Once `completed_at` is set (finish, or a lazy close) the result reveals everything, exactly as for a finished learning attempt, with `passed` against `passing_score`.
- **Attempt limit.** `max_attempts` counts every attempt of the user on the quiz, open or finished, in any mode. A start past the limit is a 409 with `code: attempt_limit_reached`. Learning quizzes have no limit.
- **Resume.** Starting a test while an open, unexpired attempt exists returns that attempt (200, `resumed: true`) instead of creating one, so a page reload does not burn an attempt. Starts are serialised per quiz and user with a Postgres advisory lock (`pg_advisory_xact_lock`), so parallel starts cannot exceed the limit.
- **Time limit and grace.** An answer to a test is refused (409, `code: time_limit_exceeded`) after `started_at + time_limit_seconds` plus a grace for answers still in flight, `golem15.quizzes.test.grace_seconds` (default 5). Learning quizzes have no grace. Finishing stays allowed after the deadline; unanswered questions score 0. `seconds_left` on every attempt row is counted by the server.
- **Lazy close.** A test attempt past its deadline and grace is closed on the next start of that quiz or the next `GET /attempts/{id}`: `completed_at` is set to its deadline and it is scored with the answers given in time, as a finish would. No job queue is involved.
- **Whole quiz only.** A test always covers every question: starting one with a `category` is a 422 on `category` (`category_not_allowed`), and `GET /quizzes/{slug}` of a test ignores `?category=`.
- **Stable order.** With `shuffle_questions`, a test attempt's questions come in an order seeded by the attempt, the same on every `GET /attempts/{id}` and on finish.
- **One-way lock.** With `navigation: one_way` an answer is final: a second answer to the same question is a 409 with `code: answer_locked` and the stored answer is unchanged. With `free` an answer is overwritten until the attempt is finished.
## Usage
Mount the plugin in an application as a git submodule at `plugins/golem15/quizzes`, next to `plugins/golem15/user`, add it to the application's `go.work`, require it in the application's `go.mod` with a local replace, and list it in `summer.yaml` after `golem15.user`:
@@ -60,8 +75,8 @@ All routes are mounted under `/_quizzes/api/v1` with the `jwt.auth` middleware o
| Method and path | Handler | Does |
|-----------------|---------|------|
| `GET /quizzes` | `api.Handlers.ListQuizzes` | Published quizzes, by title, with the caller's statistics. Query: `context`. |
| `GET /quizzes/{slug}` | `api.Handlers.ShowQuiz` | A published quiz with its questions and answers, without solutions (a `self_check` question carries its `answer_text`). Query: `category`. |
| `POST /quizzes/{slug}/attempts` | `api.Handlers.StartAttempt` | Starts an attempt. Body (optional): `{"category": "..."}`. |
| `GET /quizzes/{slug}` | `api.Handlers.ShowQuiz` | A published quiz with its questions and answers, without solutions (a `self_check` question carries its `answer_text`); a test lists no questions. Query: `category`. |
| `POST /quizzes/{slug}/attempts` | `api.Handlers.StartAttempt` | Starts an attempt, or resumes an open test attempt. Body (optional): `{"category": "..."}`. |
| `POST /attempts/{id}/answers` | `api.Handlers.Answer` | Saves or overwrites the answer to one question and returns feedback. |
| `POST /attempts/{id}/finish` | `api.Handlers.Finish` | Finishes the attempt and returns the result. |
| `GET /attempts/{id}` | `api.Handlers.ShowAttempt` | The result with per-question details. |
@@ -76,12 +91,14 @@ All routes are mounted under `/_quizzes/api/v1` with the `jwt.auth` middleware o
| 401 | `{"error": true, "message": "Token not provided"}` | No or invalid token (from the guard). |
| 404 | `{"error": "Quiz not found"}` | Unknown or unpublished slug. |
| 404 | `{"error": "Attempt not found"}` | Unknown attempt, or another user's attempt. |
| 409 | `{"error": "The attempt is already completed."}` | Answering or finishing a finished attempt. |
| 409 | `{"error": "The time limit of this attempt has been exceeded."}` | Answering after `started_at + time_limit_seconds`. |
| 409 | `{"error": "The attempt is already completed.", "code": "attempt_completed"}` | Answering or finishing a finished attempt. |
| 409 | `{"error": "The time limit of this attempt has been exceeded.", "code": "time_limit_exceeded"}` | Answering after `started_at + time_limit_seconds` (plus the grace of a test). |
| 409 | `{"error": "All attempts at this test have been used.", "code": "attempt_limit_reached"}` | Starting a test after `max_attempts` attempts. |
| 409 | `{"error": "An answer in this test cannot be changed.", "code": "answer_locked"}` | Answering a question of a `one_way` test again. |
| 413 | `{"error": "Payload too large"}` | A body over the route's limit. |
| 422 | `{"error": "<first message>", "errors": {"<field>": ["<message>"]}}` | Invalid input, the shape `golem15.user` uses. Keys follow the rule order. |
| 422 | `{"error": "<first message>", "errors": {"<field>": ["<message>"]}}` | Invalid input, the shape `golem15.user` uses. Keys follow the rule order. Starting a test with a category is a 422 on `category`: "A test covers the whole quiz; a category cannot be chosen." |
The messages are translated into the request locale (`golem15.quizzes::lang.api.*`, Polish and English).
Every 409 body carries `code`, a stable key clients can branch on; the `error` message is for people. The messages are translated into the request locale (`golem15.quizzes::lang.api.*`, Polish and English).
### GET /quizzes
@@ -98,11 +115,16 @@ The messages are translated into the request locale (`golem15.quizzes::lang.api.
"round_size": 5,
"shuffle_questions": false,
"best_score_percent": 83.33,
"attempts_count": 2
"attempts_count": 2,
"mode": "test",
"navigation": "free",
"max_attempts": 3,
"attempts_left": 1,
"question_types": {"single": 20, "multiple": 10, "self_check": 24}
}]}
```
`attempts_count` counts the caller's finished attempts and `best_score_percent` is their best finished result (`null` before the first one).
`attempts_count` counts the caller's finished attempts and `best_score_percent` is their best finished result (`null` before the first one). `question_types` counts the questions per type, with all three keys always present. `attempts_left` is how many attempts the caller may still start at a test with a limit (every attempt counts, open or finished); it is `null` for a learning quiz or a test without a limit.
### GET /quizzes/{slug}
@@ -113,6 +135,7 @@ The messages are translated into the request locale (`golem15.quizzes::lang.api.
"round_size": 5, "shuffle_questions": false,
"question_count": 2,
"categories": ["Documents", "Theory"],
"mode": "learning", "navigation": "free", "max_attempts": null, "attempts_left": null,
"questions": [
{"id": 10, "text": "What is societal resilience?", "type": "self_check", "category": "Theory",
"points": 1, "sort_order": 1, "answer_text": "The ability of a society to withstand a crisis.", "answers": []},
@@ -122,11 +145,11 @@ The messages are translated into the request locale (`golem15.quizzes::lang.api.
}}
```
`categories` lists every category of the quiz; with `?category=` the questions are only that category's and `question_count` counts them. Questions come in `sort_order`, or shuffled when `shuffle_questions` is true. Answers come in their sort order.
`categories` lists every category of the quiz; with `?category=` the questions are only that category's and `question_count` counts them. Questions come in `sort_order`, or shuffled when `shuffle_questions` is true. Answers come in their sort order. For a test, `questions` is empty, `question_count` counts the whole quiz, `?category=` is ignored and `attempts_left` is set as in the list.
### POST /quizzes/{slug}/attempts
Body, optional: `{"category": "Theory"}` limits the attempt to one category; a category without questions is a 422 on `category`. Answers status 201:
Body, optional: `{"category": "Theory"}` limits the attempt to one category; a category without questions is a 422 on `category`, and a test refuses any category. Answers status 201 with a new attempt, or 200 when an open test attempt is resumed:
```json
{"data": {
@@ -137,11 +160,13 @@ Body, optional: `{"category": "Theory"}` limits the attempt to one category; a c
"completed_at": null,
"answered_count": 0,
"score_points": 0, "max_points": 54, "score_percent": null,
"passing_score": 50, "passed": null
"passing_score": 50, "passed": null,
"quiz_mode": "learning", "quiz_navigation": "free", "seconds_left": null,
"resumed": false, "attempts_left": null
}}
```
`expires_at` is `started_at + time_limit_seconds`, or `null` without a limit. `max_points` sums the points of the questions the attempt covers. This attempt shape is also each row of `GET /attempts`.
`expires_at` is `started_at + time_limit_seconds`, or `null` without a limit. `seconds_left` is the whole seconds until `expires_at`, rounded up and never below 0, counted by the server; it is `null` without a limit and once the attempt is finished. `quiz_mode` and `quiz_navigation` tell the player which flow to run. `max_points` sums the points of the questions the attempt covers. This attempt shape, without `resumed` and `attempts_left`, is also each row of `GET /attempts` and the head of a result. `resumed` is true when an open test attempt was returned; `attempts_left` counts the attempts left after this one (`null` for a learning quiz or without a limit).
### POST /attempts/{id}/answers
@@ -153,11 +178,12 @@ Body:
| `answer_ids` | Array of answer IDs. Required for `single` (exactly one) and `multiple`; every ID must belong to the question. |
| `known` | Boolean. Required for `self_check`. |
Checks run in this order: the body (422), the attempt belongs to the caller (404), it is not finished (409), it is within the time limit (409), the question belongs to it (422 on `question_id`), the answer fits the question (422 on `answer_ids` or `known`). Answering a question again overwrites the stored answer. Answers status 200:
Checks run in this order: the body (422), the attempt belongs to the caller (404), it is not finished (409), it is within the time limit plus the grace of a test (409), the question belongs to it (422 on `question_id`), a `one_way` test has no answer to it yet (409), the answer fits the question (422 on `answer_ids` or `known`). Answering a question again overwrites the stored answer. Answers status 200:
```json
{"data": {
"question_id": 11,
"withheld": false,
"is_correct": false,
"points": 0,
"correct_answer_ids": [30],
@@ -170,7 +196,7 @@ Checks run in this order: the body (422), the attempt belongs to the caller (404
}}
```
`points` can be negative when `scoring.penalty_wrong` is set. For a `self_check` question `correct_answer_ids` and `answers` are empty and `answer_text` holds the model answer.
`points` can be negative when `scoring.penalty_wrong` is set. For a `self_check` question `correct_answer_ids` and `answers` are empty and `answer_text` holds the model answer. For an open test attempt `withheld` is true and `is_correct`, `points`, `correct_answer_ids`, `explanation` and `answer_text` are `null`; the answers carry only `id` and `text` (`is_correct` and `explanation` `null`).
### POST /attempts/{id}/finish and GET /attempts/{id}
@@ -182,6 +208,8 @@ Finishing sums the stored points, floors the total at 0, sets `score_percent = s
"started_at": "2026-10-02T12:00:00+00:00", "expires_at": null, "completed_at": "2026-10-02T12:09:41+00:00",
"answered_count": 2, "score_points": 1, "max_points": 2, "score_percent": 50,
"passing_score": 50, "passed": true,
"quiz_mode": "learning", "quiz_navigation": "free", "seconds_left": null,
"solutions_withheld": false,
"questions": [
{"id": 10, "text": "What is societal resilience?", "type": "self_check", "category": "Theory", "points": 1,
"answered": true, "answer_ids": [], "known": true, "is_correct": true, "points_earned": 1,
@@ -195,7 +223,7 @@ Finishing sums the stored points, floors the total at 0, sets `score_percent = s
}}
```
`passed` is `score_percent >= passing_score` once the attempt is finished, else `null`. While the attempt is open, an unanswered question has `correct_answer_ids`, `explanation` and `answer_text` set to `null` and `is_correct`/`explanation` of its answers set to `null`.
`passed` is `score_percent >= passing_score` once the attempt is finished, else `null`. While the attempt is open, an unanswered question has `correct_answer_ids`, `explanation` and `answer_text` set to `null` and `is_correct`/`explanation` of its answers set to `null`; its own `is_correct` is `false` and `points_earned` 0. While a test attempt is open `solutions_withheld` is true and every question, answered or not, has `is_correct`, `points_earned`, `correct_answer_ids`, `explanation` and `answer_text` set to `null`, with no answer `is_correct` or `explanation`; `answered`, `answer_ids` and `known` stay. With `shuffle_questions` a test attempt lists its questions in an order seeded by the attempt, the same on every call; other attempts list them in sort order.
### GET /attempts
@@ -206,7 +234,7 @@ Finishing sums the stored points, floors the total at 0, sets `score_percent = s
### Packages
- `classes`: `Score`, `Totals` and `ScoringConfigFrom` (scoring); `Service` (the API's queries and writes); `ParseImport` and `Import` (the importer).
- `classes`: `Score`, `Totals` and `ScoringConfigFrom` (scoring); `Service` (the API's queries and writes); `IsTest`, `Withheld`, `OneWay`, `AnswerDeadline`, `Expired`, `SecondsLeft`, `AttemptsLeft`, `AttemptLockKey` and `TestGraceFrom` (the test-mode policy); `ParseImport` and `Import` (the importer).
- `controllers/api`: `Handlers`, the HTTP handlers.
- `controllers`: the admin controllers `QuizzesController`, `QuestionsController`, `AnswersController` and `AttemptsController`.
- `models`: `Quiz`, `Question`, `Answer`, `Attempt`, `AttemptAnswer` and `Slugify`.
@@ -220,6 +248,7 @@ Defaults live in `config/config.yaml` and are merged under `golem15.quizzes.*`.
|-----|---------|---------|
| `golem15.quizzes.scoring.multiple_partial` | `false` | Partial credit on `multiple` questions: `(correct chosen - wrong chosen) / correct answers` of the points, floored at 0. Without it only the exact set earns points. |
| `golem15.quizzes.scoring.penalty_wrong` | `0` | Fraction (0 to 1) of a question's points subtracted for a wrong answer: a non-empty `single` or `multiple` answer that earns nothing. `self_check` answers marked as unknown and empty answers are never penalised. An attempt's total never goes below 0. |
| `golem15.quizzes.test.grace_seconds` | `5` | Seconds after a test's time limit during which an answer still in flight is accepted, clamped to 0..120. A missing or non-numeric value is 5. Learning quizzes have no grace. |
Scores are rounded to two decimals.
@@ -232,6 +261,8 @@ Scores are rounded to two decimals.
| Answers | `golem15/quizzes/answers` | `golem15.quizzes.access_quizzes` |
| Attempts (read-only) | `golem15/quizzes/attempts` | `golem15.quizzes.access_attempts` |
The quiz form has three test settings: Mode (Learning / Test), Test navigation (Free / One way) and Attempt limit (empty for none); the last two say in their comments that they apply to tests only. The quiz list shows the mode.
The navigation item is "Quizzes" ("Quizy" in Polish). The admin's relation manager links records through a pivot table only, so questions and answers have their own lists instead of being edited inside the quiz form; filter them by quiz.
## CLI commands
@@ -289,6 +320,9 @@ The file is checked before anything is written, and unknown keys are an error. F
| `passing_score` | no | 0 to 100, default 50. |
| `round_size`, `time_limit_seconds` | no | At least 1; `null` or missing for none. |
| `shuffle_questions` | no | Default `false`. |
| `mode` | no | `learning` or `test`. Missing keeps the quiz's current mode on `--replace` and `learning` on create. |
| `navigation` | no | `free` or `one_way` (tests only). Missing keeps the current value on `--replace` and `free` on create. |
| `max_attempts` | no | At least 1 (tests only). Missing keeps the current value on `--replace` and no limit on create. |
| `questions` | yes | At least one. The file order becomes `sort_order`. |
| `questions[].text`, `questions[].type` | yes | `type` is `single`, `multiple` or `self_check`. |
| `questions[].category`, `answer_text`, `explanation` | no | |