File size: 19,418 Bytes
afd13cb
 
 
 
 
 
0818184
7badcff
afd13cb
 
 
 
 
 
 
 
 
 
 
 
7badcff
 
afd13cb
 
7badcff
 
afd13cb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7badcff
 
 
 
afd13cb
 
 
 
 
 
7badcff
 
afd13cb
 
 
 
 
0818184
 
 
 
 
 
 
 
 
 
 
 
 
7badcff
 
 
 
 
 
0818184
 
 
 
 
 
 
 
 
afd13cb
 
 
 
 
 
 
0818184
afd13cb
 
 
 
 
 
 
 
 
 
8fb90d0
 
afd13cb
 
 
 
0818184
7badcff
 
 
afd13cb
 
 
 
 
 
 
 
 
 
7badcff
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0818184
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8fb90d0
afd13cb
 
 
 
 
8fb90d0
afd13cb
 
8fb90d0
 
 
 
 
afd13cb
8fb90d0
afd13cb
 
 
 
 
7badcff
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0ba5932
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
"""The class teacher's report: one of the students is the class's teacher and sees the others' progress and grades."""
import asyncio
import urllib.parse

import httpx

from app.answer_scans import ScanError
from app.auth import AuthUnavailable
from app.main import create_app

TEACHER, STUDENT, NEWBIE, OUTSIDER = "t-id", "s-id", "n-id", "o-id"
EMAILS = {TEACHER: "teacher@x.test", STUDENT: "student@x.test", NEWBIE: "newbie@x.test", OUTSIDER: "out@x.test"}


class ReportAuth:
    url, key, secret = "https://test.invalid", "public-test", "server-test"
    teachers = developers = frozenset()

    def __init__(self):
        self.row = {"id": 3, "name": "讟壮1", "lessons": [], "teachers": ["teacher@x.test"],
                    "emails": ["newbie@x.test", "student@x.test", "teacher@x.test", "ghost@x.test"],
                    "nicknames": {"newbie@x.test": "谞讜注讛 诇."}}
        self.saved = []
        self.progress = {STUDENT: {"done": {"t1": "2026-11-01"}, "updated": 5, "comets": ["c1"],
                                   "seen": {"t1": "2026-11-01T10:00:00Z"}, "tries": {"t1": 4, "t2": 1}, "secret_extra": 1,
                                   "examples": {"t1": {"n": 3, "last": "2026-11-02T10:00:00Z"}},
                                   "slips": [{"at": "2026-11-01T10:00:00Z", "id": "t1", "q": 0, "m": "sign"}],
                                   "quizzes": [{"at": "2026-11-02", "n": 12, "right": 9, "ms": 600000, "units": ["t"],
                                                "weak": ["t1"]}],
                                   "uq": {"t": {"sitting": {"started": 1, "attempts": ["a"], "submitted": 2},
                                                "history": [{"at": "2026-11-03", "attempts": ["a"], "score": 85}]}}},
                         TEACHER: {"done": {"secret": 1}}}

    def user(self, token):
        return {"id": token, "email": EMAILS[token]} if token in EMAILS else None

    def classes(self, fresh=False):
        return [self.row]

    def access_for(self, _id, **kwargs):
        return None

    NAMES = {STUDENT: "Sam Student"}   # Google names; the others have none

    def user_accounts(self, emails):
        return {e: {"id": i, "name": self.NAMES.get(i, "")} for i, e in EMAILS.items() if e in emails}

    def save_class(self, class_id, name, emails, nicknames, lessons, *, teachers=None):
        self.saved.append({"emails": emails, "teachers": teachers})

    def _admin(self, path, method="GET", body=None, prefer=""):
        table, _, query = path.partition("?")
        if table != "/rest/v1/math_progress":
            raise AuthUnavailable("supabase answered 404: no other tables here")   # e.g. the assignments table: not set up
        ids = dict(urllib.parse.parse_qsl(query))["user_id"].removeprefix("in.(").removesuffix(")").split(",")
        return [{"user_id": i, "data": self.progress[i], "updated_at": "2026-11-03T00:00:00Z"}
                for i in ids if i in self.progress]


class Sittings:
    """The server's record of unit-quiz sittings (app/quiz_grades.py), as the database returns it."""
    configured = True

    def __init__(self, sittings=(), fail=False):
        self.sittings, self.fail, self.asked = list(sittings), fail, []

    def quiz(self, user_id, action, **data):
        self.asked.append((user_id, action, data))
        if self.fail:
            raise ScanError(503, "quiz_sittings_unavailable")
        return [s for s in self.sittings if s["user_id"] in data["users"]]

    def review(self, user_id, action, **data):   # the students' open requests to look again
        assert action == "requests"
        if self.fail:
            raise ScanError(503, "review_unavailable")
        return [r for r in getattr(self, "requests", []) if r["user_id"] in data["users"]]


def attempt(score, state="completed", photo=True, verdict="correct"):
    return {"attempt_id": "a", "has_photo": photo, "grade_state": state, "score": score, "verdict": verdict}


def sitting(user, *attempts, sid="s1", at="2026-11-04T09:00:00Z"):
    return {"id": sid, "user_id": user, "unit_id": "t", "submitted_at": at, "time_up": False, "attempts": list(attempts)}


def client(app, user):
    return httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test",
                             headers={"Authorization": f"Bearer {user}"})


def test_teacher_sees_the_other_students():
    app = create_app(auth=ReportAuth())
    app.state.answers = Sittings()

    async def check():
        async with client(app, TEACHER) as c:
            r = await c.get("/api/classes/3/report")
            assert r.status_code == 200
            data = r.json()
            assert data["class"]["name"] == "讟壮1" and data["class"]["lessons"]
            students = {s["email"]: s for s in data["students"]}
            assert "teacher@x.test" not in students   # the teacher is not in their own report
            assert students["ghost@x.test"] == {"email": "ghost@x.test", "signed_in": False, "progress": None,
                                                "saved_at": None, "quiz_sittings": [], "name": None, "review_requests": [],
                                                "worksheet_submissions": None}   # no worksheet tables here
            assert students["newbie@x.test"]["signed_in"] and students["newbie@x.test"]["progress"] is None
            p = students["student@x.test"]["progress"]
            assert p["done"] == {"t1": "2026-11-01"} and p["slips"][0]["m"] == "sign"
            assert p["quizzes"] == [{"at": "2026-11-02", "n": 12, "right": 9, "ms": 600000, "units": ["t"]}]
            assert p["uq"] == {"t": {"history": [{"at": "2026-11-03", "timeUp": None, "sid": None}], "going": False}}
            assert p["seen"] == {"t1": "2026-11-01T10:00:00Z"} and p["tries"] == {"t1": 4, "t2": 1}
            assert p["examples"] == {"t1": {"n": 3, "last": "2026-11-02T10:00:00Z"}}   # the worked examples the student opened
            assert "comets" not in p and "secret_extra" not in p
            assert (await c.get("/api/classes/99/report")).status_code == 404
            me = (await c.get("/api/me")).json()
            assert me["classes"][0]["teacher"] is True
        for other in (STUDENT, OUTSIDER):
            async with client(app, other) as c:
                assert (await c.get("/api/classes/3/report")).status_code == 403

    asyncio.run(check())


def test_students_are_named_by_nickname_then_google_name_then_nothing():
    app = create_app(auth=ReportAuth())
    app.state.answers = Sittings()

    async def check():
        async with client(app, TEACHER) as c:
            students = {s["email"]: s for s in (await c.get("/api/classes/3/report")).json()["students"]}
            assert students["newbie@x.test"]["name"] == "谞讜注讛 诇."      # the class's nickname
            assert students["student@x.test"]["name"] == "Sam Student"  # else the Google name
            assert students["ghost@x.test"]["name"] is None             # else the page uses the email's start
    asyncio.run(check())


def test_google_names_come_from_the_admin_user_list():
    from app.auth import SupabaseAuth
    auth = SupabaseAuth("https://x.invalid", "key", secret="secret")
    users = [{"id": "u1", "email": "A@x.test", "user_metadata": {"full_name": " Noa   Levi "}},
             {"id": "u2", "email": "b@x.test", "user_metadata": {"name": "Ben"}},
             {"id": "u3", "email": "c@x.test"}, {"id": "u4", "email": "other@x.test"}]
    auth._admin = lambda path, *a, **k: {"users": users}
    got = auth.user_accounts(["a@x.test", "B@x.test", "c@x.test", "nobody@x.test"])
    assert got == {"a@x.test": {"id": "u1", "name": "Noa Levi"}, "b@x.test": {"id": "u2", "name": "Ben"},
                   "c@x.test": {"id": "u3", "name": ""}}
    assert auth.user_ids(["a@x.test"]) == {"a@x.test": "u1"}


def test_unit_quiz_grades_are_the_servers_and_not_the_devices():
    auth = ReportAuth()   # the student's uploaded progress claims 85 for unit t
    app = create_app(auth=auth)
    app.state.answers = answers = Sittings([
        sitting(STUDENT, attempt(100), attempt(60), attempt(40), attempt(48), sid="s-done"),   # 25 + 15 + 10 + 12
        sitting(NEWBIE, attempt(100), attempt(None, state="grading", verdict=None), attempt(100), attempt(100),
                sid="s-open"),
        sitting(OUTSIDER, attempt(100), attempt(100), attempt(100), attempt(100), sid="s-other-class")])

    async def check():
        async with client(app, TEACHER) as c:
            data = (await c.get("/api/classes/3/report")).json()
            students = {s["email"]: s for s in data["students"]}
            done = students["student@x.test"]["quiz_sittings"]
            assert [(x["id"], x["grade"], x["open"]) for x in done] == [("s-done", 62, 0)]   # not 85
            waiting = students["newbie@x.test"]["quiz_sittings"]
            assert [(x["id"], x["grade"], x["open"]) for x in waiting] == [("s-open", None, 1)]
            assert students["ghost@x.test"]["quiz_sittings"] == []
            assert "score" not in str(students["student@x.test"]["progress"]["uq"])   # the device's number is not passed on
            assert answers.asked[0][:2] == (None, "report")
            assert OUTSIDER not in answers.asked[0][2]["users"] and "s-other-class" not in str(students)
    asyncio.run(check())


def test_without_the_servers_record_the_report_shows_no_quiz_grades():
    app = create_app(auth=ReportAuth())
    app.state.answers = Sittings(fail=True)

    async def check():
        async with client(app, TEACHER) as c:
            r = await c.get("/api/classes/3/report")
            assert r.status_code == 200
            assert all(s["quiz_sittings"] is None for s in r.json()["students"])
    asyncio.run(check())


def test_the_teacher_is_free_text_and_never_a_student():
    auth = ReportAuth()
    app = create_app(auth=auth)

    async def check():
        # a manager (TEACHER_EMAILS) saves the class
        auth.developers = frozenset({"out@x.test"})
        async with client(app, OUTSIDER) as c:
            body = {"name": "讟壮1", "emails": ["a@x.test", "b@x.test"], "lessons": ["t"]}
            assert (await c.put("/api/classes/3", json={**body, "teachers": ["a@x.test"]})).status_code == 400   # a student
            assert (await c.put("/api/classes/3", json={**body, "teachers": ["B@x.test"]})).status_code == 400
            assert (await c.put("/api/classes/3", json={**body, "teachers": ["c@x.test", "d@x.test"]})).status_code == 400
            assert (await c.put("/api/classes/3", json={**body, "teachers": ["nope"]})).status_code == 400
            assert (await c.put("/api/classes/3", json={**body, "teachers": ["C@x.test"]})).status_code == 200
            assert (await c.put("/api/classes/3", json=body)).status_code == 200
            assert auth.saved == [{"emails": ["a@x.test", "b@x.test"], "teachers": ["c@x.test"]},
                                  {"emails": ["a@x.test", "b@x.test"], "teachers": []}]
            # a manager can open any class's report
            assert (await c.get("/api/classes/3/report")).status_code == 200

    asyncio.run(check())


def test_progress_parts_that_are_not_objects_are_dropped():
    from app.class_report import report_part
    part = report_part({"seen": "yesterday", "tries": [1, 2], "days": None, "examples": "many", "done": {"a": 1}})
    assert part["seen"] == {} and part["tries"] == {} and part["days"] == {} and part["examples"] == {} and part["done"] == {"a": 1}


def test_the_report_lists_each_students_open_requests_to_look_again():
    app = create_app(auth=ReportAuth())
    app.state.answers = answers = Sittings()
    answers.requests = [
        {"attempt_id": "a1", "user_id": STUDENT, "message": "讻转讘转讬 x=4", "question": "讘讜讞谉: 砖讗诇讛 2", "created_at": "2026-11-04T10:00:00Z"},
        {"attempt_id": "a2", "user_id": OUTSIDER, "message": "砖诇 讻讬转讛 讗讞专转", "question": "q", "created_at": "2026-11-04T10:00:00Z"}]

    async def check():
        async with client(app, TEACHER) as c:
            students = {s["email"]: s for s in (await c.get("/api/classes/3/report")).json()["students"]}
            assert students["student@x.test"]["review_requests"] == [
                {"attempt_id": "a1", "message": "讻转讘转讬 x=4", "question": "讘讜讞谉: 砖讗诇讛 2", "created_at": "2026-11-04T10:00:00Z"}]
            assert students["newbie@x.test"]["review_requests"] == [] and "砖诇 讻讬转讛 讗讞专转" not in str(students)
        answers.fail = True   # unreadable: the report still opens, without them
        async with client(app, TEACHER) as c:
            r = await c.get("/api/classes/3/report")
            assert r.status_code == 200 and all(s["review_requests"] == [] for s in r.json()["students"])
    asyncio.run(check())


def test_the_report_carries_the_classs_assignments_for_its_teacher():
    from test_assignments import GOOD, MemoryAssignments
    store = MemoryAssignments()
    app = create_app(auth=ReportAuth())
    app.state.answers, app.state.assignments = Sittings(), store
    store.add({**GOOD, "class_id": 3, "created_by_email": "teacher@x.test"})
    store.add({**GOOD, "class_id": 99, "title": "砖诇 讻讬转讛 讗讞专转", "created_by_email": "x@x.test"})

    async def check():
        async with client(app, TEACHER) as c:
            data = (await c.get("/api/classes/3/report")).json()
            assert [(a["title"], a["class_id"], a["created_by_email"]) for a in data["assignments"]] == [(GOOD["title"], 3, "teacher@x.test")]
            assert data["assignments"][0]["items"] == GOOD["items"]
        store.fail = True
        async with client(app, TEACHER) as c:   # the table is not there: the report opens, without assignments
            r = await c.get("/api/classes/3/report")
            assert r.status_code == 200 and r.json()["assignments"] is None
    asyncio.run(check())


def test_the_report_says_which_sittings_are_final_and_shows_their_grade_to_the_teacher():
    """A final sitting's grade is the teacher's to see before release (the student's view waits for it)."""
    app = create_app(auth=ReportAuth())
    app.state.answers = Sittings([
        {**sitting(STUDENT, attempt(100), attempt(100), attempt(100), attempt(40), sid="s-final"), "mode": "final",
         "window_id": "w1", "started_at": "2026-11-04T08:00:00Z"},
        sitting(STUDENT, attempt(100), attempt(100), attempt(100), attempt(100), sid="s-practice", at="2026-11-05T09:00:00Z")])

    async def check():
        async with client(app, TEACHER) as c:
            students = {s["email"]: s for s in (await c.get("/api/classes/3/report")).json()["students"]}
            mine = {x["id"]: x for x in students["student@x.test"]["quiz_sittings"]}
            assert (mine["s-final"]["mode"], mine["s-final"]["grade"], mine["s-final"]["window_id"]) == ("final", 85, "w1")
            assert (mine["s-practice"]["mode"], mine["s-practice"]["grade"]) == ("practice", 100)
    asyncio.run(check())


def test_the_class_teacher_says_which_units_an_exam_covers():
    auth = ReportAuth()
    auth.row = {**auth.row, "lessons": ["t", "k"], "exams": [{"subject": "诪转诪讟讬拽讛", "date": "2026-11-18"},
                                                             {"subject": "诪讚注讬诐", "date": "2026-11-20"}]}
    saved = []
    auth.save_class_exams = lambda class_id, exams: saved.append((class_id, exams))
    app = create_app(auth=auth)
    app.state.answers = Sittings()
    url = "/api/classes/3/exams/topics"
    body = {"subject": "诪转诪讟讬拽讛", "date": "2026-11-18", "units": ["k", "t"], "note": "驻专拽 3 注讚 注诪讜讚 80"}

    async def check():
        async with client(app, TEACHER) as c, client(app, STUDENT) as student:
            assert (await student.put(url, json=body)).status_code == 403
            assert (await c.put(url, json={**body, "units": ["g"]})).status_code == 400            # not a class unit
            assert (await c.put(url, json={**body, "note": "讗" * 301})).status_code == 400
            assert (await c.put(url, json={**body, "date": "2026-12-01"})).status_code == 404
            r = await c.put(url, json=body)
            assert r.status_code == 200 and r.json()["units"] == ["t", "k"] and r.json()["note"] == "驻专拽 3 注讚 注诪讜讚 80"
            (class_id, exams), = saved
            assert class_id == 3 and exams[0]["units"] == ["t", "k"] and "units" not in exams[1]   # the other exam is as it was
            cleared = await c.put(url, json={**body, "units": [], "note": ""})
            assert cleared.json()["units"] == []   # an exam without units works as before
    asyncio.run(check())


def test_the_report_carries_the_classs_exams_for_readiness():
    auth = ReportAuth()
    auth.row = {**auth.row, "lessons": ["t"], "exams": [{"subject": "诪转诪讟讬拽讛", "date": "2026-11-18", "units": ["t"]}, "junk"]}
    app = create_app(auth=auth)
    app.state.answers = Sittings()

    async def check():
        async with client(app, TEACHER) as c:
            data = (await c.get("/api/classes/3/report")).json()
            assert data["class"]["exams"] == [{"subject": "诪转诪讟讬拽讛", "date": "2026-11-18", "units": ["t"]}]
    asyncio.run(check())


def test_each_quiz_question_in_the_report_names_its_skills_and_points():
    app = create_app(auth=ReportAuth())
    app.state.answers = Sittings([sitting(STUDENT, attempt(100), attempt(40), attempt(100), attempt(100))])

    async def check():
        async with client(app, TEACHER) as c:
            students = {s["email"]: s for s in (await c.get("/api/classes/3/report")).json()["students"]}
            q = students["student@x.test"]["quiz_sittings"][0]["questions"][1]
            assert (q["points"], q["earned"], q["skills"]) == (25, 10, ["t_ext_eq"])   # 15 points lost on t_ext_eq
    asyncio.run(check())


def test_the_class_teacher_adds_and_removes_exams_on_the_classs_table():
    auth = ReportAuth()
    auth.row = {**auth.row, "lessons": ["t", "k"], "exams": [{"subject": "诪转诪讟讬拽讛", "date": "2026-11-18"}]}

    def save(class_id, exams):
        auth.row = {**auth.row, "exams": exams}
    auth.save_class_exams = save
    app = create_app(auth=auth)
    app.state.answers = Sittings()

    async def check():
        async with client(app, TEACHER) as c, client(app, STUDENT) as student:
            new = {"subject": "诪转诪讟讬拽讛", "date": "2026-12-02", "units": ["k"], "note": "讚诇转讜谉"}
            assert (await student.post("/api/classes/3/exams", json=new)).status_code == 403
            for bad in ({**new, "date": "soon"}, {**new, "subject": ""}, {**new, "units": ["g"]}):
                assert (await c.post("/api/classes/3/exams", json=bad)).status_code == 400, bad
            made = await c.post("/api/classes/3/exams", json=new)
            assert made.status_code == 201 and made.json() == new
            assert (await c.post("/api/classes/3/exams", json=new)).status_code == 409   # the same exam twice
            assert [e["date"] for e in auth.row["exams"]] == ["2026-11-18", "2026-12-02"]   # by date
            gone = await c.delete("/api/classes/3/exams", params={"subject": "诪转诪讟讬拽讛", "date": "2026-11-18"})
            assert gone.status_code == 200 and [e["date"] for e in auth.row["exams"]] == ["2026-12-02"]
            assert (await c.delete("/api/classes/3/exams", params={"subject": "x", "date": "2026-01-01"})).status_code == 404
    asyncio.run(check())