mathly / tests /test_progress.py
3v324v23's picture
Add tests for student task list and quiz modes functionality
0ba5932
Raw History Blame Contribute Delete
6.22 kB
import asyncio
import json
import re
import shutil
import subprocess
import urllib.parse
from pathlib import Path
import httpx
import pytest
from app.auth import AuthUnavailable
from app.main import create_app
from scan_support import OTHER, OWNER, ScanAuth
MATH_JS = Path(__file__).resolve().parent.parent / "static" / "math.js"
class ProgressAuth(ScanAuth):
"""ScanAuth with an in-memory math_progress behind the PostgREST calls app/progress.py makes."""
def __init__(self):
self.rows: dict[str, dict] = {}
def _admin(self, path, method="GET", body=None, prefer=""):
table, _, query = path.partition("?")
assert table == "/rest/v1/math_progress"
q = dict(urllib.parse.parse_qsl(query))
if method == "POST":
assert "merge-duplicates" in prefer and q.get("on_conflict") == "user_id"
self.rows[body["user_id"]] = dict(body)
return None
user = q["user_id"].removeprefix("eq.")
return [{"data": self.rows[user]["data"]}] if user in self.rows else []
def client(app, user=None):
headers = {"Authorization": f"Bearer {user}"} if user else {}
return httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test", headers=headers)
def test_progress_is_kept_per_account():
auth = ProgressAuth()
app = create_app(auth=auth)
async def check():
async with client(app) as c:
assert (await c.get("/api/math/progress")).status_code == 401
async with client(app, OWNER) as owner, client(app, OTHER) as other:
assert (await owner.get("/api/math/progress")).json() == {"progress": {}}
mine = {"done": {"s1": "2026-11-01T10:00:00Z"}, "updated": 1}
assert (await owner.put("/api/math/progress", json={"progress": mine})).json() == {"ok": True}
assert (await owner.get("/api/math/progress")).json() == {"progress": mine}
assert (await other.get("/api/math/progress")).json() == {"progress": {}}
assert (await owner.put("/api/math/progress", json={"progress": [1]})).status_code == 400
assert (await owner.put("/api/math/progress", content=b"not json")).status_code == 400
big = {"progress": {"x": "a" * 1_000_001}}
assert (await owner.put("/api/math/progress", json=big)).status_code == 413
asyncio.run(check())
def test_missing_table_answers_503():
class NoTable(ProgressAuth):
def _admin(self, *a, **k):
raise AuthUnavailable("supabase answered 404: relation math_progress does not exist")
app = create_app(auth=NoTable())
async def check():
async with client(app, OWNER) as c:
assert (await c.get("/api/math/progress")).status_code == 503
assert (await c.put("/api/math/progress", json={"progress": {}})).status_code == 503
asyncio.run(check())
def merge(a, b):
"""Run mergeProgress from static/math.js in Node."""
source = MATH_JS.read_text(encoding="utf-8")
fn = re.search(r"const isObj=.*?\n(function mergeProgress\(a,b\)\{.*?\n\})", source, re.S)
script = fn.group(0) + f"\nprocess.stdout.write(JSON.stringify(mergeProgress({json.dumps(a)},{json.dumps(b)})));"
return json.loads(subprocess.run(["node", "-e", script], capture_output=True, text=True, check=True).stdout)
@pytest.mark.skipif(not shutil.which("node"), reason="needs node")
def test_merge_keeps_the_work_of_both_devices():
phone = {"updated": 200, "done": {"a": "2026-11-02", "b": "2026-11-02"}, "gold": {"b": "2026-11-02"},
"solved": {"c": [0, 2]}, "tries": {"c": 3}, "days": {"2026-11-02": 4}, "comets": ["pi"],
"slips": [{"at": "2026-11-02T09:00", "id": "c", "q": 1, "m": "sign"}],
"at": {"c": 2}, "run": {"c": 1}, "seen": {"a": "2026-11-02"}, "cometDay": "2026-11-02"}
laptop = {"updated": 100, "done": {"a": "2026-11-01", "d": "2026-11-01"}, "solved": {"c": [1]},
"tries": {"c": 5}, "days": {"2026-11-01": 2, "2026-11-02": 1}, "comets": ["pi", "e"],
"slips": [{"at": "2026-11-01T09:00", "id": "c", "q": 0, "m": "sign"}],
"at": {"c": 1, "d": 0}, "run": {"c": 4}, "seen": {"a": "2026-11-03"},
"quizzes": [{"at": "2026-11-01T08:00", "n": 5}]}
for out in (merge(phone, laptop), merge(laptop, phone)):
assert out["done"] == {"a": "2026-11-01", "b": "2026-11-02", "d": "2026-11-01"}
assert out["gold"] == {"b": "2026-11-02"}
assert out["solved"] == {"c": [0, 1, 2]}
assert out["tries"] == {"c": 5}
assert out["days"] == {"2026-11-01": 2, "2026-11-02": 4}
assert sorted(out["comets"]) == ["e", "pi"]
assert [s["at"] for s in out["slips"]] == ["2026-11-01T09:00", "2026-11-02T09:00"]
assert out["at"] == {"c": 2, "d": 0} and out["run"] == {"c": 1} # where the student is: the newer copy
assert out["seen"] == {"a": "2026-11-03"} and out["cometDay"] == "2026-11-02"
assert out["quizzes"] == [{"at": "2026-11-01T08:00", "n": 5}] and out["updated"] == 200
alone = merge({}, phone) # a device with nothing yet takes the server's copy as it is
assert alone == merge(phone, {}) and {k: alone[k] for k in phone} == phone
@pytest.mark.skipif(not shutil.which("node"), reason="needs node")
def test_merge_keeps_the_most_worked_example_openings_and_the_latest_time():
phone = {"updated": 200, "examples": {"a": {"n": 2, "last": "2026-11-02T10:00:00Z"}}}
laptop = {"updated": 100, "examples": {"a": {"n": 3, "last": "2026-11-01T10:00:00Z"}, "b": {"n": 1, "last": "2026-11-01T11:00:00Z"}}}
for out in (merge(phone, laptop), merge(laptop, phone)):
assert out["examples"] == {"a": {"n": 3, "last": "2026-11-02T10:00:00Z"}, "b": {"n": 1, "last": "2026-11-01T11:00:00Z"}}
assert merge({}, phone)["examples"] == phone["examples"]
@pytest.mark.skipif(not shutil.which("node"), reason="needs node")
def test_merge_keeps_the_most_wrong_answers_per_skill():
phone = {"updated": 200, "wrongs": {"a": 2, "b": 1}}
laptop = {"updated": 100, "wrongs": {"a": 5}}
for out in (merge(phone, laptop), merge(laptop, phone)):
assert out["wrongs"] == {"a": 5, "b": 1}