# Дизайн системы Дата: 2026-08-03. Статус: проектирование, реализация не начата (кроме уже существующего студенческого MVP). ## 1. Идея и границы системы Standalone-система проверки знаний — аналог модуля «Тест» в Moodle, без остальной части LMS (без курсов как оргединицы, без контента/форумов). Три роли: **Admin**, **Teacher**, **Student**. Ключевые решения, зафиксированные в разговоре с пользователем: | Вопрос | Решение | |---|---| | Курсы | Не нужны. Убираем `Course`/`Enrollment` из домена. | | Банк вопросов | Приватный у каждого преподавателя (темы = топики, без общего пространства). | | «Итоговый» тест со случайными вопросами | Случайный набор вопросов формируется **заново при каждой попытке** (как в Moodle). | | Кому виден тест | Преподаватель явно назначает тест конкретным студентам (нет курсов/групп). | | Регистрация | Открытой регистрации нет — все аккаунты создаёт Admin вручную. | | Роль Admin | Admin = Teacher + управление пользователями (создание/удаление учителей и студентов). | | Персистентность | PostgreSQL (уже предусмотрено комментарием в `Store.fs`), доступ через Dapper. | | Аналитика | Нужна сразу: % правильных ответов и сложность по каждому вопросу, не только список попыток. | | Анти-списывание | В аналитике попытки нужно видеть число потерь фокуса окна теста. | | Лимит времени | Настраивается преподавателем (уже есть `TimeLimit`), но должен реально принуждаться, а не только отображаться. | | Что происходит по истечении времени | Авто-сдача текущих ответов на сервере, как в Moodle. | | Публикация теста | Отдельный флаг `IsPublished` — преподаватель готовит тест и назначения заранее, студенты не видят его, пока он явно не опубликован. | | Ручная коррекция автооценки | Не нужна для v1 — полагаемся только на автопроверку (`Grading.gradeResponse`). | ## 2. Роли и права - **Student** — видит только тесты, на которые его явно назначили; проходит попытки; видит свои результаты. - **Teacher** — управляет **своими** темами, вопросами и тестами; назначает на тест студентов из общего справочника пользователей (справочник read-only для Teacher — юзеров создаёт только Admin); видит попытки и аналитику только по **своим** тестам. - **Admin** — всё, что может Teacher (свой банк вопросов и свои тесты — то есть Admin тоже может быть автором тестов), плюс CRUD пользователей (создание/деактивация/сброс пароля, назначение ролей). Авторизация как сейчас: JWT с claim роли, выдаётся при `login`. Каждый серверный хендлер проверяет роль/владельца сам (единообразный `Result`-based шаблон ошибок, без ASP.NET `[Authorize(Roles=...)]`, чтобы не расходиться со стилем существующего `QuizApi.fs`). ## 3. Доменная модель (изменения к текущей) Убираем: `Course`, `Enrollment`, `CourseId`, `EnrollmentRole` (полностью, не используются без курсов). ### 3.1 Пользователь Текущий `User` (`Domain/Users.fs`) не несёт признака активности, хотя возможность деактивации уже решена (§2, Admin) и уже присутствует в схеме БД (§5) — это пробел, закрываем явно. `CreatedAt` — практическая необходимость для сортировки списков в UI (Admin увидит, когда заведён аккаунт; Teacher — когда создана тема/вопрос/тест). ```fsharp type User = { Id: UserId Name: string Email: string PasswordHash: string Role: Role IsActive: bool // NEW — деактивированный пользователь не может login'иться CreatedAt: DateTimeOffset } // NEW ``` `login` дополнительно проверяет `IsActive`; при `false` — тот же `Error "Неверный email или пароль"`, что и при неверном пароле (не раскрываем факт существования/деактивации аккаунта в тексте ошибки). ### 3.2 Темы и вопросы `QuestionCategory` переименовывается в `Topic` и переподвешивается на преподавателя вместо курса: ```fsharp [] type TopicId = TopicId of Guid type Topic = { Id: TopicId OwnerId: UserId // преподаватель-владелец Name: string CreatedAt: DateTimeOffset } // NEW type Question = { Id: QuestionId TopicId: TopicId Text: string Points: float // дефолтные баллы вопроса в банке Type: QuestionType // без изменений: SingleChoice/MultipleChoice/TrueFalse/ShortAnswer/Numeric IsArchived: bool // NEW — см. врезку про удаление ниже CreatedAt: DateTimeOffset } // NEW ``` **Удаление при наличии истории.** Вопрос может быть частью `Quiz.Composition` (`FixedQuestions`) и/или уже фигурировать в чьих-то `Attempt.ResolvedQuestions`/`attempt_grades` — жёсткое удаление сломало бы ссылочную целостность и обнулило бы прошлые результаты. Поэтому `deleteQuestion`: если вопрос нигде не используется — удаляет по-настоящему; если используется хоть где-то — выставляет `IsArchived = true` вместо удаления. Архивные вопросы не показываются в списке для добавления в новый тест и не участвуют в пуле `RandomFromTopics` (§3.4), но остаются видны в уже прошедших попытках и в `getQuestionStats` (§3.5) — история не должна исчезать. Тема (`deleteTopic`) удаляется только если в ней **нет вопросов** (архивных в том числе — сначала перенести/удалить вопросы); отдельного `IsArchived` для темы не вводим, это не то, на что что-либо ссылается напрямую после удаления вопросов. ### 3.3 Состав теста: fixed vs random-from-topics Главное новое понятие — тест может либо содержать явный список вопросов, либо описывать правило случайного набора по темам: ```fsharp type QuizQuestionRef = { QuestionId: QuestionId Points: float // баллы именно в этом тесте, может отличаться от дефолта вопроса Order: int } /// Правило "N случайных вопросов из темы X, каждый на Y баллов" type RandomTopicRule = { TopicId: TopicId Count: int PointsPerQuestion: float } type QuizComposition = | FixedQuestions of QuizQuestionRef list | RandomFromTopics of RandomTopicRule list type Quiz = { Id: QuizId OwnerId: UserId Title: string Description: string Composition: QuizComposition TimeLimit: TimeSpan option MaxAttempts: int option GradingMethod: GradingMethod ShuffleQuestions: bool ShuffleAnswers: bool OpenFrom: DateTimeOffset option OpenTo: DateTimeOffset option PassingScore: float option AssignedStudentIds: Set IsPublished: bool // NEW — по умолчанию false, см. таблицу решений в §1 IsArchived: bool // NEW — аналогично Question.IsArchived: мягкое удаление, если есть попытки CreatedAt: DateTimeOffset } // NEW ``` `IsPublished` отделяет подготовку теста от его показа студентам: пока флаг не выставлен явно (`publishQuiz`, см. §4.2), тест не появляется в `getAvailableQuizzes` даже у уже назначенных студентов — независимо от `OpenFrom`/`OpenTo`. `deleteQuiz` — то же правило мягкого удаления, что и для `Question` (§3.2): если по тесту уже есть хоть одна попытка, `deleteQuiz` архивирует (`IsArchived = true`) вместо удаления, чтобы не потерять историю в `getQuizResults`/`getQuestionStats`. `Quiz.totalPoints` считается без резолва вопросов (важно, чтобы студент видел максимум баллов ещё до начала попытки): - `FixedQuestions refs` → `sum refs.Points` - `RandomFromTopics rules` → `sum (rule.Count * rule.PointsPerQuestion)` "Тест по теме" из требования пользователя — это `RandomFromTopics` с одним правилом (например, все/N вопросов из одной темы), а "итоговый" тест — `RandomFromTopics` с несколькими правилами (по одному на каждую выбранную тему). Отдельный домена не нужен — это два случая одного и того же режима. ### 3.4 Резолв случайного набора и попытка Ключевая проблема: раз набор вопросов случаен, конкретная попытка должна **зафиксировать**, какие именно вопросы были показаны студенту — иначе нечего будет ни оценивать, ни показывать в ревью. Резолв случается **один раз при `startAttempt`** и сохраняется на самой попытке: ```fsharp type AttemptFinishReason = | ManualSubmit | TimedOut type Attempt = { Id: AttemptId QuizId: QuizId UserId: UserId AttemptNumber: int // NEW — 1, 2, 3... в рамках (QuizId, UserId), для UI и MaxAttempts StartedAt: DateTimeOffset SubmittedAt: DateTimeOffset option State: AttemptState FinishReason: AttemptFinishReason option // NEW — None пока InProgress; см. §3.6 ResolvedQuestions: QuizQuestionRef list // NEW — конкретные вопросы именно этой попытки Responses: Map Grades: Map Score: float option FocusLossCount: int } // NEW — см. §3.6 ``` `AttemptNumber` считается при `startAttempt` как `(существующие попытки этого студента по этому тесту).Length + 1` — избавляет UI/аналитику от пересчёта каждый раз. `Attempt.submit` принимает `AttemptFinishReason` параметром (`ManualSubmit` из ручного `finishAttempt`, `TimedOut` из принудительного завершения по лимиту времени, см. §3.6) — сигнатура меняется с `submit (now: DateTimeOffset) (attempt: Attempt)` на `submit (reason: AttemptFinishReason) (now: DateTimeOffset) (attempt: Attempt)`. Чистая (без I/O) функция резолва в `Domain`, рандом и доступ к вопросам передаются снаружи: ```fsharp module Quiz = /// shuffle — инжектируемая функция перемешивания (использует ShuffleQuestions квиза сам вызывающий код). /// topicQuestions — вопросы каждой темы, уже загруженные вызывающим кодом из Store. let resolveComposition (shuffle: 'a list -> 'a list) (topicQuestions: Map) (quiz: Quiz) : Result = match quiz.Composition with | FixedQuestions refs -> Ok(if quiz.ShuffleQuestions then shuffle refs else refs) | RandomFromTopics rules -> rules |> List.fold (fun acc rule -> acc |> Result.bind (fun picked -> match Map.tryFind rule.TopicId topicQuestions with | Some pool when pool.Length >= rule.Count -> let chosen = pool |> shuffle |> List.truncate rule.Count let refs = chosen |> List.map (fun q -> { QuestionId = q.Id; Points = rule.PointsPerQuestion; Order = 0 }) Ok(picked @ refs) | _ -> Error "В одной из тем недостаточно вопросов для случайного набора")) (Ok []) |> Result.map (fun refs -> let ordered = if quiz.ShuffleQuestions then shuffle refs else refs ordered |> List.mapi (fun i r -> { r with Order = i })) ``` `Grading.gradeAttempt` переключается на `attempt.ResolvedQuestions` вместо `quiz.Questions` как единый источник правды для обоих режимов — это же упрощает `startAttempt`/`finishAttempt`, им больше не нужно различать fixed/random после резолва. ### 3.5 Аналитика по вопросам Отдельная нормализованная таблица (см. §5) `attempt_grades` (attempt_id, question_id, points_awarded, max_points, is_correct) пишется при `finishAttempt` вместе с `Grades`. По ней считается: ```fsharp type QuestionStat = { QuestionId: QuestionId QuestionText: string TimesAsked: int TimesCorrect: int PercentCorrect: float AvgPointsAwarded: float } ``` Агрегация — обычный SQL `GROUP BY question_id`, без DU-хитростей. ### 3.6 Анти-списывание: потери фокуса окна и жёсткий лимит времени **Потери фокуса.** Клиент слушает `document.visibilitychange` (переключение вкладки, сворачивание) и `window.blur` (переключение на другое окно/приложение поверх той же вкладки) с момента получения ответа от `startAttempt` — то есть с самого начала попытки, а не с первого отвеченного вопроса (студент может уйти листать шпаргалку ещё до того, как ответит хоть на один вопрос, и это тоже должно засчитаться). При переходе в состояние "не в фокусе" клиент дёргает новый метод API, который **инкрементит** счётчик на сервере (не принимает готовое число от клиента — так его нельзя подделать в свою пользу, разве что заспамить в меньшую сторону невозможно, а накрутить больше нет смысла жулику): ```fsharp // добавляется в IQuizApi (§4.1) reportFocusLoss: AttemptId -> Async> ``` Дребезг (несколько событий подряд при одном уходе) гасится на клиенте — считаем один "уход", пока пользователь не вернулся (`visibilitychange` обратно в `visible` сбрасывает флаг "уже считали"). `FocusLossCount` попадает в `AttemptSummary`/`AttemptDetail` (§3.7), преподаватель видит число рядом с баллом и сам решает, похоже это на списывание или нет — автоматических санкций система не применяет. **Жёсткий лимит времени.** Два уровня принуждения: 1. **Активная сессия.** `submitAnswer` и `finishAttempt` перед выполнением проверяют `Attempt.isExpired quiz now attempt` (функция уже есть в `Attempts.fs`, просто не вызывается). Если время вышло — сервер сам переводит попытку в `Graded` (те же шаги, что и ручной `finishAttempt`: `Attempt.submit TimedOut` → `Grading.gradeAttempt`, `FinishReason = Some TimedOut`) и возвращает `Error "Время вышло, тест завершён автоматически"`, а не проваливает исходное действие молча. Клиент по такому ответу показывает экран результата. 2. **Заброшенная сессия.** Если студент закрыл вкладку и больше не прислал ни одного запроса, пункт 1 не сработает — некому вызвать `submitAnswer`. Поэтому на сервере нужен фоновый `IHostedService` ("expiry sweeper"), который каждые ~30 сек находит `InProgress`-попытки с `started_at + time_limit < now` в Postgres и точно так же принудительно завершает и оценивает их (`Attempt.submit TimedOut` → `Grading.gradeAttempt`). Это и есть источник истины по лимиту — клиентский таймер в UI (обратный отсчёт) только для удобства студента, не для принуждения. Обычное ручное завершение (`finishAttempt` без истечения лимита) ставит `FinishReason = Some ManualSubmit`. Вопросы, на которые студент не успел ответить к моменту авто-сдачи, никак специально не обрабатываются — они и так остаются без записи в `attempt.Responses`, а `Grading.gradeAttempt` уже сегодня трактует отсутствующий ответ через `emptyResponseFor` как нулевой/неверный (см. `Grading.fs:59-64`). Отдельной логики "пропущенный вопрос" вводить не нужно. ### 3.7 Финальная оценка по нескольким попыткам и история Пробел в более ранней версии этого документа: типы `AttemptSummary`/`AttemptDetail`/`StudentQuizResult` упоминались в §4 по имени, но нигде не были определены. Плюс — в домене уже есть `Grading.applyGradingMethod`, который сводит несколько попыток студента в одну итоговую оценку по `Quiz.GradingMethod` (`HighestAttempt`/`AverageAttempt`/`FirstAttempt`/`LastAttempt`), но раньше эта функция никуда не была подключена: ни в одном API-методе результат её работы не отдавался ни преподавателю, ни самому студенту. Закрываем оба пробела одним набором типов: ```fsharp type AttemptSummary = { AttemptId: AttemptId StudentId: UserId StudentName: string AttemptNumber: int State: AttemptState StartedAt: DateTimeOffset SubmittedAt: DateTimeOffset option FinishReason: AttemptFinishReason option Score: float option MaxScore: float FocusLossCount: int } type AttemptDetail = { Summary: AttemptSummary Questions: QuestionView list // как показывались студенту, из ResolvedQuestions Responses: Map Grades: Map } /// Итог по тесту для одного студента, с учётом Quiz.GradingMethod. type StudentQuizResult = { StudentId: UserId StudentName: string Attempts: AttemptSummary list FinalScore: float option // Grading.applyGradingMethod по всем Attempts MaxScore: float Passed: bool option } ``` Два инварианта, которые эти типы предполагают: - **Оценка — снимок на момент `finishAttempt`/sweeper'а.** Если преподаватель потом отредактирует `Question` (текст, правильный ответ) или состав теста, уже выставленные `Grades`/`Score` задним числом не пересчитываются — иначе история результатов "плыла" бы вместе с правками банка вопросов. Это и есть причина, почему `Question`/`Quiz` архивируются, а не пересчитываются на лету (§3.2, §3.3). - **`ShuffleAnswers`** (порядок вариантов ответа внутри вопроса) не требует отдельного состояния — в отличие от `ShuffleQuestions`/`ResolvedQuestions`, порядок вариантов не влияет на то, что именно засчитывается (ответ кодируется через стабильный `OptionId`), поэтому просто перемешивается на сервере при формировании `QuestionView` в `startAttempt`, без сохранения куда-либо. ## 4. API (Fable.Remoting-style, вручную через fetch — см. комментарий в `Api.fs`) Три интерфейса вместо одного, каждый — отдельный роут-неймспейс (`/api//`), роль проверяется на сервере в каждом хендлере. ### 4.1 `IQuizApi` (Student) — правки существующего - `login` — дополнительно проверяет `User.IsActive` (§3.1). - `getAvailableQuizzes` — теперь фильтрует по `IsPublished = true`, `AssignedStudentIds` (только тесты, куда назначен текущий студент), `not IsArchived` и по окну `OpenFrom`/`OpenTo`, как сейчас. - `startAttempt`, `submitAnswer`, `finishAttempt` — логика резолва встраивается в `startAttempt` (см. §3.4), наружу для клиента ничего не меняется; `submitAnswer`/`finishAttempt` дополнительно проверяют истечение времени (см. §3.6). - `reportFocusLoss: AttemptId -> Async>` — новый метод, см. §3.6. - `getMyResults: QuizId -> Async>` — новый метод: студент видит свою историю попыток по тесту и итоговую оценку по `Quiz.GradingMethod` (§3.7) — без него у студента с `MaxAttempts > 1` нет способа посмотреть, как считался финальный балл. ### 4.2 `ITeacherApi` (Teacher и Admin) ```fsharp type ITeacherApi = { listTopics: unit -> Async createTopic: string -> Async> renameTopic: TopicId * string -> Async> deleteTopic: TopicId -> Async> listQuestions: TopicId -> Async createQuestion: CreateQuestionRequest -> Async> updateQuestion: UpdateQuestionRequest -> Async> deleteQuestion: QuestionId -> Async> listMyQuizzes: unit -> Async getQuiz: QuizId -> Async> createQuiz: CreateQuizRequest -> Async> updateQuiz: UpdateQuizRequest -> Async> publishQuiz: QuizId -> Async> // NEW — см. §3.3 unpublishQuiz: QuizId -> Async> // NEW — снять с публикации (уже стартовавших попыток не отменяет) deleteQuiz: QuizId -> Async> // архивирует, если есть попытки — см. §3.3 listStudents: unit -> Async // справочник для назначения, read-only assignStudents: QuizId * UserId list -> Async> unassignStudent: QuizId * UserId -> Async> getQuizAttempts: QuizId -> Async // сырой список попыток, см. §3.7 getAttemptDetail: AttemptId -> Async> getQuizResults: QuizId -> Async // NEW — сводка по студентам, см. §3.7 getQuestionStats: QuizId -> Async } ``` `getQuizAttempts` и `getQuizResults` отвечают на разные вопросы: первый — "кто, когда и как проходил тест" (нужен для анти-читерского ревью каждой отдельной попытки — `FocusLossCount`, `FinishReason`, длительность), второй — "какая у студента итоговая оценка по тесту с учётом `GradingMethod`" (журнал-ведомость). Оба используют типы из §3.7. Владение проверяется всюду: Teacher видит/меняет только темы/вопросы/тесты со своим `OwnerId` (Admin — тоже, но плюс видит вообще всех через `IAdminApi`, не через `ITeacherApi`). ### 4.3 `IAdminApi` (только Admin) ```fsharp type IAdminApi = { listUsers: unit -> Async createUser: CreateUserRequest -> Async> // задаёт Role: Teacher | Student | Admin updateUser: UpdateUserRequest -> Async> deactivateUser: UserId -> Async> resetPassword: UserId * string -> Async> } ``` ## 5. Персистентность (PostgreSQL + Dapper) DU-тяжёлые части (`QuestionType`, `QuizComposition`, `Responses`) храним как `jsonb` — реляционных join-таблиц под каждый вариант DU не оправдано на этом масштабе, а Dapper + `System.Text.Json` (с тем же подходом к конвертерам, что уже применён для Fable.Remoting.Json на сервере) сериализует их напрямую. ```sql users ( id uuid pk, name text, email text unique, password_hash text, role text, is_active bool not null default true, created_at timestamptz not null default now() ) topics (id uuid pk, owner_id uuid references users, name text, created_at timestamptz not null default now()) questions ( id uuid pk, topic_id uuid references topics, text text, points double precision, type_json jsonb, is_archived bool not null default false, created_at timestamptz not null default now() ) quizzes ( id uuid pk, owner_id uuid references users, title text, description text, composition_json jsonb, time_limit_minutes int null, max_attempts int null, grading_method text, shuffle_questions bool, shuffle_answers bool, open_from timestamptz null, open_to timestamptz null, passing_score double precision null, is_published bool not null default false, is_archived bool not null default false, created_at timestamptz not null default now() ) quiz_assignments (quiz_id uuid references quizzes, student_id uuid references users, primary key (quiz_id, student_id)) attempts ( id uuid pk, quiz_id uuid references quizzes, user_id uuid references users, attempt_number int not null, started_at timestamptz, submitted_at timestamptz null, state text, finish_reason text null, resolved_questions_json jsonb, responses_json jsonb, score double precision null, focus_loss_count int not null default 0 ) attempt_grades ( attempt_id uuid references attempts, question_id uuid references questions, points_awarded double precision, max_points double precision, is_correct bool, primary key (attempt_id, question_id) ) ``` `Store.fs` заменяется на модуль с Dapper-запросами за тем же member-интерфейсом (там уже есть комментарий это предвосхищающий) — сигнатуры методов остаются похожими, чтобы `QuizApi.fs` и новые `TeacherApi.fs`/`AdminApi.fs` менялись минимально. Миграции — лёгкий инструмент поверх Dapper (например DbUp: пронумерованные `.sql`-файлы, применяются при старте сервера), без EF Core, чтобы не тащить лишний ORM-слой. ## 6. UX прохождения теста Список вопросов остаётся одним непрерывно прокручиваемым блоком, как сейчас (`View.fs:226`) — без пагинации/пошагового визарда «один вопрос за раз». Студент должен иметь возможность свободно скроллить вверх-вниз по всем вопросам в любом порядке и с любой скоростью, без ограничений. Кнопка «Завершить тест» физически выносится из области прокрутки. Сейчас (`View.fs:227-231`) она рендерится прямо под последним вопросом внутри того же `taking-quiz-page`-контейнера — при быстрой прокрутке длинного списка случайный клик в момент остановки скролла может преждевременно завершить попытку. Нужен отдельный зафиксированный блок (sticky-хедер сверху или боковая панель), где живут общие элементы управления попыткой — обратный отсчёт времени (§3.6) и кнопка «Завершить тест», — и который не участвует в скролле списка вопросов, чтобы моторика "долистать до конца" и "нажать завершить" были физически разными жестами. ## 7. Фазы реализации 1. **Домен**: `User.IsActive`/`CreatedAt`, переименование Category→Topic + `Topic.CreatedAt`, `Question.IsArchived`/`CreatedAt`, `QuizComposition`, `Quiz.IsPublished`/`IsArchived`/`CreatedAt`, `Attempt.ResolvedQuestions`/`AttemptNumber`/`FinishReason`/`FocusLossCount`, включение проверки `Attempt.isExpired` в поток завершения попытки, удаление Course/Enrollment, обновление `Grading`/`Attempt`/`Quiz` модулей + юнит-тесты (`tests/Domain.Tests`) на резолв случайного набора, на `totalPoints` для обоих режимов и на сведение попыток через `applyGradingMethod`. 2. **PostgreSQL**: схема (§5), Dapper-репозиторий взамен `Store.fs`, миграции, конфиг строки подключения, фоновый `IHostedService`-sweeper для заброшенных просроченных попыток (§3.6). 3. **Admin API + мини-UI**: CRUD пользователей (с учётом `IsActive`). 4. **Teacher API + UI**: темы → вопросы (с архивированием вместо жёсткого удаления, §3.2) → тесты (fixed/random, включая настройку `TimeLimit`, `publishQuiz`/`unpublishQuiz`) → назначение студентов. 5. **Результаты и аналитика**: список попыток по тесту (`getQuizAttempts`, с `FocusLossCount`, `FinishReason` и длительностью), сводка по студентам с учётом `GradingMethod` (`getQuizResults`), детальный просмотр попытки, `QuestionStat`. 6. **Student UI**: уже работает end-to-end, донастройка — reflect only assigned+open quizzes, обратный отсчёт времени в UI, слушатели `visibilitychange`/`blur` → `reportFocusLoss`, вынос кнопки «Завершить тест» в отдельный зафиксированный блок (§6). 7. **Деплой на RuVDS**: systemd-юнит для Kestrel, nginx как reverse proxy + TLS, прод-конфиг `Jwt:Secret`/`Client:Origin`/строка подключения к Postgres (сейчас в `appsettings.Development.json` захардкожен dev-секрет и dev-порт клиента — см. память проекта про CORS-баг 2026-08-03). ## 8. Открытые вопросы (не решены, всплывут по ходу реализации) - Нужен ли предпросмотр/тестовый прогон теста преподавателем без сохранения попытки в статистику? - Что показывать студенту при просмотре своего результата — только баллы, или также его ответы с правильными (риск слива вопросов в банк для будущих попыток при `MaxAttempts > 1`)? - Лимит на минимальное число вопросов в теме, чтобы `RandomFromTopics` не падал в самый ответственный момент («недостаточно вопросов») — валидировать при создании теста или только при старте попытки? - Нужен ли визуальный порог/бейдж «подозрительно» при большом `FocusLossCount`, или преподаватель просто смотрит на число сам без автоматической оценки? - ~~С какого момента считать потери фокуса~~ — решено: с ответа `startAttempt`, см. §3.6. - Показывать ли преподавателю архивные (`IsArchived`) вопросы/тесты в общих списках приглушённым цветом с фильтром, или полностью прятать и доставать только через карточку конкретной попытки?