Download tests/test_unit_resources.py from wzender/mathly: direct link, hf CLI and curl.
- Browser
- Download file 33.8 kB
-
https://huggingface.co/spaces/wzender/mathly/resolve/main/tests/test_unit_resources.py
- Command line
-
hf download hf://spaces/wzender/mathly/tests/test_unit_resources.py
-
curl -L -o test_unit_resources.py https://huggingface.co/spaces/wzender/mathly/resolve/main/tests/test_unit_resources.py
33.8 kB
| """Lesson resources: teachers photograph exercises and explanations for a unit.""" | |
| import asyncio | |
| import io | |
| import re | |
| import socket | |
| import threading | |
| import time | |
| import uuid | |
| from pathlib import Path | |
| import httpx | |
| import pytest | |
| import uvicorn | |
| from PIL import Image | |
| from app.main import create_app | |
| from app.unit_resources import UnitResources | |
| from scan_support import BOOTSTRAP, postgres_server | |
| TEACHER = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' | |
| COLLEAGUE = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' | |
| STUDENT = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc' | |
| ADMIN = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd' | |
| ROLES = {TEACHER: ['teacher'], COLLEAGUE: ['teacher'], STUDENT: ['student'], ADMIN: ['developer']} # managers are developers | |
| class Auth: | |
| url = 'https://test.invalid' | |
| key = 'public-test' | |
| secret = 'server-test' | |
| teachers = frozenset() | |
| developers = frozenset() | |
| def user(self, token): | |
| return {'id': token, 'email': token[:4] + '@test.invalid'} if token in ROLES else None | |
| def classes(self): | |
| return [] | |
| def access_for(self, user_id, **_kwargs): | |
| return {'roles': ROLES[user_id]} | |
| class MemoryResources(UnitResources): | |
| """The storage calls, kept in memory.""" | |
| def __init__(self): | |
| super().__init__(Auth()) | |
| self.rows, self.files = {}, {} | |
| def list(self, unit_id): | |
| return sorted((r for r in self.rows.values() if r['unit_id'] == unit_id), key=lambda r: r['created_at'], reverse=True) | |
| def get(self, resource_id): | |
| return self.rows.get(resource_id) | |
| def add(self, row): | |
| row = {**row, 'id': str(uuid.uuid4()), 'created_at': f'2026-11-01T10:{len(self.rows):02d}:00Z'} | |
| self.rows[row['id']] = row | |
| return row | |
| def remove(self, resource_id): | |
| self.rows.pop(resource_id, None) | |
| def set_visible(self, resource_id, visible): | |
| if resource_id in self.rows: | |
| self.rows[resource_id]['visible'] = visible | |
| return self.rows.get(resource_id) | |
| def put(self, key, data): | |
| self.files[key] = data | |
| def read(self, key): | |
| return self.files[key] | |
| def delete(self, key): | |
| self.files.pop(key, None) | |
| def png(color='white'): | |
| out = io.BytesIO() | |
| Image.new('RGB', (200, 120), color).save(out, 'PNG') | |
| return out.getvalue() | |
| def app_with(store): | |
| app = create_app(auth=Auth()) | |
| app.state.resources = store | |
| return app | |
| def client(app, token): | |
| return httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test', | |
| headers={'Authorization': f'Bearer {token}'}) | |
| def add(c, unit='t', kind='exercise', note='', skill=''): | |
| return c.post(f'/api/math/units/{unit}/resources', data={'kind': kind, 'note': note, 'skill': skill}, | |
| files={'photo': ('p.png', png(), 'image/png')}) | |
| def test_teacher_adds_lists_opens_and_deletes_lesson_photos(): | |
| store = MemoryResources() | |
| app = app_with(store) | |
| async def check(): | |
| async with client(app, TEACHER) as teacher, client(app, COLLEAGUE) as colleague, \ | |
| client(app, STUDENT) as student, client(app, ADMIN) as admin: | |
| # an exercise on a topic of the lesson | |
| saved = await add(teacher, skill='t_ext') | |
| assert saved.status_code == 201 | |
| first = saved.json() | |
| assert first['kind'] == 'exercise' and first['skill_id'] == 't_ext' and first['note'] == '' | |
| assert 'object_key' not in first | |
| # a theory explanation with no topic, and free text | |
| assert (await add(teacher, kind='theory')).json()['skill_id'] is None | |
| other = (await add(teacher, kind='other', note=' 住讬讻讜诐 讛砖讬注讜专 ')).json() | |
| assert other['note'] == '住讬讻讜诐 讛砖讬注讜专' | |
| # what the form refuses | |
| assert (await add(teacher, kind='photo')).status_code == 400 | |
| assert (await add(teacher, kind='other', note=' ')).status_code == 400 | |
| assert (await add(teacher, kind='other', note='x' * 201)).status_code == 400 | |
| assert (await add(teacher, skill='q_vertex')).status_code == 400 # a topic of another lesson | |
| assert (await add(teacher, unit='nope')).status_code == 404 | |
| bad = await teacher.post('/api/math/units/t/resources', data={'kind': 'exercise'}, | |
| files={'photo': ('p.txt', b'not an image', 'text/plain')}) | |
| assert bad.status_code == 415 | |
| assert len(store.rows) == len(store.files) == 3 | |
| listed = (await colleague.get('/api/math/units/t/resources')).json()['resources'] | |
| assert [r['kind'] for r in listed] == ['other', 'theory', 'exercise'] | |
| assert all('object_key' not in r for r in listed) | |
| assert (await teacher.get('/api/math/units/t2/resources')).json() == {'resources': []} | |
| image = await colleague.get(f"/api/math/resources/{first['id']}/image") | |
| assert image.status_code == 200 and image.headers['content-type'] == 'image/jpeg' | |
| assert Image.open(io.BytesIO(image.content)).format == 'JPEG' # re-encoded, metadata dropped | |
| assert (await teacher.get('/api/math/resources/not-a-uuid/image')).status_code == 404 | |
| # students neither see nor add resources | |
| assert (await student.get('/api/math/units/t/resources')).status_code == 403 | |
| assert (await add(student)).status_code == 403 | |
| assert (await student.get(f"/api/math/resources/{first['id']}/image")).status_code == 403 | |
| # only the teacher who added a photo, or a manager, deletes it | |
| assert (await colleague.delete(f"/api/math/resources/{first['id']}")).status_code == 403 | |
| assert (await teacher.delete(f"/api/math/resources/{first['id']}")).json() == {'ok': True} | |
| assert (await admin.delete(f"/api/math/resources/{other['id']}")).json() == {'ok': True} | |
| assert (await teacher.delete(f"/api/math/resources/{other['id']}")).status_code == 404 | |
| assert len(store.rows) == len(store.files) == 1 | |
| async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test') as guest: | |
| assert (await guest.get('/api/math/units/t/resources')).status_code == 401 | |
| asyncio.run(check()) | |
| assert [a['kind'] for a in app.state.actions.recent()].count('resource_added') == 3 | |
| def test_resources_fail_closed_without_storage_key(): | |
| store = MemoryResources() | |
| store.auth = type('NoSecret', (Auth,), {'secret': ''})() | |
| app = app_with(store) | |
| async def check(): | |
| async with client(app, TEACHER) as teacher: | |
| assert (await teacher.get('/api/math/units/t/resources')).status_code == 503 | |
| asyncio.run(check()) | |
| def test_migration_keeps_resources_private(postgres_server): | |
| psycopg = pytest.importorskip('psycopg') | |
| database = 'res_' + uuid.uuid4().hex | |
| with psycopg.connect(postgres_server.get_uri(), autocommit=True) as c: | |
| c.execute('create database ' + database) | |
| try: | |
| with psycopg.connect(postgres_server.get_uri(database=database), autocommit=True) as c: | |
| c.execute(BOOTSTRAP) | |
| c.execute(Path('supabase/migrations/20261101090000_math_unit_resources.sql').read_text()) | |
| c.execute('insert into auth.users values(%s)', (TEACHER,)) | |
| assert c.execute("select public from storage.buckets where id='mathly-resources'").fetchone()[0] is False | |
| c.execute("set role service_role") | |
| c.execute("insert into math_unit_resources(unit_id,kind,skill_id,object_key,byte_size,created_by) " | |
| "values('t','exercise','t_ext','t/a.jpg',10,%s)", (TEACHER,)) | |
| for bad in ("('t','other','','t/b.jpg',10)", "('t','photo','','t/c.jpg',10)", "('t','theory','','t/a.jpg',10)"): | |
| with pytest.raises(psycopg.errors.IntegrityError): | |
| c.execute(f"insert into math_unit_resources(unit_id,kind,note,object_key,byte_size) values{bad}") | |
| c.execute("insert into storage.objects(bucket_id,name) values('mathly-resources','t/a.jpg')") | |
| c.execute('reset role') | |
| c.execute('set role authenticated') | |
| with pytest.raises(psycopg.errors.InsufficientPrivilege): | |
| c.execute('select * from math_unit_resources') | |
| assert c.execute("select count(*) from storage.objects where bucket_id='mathly-resources'").fetchone()[0] == 0 | |
| finally: | |
| with psycopg.connect(postgres_server.get_uri(), autocommit=True) as c: | |
| c.execute('drop database ' + database + ' with (force)') | |
| def resource_server(): | |
| store = MemoryResources() | |
| app = app_with(store) | |
| with socket.socket() as sock: | |
| sock.bind(('127.0.0.1', 0)) | |
| server = uvicorn.Server(uvicorn.Config(app, log_level='error')) | |
| thread = threading.Thread(target=server.run, kwargs={'sockets': [sock]}, daemon=True) | |
| thread.start() | |
| try: | |
| deadline = time.monotonic() + 5 | |
| while not server.started: | |
| assert thread.is_alive() and time.monotonic() < deadline | |
| time.sleep(.01) | |
| yield f'http://127.0.0.1:{sock.getsockname()[1]}', store | |
| finally: | |
| server.should_exit = True | |
| thread.join(timeout=5) | |
| def phone(browser, account): | |
| ctx = browser.new_context(viewport={'width': 390, 'height': 844}, is_mobile=True, has_touch=True) | |
| ctx.route('https://cdn.jsdelivr.net/**', lambda route: route.abort()) | |
| ctx.add_init_script('''window.supabase={createClient:()=>({auth:{ | |
| getSession:async()=>({data:{session:{access_token:'%s',user:{id:'%s'}}}}), | |
| onAuthStateChange:()=>({data:{subscription:{unsubscribe(){}}}}),signOut:async()=>{}}})};''' % (account, account)) | |
| return ctx.new_page() | |
| def test_teacher_photographs_a_lesson_resource_on_a_phone(resource_server, tmp_path): | |
| playwright = pytest.importorskip('playwright.sync_api') | |
| url, store = resource_server | |
| photo = tmp_path / 'board.png' | |
| photo.write_bytes(png('lightyellow')) | |
| expect = playwright.expect | |
| with playwright.sync_playwright() as p: | |
| browser = p.chromium.launch() | |
| try: | |
| page = phone(browser, TEACHER) | |
| errors = [] | |
| page.on('pageerror', lambda error: errors.append(str(error))) | |
| page.goto(url) | |
| page.locator('.mx-nav [data-view="path"]').click() | |
| page.locator('#mx-unit-t .mx-uhead h2').click() # tapping the lesson's title | |
| sheet = page.locator('.mx-res') | |
| expect(sheet.locator('h3')).to_have_text('诪砖讗讘讬 讛砖讬注讜专') | |
| expect(sheet.locator('.mx-uchip')).to_have_text('讟讻谞讬拽讛 讗诇讙讘专讬转 1') | |
| expect(sheet).to_contain_text('注讜讚 讗讬谉 诪砖讗讘讬诐') | |
| assert page.locator('input[capture="environment"]').count() == 1 # the camera opens directly | |
| with page.expect_file_chooser() as chooser: | |
| sheet.locator('[data-act="res-take"]').click() | |
| chooser.value.set_files(str(photo)) | |
| form = sheet.locator('.mx-res-form') | |
| expect(form.locator('img.mx-thumb')).to_be_visible() | |
| form.locator('button[type=submit]').click() | |
| expect(sheet.locator('.mx-res-msg')).to_contain_text('讘讞专讜 诪讛 讬砖 讘转诪讜谞讛') | |
| assert not store.rows | |
| form.locator('label', has_text='讗讞专').click() | |
| expect(form.locator('input[name=note]')).to_be_visible() | |
| form.locator('label', has_text='转专讙讬诇 讞讚砖').click() | |
| expect(form.locator('input[name=note]')).to_be_hidden() | |
| form.locator('select[name=skill]').select_option('t_ext') | |
| form.locator('button[type=submit]').click() | |
| expect(sheet.locator('.mx-res-msg')).to_have_text('讛转诪讜谞讛 谞砖诪专讛 讘诪砖讗讘讬 讛砖讬注讜专.') | |
| item = sheet.locator('.mx-res-item') | |
| expect(item).to_have_count(1) | |
| expect(item).to_contain_text('转专讙讬诇 讞讚砖') | |
| expect(item).to_contain_text('驻讬诇讜讙 诪讜专讞讘') | |
| (row,) = store.rows.values() | |
| assert (row['unit_id'], row['kind'], row['skill_id']) == ('t', 'exercise', 't_ext') | |
| # free text, with no topic | |
| with page.expect_file_chooser() as chooser: | |
| sheet.locator('[data-act="res-take"]').click() | |
| chooser.value.set_files(str(photo)) | |
| form.locator('label', has_text='讗讞专').click() | |
| form.locator('input[name=note]').fill('讚祝 注讘讜讚讛 诇砖讬注讜专 讘讬转') | |
| form.locator('button[type=submit]').click() | |
| expect(item).to_have_count(2) | |
| expect(item.first).to_contain_text('讚祝 注讘讜讚讛 诇砖讬注讜专 讘讬转') | |
| # reopening loads the saved photos from the server | |
| sheet.locator('.mx-x').click() | |
| page.locator('#mx-unit-t .mx-res-open').click() | |
| expect(item).to_have_count(2) | |
| expect(item.last.locator('img')).to_have_attribute('src', re.compile('^blob:')) | |
| assert errors == [] | |
| student = phone(browser, STUDENT) | |
| student.goto(url) | |
| student.locator('.mx-nav [data-view="path"]').click() | |
| expect(student.locator('#mx-unit-t')).to_be_visible() | |
| expect(student.locator('.mx-res-open')).to_have_count(0) | |
| student.locator('#mx-unit-t .mx-uhead h2').click() | |
| expect(student.locator('.mx-res')).to_have_count(0) | |
| finally: | |
| browser.close() | |
| # ---- what students see | |
| def classed(*students_in_class_t): | |
| """An Auth with one class that has unit t, for the students listed; the others are in no class.""" | |
| class ClassedAuth(Auth): | |
| def classes(self): | |
| return [{'id': 1, 'name': '讟1', 'lessons': ['t'], 'emails': [s[:4] + '@test.invalid' for s in students_in_class_t], | |
| 'teachers': [], 'nicknames': {}, 'exams': []}] | |
| return ClassedAuth() | |
| def test_the_migration_adds_a_visible_column_that_starts_false(postgres_server): | |
| psycopg = pytest.importorskip('psycopg') | |
| database = 'resvis_' + uuid.uuid4().hex | |
| with psycopg.connect(postgres_server.get_uri(), autocommit=True) as c: | |
| c.execute('create database ' + database) | |
| try: | |
| with psycopg.connect(postgres_server.get_uri(database=database), autocommit=True) as c: | |
| c.execute(BOOTSTRAP) | |
| c.execute(Path('supabase/migrations/20261101090000_math_unit_resources.sql').read_text()) | |
| c.execute('insert into auth.users values(%s)', (TEACHER,)) | |
| c.execute("insert into math_unit_resources(unit_id,kind,object_key,byte_size) values('t','theory','t/old.jpg',10)") | |
| c.execute(Path('supabase/migrations/20261104180000_math_resources_visible.sql').read_text()) | |
| c.execute("insert into math_unit_resources(unit_id,kind,object_key,byte_size) values('t','exercise','t/new.jpg',10)") | |
| c.execute("insert into math_unit_resources(unit_id,kind,object_key,byte_size,visible) values('t','theory','t/shown.jpg',10,true)") | |
| rows = dict(c.execute('select object_key, visible from math_unit_resources').fetchall()) | |
| assert rows == {'t/old.jpg': False, 't/new.jpg': False, 't/shown.jpg': True} # what existed stays hidden | |
| assert c.execute("select is_nullable from information_schema.columns where table_name='math_unit_resources' " | |
| "and column_name='visible'").fetchone()[0] == 'NO' | |
| c.execute('set role authenticated') | |
| with pytest.raises(psycopg.errors.InsufficientPrivilege): | |
| c.execute('select visible from math_unit_resources') | |
| finally: | |
| with psycopg.connect(postgres_server.get_uri(), autocommit=True) as c: | |
| c.execute('drop database ' + database + ' with (force)') | |
| def test_a_resource_is_visible_by_default_only_when_it_is_a_theory_explanation(): | |
| store = MemoryResources() | |
| app = app_with(store) | |
| async def check(): | |
| async with client(app, TEACHER) as teacher: | |
| made = {k: (await add(teacher, kind=k, note='讛注专讛')).json() for k in ('exercise', 'theory', 'other')} | |
| assert [made[k]['visible'] for k in ('exercise', 'theory', 'other')] == [False, True, False] | |
| on = (await teacher.post('/api/math/units/t/resources', data={'kind': 'exercise', 'visible': '1'}, | |
| files={'photo': ('p.png', png(), 'image/png')})).json() | |
| off = (await teacher.post('/api/math/units/t/resources', data={'kind': 'theory', 'visible': '0'}, | |
| files={'photo': ('p.png', png(), 'image/png')})).json() | |
| assert (on['visible'], off['visible']) == (True, False) # the form's box wins over the default | |
| listed = (await teacher.get('/api/math/units/t/resources')).json()['resources'] | |
| assert {r['id']: r['visible'] for r in listed} == {made['exercise']['id']: False, made['theory']['id']: True, | |
| made['other']['id']: False, on['id']: True, off['id']: False} | |
| asyncio.run(check()) | |
| def test_only_the_teacher_who_added_a_resource_or_a_manager_changes_who_sees_it(): | |
| store = MemoryResources() | |
| app = app_with(store) | |
| async def check(): | |
| async with client(app, TEACHER) as teacher, client(app, COLLEAGUE) as colleague, \ | |
| client(app, STUDENT) as student, client(app, ADMIN) as admin: | |
| rid = (await add(teacher)).json()['id'] | |
| patch = lambda c, **body: c.patch(f'/api/math/resources/{rid}', json=body) | |
| assert (await patch(colleague, visible=True)).status_code == 403 | |
| assert (await patch(student, visible=True)).status_code == 403 | |
| assert store.rows[rid]['visible'] is False # still hidden | |
| shown = await patch(teacher, visible=True) | |
| assert shown.status_code == 200 and shown.json()['visible'] is True and 'object_key' not in shown.json() | |
| assert (await patch(admin, visible=False)).json()['visible'] is False | |
| for bad in ({}, {'visible': 'yes'}, {'visible': 1}, {'visible': None}): | |
| assert (await patch(teacher, **bad)).status_code == 400 | |
| assert (await teacher.patch(f'/api/math/resources/{uuid.uuid4()}', json={'visible': True})).status_code == 404 | |
| assert (await teacher.patch('/api/math/resources/not-a-uuid', json={'visible': True})).status_code == 404 | |
| asyncio.run(check()) | |
| def test_students_see_only_the_visible_resources_of_their_classs_units(): | |
| store = MemoryResources() | |
| app = create_app(auth=classed(STUDENT)) | |
| app.state.resources = store | |
| async def check(): | |
| async with client(app, TEACHER) as teacher, client(app, STUDENT) as student, client(app, COLLEAGUE) as colleague: | |
| shown = (await add(teacher, kind='theory', skill='t_ext')).json() | |
| hidden = (await add(teacher, kind='exercise')).json() | |
| other_unit = (await add(teacher, unit='k', kind='theory')).json() # t2 would come with t: it was split off from it | |
| assert shown['visible'] and not hidden['visible'] | |
| listed = await student.get('/api/math/units/t/student-resources') | |
| assert listed.status_code == 200 | |
| rows = listed.json()['resources'] | |
| assert [r['id'] for r in rows] == [shown['id']] and rows[0]['skill_id'] == 't_ext' | |
| assert set(rows[0]) == {'id', 'unit_id', 'kind', 'note', 'skill_id', 'created_at'} # no teacher, no storage path | |
| assert 'test.invalid' not in listed.text and 'object_key' not in listed.text | |
| image = await student.get(f"/api/math/student-resources/{shown['id']}/image") | |
| assert image.status_code == 200 and image.headers['content-type'] == 'image/jpeg' | |
| assert image.headers['cache-control'].startswith('private') | |
| # a hidden resource, asked for by its id, is not found | |
| assert (await student.get(f"/api/math/student-resources/{hidden['id']}/image")).status_code == 404 | |
| # a unit their class does not have | |
| assert (await student.get('/api/math/units/k/student-resources')).status_code == 404 | |
| assert (await student.get(f"/api/math/student-resources/{other_unit['id']}/image")).status_code == 404 | |
| assert (await student.get('/api/math/units/nope/student-resources')).status_code == 404 | |
| assert (await student.get('/api/math/student-resources/not-a-uuid/image')).status_code == 404 | |
| # the teacher's own list still has all of them, each marked | |
| assert len((await teacher.get('/api/math/units/t/resources')).json()['resources']) == 2 | |
| # hiding it again takes it away from the student at once | |
| await teacher.patch(f"/api/math/resources/{shown['id']}", json={'visible': False}) | |
| assert (await student.get('/api/math/units/t/student-resources')).json() == {'resources': []} | |
| assert (await student.get(f"/api/math/student-resources/{shown['id']}/image")).status_code == 404 | |
| async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test') as guest: | |
| assert (await guest.get('/api/math/units/t/student-resources')).status_code == 401 | |
| asyncio.run(check()) | |
| def test_without_any_class_every_unit_is_open_to_students(): | |
| store = MemoryResources() | |
| app = app_with(store) # no classes configured: the whole path is open | |
| async def check(): | |
| async with client(app, TEACHER) as teacher, client(app, STUDENT) as student: | |
| await add(teacher, unit='t2', kind='theory') | |
| assert len((await student.get('/api/math/units/t2/student-resources')).json()['resources']) == 1 | |
| asyncio.run(check()) | |
| def jpeg(color='white'): | |
| out = io.BytesIO() | |
| Image.new('RGB', (200, 120), color).save(out, 'JPEG') | |
| return out.getvalue() | |
| def seed(store, kind='theory', skill=None, note='', visible=True, unit='t', owner=TEACHER): | |
| key = f'{unit}/{uuid.uuid4()}.jpg' | |
| store.put(key, jpeg()) | |
| return store.add({'unit_id': unit, 'kind': kind, 'note': note, 'skill_id': skill, 'object_key': key, 'byte_size': 10, | |
| 'created_by': owner, 'created_email': 'aaaa@test.invalid', 'visible': visible}) | |
| def test_the_teacher_decides_what_students_see_with_a_box_and_a_toggle(resource_server, tmp_path): | |
| playwright = pytest.importorskip('playwright.sync_api') | |
| url, store = resource_server | |
| photo = tmp_path / 'board.png' | |
| photo.write_bytes(png('lightyellow')) | |
| expect = playwright.expect | |
| with playwright.sync_playwright() as p: | |
| browser = p.chromium.launch() | |
| try: | |
| page = phone(browser, TEACHER) | |
| errors = [] | |
| page.on('pageerror', lambda error: errors.append(str(error))) | |
| page.goto(url) | |
| page.locator('.mx-nav [data-view="path"]').click() | |
| page.locator('#mx-unit-t .mx-uhead h2').click() | |
| sheet = page.locator('.mx-res') | |
| def take(kind): | |
| with page.expect_file_chooser() as chooser: | |
| sheet.locator('[data-act="res-take"]').click() | |
| chooser.value.set_files(str(photo)) | |
| form = sheet.locator('.mx-res-form') | |
| form.locator('label', has_text=kind).click() | |
| return form | |
| # the box follows the kind until the teacher decides | |
| form = take('讛住讘专 转讬讗讜专讟讬') | |
| box = form.locator('input[name=visible]') | |
| expect(box).to_be_checked() | |
| form.locator('label', has_text='转专讙讬诇 讞讚砖').click() | |
| expect(box).not_to_be_checked() | |
| form.locator('label', has_text='讛住讘专 转讬讗讜专讟讬').click() | |
| expect(box).to_be_checked() | |
| form.locator('label', has_text='转专讙讬诇 讞讚砖').click() | |
| box.check() # an exercise the teacher wants shown | |
| form.locator('label', has_text='讛住讘专 转讬讗讜专讟讬').click() | |
| expect(box).to_be_checked() # a decision is not overridden | |
| form.locator('label', has_text='转专讙讬诇 讞讚砖').click() | |
| expect(box).to_be_checked() | |
| form.locator('button[type=submit]').click() | |
| expect(sheet.locator('.mx-res-item')).to_have_count(1) | |
| expect(sheet.locator('.mx-res-vis')).to_have_text('馃憗 讙诇讜讬 诇转诇诪讬讚讬诐') | |
| # a theory explanation is shown without touching the box; an exercise left alone stays hidden | |
| form = take('讛住讘专 转讬讗讜专讟讬') | |
| form.locator('button[type=submit]').click() | |
| form = take('转专讙讬诇 讞讚砖') | |
| expect(form.locator('input[name=visible]')).not_to_be_checked() | |
| form.locator('button[type=submit]').click() | |
| expect(sheet.locator('.mx-res-item')).to_have_count(3) | |
| assert sorted(r['visible'] for r in store.rows.values()) == [False, True, True] | |
| hidden = sheet.locator('.mx-res-item', has=page.locator('.mx-res-vis:not(.on)')) | |
| expect(hidden).to_have_count(1) | |
| hidden.locator('[data-act=res-visible]').click() | |
| expect(sheet.locator('.mx-res-msg')).to_have_text('讛转诪讜谞讛 讙诇讜讬讛 注讻砖讬讜 诇转诇诪讬讚讬诐.') | |
| assert [r['visible'] for r in store.rows.values()].count(True) == 3 | |
| expect(sheet.locator('.mx-res-vis.on')).to_have_count(3) | |
| sheet.locator('.mx-res-item').first.locator('[data-act=res-visible]').click() | |
| expect(sheet.locator('.mx-res-msg')).to_have_text('讛转诪讜谞讛 诪讜住转专转 诪讛转诇诪讬讚讬诐.') | |
| expect(sheet.locator('.mx-res-vis.on')).to_have_count(2) | |
| # a colleague sees which are shown, but cannot change them | |
| colleague = phone(browser, COLLEAGUE) | |
| colleague.goto(url) | |
| colleague.locator('.mx-nav [data-view="path"]').click() | |
| colleague.locator('#mx-unit-t .mx-uhead h2').click() | |
| other = colleague.locator('.mx-res') | |
| expect(other.locator('.mx-res-item')).to_have_count(3) | |
| expect(other.locator('.mx-res-vis')).to_have_count(3) | |
| expect(other.locator('[data-act=res-visible]')).to_have_count(0) | |
| assert errors == [] | |
| finally: | |
| browser.close() | |
| def test_a_student_finds_the_teachers_explanations_in_the_skill_its_help_and_the_unit(resource_server): | |
| playwright = pytest.importorskip('playwright.sync_api') | |
| url, store = resource_server | |
| expect = playwright.expect | |
| seed(store, kind='theory', skill='t_ext', note='') # for the skill | |
| seed(store, kind='other', note='住讬讻讜诐 讛讬讞讬讚讛', visible=True) # for the whole unit | |
| seed(store, kind='exercise', skill='t_ext', visible=False) # hidden | |
| seed(store, kind='theory', skill='t_simplify', visible=False) | |
| with playwright.sync_playwright() as p: | |
| browser = p.chromium.launch() | |
| try: | |
| page = phone(browser, STUDENT) | |
| errors = [] | |
| page.on('pageerror', lambda error: errors.append(str(error))) | |
| page.goto(url) | |
| page.locator('.mx-nav [data-view="path"]').click() | |
| # the skill's sheet: the explanation tagged with it, and only that one | |
| page.locator('.mx-star[data-id="t_ext"]').click() | |
| box = page.locator('.mx-sheet [data-skill-res]') | |
| expect(box).to_be_visible() | |
| expect(box.locator('h4')).to_have_text('讛住讘专讬诐 诪讛诪讜专讛') | |
| expect(box.locator('figure')).to_have_count(1) | |
| expect(box.locator('figure img')).to_have_attribute('src', re.compile('^blob:')) | |
| expect(box.locator('figcaption')).to_contain_text('讛住讘专 转讬讗讜专讟讬') | |
| # its help panel shows it too | |
| page.locator('.mx-helpbtn').click() | |
| expect(page.locator('.mx-help [data-help-resources] figure')).to_have_count(1) | |
| expect(page.locator('.mx-help [data-help-resources] img')).to_have_attribute('src', re.compile('^blob:')) | |
| page.locator('.mx-help button.solid').click() | |
| page.locator('.mx-sheet .mx-x').last.click() | |
| # a skill with none visible shows no such box | |
| page.locator('.mx-star[data-id="t_simplify"]').click() | |
| expect(page.locator('.mx-sheet [data-skill-res]')).to_be_hidden() | |
| page.locator('.mx-sheet .mx-x').last.click() | |
| # the unit's own: a button under the unit, then the list | |
| button = page.locator('#mx-unit-t .mx-sres-open') | |
| expect(button).to_have_text('馃摲 讛住讘专讬诐 诪讛诪讜专讛 (1)') | |
| button.click() | |
| listed = page.locator('.mx-res [data-unit-res]') | |
| expect(listed.locator('figure')).to_have_count(1) | |
| expect(listed.locator('figcaption')).to_contain_text('住讬讻讜诐 讛讬讞讬讚讛') | |
| listed.locator('.mx-sres-img').click() # tap to enlarge | |
| expect(listed.locator('.mx-sres-item.zoom')).to_have_count(1) | |
| assert errors == [] | |
| finally: | |
| browser.close() | |
| def test_a_unit_without_explanations_for_the_unit_has_no_button(resource_server): | |
| playwright = pytest.importorskip('playwright.sync_api') | |
| url, store = resource_server | |
| seed(store, kind='theory', skill='t_ext') # tied to a skill only | |
| seed(store, kind='other', note='诪讜住转专', visible=False) | |
| with playwright.sync_playwright() as p: | |
| browser = p.chromium.launch() | |
| try: | |
| page = phone(browser, STUDENT) | |
| page.goto(url) | |
| page.locator('.mx-nav [data-view="path"]').click() | |
| playwright.expect(page.locator('#mx-unit-t')).to_be_visible() | |
| page.wait_for_timeout(800) | |
| assert page.locator('.mx-sres-open').count() == 0 | |
| finally: | |
| browser.close() | |
| def class_server(): | |
| """The app with one class (unit t) that the student is in, and the resources kept in memory.""" | |
| store = MemoryResources() | |
| app = create_app(auth=classed(STUDENT)) | |
| app.state.resources = store | |
| with socket.socket() as sock: | |
| sock.bind(('127.0.0.1', 0)) | |
| server = uvicorn.Server(uvicorn.Config(app, log_level='error')) | |
| thread = threading.Thread(target=server.run, kwargs={'sockets': [sock]}, daemon=True) | |
| thread.start() | |
| try: | |
| deadline = time.monotonic() + 5 | |
| while not server.started: | |
| assert thread.is_alive() and time.monotonic() < deadline | |
| time.sleep(.01) | |
| yield f'http://127.0.0.1:{sock.getsockname()[1]}', store | |
| finally: | |
| server.should_exit = True | |
| thread.join(timeout=5) | |
| def test_a_theory_photo_a_teacher_uploads_appears_in_their_students_skill_sheet(class_server, tmp_path): | |
| """The whole path in two browsers: the teacher photographs a theory explanation of a skill, and the student of the | |
| class, opening that skill, finds it under the teacher's explanations.""" | |
| playwright = pytest.importorskip('playwright.sync_api') | |
| url, store = class_server | |
| photo = tmp_path / 'board.png' | |
| photo.write_bytes(png('lightyellow')) | |
| expect = playwright.expect | |
| with playwright.sync_playwright() as p: | |
| browser = p.chromium.launch() | |
| try: | |
| teacher = phone(browser, TEACHER) | |
| teacher.goto(url) | |
| teacher.locator('.mx-nav [data-view="path"]').click() | |
| teacher.locator('#mx-unit-t .mx-uhead h2').click() | |
| sheet = teacher.locator('.mx-res') | |
| with teacher.expect_file_chooser() as chooser: | |
| sheet.locator('[data-act="res-take"]').click() | |
| chooser.value.set_files(str(photo)) | |
| form = sheet.locator('.mx-res-form') | |
| form.locator('label', has_text='讛住讘专 转讬讗讜专讟讬').click() | |
| form.locator('select[name=skill]').select_option('t_ext') | |
| form.locator('button[type=submit]').click() | |
| expect(sheet.locator('.mx-res-vis')).to_have_text('馃憗 讙诇讜讬 诇转诇诪讬讚讬诐') | |
| (row,) = store.rows.values() | |
| assert (row['kind'], row['skill_id'], row['visible']) == ('theory', 't_ext', True) | |
| student = phone(browser, STUDENT) | |
| errors = [] | |
| student.on('pageerror', lambda error: errors.append(str(error))) | |
| student.goto(url) | |
| student.locator('.mx-nav [data-view="path"]').click() | |
| student.locator('.mx-star[data-id="t_ext"]').click() | |
| box = student.locator('.mx-sheet [data-skill-res]') | |
| expect(box).to_be_visible() | |
| expect(box.locator('figure')).to_have_count(1) | |
| expect(box.locator('figure img')).to_have_attribute('src', re.compile('^blob:')) | |
| assert errors == [] | |
| # the teacher hides it: the student no longer finds it (after the page asks again) | |
| teacher.locator('.mx-res [data-act=res-visible]').click() | |
| expect(teacher.locator('.mx-res-vis')).to_have_text('馃敀 诪讜住转专 诪讛转诇诪讬讚讬诐') | |
| student.reload() | |
| student.locator('.mx-nav [data-view="path"]').click() | |
| student.locator('.mx-star[data-id="t_ext"]').click() | |
| expect(student.locator('.mx-sheet [data-skill-res]')).to_be_hidden() | |
| finally: | |
| browser.close() | |