"""End-to-end test in Firefox: two participants, one page, through the relay. Starts a relay and the local program on free ports, then drives a headless Firefox through geckodriver (the WebDriver protocol, spoken here with the standard library only). Skipped if geckodriver is not installed. Run from the project directory: .venv/bin/python -m unittest tests.test_end_to_end -v Set KOKKOJA_SCREENSHOT to a file name to save a picture of the page as the second participant sees it at the end. """ import base64 import json import os import pathlib import re import shutil import socket import struct import subprocess import sys import tempfile import time import unittest sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import test_qr_code # noqa: E402 The reference encoder's modules. import urllib.error import urllib.parse import urllib.request import zlib PROJECT = pathlib.Path(__file__).resolve().parent.parent # Describes the page as one participant holds it: for each layer, by the # participant's name, the strokes with their points. PAGE_STATE = """ const session = window.kokkoja && window.kokkoja.session; if (!session) return null; const layers = {}; const texts = {}; for (const layer of session.page.layers.values()) { layers[layer.name] = [...layer.strokes.values()].map( (stroke) => ({id: stroke.id, done: stroke.done, points: stroke.points})); texts[layer.name] = [...layer.texts.values()]; } // The first image, and all of them in sequence. const images = session.page.images; const image = images[0] ?? null; const shown = document.querySelectorAll('.page_image'); return { status: document.getElementById('status').textContent, page: {number: session.page.number, width: session.page.width, height: session.page.height}, origin: [session.page.left, session.page.top], image: image && {type: image.type, size: image.size, hash: image.hash, ready: image.data !== null, shown: shown.length > 0 && !shown[0].hidden}, placed: image && {x: image.x, y: image.y, width: image.width, height: image.height}, hides: session.page.hides.map((h) => [h.x, h.y, h.width, h.height]), images: images.map((one, i) => ({ hash: one.hash, x: one.x, y: one.y, width: one.width, height: one.height, ready: one.data !== null, shown: i < shown.length && !shown[i].hidden})), participants: session.participantList().map( (p) => ({name: p.name, host: p.host, present: p.self || p.peer !== null})), layers, texts, }; """ # A press and release with the text tool, from (fx0, fy0) to (fx1, fy1) as # fractions of the page. TEXT_GESTURE = """ const [fx0, fy0, fx1, fy1] = arguments; const page = document.getElementById('page'); const box = page.getBoundingClientRect(); const capture = page.setPointerCapture; page.setPointerCapture = () => {}; const fire = (type, x, y, buttons) => page.dispatchEvent( new PointerEvent(type, {pointerId: 9, pointerType: 'mouse', isPrimary: true, clientX: box.left + x, clientY: box.top + y, pressure: 0.5, button: type === 'pointermove' ? -1 : 0, buttons, bubbles: true, cancelable: true})); fire('pointerdown', fx0 * box.width, fy0 * box.height, 1); fire('pointermove', fx1 * box.width, fy1 * box.height, 1); fire('pointerup', fx1 * box.width, fy1 * box.height, 0); page.setPointerCapture = capture; """ # A pen press with Ctrl held at a fraction of the page, moved by dy CSS # pixels (optional), and lifted: a zoom drag, or a Ctrl-tap. CTRL_DRAG = """ const [fx0, fy0, fx1, fy1, dy] = arguments; const page = document.getElementById('page'); const box = page.getBoundingClientRect(); const capture = page.setPointerCapture; page.setPointerCapture = () => {}; const fire = (type, x, y, buttons) => page.dispatchEvent( new PointerEvent(type, {pointerId: 7, pointerType: 'pen', isPrimary: true, ctrlKey: true, clientX: box.left + x, clientY: box.top + y, pressure: 0.5, button: type === 'pointermove' ? -1 : 0, buttons, bubbles: true, cancelable: true})); const x = fx0 * box.width, y = fy0 * box.height; fire('pointerdown', x, y, 1); if (dy) { fire('pointermove', x, y + dy / 2, 1); fire('pointermove', x, y + dy, 1); } fire('pointerup', x, y + (dy || 0), 0); page.setPointerCapture = capture; """ # A pen press at (x, y) CSS pixels from the page's top left, held still # for `wait` ms, then moved by (dx, dy) in two steps and lifted. PEN_HOLD = """ const [x, y, dx, dy, wait, done] = arguments; const page = document.getElementById('page'); const box = page.getBoundingClientRect(); const capture = page.setPointerCapture; page.setPointerCapture = () => {}; const fire = (type, px, py, buttons) => page.dispatchEvent( new PointerEvent(type, {pointerId: 11, pointerType: 'pen', isPrimary: true, clientX: box.left + px, clientY: box.top + py, pressure: 0.5, button: type === 'pointermove' ? -1 : 0, buttons, bubbles: true, cancelable: true})); fire('pointerdown', x, y, 1); setTimeout(() => { fire('pointermove', x + dx / 2, y + dy / 2, 1); fire('pointermove', x + dx, y + dy, 1); fire('pointerup', x + dx, y + dy, 0); page.setPointerCapture = capture; done(true); }, wait); """ # A pen press on the text field at (x, y) CSS pixels from the page's top # left, held still for `wait` ms, moved by (dx, dy) and lifted. FIELD_HOLD = """ const [x, y, dx, dy, wait, done] = arguments; const page = document.getElementById('page'); const field = document.querySelector('.text_field'); const box = page.getBoundingClientRect(); field.setPointerCapture = () => {}; const fire = (type, px, py, buttons) => field.dispatchEvent( new PointerEvent(type, {pointerId: 12, pointerType: 'pen', isPrimary: true, clientX: box.left + px, clientY: box.top + py, pressure: 0.5, button: type === 'pointermove' ? -1 : 0, buttons, bubbles: true, cancelable: true})); fire('pointerdown', x, y, 1); setTimeout(() => { fire('pointermove', x + dx / 2, y + dy / 2, 1); fire('pointermove', x + dx, y + dy, 1); fire('pointerup', x + dx, y + dy, 0); done(true); }, wait); """ # Drags one of the text editor's handles by (dx, dy) CSS pixels. DRAG_HANDLE = """ const [selector, dx, dy] = arguments; const handle = document.querySelector(selector); const box = handle.getBoundingClientRect(); handle.setPointerCapture = () => {}; const fire = (type, x, y, buttons) => handle.dispatchEvent( new PointerEvent(type, {pointerId: 11, pointerType: 'mouse', isPrimary: true, clientX: x, clientY: y, button: type === 'pointermove' ? -1 : 0, buttons, bubbles: true, cancelable: true})); const x = box.left + box.width / 2, y = box.top + box.height / 2; fire('pointerdown', x, y, 1); fire('pointermove', x + dx, y + dy, 1); fire('pointerup', x + dx, y + dy, 0); """ # Draws with a simulated pen: a wave with changing pressure. Arguments: # x and y of the start, as fractions of the page width and height. PEN_WAVE = """ const [fx, fy] = arguments; const page = document.getElementById('page'); const box = page.getBoundingClientRect(); const capture = page.setPointerCapture; page.setPointerCapture = () => {}; const fire = (type, x, y, pressure, buttons) => page.dispatchEvent( new PointerEvent(type, {pointerId: 7, pointerType: 'pen', isPrimary: true, clientX: box.left + x, clientY: box.top + y, pressure, button: type === 'pointermove' ? -1 : 0, buttons, bubbles: true, cancelable: true})); const x0 = fx * box.width; const y0 = fy * box.height; fire('pointerdown', x0, y0, 0.1, 1); for (let i = 1; i <= 60; i++) { const t = i / 60; fire('pointermove', x0 + t * 150, y0 + Math.sin(t * Math.PI * 6) * 25, 0.15 + 0.8 * Math.abs(Math.sin(t * Math.PI * 3)), 1); } fire('pointerup', x0 + 150, y0, 0, 0); page.setPointerCapture = capture; """ # Clicks the host's "Co-host" box beside the named participant. TOGGLE_PERMISSION = """ const find = () => [...document.querySelectorAll('#participants li')] .find((li) => li.textContent.includes(arguments[0])).querySelector('.allow'); find().click(); return String(find().checked); // The list is rebuilt by the click. """ # Makes an image of the given size in pixels and puts it on the page, as # the "Image" button does with a file. Arguments: width, height, kind # ('png' or 'jpeg'), a resolution in dots per inch to record in the file, # or 0 for none, and whether to press "New" first, for a page of its own. LOAD_IMAGE = """ const [width, height, kind, dpi, fresh, done] = arguments; if (fresh) document.getElementById('new_page').click(); const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const context = canvas.getContext('2d'); context.fillStyle = '#f4f0e0'; context.fillRect(0, 0, width, height); context.fillStyle = '#203040'; for (let y = 40; y < height; y += 60) context.fillRect(30, y, width - 60, 3); const crc32 = (bytes) => { let crc = -1; for (const byte of bytes) { crc ^= byte; for (let k = 0; k < 8; k++) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)); } return (crc ^ -1) >>> 0; }; const withResolution = async (blob) => { if (!dpi) return blob; const bytes = new Uint8Array(await blob.arrayBuffer()); if (kind === 'png') { // A pHYs chunk after IHDR: pixels per metre, both ways, unit 1. const perMetre = Math.round(dpi / 0.0254); const chunk = new Uint8Array(4 + 4 + 9 + 4); const view = new DataView(chunk.buffer); view.setUint32(0, 9); chunk.set([0x70, 0x48, 0x59, 0x73], 4); view.setUint32(8, perMetre); view.setUint32(12, perMetre); chunk[16] = 1; view.setUint32(17, crc32(chunk.subarray(4, 17))); const ihdrEnd = 8 + 4 + 4 + 13 + 4; const out = new Uint8Array(bytes.length + chunk.length); out.set(bytes.subarray(0, ihdrEnd)); out.set(chunk, ihdrEnd); out.set(bytes.subarray(ihdrEnd), ihdrEnd + chunk.length); return new Blob([out], {type: 'image/png'}); } // The JFIF header: units 1 (inches) and the density, at fixed offsets. const view = new DataView(bytes.buffer); if (view.getUint16(2) !== 0xffe0) throw new Error('no JFIF header to change'); bytes[13] = 1; view.setUint16(14, dpi); view.setUint16(16, dpi); return new Blob([bytes], {type: 'image/jpeg'}); }; canvas.toBlob((blob) => withResolution(blob) .then((file) => window.kokkoja.loadImage(file).then(() => done(file.size))), kind === 'png' ? 'image/png' : 'image/jpeg', 0.92); """ # Puts the eraser down at (x, y), in page units, moves it dx to the right # and lifts it. ERASER_AT = """ const [x, y, dx] = arguments; const page = document.getElementById('page'); const box = page.getBoundingClientRect(); const extent = window.kokkoja.session.page; const cx = (px) => box.left + (px - extent.left) / extent.width * box.width; const cy = (py) => box.top + (py - extent.top) / extent.height * box.height; const capture = page.setPointerCapture; page.setPointerCapture = () => {}; const fire = (type, px, buttons) => page.dispatchEvent( new PointerEvent(type, {pointerId: 7, pointerType: 'pen', isPrimary: true, clientX: cx(px), clientY: cy(y), pressure: 0.5, button: type === 'pointermove' ? -1 : 0, buttons, bubbles: true, cancelable: true})); document.querySelector('[data-tool=eraser]').click(); fire('pointerdown', x, 1); fire('pointermove', x + dx, 1); fire('pointerup', x + dx, 0); document.querySelector('[data-tool=pen]').click(); page.setPointerCapture = capture; """ # Describes how the page is shown: whether fitted to the window, at what # scale, and whether it extends beyond the window. VIEW = """ const desk = document.getElementById('desk'); return { scale: Math.round(window.kokkoja.renderer.cssPixelsPerUnit * 1e6) / 1e6, top: Math.round(desk.scrollTop), scrollsDown: desk.scrollHeight > desk.clientHeight + 1, scrollsAcross: desk.scrollWidth > desk.clientWidth + 1, }; """ # Describes the file chooser as it stands. FILE_CHOOSER = """ const texts = (selector) => [...document.querySelectorAll(selector)].map((e) => e.textContent); return { open: document.getElementById('file_chooser').open, title: document.getElementById('files_title').textContent, directory: document.getElementById('files_directory').value, directories: texts('.files_directories button'), files: texts('.file_card span'), drawn: [...document.querySelectorAll('.file_card canvas')].map((c) => [c.width, c.height]), existing: texts('.files_existing button'), saving: !document.getElementById('files_save').hidden, name: document.getElementById('files_name').value, button: document.getElementById('files_save_here').textContent, message: document.getElementById('files_message').textContent, }; """ # Reads an exported picture back and returns the colours at three places: # a corner of the page, the middle of its first image, and the middle of # the teacher's latest stroke. Argument: the picture's full path. PICTURE_COLOURS = """ const [path, done] = arguments; (async () => { const {readFile} = await import('./js/files.js'); const bitmap = await createImageBitmap(await readFile(path)); const canvas = document.createElement('canvas'); canvas.width = bitmap.width; canvas.height = bitmap.height; const context = canvas.getContext('2d'); context.drawImage(bitmap, 0, 0); const at = (x, y) => [...context.getImageData( Math.round(x * 200 / 254), Math.round(y * 200 / 254), 1, 1).data].slice(0, 3); const session = window.kokkoja.session; const image = session.page.images[0]; const points = [...session.ownLayer.strokes.values()].at(-1).points; const middle = Math.floor(points.length / 6) * 3; return {size: [bitmap.width, bitmap.height], corner: at(20, 20), image: at(image.x + image.width / 2, image.y + image.height / 2), stroke: at(points[middle], points[middle + 1])}; })().then(done); """ def png_picture(width, height, colour): """A PNG file of one colour.""" def chunk(kind, data): return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data)) row = b"\x00" + bytes(colour) * width return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)) + chunk(b"IDAT", zlib.compress(row * height)) + chunk(b"IEND", b"")) # Moves the eraser down through the point (fx, fy) of the page. ERASE_THROUGH = """ const [fx, fy] = arguments; const page = document.getElementById('page'); const box = page.getBoundingClientRect(); const capture = page.setPointerCapture; page.setPointerCapture = () => {}; const fire = (type, x, y, buttons) => page.dispatchEvent( new PointerEvent(type, {pointerId: 7, pointerType: 'pen', isPrimary: true, clientX: box.left + x, clientY: box.top + y, pressure: 0.5, button: type === 'pointermove' ? -1 : 0, buttons, bubbles: true, cancelable: true})); document.querySelector('[data-tool=eraser]').click(); const x = fx * box.width; const y = fy * box.height; fire('pointerdown', x, y - 40, 1); for (let i = 0; i <= 8; i++) fire('pointermove', x, y - 40 + i * 10, 1); fire('pointerup', x, y + 40, 0); document.querySelector('[data-tool=pen]').click(); page.setPointerCapture = capture; """ # Taps the eraser `count` times at (x, y) in page units, `gap` ms apart, # and calls the last argument when done. ERASER_TAPS = """ const [x, y, count, gap, done] = arguments; const page = document.getElementById('page'); const box = page.getBoundingClientRect(); const extent = window.kokkoja.session.page; const cx = box.left + (x - extent.left) / extent.width * box.width; const cy = box.top + (y - extent.top) / extent.height * box.height; const capture = page.setPointerCapture; page.setPointerCapture = () => {}; const fire = (type, buttons) => page.dispatchEvent( new PointerEvent(type, {pointerId: 7, pointerType: 'pen', isPrimary: true, clientX: cx, clientY: cy, pressure: 0.5, button: type === 'pointermove' ? -1 : 0, buttons, bubbles: true, cancelable: true})); document.querySelector('[data-tool=eraser]').click(); let left = count; const tap = () => { fire('pointerdown', 1); fire('pointerup', 0); if (--left > 0) { setTimeout(tap, gap); } else { document.querySelector('[data-tool=pen]').click(); page.setPointerCapture = capture; done(); } }; tap(); """ def free_port(): with socket.socket() as listener: listener.bind(("127.0.0.1", 0)) return listener.getsockname()[1] def wait_for_port(port, seconds=15): deadline = time.monotonic() + seconds while time.monotonic() < deadline: with socket.socket() as probe: if probe.connect_ex(("127.0.0.1", port)) == 0: return time.sleep(0.1) raise TimeoutError(f"nothing is listening on port {port}") class Firefox: """A small WebDriver client.""" def __init__(self, port, downloads): self.base = f"http://127.0.0.1:{port}" capabilities = {"alwaysMatch": { "browserName": "firefox", "moz:firefoxOptions": { "args": ["-headless", "--width=2000", "--height=1000"], # Downloads go to the given directory without asking. "prefs": {"browser.download.folderList": 2, "browser.download.dir": str(downloads), "browser.download.useDownloadDir": True, "browser.download.always_ask_before_handling_new_types": False}, }, }} self.session = self.command("POST", "/session", {"capabilities": capabilities})["sessionId"] def command(self, method, path, body=None): data = None if body is None else json.dumps(body).encode() request = urllib.request.Request(self.base + path, data=data, method=method, headers={"Content-Type": "application/json"}) try: with urllib.request.urlopen(request, timeout=60) as response: return json.load(response)["value"] except urllib.error.HTTPError as error: raise RuntimeError(error.read().decode()) from None def in_session(self, method, path, body=None): return self.command(method, f"/session/{self.session}{path}", body) def go(self, address): self.in_session("POST", "/url", {"url": address}) def run(self, script, *arguments): return self.in_session("POST", "/execute/sync", {"script": script, "args": list(arguments)}) def run_until_done(self, script, *arguments): """Run a script that calls its last argument when it has finished.""" return self.in_session("POST", "/execute/async", {"script": script, "args": list(arguments)}) def new_tab(self): handle = self.in_session("POST", "/window/new", {"type": "tab"})["handle"] self.switch_to(handle) return handle def current_tab(self): return self.in_session("GET", "/window") def switch_to(self, handle): self.in_session("POST", "/window", {"handle": handle}) def drag(self, selector, from_x, from_y, to_x, to_y): """Drag the mouse over an element; positions are from its top left.""" box = self.run(f"const b = document.querySelector({json.dumps(selector)})" ".getBoundingClientRect(); return [b.left, b.top];") moves = [{"type": "pointerMove", "duration": 0, "origin": "viewport", "x": round(box[0] + from_x), "y": round(box[1] + from_y)}, {"type": "pointerDown", "button": 0}] for step in range(1, 11): moves.append({"type": "pointerMove", "duration": 20, "origin": "viewport", "x": round(box[0] + from_x + (to_x - from_x) * step / 10), "y": round(box[1] + from_y + (to_y - from_y) * step / 10)}) moves.append({"type": "pointerUp", "button": 0}) self.in_session("POST", "/actions", {"actions": [{ "type": "pointer", "id": "mouse", "parameters": {"pointerType": "mouse"}, "actions": moves}]}) def type_into(self, selector, text): """Type text into an element, as the keyboard would.""" found = self.in_session("POST", "/element", {"using": "css selector", "value": selector}) element_id = next(iter(found.values())) self.in_session("POST", f"/element/{element_id}/value", {"text": text}) def hover(self, selector): """Rest the mouse on the middle of an element.""" box = self.run(f"const b = document.querySelector({json.dumps(selector)})" ".getBoundingClientRect(); return [b.left + b.width / 2, b.top + b.height / 2];") self.in_session("POST", "/actions", {"actions": [{ "type": "pointer", "id": "mouse", "parameters": {"pointerType": "mouse"}, "actions": [{"type": "pointerMove", "duration": 0, "origin": "viewport", "x": round(box[0]), "y": round(box[1])}]}]}) def chord(self, *keys): """Press keys together and release them, as a keyboard shortcut.""" actions = ([{"type": "keyDown", "value": key} for key in keys] + [{"type": "keyUp", "value": key} for key in reversed(keys)]) self.in_session("POST", "/actions", {"actions": [{"type": "key", "id": "keyboard", "actions": actions}]}) def set_window(self, width, height): self.in_session("POST", "/window/rect", {"width": width, "height": height}) def screenshot(self, file_name): pathlib.Path(file_name).write_bytes(base64.b64decode(self.in_session("GET", "/screenshot"))) def quit(self): self.in_session("DELETE", "") @unittest.skipUnless(shutil.which("geckodriver"), "geckodriver is not installed") class EndToEndTest(unittest.TestCase): @classmethod def setUpClass(cls): cls.processes = [] cls.directory = tempfile.TemporaryDirectory() cls.notebooks = pathlib.Path(cls.directory.name) / "notebooks" # Deleted notebooks and pages go here, not to the real Trash. cls.trash = pathlib.Path(cls.directory.name) / "trash" relay_port, driver_port = free_port(), free_port() cls.start([sys.executable, str(PROJECT / "relay" / "kokkoja_relay.py"), "--port", str(relay_port)]) # Each participant has their own local program, as on their own # computer: it keeps their identity. All share one notebooks # directory here, so that the test can read every notebook. # A Kokkoja web site, as a server would host one: the client's files # and a config.json that says the notebooks are kept in the browser. # Its participants have nothing installed. site_port = free_port() site = pathlib.Path(cls.directory.name) / "site" shutil.copytree(PROJECT / "client", site) cls.site = f"http://127.0.0.1:{site_port}/" (site / "config.json").write_text(json.dumps( {"relay": f"ws://127.0.0.1:{relay_port}", "storage": "browser", "site": cls.site})) cls.start([sys.executable, "-m", "http.server", str(site_port), "--bind", "127.0.0.1", "--directory", str(site)]) # Where the browser's downloads go. Not in the temporary directory: # a Firefox installed as a snap has a /tmp of its own, and a download # saved there is invisible to this test. A directory under the home # directory is reachable by both. cls.downloads = pathlib.Path(tempfile.mkdtemp(prefix="downloads_", dir=PROJECT / "tests")) cls.programs = {} for name, host in (("Teacher", "localhost"), ("Student", "127.0.0.1"), ("Late", "localhost"), ("Host", "localhost")): port = free_port() config = pathlib.Path(cls.directory.name) / f"config_{name}" # The "Host" program knows the web site, so its invitations are links. site_option = ["--site", cls.site] if name == "Host" else [] cls.start([sys.executable, str(PROJECT / "kokkoja.py"), "--no-browser", "--port", str(port), "--relay", f"ws://127.0.0.1:{relay_port}", "--notebooks", str(cls.notebooks), "--config", str(config), "--trash", str(cls.trash), *site_option]) cls.programs[name] = (f"http://{host}:{port}/", config) cls.start(["geckodriver", "--port", str(driver_port)]) try: for port in (relay_port, driver_port, site_port): wait_for_port(port) for address, config in cls.programs.values(): wait_for_port(int(address.rsplit(":", 1)[1].rstrip("/"))) cls.programs[[k for k, v in cls.programs.items() if v[0] == address][0]] = ( address, (config / "token").read_text().strip()) cls.firefox = Firefox(driver_port, cls.downloads) except Exception: cls.stop() raise def address(self, name, join=None, focus_settle=False): """The Kokkoja address of a participant's own local program, with its token, and an invitation to join if given. By default it carries `?no_focus_settle`, which turns off the focusing-click rule: a driven browser gains focus on the first draw after a load or tab switch, which the rule would take for a focusing click (see test_a_focusing_click_draws_nothing, which keeps it on).""" base, token = self.programs[name] query = "" if focus_settle else "?no_focus_settle" joining = "" if join is None else f"&join={urllib.parse.quote(join, safe='')}" return f"{base}{query}#token={token}{joining}" @classmethod def start(cls, command): cls.processes.append(subprocess.Popen( command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)) @classmethod def stop(cls): for process in reversed(cls.processes): process.terminate() for process in cls.processes: process.wait(timeout=10) @classmethod def tearDownClass(cls): try: cls.firefox.quit() finally: cls.stop() cls.directory.cleanup() shutil.rmtree(cls.downloads, ignore_errors=True) def state(self): return self.firefox.run(PAGE_STATE) def wait_until_true(self, read, condition, what, seconds=15): """Wait until condition(read()) is true and return the value read.""" deadline = time.monotonic() + seconds value = None while time.monotonic() < deadline: value = read() if condition(value): return value time.sleep(0.1) self.fail(f"timed out waiting for {what}; last value: {value}") def wait_until(self, condition, what, seconds=15): """Wait until condition(state) is true and return the state.""" deadline = time.monotonic() + seconds state = None while time.monotonic() < deadline: state = self.state() if state and condition(state): return state time.sleep(0.1) self.fail(f"timed out waiting for {what}; last state: {json.dumps(state)[:600]}") def enter_name(self, name, button): # The start screen appears once the client has read config.json. self.wait_until_true(lambda: self.firefox.run("return !document.getElementById('start').hidden;"), bool, "the start screen") # The notebook is made through the picker: "New notebook…", then its name. self.firefox.run(""" document.getElementById('name').value = arguments[0]; const pick = document.getElementById('notebook'); pick.value = '/new'; pick.dispatchEvent(new Event('change')); const entry = document.getElementById('notebook_name'); entry.value = arguments[0] + ' notebook'; entry.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', bubbles: true})); if (pick.value !== arguments[0] + ' notebook') throw new Error('notebook not chosen: ' + pick.value); document.getElementById(arguments[1]).click(); """, name, button) return self.wait_until(lambda s: s["status"] == "Connected", f"{name} to connect") @staticmethod def stroke_counts(text): """For each layer of a page file that has strokes, by name, their number.""" counts = {layer.split('data-name="')[1].split('"')[0]: layer.count(" l.textContent.trim()), invitation: document.getElementById('invitation').value}; """), {"open": True, "names": ["Guest", "Host"], "invitation": link}) # Below the link stands the same invitation as a QR code: the # modules of the reference encoder, inside the quiet zone. drawn = firefox.run(""" const svg = document.querySelector('#invitation_code svg'); return {hidden: document.getElementById('invitation_code').hidden, viewBox: svg.getAttribute('viewBox'), d: svg.firstElementChild.getAttribute('d')}; """) _, _, matrix = test_qr_code.reference(link.encode(), "M") size = len(matrix) self.assertEqual((drawn["hidden"], drawn["viewBox"]), (False, f"-4 -4 {size + 8} {size + 8}")) self.assertEqual({(int(x), int(y)) for x, y in re.findall(r"M(\d+) (\d+)h1v1h-1z", drawn["d"])}, {(x, y) for y, row in enumerate(matrix) for x, dark in enumerate(row) if dark}) # The log in the window records the stroke drawn above, as a press # and a release of the pen, after the browser's name. shown = firefox.run("return document.getElementById('event_log').textContent;") self.assertRegex(shown, r"^\d\d:\d\d:\d\d\.\d\d\d Browser: Mozilla") self.assertRegex(shown, r"\n\S+ press pen \d+ buttons 1 pressure \d\.\d\d \((?:in front|behind)\), pen\n") self.assertRegex(shown, r"\n\S+ pointerup pen \d+, \d+ points") firefox.run("document.getElementById('session_close').click();") self.assertFalse(firefox.run("return document.getElementById('session_dialog').open;")) # While all is well the notice line says nothing and takes no room. self.assertEqual(firefox.run("const n = document.getElementById('notice'); return [n.hidden, n.textContent];"), [True, ""]) # The guest's page is in the browser's notebook, nowhere on disk. self.wait_until_true(lambda: firefox.run("return document.getElementById('save_status').textContent;"), lambda v: v.startswith("Saved at"), "the guest's page to be saved") self.assertFalse((self.notebooks / "Guest notebook").exists()) in_browser = firefox.run_until_done(""" const done = arguments[0]; const m = window.kokkoja.notebook; (async () => { const {notebooks, directory} = await m.listNotebooks(); done({notebooks, directory, pages: await m.listKeptPages('Guest notebook'), current: await m.readCurrentPage('Guest notebook')}); })().catch((e) => done({error: String(e)})); """) self.assertEqual(in_browser["notebooks"], ["Guest notebook"]) self.assertEqual(in_browser["directory"], "") self.assertEqual(in_browser["pages"], []) # The page is written a moment after the latest change. self.wait_until_true(lambda: self.stroke_counts(firefox.run_until_done(""" const done = arguments[0]; window.kokkoja.notebook.readCurrentPage('Guest notebook').then(done); """) or ""), lambda counts: counts == {"Host": 1, "Guest": 1}, "both strokes in the browser's notebook") # Keeping the page keeps it in the browser and downloads it. firefox.run("document.getElementById('keep_page').click();") kept = self.wait_for_download(f"{today}_01.svg").decode() self.assertEqual(self.stroke_counts(kept), {"Host": 1, "Guest": 1}) self.assertIn("Kept as", firefox.run("return document.getElementById('notice').textContent;")) self.assertEqual(firefox.run_until_done(""" const done = arguments[0]; window.kokkoja.notebook.listKeptPages('Guest notebook').then(done); """), [f"{today}_01.svg"]) # A second press keeps nothing new and downloads nothing more. firefox.run("document.getElementById('keep_page').click();") self.wait_until_true(lambda: firefox.run("return document.getElementById('notice').textContent;"), lambda v: v.startswith("Already kept"), "the second keep to be reported") time.sleep(1) self.assertEqual(sorted(p.name for p in self.downloads.iterdir()), [f"{today}_01.svg"]) # The browser has no trash: a deleted page is gone, and the # notebook with it when it is deleted whole. self.assertEqual(firefox.run_until_done(""" const [name, done] = arguments; (async () => { const m = window.kokkoja.notebook; const listed = await m.listNotebooks(); await m.discardPage('Guest notebook', name); const after = await m.listKeptPages('Guest notebook'); await m.discardNotebook('Guest notebook'); done({trash: listed.trash, after, notebooks: (await m.listNotebooks()).notebooks, current: await m.readCurrentPage('Guest notebook')}); })().catch((e) => done({error: String(e)})); """, f"{today}_01.svg"), {"trash": False, "after": [], "notebooks": [], "current": None}) # Exporting downloads a picture. firefox.run("document.getElementById('export_page').click();") picture = self.wait_for_download(f"{today}_page_1.png") self.assertTrue(picture.startswith(b"\x89PNG")) self.assertIn("downloads", firefox.run("return document.getElementById('notice').textContent;")) # Allowed by the host, the guest puts an image on the page (the # file would come from the browser's own chooser). self.assertTrue(firefox.run("return document.getElementById('new_page').hidden;")) firefox.switch_to(host_tab) self.assertEqual(firefox.run(TOGGLE_PERMISSION, "Guest"), "true") firefox.switch_to(guest_tab) self.wait_until(lambda s: not firefox.run("return document.getElementById('new_page').hidden;"), "the guest to be allowed to start pages") firefox.run_until_done(LOAD_IMAGE, 300, 400, "png", 0, False) guest = self.wait_until(lambda s: s["image"] and s["image"]["ready"], "the guest's image") firefox.switch_to(host_tab) self.wait_until(lambda s: s["image"] and s["image"]["ready"] and s["image"]["hash"] == guest["image"]["hash"], "the host to receive the image") # Reloaded, the guest's tab comes back as the same participant with # the page from the browser's notebook. firefox.switch_to(guest_tab) firefox.run("location.reload();") time.sleep(0.5) reloaded = self.wait_until( lambda s: len(s["layers"].get("Guest", [])) == 1 and len(s["layers"].get("Host", [])) == 1 and s["image"] and s["image"]["ready"], "the guest's page after a reload") self.assertEqual(reloaded["layers"]["Guest"], guest["layers"]["Guest"]) self.assertEqual(len(reloaded["participants"]), 2) # The host leaves the session; the tabs are closed for the next test. firefox.in_session("DELETE", "/window") firefox.switch_to(host_tab) firefox.go("about:blank") def test_tooltips(self): """A button's explanation appears after the pointer has been in it for a second; once one is showing, a neighbour's appears at once; a press ends that.""" firefox = self.firefox firefox.go(self.address("Late")) self.enter_name("Reader", "start_session") self.wait_until(lambda s: s["page"]["number"] == 1, "the page") hover = lambda tool: firefox.run(""" document.querySelector('[data-tool=' + arguments[0] + ']') .dispatchEvent(new PointerEvent('pointerover', {bubbles: true, pointerType: 'pen'})); """, tool) showing = lambda: firefox.run(""" const t = document.querySelector('.tooltip'); return t && !t.hidden ? t.textContent.slice(0, 20) : null; """) hover("pen") time.sleep(0.5) self.assertIsNone(showing()) time.sleep(0.8) self.assertEqual(showing(), "Write on the page. O") # The neighbour rule: the next tooltip comes without the wait. firefox.run("document.body.dispatchEvent(new PointerEvent('pointerover', {bubbles: true}));") self.assertIsNone(showing()) hover("text") time.sleep(0.1) self.assertEqual(showing(), "Type on the page. Ta") # A press ends the tooltip and the rule. firefox.run("document.body.dispatchEvent(new PointerEvent('pointerdown', {bubbles: true}));") self.assertIsNone(showing()) hover("pen") time.sleep(0.5) self.assertIsNone(showing()) time.sleep(0.8) self.assertEqual(showing(), "Write on the page. O") # In the Notebooks chooser, a page card's tooltip gives the facts # about the page, headed by its first text (nearest the top, then # the left), and a notebook's gives its facts; both shown inside # the dialog, which is above everything else. notebook = self.notebooks / "Reader notebook" notebook.mkdir(parents=True, exist_ok=True) (notebook / "2026_10_01_01.svg").write_text( '' '' 'Lower text' 'Lower text' 'Übung 12: der Dativ' 'Übung 12: der Dativ') (notebook / "2026_10_03_01.svg").write_text( '') firefox.run("document.getElementById('load_notebook_page').click();") self.wait_until_true(lambda: firefox.run(""" return [...document.querySelectorAll('#chooser_notebooks button.notebook_pick')].map((b) => b.textContent); """), lambda names: "Reader notebook" in names, "the chooser to list the notebooks") firefox.run(""" [...document.querySelectorAll('#chooser_notebooks button.notebook_pick')] .find((b) => b.textContent === 'Reader notebook').click(); """) self.wait_until_true(lambda: firefox.run(""" return [...document.querySelectorAll('#chooser_pages .page_card')].map((c) => c.dataset.help); """), lambda helps: len(helps) == 2 and all("Sheet" in h for h in helps), "the cards' tooltips to be made") helps = firefox.run("return [...document.querySelectorAll('#chooser_pages .page_card')].map((c) => c.dataset.help);") self.assertEqual(helps[0].split("\n")[0], "Übung 12: der Dativ") self.assertIn("Written by Anna", helps[0]) self.assertIn("1 stroke, 2 text blocks", helps[0]) self.assertIn("Sheet 29.7 × 21.0 cm", helps[0]) self.assertRegex(helps[0], r"Last changed \S+ at \d") self.assertEqual(helps[1].split("\n")[0], "Last changed " + helps[1].split("\n")[0][13:]) self.assertIn("Nothing on it", helps[1]) self.wait_until_true(lambda: firefox.run(""" return [...document.querySelectorAll('#chooser_notebooks button.notebook_pick')] .find((b) => b.textContent === 'Reader notebook').dataset.help; """), lambda help: "kept pages" in help, "the notebook's tooltip to be made") self.assertIn("2 kept pages, from 2026-10-01 to 2026-10-03", firefox.run(""" return [...document.querySelectorAll('#chooser_notebooks button.notebook_pick')] .find((b) => b.textContent === 'Reader notebook').dataset.help; """)) firefox.run(""" document.querySelector('#chooser_pages .page_card') .dispatchEvent(new PointerEvent('pointerover', {bubbles: true, pointerType: 'mouse'})); """) time.sleep(1.3) self.assertEqual(firefox.run(""" const t = document.querySelector('dialog[open] .tooltip'); return t && !t.hidden ? t.textContent.split('\\n')[0] : null; """), "Übung 12: der Dativ") firefox.run("document.getElementById('chooser_close').click();") def test_hide_rectangles(self): """The teacher's requests of 2026-10-08: a Hide rectangle covers part of the page in white for everyone, marked by blue corners.""" firefox = self.firefox host_tab = firefox.current_tab() firefox.go(self.address("Late")) self.enter_name("Hider", "start_session") invitation = firefox.run("return document.getElementById('invitation').value;") guest_tab = firefox.new_tab() firefox.go(self.address("Student", join=invitation)) self.enter_name("Watcher", "join_session") self.wait_until(lambda s: s["page"]["number"] == 1 and len(s["participants"]) == 2, "the watcher") self.assertTrue(firefox.run("return document.querySelector('[data-tool=hide]').hidden;")) firefox.switch_to(host_tab) self.assertFalse(firefox.run("return document.querySelector('[data-tool=hide]').hidden;")) # The host writes, then hides a rectangle over the writing. firefox.drag("#page", 200, 300, 500, 320) written = self.wait_until(lambda s: s["layers"]["Hider"] and s["layers"]["Hider"][-1]["done"], "a stroke") firefox.run("document.querySelector('[data-tool=hide]').click();") self.assertTrue(firefox.run("return document.getElementById('colors').hidden;")) firefox.run(TEXT_GESTURE, 0.05, 0.2, 0.35, 0.4) made = self.wait_until(lambda s: len(s["hides"]) == 1, "the Hide rectangle") hide = made["hides"][0] self.assertGreater(hide[2], 100) self.assertGreater(hide[3], 100) white_at = lambda x, y: firefox.run(""" const r = window.kokkoja.renderer; const c = r.hideCanvas.getContext('2d'); const [x, y] = arguments; const px = c.getImageData((x - r.page.left) * r.scale, (y - r.page.top) * r.scale, 1, 1).data; return px[3] === 255 && px[0] === 255 && px[1] === 255 && px[2] === 255; """, x, y) self.assertTrue(white_at(hide[0] + hide[2] / 2, hide[1] + hide[3] / 2)) self.assertFalse(white_at(hide[0] + hide[2] + 100, hide[1] + hide[3] + 100)) # The watcher sees it; hiding the host's layer on the watcher's # screen does not uncover it; the watcher can neither move nor # remove it. firefox.switch_to(guest_tab) self.wait_until(lambda s: s["hides"] == made["hides"], "the Hide to reach the watcher") firefox.run(""" [...document.querySelectorAll('#participants li')].find((li) => li.textContent.includes('Hider')) .querySelector('input[type=checkbox]').click(); """) self.assertTrue(white_at(hide[0] + hide[2] / 2, hide[1] + hide[3] / 2)) vscale = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;") origin = self.state()["origin"] firefox.run_until_done(PEN_HOLD, (hide[0] + 50 - origin[0]) * vscale, (hide[1] + 50 - origin[1]) * vscale, 100 * vscale, 0, 800) firefox.run(ERASER_AT, hide[0] + 50, hide[1] + 50, 5) time.sleep(0.5) self.assertEqual(self.state()["hides"], made["hides"]) # The host moves it by a hold, resizes it freely from a corner, # and the file carries it above every layer. firefox.switch_to(host_tab) scale = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;") origin = self.state()["origin"] px = lambda x: (x - origin[0]) * scale py = lambda y: (y - origin[1]) * scale firefox.run_until_done(PEN_HOLD, px(hide[0] + 50), py(hide[1] + 50), 200 * scale, 100 * scale, 800) moved = self.wait_until(lambda s: s["hides"][0][0] == hide[0] + 200, "the Hide moved")["hides"][0] self.assertEqual(moved, [hide[0] + 200, hide[1] + 100, hide[2], hide[3]]) firefox.run_until_done(PEN_HOLD, px(moved[0] + moved[2]) - 4, py(moved[1] + moved[3]) - 4, 300 * scale, 0, 800) resized = self.wait_until(lambda s: s["hides"][0][2] > moved[2], "the Hide resized")["hides"][0] # The width grew; the height followed the pen, 4 pixels inside the corner: no shape kept. self.assertAlmostEqual(resized[3], moved[3], delta=4 / scale + 1) self.assertEqual(resized[:2], moved[:2]) saved = self.current_page("Hider notebook", {"Hider": 1}, number=1) self.assertLess(saved.index(' 300, "the image scaled") self.assertEqual((scaled["images"][1]["x"], scaled["images"][1]["y"]), (moved["images"][1]["x"], moved["images"][1]["y"])) self.assertAlmostEqual(scaled["images"][1]["width"] / scaled["images"][1]["height"], 1.5, places=2) self.assertAlmostEqual(scaled["images"][1]["width"], 600, delta=12) # The eraser holds an image too, and erases nothing meanwhile: a # stroke under the press stays, and the image moves. firefox.run("document.querySelector('[data-tool=eraser]').click();") firefox.run("document.querySelector('[data-tool=pen]').click();") firefox.drag("#page", px(scaled["images"][1]["x"] + 100), py(scaled["images"][1]["y"] + 100), px(scaled["images"][1]["x"] + 160), py(scaled["images"][1]["y"] + 104)) on_image = self.wait_until(lambda s: s["layers"]["Mover"][-1]["done"] and len(s["layers"]["Mover"]) == strokes_before + 1, "a stroke on the image") firefox.run("document.querySelector('[data-tool=eraser]').click();") pen_hold(px(scaled["images"][1]["x"] + 120), py(scaled["images"][1]["y"] + 102), 200 * scale, 0) erased_not = self.wait_until(lambda s: s["images"][1]["x"] == scaled["images"][1]["x"] + 200, "the image moved by the eraser's hold") self.assertEqual(len(erased_not["layers"]["Mover"]), strokes_before + 1) firefox.run("document.querySelector('[data-tool=pen]').click();") scaled = erased_not # A guest given the right adds an image, keeps the power to remove it # after the right is withdrawn, and cannot remove the host's. self.assertEqual(firefox.run(TOGGLE_PERMISSION, "Viewer"), "true") firefox.switch_to(guest_tab) self.wait_until(lambda s: not firefox.run("return document.getElementById('new_page').hidden;"), "the viewer to be allowed") firefox.run_until_done(LOAD_IMAGE, 200, 100, "png", 0, False) added = self.wait_until(lambda s: len(s["images"]) == 3, "the viewer's image") firefox.switch_to(host_tab) self.wait_until(lambda s: len(s["images"]) == 3, "the viewer's image to reach the host") self.assertEqual(firefox.run(TOGGLE_PERMISSION, "Viewer"), "false") firefox.switch_to(guest_tab) self.wait_until(lambda s: firefox.run("return document.getElementById('new_page').hidden;"), "the right to be withdrawn") own = added["images"][2] firefox.run(ERASER_AT, own["x"] + 100, own["y"] + 50, 5) self.wait_until(lambda s: len(s["images"]) == 2, "the viewer's own image removed") hosts = self.state()["images"][1] firefox.run(ERASER_AT, hosts["x"] + 50, hosts["y"] + 50, 5) time.sleep(0.5) self.assertEqual(len(self.state()["images"]), 2) firefox.switch_to(host_tab) self.wait_until(lambda s: len(s["images"]) == 2, "the removal to reach the host") firefox.switch_to(guest_tab) # The viewer sees the same, and cannot move an image: a hold there # draws a dot instead. firefox.switch_to(guest_tab) viewer = self.wait_until(lambda s: s["images"] and rect(s, 1) == rect(scaled, 1), "the viewer's copy") self.assertEqual(viewer["images"][1]["hash"], scaled["images"][1]["hash"]) vscale = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;") firefox.run_until_done(PEN_HOLD, (viewer["images"][1]["x"] + 50 - viewer["origin"][0]) * vscale, (viewer["images"][1]["y"] + 50 - viewer["origin"][1]) * vscale, 60 * vscale, 0, 800) time.sleep(0.5) after = self.state() self.assertEqual(rect(after, 1), rect(scaled, 1)) self.assertEqual(len(after["layers"].get("Viewer", [])), 1) firefox.in_session("DELETE", "/window") firefox.switch_to(host_tab) firefox.go("about:blank") def test_character_codes_in_text(self): """Andy, 2026-10-13: Klammertext's character codes in a text block, replaced as each is completed; the field's own undo survives a replacement.""" firefox = self.firefox host_tab = firefox.current_tab() firefox.go(self.address("Late")) self.enter_name("Typist", "start_session") invitation = firefox.run("return document.getElementById('invitation').value;") guest_tab = firefox.new_tab() firefox.go(self.address("Student", join=invitation)) self.enter_name("Onlooker", "join_session") self.wait_until(lambda s: s["page"]["number"] == 1 and len(s["participants"]) == 2, "the onlooker") firefox.switch_to(host_tab) field_text = lambda: firefox.run("return document.querySelector('.text_field').value;") firefox.run("document.querySelector('[data-tool=text]').click();") firefox.run(TEXT_GESTURE, 0.2, 0.3, 0.2, 0.3) firefox.type_into(".text_field", 'Gr^u"^s^e, ^a` 2^^3, ^OE^uvre, ^00E9^, m^a1, ^130ED^ ^9053 53EF^') self.assertEqual(field_text(), "Grüße, à 2^3, Œuvre, é, mā, 𓃭 道可") self.wait_until(lambda s: s["texts"]["Typist"] and s["texts"]["Typist"][0]["text"] == "Grüße, à 2^3, Œuvre, é, mā, 𓃭 道可", "the text with its codes replaced") # The caret counts UTF-16 units, so the hieroglyph counts twice. self.assertEqual(firefox.run("return document.querySelector('.text_field').selectionStart;"), len("Grüße, à 2^3, Œuvre, é, mā, 𓃭 道可".encode("utf-16-le")) // 2) # In the middle of the text too, and only at the caret: the ^ # left earlier stays. firefox.run("const f = document.querySelector('.text_field'); f.setSelectionRange(2, 2);") firefox.type_into(".text_field", "^o-") self.assertEqual(field_text(), "Grōüße, à 2^3, Œuvre, é, mā, 𓃭 道可") self.assertEqual(firefox.run("return document.querySelector('.text_field').selectionStart;"), 3) # The browser's own undo in the field is not lost by a replacement: # one step gives back the code as typed. firefox.chord("\ue009", "z") self.assertEqual(field_text(), "Gr^o-üße, à 2^3, Œuvre, é, mā, 𓃭 道可") firefox.run("document.querySelector('[data-tool=pen]').click();") shown = self.wait_until(lambda s: s["texts"]["Typist"] and "üße" in s["texts"]["Typist"][0]["text"], "the finished block")["texts"]["Typist"][0]["text"] firefox.switch_to(guest_tab) self.wait_until(lambda s: s["texts"].get("Typist") and s["texts"]["Typist"][0]["text"] == shown, "the onlooker to see the same text") def test_text_being_typed_stays_on_the_page_left(self): """Andy, 2026-10-06: a guest was typing when the host pressed New, and the text reappeared on the new page.""" firefox = self.firefox host_tab = firefox.current_tab() firefox.go(self.address("Late")) self.enter_name("Lecturer", "start_session") invitation = firefox.run("return document.getElementById('invitation').value;") guest_tab = firefox.new_tab() firefox.go(self.address("Student", join=invitation)) self.enter_name("Writer", "join_session") self.wait_until(lambda s: s["page"]["number"] == 1 and len(s["participants"]) == 2, "the writer to be in the session") # The writer is in the middle of typing when the host starts a new page. firefox.run("document.querySelector('[data-tool=text]').click();") firefox.run(TEXT_GESTURE, 0.2, 0.3, 0.2, 0.3) firefox.type_into(".text_field", "unfinished") self.wait_until(lambda s: s["texts"].get("Writer") and s["texts"]["Writer"][0]["text"] == "unfinished", "the writer's text") firefox.switch_to(host_tab) self.wait_until(lambda s: s["texts"].get("Writer") and s["texts"]["Writer"][0]["text"] == "unfinished", "the host to receive the text") firefox.run("document.getElementById('new_page').click();") self.wait_until(lambda s: s["page"]["number"] == 2, "the host's new page") # The new page is empty for both, and stays so; the text is on # the kept page in both notebooks. firefox.switch_to(guest_tab) self.wait_until(lambda s: s["page"]["number"] == 2, "the writer's new page") self.assertTrue(firefox.run("return document.querySelector('.text_field').hidden;")) time.sleep(1) self.assertEqual(self.state()["texts"], {"Lecturer": [], "Writer": []}) firefox.switch_to(host_tab) self.assertEqual(self.state()["texts"], {"Lecturer": [], "Writer": []}) today = time.strftime("%Y_%m_%d") for notebook in ("Lecturer notebook", "Writer notebook"): self.assertEqual(self.wait_for_kept(notebook, 1), [f"{today}_01.svg"]) self.assertIn("unfinished", (self.notebooks / notebook / f"{today}_01.svg").read_text()) self.current_page("Lecturer notebook", {}, number=2) self.assertNotIn("unfinished", (self.notebooks / "Lecturer notebook" / "Current.svg").read_text()) # "Extend" off: a stroke stops short of the page's edge and the page # does not grow; on again, writing near the edge grows it. extend = lambda: firefox.run("return document.getElementById('extend').checked;") self.assertTrue(extend()) firefox.run("document.getElementById('extend').click();") self.assertFalse(extend()) width = self.state()["page"]["width"] box = firefox.run("const b = document.getElementById('page').getBoundingClientRect();" "return [b.width, b.height];") firefox.drag("#page", box[0] - 60, 300, box[0] - 2, 300) stopped = self.wait_until(lambda s: s["layers"]["Lecturer"] and s["layers"]["Lecturer"][-1]["done"], "the stroke at the edge") self.assertEqual(stopped["page"]["width"], width) self.assertLessEqual(max(stopped["layers"]["Lecturer"][-1]["points"][0::3]), width - 50) firefox.run("document.getElementById('extend').click();") self.assertTrue(extend()) firefox.drag("#page", box[0] - 60, 350, box[0] - 2, 350) grown = self.wait_until(lambda s: s["page"]["width"] > width, "the page to grow with Extend on") # An image goes to the top of the page as it stands: grown upward # by writing, the page's top is above the sheet's, and the image # lies the margin below that top, not below the old one (Andy, # 2026-10-08). firefox.drag("#page", 300, 4, 400, 4) upward = self.wait_until(lambda s: s["origin"][1] < 0, "the page to grow upward") firefox.run_until_done(LOAD_IMAGE, 300, 200, "png", 0, False) placed = self.wait_until(lambda s: s["image"] and s["image"]["ready"], "the image on the grown page") self.assertEqual(placed["placed"]["y"], upward["origin"][1] + 50) self.assertEqual(placed["placed"]["x"], upward["origin"][0] + (upward["page"]["width"] - 300) // 2) # Expand on a page that writing has grown on one side: the page # comes out as wide and as tall as the window, measured from the # page's own edges, not the sheet's (Andy, 2026-10-08, 2026-10-11). firefox.run(""" document.getElementById('desk').dispatchEvent(new WheelEvent('wheel', {deltaY: 100, ctrlKey: true, clientX: 300, clientY: 300, bubbles: true, cancelable: true})); """) firefox.run("document.getElementById('zoom_expand').click();") self.wait_until(lambda s: s["page"]["width"] > grown["page"]["width"] and s["page"]["height"] > grown["page"]["height"], "the page to expand") measured = firefox.run(""" const desk = document.getElementById('desk'); const style = getComputedStyle(desk); const page = document.getElementById('page').getBoundingClientRect(); return {width: page.width, height: page.height, room: desk.offsetWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight), roomHeight: desk.offsetHeight - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom)}; """) self.assertAlmostEqual(measured["width"], measured["room"], delta=1) self.assertAlmostEqual(measured["height"], measured["roomHeight"], delta=1) # The writer's tab is closed for the next test; the host's is cleared. firefox.switch_to(guest_tab) firefox.in_session("DELETE", "/window") firefox.switch_to(host_tab) firefox.go("about:blank") def test_two_participants(self): firefox = self.firefox # The teacher starts a session and draws two lines with the mouse. teacher_tab = firefox.current_tab() firefox.go(self.address("Teacher")) self.assertNotIn("token", firefox.run("return location.href;")) self.enter_name("Teacher", "start_session") # Without an invitation the start screen offered both choices. self.assertFalse(firefox.run("return document.getElementById('start_choice').hidden;")) invitation = firefox.run("return document.getElementById('invitation').value;") self.assertRegex(invitation, r"^ws://127\.0\.0\.1:\d+#[\w-]{22}\.[\w-]{22}$") firefox.drag("#page", 100, 100, 400, 180) firefox.drag("#page", 100, 250, 400, 230) teacher = self.wait_until( lambda s: len(s["layers"]["Teacher"]) == 2 and all(k["done"] for k in s["layers"]["Teacher"]), "the teacher's two strokes") self.assertGreaterEqual(len(teacher["layers"]["Teacher"][0]["points"]), 6) # The student joins later, from the invitation, and receives them. student_tab = firefox.new_tab() firefox.go(self.address("Student", join=invitation)) self.wait_until_true(lambda: firefox.run("return document.getElementById('invitation_entry').value;"), lambda v: v == invitation, "the invitation in the start screen") self.enter_name("Student", "join_session") student = self.wait_until(lambda s: len(s["layers"].get("Teacher", [])) == 2, "the student to receive the teacher's layer") self.assertEqual(student["layers"]["Teacher"], teacher["layers"]["Teacher"]) teacher_first_page = {"Teacher": teacher["layers"]["Teacher"]} self.assertEqual([p["host"] for p in student["participants"]], [False, True]) # The student writes with a pen; the teacher sees the same strokes. for fx in (0.15, 0.4, 0.65): firefox.run(PEN_WAVE, fx, 0.45) student = self.state() self.assertEqual(len(student["layers"]["Student"]), 3) pressures = student["layers"]["Student"][0]["points"][2::3] self.assertGreater(max(pressures) - min(pressures), 0.5) firefox.switch_to(teacher_tab) teacher = self.wait_until( lambda s: len(s["layers"].get("Student", [])) == 3 and all(k["done"] for k in s["layers"]["Student"]), "the teacher to receive the student's strokes") self.assertEqual(teacher["layers"]["Student"], student["layers"]["Student"]) # The student erases the middle stroke. firefox.switch_to(student_tab) box = firefox.run("const b = document.getElementById('page').getBoundingClientRect();" "return [b.width, b.height];") firefox.run(ERASE_THROUGH, 0.4 + 75 / box[0], 0.45) student = self.state() self.assertEqual(len(student["layers"]["Student"]), 2) firefox.switch_to(teacher_tab) teacher = self.wait_until(lambda s: len(s["layers"]["Student"]) == 2, "the erasure to reach the teacher") self.assertEqual(teacher["layers"]["Student"], student["layers"]["Student"]) # The teacher cannot erase the student's writing: the eraser acts # only on one's own layer. firefox.run(ERASE_THROUGH, 0.15 + 75 / box[0], 0.45) self.assertEqual(len(self.state()["layers"]["Student"]), 2) # The log in the Session window says so: the tool changed, and # the release erased nothing. firefox.run("document.getElementById('session_button').click();") shown = firefox.run("return document.getElementById('event_log').textContent;") firefox.run("document.getElementById('session_close').click();") self.assertRegex(shown, r"\n\S+ Tool: eraser\n") self.assertRegex(shown, r"\n\S+ pointerup pen \d+, erased 0 strokes and 0 texts\n") # The student reloads the page and gets everything back, as the # same participant. firefox.switch_to(student_tab) firefox.run("location.reload();") time.sleep(0.5) reloaded = self.wait_until( lambda s: len(s["layers"].get("Student", [])) == 2 and len(s["layers"].get("Teacher", [])) == 2, "the student's page after a reload") self.assertEqual(reloaded["layers"]["Student"], student["layers"]["Student"]) self.assertEqual(len(reloaded["participants"]), 2) # Each participant's notebook holds the page, with both layers. counts = {"Teacher": 2, "Student": 2} self.current_page("Student notebook", counts, number=1) saved = self.current_page("Teacher notebook", counts, number=1) self.assertTrue(saved.startswith('\n { const {pageToSvg, pageFromSvg} = await import('./js/page_file.js'); const session = window.kokkoja.session; const text = pageToSvg(session.page, {session: session.roomId, hostId: session.hostId}); const read = pageFromSvg(text); const strokes = (page) => JSON.stringify([...page.layers.values()] .map((layer) => [layer.id, layer.name, [...layer.strokes.values()]]) .sort()); return read.hostId === session.hostId && read.session === session.roomId && JSON.stringify(read.page.descriptor()) === JSON.stringify(session.page.descriptor()) && strokes(read.page) === strokes(session.page); })(); """)) # The student keeps the page. Asking again makes no second copy. today = time.strftime("%Y_%m_%d") firefox.switch_to(student_tab) firefox.run("document.getElementById('keep_page').click();") self.assertEqual(self.wait_for_kept("Student notebook", 1), [f"{today}_01.svg"]) firefox.run("document.getElementById('keep_page').click();") time.sleep(1) self.assertEqual(self.kept_pages("Student notebook"), [f"{today}_01.svg"]) self.assertIn("Already kept", firefox.run( "return document.getElementById('notice').textContent;")) # Only the host can start a new page. self.assertTrue(firefox.run("return document.getElementById('new_page').hidden;")) firefox.switch_to(teacher_tab) self.assertFalse(firefox.run("return document.getElementById('new_page').hidden;")) # The host starts a new page. Everyone moves to it, and everyone's # old page is kept without anyone asking: the teacher's now, the # student's already. firefox.run("document.getElementById('new_page').click();") self.assertEqual(self.state()["layers"], {"Teacher": [], "Student": []}) self.assertEqual(self.wait_for_kept("Teacher notebook", 1), [f"{today}_01.svg"]) kept = (self.notebooks / "Teacher notebook" / f"{today}_01.svg").read_text() self.assertEqual(self.stroke_counts(kept), counts) self.assertIn('data-number="1"', kept) self.current_page("Teacher notebook", {}, number=2) self.current_page("Student notebook", {}, number=2) self.assertEqual(self.kept_pages("Student notebook"), [f"{today}_01.svg"]) firefox.switch_to(student_tab) self.assertEqual(firefox.run("return document.getElementById('page_number').textContent;"), "Page 2") # Writing continues on the new page. firefox.run(PEN_WAVE, 0.2, 0.3) student = self.state() self.assertEqual(len(student["layers"]["Student"]), 1) firefox.switch_to(teacher_tab) teacher = self.wait_until( lambda s: len(s["layers"].get("Student", [])) == 1 and s["layers"]["Student"][0]["done"], "the student's stroke on the new page") self.assertEqual(teacher["layers"]["Student"], student["layers"]["Student"]) self.current_page("Teacher notebook", {"Student": 1}, number=2) # The host starts a new page and puts an image on it. The page that # was being written is kept in each notebook. size = firefox.run_until_done(LOAD_IMAGE, 300, 400, "png", 0, True) teacher = self.wait_until(lambda s: s["page"]["number"] == 3, "the teacher's image page") # The image lies at the top of an A4 sheet, centred across it, # inside the 5 mm margin, a pixel being 0.1 mm. self.assertEqual(teacher["page"], {"number": 3, "width": 2970, "height": 2100}) self.assertEqual(teacher["placed"], {"x": 1335, "y": 50, "width": 300, "height": 400}) self.assertEqual(teacher["image"]["type"], "image/png") self.assertEqual(teacher["image"]["size"], size) self.assertTrue(teacher["image"]["ready"] and teacher["image"]["shown"]) self.assertEqual(self.wait_for_kept("Teacher notebook", 2), [f"{today}_01.svg", f"{today}_02.svg"]) self.current_page("Teacher notebook", {}, number=3, images=1) # Zoom is each person's own. Ctrl (or Command) and the wheel zoom # by 10% about the pointer; Ctrl and a pen drag zoom continuously, # a factor of 2 per 100 pixels, nothing drawn; "Fit" shows the # whole page; "Widen" makes it as wide as the window with the top # of the view staying put. None of it changes the page. zoom = lambda: firefox.run(""" const r = window.kokkoja.renderer; const page = document.getElementById('page').getBoundingClientRect(); const desk = document.getElementById('desk'); return {zoom: r.zoom, width: page.width, top: desk.scrollTop, strokes: window.kokkoja.session.ownLayer.strokes.size}; """) before = zoom() self.assertIsInstance(before["zoom"], float) for modifier in ("ctrlKey", "metaKey"): firefox.run(""" document.getElementById('desk').dispatchEvent(new WheelEvent('wheel', {deltaY: -100, [arguments[0]]: true, clientX: 300, clientY: 300, bubbles: true, cancelable: true})); """, modifier) self.assertAlmostEqual(zoom()["width"], before["width"] * 1.21, delta=1) firefox.run("document.getElementById('zoom_fit').click();") self.assertEqual(zoom(), before) # A pen drag of 100 pixels up with Ctrl doubles the scale, 4 pixels # of it being the dead zone; a tap with Ctrl changes nothing. firefox.run(CTRL_DRAG, 0.5, 0.6, 0.5, 0.6) self.assertEqual(zoom(), before) firefox.run(CTRL_DRAG, 0.5, 0.7, 0.5, 0.7, -104) doubled = zoom() self.assertAlmostEqual(doubled["width"], before["width"] * 2, delta=2) self.assertEqual(doubled["strokes"], before["strokes"]) firefox.run(CTRL_DRAG, 0.5, 0.3, 0.5, 0.3, 104) self.assertAlmostEqual(zoom()["width"], before["width"], delta=2) # The host's Fit and A4 reach the guest, who then fits the page to # the guest's own window; the guest's own Fit stays the guest's. firefox.switch_to(student_tab) firefox.run(""" document.getElementById('desk').dispatchEvent(new WheelEvent('wheel', {deltaY: -100, ctrlKey: true, clientX: 300, clientY: 300, bubbles: true, cancelable: true})); """) guest_zoomed = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;") firefox.switch_to(teacher_tab) firefox.run("document.getElementById('zoom_fit').click();") firefox.switch_to(student_tab) self.wait_until_true(lambda: firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;"), lambda v: v < guest_zoomed, "the guest's view to follow the host's Fit") firefox.run("document.getElementById('zoom_sheet').click();") firefox.switch_to(teacher_tab) host_scale = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;") time.sleep(0.5) self.assertEqual(firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;"), host_scale) # Expand makes the sheet fill the window, for everyone, at this # zoom, in both directions; the scale and what is in view stay. firefox.run(""" document.getElementById('desk').dispatchEvent(new WheelEvent('wheel', {deltaY: 100, ctrlKey: true, clientX: 300, clientY: 300, bubbles: true, cancelable: true})); """) smaller = zoom() image_box = lambda: firefox.run(""" const b = document.querySelector('.page_image').getBoundingClientRect(); return [Math.round(b.left), Math.round(b.top), Math.round(b.width)]; """) box_before = image_box() firefox.run("document.getElementById('zoom_expand').click();") expanded = zoom() # The sheet grew equally on every side: nothing on it moved on screen. self.assertEqual(image_box(), box_before) self.assertLess(firefox.run("return window.kokkoja.session.page.baseLeft;"), 0) self.assertLess(firefox.run("return window.kokkoja.session.page.baseTop;"), 0) room = firefox.run(""" const desk = document.getElementById('desk'); const style = getComputedStyle(desk); return {width: desk.offsetWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight), height: desk.offsetHeight - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom)}; """) page_box = firefox.run(""" const b = document.getElementById('page').getBoundingClientRect(); return {width: b.width, height: b.height}; """) self.assertAlmostEqual(page_box["width"], room["width"], delta=1) self.assertAlmostEqual(page_box["height"], room["height"], delta=1) self.assertEqual(expanded["zoom"], smaller["zoom"]) wide_page = self.state()["page"] self.assertGreater(wide_page["width"], teacher["page"]["width"]) self.assertGreater(wide_page["height"], teacher["page"]["height"]) firefox.switch_to(student_tab) self.wait_until(lambda s: s["page"] == wide_page, "the expanded page to reach the student") self.assertTrue(firefox.run("return document.getElementById('zoom_expand').hidden;")) self.assertEqual(firefox.run("return window.kokkoja.session.page.baseLeft;"), -((wide_page["width"] - teacher["page"]["width"]) // 2)) # The page file carries the sheet's edges and reads back with them. expanded_file = self.current_page("Student notebook", {}, number=3) self.assertRegex(expanded_file, r'data-base="\d+ \d+ -\d+ -\d+"') firefox.switch_to(teacher_tab) # Shrink makes the sheet just large enough for what is on it, with # the margin around, on the margin's grid; everyone gets it, and # the file carries the sheet's edges. firefox.run("document.getElementById('zoom_shrink').click();") shrunk_page = self.wait_until(lambda s: s["page"]["width"] < wide_page["width"], "the shrunk page") sheet = firefox.run(""" const p = window.kokkoja.session.page; return {left: p.baseLeft, top: p.baseTop, width: p.baseWidth, height: p.baseHeight, pageLeft: p.left, pageTop: p.top, pageWidth: p.width, pageHeight: p.height, images: p.images.map((i) => [i.x, i.y, i.width, i.height])}; """) self.assertEqual((sheet["left"], sheet["top"], sheet["width"], sheet["height"]), (sheet["pageLeft"], sheet["pageTop"], sheet["pageWidth"], sheet["pageHeight"])) # Exactly the margin on every side of the contents: the image is at # the top and the strokes lie within it horizontally. x, y, w, h = sheet["images"][0] self.assertEqual(sheet["top"], y - 50) self.assertLessEqual(sheet["left"] + 50, x) self.assertGreaterEqual(sheet["left"] + sheet["width"] - 50, x + w) self.assertGreaterEqual(sheet["top"] + sheet["height"] - 50, y + h) self.assertLess(sheet["height"], 2100) firefox.switch_to(student_tab) self.wait_until(lambda s: s["page"] == shrunk_page["page"], "the shrunk page to reach the student") self.wait_until_true(lambda: self.current_page("Student notebook", {}, number=3), lambda text: f'data-base="{sheet["width"]} {sheet["height"]} {sheet["left"]} {sheet["top"]}"' in text, "the shrunk sheet in the student's file") firefox.switch_to(teacher_tab) # "A4" shows the shrunk page at the scale a new page has: a blank # sheet fitted to the window, so the page is smaller than it. firefox.run("document.getElementById('zoom_fit').click();") fitted_scale = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;") firefox.run("document.getElementById('zoom_sheet').click();") sheet_scale = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;") self.assertLess(sheet_scale, fitted_scale) self.assertAlmostEqual(sheet_scale, min(room["width"] / 2970, firefox.run(""" const desk = document.getElementById('desk'); const style = getComputedStyle(desk); return desk.offsetHeight - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom); """) / 2100), places=4) # Expanded again, from the shrunk sheet, for the room the rest needs. firefox.run("document.getElementById('zoom_fit').click();") firefox.run("document.getElementById('zoom_expand').click();") self.wait_until(lambda s: s["page"]["width"] > shrunk_page["page"]["width"], "the page expanded again") firefox.run("document.getElementById('zoom_fit').click();") teacher = self.state() # Resting the pointer on a button brings up its explanation below it. tooltip = lambda: firefox.run(""" const tip = document.querySelector('.tooltip'); const button = document.getElementById('new_page').getBoundingClientRect(); if (!tip || tip.hidden) return null; const box = tip.getBoundingClientRect(); return {text: tip.textContent, below: box.top > button.bottom, left: box.left, buttonLeft: button.left}; """) # One second in the button before the tooltip shows (two until # 2026-10-11, one until 2026-10-06). firefox.hover("#new_page") time.sleep(0.5) self.assertIsNone(tooltip()) time.sleep(0.8) shown = tooltip() self.assertEqual(shown["text"], firefox.run( "return document.getElementById('new_page').getAttribute('data-help');")) self.assertTrue(shown["below"]) self.assertAlmostEqual(shown["left"], shown["buttonLeft"], delta=1) firefox.hover("#page") self.assertIsNone(tooltip()) # A tooltip goes away as soon as a key is pressed. firefox.hover("#new_page") time.sleep(2.5) self.assertIsNotNone(tooltip()) firefox.run("document.dispatchEvent(new KeyboardEvent('keydown', {key: 'a', bubbles: true}));") self.assertIsNone(tooltip()) # Focus by a click brings no tooltip (it would cover the notebook # list); focus from the keyboard does. firefox.run(""" const pick = document.getElementById('session_notebook'); pick.dispatchEvent(new PointerEvent('pointerdown', {bubbles: true})); pick.focus(); """) self.assertIsNone(tooltip()) firefox.run("document.getElementById('session_notebook').blur();") time.sleep(0.6) firefox.run("document.getElementById('session_notebook').focus();") self.assertIsNotNone(tooltip()) firefox.run("document.getElementById('session_notebook').blur();") self.assertIsNone(tooltip()) # Text. A tap with the text tool places the corner of a block; # typing goes through a real field, umlauts and line breaks # included, and reaches the others as it is typed. firefox.run("document.querySelector('[data-tool=text]').click();") firefox.run(TEXT_GESTURE, 0.1, 0.6, 0.1, 0.6) self.assertFalse(firefox.run("return document.querySelector('.text_field').hidden;")) firefox.type_into(".text_field", "Grüße\nzwei Zeilen") typed = self.wait_until(lambda s: len(s["texts"]["Teacher"]) == 1 and s["texts"]["Teacher"][0]["text"] == "Grüße\nzwei Zeilen", "the typed text") block = typed["texts"]["Teacher"][0] self.assertEqual(block["lines"], ["Grüße", "zwei Zeilen"]) self.assertIsNone(block["width"]) # The field shows every line and no spare one, with room above the first. self.assertTrue(firefox.run(""" const field = document.querySelector('.text_field'); const style = getComputedStyle(field); const lineHeight = parseFloat(style.fontSize) * 1.25; const content = parseFloat(style.height); return Math.abs(content - 2 * lineHeight) < 1 && parseFloat(style.paddingTop) > 2; """)) self.assertEqual(block["color"], "#000000") self.assertEqual(block["font"], "sans-serif") firefox.switch_to(student_tab) self.wait_until(lambda s: s["texts"].get("Teacher") == [block], "the text to reach the student") firefox.switch_to(teacher_tab) # With the text tool, a size and a font stand where the line widths # were. Changing them changes the block being typed. self.assertTrue(firefox.run("return document.getElementById('widths').hidden;")) self.assertFalse(firefox.run("return document.getElementById('text_style').hidden;")) self.assertEqual(block["size"], 49.4) # 14 points. firefox.run(""" const size = document.getElementById('text_size'); size.value = '24'; size.dispatchEvent(new Event('change')); const font = document.getElementById('text_font'); font.value = 'serif'; font.dispatchEvent(new Event('change')); """) restyled = self.wait_until(lambda s: s["texts"]["Teacher"][0]["size"] == 84.7 and s["texts"]["Teacher"][0]["font"] == "serif", "the restyled text") self.assertGreater(restyled["texts"]["Teacher"][0]["boxHeight"], block["boxHeight"]) firefox.run(""" const size = document.getElementById('text_size'); size.value = '14'; size.dispatchEvent(new Event('change')); const font = document.getElementById('text_font'); font.value = 'sans-serif'; font.dispatchEvent(new Event('change')); """) block = self.wait_until(lambda s: s["texts"]["Teacher"][0]["size"] == 49.4, "the text back at 14 points")["texts"]["Teacher"][0] # A drag opens a text rectangle, shown dotted while dragging: a red # box, white inside, in which the text wraps and which grows # downward by lines as the text needs. A hold at its corner # resizes it freely; a hold inside moves it. firefox.run(""" const page = document.getElementById('page'); const box = page.getBoundingClientRect(); page.setPointerCapture = () => {}; const fire = (type, x, y, buttons) => page.dispatchEvent( new PointerEvent(type, {pointerId: 9, pointerType: 'mouse', isPrimary: true, clientX: box.left + x, clientY: box.top + y, button: type === 'pointermove' ? -1 : 0, buttons, bubbles: true, cancelable: true})); fire('pointerdown', 0.1 * box.width, 0.75 * box.height, 1); fire('pointermove', 0.25 * box.width, 0.8 * box.height, 1); """) preview = firefox.run(""" const p = document.querySelector('.rect_preview'); const b = p.getBoundingClientRect(); return {hidden: p.hidden, width: b.width, height: b.height}; """) self.assertFalse(preview["hidden"]) self.assertGreater(preview["width"], 50) self.assertGreater(preview["height"], 10) firefox.run(TEXT_GESTURE, 0.1, 0.75, 0.6, 0.78) self.assertTrue(firefox.run("return document.querySelector('.rect_preview').hidden;")) self.assertEqual(firefox.run("return getComputedStyle(document.querySelector('.text_field')).outlineStyle;"), "solid") firefox.type_into(".text_field", "Ein langer Satz, der an der rechten Kante umbrochen wird.") wrapped = self.wait_until(lambda s: len(s["texts"]["Teacher"]) == 2 and len(s["texts"]["Teacher"][1]["lines"]) > 1, "the wrapped text") wide = wrapped["texts"]["Teacher"][1] self.assertGreater(wide["width"], 100) self.assertIsNotNone(wide["height"]) self.assertGreaterEqual(wide["height"], wide["boxHeight"] - 1) self.assertEqual(" ".join(wide["lines"]), wide["text"]) # More text than the box holds: it grows downward. firefox.type_into(".text_field", " Und noch ein Satz, der die Box nach unten wachsen lässt, Zeile um Zeile.") grown = self.wait_until(lambda s: s["texts"]["Teacher"][1]["height"] > wide["height"], "the box to grow")["texts"]["Teacher"][1] self.assertGreater(len(grown["lines"]), len(wide["lines"])) # A hold on the field being typed in moves the box, typing going on # afterwards; a tap elsewhere then finishes it and starts nothing. scale = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;") origin = self.state()["origin"] px = lambda x: (x - origin[0]) * scale py = lambda y: (y - origin[1]) * scale firefox.run_until_done(FIELD_HOLD, px(grown["x"] + 80), py(grown["y"] + 40), 30 * scale, 50 * scale, 800) nudged = self.wait_until(lambda s: s["texts"]["Teacher"][1]["y"] > grown["y"], "the box to move while typed in")["texts"]["Teacher"][1] self.assertFalse(firefox.run("return document.querySelector('.text_field').hidden;")) firefox.type_into(".text_field", " Weiter.") self.wait_until(lambda s: s["texts"]["Teacher"][1]["text"].endswith(" Weiter."), "typing to go on") texts_before = len(self.state()["texts"]["Teacher"]) firefox.run(TEXT_GESTURE, 0.9, 0.95, 0.9, 0.95) time.sleep(0.4) self.assertTrue(firefox.run("return document.querySelector('.text_field').hidden;")) self.assertEqual(len(self.state()["texts"]["Teacher"]), texts_before) grown = self.state()["texts"]["Teacher"][1] # A tap in the box's empty part edits the box, the cursor at the end # of its text, rather than starting free text there. fx = (grown["x"] + grown["width"] - 40 - origin[0]) / self.state()["page"]["width"] fy = (grown["y"] + grown["height"] - 20 - origin[1]) / self.state()["page"]["height"] firefox.run(TEXT_GESTURE, fx, fy, fx, fy) self.assertEqual(firefox.run(""" const f = document.querySelector('.text_field'); return {hidden: f.hidden, rect: f.classList.contains('rect'), atEnd: f.selectionStart === f.value.length, length: f.value.length, blocks: window.kokkoja.session.ownLayer.texts.size}; """), {"hidden": False, "rect": True, "atEnd": True, "length": len(grown["text"]), "blocks": texts_before}) firefox.run("document.querySelector('[data-tool=pen]').click();") firefox.run("document.querySelector('[data-tool=text]').click();") firefox.run_until_done(PEN_HOLD, px(grown["x"] + grown["width"]) - 4, py(grown["y"] + grown["height"]) - 4, 600 * scale, 0, 800) widened = self.wait_until(lambda s: s["texts"]["Teacher"][1]["width"] > grown["width"], "the box to widen")["texts"]["Teacher"][1] self.assertLess(len(widened["lines"]), len(grown["lines"])) self.assertEqual((widened["x"], widened["y"]), (grown["x"], grown["y"])) firefox.run_until_done(PEN_HOLD, px(widened["x"] + 60), py(widened["y"] + 40), 40 * scale, 60 * scale, 800) moved = self.wait_until(lambda s: s["texts"]["Teacher"][1]["y"] > widened["y"], "the box to move")["texts"]["Teacher"][1] self.assertGreater(moved["x"], widened["x"]) self.assertEqual((moved["width"], moved["height"]), (widened["width"], widened["height"])) firefox.switch_to(student_tab) self.wait_until(lambda s: len(s["texts"].get("Teacher", [])) == 2 and s["texts"]["Teacher"][1] == moved, "the moved text to reach the student") firefox.switch_to(teacher_tab) # The page file holds the text, and reads it back. saved_text = self.wait_until_true( lambda: (self.notebooks / "Teacher notebook" / "Current.svg").read_text(), lambda saved: " { const {pageToSvg, pageFromSvg} = await import('./js/page_file.js'); const session = window.kokkoja.session; const read = pageFromSvg(pageToSvg(session.page, {session: session.roomId, hostId: session.hostId})); const texts = (page) => JSON.stringify([...page.layers.values()] .filter((l) => l.texts.size).map((l) => [l.id, [...l.texts.values()]])); return texts(read.page) === texts(session.page) || 'READ ' + texts(read.page) + ' LIVE ' + texts(session.page); })(); """) if round_trip is not True: self.fail(round_trip) # The eraser removes a whole block of one's own. firefox.run("document.querySelector('[data-tool=eraser]').click();") page_box = firefox.run("const b = document.getElementById('page').getBoundingClientRect(); return [b.width, b.height];") state = self.state() first = state["texts"]["Teacher"][0] fx = (first["x"] + 10 - state["origin"][0]) / state["page"]["width"] fy = (first["y"] + 10 - state["origin"][1]) / state["page"]["height"] firefox.run(ERASE_THROUGH, fx, fy + 20 / page_box[1]) self.wait_until(lambda s: len(s["texts"]["Teacher"]) == 1 and s["texts"]["Teacher"][0]["id"] == moved["id"], "the first text to be erased") firefox.run("document.querySelector('[data-tool=eraser]').click();") self.assertTrue(firefox.run(""" return ['colors', 'widths', 'text_style'].every((id) => document.getElementById(id).hidden); """)) firefox.run("document.querySelector('[data-tool=pen]').click();") self.assertFalse(firefox.run("return document.getElementById('widths').hidden;")) self.assertFalse(firefox.run("return document.getElementById('colors').hidden;")) self.assertTrue(firefox.run("return document.getElementById('text_style').hidden;")) teacher = self.state() # The text may have grown the page. firefox.switch_to(student_tab) student = self.wait_until(lambda s: s["image"] and s["image"]["ready"] and s["page"] == teacher["page"], "the image to reach the student") self.assertEqual(student["image"], teacher["image"]) self.assertEqual(student["placed"], teacher["placed"]) self.assertEqual(self.wait_for_kept("Student notebook", 2), [f"{today}_01.svg", f"{today}_02.svg"]) self.current_page("Student notebook", {}, number=3, images=1) firefox.run(PEN_WAVE, 0.2, 0.3) student = self.state() # Someone who joins now gets the page, the image and the writing. # Arriving with an invitation shows the start screen, where the name # and the notebook are chosen. firefox.new_tab() firefox.go(self.address("Late", join=invitation)) self.assertIsNone(self.state()) self.wait_until_true(lambda: firefox.run("return document.getElementById('invitation_entry').value;"), lambda v: v == invitation, "the invitation in the start screen") late = self.enter_name("Late", "join_session") late = self.wait_until( lambda s: s["image"] and s["image"]["ready"] and len(s["layers"].get("Student", [])) == 1, "the image and writing to reach the late joiner") self.assertEqual(late["image"], teacher["image"]) self.assertEqual(late["layers"]["Student"], student["layers"]["Student"]) saved = self.current_page("Late notebook", {"Student": 1}, number=3) self.assertRegex(saved, f'data-hash="{teacher["image"]["hash"]}" data-by="[A-Za-z0-9_-]{{22}}" x="1335" y="50" width="300" height="400"') firefox.go("about:blank") # Everyone leaves. The teacher comes back alone: the page, with its # image, is restored from the notebook, and writing continues in the # same file. firefox.switch_to(student_tab) firefox.go("about:blank") firefox.switch_to(teacher_tab) self.wait_until(lambda s: [p["present"] for p in s["participants"]] == [True, False, False], "the others to leave") firefox.run("location.reload();") time.sleep(0.5) alone = self.wait_until(lambda s: s["status"] == "Connected", "the teacher to reconnect") self.assertEqual(alone["layers"], {"Student": student["layers"]["Student"], "Teacher": []}) self.assertEqual(alone["page"], teacher["page"]) self.assertEqual(alone["image"], teacher["image"]) self.assertEqual(alone["participants"], [ {"name": "Teacher", "host": True, "present": True}, {"name": "Student", "host": False, "present": False}]) firefox.run("document.getElementById('zoom_fit').click();") # The view came back scrolled. firefox.drag("#page", 100, 500, 300, 520) self.current_page("Teacher notebook", {"Teacher": 1, "Student": 1}, number=3) self.assertEqual(len(self.kept_pages("Teacher notebook")), 2) # Changing notebook writes the page there from then on. A new # notebook is made from the picker's last entry and named in the # field that takes its place; an existing one is simply chosen. firefox.run(""" const pick = document.getElementById('session_notebook'); pick.value = '/new'; pick.dispatchEvent(new Event('change')); const entry = document.getElementById('session_notebook_name'); if (entry.hidden || !pick.hidden) throw new Error('the name field did not appear'); entry.value = 'Other notebook'; entry.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', bubbles: true})); if (!entry.hidden || pick.hidden) throw new Error('the name field did not go away'); """) self.current_page("Other notebook", {"Teacher": 1, "Student": 1}, number=3) # A refused name is explained and nothing changes. self.assertIn("cannot contain", firefox.run(""" const pick = document.getElementById('session_notebook'); pick.value = '/new'; pick.dispatchEvent(new Event('change')); const entry = document.getElementById('session_notebook_name'); entry.value = 'a/b'; entry.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', bubbles: true})); const said = document.getElementById('notice').textContent; entry.dispatchEvent(new KeyboardEvent('keydown', {key: 'Escape', bubbles: true})); if (pick.value !== 'Other notebook') throw new Error('not back to the notebook: ' + pick.value); return said; """)) self.assertEqual(firefox.run(""" const pick = document.getElementById('session_notebook'); return [...pick.options].map((o) => o.textContent); """)[-1], "New notebook…") firefox.run(""" const pick = document.getElementById('session_notebook'); pick.value = 'Teacher notebook'; pick.dispatchEvent(new Event('change')); """) time.sleep(1) self.assertEqual(len(self.kept_pages("Teacher notebook")), 2) # A new session in the same notebook: the page the last session # left there is kept, without anyone having asked. (The param # that turns off the focusing-click rule, as address() adds, so # that drawing after this navigation is not taken for a focusing # click by the driven browser.) firefox.go(self.programs["Teacher"][0] + "?no_focus_settle") firefox.run("location.reload();") time.sleep(0.5) self.enter_name("Teacher", "start_session") self.assertEqual(self.wait_for_kept("Teacher notebook", 3)[-1], f"{today}_03.svg") last = (self.notebooks / "Teacher notebook" / f"{today}_03.svg").read_text() self.assertEqual(self.stroke_counts(last), {"Teacher": 1, "Student": 1}) self.assertIn(" 2970, "the page to grow") self.assertEqual((grown["page"]["width"] - 2970) % 50, 0) firefox.switch_to(teacher_tab) self.wait_until(lambda s: s["page"] == grown["page"], "the teacher's page to grow the same way") box = f'viewBox="0 0 {grown["page"]["width"]} {grown["page"]["height"]}"' deadline = time.monotonic() + 10 while box not in (self.notebooks / "Teacher notebook" / "Current.svg").read_text(): self.assertLess(time.monotonic(), deadline, f"the saved page never got {box}") time.sleep(0.1) # An image that records its resolution is placed at that size: at # 300 dots per inch, 1200 pixels are 101.6 mm. PNG and JPEG alike. firefox.run_until_done(LOAD_IMAGE, 1200, 600, "png", 300, True) scanned = self.wait_until(lambda s: s["page"]["number"] == 4, "the scanned PNG page") self.assertEqual(scanned["placed"], {"x": 977, "y": 50, "width": 1016, "height": 508}) firefox.run_until_done(LOAD_IMAGE, 1200, 600, "jpeg", 300, True) scanned = self.wait_until(lambda s: s["page"]["number"] == 5, "the scanned JPEG page") self.assertEqual(scanned["image"]["type"], "image/jpeg") self.assertEqual(scanned["placed"], {"x": 977, "y": 50, "width": 1016, "height": 508}) # A further image goes on the same page, below the lowest image # there and the margin apart, centred like the first. The sheet # grows downward when an image needs the room. Everyone gets them. sheet_view = firefox.run(VIEW) self.assertFalse(sheet_view["scrollsDown"]) firefox.run_until_done(LOAD_IMAGE, 300, 400, "png", 0, False) self.assertEqual(firefox.run(VIEW), sheet_view) # The sheet did not grow. firefox.run_until_done(LOAD_IMAGE, 1000, 2500, "png", 0, False) stacked = self.wait_until(lambda s: len(s["images"]) == 3, "the three images") # The view keeps its scale: the page now extends below the window, # to be scrolled to, and is no longer fitted to it. long_view = {"scale": sheet_view["scale"], "top": 0, "scrollsDown": True, "scrollsAcross": False} self.assertEqual(firefox.run(VIEW), long_view) self.assertEqual(stacked["page"], {"number": 5, "width": 2970, "height": 3608}) self.assertEqual( [[i["x"], i["y"], i["width"], i["height"]] for i in stacked["images"]], [[977, 50, 1016, 508], [1335, 608, 300, 400], [985, 1058, 1000, 2500]]) self.assertTrue(all(i["ready"] and i["shown"] for i in stacked["images"])) # Each is shown where it lies on the page. self.assertEqual(firefox.run(""" const page = document.getElementById('page').getBoundingClientRect(); return [...document.querySelectorAll('.page_image')].map((img) => { const box = img.getBoundingClientRect(); return [(box.left - page.left) / page.width, (box.top - page.top) / page.height, box.width / page.width, box.height / page.height] .map((v) => Math.round(v * 1000)); }); """), [[round(v * 1000) for v in (i["x"] / 2970, i["y"] / 3608, i["width"] / 2970, i["height"] / 3608)] for i in stacked["images"]]) firefox.switch_to(student_tab) student = self.wait_until( lambda s: len(s["images"]) == 3 and all(i["ready"] and i["shown"] for i in s["images"]), "the three images to reach the student") self.assertEqual(student["images"], stacked["images"]) self.assertEqual(student["page"], stacked["page"]) self.assertEqual(firefox.run(VIEW), long_view) saved = self.current_page("Student notebook", {}, number=5, images=3) # A reloaded tab shows the page as it was last seen there: zoomed # and scrolled as it was, not fitted anew. firefox.run(""" document.getElementById('desk').dispatchEvent(new WheelEvent('wheel', {deltaY: -100, ctrlKey: true, clientX: 300, clientY: 300, bubbles: true, cancelable: true})); document.getElementById('desk').scrollTop = 240; """) seen = firefox.run(VIEW) self.assertEqual(seen["top"], 240) self.assertGreater(seen["scale"], long_view["scale"]) firefox.run("location.reload();") time.sleep(0.5) self.wait_until(lambda s: s["status"] == "Connected" and len(s["images"]) == 3 and all(i["shown"] for i in s["images"]), "the student to return to the long page") self.assertEqual(firefox.run(VIEW), seen) firefox.run("document.getElementById('zoom_fit').click();") fitted = firefox.run(VIEW) self.assertFalse(fitted["scrollsDown"]) firefox.run("location.reload();") time.sleep(0.5) self.wait_until(lambda s: s["status"] == "Connected" and len(s["images"]) == 3, "the student to return again") self.assertEqual(firefox.run(VIEW), fitted) self.assertIn('data-base="2970 3608"', saved) # The page file reads back with its images, in their sequence. self.assertTrue(firefox.run(""" return (async () => { const {pageToSvg, pageFromSvg} = await import('./js/page_file.js'); const session = window.kokkoja.session; const read = pageFromSvg(pageToSvg(session.page, {session: session.roomId, hostId: session.hostId})); return JSON.stringify(read.page.descriptor()) === JSON.stringify(session.page.descriptor()) && read.page.images.every((image, i) => image.data.length === session.page.images[i].size); })(); """)) firefox.switch_to(teacher_tab) # The host loads a page kept in a notebook: the first page of the # first session, with the teacher's and the student's writing. It # comes back as a new page for everyone, and the student's writing # is the student's again. firefox.run("document.getElementById('load_notebook_page').click();") notebooks = self.wait_until_true(lambda: firefox.run(""" return [...document.querySelectorAll('#chooser_notebooks button.notebook_pick')].map((b) => b.textContent); """), lambda names: "Teacher notebook" in names, "the chooser to list the notebooks") self.assertEqual(notebooks, sorted(notebooks)) firefox.run(""" [...document.querySelectorAll('#chooser_notebooks button.notebook_pick')] .find((b) => b.textContent === 'Teacher notebook').click(); """) cards = self.wait_until_true(lambda: firefox.run(""" return [...document.querySelectorAll('#chooser_pages .page_card span')].map((s) => s.textContent); """), lambda titles: len(titles) >= 3, "the chooser to list the kept pages") self.assertEqual(cards[0], f"{today[:4]}-{today[5:7]}-{today[8:10]} (1)") firefox.run("document.querySelector('#chooser_pages .page_card').click();") loaded = self.wait_until(lambda s: s["page"]["number"] == 6, "the page loaded from the notebook") self.assertFalse(firefox.run("return document.getElementById('notebook_chooser').open;")) self.assertEqual({name: len(strokes) for name, strokes in loaded["layers"].items()}, {"Teacher": 2, "Student": 2}) self.assertEqual(loaded["layers"]["Teacher"], teacher_first_page["Teacher"]) firefox.switch_to(student_tab) student = self.wait_until( lambda s: s["page"]["number"] == 6 and len(s["layers"].get("Student", [])) == 2 and len(s["layers"].get("Teacher", [])) == 2, "the loaded page to reach the student") self.assertEqual(student["layers"], loaded["layers"]) page_width = firefox.run("return document.getElementById('page').getBoundingClientRect().width;") firefox.run(ERASE_THROUGH, 0.15 + 75 / page_width, 0.45) self.assertEqual(len(self.state()["layers"]["Student"]), 1) firefox.switch_to(teacher_tab) self.wait_until(lambda s: len(s["layers"]["Student"]) == 1, "the student's erasure on the loaded page to reach the teacher") # A kept page with an image comes back with the image, shown to # everyone without further ado. firefox.run("document.getElementById('load_notebook_page').click();") self.wait_until_true(lambda: firefox.run(""" return document.querySelectorAll('#chooser_pages .page_card').length; """), lambda n: n >= 3, "the chooser to list the kept pages again") firefox.run("document.querySelectorAll('#chooser_pages .page_card')[2].click();") loaded = self.wait_until(lambda s: s["page"]["number"] == 7, "the loaded image page") self.assertTrue(loaded["image"]["ready"] and loaded["image"]["shown"]) self.assertEqual(loaded["placed"], {"x": 1335, "y": 50, "width": 300, "height": 400}) firefox.switch_to(student_tab) student = self.wait_until(lambda s: s["page"]["number"] == 7 and s["image"] and s["image"]["ready"], "the loaded image to reach the student") self.assertTrue(student["image"]["shown"]) self.assertEqual(firefox.run(""" const img = document.querySelector('.page_image'); const s = getComputedStyle(img); return [img.naturalWidth, s.display, parseFloat(s.width) > 10]; """), [300, "block", True]) firefox.switch_to(teacher_tab) # The page of three images was kept when it was left, and comes # back from the notebook with all three, for everyone. firefox.run("document.getElementById('load_notebook_page').click();") count = self.wait_until_true(lambda: firefox.run(""" return document.querySelectorAll('#chooser_pages .page_card').length; """), lambda n: n >= 4, "the chooser to list the kept pages a third time") for card in range(count): firefox.run("document.querySelectorAll('#chooser_pages .page_card')[arguments[0]].click();", card) loaded = self.wait_until(lambda s: s["page"]["number"] == 8 + card, "a kept page to load") if len(loaded["images"]) == 3: break firefox.run("document.getElementById('load_notebook_page').click();") self.wait_until_true(lambda: firefox.run(""" return document.querySelectorAll('#chooser_pages .page_card').length; """), lambda n: n >= count, "the chooser to list the kept pages again") self.assertEqual(loaded["images"], stacked["images"]) self.assertEqual(loaded["page"]["height"], 3608) # Each page loaded on the way was left unchanged, so none was kept # again (the file names a page anew at every load; the contents # are compared without the names). self.wait_until_true( lambda: (self.notebooks / "Teacher notebook" / "Current.svg").read_text(), lambda text: f'data-number="{loaded["page"]["number"]}"' in text, "the loaded page to be written") self.assertEqual(len(self.kept_pages("Teacher notebook")), count) # Deleting from the chooser: a mark on each page and notebook, one # Delete button that asks for a second press, the entries moved # to the Trash. The notebook being written in cannot be # marked; another notebook goes with everything in it. firefox.run("document.getElementById('load_notebook_page').click();") self.wait_until_true(lambda: firefox.run(""" return document.querySelectorAll('#chooser_pages .page_slot button.mark').length; """), lambda n: n == count, "the chooser to list the kept pages with marks") self.assertTrue(firefox.run("return document.getElementById('chooser_delete').hidden;")) before = self.kept_pages("Teacher notebook") firefox.run(""" const marks = document.querySelectorAll('#chooser_pages .page_slot button.mark'); marks[0].click(); marks[1].click(); marks[2].click(); marks[2].click(); """) delete = lambda: firefox.run(""" const b = document.getElementById('chooser_delete'); return b.hidden ? null : b.textContent; """) self.assertEqual(delete(), "Delete 2 pages?") self.assertEqual(firefox.run(""" return [...document.querySelectorAll('#chooser_pages .page_slot')].map((s) => s.classList.contains('marked')); """)[:3], [True, True, False]) # A marked card is dimmed, and its mark must still be what a tap # at the mark's place hits, so that the second tap unmarks and # does not load the page (a scripted click would not tell). self.assertEqual(firefox.run(""" return [...document.querySelectorAll('#chooser_pages .page_slot')].slice(0, 3).map((slot) => { const r = slot.querySelector('button.mark').getBoundingClientRect(); return document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2).classList.contains('mark'); }); """), [True, True, True]) firefox.run("document.getElementById('chooser_delete').click();") self.assertEqual(delete(), "Press again to move 2 pages to the Trash") # Marking something else takes the first press back. firefox.run("document.querySelectorAll('#chooser_pages .page_slot button.mark')[2].click();") self.assertEqual(delete(), "Delete 3 pages?") firefox.run("document.getElementById('chooser_delete').click();") firefox.run("document.getElementById('chooser_delete').click();") self.wait_until_true(lambda: firefox.run("return document.getElementById('chooser_message').textContent;"), lambda text: text == "3 pages moved to the Trash.", "the deletion to be reported") self.assertEqual(self.kept_pages("Teacher notebook"), before[3:]) self.assertEqual(sorted(p.name for p in self.trash.iterdir()), before[:3]) self.assertEqual(firefox.run("return document.querySelectorAll('#chooser_pages .page_slot').length;"), count - 3) self.assertIsNone(delete()) # The notebook being written in: its mark explains instead. firefox.run(""" [...document.querySelectorAll('#chooser_notebooks .notebook_row')] .find((r) => r.querySelector('.notebook_pick').textContent === 'Teacher notebook') .querySelector('button.mark').click(); """) self.assertIn("writing in this notebook", firefox.run("return document.getElementById('chooser_message').textContent;")) self.assertIsNone(delete()) # Another notebook, with its pages, goes as one; the toolbar's list loses it. self.assertTrue((self.notebooks / "Other notebook" / "Current.svg").exists()) firefox.run(""" [...document.querySelectorAll('#chooser_notebooks .notebook_row')] .find((r) => r.querySelector('.notebook_pick').textContent === 'Other notebook') .querySelector('button.mark').click(); """) self.assertEqual(delete(), "Delete 1 notebook?") firefox.run("document.getElementById('chooser_delete').click();") self.assertEqual(delete(), "Press again to move 1 notebook to the Trash") firefox.run("document.getElementById('chooser_delete').click();") self.wait_until_true(lambda: firefox.run("return document.getElementById('chooser_message').textContent;"), lambda text: text == "1 notebook moved to the Trash.", "the notebook's deletion to be reported") self.assertFalse((self.notebooks / "Other notebook").exists()) self.assertTrue((self.trash / "Other notebook" / "Current.svg").exists()) self.assertNotIn("Other notebook", firefox.run(""" return [...document.querySelectorAll('#chooser_notebooks button.notebook_pick')].map((b) => b.textContent); """)) self.wait_until_true(lambda: firefox.run(""" return [...document.getElementById('session_notebook').options].map((o) => o.textContent); """), lambda names: "Other notebook" not in names, "the toolbar's list to lose the notebook") firefox.run("document.getElementById('chooser_close').click();") # A guest has the chooser too, to look at and clear their own # notebook; a card tells them that loading is the host's. firefox.switch_to(student_tab) number = self.state()["page"]["number"] firefox.run("document.getElementById('load_notebook_page').click();") self.wait_until_true(lambda: firefox.run(""" return document.querySelectorAll('#chooser_pages .page_card').length; """), lambda n: n >= 1, "the student's chooser to list kept pages") firefox.run("document.querySelector('#chooser_pages .page_card').click();") self.assertIn("Only the host and co-hosts", firefox.run("return document.getElementById('chooser_message').textContent;")) self.assertTrue(firefox.run("return document.getElementById('notebook_chooser').open;")) self.assertEqual(self.state()["page"]["number"], number) firefox.run("document.getElementById('chooser_close').click();") firefox.switch_to(teacher_tab) # A page that its images have made longer than a sheet is first # shown at the scale at which one sheet fits the window. self.assertEqual(firefox.run(VIEW), long_view) firefox.switch_to(student_tab) student = self.wait_until( lambda s: s["page"]["number"] == loaded["page"]["number"] and len(s["images"]) == 3 and all(i["ready"] and i["shown"] for i in s["images"]), "the loaded page of three images to reach the student") self.assertEqual(student["images"], stacked["images"]) self.assertEqual(firefox.run(VIEW), long_view) firefox.switch_to(teacher_tab) # Five colours and four widths in the toolbar. "+" among the # colours opens a palette of our own, in which the colours already # in the row leave their places empty; a chosen colour becomes a # swatch and the pen's colour, and its place in the palette empties. swatches = lambda: firefox.run(""" return [...document.querySelectorAll('#colors .swatch:not(.more)')] .map((s) => s.getAttribute('aria-label').replace('Color ', '')); """) self.assertEqual(swatches(), ["#000000", "#e53935", "#43a047", "#1e88e5", "#ffffff"]) self.assertEqual(firefox.run("return document.querySelectorAll('#widths button').length;"), 4) palette = lambda: firefox.run(""" return [...document.querySelectorAll('#color_palette .swatch')] .map((s) => s.classList.contains('missing') ? '' : s.getAttribute('aria-label').replace('Color ', '')); """) firefox.run("document.getElementById('more_colors').click();") self.assertFalse(firefox.run("return document.getElementById('color_palette').hidden;")) shown = palette() self.assertEqual(len(shown), 32) self.assertEqual([i for i, c in enumerate(shown) if c == ""], [0, 3, 5, 24, 31]) firefox.run(""" [...document.querySelectorAll('#color_palette .swatch')] .find((s) => s.getAttribute('aria-label') === 'Color #8e24aa').click(); """) self.assertTrue(firefox.run("return document.getElementById('color_palette').hidden;")) self.assertEqual(swatches()[-1], "#8e24aa") self.assertEqual(firefox.run(""" const pressed = [...document.querySelectorAll('#colors .swatch[aria-pressed=true]')]; return pressed.map((s) => s.getAttribute('aria-label')); """), ["Color #8e24aa"]) firefox.run("document.getElementById('more_colors').click();") self.assertEqual(palette()[6], "") firefox.run("document.getElementById('more_colors').click();") firefox.run("document.querySelector('[data-tool=pen]').click();") firefox.drag("#page", 100, 700, 300, 720) picked = self.wait_until(lambda s: s["layers"]["Teacher"] and s["layers"]["Teacher"][-1]["done"], "the stroke in the picked colour") self.assertEqual(firefox.run(""" const layer = window.kokkoja.session.ownLayer; return [...layer.strokes.values()].at(-1).color; """), "#8e24aa") # "Image" opens a chooser of our own. It begins where the notebooks # are, reaches other directories through the local program, and # shows image files only, each with a small picture. scans = pathlib.Path(self.directory.name) / "Scans" scans.mkdir() (scans / "exercise.png").write_bytes(png_picture(300, 200, (200, 60, 60))) (scans / "notes.txt").write_text("not an image") chooser = lambda condition, what: self.wait_until_true( lambda: firefox.run(FILE_CHOOSER), condition, what) firefox.run("document.getElementById('new_page').click();") number = self.wait_until(lambda s: not s["images"], "a new page")["page"]["number"] firefox.run("document.getElementById('load_image').click();") shown = chooser(lambda c: c["open"] and c["directory"] == str(self.notebooks), "the chooser to show the notebooks directory") self.assertEqual(shown["title"], "Image") self.assertIn("Teacher notebook", shown["directories"]) self.assertEqual((shown["files"], shown["saving"]), ([], False)) self.assertEqual(shown["message"], "No image files here.") firefox.run("document.getElementById('files_up').click();") shown = chooser(lambda c: c["directory"] == self.directory.name, "the directory above") self.assertIn("Scans", shown["directories"]) firefox.run(""" [...document.querySelectorAll('.files_directories button')] .find((b) => b.textContent === 'Scans').click(); """) shown = chooser(lambda c: c["directory"] == str(scans) and c["drawn"] == [[240, 160]], "the image file and its small picture") self.assertEqual((shown["files"], shown["message"]), (["exercise.png"], "")) firefox.run("document.querySelector('.file_card').click();") chosen = self.wait_until(lambda s: len(s["images"]) == 1 and s["image"]["shown"], "the chosen image on the page") self.assertEqual(chosen["page"]["number"], number) self.assertEqual(chosen["placed"], {"x": 1335, "y": 50, "width": 300, "height": 200}) self.assertFalse(firefox.run(FILE_CHOOSER)["open"]) # The chooser begins where it was last used. firefox.run("document.getElementById('load_image').click();") chooser(lambda c: c["open"] and c["directory"] == str(scans), "the chooser to return to Scans") # Closed and opened again at once, it stays open: the first close # is reported late and must not end the second use. firefox.run(""" document.getElementById('files_close').click(); document.getElementById('load_image').click(); """) time.sleep(0.3) chooser(lambda c: c["open"] and c["files"] == ["exercise.png"], "the chooser to stay open") firefox.run("document.getElementById('files_close').click();") chooser(lambda c: not c["open"], "the chooser to close") self.assertEqual(len(self.state()["images"]), 1) # "Export" saves a picture of the page where the user chooses: a # PNG at 200 dots per inch, which records that resolution. firefox.drag("#page", 200, 400, 320, 420) self.wait_until(lambda s: s["layers"]["Teacher"] and s["layers"]["Teacher"][-1]["done"], "a stroke to find in the picture") firefox.run("document.getElementById('export_page').click();") shown = chooser(lambda c: c["open"] and c["directory"] == str(self.notebooks), "the export chooser to show the notebooks directory") self.assertEqual((shown["title"], shown["saving"], shown["button"]), ("Export", True, "Export")) self.assertEqual(shown["name"], f"{today}_page_{number}.png") self.assertEqual(shown["files"], []) firefox.run(""" const field = document.getElementById('files_directory'); field.value = arguments[0]; field.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', bubbles: true})); """, str(scans)) shown = chooser(lambda c: c["directory"] == str(scans) and c["existing"], "the typed directory") self.assertEqual((shown["existing"], shown["directories"]), (["exercise.png"], [])) export = """ document.getElementById('files_name').value = 'page'; document.getElementById('files_name').dispatchEvent(new Event('input', {bubbles: true})); document.getElementById('files_save_here').click(); """ firefox.run(export) chooser(lambda c: not c["open"], "the export to finish") picture = scans / "page.png" self.assertEqual(firefox.run("return document.getElementById('notice').textContent;"), f"Exported to {picture}") data = picture.read_bytes() self.assertEqual(data[:8], b"\x89PNG\r\n\x1a\n") self.assertEqual(struct.unpack(">II", data[16:24]), (2339, 1654)) self.assertEqual((data[33:41], struct.unpack(">IIB", data[41:50])), (b"\x00\x00\x00\x09pHYs", (7874, 7874, 1))) colours = firefox.run_until_done(PICTURE_COLOURS, str(picture)) self.assertEqual((colours["size"], colours["corner"]), ([2339, 1654], [255, 255, 255])) self.assertTrue(all(abs(a - b) <= 3 for a, b in zip(colours["image"], (200, 60, 60))), colours) self.assertLess(sum(colours["stroke"]), 500, colours) # Layers hidden on one's own screen are left out of the picture. A # file is replaced only at a second press of the button. firefox.run("document.querySelector('#participants input[type=checkbox]').click();") firefox.run("document.getElementById('export_page').click();") chooser(lambda c: c["open"] and c["directory"] == str(scans) and "page.png" in c["existing"], "the export chooser to return to Scans") firefox.run(export) shown = chooser(lambda c: c["button"] == "Replace", "the offer to replace") self.assertEqual((shown["open"], shown["message"]), (True, "page.png exists here.")) self.assertEqual(picture.read_bytes(), data) firefox.run("document.getElementById('files_save_here').click();") chooser(lambda c: not c["open"], "the replacing export to finish") colours = firefox.run_until_done(PICTURE_COLOURS, str(picture)) self.assertEqual(colours["stroke"], [255, 255, 255]) self.assertTrue(all(abs(a - b) <= 3 for a, b in zip(colours["image"], (200, 60, 60))), colours) firefox.run("document.querySelector('#participants input[type=checkbox]').click();") # A tap of the eraser on an image removes it, for whoever may make # pages or added the image; a sweep across it does not. Writing on # it stays; the sheet keeps its size. firefox.run("document.getElementById('new_page').click();") number = self.wait_until(lambda s: not s["images"], "another new page")["page"]["number"] firefox.run_until_done(LOAD_IMAGE, 300, 400, "png", 0, False) firefox.run_until_done(LOAD_IMAGE, 1000, 2700, "png", 0, False) two = self.wait_until(lambda s: len(s["images"]) == 2, "two images") self.assertEqual(two["page"], {"number": number, "width": 2970, "height": 3250}) rectangles = lambda state: [[i["x"], i["y"], i["width"], i["height"]] for i in state["images"]] self.assertEqual(rectangles(two), [[1335, 50, 300, 400], [985, 500, 1000, 2700]]) firefox.switch_to(student_tab) self.wait_until(lambda s: len(s["images"]) == 2 and all(i["ready"] for i in s["images"]), "the two images to reach the student") # Not the student, who has no right and did not add it. firefox.run(ERASER_AT, 1050, 1500, 5) firefox.run(PEN_WAVE, 0.3, 0.5) firefox.switch_to(teacher_tab) self.wait_until(lambda s: len(s["layers"].get("Student", [])) == 1 and s["layers"]["Student"][0]["done"], "the student's writing on the image") # Not by moving the eraser across it. firefox.run(ERASER_AT, 1050, 1500, 100) time.sleep(0.3) self.assertEqual(rectangles(self.state()), rectangles(two)) # A tap by the teacher removes it, written on or not. firefox.run(ERASER_AT, 1050, 1500, 5) one = self.wait_until(lambda s: len(s["images"]) == 1, "the image to be removed") self.assertEqual(one["page"], {"number": number, "width": 2970, "height": 3250}) self.assertEqual(rectangles(one), [[1335, 50, 300, 400]]) self.assertEqual(len(one["layers"]["Student"]), 1) self.assertEqual(firefox.run("return document.querySelectorAll('.page_image').length;"), 1) firefox.switch_to(student_tab) student = self.wait_until(lambda s: len(s["images"]) == 1, "the student to lose the image too") self.assertEqual(student["page"], one["page"]) self.current_page("Student notebook", {"Student": 1}, number=number, images=1) # The next image lands where the removed one was. The file records # who added each image. firefox.switch_to(teacher_tab) firefox.run_until_done(LOAD_IMAGE, 1000, 2700, "png", 0, False) again = self.wait_until(lambda s: len(s["images"]) == 2, "the image added again") self.assertEqual(rectangles(again), rectangles(two)) self.assertEqual(self.current_page("Teacher notebook", {"Student": 1}, number=number, images=2) .count('data-by="'), 2) firefox.switch_to(student_tab) self.wait_until(lambda s: len(s["images"]) == 2 and all(i["ready"] and i["shown"] for i in s["images"]), "the image added again to reach the student") firefox.switch_to(teacher_tab) # An image wider than the page arrives at its own size, centred, # the sheet widened equally on both sides to hold it. (scans / "Wide").mkdir() (scans / "Wide" / "wide.png").write_bytes(png_picture(3200, 300, (60, 120, 200))) firefox.run("document.getElementById('new_page').click();") number = self.wait_until(lambda s: not s["images"], "a page for the wide image")["page"]["number"] sheet_view = firefox.run(VIEW) firefox.run("document.getElementById('load_image').click();") chooser(lambda c: c["open"] and "Wide" in c["directories"], "the chooser to list Wide") firefox.run(""" [...document.querySelectorAll('.files_directories button')] .find((b) => b.textContent === 'Wide').click(); """) chooser(lambda c: c["files"] == ["wide.png"], "the wide image file") firefox.run("document.querySelector('.file_card').click();") wide = self.wait_until(lambda s: len(s["images"]) == 1 and s["image"]["shown"], "the wide image at its own size") self.assertEqual(rectangles(wide), [[-115, 50, 3200, 300]]) self.assertEqual(wide["page"], {"number": number, "width": 3300, "height": 2100}) view = firefox.run(VIEW) # The scale is kept here too. self.assertEqual(view["scale"], sheet_view["scale"]) firefox.switch_to(student_tab) student = self.wait_until(lambda s: len(s["images"]) == 1 and s["image"]["ready"], "the wide image to reach the student") self.assertEqual(student["page"], wide["page"]) self.assertIn('data-base="3300 2100 -165 0"', self.current_page("Student notebook", {}, number=number, images=1)) # Removed, it leaves the sheet as wide as it made it; Shrink would trim it. firefox.switch_to(teacher_tab) firefox.run(ERASER_AT, 1000, 200, 5) back = self.wait_until(lambda s: not s["images"], "the wide image to be removed") self.assertEqual(back["page"], {"number": number, "width": 3300, "height": 2100}) screenshot = os.environ.get("KOKKOJA_SCREENSHOT") if screenshot: firefox.screenshot(screenshot) if __name__ == "__main__": unittest.main()