Compare commits
10 Commits
a4061331e5
...
7001a51803
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7001a51803 | ||
|
|
0480d1528c | ||
|
|
bb070a688d | ||
|
|
b8e29e9b1e | ||
|
|
95a4dd2475 | ||
|
|
0f8824bd43 | ||
|
|
63a03c0367 | ||
|
|
dfebfe2ee8 | ||
|
|
81e8173fb9 | ||
|
|
a02f735c26 |
11
.gitignore
vendored
11
.gitignore
vendored
@@ -4,8 +4,19 @@ __pycache__/
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
*.egg-info/
|
||||
.env
|
||||
quiz.db
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
# Real quiz pools must never be committed (they contain answer keys).
|
||||
# Only generic demo pools tracked under examples/pool_example.json.
|
||||
examples/*_pool.json
|
||||
!examples/pool_example.json
|
||||
|
||||
# Codex build leftovers
|
||||
.codex_done
|
||||
codex_last_message.md
|
||||
codex_run.log
|
||||
|
||||
67
IMPLEMENTATION_REPORT.md
Normal file
67
IMPLEMENTATION_REPORT.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# Implementation Report
|
||||
|
||||
## Built
|
||||
|
||||
Implemented the live in-lecture quiz portal per `SPEC.md`: FastAPI backend, SQLite schema with WAL, signed student and admin cookies, quiz pool validation, modular scoring, student/admin APIs, WebSocket state machine, auto-close, CSV export, vanilla student/admin frontends, example Week 9 question pool, docs, and tests.
|
||||
|
||||
File inventory and line counts:
|
||||
|
||||
```text
|
||||
app/: 1400 lines
|
||||
static/: 775 lines
|
||||
tests/: 504 lines
|
||||
examples/week9_pool.json: 127 lines
|
||||
total across app, static, tests, examples: 2806 lines
|
||||
```
|
||||
|
||||
## Test Results
|
||||
|
||||
`pytest -q`:
|
||||
|
||||
```text
|
||||
31 passed, 33 warnings in 5.43s
|
||||
```
|
||||
|
||||
`pytest --cov=app`:
|
||||
|
||||
```text
|
||||
31 passed, 33 warnings in 5.48s
|
||||
TOTAL 854 statements, 67 missed, 92.15% coverage
|
||||
Required test coverage of 80.0% reached.
|
||||
```
|
||||
|
||||
Smoke test:
|
||||
|
||||
```text
|
||||
uvicorn app.main:app started successfully on 127.0.0.1:8001
|
||||
GET /healthz returned {"ok":true,"version":"0.1.0","sessions_active":0,"ws_clients":0}
|
||||
```
|
||||
|
||||
## Run Locally
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
. .venv/bin/activate
|
||||
pip install -e '.[dev]'
|
||||
cp .env.example .env
|
||||
uvicorn app.main:app --host 127.0.0.1 --port 8001 --reload
|
||||
```
|
||||
|
||||
## Deviations and Notes
|
||||
|
||||
- Server-side QR SVG data URLs are used instead of a client-side QR library.
|
||||
- `live_histogram` is pushed on every accepted submission, not throttled.
|
||||
- Broadcast sends are queued so slow WebSocket clients do not block state changes.
|
||||
- `static/observer.html` is a placeholder because the observer page is optional.
|
||||
- FastAPI emits a non-fatal `on_event` deprecation warning with the installed package version.
|
||||
|
||||
## Open Issues
|
||||
|
||||
No known functional blockers. The admin UI is intentionally plain and should be reviewed with the instructor workflow in mind before classroom use.
|
||||
|
||||
## Review Carefully
|
||||
|
||||
- The late-join behavior and missed-submission rows.
|
||||
- The session control flow from `lobby` through `finished`.
|
||||
- CSV shape against the exact spreadsheet format wanted for class records.
|
||||
- The generated example questions, because they are plausible placeholders.
|
||||
14
NOTES.md
14
NOTES.md
@@ -1,3 +1,15 @@
|
||||
# Notes
|
||||
|
||||
Implementation notes will be filled in as the application lands.
|
||||
## Implementation Choices
|
||||
|
||||
- QR codes are generated server-side with the Python `qrcode` package and returned as an SVG data URL from session creation.
|
||||
- `live_histogram` is pushed on every accepted submission. There is no throttling in v1 because the acceptance load is small and this keeps behavior simple.
|
||||
- WebSocket broadcast sends are queued as background tasks. This prevents one slow classroom device from blocking state transitions or auto-close.
|
||||
- The optional observer page exists as a placeholder only. The spec explicitly marks it optional.
|
||||
- Tests use one FastAPI `TestClient` portal for multi-student WebSocket simulation. Each socket is opened after joining as that student, and the stored socket identity remains stable after the cookie is overwritten for the next simulated student.
|
||||
- Python 3.14.4 was used locally. The project requires Python 3.11 or newer.
|
||||
|
||||
## Deviations
|
||||
|
||||
- The admin frontend includes a compact one-question sample in the Add Pool modal for convenience. The full 10-question Week 9 pool is in `examples/week9_pool.json`.
|
||||
- FastAPI emits a deprecation warning for `on_event` under the installed version. Startup works correctly, and the warning is not user-facing.
|
||||
|
||||
78
README.md
78
README.md
@@ -1,3 +1,77 @@
|
||||
# Live In-Lecture Quiz Portal
|
||||
# Live in-lecture quiz portal
|
||||
|
||||
Development notes will be filled in as the implementation lands.
|
||||
FastAPI + WebSocket + SQLite quiz portal designed for ~40 students per
|
||||
class session. Single-process, in-memory room manager, vanilla HTML/JS
|
||||
front-end, Caddy in front for TLS.
|
||||
|
||||
## Quick local run
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
. .venv/bin/activate
|
||||
pip install -e '.[dev]'
|
||||
cp .env.example .env # edit QUIZ_SECRET_KEY + QUIZ_ADMIN_PASSWORD
|
||||
uvicorn app.main:app --host 127.0.0.1 --port 8001 --reload
|
||||
```
|
||||
|
||||
Open `http://127.0.0.1:8001/admin/`, log in, create a quiz pool from a
|
||||
JSON pool file (see `examples/pool_example.json` for the schema), create
|
||||
a session, and share the join URL.
|
||||
|
||||
## VPS deploy (one-shot)
|
||||
|
||||
On a fresh Ubuntu 24.04 LTS root SSH:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://gitea.ahkhan.me/apps/quiz/raw/branch/master/deploy/bootstrap.sh | bash
|
||||
```
|
||||
|
||||
The bootstrap:
|
||||
1. apt-installs Caddy + Python venv tooling
|
||||
2. Creates a `quiz` system user (no shell, no SSH)
|
||||
3. Clones this repo to `/opt/quiz`
|
||||
4. Builds the venv and installs the app
|
||||
5. Generates `QUIZ_SECRET_KEY`, prompts for `QUIZ_ADMIN_PASSWORD`
|
||||
6. Drops the systemd unit and Caddyfile
|
||||
7. Starts both services
|
||||
8. Curl-checks `127.0.0.1:8001/healthz`
|
||||
|
||||
After: `quiz.ahkhan.me` is live with auto-Let's-Encrypt cert. To override
|
||||
the domain or repo URL, set `DOMAIN=` or `REPO_URL=` in the environment
|
||||
before running the script.
|
||||
|
||||
## Class-day workflow
|
||||
|
||||
1. Provision Aliyun Intl HK ECS pay-as-you-go (`ecs.t6-c2m1.large`,
|
||||
Ubuntu 24.04 LTS).
|
||||
2. Point DNS A-record `quiz.ahkhan.me` at the new IP.
|
||||
3. SSH in as root, run the curl|bash one-liner above.
|
||||
4. Open `quiz.ahkhan.me/admin/`, log in, upload the week's pool JSON,
|
||||
create a session.
|
||||
5. Share the QR / join URL with the class.
|
||||
6. After class:
|
||||
`scp root@<ip>:/opt/quiz/quiz.db ./backups/quiz-YYYY-MM-DD.db`
|
||||
7. Destroy the instance.
|
||||
|
||||
## Quiz pool files
|
||||
|
||||
Real pool JSON files contain answer keys and **must not be committed**
|
||||
to this repo. `.gitignore` excludes `examples/*_pool.json` (only
|
||||
`examples/pool_example.json` may be tracked). Author pools elsewhere
|
||||
(e.g., your course-material directory) and upload at runtime via the
|
||||
admin UI.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
pytest -q
|
||||
pytest --cov=app
|
||||
```
|
||||
|
||||
For the WebSocket adversarial stress harness (Node.js + Playwright,
|
||||
runs in a tmux loop), see `tests/stress/README.md`.
|
||||
|
||||
## Spec
|
||||
|
||||
`SPEC.md` documents the locked v1.0 design (state machine, scoring,
|
||||
identity flow, all WS message types).
|
||||
|
||||
106
app/auth.py
106
app/auth.py
@@ -1 +1,107 @@
|
||||
"""Cookie signing helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import time
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException, Request, Response, WebSocket, status
|
||||
from itsdangerous import BadSignature, URLSafeSerializer
|
||||
|
||||
from app.config import Settings
|
||||
|
||||
STUDENT_COOKIE = "qz_student"
|
||||
ADMIN_COOKIE = "qz_admin"
|
||||
STUDENT_MAX_AGE = 31_536_000
|
||||
ADMIN_MAX_AGE = 86_400
|
||||
|
||||
|
||||
def serializer(settings: Settings) -> URLSafeSerializer:
|
||||
if not settings.secret_key:
|
||||
raise HTTPException(status_code=500, detail="QUIZ_SECRET_KEY is not configured")
|
||||
return URLSafeSerializer(settings.secret_key, salt="quiz-cookie-v1")
|
||||
|
||||
|
||||
def sign_student(settings: Settings, sid: str, student_id: str, name: str, cookie_id: str | None = None) -> str:
|
||||
payload = {
|
||||
"sid": sid,
|
||||
"student_id": student_id.strip(),
|
||||
"name": name.strip(),
|
||||
"cookie_id": cookie_id or str(uuid4()),
|
||||
}
|
||||
return serializer(settings).dumps(payload)
|
||||
|
||||
|
||||
def sign_admin(settings: Settings) -> str:
|
||||
return serializer(settings).dumps({"is_admin": True, "ts": int(time.time())})
|
||||
|
||||
|
||||
def loads_cookie(settings: Settings, value: str | None) -> dict[str, Any] | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
payload = serializer(settings).loads(value)
|
||||
except BadSignature:
|
||||
return None
|
||||
return payload if isinstance(payload, dict) else None
|
||||
|
||||
|
||||
def get_student_identity(settings: Settings, request: Request, sid: str | None = None) -> dict[str, Any] | None:
|
||||
payload = loads_cookie(settings, request.cookies.get(STUDENT_COOKIE))
|
||||
if not payload:
|
||||
return None
|
||||
if sid is not None and payload.get("sid") != sid:
|
||||
return None
|
||||
required = {"sid", "student_id", "name", "cookie_id"}
|
||||
return payload if required.issubset(payload) else None
|
||||
|
||||
|
||||
def get_student_identity_ws(settings: Settings, websocket: WebSocket, sid: str) -> dict[str, Any] | None:
|
||||
payload = loads_cookie(settings, websocket.cookies.get(STUDENT_COOKIE))
|
||||
if not payload or payload.get("sid") != sid:
|
||||
return None
|
||||
required = {"sid", "student_id", "name", "cookie_id"}
|
||||
return payload if required.issubset(payload) else None
|
||||
|
||||
|
||||
def require_admin_request(settings: Settings, request: Request) -> None:
|
||||
payload = loads_cookie(settings, request.cookies.get(ADMIN_COOKIE))
|
||||
if not payload or payload.get("is_admin") is not True:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Admin login required")
|
||||
|
||||
|
||||
def is_admin_ws(settings: Settings, websocket: WebSocket) -> bool:
|
||||
payload = loads_cookie(settings, websocket.cookies.get(ADMIN_COOKIE))
|
||||
return bool(payload and payload.get("is_admin") is True)
|
||||
|
||||
|
||||
def verify_admin_password(settings: Settings, password: str) -> bool:
|
||||
if not settings.admin_password:
|
||||
return False
|
||||
return secrets.compare_digest(password, settings.admin_password)
|
||||
|
||||
|
||||
def set_student_cookie(settings: Settings, response: Response, value: str) -> None:
|
||||
response.set_cookie(
|
||||
STUDENT_COOKIE,
|
||||
value,
|
||||
max_age=STUDENT_MAX_AGE,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=settings.secure_cookies,
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
def set_admin_cookie(settings: Settings, response: Response, value: str) -> None:
|
||||
response.set_cookie(
|
||||
ADMIN_COOKIE,
|
||||
value,
|
||||
max_age=ADMIN_MAX_AGE,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=settings.secure_cookies,
|
||||
path="/",
|
||||
)
|
||||
|
||||
657
app/room.py
657
app/room.py
@@ -1 +1,658 @@
|
||||
"""In-process WebSocket room manager."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from collections import defaultdict
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import WebSocket, WebSocketDisconnect
|
||||
|
||||
from app.config import Settings
|
||||
from app.db import connect
|
||||
from app.pool import get_question, parse_pool_json, public_question_payload, question_count, question_time_limit
|
||||
from app.scoring import SCORE_FNS
|
||||
|
||||
|
||||
def now_utc() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
def now_ms() -> int:
|
||||
return int(now_utc().timestamp() * 1000)
|
||||
|
||||
|
||||
def iso_now() -> str:
|
||||
return now_utc().isoformat()
|
||||
|
||||
|
||||
def parse_ts(value: str) -> datetime:
|
||||
if value.endswith("Z"):
|
||||
value = value[:-1] + "+00:00"
|
||||
parsed = datetime.fromisoformat(value)
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=UTC)
|
||||
return parsed
|
||||
|
||||
|
||||
class RoomManager:
|
||||
def __init__(self, settings: Settings):
|
||||
self.settings = settings
|
||||
self.student_clients: dict[str, dict[WebSocket, dict[str, Any]]] = defaultdict(dict)
|
||||
self.instructor_clients: dict[str, set[WebSocket]] = defaultdict(set)
|
||||
self.autoclose_tasks: dict[tuple[str, int], asyncio.Task] = {}
|
||||
self.locks: dict[str, asyncio.Lock] = defaultdict(asyncio.Lock)
|
||||
|
||||
async def sessions_active(self) -> int:
|
||||
async with connect(self.settings.db_path) as db:
|
||||
cursor = await db.execute("SELECT COUNT(*) AS count FROM quiz_sessions WHERE state != 'finished'")
|
||||
row = await cursor.fetchone()
|
||||
return int(row["count"])
|
||||
|
||||
def ws_client_count(self) -> int:
|
||||
return sum(len(clients) for clients in self.student_clients.values()) + sum(
|
||||
len(clients) for clients in self.instructor_clients.values()
|
||||
)
|
||||
|
||||
async def add_participant(self, sid: str, student_id: str, name: str, cookie_id: str) -> None:
|
||||
async with connect(self.settings.db_path) as db:
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO participants (sid, student_id, name, cookie_id)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(sid, student_id) DO UPDATE SET name = excluded.name, cookie_id = excluded.cookie_id
|
||||
""",
|
||||
(sid, student_id, name, cookie_id),
|
||||
)
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO submissions (sid, student_id, question_idx, status, score)
|
||||
SELECT sid, ?, question_idx, 'missed', 0
|
||||
FROM question_events
|
||||
WHERE sid = ? AND closed_at IS NOT NULL
|
||||
""",
|
||||
(student_id, sid),
|
||||
)
|
||||
await db.commit()
|
||||
await self.broadcast_lobby(sid)
|
||||
|
||||
async def student_ws(self, websocket: WebSocket, sid: str, identity: dict[str, Any]) -> None:
|
||||
await websocket.accept()
|
||||
self.student_clients[sid][websocket] = identity
|
||||
try:
|
||||
await self.send_student_snapshot(websocket, sid, identity)
|
||||
while True:
|
||||
try:
|
||||
data = await websocket.receive_json()
|
||||
except json.JSONDecodeError:
|
||||
try:
|
||||
await websocket.send_json({"type": "error", "code": "bad_message", "message": "Invalid JSON"})
|
||||
except (WebSocketDisconnect, RuntimeError):
|
||||
break
|
||||
continue
|
||||
if not isinstance(data, dict):
|
||||
try:
|
||||
await websocket.send_json({"type": "error", "code": "bad_message", "message": "Message must be a JSON object"})
|
||||
except (WebSocketDisconnect, RuntimeError):
|
||||
break
|
||||
continue
|
||||
msg_type = data.get("type")
|
||||
if msg_type == "ping":
|
||||
await websocket.send_json({"type": "pong"})
|
||||
elif msg_type == "submit":
|
||||
ack = await self.submit_answer(sid, identity["student_id"], data.get("question_idx"), data.get("answer"))
|
||||
await websocket.send_json(ack)
|
||||
else:
|
||||
await websocket.send_json({"type": "error", "code": "bad_message", "message": "Unknown message type"})
|
||||
except (WebSocketDisconnect, RuntimeError):
|
||||
pass
|
||||
finally:
|
||||
self.student_clients[sid].pop(websocket, None)
|
||||
|
||||
async def instructor_ws(self, websocket: WebSocket, sid: str) -> None:
|
||||
await websocket.accept()
|
||||
self.instructor_clients[sid].add(websocket)
|
||||
try:
|
||||
await self.send_instructor_snapshot(websocket, sid)
|
||||
while True:
|
||||
try:
|
||||
data = await websocket.receive_json()
|
||||
except json.JSONDecodeError:
|
||||
try:
|
||||
await websocket.send_json({"type": "error", "code": "bad_message", "message": "Invalid JSON"})
|
||||
except (WebSocketDisconnect, RuntimeError):
|
||||
break
|
||||
continue
|
||||
if not isinstance(data, dict):
|
||||
try:
|
||||
await websocket.send_json({"type": "error", "code": "bad_message", "message": "Message must be a JSON object"})
|
||||
except (WebSocketDisconnect, RuntimeError):
|
||||
break
|
||||
continue
|
||||
msg_type = data.get("type")
|
||||
if msg_type == "ping":
|
||||
await websocket.send_json({"type": "pong"})
|
||||
elif msg_type == "open_question":
|
||||
await self.open_question(sid, int(data.get("question_idx", 0)), data.get("time_limit"))
|
||||
elif msg_type == "close_question":
|
||||
await self.close_question(sid)
|
||||
elif msg_type == "next":
|
||||
await self.next_question(sid)
|
||||
elif msg_type == "end_session":
|
||||
await self.end_session(sid)
|
||||
else:
|
||||
await websocket.send_json({"type": "error", "code": "bad_message", "message": "Unknown message type"})
|
||||
except (WebSocketDisconnect, RuntimeError):
|
||||
pass
|
||||
finally:
|
||||
self.instructor_clients[sid].discard(websocket)
|
||||
|
||||
async def send_student_snapshot(self, websocket: WebSocket, sid: str, identity: dict[str, Any]) -> None:
|
||||
session = await self.get_session(sid)
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "state",
|
||||
"state": session["state"],
|
||||
"current_question_idx": session["current_question_idx"],
|
||||
"title": session["title"],
|
||||
}
|
||||
)
|
||||
if session["state"] == "question_open":
|
||||
await websocket.send_json(await self.question_open_message(sid, session["current_question_idx"]))
|
||||
ack = await self.existing_submit_ack(sid, identity["student_id"], session["current_question_idx"])
|
||||
if ack:
|
||||
await websocket.send_json(ack)
|
||||
|
||||
async def send_instructor_snapshot(self, websocket: WebSocket, sid: str) -> None:
|
||||
session = await self.get_session(sid)
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "state",
|
||||
"state": session["state"],
|
||||
"current_question_idx": session["current_question_idx"],
|
||||
"title": session["title"],
|
||||
}
|
||||
)
|
||||
await websocket.send_json(await self.lobby_message(sid))
|
||||
if session["state"] == "question_open":
|
||||
await websocket.send_json(await self.question_open_message(sid, session["current_question_idx"]))
|
||||
await websocket.send_json(await self.live_histogram_message(sid, session["current_question_idx"]))
|
||||
if session["state"] in {"question_closed", "between_questions", "finished"}:
|
||||
await websocket.send_json(await self.full_leaderboard_message(sid))
|
||||
|
||||
async def open_question(self, sid: str, question_idx: int, time_limit: int | None = None) -> None:
|
||||
async with self.locks[sid]:
|
||||
session = await self.get_session(sid)
|
||||
if session["state"] == "question_open" and session["current_question_idx"] == question_idx:
|
||||
return
|
||||
if session["state"] == "question_open":
|
||||
await self._close_question_locked(sid, session["current_question_idx"])
|
||||
pool = await self.get_pool_for_session(sid)
|
||||
if question_idx < 0 or question_idx >= question_count(pool):
|
||||
await self.broadcast_instructors(sid, {"type": "error", "code": "bad_question", "message": "Question index out of range"})
|
||||
return
|
||||
limit = int(time_limit or question_time_limit(pool, question_idx))
|
||||
opened = iso_now()
|
||||
async with connect(self.settings.db_path) as db:
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO question_events (sid, question_idx, opened_at, time_limit)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(sid, question_idx) DO UPDATE SET opened_at = excluded.opened_at,
|
||||
closed_at = NULL, time_limit = excluded.time_limit
|
||||
""",
|
||||
(sid, question_idx, opened, limit),
|
||||
)
|
||||
await db.execute(
|
||||
"UPDATE quiz_sessions SET state = 'question_open', current_question_idx = ? WHERE sid = ?",
|
||||
(question_idx, sid),
|
||||
)
|
||||
await db.commit()
|
||||
self._schedule_autoclose(sid, question_idx, limit)
|
||||
msg = await self.question_open_message(sid, question_idx)
|
||||
await self.broadcast_students(sid, msg)
|
||||
await self.broadcast_instructors(sid, msg)
|
||||
await self.broadcast_instructors(sid, await self.live_histogram_message(sid, question_idx))
|
||||
|
||||
async def close_question(self, sid: str) -> None:
|
||||
async with self.locks[sid]:
|
||||
session = await self.get_session(sid)
|
||||
if session["state"] != "question_open":
|
||||
return
|
||||
question_idx = session["current_question_idx"]
|
||||
await self._close_question_locked(sid, question_idx)
|
||||
await self.broadcast_question_closed(sid, question_idx)
|
||||
|
||||
async def _close_question_locked(self, sid: str, question_idx: int) -> None:
|
||||
task = self.autoclose_tasks.pop((sid, question_idx), None)
|
||||
current = asyncio.current_task()
|
||||
if task and task is not current:
|
||||
task.cancel()
|
||||
async with connect(self.settings.db_path) as db:
|
||||
await db.execute(
|
||||
"UPDATE question_events SET closed_at = COALESCE(closed_at, ?) WHERE sid = ? AND question_idx = ?",
|
||||
(iso_now(), sid, question_idx),
|
||||
)
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO submissions (sid, student_id, question_idx, status, score)
|
||||
SELECT p.sid, p.student_id, ?, 'missed', 0
|
||||
FROM participants p
|
||||
WHERE p.sid = ?
|
||||
""",
|
||||
(question_idx, sid),
|
||||
)
|
||||
await db.execute(
|
||||
"UPDATE quiz_sessions SET state = 'question_closed', current_question_idx = ? WHERE sid = ?",
|
||||
(question_idx, sid),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
async def next_question(self, sid: str) -> None:
|
||||
async with self.locks[sid]:
|
||||
session = await self.get_session(sid)
|
||||
if session["state"] != "question_closed":
|
||||
return
|
||||
next_idx = int(session["current_question_idx"]) + 1
|
||||
async with connect(self.settings.db_path) as db:
|
||||
await db.execute("UPDATE quiz_sessions SET state = 'between_questions' WHERE sid = ?", (sid,))
|
||||
await db.commit()
|
||||
await self.broadcast_between_questions(sid, next_idx)
|
||||
|
||||
async def end_session(self, sid: str) -> None:
|
||||
async with self.locks[sid]:
|
||||
session = await self.get_session(sid)
|
||||
if session["state"] == "question_open":
|
||||
await self._close_question_locked(sid, session["current_question_idx"])
|
||||
async with connect(self.settings.db_path) as db:
|
||||
await db.execute(
|
||||
"UPDATE quiz_sessions SET state = 'finished', current_question_idx = NULL, finished_at = ? WHERE sid = ?",
|
||||
(iso_now(), sid),
|
||||
)
|
||||
await db.commit()
|
||||
await self.broadcast_session_ended(sid)
|
||||
await self.broadcast_instructors(sid, await self.full_leaderboard_message(sid))
|
||||
|
||||
async def submit_answer(self, sid: str, student_id: str, question_idx: Any, answer: Any) -> dict[str, Any]:
|
||||
try:
|
||||
qidx = int(question_idx)
|
||||
except (TypeError, ValueError):
|
||||
return {"type": "error", "code": "bad_question", "message": "Invalid question index"}
|
||||
if not isinstance(answer, str) or answer not in {"A", "B", "C", "D"}:
|
||||
return {"type": "error", "code": "bad_answer", "message": "Answer must be A, B, C, or D"}
|
||||
async with self.locks[sid]:
|
||||
session = await self.get_session(sid)
|
||||
if session["state"] != "question_open" or session["current_question_idx"] != qidx:
|
||||
return {"type": "error", "code": "not_open", "message": "Question is not open"}
|
||||
existing = await self.existing_submit_ack(sid, student_id, qidx)
|
||||
if existing:
|
||||
return existing
|
||||
event = await self.get_question_event(sid, qidx)
|
||||
opened_at = parse_ts(event["opened_at"])
|
||||
elapsed_ms = max(0, int((now_utc() - opened_at).total_seconds() * 1000))
|
||||
time_limit_ms = int(event["time_limit"]) * 1000
|
||||
if elapsed_ms > time_limit_ms:
|
||||
return {"type": "error", "code": "time_expired", "message": "Question time has expired"}
|
||||
pool = await self.get_pool_for_session(sid)
|
||||
question = get_question(pool, qidx)
|
||||
correct = answer == question["correct"]
|
||||
score_fn = SCORE_FNS[pool["score_fn"]]
|
||||
score = score_fn(correct, elapsed_ms, time_limit_ms)
|
||||
submitted_at = iso_now()
|
||||
async with connect(self.settings.db_path) as db:
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO submissions (sid, student_id, question_idx, answer, submitted_at, elapsed_ms, score, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'submitted')
|
||||
ON CONFLICT(sid, student_id, question_idx) DO NOTHING
|
||||
""",
|
||||
(sid, student_id, qidx, answer, submitted_at, elapsed_ms, score),
|
||||
)
|
||||
await db.commit()
|
||||
await self.broadcast_instructors(sid, await self.live_histogram_message(sid, qidx))
|
||||
return {"type": "submit_ack", "question_idx": qidx, "answer": answer, "score": score, "elapsed_ms": elapsed_ms}
|
||||
|
||||
def _schedule_autoclose(self, sid: str, question_idx: int, time_limit: int) -> None:
|
||||
previous = self.autoclose_tasks.pop((sid, question_idx), None)
|
||||
if previous:
|
||||
previous.cancel()
|
||||
self.autoclose_tasks[(sid, question_idx)] = asyncio.create_task(self._autoclose_after(sid, question_idx, time_limit))
|
||||
|
||||
async def _autoclose_after(self, sid: str, question_idx: int, time_limit: int) -> None:
|
||||
try:
|
||||
await asyncio.sleep(time_limit)
|
||||
async with self.locks[sid]:
|
||||
session = await self.get_session(sid)
|
||||
if session["state"] == "question_open" and session["current_question_idx"] == question_idx:
|
||||
await self._close_question_locked(sid, question_idx)
|
||||
else:
|
||||
return
|
||||
await self.broadcast_question_closed(sid, question_idx)
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
|
||||
async def get_session(self, sid: str) -> dict[str, Any]:
|
||||
async with connect(self.settings.db_path) as db:
|
||||
cursor = await db.execute("SELECT * FROM quiz_sessions WHERE sid = ?", (sid,))
|
||||
row = await cursor.fetchone()
|
||||
if row is None:
|
||||
raise KeyError(f"Unknown session {sid}")
|
||||
return dict(row)
|
||||
|
||||
async def session_exists(self, sid: str) -> bool:
|
||||
async with connect(self.settings.db_path) as db:
|
||||
cursor = await db.execute("SELECT 1 FROM quiz_sessions WHERE sid = ?", (sid,))
|
||||
row = await cursor.fetchone()
|
||||
return row is not None
|
||||
|
||||
async def get_pool_for_session(self, sid: str) -> dict[str, Any]:
|
||||
async with connect(self.settings.db_path) as db:
|
||||
cursor = await db.execute(
|
||||
"""
|
||||
SELECT q.pool_json
|
||||
FROM quizzes q
|
||||
JOIN quiz_sessions s ON s.quiz_id = q.id
|
||||
WHERE s.sid = ?
|
||||
""",
|
||||
(sid,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if row is None:
|
||||
raise KeyError(f"Unknown session {sid}")
|
||||
return parse_pool_json(row["pool_json"])
|
||||
|
||||
async def get_question_event(self, sid: str, question_idx: int) -> dict[str, Any]:
|
||||
async with connect(self.settings.db_path) as db:
|
||||
cursor = await db.execute(
|
||||
"SELECT * FROM question_events WHERE sid = ? AND question_idx = ?",
|
||||
(sid, question_idx),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if row is None:
|
||||
raise KeyError("Question has not been opened")
|
||||
return dict(row)
|
||||
|
||||
async def existing_submit_ack(self, sid: str, student_id: str, question_idx: int) -> dict[str, Any] | None:
|
||||
async with connect(self.settings.db_path) as db:
|
||||
cursor = await db.execute(
|
||||
"""
|
||||
SELECT answer, elapsed_ms, score, status
|
||||
FROM submissions
|
||||
WHERE sid = ? AND student_id = ? AND question_idx = ? AND status = 'submitted'
|
||||
""",
|
||||
(sid, student_id, question_idx),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return {
|
||||
"type": "submit_ack",
|
||||
"question_idx": question_idx,
|
||||
"answer": row["answer"],
|
||||
"score": row["score"],
|
||||
"elapsed_ms": row["elapsed_ms"],
|
||||
}
|
||||
|
||||
async def question_open_message(self, sid: str, question_idx: int) -> dict[str, Any]:
|
||||
pool = await self.get_pool_for_session(sid)
|
||||
event = await self.get_question_event(sid, question_idx)
|
||||
opened_ms = int(parse_ts(event["opened_at"]).timestamp() * 1000)
|
||||
remaining_ms = max(0, opened_ms + int(event["time_limit"]) * 1000 - now_ms())
|
||||
msg = {"type": "question_open", **public_question_payload(pool, question_idx)}
|
||||
msg["opened_at_server_ts"] = opened_ms
|
||||
msg["remaining_ms"] = remaining_ms
|
||||
return msg
|
||||
|
||||
async def question_closed_message(self, sid: str, question_idx: int, identity: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
pool = await self.get_pool_for_session(sid)
|
||||
question = get_question(pool, question_idx)
|
||||
msg = {
|
||||
"type": "question_closed",
|
||||
"question_idx": question_idx,
|
||||
"correct": question["correct"],
|
||||
"explanation": question.get("explanation", ""),
|
||||
"histogram": await self.histogram(sid, question_idx),
|
||||
"top5": await self.leaderboard(sid, limit=5),
|
||||
}
|
||||
if identity:
|
||||
student = identity["student_id"]
|
||||
submission = await self.submission_for(sid, student, question_idx)
|
||||
rank = await self.rank_for(sid, student)
|
||||
total = await self.total_for(sid, student)
|
||||
msg.update(
|
||||
{
|
||||
"your_answer": submission.get("answer") if submission else None,
|
||||
"your_score": submission.get("score", 0) if submission else 0,
|
||||
"your_rank": rank,
|
||||
"your_total": total,
|
||||
}
|
||||
)
|
||||
return msg
|
||||
|
||||
async def between_message(self, sid: str, next_idx: int, identity: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
msg = {"type": "between_questions", "next_idx": next_idx, "top5": await self.leaderboard(sid, limit=5)}
|
||||
if identity:
|
||||
msg["your_rank"] = await self.rank_for(sid, identity["student_id"])
|
||||
msg["your_total"] = await self.total_for(sid, identity["student_id"])
|
||||
return msg
|
||||
|
||||
async def ended_message(self, sid: str, identity: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
msg = {"type": "session_ended", "final_top5": await self.leaderboard(sid, limit=5)}
|
||||
if identity:
|
||||
student = identity["student_id"]
|
||||
msg.update(await self.student_summary(sid, student))
|
||||
msg["your_rank"] = await self.rank_for(sid, student)
|
||||
msg["your_total"] = await self.total_for(sid, student)
|
||||
return msg
|
||||
|
||||
async def histogram(self, sid: str, question_idx: int, pending: bool = False) -> dict[str, int]:
|
||||
result = {"A": 0, "B": 0, "C": 0, "D": 0, "missed": 0}
|
||||
async with connect(self.settings.db_path) as db:
|
||||
cursor = await db.execute(
|
||||
"SELECT answer, status, COUNT(*) AS count FROM submissions WHERE sid = ? AND question_idx = ? GROUP BY answer, status",
|
||||
(sid, question_idx),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
total_cursor = await db.execute("SELECT COUNT(*) AS count FROM participants WHERE sid = ?", (sid,))
|
||||
total_row = await total_cursor.fetchone()
|
||||
submitted = 0
|
||||
for row in rows:
|
||||
if row["status"] == "missed":
|
||||
result["missed"] += row["count"]
|
||||
elif row["answer"] in result:
|
||||
result[row["answer"]] += row["count"]
|
||||
submitted += row["count"]
|
||||
if pending:
|
||||
result["pending"] = max(0, int(total_row["count"]) - submitted - result["missed"])
|
||||
return result
|
||||
|
||||
async def leaderboard(self, sid: str, limit: int | None = None, include_ids: bool = False) -> list[dict[str, Any]]:
|
||||
query_limit = "" if limit is None else f"LIMIT {int(limit)}"
|
||||
async with connect(self.settings.db_path) as db:
|
||||
cursor = await db.execute(
|
||||
f"""
|
||||
SELECT p.student_id, p.name, COALESCE(SUM(s.score), 0) AS score
|
||||
FROM participants p
|
||||
LEFT JOIN submissions s ON s.sid = p.sid AND s.student_id = p.student_id
|
||||
WHERE p.sid = ?
|
||||
GROUP BY p.student_id, p.name
|
||||
ORDER BY score DESC, p.name ASC, p.student_id ASC
|
||||
{query_limit}
|
||||
""",
|
||||
(sid,),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
board = []
|
||||
for rank, row in enumerate(rows, start=1):
|
||||
item = {"rank": rank, "name": row["name"], "score": int(row["score"])}
|
||||
if include_ids:
|
||||
item["student_id"] = row["student_id"]
|
||||
board.append(item)
|
||||
return board
|
||||
|
||||
async def rank_for(self, sid: str, student_id: str) -> int | None:
|
||||
board = await self.leaderboard(sid, include_ids=True)
|
||||
for item in board:
|
||||
if item["student_id"] == student_id:
|
||||
return item["rank"]
|
||||
return None
|
||||
|
||||
async def total_for(self, sid: str, student_id: str) -> int:
|
||||
async with connect(self.settings.db_path) as db:
|
||||
cursor = await db.execute(
|
||||
"SELECT COALESCE(SUM(score), 0) AS total FROM submissions WHERE sid = ? AND student_id = ?",
|
||||
(sid, student_id),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return int(row["total"])
|
||||
|
||||
async def submission_for(self, sid: str, student_id: str, question_idx: int) -> dict[str, Any] | None:
|
||||
async with connect(self.settings.db_path) as db:
|
||||
cursor = await db.execute(
|
||||
"SELECT * FROM submissions WHERE sid = ? AND student_id = ? AND question_idx = ?",
|
||||
(sid, student_id, question_idx),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
async def student_summary(self, sid: str, student_id: str) -> dict[str, int]:
|
||||
pool = await self.get_pool_for_session(sid)
|
||||
async with connect(self.settings.db_path) as db:
|
||||
cursor = await db.execute(
|
||||
"SELECT question_idx, answer, status FROM submissions WHERE sid = ? AND student_id = ?",
|
||||
(sid, student_id),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
answered = sum(1 for row in rows if row["status"] == "submitted")
|
||||
correct = 0
|
||||
for row in rows:
|
||||
if row["status"] == "submitted" and row["answer"] == get_question(pool, row["question_idx"])["correct"]:
|
||||
correct += 1
|
||||
return {"questions_answered": answered, "questions_correct": correct}
|
||||
|
||||
async def lobby_message(self, sid: str) -> dict[str, Any]:
|
||||
async with connect(self.settings.db_path) as db:
|
||||
cursor = await db.execute(
|
||||
"SELECT student_id, name, joined_at FROM participants WHERE sid = ? ORDER BY joined_at, name",
|
||||
(sid,),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
participants = [dict(row) for row in rows]
|
||||
return {"type": "lobby_update", "participants": participants, "count": len(participants)}
|
||||
|
||||
async def live_histogram_message(self, sid: str, question_idx: int) -> dict[str, Any]:
|
||||
histogram = await self.histogram(sid, question_idx, pending=True)
|
||||
submitted_count = histogram["A"] + histogram["B"] + histogram["C"] + histogram["D"]
|
||||
return {
|
||||
"type": "live_histogram",
|
||||
"question_idx": question_idx,
|
||||
"histogram": histogram,
|
||||
"submitted_count": submitted_count,
|
||||
"total_count": submitted_count + histogram["missed"] + histogram.get("pending", 0),
|
||||
}
|
||||
|
||||
async def full_leaderboard_message(self, sid: str) -> dict[str, Any]:
|
||||
return {"type": "full_leaderboard", "leaderboard": await self.leaderboard(sid, include_ids=True)}
|
||||
|
||||
async def me(self, sid: str, student_id: str) -> dict[str, Any]:
|
||||
async with connect(self.settings.db_path) as db:
|
||||
part_cursor = await db.execute("SELECT * FROM participants WHERE sid = ? AND student_id = ?", (sid, student_id))
|
||||
participant = await part_cursor.fetchone()
|
||||
sub_cursor = await db.execute(
|
||||
"SELECT question_idx, answer, elapsed_ms, score, status FROM submissions WHERE sid = ? AND student_id = ? ORDER BY question_idx",
|
||||
(sid, student_id),
|
||||
)
|
||||
submissions = await sub_cursor.fetchall()
|
||||
return {
|
||||
"student_id": participant["student_id"],
|
||||
"name": participant["name"],
|
||||
"total_score": await self.total_for(sid, student_id),
|
||||
"submissions": [dict(row) for row in submissions],
|
||||
}
|
||||
|
||||
async def stats(self, sid: str, question_idx: int | None, student_id: str | None = None) -> dict[str, Any]:
|
||||
session = await self.get_session(sid)
|
||||
qidx = question_idx if question_idx is not None else session["current_question_idx"]
|
||||
if qidx is None:
|
||||
return {
|
||||
"question_idx": None,
|
||||
"response_time_avg_ms": None,
|
||||
"response_time_distribution": {},
|
||||
"average_score": 0,
|
||||
"top5": await self.leaderboard(sid, limit=5),
|
||||
"your_rank": None,
|
||||
}
|
||||
async with connect(self.settings.db_path) as db:
|
||||
cursor = await db.execute(
|
||||
"""
|
||||
SELECT elapsed_ms, score
|
||||
FROM submissions
|
||||
WHERE sid = ? AND question_idx = ? AND status = 'submitted'
|
||||
""",
|
||||
(sid, qidx),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
times = [row["elapsed_ms"] for row in rows if row["elapsed_ms"] is not None]
|
||||
scores = [row["score"] for row in rows]
|
||||
distribution = {"0_10s": 0, "10_30s": 0, "30s_plus": 0}
|
||||
for elapsed in times:
|
||||
if elapsed < 10_000:
|
||||
distribution["0_10s"] += 1
|
||||
elif elapsed < 30_000:
|
||||
distribution["10_30s"] += 1
|
||||
else:
|
||||
distribution["30s_plus"] += 1
|
||||
payload = {
|
||||
"question_idx": qidx,
|
||||
"response_time_avg_ms": round(sum(times) / len(times)) if times else None,
|
||||
"response_time_distribution": distribution,
|
||||
"average_score": round(sum(scores) / len(scores), 2) if scores else 0,
|
||||
"top5": await self.leaderboard(sid, limit=5),
|
||||
}
|
||||
if student_id:
|
||||
payload["your_rank"] = await self.rank_for(sid, student_id)
|
||||
return payload
|
||||
|
||||
async def broadcast_lobby(self, sid: str) -> None:
|
||||
await self.broadcast_instructors(sid, await self.lobby_message(sid))
|
||||
|
||||
async def broadcast_question_closed(self, sid: str, question_idx: int) -> None:
|
||||
for websocket, identity in list(self.student_clients[sid].items()):
|
||||
self._queue_send(websocket, await self.question_closed_message(sid, question_idx, identity))
|
||||
await self.broadcast_instructors(sid, await self.question_closed_message(sid, question_idx))
|
||||
await self.broadcast_instructors(sid, await self.full_leaderboard_message(sid))
|
||||
await asyncio.sleep(0)
|
||||
|
||||
async def broadcast_between_questions(self, sid: str, next_idx: int) -> None:
|
||||
for websocket, identity in list(self.student_clients[sid].items()):
|
||||
self._queue_send(websocket, await self.between_message(sid, next_idx, identity))
|
||||
await self.broadcast_instructors(sid, await self.between_message(sid, next_idx))
|
||||
await self.broadcast_instructors(sid, await self.full_leaderboard_message(sid))
|
||||
await asyncio.sleep(0)
|
||||
|
||||
async def broadcast_session_ended(self, sid: str) -> None:
|
||||
for websocket, identity in list(self.student_clients[sid].items()):
|
||||
self._queue_send(websocket, await self.ended_message(sid, identity))
|
||||
await self.broadcast_instructors(sid, await self.ended_message(sid))
|
||||
await asyncio.sleep(0)
|
||||
|
||||
async def broadcast_students(self, sid: str, message: dict[str, Any]) -> None:
|
||||
for websocket in list(self.student_clients[sid]):
|
||||
self._queue_send(websocket, message)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
async def broadcast_instructors(self, sid: str, message: dict[str, Any]) -> None:
|
||||
for websocket in list(self.instructor_clients[sid]):
|
||||
self._queue_send(websocket, message)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
def _queue_send(self, websocket: WebSocket, message: dict[str, Any]) -> None:
|
||||
asyncio.create_task(self._safe_send(websocket, message))
|
||||
|
||||
async def _safe_send(self, websocket: WebSocket, message: dict[str, Any]) -> None:
|
||||
try:
|
||||
await websocket.send_text(json.dumps(message))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -1 +1,177 @@
|
||||
"""Instructor routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import secrets
|
||||
from io import BytesIO
|
||||
from typing import Any
|
||||
|
||||
import qrcode
|
||||
import qrcode.image.svg
|
||||
from fastapi import APIRouter, File, HTTPException, Request, Response, UploadFile, WebSocket
|
||||
from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse
|
||||
|
||||
from app import auth
|
||||
from app.config import Settings
|
||||
from app.csv_export import export_session_csv
|
||||
from app.db import connect
|
||||
from app.models import QuizCreateRequest, SessionCreateRequest
|
||||
from app.pool import PoolValidationError, parse_pool_json
|
||||
from app.room import RoomManager
|
||||
|
||||
CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
||||
|
||||
|
||||
def router(settings: Settings, rooms: RoomManager) -> APIRouter:
|
||||
api = APIRouter()
|
||||
|
||||
def require_admin(request: Request) -> None:
|
||||
auth.require_admin_request(settings, request)
|
||||
|
||||
@api.get("/admin/login")
|
||||
async def login_form():
|
||||
return HTMLResponse(
|
||||
"<!doctype html><title>Admin Login</title><form method='post'>"
|
||||
"<label>Password <input name='password' type='password'></label>"
|
||||
"<button type='submit'>Log in</button></form>"
|
||||
)
|
||||
|
||||
@api.post("/admin/login")
|
||||
async def login(request: Request, response: Response):
|
||||
password = ""
|
||||
content_type = request.headers.get("content-type", "")
|
||||
if "application/json" in content_type:
|
||||
data = await request.json()
|
||||
password = str(data.get("password", ""))
|
||||
else:
|
||||
form = await request.form()
|
||||
password = str(form.get("password", ""))
|
||||
if not auth.verify_admin_password(settings, password):
|
||||
raise HTTPException(status_code=401, detail="Invalid admin password")
|
||||
auth.set_admin_cookie(settings, response, auth.sign_admin(settings))
|
||||
return {"ok": True}
|
||||
|
||||
@api.get("/admin/")
|
||||
async def admin_page(request: Request):
|
||||
require_admin(request)
|
||||
return FileResponse("static/admin.html")
|
||||
|
||||
@api.get("/admin/api/quizzes")
|
||||
async def list_quizzes(request: Request):
|
||||
require_admin(request)
|
||||
async with connect(settings.db_path) as db:
|
||||
cursor = await db.execute(
|
||||
"SELECT id, title, time_limit_default, score_fn_name, created_at FROM quizzes ORDER BY created_at DESC, id DESC"
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return {"quizzes": [dict(row) for row in rows]}
|
||||
|
||||
@api.post("/admin/api/quizzes")
|
||||
async def create_quiz(request: Request, body: QuizCreateRequest):
|
||||
require_admin(request)
|
||||
try:
|
||||
pool = parse_pool_json(body.pool_json)
|
||||
except PoolValidationError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
if body.time_limit_default is not None:
|
||||
pool["time_limit_default"] = body.time_limit_default
|
||||
title = (body.title or pool["title"]).strip()
|
||||
async with connect(settings.db_path) as db:
|
||||
cursor = await db.execute(
|
||||
"INSERT INTO quizzes (title, pool_json, time_limit_default, score_fn_name) VALUES (?, ?, ?, ?)",
|
||||
(title, json.dumps(pool), pool["time_limit_default"], pool["score_fn"]),
|
||||
)
|
||||
await db.commit()
|
||||
quiz_id = cursor.lastrowid
|
||||
return {"ok": True, "quiz_id": quiz_id}
|
||||
|
||||
@api.post("/admin/api/quizzes/upload")
|
||||
async def upload_quiz(request: Request, file: UploadFile = File(...)):
|
||||
require_admin(request)
|
||||
raw = (await file.read()).decode("utf-8")
|
||||
try:
|
||||
pool = parse_pool_json(raw)
|
||||
except PoolValidationError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
async with connect(settings.db_path) as db:
|
||||
cursor = await db.execute(
|
||||
"INSERT INTO quizzes (title, pool_json, time_limit_default, score_fn_name) VALUES (?, ?, ?, ?)",
|
||||
(pool["title"], json.dumps(pool), pool["time_limit_default"], pool["score_fn"]),
|
||||
)
|
||||
await db.commit()
|
||||
quiz_id = cursor.lastrowid
|
||||
return {"ok": True, "quiz_id": quiz_id}
|
||||
|
||||
@api.get("/admin/api/sessions")
|
||||
async def list_sessions(request: Request):
|
||||
require_admin(request)
|
||||
async with connect(settings.db_path) as db:
|
||||
cursor = await db.execute(
|
||||
"""
|
||||
SELECT s.sid, s.quiz_id, s.title, s.state, s.current_question_idx, s.started_at, s.finished_at,
|
||||
COUNT(p.student_id) AS participant_count
|
||||
FROM quiz_sessions s
|
||||
LEFT JOIN participants p ON p.sid = s.sid
|
||||
GROUP BY s.sid
|
||||
ORDER BY s.started_at DESC
|
||||
"""
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return {"sessions": [dict(row) for row in rows]}
|
||||
|
||||
@api.post("/admin/api/sessions")
|
||||
async def create_session(request: Request, body: SessionCreateRequest):
|
||||
require_admin(request)
|
||||
async with connect(settings.db_path) as db:
|
||||
quiz_cursor = await db.execute("SELECT id, title FROM quizzes WHERE id = ?", (body.quiz_id,))
|
||||
quiz = await quiz_cursor.fetchone()
|
||||
if quiz is None:
|
||||
raise HTTPException(status_code=404, detail="Quiz not found")
|
||||
sid = await _generate_sid(db)
|
||||
await db.execute(
|
||||
"INSERT INTO quiz_sessions (sid, quiz_id, title) VALUES (?, ?, ?)",
|
||||
(sid, body.quiz_id, quiz["title"]),
|
||||
)
|
||||
await db.commit()
|
||||
join_url = f"{settings.public_url}/?sid={sid}"
|
||||
return {"sid": sid, "join_url": join_url, "qr_url": _qr_data_url(join_url)}
|
||||
|
||||
@api.get("/admin/api/sessions/{sid}/csv")
|
||||
async def csv_download(sid: str, request: Request):
|
||||
require_admin(request)
|
||||
if not await rooms.session_exists(sid):
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
csv_text = await export_session_csv(settings.db_path, sid)
|
||||
return PlainTextResponse(
|
||||
csv_text,
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": f'attachment; filename="{sid}-results.csv"'},
|
||||
)
|
||||
|
||||
@api.websocket("/ws/instructor/{sid}")
|
||||
async def instructor_socket(websocket: WebSocket, sid: str):
|
||||
if not auth.is_admin_ws(settings, websocket) or not await rooms.session_exists(sid):
|
||||
await websocket.close(code=4001)
|
||||
return
|
||||
await rooms.instructor_ws(websocket, sid)
|
||||
|
||||
return api
|
||||
|
||||
|
||||
async def _generate_sid(db: Any) -> str:
|
||||
for _ in range(5):
|
||||
sid = "".join(secrets.choice(CROCKFORD) for _ in range(6))
|
||||
cursor = await db.execute("SELECT 1 FROM quiz_sessions WHERE sid = ?", (sid,))
|
||||
if await cursor.fetchone() is None:
|
||||
return sid
|
||||
raise HTTPException(status_code=500, detail="Could not allocate session ID")
|
||||
|
||||
|
||||
def _qr_data_url(value: str) -> str:
|
||||
image = qrcode.make(value, image_factory=qrcode.image.svg.SvgPathImage)
|
||||
buf = BytesIO()
|
||||
image.save(buf)
|
||||
encoded = base64.b64encode(buf.getvalue()).decode("ascii")
|
||||
return f"data:image/svg+xml;base64,{encoded}"
|
||||
|
||||
@@ -1 +1,75 @@
|
||||
"""Student routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Response, WebSocket
|
||||
from fastapi.responses import FileResponse, HTMLResponse
|
||||
|
||||
from app import auth
|
||||
from app.config import Settings
|
||||
from app.models import JoinRequest
|
||||
from app.room import RoomManager
|
||||
|
||||
|
||||
def router(settings: Settings, rooms: RoomManager) -> APIRouter:
|
||||
api = APIRouter()
|
||||
|
||||
@api.get("/")
|
||||
async def student_entry(sid: str | None = None):
|
||||
if not sid or not await rooms.session_exists(sid):
|
||||
return HTMLResponse(
|
||||
"<!doctype html><title>Quiz</title><main><h1>Ask your instructor for the link</h1>"
|
||||
"<p>This quiz link is missing or no longer valid.</p></main>"
|
||||
)
|
||||
return FileResponse(Path("static/student.html"))
|
||||
|
||||
@api.get("/api/session/{sid}")
|
||||
async def session_metadata(sid: str):
|
||||
if not await rooms.session_exists(sid):
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
session = await rooms.get_session(sid)
|
||||
return {
|
||||
"title": session["title"],
|
||||
"state": session["state"],
|
||||
"current_question_idx": session["current_question_idx"],
|
||||
"time_limit_default": (await rooms.get_pool_for_session(sid))["time_limit_default"],
|
||||
}
|
||||
|
||||
@api.post("/api/session/{sid}/join")
|
||||
async def join_session(sid: str, body: JoinRequest, response: Response):
|
||||
if not await rooms.session_exists(sid):
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
student_id = body.student_id.strip()
|
||||
name = body.name.strip()
|
||||
cookie_id = str(uuid4())
|
||||
cookie_value = auth.sign_student(settings, sid, student_id, name, cookie_id)
|
||||
await rooms.add_participant(sid, student_id, name, cookie_id)
|
||||
auth.set_student_cookie(settings, response, cookie_value)
|
||||
return {"ok": True, "cookie_id": cookie_id}
|
||||
|
||||
@api.get("/api/session/{sid}/me")
|
||||
async def me(sid: str, request: Request):
|
||||
identity = auth.get_student_identity(settings, request, sid)
|
||||
if not identity:
|
||||
raise HTTPException(status_code=401, detail="Student cookie required")
|
||||
return await rooms.me(sid, identity["student_id"])
|
||||
|
||||
@api.get("/api/session/{sid}/stats")
|
||||
async def stats(sid: str, request: Request, question_idx: int | None = None):
|
||||
if not await rooms.session_exists(sid):
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
identity = auth.get_student_identity(settings, request, sid)
|
||||
return await rooms.stats(sid, question_idx, identity["student_id"] if identity else None)
|
||||
|
||||
@api.websocket("/ws/student/{sid}")
|
||||
async def student_socket(websocket: WebSocket, sid: str):
|
||||
identity = auth.get_student_identity_ws(settings, websocket, sid)
|
||||
if not identity or not await rooms.session_exists(sid):
|
||||
await websocket.close(code=4001)
|
||||
return
|
||||
await rooms.student_ws(websocket, sid, identity)
|
||||
|
||||
return api
|
||||
|
||||
4
deploy/Caddyfile.tpl
Normal file
4
deploy/Caddyfile.tpl
Normal file
@@ -0,0 +1,4 @@
|
||||
__DOMAIN__ {
|
||||
encode gzip
|
||||
reverse_proxy 127.0.0.1:8001
|
||||
}
|
||||
114
deploy/bootstrap.sh
Executable file
114
deploy/bootstrap.sh
Executable file
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env bash
|
||||
# Live in-lecture quiz portal — VPS bootstrap.
|
||||
# Idempotent: safe to re-run on a partially-bootstrapped host.
|
||||
# Designed for: fresh Ubuntu 24.04 LTS, run as root.
|
||||
#
|
||||
# Usage (one-shot, on the VPS):
|
||||
# curl -fsSL https://gitea.ahkhan.me/apps/quiz/raw/branch/master/deploy/bootstrap.sh | bash
|
||||
#
|
||||
# Override via env:
|
||||
# DOMAIN=quiz.example.org curl ... | bash
|
||||
# REPO_URL=https://... curl ... | bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# When invoked through curl|bash, stdin is the pipe, not the TTY.
|
||||
# Reattach TTY so `read -s` works for the password prompt.
|
||||
[ -t 0 ] || exec < /dev/tty
|
||||
|
||||
REPO_URL="${REPO_URL:-https://gitea.ahkhan.me/apps/quiz.git}"
|
||||
APP_DIR="${APP_DIR:-/opt/quiz}"
|
||||
APP_USER="${APP_USER:-quiz}"
|
||||
DOMAIN="${DOMAIN:-quiz.ahkhan.me}"
|
||||
BRANCH="${BRANCH:-master}"
|
||||
|
||||
if [ "$(id -u)" != "0" ]; then
|
||||
echo "bootstrap.sh must run as root" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
stage() { printf '\n==> Stage %s\n' "$*"; }
|
||||
|
||||
stage "1/8: apt update + base packages"
|
||||
apt-get update -q
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -q \
|
||||
git curl ca-certificates gnupg \
|
||||
python3 python3-venv python3-pip \
|
||||
debian-keyring debian-archive-keyring apt-transport-https
|
||||
|
||||
stage "2/8: install Caddy (skip if present)"
|
||||
if ! command -v caddy >/dev/null 2>&1; then
|
||||
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
|
||||
| gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
|
||||
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
|
||||
| tee /etc/apt/sources.list.d/caddy-stable.list >/dev/null
|
||||
apt-get update -q
|
||||
apt-get install -y -q caddy
|
||||
fi
|
||||
|
||||
stage "3/8: create $APP_USER system user (skip if present)"
|
||||
if ! id "$APP_USER" >/dev/null 2>&1; then
|
||||
useradd --system --shell /usr/sbin/nologin --home-dir "$APP_DIR" "$APP_USER"
|
||||
fi
|
||||
|
||||
stage "4/8: clone or update repo into $APP_DIR"
|
||||
if [ -d "$APP_DIR/.git" ]; then
|
||||
git -C "$APP_DIR" fetch origin
|
||||
git -C "$APP_DIR" reset --hard "origin/$BRANCH"
|
||||
else
|
||||
rm -rf "$APP_DIR"
|
||||
git clone --branch "$BRANCH" "$REPO_URL" "$APP_DIR"
|
||||
fi
|
||||
chown -R "$APP_USER":"$APP_USER" "$APP_DIR"
|
||||
|
||||
stage "5/8: build venv + install dependencies"
|
||||
sudo -u "$APP_USER" -H python3 -m venv "$APP_DIR/.venv"
|
||||
sudo -u "$APP_USER" -H "$APP_DIR/.venv/bin/pip" install --quiet --upgrade pip
|
||||
sudo -u "$APP_USER" -H "$APP_DIR/.venv/bin/pip" install --quiet -e "$APP_DIR"
|
||||
|
||||
stage "6/8: configure environment (.env)"
|
||||
ENV_FILE="$APP_DIR/.env"
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
if [ -f /root/.quiz.env ]; then
|
||||
echo "Using /root/.quiz.env"
|
||||
cp /root/.quiz.env "$ENV_FILE"
|
||||
else
|
||||
QUIZ_SECRET_KEY=$(python3 -c 'import secrets; print(secrets.token_urlsafe(48))')
|
||||
printf 'Admin password (input hidden): '
|
||||
read -rs QUIZ_ADMIN_PASSWORD
|
||||
echo
|
||||
cat > "$ENV_FILE" <<EOF
|
||||
QUIZ_DB_PATH=$APP_DIR/quiz.db
|
||||
QUIZ_SECRET_KEY=$QUIZ_SECRET_KEY
|
||||
QUIZ_ADMIN_PASSWORD=$QUIZ_ADMIN_PASSWORD
|
||||
QUIZ_HOST=127.0.0.1
|
||||
QUIZ_PORT=8001
|
||||
QUIZ_PUBLIC_URL=https://$DOMAIN
|
||||
QUIZ_LOG_LEVEL=INFO
|
||||
EOF
|
||||
fi
|
||||
chown "$APP_USER":"$APP_USER" "$ENV_FILE"
|
||||
chmod 600 "$ENV_FILE"
|
||||
fi
|
||||
|
||||
stage "7/8: install systemd unit"
|
||||
install -m 644 "$APP_DIR/deploy/quiz.service" /etc/systemd/system/quiz.service
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now quiz.service
|
||||
|
||||
stage "8/8: configure Caddy"
|
||||
sed "s/__DOMAIN__/$DOMAIN/g" "$APP_DIR/deploy/Caddyfile.tpl" > /etc/caddy/Caddyfile
|
||||
systemctl reload caddy
|
||||
|
||||
echo
|
||||
echo "==> Health check"
|
||||
sleep 2
|
||||
if curl -fs http://127.0.0.1:8001/healthz; then
|
||||
echo
|
||||
echo
|
||||
echo "Bootstrap complete. Public URL: https://$DOMAIN"
|
||||
else
|
||||
echo
|
||||
echo "Health check failed. Inspect: journalctl -u quiz.service -n 50"
|
||||
exit 1
|
||||
fi
|
||||
21
deploy/quiz.service
Normal file
21
deploy/quiz.service
Normal file
@@ -0,0 +1,21 @@
|
||||
[Unit]
|
||||
Description=Live in-lecture quiz portal (uvicorn)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=quiz
|
||||
Group=quiz
|
||||
WorkingDirectory=/opt/quiz
|
||||
EnvironmentFile=/opt/quiz/.env
|
||||
ExecStart=/opt/quiz/.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8001 --no-access-log
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
ProtectSystem=full
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
NoNewPrivileges=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
31
examples/pool_example.json
Normal file
31
examples/pool_example.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"title": "Demo Pool: Generic Knowledge",
|
||||
"score_fn": "linear_decay",
|
||||
"time_limit_default": 60,
|
||||
"questions": [
|
||||
{
|
||||
"id": "demo1",
|
||||
"text": "Which of these is a programming language?",
|
||||
"options": {
|
||||
"A": "HTTP",
|
||||
"B": "Python",
|
||||
"C": "TCP",
|
||||
"D": "DNS"
|
||||
},
|
||||
"correct": "B",
|
||||
"explanation": "Python is a general-purpose programming language; the others are network protocols."
|
||||
},
|
||||
{
|
||||
"id": "demo2",
|
||||
"text": "What is 2 + 2?",
|
||||
"options": {
|
||||
"A": "3",
|
||||
"B": "4",
|
||||
"C": "5",
|
||||
"D": "22"
|
||||
},
|
||||
"correct": "B",
|
||||
"explanation": "Basic arithmetic."
|
||||
}
|
||||
]
|
||||
}
|
||||
212
static/admin.js
212
static/admin.js
@@ -1,2 +1,212 @@
|
||||
const app = document.querySelector("#admin-app");
|
||||
app.textContent = "Loading admin...";
|
||||
|
||||
let quizzes = [];
|
||||
let sessions = [];
|
||||
let activeSid = null;
|
||||
let ws = null;
|
||||
let leaderboard = [];
|
||||
let roster = [];
|
||||
let liveHistogram = null;
|
||||
let currentState = null;
|
||||
|
||||
const samplePool = {
|
||||
title: "Week 9 Recap: Computer Organization",
|
||||
score_fn: "linear_decay",
|
||||
time_limit_default: 60,
|
||||
questions: [
|
||||
{id: "q1", text: "Which unit sequences control signals in a multi-cycle datapath?", options: {A: "ALU", B: "Control unit", C: "Register file", D: "Instruction memory"}, correct: "B"}
|
||||
]
|
||||
};
|
||||
|
||||
function escapeText(value) {
|
||||
return String(value ?? "").replace(/[&<>"']/g, (char) => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
})[char]);
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const response = await fetch(path, {
|
||||
credentials: "same-origin",
|
||||
headers: {"Content-Type": "application/json", ...(options.headers || {})},
|
||||
...options,
|
||||
});
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
return contentType.includes("json") ? response.json() : response.text();
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
try {
|
||||
await refresh();
|
||||
render();
|
||||
} catch {
|
||||
renderLogin();
|
||||
}
|
||||
}
|
||||
|
||||
function renderLogin(error = "") {
|
||||
app.innerHTML = `<section class="shell"><div class="panel narrow">
|
||||
<h1>Admin Login</h1>
|
||||
<form id="login-form" class="stack">
|
||||
<label>Password <input name="password" type="password" required></label>
|
||||
${error ? `<p class="error">${escapeText(error)}</p>` : ""}
|
||||
<button class="primary">Log in</button>
|
||||
</form>
|
||||
</div></section>`;
|
||||
document.querySelector("#login-form").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
try {
|
||||
await api("/admin/login", {method: "POST", body: JSON.stringify({password: form.get("password")})});
|
||||
await refresh();
|
||||
render();
|
||||
} catch {
|
||||
renderLogin("Login failed.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
quizzes = (await api("/admin/api/quizzes")).quizzes;
|
||||
sessions = (await api("/admin/api/sessions")).sessions;
|
||||
}
|
||||
|
||||
function render() {
|
||||
app.innerHTML = `<section class="admin-layout">
|
||||
<aside class="sidebar">
|
||||
<h1>Quiz Admin</h1>
|
||||
<button id="new-quiz" class="secondary">Add Pool</button>
|
||||
<button id="new-session" class="primary" ${quizzes.length ? "" : "disabled"}>Create Session</button>
|
||||
<h2>Quizzes</h2>
|
||||
<div class="list">${quizzes.map((quiz) => `<button data-quiz="${quiz.id}">${escapeText(quiz.title)}</button>`).join("") || "<p>No quizzes yet.</p>"}</div>
|
||||
<h2>Sessions</h2>
|
||||
<div class="list">${sessions.map((session) => `<button data-session="${session.sid}">${session.sid} ${escapeText(session.state)}</button>`).join("") || "<p>No sessions yet.</p>"}</div>
|
||||
</aside>
|
||||
<main class="workspace">${renderSession()}</main>
|
||||
</section>`;
|
||||
document.querySelector("#new-quiz").addEventListener("click", renderQuizModal);
|
||||
document.querySelector("#new-session").addEventListener("click", renderSessionModal);
|
||||
document.querySelectorAll("[data-session]").forEach((button) => {
|
||||
button.addEventListener("click", () => connectSession(button.dataset.session));
|
||||
});
|
||||
bindControls();
|
||||
}
|
||||
|
||||
function renderSession() {
|
||||
if (!activeSid) return `<div class="panel"><h1>No active session</h1><p>Create or select a session.</p></div>`;
|
||||
const session = sessions.find((item) => item.sid === activeSid);
|
||||
return `<div class="panel">
|
||||
<div class="topline"><h1>${escapeText(session?.title || activeSid)}</h1><span>${escapeText(currentState?.state || session?.state || "")}</span></div>
|
||||
<p>Session ID: <strong>${activeSid}</strong></p>
|
||||
<div class="toolbar">
|
||||
<button data-command="open_question" class="primary">Open</button>
|
||||
<button data-command="close_question">Close & Reveal</button>
|
||||
<button data-command="next">Next</button>
|
||||
<button data-command="end_session" class="danger">End</button>
|
||||
<a class="button" href="/admin/api/sessions/${activeSid}/csv">Download CSV</a>
|
||||
</div>
|
||||
<label>Question index <input id="question-idx" type="number" min="0" value="${currentState?.current_question_idx ?? 0}"></label>
|
||||
<label>Time limit <input id="time-limit" type="number" min="1" value="60"></label>
|
||||
<h2>Roster (${roster.length})</h2>
|
||||
<div class="roster">${roster.map((p) => `<span>${escapeText(p.student_id)} ${escapeText(p.name)}</span>`).join("") || "No students yet."}</div>
|
||||
<h2>Live Histogram</h2>
|
||||
${renderHistogram(liveHistogram?.histogram)}
|
||||
<h2>Leaderboard</h2>
|
||||
${renderLeaderboard(leaderboard)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderHistogram(histogram) {
|
||||
const data = histogram || {A: 0, B: 0, C: 0, D: 0, missed: 0, pending: 0};
|
||||
return `<div class="histogram">${Object.entries(data).map(([key, value]) => (
|
||||
`<div class="hist-row"><span>${key}</span><meter min="0" max="50" value="${value}"></meter><b>${value}</b></div>`
|
||||
)).join("")}</div>`;
|
||||
}
|
||||
|
||||
function renderLeaderboard(rows) {
|
||||
return `<ol class="leaderboard">${(rows || []).map((row) => (
|
||||
`<li><span>${row.rank}. ${escapeText(row.name)} ${row.student_id ? `(${escapeText(row.student_id)})` : ""}</span><strong>${row.score}</strong></li>`
|
||||
)).join("") || "<li>No scores yet.</li>"}</ol>`;
|
||||
}
|
||||
|
||||
function bindControls() {
|
||||
document.querySelectorAll("[data-command]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
const command = button.dataset.command;
|
||||
if (command === "open_question") {
|
||||
ws.send(JSON.stringify({
|
||||
type: command,
|
||||
question_idx: Number(document.querySelector("#question-idx").value || 0),
|
||||
time_limit: Number(document.querySelector("#time-limit").value || 60),
|
||||
}));
|
||||
} else {
|
||||
ws.send(JSON.stringify({type: command}));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderQuizModal() {
|
||||
app.innerHTML = `<section class="shell"><div class="panel">
|
||||
<h1>Add Pool</h1>
|
||||
<form id="quiz-form" class="stack">
|
||||
<label>Pool JSON <textarea name="pool" rows="18">${escapeText(JSON.stringify(samplePool, null, 2))}</textarea></label>
|
||||
<button class="primary">Create Quiz</button>
|
||||
<button type="button" id="cancel">Cancel</button>
|
||||
</form>
|
||||
</div></section>`;
|
||||
document.querySelector("#cancel").addEventListener("click", render);
|
||||
document.querySelector("#quiz-form").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const pool = JSON.parse(new FormData(event.currentTarget).get("pool"));
|
||||
await api("/admin/api/quizzes", {method: "POST", body: JSON.stringify({pool_json: pool})});
|
||||
await refresh();
|
||||
render();
|
||||
});
|
||||
}
|
||||
|
||||
async function renderSessionModal() {
|
||||
const options = quizzes.map((quiz) => `<option value="${quiz.id}">${escapeText(quiz.title)}</option>`).join("");
|
||||
app.innerHTML = `<section class="shell"><div class="panel narrow">
|
||||
<h1>Create Session</h1>
|
||||
<form id="session-form" class="stack">
|
||||
<label>Quiz <select name="quiz_id">${options}</select></label>
|
||||
<button class="primary">Create</button>
|
||||
<button type="button" id="cancel">Cancel</button>
|
||||
</form>
|
||||
<div id="session-result"></div>
|
||||
</div></section>`;
|
||||
document.querySelector("#cancel").addEventListener("click", render);
|
||||
document.querySelector("#session-form").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const quizId = Number(new FormData(event.currentTarget).get("quiz_id"));
|
||||
const result = await api("/admin/api/sessions", {method: "POST", body: JSON.stringify({quiz_id: quizId})});
|
||||
document.querySelector("#session-result").innerHTML = `<h2>${result.sid}</h2><p><a href="${result.join_url}">${result.join_url}</a></p><img class="qr" src="${result.qr_url}" alt="QR code">`;
|
||||
await refresh();
|
||||
connectSession(result.sid);
|
||||
});
|
||||
}
|
||||
|
||||
function connectSession(sid) {
|
||||
activeSid = sid;
|
||||
if (ws) ws.close();
|
||||
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
||||
ws = new WebSocket(`${protocol}://${window.location.host}/ws/instructor/${sid}`);
|
||||
ws.addEventListener("message", (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.type === "state") currentState = message;
|
||||
if (message.type === "lobby_update") roster = message.participants;
|
||||
if (message.type === "live_histogram") liveHistogram = message;
|
||||
if (message.type === "full_leaderboard") leaderboard = message.leaderboard;
|
||||
if (message.type === "question_closed") liveHistogram = {histogram: message.histogram};
|
||||
render();
|
||||
});
|
||||
render();
|
||||
}
|
||||
|
||||
boot();
|
||||
|
||||
223
static/quiz.js
223
static/quiz.js
@@ -1,2 +1,223 @@
|
||||
const app = document.querySelector("#app");
|
||||
app.textContent = "Loading quiz...";
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const sid = params.get("sid");
|
||||
|
||||
let ws = null;
|
||||
let me = null;
|
||||
let activeQuestion = null;
|
||||
let submitted = null;
|
||||
let countdownTimer = null;
|
||||
|
||||
function html(strings, ...values) {
|
||||
return strings.map((part, index) => part + (values[index] ?? "")).join("");
|
||||
}
|
||||
|
||||
function escapeText(value) {
|
||||
return String(value ?? "").replace(/[&<>"']/g, (char) => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
})[char]);
|
||||
}
|
||||
|
||||
function setView(markup) {
|
||||
app.innerHTML = `<section class="shell">${markup}</section>`;
|
||||
}
|
||||
|
||||
function askForLink() {
|
||||
setView(html`<div class="panel center">
|
||||
<h1>Ask your instructor for the link</h1>
|
||||
<p>This quiz link is missing or no longer valid.</p>
|
||||
</div>`);
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const response = await fetch(path, {
|
||||
credentials: "same-origin",
|
||||
headers: {"Content-Type": "application/json", ...(options.headers || {})},
|
||||
...options,
|
||||
});
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
if (!sid) {
|
||||
askForLink();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api(`/api/session/${sid}`);
|
||||
} catch {
|
||||
askForLink();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
me = await api(`/api/session/${sid}/me`);
|
||||
connect();
|
||||
} catch {
|
||||
renderJoin();
|
||||
}
|
||||
}
|
||||
|
||||
function renderJoin(error = "") {
|
||||
setView(html`<div class="panel narrow">
|
||||
<h1>Join Quiz</h1>
|
||||
<form id="join-form" class="stack">
|
||||
<label>Student ID <input name="student_id" autocomplete="username" required></label>
|
||||
<label>Name <input name="name" autocomplete="name" required></label>
|
||||
${error ? `<p class="error">${escapeText(error)}</p>` : ""}
|
||||
<button class="primary" type="submit">Join</button>
|
||||
</form>
|
||||
</div>`);
|
||||
document.querySelector("#join-form").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
try {
|
||||
await api(`/api/session/${sid}/join`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
student_id: form.get("student_id"),
|
||||
name: form.get("name"),
|
||||
}),
|
||||
});
|
||||
me = await api(`/api/session/${sid}/me`);
|
||||
connect();
|
||||
} catch {
|
||||
renderJoin("Could not join this session.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function connect() {
|
||||
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
||||
ws = new WebSocket(`${protocol}://${window.location.host}/ws/student/${sid}`);
|
||||
ws.addEventListener("message", (event) => handleMessage(JSON.parse(event.data)));
|
||||
ws.addEventListener("close", () => {
|
||||
clearInterval(countdownTimer);
|
||||
setView(html`<div class="panel center"><h1>Connection closed</h1><p>Refresh the page to reconnect.</p></div>`);
|
||||
});
|
||||
}
|
||||
|
||||
function handleMessage(message) {
|
||||
if (message.type === "state") renderState(message);
|
||||
if (message.type === "question_open") renderQuestion(message);
|
||||
if (message.type === "submit_ack") renderSubmitted(message);
|
||||
if (message.type === "question_closed") renderReveal(message);
|
||||
if (message.type === "between_questions") renderBetween(message);
|
||||
if (message.type === "session_ended") renderFinished(message);
|
||||
if (message.type === "error") renderError(message.message);
|
||||
}
|
||||
|
||||
function renderState(message) {
|
||||
activeQuestion = null;
|
||||
submitted = null;
|
||||
clearInterval(countdownTimer);
|
||||
if (message.state === "lobby") {
|
||||
setView(html`<div class="panel center">
|
||||
<h1>${escapeText(message.title)}</h1>
|
||||
<p class="status">You are in. Waiting for instructor to start.</p>
|
||||
<p>${escapeText(me?.name || "")}</p>
|
||||
<div class="spinner"></div>
|
||||
</div>`);
|
||||
}
|
||||
}
|
||||
|
||||
function renderQuestion(message) {
|
||||
activeQuestion = message;
|
||||
submitted = null;
|
||||
const buttons = Object.entries(message.options).map(([key, value]) => (
|
||||
`<button class="answer" data-answer="${key}"><strong>${key}</strong><span>${escapeText(value)}</span></button>`
|
||||
)).join("");
|
||||
setView(html`<article class="panel quiz-panel">
|
||||
<div class="topline"><span>Question ${message.question_idx + 1}</span><span id="timer"></span></div>
|
||||
<div class="bar"><span id="bar-fill"></span></div>
|
||||
<h1>${escapeText(message.text)}</h1>
|
||||
<div class="answers">${buttons}</div>
|
||||
</article>`);
|
||||
document.querySelectorAll("[data-answer]").forEach((button) => {
|
||||
button.addEventListener("click", () => submitAnswer(button.dataset.answer));
|
||||
});
|
||||
startCountdown(message);
|
||||
}
|
||||
|
||||
function startCountdown(message) {
|
||||
clearInterval(countdownTimer);
|
||||
const endAt = Date.now() + message.remaining_ms;
|
||||
const total = message.time_limit * 1000;
|
||||
const tick = () => {
|
||||
const remaining = Math.max(0, endAt - Date.now());
|
||||
const timer = document.querySelector("#timer");
|
||||
const fill = document.querySelector("#bar-fill");
|
||||
if (timer) timer.textContent = `${Math.ceil(remaining / 1000)}s`;
|
||||
if (fill) fill.style.width = `${Math.max(0, Math.min(100, remaining / total * 100))}%`;
|
||||
};
|
||||
tick();
|
||||
countdownTimer = setInterval(tick, 250);
|
||||
}
|
||||
|
||||
function submitAnswer(answer) {
|
||||
if (!ws || !activeQuestion || submitted) return;
|
||||
ws.send(JSON.stringify({type: "submit", question_idx: activeQuestion.question_idx, answer}));
|
||||
document.querySelectorAll("[data-answer]").forEach((button) => button.disabled = true);
|
||||
}
|
||||
|
||||
function renderSubmitted(message) {
|
||||
submitted = message;
|
||||
const seconds = (message.elapsed_ms / 1000).toFixed(1);
|
||||
setView(html`<div class="panel center">
|
||||
<h1>Submitted</h1>
|
||||
<p class="score">Submitted in ${seconds}s, +${message.score} pts.</p>
|
||||
<p>Wait for the reveal.</p>
|
||||
</div>`);
|
||||
}
|
||||
|
||||
function renderReveal(message) {
|
||||
clearInterval(countdownTimer);
|
||||
const rows = Object.entries(message.histogram).map(([key, value]) => (
|
||||
`<div class="hist-row"><span>${key}</span><meter min="0" max="50" value="${value}"></meter><b>${value}</b></div>`
|
||||
)).join("");
|
||||
const board = renderBoard(message.top5);
|
||||
setView(html`<article class="panel">
|
||||
<p class="status">Correct answer: ${escapeText(message.correct)}</p>
|
||||
<h1>Reveal</h1>
|
||||
<p>Your answer: ${escapeText(message.your_answer || "missed")}. Score: ${message.your_score || 0}.</p>
|
||||
${message.explanation ? `<p>${escapeText(message.explanation)}</p>` : ""}
|
||||
<div class="histogram">${rows}</div>
|
||||
${board}
|
||||
<p>Your rank: ${message.your_rank ?? "pending"}. Total: ${message.your_total ?? 0}.</p>
|
||||
</article>`);
|
||||
}
|
||||
|
||||
function renderBetween(message) {
|
||||
setView(html`<div class="panel center">
|
||||
<h1>Next question coming up</h1>
|
||||
<p>Your rank: ${message.your_rank ?? "pending"}</p>
|
||||
<p>Total: ${message.your_total ?? 0}</p>
|
||||
${renderBoard(message.top5)}
|
||||
</div>`);
|
||||
}
|
||||
|
||||
function renderFinished(message) {
|
||||
setView(html`<div class="panel center celebration">
|
||||
<h1>Quiz finished</h1>
|
||||
<p>Your rank: ${message.your_rank ?? "pending"}. Total: ${message.your_total ?? 0}.</p>
|
||||
<p>Answered ${message.questions_answered ?? 0}, correct ${message.questions_correct ?? 0}.</p>
|
||||
${renderBoard(message.final_top5)}
|
||||
</div>`);
|
||||
}
|
||||
|
||||
function renderBoard(rows = []) {
|
||||
if (!rows.length) return "<p>No scores yet.</p>";
|
||||
return `<ol class="leaderboard">${rows.map((row) => (
|
||||
`<li><span>${row.rank}. ${escapeText(row.name)}</span><strong>${row.score}</strong></li>`
|
||||
)).join("")}</ol>`;
|
||||
}
|
||||
|
||||
function renderError(message) {
|
||||
setView(html`<div class="panel center"><h1>Quiz message</h1><p>${escapeText(message)}</p></div>`);
|
||||
}
|
||||
|
||||
boot();
|
||||
|
||||
282
static/style.css
282
static/style.css
@@ -1,6 +1,8 @@
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: #f6f7f9;
|
||||
color: #18212f;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -15,3 +17,283 @@ body {
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: #ffffff;
|
||||
border: 1px solid #d9dee7;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 10px 30px rgba(19, 33, 54, 0.08);
|
||||
}
|
||||
|
||||
.narrow {
|
||||
max-width: 440px;
|
||||
margin: 8vh auto;
|
||||
}
|
||||
|
||||
.center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
h1, h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
input, textarea, select, button, .button {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
input, textarea, select {
|
||||
border: 1px solid #b8c0cc;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
background: #fff;
|
||||
color: #18212f;
|
||||
}
|
||||
|
||||
button, .button {
|
||||
border: 1px solid #9aa7b8;
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
background: #eef1f5;
|
||||
color: #18212f;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.primary {
|
||||
background: #0d6b57;
|
||||
border-color: #0d6b57;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
background: #254f7a;
|
||||
border-color: #254f7a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.danger {
|
||||
background: #a43831;
|
||||
border-color: #a43831;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #a43831;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.status, .score {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
margin: 20px auto 0;
|
||||
border: 4px solid #cbd3df;
|
||||
border-top-color: #0d6b57;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.quiz-panel h1 {
|
||||
font-size: clamp(1.5rem, 4vw, 2.5rem);
|
||||
}
|
||||
|
||||
.topline {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.bar {
|
||||
height: 12px;
|
||||
background: #e0e6ef;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
margin: 12px 0 24px;
|
||||
}
|
||||
|
||||
.bar span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: #0d6b57;
|
||||
transition: width 0.2s linear;
|
||||
}
|
||||
|
||||
.answers {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.answer {
|
||||
display: grid;
|
||||
grid-template-columns: 42px 1fr;
|
||||
gap: 12px;
|
||||
min-height: 68px;
|
||||
align-items: center;
|
||||
text-align: left;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.answer strong {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: #254f7a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.histogram {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.hist-row {
|
||||
display: grid;
|
||||
grid-template-columns: 72px 1fr 48px;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
meter {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.leaderboard {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding-left: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.leaderboard li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #e2e7ee;
|
||||
}
|
||||
|
||||
.celebration {
|
||||
outline: 6px solid #f2c94c;
|
||||
}
|
||||
|
||||
.admin-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
padding: 20px;
|
||||
background: #223044;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.sidebar .list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.sidebar button {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.roster {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.roster span {
|
||||
border: 1px solid #ccd4df;
|
||||
border-radius: 999px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.qr {
|
||||
width: min(280px, 100%);
|
||||
height: auto;
|
||||
background: #fff;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.shell, .workspace {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.admin-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root, body {
|
||||
background: #10141b;
|
||||
color: #edf1f7;
|
||||
}
|
||||
|
||||
.panel, input, textarea, select, .answer {
|
||||
background: #171d27;
|
||||
color: #edf1f7;
|
||||
border-color: #344052;
|
||||
}
|
||||
|
||||
button, .button {
|
||||
background: #263246;
|
||||
color: #edf1f7;
|
||||
border-color: #4a586d;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,94 @@
|
||||
"""Test fixtures."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.config import Settings
|
||||
from app.main import create_app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_pool():
|
||||
return {
|
||||
"title": "Sample Quiz",
|
||||
"score_fn": "linear_decay",
|
||||
"time_limit_default": 2,
|
||||
"questions": [
|
||||
{
|
||||
"id": "q1",
|
||||
"text": "First question?",
|
||||
"options": {"A": "Alpha", "B": "Beta", "C": "Gamma", "D": "Delta"},
|
||||
"correct": "B",
|
||||
"time_limit": 2,
|
||||
"explanation": "B is correct.",
|
||||
},
|
||||
{
|
||||
"id": "q2",
|
||||
"text": "Second question?",
|
||||
"options": {"A": "One", "B": "Two", "C": "Three", "D": "Four"},
|
||||
"correct": "C",
|
||||
"time_limit": 2,
|
||||
},
|
||||
{
|
||||
"id": "q3",
|
||||
"text": "Third question?",
|
||||
"options": {"A": "Red", "B": "Blue", "C": "Green", "D": "Gold"},
|
||||
"correct": "A",
|
||||
"time_limit": 2,
|
||||
},
|
||||
{
|
||||
"id": "q4",
|
||||
"text": "Fourth question?",
|
||||
"options": {"A": "North", "B": "South", "C": "East", "D": "West"},
|
||||
"correct": "D",
|
||||
"time_limit": 2,
|
||||
},
|
||||
{
|
||||
"id": "q5",
|
||||
"text": "Fifth question?",
|
||||
"options": {"A": "Fetch", "B": "Decode", "C": "Execute", "D": "Write"},
|
||||
"correct": "A",
|
||||
"time_limit": 1,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path):
|
||||
settings = Settings(
|
||||
db_path=str(tmp_path / "quiz.db"),
|
||||
secret_key="test-secret",
|
||||
admin_password="admin-pass",
|
||||
public_url="http://testserver",
|
||||
)
|
||||
app = create_app(settings)
|
||||
with TestClient(app) as test_client:
|
||||
yield test_client
|
||||
|
||||
|
||||
def admin_login(client: TestClient) -> None:
|
||||
response = client.post("/admin/login", json={"password": "admin-pass"})
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def create_quiz(client: TestClient, pool: dict) -> int:
|
||||
admin_login(client)
|
||||
response = client.post("/admin/api/quizzes", json={"pool_json": pool})
|
||||
assert response.status_code == 200, response.text
|
||||
return response.json()["quiz_id"]
|
||||
|
||||
|
||||
def create_session(client: TestClient, pool: dict) -> str:
|
||||
quiz_id = create_quiz(client, pool)
|
||||
response = client.post("/admin/api/sessions", json={"quiz_id": quiz_id})
|
||||
assert response.status_code == 200, response.text
|
||||
return response.json()["sid"]
|
||||
|
||||
|
||||
def join_student(client: TestClient, sid: str, student_id: str = "s1", name: str = "Student One") -> dict:
|
||||
response = client.post(f"/api/session/{sid}/join", json={"student_id": student_id, "name": name})
|
||||
assert response.status_code == 200, response.text
|
||||
return response.json()
|
||||
|
||||
2
tests/stress/.gitignore
vendored
Normal file
2
tests/stress/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
runs/
|
||||
34
tests/stress/README.md
Normal file
34
tests/stress/README.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Quiz portal stress harness
|
||||
|
||||
Adversarial frontend + API stress tests for the quiz portal. Built 2026-05-02.
|
||||
|
||||
## Files
|
||||
|
||||
- `lib.mjs` — shared helpers: server boot, cookie jar, `Student` and `Admin` WS wrappers, the fixed `STRESS_POOL`.
|
||||
- `api_stress.mjs` — pure WS adversarial scenarios (no browser): happy path with 20 concurrent students, late join, mid-question disconnect, **sleep/wake to next question** (the phone-screen-sleep scenario), cookie tampering, cross-session cookie reuse, duplicate student_id, bad submits (out-of-order, wrong idx, resubmit), close-boundary race, malformed-JSON fuzz, flaky reconnect.
|
||||
- `ui_stress.mjs` — Playwright/Chromium scenarios that exercise the real SPA: happy UI flow, sleep/wake by closing+reopening browser context with persisted cookie, cookie-tamper via `document.cookie`, two browsers with same student_id.
|
||||
- `run_loop.sh` — bash wrapper that runs `api_stress.mjs` every cycle and `ui_stress.mjs` every `UI_EVERY` cycles (default 5), with a fresh random seed each time. Logs JSON summary lines to `runs/summary.jsonl` and full output to `runs/run-<timestamp>.jsonl`.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# One-shot
|
||||
node api_stress.mjs # uses Date.now() seed
|
||||
node api_stress.mjs 12345 8210 # explicit seed + port
|
||||
node ui_stress.mjs # browser-based; HEADLESS=0 to watch
|
||||
|
||||
# Long-running loop in tmux
|
||||
tmux new -d -s quiz_stress 'cd /home/ameer/RD/Projects/Apps/quiz/tests/stress && bash run_loop.sh'
|
||||
tmux attach -t quiz_stress # to watch
|
||||
tmux send -t quiz_stress C-c # to stop
|
||||
```
|
||||
|
||||
Each cycle boots a fresh uvicorn on its own port and clean DB, runs scenarios, then tears down. Failures are recorded in the `failures` array of the per-cycle summary line.
|
||||
|
||||
## Known findings (tracked outside this dir)
|
||||
|
||||
- **Codex bug:** `app/room.py` `student_ws` (line ~87) and `instructor_ws` call `await websocket.receive_json()` whose JSON parsing can raise `JSONDecodeError`, but the surrounding `try/except` only catches `WebSocketDisconnect`. Result: a single malformed message kills that client's WS handler. The fuzz scenario in `api_stress.mjs` flags this consistently. Fix: wrap the receive in `try/except (JSONDecodeError, RuntimeError):` and either close cleanly or send `{"type":"error","code":"bad_message"}` and continue.
|
||||
|
||||
## Adding scenarios
|
||||
|
||||
Write an `async function name(server) { ... }` in `api_stress.mjs` (or `(server, browser)` for UI), add it to the `SCENARIOS` map / array, and re-run. Use `expect(cond, scenario, msg, extra)` for assertions and `note(scenario, msg)` for warnings that shouldn't fail the suite. **Critical pattern:** pre-register `waitFor` waiters BEFORE the action that triggers the message — `Student.waitFor(type)` only resolves on NEW messages, not cached ones, to avoid stale-state false passes.
|
||||
461
tests/stress/api_stress.mjs
Normal file
461
tests/stress/api_stress.mjs
Normal file
@@ -0,0 +1,461 @@
|
||||
// API-level adversarial stress tests for the quiz portal.
|
||||
// Each scenario boots a fresh server on its own port, runs assertions,
|
||||
// and logs JSON lines to stdout. Designed to be run repeatedly with
|
||||
// different seeds; see run_loop.sh for the wrapper.
|
||||
|
||||
import { bootServer, setupSession, Student, Admin, STRESS_POOL, sleep, logLine, rand, pickRandom } from "./lib.mjs";
|
||||
import WebSocket from "ws";
|
||||
|
||||
const SEED = parseInt(process.argv[2] || Date.now(), 10);
|
||||
const PORT = parseInt(process.argv[3] || (8200 + (SEED % 100)), 10);
|
||||
|
||||
let mulberry32 = (s) => () => {
|
||||
s |= 0; s = s + 0x6D2B79F5 | 0;
|
||||
let t = Math.imul(s ^ s >>> 15, 1 | s);
|
||||
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
|
||||
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
||||
};
|
||||
const rng = mulberry32(SEED);
|
||||
|
||||
let pass = 0, fail = 0, warn = 0;
|
||||
const failures = [];
|
||||
|
||||
function expect(cond, scenario, msg, extra = {}) {
|
||||
if (cond) { pass++; logLine(scenario, "pass", msg, extra); }
|
||||
else { fail++; failures.push({ scenario, msg, extra }); logLine(scenario, "fail", msg, extra); }
|
||||
}
|
||||
function note(scenario, msg, extra = {}) { warn++; logLine(scenario, "note", msg, extra); }
|
||||
|
||||
async function runScenario(name, fn) {
|
||||
logLine(name, "start", `seed=${SEED}`);
|
||||
let server;
|
||||
try {
|
||||
server = await bootServer({ port: PORT });
|
||||
await fn(server);
|
||||
logLine(name, "ok", "scenario completed");
|
||||
} catch (err) {
|
||||
fail++;
|
||||
failures.push({ scenario: name, msg: "uncaught", extra: { err: err.message, stack: err.stack?.slice(0, 600) } });
|
||||
logLine(name, "fail", "uncaught exception", { err: err.message });
|
||||
} finally {
|
||||
if (server) await server.stop();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Scenarios ----------
|
||||
|
||||
async function happyPath(server) {
|
||||
const { sid, jar } = await setupSession(server.url, server.adminPw, STRESS_POOL);
|
||||
const N = 20;
|
||||
const students = await Promise.all(Array.from({ length: N }, async (_, i) => {
|
||||
const s = new Student(server.url, sid, `S${i.toString().padStart(3, "0")}`, `Student${i}`);
|
||||
await s.join();
|
||||
await s.connect();
|
||||
return s;
|
||||
}));
|
||||
const admin = new Admin(server.url, sid, jar);
|
||||
await admin.connect();
|
||||
await admin.waitFor("lobby_update");
|
||||
|
||||
for (let q = 0; q < STRESS_POOL.questions.length; q++) {
|
||||
// Pre-register waiters so we don't lose the broadcast in the race window
|
||||
const studentOpenWaits = students.map(s => s.waitFor("question_open"));
|
||||
const adminOpenWait = admin.waitFor("question_open");
|
||||
admin.open(q, 5);
|
||||
await adminOpenWait;
|
||||
await Promise.all(studentOpenWaits);
|
||||
// Each student picks a random answer (mostly correct)
|
||||
await Promise.all(students.map(async (s, i) => {
|
||||
try {
|
||||
await sleep(rand(50, 800));
|
||||
const correct = STRESS_POOL.questions[q].correct;
|
||||
const ans = rng() < 0.7 ? correct : pickRandom(["A","B","C","D"]);
|
||||
const ackWait = s.waitFor("submit_ack", { timeoutMs: 3000 });
|
||||
s.submit(q, ans);
|
||||
const ack = await ackWait;
|
||||
expect(ack.question_idx === q, "happy", `student${i} q${q} ack idx==q`, { ack_idx: ack.question_idx, expected: q });
|
||||
} catch (e) {
|
||||
note("happy", `student${i} q${q}: ${e.message}`);
|
||||
}
|
||||
}));
|
||||
const studentClosedWaits = students.map(s => s.waitFor("question_closed", { timeoutMs: 3000 }).catch(() => null));
|
||||
const adminClosedWait = admin.waitFor("question_closed", { timeoutMs: 3000 });
|
||||
admin.close();
|
||||
await adminClosedWait;
|
||||
await Promise.all(studentClosedWaits);
|
||||
if (q < STRESS_POOL.questions.length - 1) {
|
||||
admin.next();
|
||||
await sleep(150);
|
||||
}
|
||||
}
|
||||
const sessionEndedWait = admin.waitFor("session_ended", { timeoutMs: 3000 });
|
||||
admin.end();
|
||||
await sessionEndedWait;
|
||||
expect(true, "happy", "session ended cleanly");
|
||||
students.forEach(s => s.disconnect());
|
||||
admin.disconnect();
|
||||
}
|
||||
|
||||
async function lateJoiners(server) {
|
||||
const { sid, jar } = await setupSession(server.url, server.adminPw, STRESS_POOL);
|
||||
const admin = new Admin(server.url, sid, jar);
|
||||
await admin.connect();
|
||||
// 5 students join in lobby
|
||||
const early = await Promise.all([0,1,2,3,4].map(async i => {
|
||||
const s = new Student(server.url, sid, `E${i}`, `Early${i}`);
|
||||
await s.join(); await s.connect(); return s;
|
||||
}));
|
||||
const adminOpenWait = admin.waitFor("question_open");
|
||||
admin.open(0, 8);
|
||||
await adminOpenWait;
|
||||
await sleep(2000);
|
||||
// 3 late joiners
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const s = new Student(server.url, sid, `L${i}`, `Late${i}`);
|
||||
await s.join();
|
||||
// Pre-register waiters BEFORE connect so we catch the snapshot on connect
|
||||
const stateWait = s.waitFor("state");
|
||||
const qopenWait = s.waitFor("question_open", { timeoutMs: 2000 });
|
||||
await s.connect();
|
||||
const m = await qopenWait.catch(() => null);
|
||||
if (!m) { fail++; failures.push({ scenario: "late_join", msg: `late${i} got no question_open on connect`, extra: {} }); logLine("late_join", "fail", `late${i} got no question_open on connect`); continue; }
|
||||
expect(m.question_idx === 0, "late_join", `late${i} sees correct idx`);
|
||||
expect(m.remaining_ms < 8000, "late_join", `late${i} remaining_ms reduced`, { remaining_ms: m.remaining_ms });
|
||||
expect(m.remaining_ms > 0, "late_join", `late${i} remaining_ms > 0`, { remaining_ms: m.remaining_ms });
|
||||
const ackWait = s.waitFor("submit_ack", { timeoutMs: 3000 });
|
||||
s.submit(0, STRESS_POOL.questions[0].correct);
|
||||
const ack = await ackWait.catch(() => null);
|
||||
expect(ack && ack.score > 0, "late_join", `late${i} got positive score from late submit`, { score: ack?.score });
|
||||
s.disconnect();
|
||||
}
|
||||
const adminClosedWait = admin.waitFor("question_closed");
|
||||
admin.close();
|
||||
await adminClosedWait;
|
||||
early.forEach(s => s.disconnect());
|
||||
admin.disconnect();
|
||||
}
|
||||
|
||||
async function disconnectMidQuestion(server) {
|
||||
const { sid, jar } = await setupSession(server.url, server.adminPw, STRESS_POOL);
|
||||
const admin = new Admin(server.url, sid, jar);
|
||||
await admin.connect();
|
||||
const s = new Student(server.url, sid, "D1", "Drop");
|
||||
await s.join();
|
||||
const stateWaitInitial = s.waitFor("state");
|
||||
await s.connect();
|
||||
await stateWaitInitial;
|
||||
const sQopenWait = s.waitFor("question_open");
|
||||
const aOpenWait = admin.waitFor("question_open");
|
||||
admin.open(0, 10);
|
||||
await aOpenWait;
|
||||
await sQopenWait;
|
||||
s.disconnect();
|
||||
await sleep(500);
|
||||
// While dropped, instructor closes
|
||||
const closedWait = admin.waitFor("question_closed");
|
||||
admin.close();
|
||||
await closedWait;
|
||||
// Reconnect — should get state=question_closed (or current state)
|
||||
const reconnectStateWait = s.waitFor("state", { timeoutMs: 2000 });
|
||||
await s.reconnect();
|
||||
const state = await reconnectStateWait;
|
||||
expect(["question_closed", "between_questions", "lobby"].includes(state.state), "disconnect_midq", `state on reconnect = ${state.state}`);
|
||||
s.disconnect();
|
||||
admin.disconnect();
|
||||
}
|
||||
|
||||
// THE KEY scenario from the user: phone screen sleeps mid-quiz, instructor
|
||||
// advances to a new question, phone wakes — the student MUST see the
|
||||
// LATEST question, not a stale screen.
|
||||
async function sleepWakeNextQuestion(server) {
|
||||
const { sid, jar } = await setupSession(server.url, server.adminPw, STRESS_POOL);
|
||||
const admin = new Admin(server.url, sid, jar);
|
||||
await admin.connect();
|
||||
const s = new Student(server.url, sid, "SW1", "SleepWake");
|
||||
await s.join();
|
||||
const initState = s.waitFor("state");
|
||||
await s.connect();
|
||||
await initState;
|
||||
// Open Q0
|
||||
let sWait = s.waitFor("question_open");
|
||||
let aWait = admin.waitFor("question_open");
|
||||
admin.open(0, 5);
|
||||
await aWait; await sWait;
|
||||
await sleep(500);
|
||||
const ackWait = s.waitFor("submit_ack");
|
||||
s.submit(0, "B");
|
||||
await ackWait;
|
||||
// Phone "sleeps" — drop WS hard
|
||||
s.disconnect();
|
||||
await sleep(800);
|
||||
// Instructor closes, advances, opens Q2 (skip Q1 to make wake state non-trivial)
|
||||
let closedW = admin.waitFor("question_closed");
|
||||
admin.close();
|
||||
await closedW;
|
||||
admin.next();
|
||||
await sleep(150);
|
||||
aWait = admin.waitFor("question_open");
|
||||
admin.open(2, 8);
|
||||
await aWait;
|
||||
await sleep(300);
|
||||
// Phone wakes — reconnect; pre-register both expected events
|
||||
const stateOnWake = s.waitFor("state", { timeoutMs: 2000 });
|
||||
const qopenOnWake = s.waitFor("question_open", { timeoutMs: 2000 });
|
||||
await s.reconnect();
|
||||
const state = await stateOnWake;
|
||||
expect(state.state === "question_open", "sleep_wake", `state on wake = ${state.state}`, { state });
|
||||
expect(state.current_question_idx === 2, "sleep_wake", `current_question_idx on wake = ${state.current_question_idx}`);
|
||||
const qopen = await qopenOnWake.catch(() => null);
|
||||
expect(qopen && qopen.question_idx === 2, "sleep_wake", "reconnect emits question_open for current idx", { qopen_idx: qopen?.question_idx });
|
||||
expect(qopen && qopen.text === STRESS_POOL.questions[2].text, "sleep_wake", "question text matches latest Q");
|
||||
if (qopen) {
|
||||
const ack2W = s.waitFor("submit_ack", { timeoutMs: 3000 });
|
||||
s.submit(2, "B");
|
||||
const ack = await ack2W.catch(() => null);
|
||||
expect(ack && ack.question_idx === 2, "sleep_wake", "submit on Q2 after wake succeeded", { ack_idx: ack?.question_idx });
|
||||
}
|
||||
s.disconnect();
|
||||
admin.disconnect();
|
||||
}
|
||||
|
||||
// Cookie tampering: try to flip student_id by mangling the signed cookie
|
||||
async function cookieTampering(server) {
|
||||
const { sid } = await setupSession(server.url, server.adminPw, STRESS_POOL);
|
||||
const s = new Student(server.url, sid, "ORIG", "Original");
|
||||
await s.join();
|
||||
// Tamper: append a junk byte
|
||||
const original = s.jar.get("qz_student");
|
||||
s.jar.set("qz_student", original + "X");
|
||||
// Try to connect WS
|
||||
let connected = false, closeCode = null;
|
||||
try {
|
||||
await new Promise((res, rej) => {
|
||||
const wsUrl = server.url.replace(/^http/, "ws") + `/ws/student/${sid}`;
|
||||
const w = new WebSocket(wsUrl, { headers: { Cookie: s.jar.header() } });
|
||||
w.on("open", () => { connected = true; w.close(); res(); });
|
||||
w.on("close", (c) => { closeCode = c; res(); });
|
||||
w.on("error", () => res());
|
||||
setTimeout(res, 1500);
|
||||
});
|
||||
} catch {}
|
||||
expect(!connected || closeCode === 4001 || closeCode === 1006 || closeCode === 1008, "cookie_tamper", "tampered cookie rejected on WS", { connected, closeCode });
|
||||
// Reset cookie and retry
|
||||
s.jar.set("qz_student", original);
|
||||
await s.connect();
|
||||
await s.waitFor("state");
|
||||
expect(true, "cookie_tamper", "valid cookie still works after tamper attempt");
|
||||
s.disconnect();
|
||||
}
|
||||
|
||||
// Cross-session cookie: cookie from session A should not work on session B.
|
||||
async function crossSessionCookie(server) {
|
||||
const { sid: sidA, jar: jarA } = await setupSession(server.url, server.adminPw, STRESS_POOL);
|
||||
const { sid: sidB } = await setupSession(server.url, server.adminPw, STRESS_POOL);
|
||||
const s = new Student(server.url, sidA, "X1", "CrossUser");
|
||||
await s.join();
|
||||
// Try to use sidA's cookie to access sidB
|
||||
const wsUrl = server.url.replace(/^http/, "ws") + `/ws/student/${sidB}`;
|
||||
let opened = false;
|
||||
await new Promise(res => {
|
||||
const w = new WebSocket(wsUrl, { headers: { Cookie: s.jar.header() } });
|
||||
w.on("open", () => { opened = true; w.close(); res(); });
|
||||
w.on("close", () => res());
|
||||
w.on("error", () => res());
|
||||
setTimeout(res, 1500);
|
||||
});
|
||||
expect(!opened, "cross_session", "cookie from sidA rejected when used against sidB", { opened });
|
||||
}
|
||||
|
||||
// Duplicate student_id: two browsers join with same student_id (different cookies)
|
||||
async function duplicateStudentId(server) {
|
||||
const { sid } = await setupSession(server.url, server.adminPw, STRESS_POOL);
|
||||
const s1 = new Student(server.url, sid, "DUP", "FirstSession");
|
||||
const s2 = new Student(server.url, sid, "DUP", "SecondSession");
|
||||
await s1.join();
|
||||
const s1State = s1.waitFor("state");
|
||||
await s1.connect();
|
||||
await s1State;
|
||||
await s2.join();
|
||||
const s2State = s2.waitFor("state");
|
||||
await s2.connect();
|
||||
await s2State;
|
||||
expect(s1.ws.readyState === WebSocket.OPEN, "dup_id", "first DUP session open");
|
||||
expect(s2.ws.readyState === WebSocket.OPEN, "dup_id", "second DUP session open");
|
||||
s1.disconnect(); s2.disconnect();
|
||||
}
|
||||
|
||||
// Submit out-of-order / wrong question_idx
|
||||
async function badSubmits(server) {
|
||||
const { sid, jar } = await setupSession(server.url, server.adminPw, STRESS_POOL);
|
||||
const admin = new Admin(server.url, sid, jar); await admin.connect();
|
||||
const s = new Student(server.url, sid, "BAD", "Bad");
|
||||
await s.join();
|
||||
const initState = s.waitFor("state");
|
||||
await s.connect();
|
||||
await initState;
|
||||
// Submit before any question opened
|
||||
s.submit(0, "A");
|
||||
await sleep(300);
|
||||
expect(!s.lastMsgByType.submit_ack, "bad_submit", "submit before open ignored / no ack", { last: Object.keys(s.lastMsgByType) });
|
||||
let sWait = s.waitFor("question_open");
|
||||
let aWait = admin.waitFor("question_open");
|
||||
admin.open(0, 5);
|
||||
await aWait; await sWait;
|
||||
// Wrong idx submit (give it 600 ms to arrive if it does)
|
||||
const ackBefore = s.lastMsgByType.submit_ack;
|
||||
s.submit(99, "A");
|
||||
await sleep(600);
|
||||
const ackAfter = s.lastMsgByType.submit_ack;
|
||||
expect(ackAfter === ackBefore || (ackAfter && ackAfter.question_idx !== 99), "bad_submit", "wrong-idx submit not acked", { ackAfter });
|
||||
// Valid submit
|
||||
const okWait = s.waitFor("submit_ack", { timeoutMs: 2000 });
|
||||
s.submit(0, "B");
|
||||
const ok = await okWait;
|
||||
expect(ok.question_idx === 0, "bad_submit", "valid submit acked");
|
||||
// Resubmit (already submitted) — should NOT change stored answer
|
||||
s.submit(0, "A");
|
||||
await sleep(400);
|
||||
const closedWait = s.waitFor("question_closed");
|
||||
admin.close();
|
||||
const closed = await closedWait;
|
||||
expect(closed.your_answer === "B", "bad_submit", "your_answer remained 'B' after resubmit attempt", { your_answer: closed.your_answer });
|
||||
s.disconnect(); admin.disconnect();
|
||||
}
|
||||
|
||||
// Race: many students submit at the moment of close (within ms)
|
||||
async function closeBoundaryRace(server) {
|
||||
const { sid, jar } = await setupSession(server.url, server.adminPw, STRESS_POOL);
|
||||
const admin = new Admin(server.url, sid, jar); await admin.connect();
|
||||
const N = 30;
|
||||
const students = await Promise.all(Array.from({ length: N }, async (_, i) => {
|
||||
const s = new Student(server.url, sid, `R${i}`, `Race${i}`);
|
||||
await s.join(); await s.connect(); return s;
|
||||
}));
|
||||
// Pre-register everyone's question_open + question_closed waits
|
||||
const sOpens = students.map(s => s.waitFor("question_open", { timeoutMs: 3000 }).catch(() => null));
|
||||
const aOpen = admin.waitFor("question_open", { timeoutMs: 3000 });
|
||||
// Pre-register the closed wait too — auto-close fires even without manual close()
|
||||
const aClosed = admin.waitFor("question_closed", { timeoutMs: 6000 });
|
||||
admin.open(0, 1); // 1-second window — auto-close should fire ~1s later
|
||||
await aOpen;
|
||||
await Promise.all(sOpens);
|
||||
// Have all students fire submit at random times spanning the window edge
|
||||
const fires = students.map(async (s, i) => {
|
||||
const ackW = s.waitFor("submit_ack", { timeoutMs: 2000 });
|
||||
await sleep(rand(800, 1200)); // some fire after auto-close
|
||||
s.submit(0, "B");
|
||||
return ackW.catch(() => null);
|
||||
});
|
||||
const acks = await Promise.all(fires);
|
||||
const acked = acks.filter(Boolean).length;
|
||||
const closed = await aClosed.catch(() => null);
|
||||
logLine("close_race", "info", `race results`, { acked, total: N, hist: closed?.histogram });
|
||||
expect(closed !== null, "close_race", "question_closed broadcast received (auto-close or manual)");
|
||||
expect(acked >= 1 && acked <= N, "close_race", "no crash, some submits succeeded");
|
||||
students.forEach(s => s.disconnect()); admin.disconnect();
|
||||
}
|
||||
|
||||
// Fuzz: malformed messages from a student WS
|
||||
async function fuzzMessages(server) {
|
||||
const { sid, jar } = await setupSession(server.url, server.adminPw, STRESS_POOL);
|
||||
const admin = new Admin(server.url, sid, jar); await admin.connect();
|
||||
// First: a sentinel student that does NOT receive fuzz, to verify global server health
|
||||
const sentinel = new Student(server.url, sid, "SENT", "Sentinel");
|
||||
await sentinel.join();
|
||||
const sentinelStateW = sentinel.waitFor("state");
|
||||
await sentinel.connect();
|
||||
await sentinelStateW;
|
||||
// Second: a fuzz student that gets garbage shoved at it
|
||||
const s = new Student(server.url, sid, "FZ", "Fuzz");
|
||||
await s.join();
|
||||
const fzStateW = s.waitFor("state");
|
||||
await s.connect();
|
||||
await fzStateW;
|
||||
const garbages = [
|
||||
"not json",
|
||||
"{}",
|
||||
JSON.stringify({ type: "open_question" }), // student trying to act as instructor
|
||||
JSON.stringify({ type: "submit", question_idx: -1, answer: "Z" }),
|
||||
JSON.stringify({ type: "submit", question_idx: 0, answer: { nested: "obj" } }),
|
||||
JSON.stringify({ type: "💀" }),
|
||||
"x".repeat(50_000),
|
||||
];
|
||||
for (const g of garbages) { try { s.ws.send(g); } catch {} ; await sleep(50); }
|
||||
await sleep(500);
|
||||
// Server should still serve OTHER clients regardless of what happened to fuzz student.
|
||||
const sentOpenW = sentinel.waitFor("question_open", { timeoutMs: 2000 });
|
||||
const adminOpenW = admin.waitFor("question_open", { timeoutMs: 2000 });
|
||||
admin.open(0, 3);
|
||||
await adminOpenW;
|
||||
const sm = await sentOpenW.catch(() => null);
|
||||
expect(sm && sm.question_idx === 0, "fuzz", "OTHER clients still served after fuzz on one student", { got: !!sm });
|
||||
// Did the fuzz student survive? (informational, not asserted as pass/fail)
|
||||
const survived = !s.closed && s.ws.readyState === WebSocket.OPEN;
|
||||
logLine("fuzz", "info", `fuzz student survival`, { survived, ws_state: s.ws.readyState });
|
||||
if (!survived) note("fuzz", "fuzz student WS was killed by malformed input — server lacks JSON-decode try/except in WS loop (codex room.py student_ws line ~87)");
|
||||
const adminClosedW = admin.waitFor("question_closed", { timeoutMs: 5000 });
|
||||
admin.close();
|
||||
await adminClosedW.catch(() => null);
|
||||
s.disconnect(); sentinel.disconnect(); admin.disconnect();
|
||||
}
|
||||
|
||||
// Repeated rapid connect/disconnect (simulating flaky network)
|
||||
async function flakyReconnect(server) {
|
||||
const { sid, jar } = await setupSession(server.url, server.adminPw, STRESS_POOL);
|
||||
const admin = new Admin(server.url, sid, jar); await admin.connect();
|
||||
const s = new Student(server.url, sid, "FK", "Flaky");
|
||||
await s.join();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const stateW = s.waitFor("state", { timeoutMs: 2000 });
|
||||
await s.connect();
|
||||
await stateW.catch(() => {});
|
||||
s.disconnect();
|
||||
await sleep(rand(50, 200));
|
||||
}
|
||||
// Final reconnect
|
||||
const finalStateW = s.waitFor("state", { timeoutMs: 2000 });
|
||||
await s.connect();
|
||||
await finalStateW;
|
||||
const sQopenW = s.waitFor("question_open");
|
||||
const aQopenW = admin.waitFor("question_open");
|
||||
admin.open(0, 5);
|
||||
await aQopenW; await sQopenW;
|
||||
const ackW = s.waitFor("submit_ack", { timeoutMs: 3000 });
|
||||
s.submit(0, "B");
|
||||
const ack = await ackW;
|
||||
expect(ack !== null, "flaky_reconnect", "post-flaky student can still submit");
|
||||
const closedW = admin.waitFor("question_closed");
|
||||
admin.close();
|
||||
await closedW;
|
||||
s.disconnect(); admin.disconnect();
|
||||
}
|
||||
|
||||
const SCENARIOS = {
|
||||
happy: happyPath,
|
||||
late_join: lateJoiners,
|
||||
disconnect_midq: disconnectMidQuestion,
|
||||
sleep_wake: sleepWakeNextQuestion,
|
||||
cookie_tamper: cookieTampering,
|
||||
cross_session: crossSessionCookie,
|
||||
dup_id: duplicateStudentId,
|
||||
bad_submit: badSubmits,
|
||||
close_race: closeBoundaryRace,
|
||||
fuzz: fuzzMessages,
|
||||
flaky_reconnect: flakyReconnect,
|
||||
};
|
||||
|
||||
// Pick scenario subset based on env or run all in random order
|
||||
const wanted = process.env.SCENARIOS
|
||||
? process.env.SCENARIOS.split(",")
|
||||
: Object.keys(SCENARIOS).sort(() => rng() - 0.5);
|
||||
|
||||
logLine("runner", "info", `starting api stress`, { seed: SEED, port: PORT, scenarios: wanted });
|
||||
|
||||
for (const name of wanted) {
|
||||
const fn = SCENARIOS[name];
|
||||
if (!fn) { logLine("runner", "warn", `unknown scenario ${name}`); continue; }
|
||||
await runScenario(name, fn);
|
||||
}
|
||||
|
||||
logLine("runner", "summary", `done`, { pass, fail, warn, failures });
|
||||
process.exit(fail > 0 ? 1 : 0);
|
||||
256
tests/stress/lib.mjs
Normal file
256
tests/stress/lib.mjs
Normal file
@@ -0,0 +1,256 @@
|
||||
// Shared helpers for quiz portal stress tests.
|
||||
// Boots a fresh uvicorn server, logs in as admin, creates quiz + session.
|
||||
// Provides a Student class that wraps an authenticated WS + cookie state.
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import WebSocket from "ws";
|
||||
|
||||
const QUIZ_ROOT = "/home/ameer/RD/Projects/Apps/quiz";
|
||||
const PORT_BASE = 8200;
|
||||
|
||||
export function nowMs() { return Date.now(); }
|
||||
export function logLine(scenario, level, msg, extra = {}) {
|
||||
const rec = { ts: new Date().toISOString(), scenario, level, msg, ...extra };
|
||||
process.stdout.write(JSON.stringify(rec) + "\n");
|
||||
}
|
||||
|
||||
export function pickRandom(arr) { return arr[Math.floor(Math.random() * arr.length)]; }
|
||||
export function rand(min, max) { return Math.random() * (max - min) + min; }
|
||||
|
||||
// Boot a fresh server on its own port + DB. Returns { url, stop }.
|
||||
export async function bootServer({ port, secret = "stress-secret-12345678", adminPw = "stresspw" } = {}) {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "quiz-stress-"));
|
||||
const dbPath = join(tmp, "stress.db");
|
||||
const env = {
|
||||
...process.env,
|
||||
QUIZ_DB_PATH: dbPath,
|
||||
QUIZ_SECRET_KEY: secret,
|
||||
QUIZ_ADMIN_PASSWORD: adminPw,
|
||||
QUIZ_HOST: "127.0.0.1",
|
||||
QUIZ_PORT: String(port),
|
||||
QUIZ_PUBLIC_URL: `http://127.0.0.1:${port}`,
|
||||
};
|
||||
const proc = spawn(
|
||||
`${QUIZ_ROOT}/.venv/bin/uvicorn`,
|
||||
["app.main:app", "--host", "127.0.0.1", "--port", String(port), "--log-level", "warning"],
|
||||
{ cwd: QUIZ_ROOT, env, stdio: ["ignore", "pipe", "pipe"] },
|
||||
);
|
||||
// Pipe server stderr to our stderr so panics are visible
|
||||
proc.stderr.on("data", chunk => process.stderr.write(`[server:${port}] ${chunk}`));
|
||||
|
||||
const url = `http://127.0.0.1:${port}`;
|
||||
// Wait for /healthz
|
||||
const deadline = Date.now() + 20_000;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const r = await fetch(`${url}/healthz`);
|
||||
if (r.ok) {
|
||||
return {
|
||||
url,
|
||||
adminPw,
|
||||
stop: () => new Promise(res => {
|
||||
proc.once("exit", () => { rmSync(tmp, { recursive: true, force: true }); res(); });
|
||||
proc.kill("SIGTERM");
|
||||
// Hard-kill fallback
|
||||
setTimeout(() => proc.kill("SIGKILL"), 2000);
|
||||
}),
|
||||
};
|
||||
}
|
||||
} catch {}
|
||||
await sleep(150);
|
||||
}
|
||||
proc.kill("SIGKILL");
|
||||
throw new Error(`server on ${port} did not come up`);
|
||||
}
|
||||
|
||||
// Cookie jar helper - parses Set-Cookie headers from fetch response.
|
||||
export class CookieJar {
|
||||
constructor() { this.jar = new Map(); }
|
||||
ingest(response) {
|
||||
const raw = response.headers.getSetCookie?.() || (response.headers.get("set-cookie") ? [response.headers.get("set-cookie")] : []);
|
||||
for (const line of raw) {
|
||||
const [pair] = line.split(";");
|
||||
const eq = pair.indexOf("=");
|
||||
if (eq > 0) this.jar.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
|
||||
}
|
||||
}
|
||||
header() {
|
||||
return [...this.jar.entries()].map(([k, v]) => `${k}=${v}`).join("; ");
|
||||
}
|
||||
get(name) { return this.jar.get(name); }
|
||||
set(name, value) { this.jar.set(name, value); }
|
||||
clear() { this.jar.clear(); }
|
||||
}
|
||||
|
||||
export async function jsonReq(method, url, { jar, body, headers = {} } = {}) {
|
||||
const opts = { method, headers: { ...headers } };
|
||||
if (jar) opts.headers["Cookie"] = jar.header();
|
||||
if (body !== undefined) {
|
||||
opts.body = JSON.stringify(body);
|
||||
opts.headers["Content-Type"] = "application/json";
|
||||
}
|
||||
const r = await fetch(url, opts);
|
||||
if (jar) jar.ingest(r);
|
||||
let data = null;
|
||||
const txt = await r.text();
|
||||
try { data = txt ? JSON.parse(txt) : null; } catch { data = txt; }
|
||||
return { status: r.status, ok: r.ok, data, headers: r.headers };
|
||||
}
|
||||
|
||||
// Build admin session: login + upload pool + create session. Returns { sid, jar }.
|
||||
export async function setupSession(serverUrl, adminPw, pool) {
|
||||
const jar = new CookieJar();
|
||||
const login = await jsonReq("POST", `${serverUrl}/admin/login`, { jar, body: { password: adminPw } });
|
||||
if (!login.ok) throw new Error(`admin login failed: ${login.status} ${JSON.stringify(login.data)}`);
|
||||
const create = await jsonReq("POST", `${serverUrl}/admin/api/quizzes`, { jar, body: { pool_json: pool } });
|
||||
if (!create.ok) throw new Error(`quiz create failed: ${create.status} ${JSON.stringify(create.data)}`);
|
||||
const sess = await jsonReq("POST", `${serverUrl}/admin/api/sessions`, { jar, body: { quiz_id: create.data.quiz_id } });
|
||||
if (!sess.ok) throw new Error(`session create failed: ${sess.status} ${JSON.stringify(sess.data)}`);
|
||||
return { sid: sess.data.sid, jar };
|
||||
}
|
||||
|
||||
// Student wrapper: join + connect WS + collect messages.
|
||||
export class Student {
|
||||
constructor(serverUrl, sid, studentId, name) {
|
||||
this.serverUrl = serverUrl;
|
||||
this.sid = sid;
|
||||
this.studentId = studentId;
|
||||
this.name = name;
|
||||
this.jar = new CookieJar();
|
||||
this.ws = null;
|
||||
this.messages = [];
|
||||
this.lastMsgByType = {};
|
||||
this.events = new EventTarget();
|
||||
this.closed = false;
|
||||
}
|
||||
async join() {
|
||||
const r = await jsonReq("POST", `${this.serverUrl}/api/session/${this.sid}/join`, {
|
||||
jar: this.jar,
|
||||
body: { student_id: this.studentId, name: this.name },
|
||||
});
|
||||
if (!r.ok) throw new Error(`student join failed: ${r.status} ${JSON.stringify(r.data)}`);
|
||||
return r;
|
||||
}
|
||||
connect() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const wsUrl = this.serverUrl.replace(/^http/, "ws") + `/ws/student/${this.sid}`;
|
||||
const headers = { Cookie: this.jar.header() };
|
||||
this.ws = new WebSocket(wsUrl, { headers });
|
||||
this.ws.on("open", () => resolve());
|
||||
this.ws.on("message", buf => {
|
||||
let msg;
|
||||
try { msg = JSON.parse(buf.toString()); } catch { return; }
|
||||
this.messages.push({ ts: nowMs(), msg });
|
||||
this.lastMsgByType[msg.type] = msg;
|
||||
this.events.dispatchEvent(new CustomEvent("msg", { detail: msg }));
|
||||
});
|
||||
this.ws.on("close", (code, reason) => {
|
||||
this.closed = true;
|
||||
this.events.dispatchEvent(new CustomEvent("close", { detail: { code, reason: reason.toString() } }));
|
||||
});
|
||||
this.ws.on("error", err => reject(err));
|
||||
});
|
||||
}
|
||||
// Wait until a NEW message of the given type arrives (does not use cache).
|
||||
// Use lastMsgByType[type] to inspect cached values without waiting.
|
||||
waitFor(type, { timeoutMs = 5000, useCache = false } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (useCache && this.lastMsgByType[type]) return resolve(this.lastMsgByType[type]);
|
||||
const handler = ev => {
|
||||
if (ev.detail?.type === type) {
|
||||
this.events.removeEventListener("msg", handler);
|
||||
clearTimeout(timer);
|
||||
resolve(ev.detail);
|
||||
}
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
this.events.removeEventListener("msg", handler);
|
||||
reject(new Error(`timed out waiting for WS type=${type} after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
this.events.addEventListener("msg", handler);
|
||||
});
|
||||
}
|
||||
send(obj) {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(obj));
|
||||
}
|
||||
submit(qIdx, answer) {
|
||||
this.send({ type: "submit", question_idx: qIdx, answer });
|
||||
}
|
||||
disconnect() {
|
||||
if (this.ws && !this.closed) {
|
||||
try { this.ws.terminate(); } catch {}
|
||||
}
|
||||
}
|
||||
async reconnect() {
|
||||
this.closed = false;
|
||||
this.lastMsgByType = {};
|
||||
await this.connect();
|
||||
}
|
||||
}
|
||||
|
||||
// Admin WS wrapper.
|
||||
export class Admin {
|
||||
constructor(serverUrl, sid, jar) {
|
||||
this.serverUrl = serverUrl;
|
||||
this.sid = sid;
|
||||
this.jar = jar;
|
||||
this.ws = null;
|
||||
this.messages = [];
|
||||
this.events = new EventTarget();
|
||||
}
|
||||
connect() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const wsUrl = this.serverUrl.replace(/^http/, "ws") + `/ws/instructor/${this.sid}`;
|
||||
this.ws = new WebSocket(wsUrl, { headers: { Cookie: this.jar.header() } });
|
||||
this.ws.on("open", () => resolve());
|
||||
this.ws.on("message", buf => {
|
||||
let msg;
|
||||
try { msg = JSON.parse(buf.toString()); } catch { return; }
|
||||
this.messages.push({ ts: nowMs(), msg });
|
||||
this.events.dispatchEvent(new CustomEvent("msg", { detail: msg }));
|
||||
});
|
||||
this.ws.on("error", err => reject(err));
|
||||
});
|
||||
}
|
||||
open(qIdx, timeLimit = 60) { this.ws.send(JSON.stringify({ type: "open_question", question_idx: qIdx, time_limit: timeLimit })); }
|
||||
close() { this.ws.send(JSON.stringify({ type: "close_question" })); }
|
||||
next() { this.ws.send(JSON.stringify({ type: "next" })); }
|
||||
end() { this.ws.send(JSON.stringify({ type: "end_session" })); }
|
||||
waitFor(type, { timeoutMs = 5000 } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const handler = ev => {
|
||||
if (ev.detail?.type === type) {
|
||||
this.events.removeEventListener("msg", handler);
|
||||
clearTimeout(timer);
|
||||
resolve(ev.detail);
|
||||
}
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
this.events.removeEventListener("msg", handler);
|
||||
reject(new Error(`admin timed out waiting for type=${type} after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
this.events.addEventListener("msg", handler);
|
||||
});
|
||||
}
|
||||
disconnect() { try { this.ws?.terminate(); } catch {} }
|
||||
}
|
||||
|
||||
// A small fixed pool used for stress runs.
|
||||
export const STRESS_POOL = {
|
||||
title: "Stress Pool",
|
||||
score_fn: "linear_decay",
|
||||
time_limit_default: 10,
|
||||
questions: [
|
||||
{ id: "s1", text: "2+2?", options: { A: "3", B: "4", C: "5", D: "6" }, correct: "B", explanation: "" },
|
||||
{ id: "s2", text: "Capital of France?", options: { A: "Berlin", B: "Madrid", C: "Paris", D: "Rome" }, correct: "C", explanation: "" },
|
||||
{ id: "s3", text: "Fastest sort?", options: { A: "Bubble", B: "Quick", C: "Insertion", D: "Selection" }, correct: "B", explanation: "" },
|
||||
{ id: "s4", text: "HTTP code for not found?", options: { A: "200", B: "301", C: "404", D: "500" }, correct: "C", explanation: "" },
|
||||
{ id: "s5", text: "Speed of light (m/s)?", options: { A: "3e8", B: "3e6", C: "1.5e8", D: "9.8" }, correct: "A", explanation: "" },
|
||||
],
|
||||
};
|
||||
|
||||
export { sleep };
|
||||
81
tests/stress/package-lock.json
generated
Normal file
81
tests/stress/package-lock.json
generated
Normal file
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"name": "quiz-stress",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "quiz-stress",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"playwright": "^1.58.2",
|
||||
"ws": "^8.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
|
||||
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.59.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
|
||||
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
||||
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
14
tests/stress/package.json
Normal file
14
tests/stress/package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "quiz-stress",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"api": "node api_stress.mjs",
|
||||
"ui": "node ui_stress.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"ws": "^8.18.0",
|
||||
"playwright": "^1.58.2"
|
||||
}
|
||||
}
|
||||
66
tests/stress/run_loop.sh
Executable file
66
tests/stress/run_loop.sh
Executable file
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env bash
|
||||
# Loop runner for the stress harness.
|
||||
# Runs api_stress.mjs each cycle with a fresh random seed, and runs ui_stress.mjs
|
||||
# every $UI_EVERY cycles (default 5). Logs JSON lines to runs/<timestamp>.jsonl.
|
||||
# Run this in tmux: tmux new -d -s quiz_stress 'bash run_loop.sh'
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
mkdir -p runs
|
||||
|
||||
UI_EVERY=${UI_EVERY:-5}
|
||||
SLEEP_BETWEEN=${SLEEP_BETWEEN:-3}
|
||||
LOG="runs/run-$(date -u +%Y%m%dT%H%M%SZ).jsonl"
|
||||
SUM="runs/summary.jsonl"
|
||||
|
||||
echo "{\"event\":\"loop_start\",\"ts\":\"$(date -u +%FT%TZ)\",\"log\":\"$LOG\",\"ui_every\":$UI_EVERY}" | tee -a "$SUM"
|
||||
|
||||
cycle=0
|
||||
total_pass=0
|
||||
total_fail=0
|
||||
total_warn=0
|
||||
|
||||
trap 'echo "{\"event\":\"loop_stop\",\"ts\":\"$(date -u +%FT%TZ)\",\"cycles\":'$cycle',\"total_pass\":'$total_pass',\"total_fail\":'$total_fail',\"total_warn\":'$total_warn'}" | tee -a "$SUM"; exit 0' INT TERM
|
||||
|
||||
while true; do
|
||||
cycle=$((cycle + 1))
|
||||
seed=$(( (RANDOM * 32768 + RANDOM) % 1000000 ))
|
||||
port=$((8200 + (cycle % 50)))
|
||||
|
||||
printf '\n----- cycle %d (seed=%d port=%d) api -----\n' "$cycle" "$seed" "$port" | tee -a "$LOG"
|
||||
out=$(timeout 120 node api_stress.mjs "$seed" "$port" 2>&1)
|
||||
echo "$out" | tee -a "$LOG" >/dev/null
|
||||
summary=$(echo "$out" | grep '"runner"' | grep '"summary"' | tail -1)
|
||||
if [ -n "$summary" ]; then
|
||||
p=$(echo "$summary" | sed -n 's/.*"pass":\([0-9]*\).*/\1/p')
|
||||
f=$(echo "$summary" | sed -n 's/.*"fail":\([0-9]*\).*/\1/p')
|
||||
w=$(echo "$summary" | sed -n 's/.*"warn":\([0-9]*\).*/\1/p')
|
||||
total_pass=$((total_pass + ${p:-0}))
|
||||
total_fail=$((total_fail + ${f:-0}))
|
||||
total_warn=$((total_warn + ${w:-0}))
|
||||
echo "{\"event\":\"cycle\",\"ts\":\"$(date -u +%FT%TZ)\",\"cycle\":$cycle,\"kind\":\"api\",\"seed\":$seed,\"port\":$port,\"pass\":${p:-0},\"fail\":${f:-0},\"warn\":${w:-0},\"running_pass\":$total_pass,\"running_fail\":$total_fail,\"running_warn\":$total_warn}" | tee -a "$SUM"
|
||||
else
|
||||
echo "{\"event\":\"cycle\",\"ts\":\"$(date -u +%FT%TZ)\",\"cycle\":$cycle,\"kind\":\"api\",\"seed\":$seed,\"port\":$port,\"status\":\"NO_SUMMARY\"}" | tee -a "$SUM"
|
||||
fi
|
||||
|
||||
if [ $((cycle % UI_EVERY)) -eq 0 ]; then
|
||||
printf '\n----- cycle %d (seed=%d port=%d) ui -----\n' "$cycle" "$seed" "$((port + 100))" | tee -a "$LOG"
|
||||
out=$(timeout 180 node ui_stress.mjs "$seed" "$((port + 100))" 2>&1)
|
||||
echo "$out" | tee -a "$LOG" >/dev/null
|
||||
summary=$(echo "$out" | grep '"runner"' | grep '"summary"' | tail -1)
|
||||
if [ -n "$summary" ]; then
|
||||
p=$(echo "$summary" | sed -n 's/.*"pass":\([0-9]*\).*/\1/p')
|
||||
f=$(echo "$summary" | sed -n 's/.*"fail":\([0-9]*\).*/\1/p')
|
||||
w=$(echo "$summary" | sed -n 's/.*"warn":\([0-9]*\).*/\1/p')
|
||||
total_pass=$((total_pass + ${p:-0}))
|
||||
total_fail=$((total_fail + ${f:-0}))
|
||||
total_warn=$((total_warn + ${w:-0}))
|
||||
echo "{\"event\":\"cycle\",\"ts\":\"$(date -u +%FT%TZ)\",\"cycle\":$cycle,\"kind\":\"ui\",\"seed\":$seed,\"port\":$((port + 100)),\"pass\":${p:-0},\"fail\":${f:-0},\"warn\":${w:-0},\"running_pass\":$total_pass,\"running_fail\":$total_fail,\"running_warn\":$total_warn}" | tee -a "$SUM"
|
||||
else
|
||||
echo "{\"event\":\"cycle\",\"ts\":\"$(date -u +%FT%TZ)\",\"cycle\":$cycle,\"kind\":\"ui\",\"seed\":$seed,\"port\":$((port + 100)),\"status\":\"NO_SUMMARY\"}" | tee -a "$SUM"
|
||||
fi
|
||||
fi
|
||||
|
||||
sleep "$SLEEP_BETWEEN"
|
||||
done
|
||||
181
tests/stress/ui_stress.mjs
Normal file
181
tests/stress/ui_stress.mjs
Normal file
@@ -0,0 +1,181 @@
|
||||
// UI-side stress: real Chromium browser contexts driving the SPA.
|
||||
// Tests scenarios that only matter at the JS layer:
|
||||
// - happy path through the student SPA UI
|
||||
// - sleep/wake (browser context closed mid-quiz, instructor advances, browser reopens)
|
||||
// - cookie tampering via document.cookie
|
||||
// - simultaneous browsers with same student_id
|
||||
// Boots its own server. Slower than api_stress but exercises real DOM rendering.
|
||||
|
||||
import { bootServer, setupSession, STRESS_POOL, sleep, logLine, jsonReq, CookieJar, Admin } from "./lib.mjs";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const SEED = parseInt(process.argv[2] || Date.now(), 10);
|
||||
const PORT = parseInt(process.argv[3] || (8300 + (SEED % 100)), 10);
|
||||
const HEADLESS = process.env.HEADLESS !== "0";
|
||||
|
||||
let pass = 0, fail = 0, warn = 0;
|
||||
const failures = [];
|
||||
|
||||
function expect(cond, scenario, msg, extra = {}) {
|
||||
if (cond) { pass++; logLine(scenario, "pass", msg, extra); }
|
||||
else { fail++; failures.push({ scenario, msg, extra }); logLine(scenario, "fail", msg, extra); }
|
||||
}
|
||||
function note(scenario, msg, extra = {}) { warn++; logLine(scenario, "note", msg, extra); }
|
||||
|
||||
async function joinAsStudent(page, baseUrl, sid, sid_id, name) {
|
||||
await page.goto(`${baseUrl}/?sid=${sid}`);
|
||||
await page.waitForSelector('input[name="student_id"], input[id*=student]', { timeout: 5000 });
|
||||
// Codex SPA uses input[name=student_id]
|
||||
const idInput = await page.$('input[name="student_id"]');
|
||||
const nameInput = await page.$('input[name="name"]');
|
||||
if (!idInput || !nameInput) throw new Error("join form fields not found");
|
||||
await idInput.fill(sid_id);
|
||||
await nameInput.fill(name);
|
||||
await page.click('button:has-text("Join")');
|
||||
await page.waitForSelector('text=Waiting for instructor', { timeout: 5000 });
|
||||
}
|
||||
|
||||
async function adminOpenQuestion(server, jar, sid, qIdx, timeLimit = 10) {
|
||||
// Open via admin instructor WS
|
||||
const admin = new Admin(server.url, sid, jar);
|
||||
await admin.connect();
|
||||
const w = admin.waitFor("question_open", { timeoutMs: 5000 });
|
||||
admin.open(qIdx, timeLimit);
|
||||
await w;
|
||||
return admin;
|
||||
}
|
||||
|
||||
// Scenario 1: happy path through the SPA
|
||||
async function uiHappy(server, browser) {
|
||||
const { sid, jar } = await setupSession(server.url, server.adminPw, STRESS_POOL);
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
await joinAsStudent(page, server.url, sid, "U1", "UIStudent");
|
||||
const admin = await adminOpenQuestion(server, jar, sid, 0, 10);
|
||||
await page.waitForSelector('text=2+2?', { timeout: 5000 });
|
||||
expect(true, "ui_happy", "Q1 text rendered in browser");
|
||||
await page.click('button:has-text("B")');
|
||||
await page.waitForSelector('text=Submitted in', { timeout: 5000 });
|
||||
expect(true, "ui_happy", "submitted view shown");
|
||||
const closedW = admin.waitFor("question_closed", { timeoutMs: 5000 });
|
||||
admin.close();
|
||||
await closedW;
|
||||
await page.waitForSelector('text=Reveal', { timeout: 5000 });
|
||||
expect(true, "ui_happy", "reveal view shown");
|
||||
admin.disconnect();
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
// Scenario 2: sleep/wake via real browser context close-and-reopen, with persisted cookie.
|
||||
async function uiSleepWake(server, browser) {
|
||||
const { sid, jar } = await setupSession(server.url, server.adminPw, STRESS_POOL);
|
||||
const ctx1 = await browser.newContext();
|
||||
const page1 = await ctx1.newPage();
|
||||
await joinAsStudent(page1, server.url, sid, "U2", "Sleeper");
|
||||
// Capture the cookie so we can restore it in a fresh context (simulating phone-wake on same device)
|
||||
const cookies = await ctx1.cookies();
|
||||
const adminA = await adminOpenQuestion(server, jar, sid, 0, 10);
|
||||
await page1.waitForSelector('text=2+2?', { timeout: 5000 });
|
||||
await page1.click('button:has-text("B")');
|
||||
await page1.waitForSelector('text=Submitted in', { timeout: 5000 });
|
||||
// "Phone goes to sleep" — close the context entirely
|
||||
await ctx1.close();
|
||||
// Instructor closes, advances, opens Q2 (skip 1)
|
||||
let cw = adminA.waitFor("question_closed", { timeoutMs: 5000 });
|
||||
adminA.close();
|
||||
await cw;
|
||||
adminA.next();
|
||||
await sleep(150);
|
||||
let ow = adminA.waitFor("question_open", { timeoutMs: 5000 });
|
||||
adminA.open(2, 10);
|
||||
await ow;
|
||||
// "Phone wakes" — fresh context with same persisted cookie
|
||||
const ctx2 = await browser.newContext();
|
||||
await ctx2.addCookies(cookies);
|
||||
const page2 = await ctx2.newPage();
|
||||
await page2.goto(`${server.url}/?sid=${sid}`);
|
||||
// Should see Q3 (idx 2) text "Fastest sort?"
|
||||
try {
|
||||
await page2.waitForSelector('text=Fastest sort?', { timeout: 5000 });
|
||||
expect(true, "ui_sleep_wake", "browser shows the LATEST question after wake");
|
||||
} catch (e) {
|
||||
expect(false, "ui_sleep_wake", "browser did NOT show latest question after wake", { err: e.message });
|
||||
}
|
||||
// Try to submit on the new question
|
||||
try {
|
||||
await page2.click('button:has-text("B")');
|
||||
await page2.waitForSelector('text=Submitted in', { timeout: 5000 });
|
||||
expect(true, "ui_sleep_wake", "post-wake submit acked in UI");
|
||||
} catch (e) {
|
||||
expect(false, "ui_sleep_wake", "post-wake submit failed in UI", { err: e.message });
|
||||
}
|
||||
adminA.disconnect();
|
||||
await ctx2.close();
|
||||
}
|
||||
|
||||
// Scenario 3: cookie tampering via document.cookie (browser cookie is HttpOnly so this should be a no-op)
|
||||
async function uiCookieTamper(server, browser) {
|
||||
const { sid } = await setupSession(server.url, server.adminPw, STRESS_POOL);
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
await joinAsStudent(page, server.url, sid, "U3", "Tamper");
|
||||
// Confirm document.cookie does NOT see qz_student (HttpOnly)
|
||||
const visible = await page.evaluate(() => document.cookie);
|
||||
expect(!visible.includes("qz_student"), "ui_cookie_tamper", "qz_student is not visible to JS (HttpOnly verified)", { document_cookie: visible });
|
||||
// Try to overwrite (browsers will silently ignore HttpOnly overwrite from JS)
|
||||
await page.evaluate(() => { document.cookie = "qz_student=GARBAGE; path=/"; });
|
||||
await page.reload();
|
||||
// Should still be in lobby (cookie wasn't actually changed)
|
||||
try {
|
||||
await page.waitForSelector('text=Waiting for instructor', { timeout: 4000 });
|
||||
expect(true, "ui_cookie_tamper", "tamper attempt did not log student out");
|
||||
} catch (e) {
|
||||
note("ui_cookie_tamper", `tamper may have succeeded (lobby not re-rendered): ${e.message}`);
|
||||
}
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
// Scenario 4: two browser contexts with same student_id race (different cookies → 2 participants)
|
||||
async function uiDupId(server, browser) {
|
||||
const { sid } = await setupSession(server.url, server.adminPw, STRESS_POOL);
|
||||
const ctxA = await browser.newContext();
|
||||
const ctxB = await browser.newContext();
|
||||
const pA = await ctxA.newPage();
|
||||
const pB = await ctxB.newPage();
|
||||
await joinAsStudent(pA, server.url, sid, "DUPUI", "FirstBrowser");
|
||||
await joinAsStudent(pB, server.url, sid, "DUPUI", "SecondBrowser");
|
||||
expect(true, "ui_dup_id", "two browsers with same student_id both reach lobby");
|
||||
await ctxA.close(); await ctxB.close();
|
||||
}
|
||||
|
||||
const SCENARIOS = [
|
||||
["ui_happy", uiHappy],
|
||||
["ui_sleep_wake", uiSleepWake],
|
||||
["ui_cookie_tamper", uiCookieTamper],
|
||||
["ui_dup_id", uiDupId],
|
||||
];
|
||||
|
||||
logLine("runner", "info", "starting ui stress", { seed: SEED, port: PORT, headless: HEADLESS });
|
||||
|
||||
const browser = await chromium.launch({ headless: HEADLESS });
|
||||
let server = null;
|
||||
try {
|
||||
server = await bootServer({ port: PORT });
|
||||
for (const [name, fn] of SCENARIOS) {
|
||||
logLine(name, "start", `seed=${SEED}`);
|
||||
try {
|
||||
await fn(server, browser);
|
||||
logLine(name, "ok", "scenario completed");
|
||||
} catch (e) {
|
||||
fail++;
|
||||
failures.push({ scenario: name, msg: "uncaught", extra: { err: e.message } });
|
||||
logLine(name, "fail", "uncaught exception", { err: e.message });
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (server) await server.stop();
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
logLine("runner", "summary", "done", { pass, fail, warn, failures });
|
||||
process.exit(fail > 0 ? 1 : 0);
|
||||
@@ -1,2 +1,42 @@
|
||||
def test_placeholder_api_admin():
|
||||
assert True
|
||||
from conftest import admin_login, create_quiz, create_session, join_student
|
||||
|
||||
|
||||
def test_admin_login_required_and_quiz_session_crud(client, sample_pool):
|
||||
assert client.get("/admin/").status_code == 401
|
||||
assert client.post("/admin/login", json={"password": "wrong"}).status_code == 401
|
||||
|
||||
admin_login(client)
|
||||
assert client.get("/admin/").status_code == 200
|
||||
quiz_id = create_quiz(client, sample_pool)
|
||||
quizzes = client.get("/admin/api/quizzes").json()["quizzes"]
|
||||
assert any(item["id"] == quiz_id for item in quizzes)
|
||||
|
||||
response = client.post("/admin/api/sessions", json={"quiz_id": quiz_id})
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert len(payload["sid"]) == 6
|
||||
assert payload["join_url"].endswith(f"?sid={payload['sid']}")
|
||||
assert payload["qr_url"].startswith("data:image/svg+xml;base64,")
|
||||
|
||||
sessions = client.get("/admin/api/sessions").json()["sessions"]
|
||||
assert sessions[0]["sid"] == payload["sid"]
|
||||
|
||||
|
||||
def test_quiz_upload_and_csv_export(client, sample_pool):
|
||||
sid = create_session(client, sample_pool)
|
||||
join_student(client, sid, "s1", "Student One")
|
||||
csv_response = client.get(f"/admin/api/sessions/{sid}/csv")
|
||||
assert csv_response.status_code == 200
|
||||
assert "student_id,name,question_idx" in csv_response.text
|
||||
|
||||
upload = client.post(
|
||||
"/admin/api/quizzes/upload",
|
||||
files={"file": ("pool.json", __import__("json").dumps(sample_pool), "application/json")},
|
||||
)
|
||||
assert upload.status_code == 200
|
||||
|
||||
|
||||
def test_invalid_quiz_and_session_errors(client):
|
||||
admin_login(client)
|
||||
assert client.post("/admin/api/quizzes", json={"pool_json": {"title": "bad", "questions": []}}).status_code == 400
|
||||
assert client.post("/admin/api/sessions", json={"quiz_id": 999}).status_code == 404
|
||||
|
||||
@@ -1,2 +1,29 @@
|
||||
def test_placeholder_api_student():
|
||||
assert True
|
||||
from conftest import create_session, join_student
|
||||
|
||||
|
||||
def test_session_metadata_join_me_and_stats(client, sample_pool):
|
||||
sid = create_session(client, sample_pool)
|
||||
metadata = client.get(f"/api/session/{sid}").json()
|
||||
assert metadata["title"] == "Sample Quiz"
|
||||
assert metadata["state"] == "lobby"
|
||||
assert metadata["current_question_idx"] is None
|
||||
|
||||
join = join_student(client, sid, "s1", "First Name")
|
||||
assert join["ok"] is True
|
||||
assert "qz_student" in client.cookies
|
||||
|
||||
join_student(client, sid, "s1", "Updated Name")
|
||||
me = client.get(f"/api/session/{sid}/me")
|
||||
assert me.status_code == 200
|
||||
assert me.json()["name"] == "Updated Name"
|
||||
|
||||
stats = client.get(f"/api/session/{sid}/stats").json()
|
||||
assert stats["question_idx"] is None
|
||||
assert stats["top5"][0]["name"] == "Updated Name"
|
||||
|
||||
|
||||
def test_invalid_session_and_missing_cookie_paths(client):
|
||||
assert client.get("/?sid=BAD").status_code == 200
|
||||
assert "Ask your instructor" in client.get("/?sid=BAD").text
|
||||
assert client.get("/api/session/BAD").status_code == 404
|
||||
assert client.get("/api/session/BAD/me").status_code == 401
|
||||
|
||||
@@ -1,2 +1,33 @@
|
||||
def test_placeholder_auth():
|
||||
assert True
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app import auth
|
||||
from app.config import Settings
|
||||
|
||||
|
||||
def test_student_cookie_signing_roundtrip():
|
||||
settings = Settings(secret_key="secret", public_url="http://testserver")
|
||||
token = auth.sign_student(settings, "ABC123", "s1", "Ada", "cookie-id")
|
||||
payload = auth.loads_cookie(settings, token)
|
||||
assert payload == {"sid": "ABC123", "student_id": "s1", "name": "Ada", "cookie_id": "cookie-id"}
|
||||
|
||||
|
||||
def test_tampered_cookie_rejected():
|
||||
settings = Settings(secret_key="secret")
|
||||
token = auth.sign_admin(settings)
|
||||
assert auth.loads_cookie(settings, token + "tamper") is None
|
||||
|
||||
|
||||
def test_admin_password_success_and_failure():
|
||||
settings = Settings(secret_key="secret", admin_password="pw")
|
||||
assert auth.verify_admin_password(settings, "pw")
|
||||
assert not auth.verify_admin_password(settings, "wrong")
|
||||
assert not auth.verify_admin_password(Settings(secret_key="secret"), "pw")
|
||||
|
||||
|
||||
def test_serializer_requires_secret():
|
||||
try:
|
||||
auth.sign_admin(Settings(secret_key=None))
|
||||
except HTTPException as exc:
|
||||
assert exc.status_code == 500
|
||||
else:
|
||||
raise AssertionError("Expected missing secret to fail")
|
||||
|
||||
@@ -1,2 +1,17 @@
|
||||
def test_placeholder_csv_export():
|
||||
assert True
|
||||
from conftest import create_session, join_student
|
||||
|
||||
|
||||
def test_csv_export_contains_one_row_per_submission(client, sample_pool):
|
||||
sid = create_session(client, sample_pool)
|
||||
join_student(client, sid, "s1", "Student One")
|
||||
rooms = client.app.state.rooms
|
||||
client.portal.call(rooms.open_question, sid, 0, 2)
|
||||
ack = client.portal.call(rooms.submit_answer, sid, "s1", 0, "B")
|
||||
assert ack["type"] == "submit_ack"
|
||||
client.portal.call(rooms.close_question, sid)
|
||||
|
||||
response = client.get(f"/admin/api/sessions/{sid}/csv")
|
||||
lines = response.text.strip().splitlines()
|
||||
assert lines[0] == "sid,student_id,name,question_idx,answer,elapsed_ms,score,status"
|
||||
assert len(lines) == 2
|
||||
assert ",s1,Student One,0,B," in lines[1]
|
||||
|
||||
@@ -1,2 +1,35 @@
|
||||
def test_placeholder_late_join():
|
||||
assert True
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from conftest import create_session, join_student
|
||||
|
||||
|
||||
def test_late_join_during_open_gets_reduced_remaining_and_can_score(client, sample_pool):
|
||||
sid = create_session(client, sample_pool)
|
||||
rooms = client.app.state.rooms
|
||||
client.portal.call(rooms.open_question, sid, 0, 2)
|
||||
|
||||
late = TestClient(client.app)
|
||||
with late:
|
||||
join_student(late, sid, "late", "Late Student")
|
||||
with late.websocket_connect(f"/ws/student/{sid}") as ws:
|
||||
assert ws.receive_json()["state"] == "question_open"
|
||||
opened = ws.receive_json()
|
||||
assert opened["type"] == "question_open"
|
||||
assert 0 < opened["remaining_ms"] <= 2000
|
||||
ws.send_json({"type": "submit", "question_idx": 0, "answer": "B"})
|
||||
assert ws.receive_json()["score"] > 0
|
||||
|
||||
|
||||
def test_join_after_closed_gets_missed_row(client, sample_pool):
|
||||
sid = create_session(client, sample_pool)
|
||||
rooms = client.app.state.rooms
|
||||
client.portal.call(rooms.open_question, sid, 0, 1)
|
||||
client.portal.call(rooms.close_question, sid)
|
||||
|
||||
late = TestClient(client.app)
|
||||
with late:
|
||||
join_student(late, sid, "late", "Late Student")
|
||||
me = late.get(f"/api/session/{sid}/me").json()
|
||||
assert me["submissions"][0]["question_idx"] == 0
|
||||
assert me["submissions"][0]["status"] == "missed"
|
||||
assert me["submissions"][0]["score"] == 0
|
||||
|
||||
@@ -1,2 +1,49 @@
|
||||
def test_placeholder_load_simulation():
|
||||
assert True
|
||||
import time
|
||||
|
||||
from conftest import create_session, join_student
|
||||
|
||||
|
||||
def test_load_simulation_50_students_full_quiz_and_autoclose(client, sample_pool):
|
||||
sid = create_session(client, sample_pool)
|
||||
rooms = client.app.state.rooms
|
||||
sockets = []
|
||||
try:
|
||||
for idx in range(50):
|
||||
join_student(client, sid, f"s{idx:02d}", f"Student {idx:02d}")
|
||||
ws = client.websocket_connect(f"/ws/student/{sid}").__enter__()
|
||||
sockets.append(ws)
|
||||
assert ws.receive_json()["type"] == "state"
|
||||
|
||||
for question_idx in range(5):
|
||||
client.portal.call(rooms.open_question, sid, question_idx, 1)
|
||||
for ws in sockets:
|
||||
assert ws.receive_json()["type"] == "question_open"
|
||||
for idx, ws in enumerate(sockets):
|
||||
answer = sample_pool["questions"][question_idx]["correct"] if idx % 3 else "A"
|
||||
ws.send_json({"type": "submit", "question_idx": question_idx, "answer": answer})
|
||||
assert ws.receive_json()["type"] == "submit_ack"
|
||||
if question_idx == 4:
|
||||
started = time.monotonic()
|
||||
for ws in sockets:
|
||||
message = ws.receive_json()
|
||||
assert message["type"] == "question_closed"
|
||||
assert time.monotonic() - started < 2
|
||||
else:
|
||||
client.portal.call(rooms.close_question, sid)
|
||||
for ws in sockets:
|
||||
assert ws.receive_json()["type"] == "question_closed"
|
||||
client.portal.call(rooms.next_question, sid)
|
||||
for ws in sockets:
|
||||
assert ws.receive_json()["type"] == "between_questions"
|
||||
|
||||
client.portal.call(rooms.end_session, sid)
|
||||
for ws in sockets:
|
||||
assert ws.receive_json()["type"] == "session_ended"
|
||||
|
||||
csv_lines = client.get(f"/admin/api/sessions/{sid}/csv").text.strip().splitlines()
|
||||
assert len(csv_lines) == 1 + 50 * 5
|
||||
stats = client.get(f"/api/session/{sid}/stats?question_idx=4").json()
|
||||
assert stats["top5"]
|
||||
finally:
|
||||
for ws in sockets:
|
||||
ws.__exit__(None, None, None)
|
||||
|
||||
@@ -1,2 +1,37 @@
|
||||
def test_placeholder_pool():
|
||||
assert True
|
||||
import pytest
|
||||
|
||||
from app.pool import PoolValidationError, get_question, parse_pool_json, public_question_payload, question_time_limit
|
||||
|
||||
|
||||
def test_pool_validation_accepts_well_formed_pool(sample_pool):
|
||||
pool = parse_pool_json(sample_pool)
|
||||
assert pool["title"] == "Sample Quiz"
|
||||
assert pool["score_fn"] == "linear_decay"
|
||||
assert question_time_limit(pool, 0) == 2
|
||||
assert get_question(pool, 0)["correct"] == "B"
|
||||
public = public_question_payload(pool, 0)
|
||||
assert "correct" not in public
|
||||
assert public["options"]["A"] == "Alpha"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mutator, message",
|
||||
[
|
||||
(lambda p: p.pop("title"), "title"),
|
||||
(lambda p: p.update({"questions": []}), "at least one"),
|
||||
(lambda p: p["questions"][0].pop("text"), "text"),
|
||||
(lambda p: p["questions"][0].update({"options": {"A": "x"}}), "options"),
|
||||
(lambda p: p["questions"][0].update({"correct": "E"}), "correct"),
|
||||
(lambda p: p.update({"score_fn": "missing"}), "Unknown"),
|
||||
(lambda p: p.update({"time_limit_default": 0}), "positive"),
|
||||
],
|
||||
)
|
||||
def test_pool_validation_rejects_invalid_shapes(sample_pool, mutator, message):
|
||||
mutator(sample_pool)
|
||||
with pytest.raises(PoolValidationError, match=message):
|
||||
parse_pool_json(sample_pool)
|
||||
|
||||
|
||||
def test_pool_validation_rejects_invalid_json():
|
||||
with pytest.raises(PoolValidationError, match="Invalid JSON"):
|
||||
parse_pool_json("{bad")
|
||||
|
||||
@@ -1,2 +1,22 @@
|
||||
def test_placeholder_reconnect():
|
||||
assert True
|
||||
from conftest import create_session, join_student
|
||||
|
||||
|
||||
def test_reconnect_replays_existing_submit_ack(client, sample_pool):
|
||||
sid = create_session(client, sample_pool)
|
||||
join_student(client, sid, "s1", "Student One")
|
||||
rooms = client.app.state.rooms
|
||||
client.portal.call(rooms.open_question, sid, 0, 2)
|
||||
|
||||
with client.websocket_connect(f"/ws/student/{sid}") as ws:
|
||||
assert ws.receive_json()["type"] == "state"
|
||||
assert ws.receive_json()["type"] == "question_open"
|
||||
ws.send_json({"type": "submit", "question_idx": 0, "answer": "B"})
|
||||
ack = ws.receive_json()
|
||||
assert ack["type"] == "submit_ack"
|
||||
|
||||
with client.websocket_connect(f"/ws/student/{sid}") as ws:
|
||||
assert ws.receive_json()["type"] == "state"
|
||||
assert ws.receive_json()["type"] == "question_open"
|
||||
replay = ws.receive_json()
|
||||
assert replay["type"] == "submit_ack"
|
||||
assert replay["score"] == ack["score"]
|
||||
|
||||
@@ -1,2 +1,26 @@
|
||||
def test_placeholder_scoring():
|
||||
assert True
|
||||
from app.scoring import SCORE_FNS
|
||||
|
||||
|
||||
def test_linear_decay_values():
|
||||
fn = SCORE_FNS["linear_decay"]
|
||||
assert fn(True, 0, 60_000) == 1000
|
||||
assert fn(True, 30_000, 60_000) == 750
|
||||
assert fn(True, 60_000, 60_000) == 500
|
||||
assert fn(True, 90_000, 60_000) == 500
|
||||
assert fn(False, 0, 60_000) == 0
|
||||
|
||||
|
||||
def test_flat_values():
|
||||
fn = SCORE_FNS["flat"]
|
||||
assert fn(True, 0, 60_000) == 1000
|
||||
assert fn(True, 60_000, 60_000) == 1000
|
||||
assert fn(True, 90_000, 60_000) == 1000
|
||||
assert fn(False, 0, 60_000) == 0
|
||||
|
||||
|
||||
def test_exponential_decay_values():
|
||||
fn = SCORE_FNS["exponential_decay"]
|
||||
assert fn(True, 0, 60_000) == 1000
|
||||
assert 560 < fn(True, 60_000, 60_000) < 570
|
||||
assert fn(True, 90_000, 60_000) == fn(True, 60_000, 60_000)
|
||||
assert fn(False, 0, 60_000) == 0
|
||||
|
||||
@@ -1,2 +1,45 @@
|
||||
def test_placeholder_state_machine():
|
||||
assert True
|
||||
from conftest import create_session, join_student
|
||||
|
||||
|
||||
def test_full_lifecycle_with_three_students(client, sample_pool):
|
||||
sid = create_session(client, sample_pool)
|
||||
rooms = client.app.state.rooms
|
||||
sockets = []
|
||||
for idx in range(3):
|
||||
join_student(client, sid, f"s{idx}", f"Student {idx}")
|
||||
ws = client.websocket_connect(f"/ws/student/{sid}").__enter__()
|
||||
sockets.append(ws)
|
||||
assert ws.receive_json()["state"] == "lobby"
|
||||
|
||||
try:
|
||||
client.portal.call(rooms.open_question, sid, 0, 2)
|
||||
for ws in sockets:
|
||||
assert ws.receive_json()["type"] == "question_open"
|
||||
for idx, ws in enumerate(sockets):
|
||||
ws.send_json({"type": "submit", "question_idx": 0, "answer": "B" if idx < 2 else "A"})
|
||||
assert ws.receive_json()["type"] == "submit_ack"
|
||||
|
||||
client.portal.call(rooms.close_question, sid)
|
||||
for ws in sockets:
|
||||
assert ws.receive_json()["type"] == "question_closed"
|
||||
session = client.portal.call(rooms.get_session, sid)
|
||||
assert session["state"] == "question_closed"
|
||||
|
||||
client.portal.call(rooms.next_question, sid)
|
||||
for ws in sockets:
|
||||
assert ws.receive_json()["type"] == "between_questions"
|
||||
assert client.portal.call(rooms.get_session, sid)["state"] == "between_questions"
|
||||
|
||||
client.portal.call(rooms.open_question, sid, 1, 2)
|
||||
for ws in sockets:
|
||||
assert ws.receive_json()["type"] == "question_open"
|
||||
client.portal.call(rooms.end_session, sid)
|
||||
for ws in sockets:
|
||||
message = ws.receive_json()
|
||||
assert message["type"] in {"question_closed", "session_ended"}
|
||||
if message["type"] == "question_closed":
|
||||
assert ws.receive_json()["type"] == "session_ended"
|
||||
assert client.portal.call(rooms.get_session, sid)["state"] == "finished"
|
||||
finally:
|
||||
for ws in locals().get("sockets", []):
|
||||
ws.__exit__(None, None, None)
|
||||
|
||||
@@ -1,2 +1,38 @@
|
||||
def test_placeholder_ws_admin():
|
||||
assert True
|
||||
import pytest
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
from conftest import admin_login, create_session, join_student
|
||||
|
||||
|
||||
def test_instructor_ws_requires_admin_cookie(client, sample_pool):
|
||||
sid = create_session(client, sample_pool)
|
||||
client.cookies.clear()
|
||||
with pytest.raises(WebSocketDisconnect) as exc:
|
||||
with client.websocket_connect(f"/ws/instructor/{sid}"):
|
||||
pass
|
||||
assert exc.value.code == 4001
|
||||
|
||||
|
||||
def test_instructor_controls_transition_and_broadcast(client, sample_pool):
|
||||
sid = create_session(client, sample_pool)
|
||||
join_student(client, sid, "s1", "Student One")
|
||||
admin_login(client)
|
||||
with client.websocket_connect(f"/ws/student/{sid}") as student_ws:
|
||||
assert student_ws.receive_json()["type"] == "state"
|
||||
with client.websocket_connect(f"/ws/instructor/{sid}") as admin_ws:
|
||||
assert admin_ws.receive_json()["type"] == "state"
|
||||
assert admin_ws.receive_json()["type"] == "lobby_update"
|
||||
admin_ws.send_json({"type": "open_question", "question_idx": 0, "time_limit": 2})
|
||||
student_open = student_ws.receive_json()
|
||||
assert student_open["type"] == "question_open"
|
||||
admin_open = admin_ws.receive_json()
|
||||
assert admin_open["type"] == "question_open"
|
||||
assert admin_ws.receive_json()["type"] == "live_histogram"
|
||||
|
||||
admin_ws.send_json({"type": "close_question"})
|
||||
assert student_ws.receive_json()["type"] == "question_closed"
|
||||
messages = [admin_ws.receive_json(), admin_ws.receive_json()]
|
||||
assert {msg["type"] for msg in messages} == {"question_closed", "full_leaderboard"}
|
||||
|
||||
admin_ws.send_json({"type": "next"})
|
||||
assert student_ws.receive_json()["type"] == "between_questions"
|
||||
|
||||
@@ -1,2 +1,37 @@
|
||||
def test_placeholder_ws_student():
|
||||
assert True
|
||||
import pytest
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
from conftest import create_session, join_student
|
||||
|
||||
|
||||
def test_student_ws_requires_cookie(client, sample_pool):
|
||||
sid = create_session(client, sample_pool)
|
||||
with pytest.raises(WebSocketDisconnect) as exc:
|
||||
with client.websocket_connect(f"/ws/student/{sid}"):
|
||||
pass
|
||||
assert exc.value.code == 4001
|
||||
|
||||
|
||||
def test_student_ws_initial_state_submit_and_closed_reject(client, sample_pool):
|
||||
sid = create_session(client, sample_pool)
|
||||
join_student(client, sid, "s1", "Student One")
|
||||
with client.websocket_connect(f"/ws/student/{sid}") as ws:
|
||||
state = ws.receive_json()
|
||||
assert state["type"] == "state"
|
||||
assert state["state"] == "lobby"
|
||||
|
||||
rooms = client.app.state.rooms
|
||||
client.portal.call(rooms.open_question, sid, 0, 2)
|
||||
opened = ws.receive_json()
|
||||
assert opened["type"] == "question_open"
|
||||
ws.send_json({"type": "submit", "question_idx": 0, "answer": "B"})
|
||||
ack = ws.receive_json()
|
||||
assert ack["type"] == "submit_ack"
|
||||
assert ack["score"] > 0
|
||||
|
||||
client.portal.call(rooms.close_question, sid)
|
||||
closed = ws.receive_json()
|
||||
assert closed["type"] == "question_closed"
|
||||
ws.send_json({"type": "submit", "question_idx": 0, "answer": "B"})
|
||||
error = ws.receive_json()
|
||||
assert error["code"] == "not_open"
|
||||
|
||||
Reference in New Issue
Block a user