docs: describe test mode
This commit is contained in:
68
README.md
68
README.md
@@ -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 | |
|
||||
|
||||
Reference in New Issue
Block a user