"""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 math 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 # The hover cue (the blue corners over an implied rectangle under the # pointer): whether it shows, and where, in page units. HOVER_CUE = """ const c = document.querySelector('.hover_cue'); const r = window.kokkoja.renderer, s = window.kokkoja.session; const page = document.getElementById('page').getBoundingClientRect(); const b = c.getBoundingClientRect(); const k = s.page.width / page.width; const st = getComputedStyle(c); return {hidden: c.hidden, x: s.page.left + (b.left - page.left) * k, y: s.page.top + (b.top - page.top) * k, width: b.width * k, height: b.height * k, kind: c.className.split(' ')[1], box: [b.left - page.left, b.top - page.top, b.right - page.left, b.bottom - page.top], scale: r.cssPixelsPerUnit, origin: [s.page.left, s.page.top], edges: [st.borderTopWidth, st.borderRightWidth, st.borderBottomWidth, st.borderLeftWidth]}; """ # Where an overlay's box must lie around a rectangle's edges in pixels # (CUE_OFFSET_PX in renderer.js): the edges taken out to whole pixels and # the line this far beyond them. CUE_OFFSET_PX = 2 def overlay_box(left, top, right, bottom, line): """The pixel box an overlay with this line width takes around a rectangle whose edges are at these pixel positions.""" beyond = CUE_OFFSET_PX - 1 + line return [math.floor(left) - beyond, math.floor(top) - beyond, math.ceil(right) + beyond, math.ceil(bottom) + beyond] # Whether anything at all is drawn on the writer's own layer canvas within # two device pixels of the page point (x, y). INK_AT = """ const [x, y] = arguments; const r = window.kokkoja.renderer, s = window.kokkoja.session; const canvas = r.canvases.get(s.identity.id); const px = Math.round((x - s.page.left) * r.scale), py = Math.round((y - s.page.top) * r.scale); const data = canvas.getContext('2d').getImageData(px - 2, py - 2, 5, 5).data; for (let i = 3; i < data.length; i += 4) if (data[i] > 0) return true; return false; """ # A mouse moving over the page with no button down, to a fraction of the # page: a hover. HOVER = """ const [fx, fy] = arguments; const page = document.getElementById('page'); const box = page.getBoundingClientRect(); page.dispatchEvent(new PointerEvent('pointermove', {pointerId: 9, pointerType: 'mouse', isPrimary: true, clientX: box.left + fx * box.width, clientY: box.top + fy * box.height, button: -1, buttons: 0, bubbles: true, cancelable: true})); """ # A press on the Shape button held for `wait` ms, released, and the click # that a browser sends after a press; calls back with whether the menu # is open. HOLD_SHAPE_BUTTON = """ const [wait, done] = arguments; const b = document.getElementById('shape_tool'); const box = b.getBoundingClientRect(); const fire = (type) => b.dispatchEvent(new PointerEvent(type, {pointerId: 5, pointerType: 'mouse', isPrimary: true, clientX: box.left + 5, clientY: box.top + 5, button: 0, buttons: type === 'pointerup' ? 0 : 1, bubbles: true, cancelable: true})); fire('pointerdown'); setTimeout(() => { fire('pointerup'); b.click(); done(!document.getElementById('shape_menu').hidden); }, wait); """ # 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, color: stroke.color, width: stroke.width, filled: stroke.filled, fill: stroke.fill, shape: stroke.shape})); 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 pointer_actions(self, actions): """Perform mouse actions (WebDriver's pointer actions) in sequence.""" self.in_session("POST", "/actions", {"actions": [{ "type": "pointer", "id": "mouse", "parameters": {"pointerType": "mouse"}, "actions": actions}]}) 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 assert_hover_cue(self, x, y, rect, kind="hide"): """A pointer hovering at the page point (x, y) shows the blue cue over the rectangle (page units), and none once it leaves: a text rectangle or a Hide outlined, free text's top and left edges (Andy, 2026-10-14).""" state = self.state() fx = (x - state["origin"][0]) / state["page"]["width"] fy = (y - state["origin"][1]) / state["page"]["height"] self.firefox.run(HOVER, fx, fy) cue = self.firefox.run(HOVER_CUE) self.assertFalse(cue["hidden"], f"no cue at {x}, {y}") self.assertEqual(cue["kind"], kind) line = cue["edges"][0] self.assertNotEqual(line, "0px") # The cue's box lies CUE_OFFSET_PX outside the rectangle. px = lambda v: (v - cue["origin"][0]) * cue["scale"] py = lambda v: (v - cue["origin"][1]) * cue["scale"] expected = overlay_box(px(rect[0]), py(rect[1]), px(rect[0] + rect[2]), py(rect[1] + rect[3]), float(line[:-2])) for side, got, want in zip(("left", "top", "right", "bottom"), cue["box"], expected): self.assertAlmostEqual(got, want, delta=0.01, msg=side) if kind == "free": self.assertEqual(cue["edges"], [line, "0px", "0px", line], "top and left edges only") else: self.assertEqual(cue["edges"], [line] * 4, "outlined") self.firefox.run(HOVER, 0.01, 0.01) self.assertTrue(self.firefox.run(HOVER_CUE)["hidden"]) 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_arrange_tool(self): """Andy, 2026-10-13: with the Arrange tool, blue corners mark every image (and, until 2026-10-14, every Hide and own text rectangle, which carry blue corners at all times since), a drag inside one moves it and a drag on a corner resizes it, at once, no hold; a press elsewhere does nothing. A guest without the right sees no cues, their own text rectangle's corners being drawn anyway.""" firefox = self.firefox host_tab = firefox.current_tab() firefox.go(self.address("Late")) self.enter_name("Arranger", "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") firefox.switch_to(host_tab) cues = lambda: firefox.run("return [...document.querySelectorAll('.arrange_cue')].map((c) => c.className.split(' ')[1]);") choose = lambda tool: firefox.run(f"document.querySelector('[data-tool={tool}]').click();") rect = lambda state, i: [state["images"][i][k] for k in ("x", "y", "width", "height")] firefox.run_until_done(LOAD_IMAGE, 300, 200, "png", 0, False) placed = self.wait_until(lambda s: len(s["images"]) == 1 and s["image"]["ready"], "the image") self.assertEqual(cues(), [], "no cues with the pen") choose("arrange") self.assertEqual(cues(), ["image"]) self.assertTrue(firefox.run("return document.getElementById('colors').hidden;")) scale = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;") origin = placed["origin"] px = lambda x: (x - origin[0]) * scale py = lambda y: (y - origin[1]) * scale # A drag inside the image moves it at once, and draws nothing. before = rect(placed, 0) firefox.drag("#page", px(before[0] + 150), py(before[1] + 100), px(before[0] + 150) + 200, py(before[1] + 100) + 100) moved = self.wait_until(lambda s: s["images"][0]["x"] != before[0], "the image moved") self.assertEqual(rect(moved, 0)[2:], before[2:], "the same size") self.assertAlmostEqual(moved["images"][0]["x"], before[0] + 200 / scale, delta=2) self.assertAlmostEqual(moved["images"][0]["y"], before[1] + 100 / scale, delta=2) self.assertEqual(moved["layers"]["Arranger"], [], "no stroke") # A drag on its bottom-right corner scales it, the shape kept, the # top-left corner fixed. now = rect(moved, 0) firefox.drag("#page", px(now[0] + now[2]) - 3, py(now[1] + now[3]) - 3, px(now[0] + now[2]) + 150, py(now[1] + now[3]) - 3) scaled = self.wait_until(lambda s: s["images"][0]["width"] != now[2], "the image scaled") self.assertEqual(rect(scaled, 0)[:2], now[:2], "the top-left corner stays") self.assertAlmostEqual(scaled["images"][0]["width"] / scaled["images"][0]["height"], 300 / 200, places=2) self.assertGreater(scaled["images"][0]["width"], now[2] + 100 / scale) # A drag on empty paper does nothing at all. firefox.drag("#page", px(100), py(1800), px(600), py(1900)) time.sleep(0.5) after = self.state() self.assertEqual(after["layers"]["Arranger"], []) self.assertEqual(rect(after, 0), rect(scaled, 0)) # A text rectangle of one's own, free text and a Hide get cues too # (Andy, 2026-10-14), the free text's without corners; the hover # cue still shows on the box and the Hide; a corner drag widens # the text box, a drag inside moves the Hide. choose("text") firefox.run(TEXT_GESTURE, 0.1, 0.6, 0.3, 0.7) firefox.type_into(".text_field", "Box") firefox.run(TEXT_GESTURE, 0.4, 0.8, 0.4, 0.8) # Finishes the box. self.wait_until(lambda s: firefox.run("return document.querySelector('.text_field').hidden;"), "the box finished") firefox.run(TEXT_GESTURE, 0.4, 0.8, 0.4, 0.8) # Starts free text. firefox.type_into(".text_field", "Free") choose("hide") firefox.run(TEXT_GESTURE, 0.5, 0.6, 0.7, 0.7) with_all = self.wait_until(lambda s: len(s["hides"]) == 1 and len(s["texts"]["Arranger"]) == 2 and s["texts"]["Arranger"][0]["text"] == "Box" and s["texts"]["Arranger"][1]["text"] == "Free", "the box, the free text and the Hide") choose("arrange") self.assertEqual(cues(), ["image", "hide", "text", "text"]) self.assertEqual(firefox.run("return [...document.querySelectorAll('.arrange_cue.text')].map((c) => c.classList.contains('no_corners'));"), [False, True], "free text's cue has no corners") self.assertEqual(firefox.run("return [...document.querySelectorAll('.arrange_cue')].map((c) => getComputedStyle(c).borderTopWidth);"), ["2px"] * 4, "every cue is a box, its line the grip's width") # The image's cue lies CUE_OFFSET_PX outside the image. im = scaled["images"][0] page_box = firefox.run("const b = document.getElementById('page').getBoundingClientRect(); return [b.left, b.top];") got = firefox.run("const b = document.querySelector('.arrange_cue.image').getBoundingClientRect(); return [b.left, b.top, b.right, b.bottom];") want = overlay_box(px(im["x"]), py(im["y"]), px(im["x"] + im["width"]), py(im["y"] + im["height"]), 2) for side, g, w in zip(("left", "top", "right", "bottom"), (got[0] - page_box[0], got[1] - page_box[1], got[2] - page_box[0], got[3] - page_box[1]), want): self.assertAlmostEqual(g, w, delta=0.01, msg=side) self.assertEqual(firefox.run("return getComputedStyle(document.documentElement).getPropertyValue('--arrange_blue');").strip(), "#1e88e5") self.assertEqual(firefox.run("return getComputedStyle(document.querySelector('.arrange_cue')).borderTopColor;"), "rgb(30, 136, 229)") box = with_all["texts"]["Arranger"][0] self.assert_hover_cue(box["x"] + 20, box["y"] + 20, [box["x"], box["y"], box["width"], box["height"]], "rect") h = with_all["hides"][0] self.assert_hover_cue(h[0] + h[2] / 2, h[1] + h[3] / 2, h) firefox.drag("#page", px(box["x"] + box["width"]) - 3, py(box["y"] + box["height"]) - 3, px(box["x"] + box["width"]) + 100, py(box["y"] + box["height"]) + 40) widened = self.wait_until(lambda s: s["texts"]["Arranger"][0]["width"] > box["width"] + 80 / scale, "the box widened") self.assertEqual((widened["texts"]["Arranger"][0]["x"], widened["texts"]["Arranger"][0]["y"]), (box["x"], box["y"])) hide = with_all["hides"][0] firefox.drag("#page", px(hide[0] + hide[2] / 2), py(hide[1] + hide[3] / 2), px(hide[0] + hide[2] / 2) - 100, py(hide[1] + hide[3] / 2) + 50) hide_moved = self.wait_until(lambda s: s["hides"][0][0] != hide[0], "the Hide moved") self.assertEqual(hide_moved["hides"][0][2:], hide[2:]) self.assertEqual(len(cues()), 4) # Undo takes the Hide's move back, as it does a hold's. firefox.run("document.getElementById('undo').click();") self.wait_until(lambda s: s["hides"][0] == hide, "the Hide back by Undo") # While something is held its blue cue gives way to the green grip: # a press on the Hide, held down, then released. page_box = firefox.run("const b = document.getElementById('page').getBoundingClientRect(); return [b.left, b.top];") firefox.pointer_actions([{"type": "pointerMove", "duration": 0, "origin": "viewport", "x": round(page_box[0] + px(hide[0] + hide[2] / 2)), "y": round(page_box[1] + py(hide[1] + hide[3] / 2))}, {"type": "pointerDown", "button": 0}, {"type": "pause", "duration": 100}]) self.assertEqual(cues(), ["image", "text", "text"], "the held Hide's cue is gone") self.assertFalse(firefox.run("return document.querySelector('.grip').hidden;")) self.assertEqual(firefox.run("return getComputedStyle(document.querySelector('.grip')).borderTopColor;"), "rgb(46, 125, 50)") # Held for moving, all four corners are the moving size (Andy, 2026-10-14). self.assertEqual(firefox.run("return [...document.querySelectorAll('.grip .handle')].map((h) => getComputedStyle(h).width);"), ["14px"] * 4) firefox.pointer_actions([{"type": "pointerUp", "button": 0}]) self.assertEqual(cues(), ["image", "hide", "text", "text"], "the cue is back") self.assertTrue(firefox.run("return document.querySelector('.grip').hidden;")) # Held at a corner, only that corner is the moving size. firefox.pointer_actions([{"type": "pointerMove", "duration": 0, "origin": "viewport", "x": round(page_box[0] + px(hide[0] + hide[2])) - 3, "y": round(page_box[1] + py(hide[1] + hide[3])) - 3}, {"type": "pointerDown", "button": 0}, {"type": "pause", "duration": 100}]) self.assertEqual(firefox.run("return [...document.querySelectorAll('.grip .handle')].map((h) => getComputedStyle(h).width);"), ["10px"] * 4) # The square's position is a percentage of the grip's padding box. self.assertEqual(firefox.run("const g = document.querySelector('.grip'); const s = getComputedStyle(g, '::after');" " return [s.width, parseFloat(s.left) === g.clientWidth, parseFloat(s.top) === g.clientHeight];"), ["14px", True, True], "the dragged corner, bottom right, larger") firefox.pointer_actions([{"type": "pointerUp", "button": 0}]) self.wait_until(lambda s: s["hides"][0] == hide, "the Hide unchanged by a press") # Held free text shows the grip without corners, as its cue has none. free = self.state()["texts"]["Arranger"][1] firefox.pointer_actions([{"type": "pointerMove", "duration": 0, "origin": "viewport", "x": round(page_box[0] + px(free["x"] + 20)), "y": round(page_box[1] + py(free["y"] + 20))}, {"type": "pointerDown", "button": 0}, {"type": "pause", "duration": 100}]) self.assertEqual(cues(), ["image", "hide", "text"], "the held free text's cue is gone") self.assertEqual(firefox.run("return [...document.querySelectorAll('.grip .handle')].map((h) => getComputedStyle(h).display);"), ["none"] * 4, "no corners on held free text") firefox.pointer_actions([{"type": "pointerUp", "button": 0}]) self.assertEqual(cues(), ["image", "hide", "text", "text"]) # The watcher, without the right, is shown no cue on the image or # the Hide, only on their own text. firefox.switch_to(guest_tab) self.wait_until(lambda s: len(s["images"]) == 1 and len(s["hides"]) == 1, "the page at the watcher") choose("arrange") self.assertEqual(cues(), []) choose("text") firefox.run(TEXT_GESTURE, 0.1, 0.8, 0.3, 0.9) firefox.type_into(".text_field", "Mine") choose("arrange") self.assertEqual(cues(), ["text"]) mine = self.wait_until(lambda s: s["texts"].get("Watcher") and s["texts"]["Watcher"][0]["text"] == "Mine", "the watcher's box") mine = mine["texts"]["Watcher"][0] self.assert_hover_cue(mine["x"] + 20, mine["y"] + 20, [mine["x"], mine["y"], mine["width"], mine["height"]], "rect") def test_shapes(self): """Andy, 2026-10-14: a Shape button right of Text draws the shape it names in the pen's colour and width; a hold on the button, or a secondary press, opens a menu of shapes. A rectangle, an ellipse and an arrow are dragged open and become one stroke each, the arrow a filled one; a line is made of taps, one point each, grows for everyone as it is made, and is finished by a tap on its last point or closed by one on its first. Text rectangles have no frame or ground any more, only blue corners on screen; free text shows its corners while the pointer hovers over it.""" firefox = self.firefox host_tab = firefox.current_tab() firefox.go(self.address("Late")) self.enter_name("Drawer", "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("Viewer", "join_session") self.wait_until(lambda s: s["page"]["number"] == 1 and len(s["participants"]) == 2, "the viewer") firefox.switch_to(host_tab) choose = lambda tool: firefox.run(f"document.querySelector('[data-tool={tool}]').click();") label = lambda: firefox.run("return document.getElementById('shape_tool').textContent;") pressed = lambda: firefox.run("return document.querySelector('[data-tool][aria-pressed=true]').dataset.tool;") menu_open = lambda: firefox.run("return !document.getElementById('shape_menu').hidden;") pick = lambda shape: firefox.run(f"document.querySelector('#shape_menu [data-shape={shape}]').click();") strokes = lambda s: s["layers"].get("Drawer", []) state = self.state() origin, page = state["origin"], state["page"] at = lambda fx, fy: (origin[0] + fx * page["width"], origin[1] + fy * page["height"]) # The button names the default shape and keeps one width whatever it names. self.assertEqual(label(), "Rectangle") button_width = firefox.run("return document.getElementById('shape_tool').getBoundingClientRect().width;") choose("shape") self.assertEqual(pressed(), "shape") self.assertEqual(firefox.run("return ['colors', 'widths', 'text_style'].map((id) => document.getElementById(id).hidden);"), [False, False, True], "colours and widths show, as for the pen") # A rectangle: one stroke of four corners, each twice for straight # sides (the first and last once), at the pen's width and the # nominal pressure, no fill. firefox.run(TEXT_GESTURE, 0.1, 0.1, 0.3, 0.25) drawn = self.wait_until(lambda s: len(strokes(s)) == 1 and strokes(s)[0]["done"], "the rectangle") rect = strokes(drawn)[0] self.assertEqual((rect["filled"], rect["color"], rect["width"]), (False, "#000000", 7)) self.assertEqual(len(rect["points"]), 8 * 3) self.assertEqual(set(rect["points"][2::3]), {0.4}) self.assertAlmostEqual(rect["points"][0], at(0.1, 0.1)[0], delta=1) self.assertAlmostEqual(rect["points"][13], at(0.3, 0.25)[1], delta=1) self.assertEqual(rect["points"][0:2], rect["points"][21:23], "closed at the first corner") # A tap draws nothing. firefox.run(TEXT_GESTURE, 0.5, 0.5, 0.5, 0.5) time.sleep(0.3) self.assertEqual(len(strokes(self.state())), 1) # The menu: a quick press on the button chooses the tool and opens # nothing; a press held for the hold time opens it, and the click # that follows chooses nothing. choose("pen") self.assertFalse(menu_open()) self.assertTrue(firefox.run_until_done(HOLD_SHAPE_BUTTON, 600), "the menu opens on a hold") self.assertEqual(pressed(), "pen", "the click after the hold chooses nothing") pick("ellipse") self.assertFalse(menu_open()) self.assertEqual((label(), pressed()), ("Ellipse", "shape")) self.assertEqual(firefox.run("return document.getElementById('shape_tool').getBoundingClientRect().width;"), button_width) # Two quick clicks open it too, and it marks the current shape. firefox.run("const b = document.getElementById('shape_tool'); b.click(); b.click();") self.assertTrue(menu_open()) self.assertEqual(firefox.run("return [...document.querySelectorAll('#shape_menu [data-shape]')].filter((i) => i.getAttribute('aria-checked') === 'true').map((i) => i.dataset.shape);"), ["ellipse"]) self.assertEqual(firefox.run("return getComputedStyle(document.querySelector('#shape_menu [data-shape=ellipse]'), '::before').content;"), '"✓"') self.assertEqual(firefox.run("return getComputedStyle(document.querySelector('#shape_menu [data-shape=line]'), '::before').content;"), '""') firefox.chord("\ue00c") self.assertFalse(menu_open()) firefox.run(TEXT_GESTURE, 0.4, 0.1, 0.6, 0.3) drawn = self.wait_until(lambda s: len(strokes(s)) == 2 and strokes(s)[1]["done"], "the ellipse") ellipse = strokes(drawn)[1]["points"] self.assertGreaterEqual(len(ellipse) // 3, 24) self.assertEqual(ellipse[0:2], ellipse[-3:-1], "closed") self.assertAlmostEqual(ellipse[0], at(0.6, 0.1)[0], delta=1, msg="begins at the right end of the middle line") self.assertAlmostEqual(ellipse[1], at(0.5, 0.2)[1], delta=1) # A secondary press (the right button, a pen's barrel button) opens it too. firefox.run("document.getElementById('shape_tool').dispatchEvent(new MouseEvent('contextmenu', {bubbles: true, cancelable: true}));") self.assertTrue(menu_open()) pick("arrow") self.assertEqual(label(), "Arrow") # An arrow: a filled polygon of seven points, its tip at the end of # the drag; the file holds it as a filled path. firefox.run(TEXT_GESTURE, 0.1, 0.5, 0.3, 0.5) drawn = self.wait_until(lambda s: len(strokes(s)) == 3 and strokes(s)[2]["done"], "the arrow") arrow = strokes(drawn)[2] self.assertTrue(arrow["filled"]) self.assertEqual(len(arrow["points"]), 7 * 3) self.assertAlmostEqual(arrow["points"][9], at(0.3, 0.5)[0], delta=1) self.assertAlmostEqual(arrow["points"][10], at(0.3, 0.5)[1], delta=1) self.assertEqual(arrow["points"][6], arrow["points"][12], "the barbs are abreast") self.assertAlmostEqual(abs(arrow["points"][13] - arrow["points"][7]), 2 * 0.6 * 25 / 7 * 7, delta=1, msg="the head of a 0.7 mm arrow is 2.5 mm long, 3 mm wide") self.assertAlmostEqual(arrow["points"][9] - arrow["points"][6], 25, delta=0.5, msg="the head's length") self.assertEqual(arrow["shape"], "arrow") saved = self.wait_until_true(lambda: (self.notebooks / "Drawer notebook" / "Current.svg").read_text(), lambda text: "data-filled" in text, "the arrow saved") self.assertRegex(saved, r']*>') self.assertEqual(saved.count("]*> c.className.split(' ')[1]);") choose("arrange") self.assertEqual(cues(), ["shape"] * 7) scale = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;") px = lambda x: (x - origin[0]) * scale py = lambda y: (y - origin[1]) * scale # Each cue's line lies just outside the shape's ink (the points plus # half the line's width, taken out to whole pixels), one pixel # beyond it on every side (Andy, 2026-10-14). page_box = firefox.run("const b = document.getElementById('page').getBoundingClientRect(); return [b.left, b.top];") boxes = firefox.run("return [...document.querySelectorAll('.arrange_cue')].map((c) => { const b = c.getBoundingClientRect();" " return [b.left, b.top, b.right, b.bottom, parseFloat(getComputedStyle(c).borderTopWidth)]; });") for stroke, (left, top, right, bottom, line) in zip(strokes(unfilled), boxes): xs, ys = stroke["points"][0::3], stroke["points"][1::3] half = stroke["width"] / 2 self.assertEqual(line, 2) expected = overlay_box(px(min(xs) - half), py(min(ys) - half), px(max(xs) + half), py(max(ys) + half), line) for side, got, want in zip(("left", "top", "right", "bottom"), (left - page_box[0], top - page_box[1], right - page_box[0], bottom - page_box[1]), expected): self.assertAlmostEqual(got, want, delta=0.01, msg=side) before = strokes(filled)[4]["points"] firefox.drag("#page", px(at(0.2, 0.37)[0]), py(at(0.2, 0.37)[1]), px(at(0.2, 0.37)[0]) + 100, py(at(0.2, 0.37)[1]) + 50) moved = self.wait_until(lambda s: strokes(s)[4]["points"] != before, "the rectangle moved") after = strokes(moved)[4]["points"] self.assertEqual(len(after), len(before)) for i in range(0, len(before), 3): self.assertAlmostEqual(after[i] - before[i], 100 / scale, delta=2) self.assertAlmostEqual(after[i + 1] - before[i + 1], 50 / scale, delta=2) self.assertEqual((strokes(moved)[4]["id"], strokes(moved)[4]["fill"]), (strokes(filled)[4]["id"], "#43a047")) firefox.switch_to(guest_tab) self.wait_until(lambda s: strokes(s)[4]["points"] == after, "the moved rectangle at the viewer") firefox.switch_to(host_tab) firefox.run("document.getElementById('undo').click();") self.wait_until(lambda s: strokes(s)[4]["points"] == before, "the move undone") tri = strokes(triangle)[5]["points"] right, bottom = max(tri[0::3]), max(tri[1::3]) firefox.drag("#page", px(right) - 3, py(bottom) - 3, px(right) + 80, py(bottom) + 60) resized = self.wait_until(lambda s: strokes(s)[5]["points"] != tri, "the triangle resized") p = strokes(resized)[5]["points"] self.assertAlmostEqual(min(p[0::3]), min(tri[0::3]), delta=1, msg="the left edge stays") self.assertAlmostEqual(max(p[0::3]), right + 80 / scale, delta=2) self.assertAlmostEqual(max(p[1::3]), bottom + 60 / scale, delta=2) self.assertEqual(p[0:2], p[-3:-1], "still closed") head = arrow["points"][13] - arrow["points"][7] firefox.drag("#page", px(at(0.2, 0.5)[0]), py(at(0.2, 0.5)[1]), px(at(0.2, 0.5)[0]), py(at(0.2, 0.5)[1]) + 40) arrow_moved = self.wait_until(lambda s: strokes(s)[1]["points"] != arrow["points"], "the arrow moved") # Index 1 since the rectangle's erasure. a = strokes(arrow_moved)[1]["points"] self.assertAlmostEqual(a[13] - a[7], head, delta=0.5, msg="the head keeps its size") self.assertAlmostEqual(a[10] - arrow["points"][10], 40 / scale, delta=2) choose("pen") self.assertEqual(cues(), []) # A text rectangle: nothing along its edges, nothing of it in the # file; it and free text show their corners while the pointer # hovers over them, and not otherwise. choose("text") firefox.run(TEXT_GESTURE, 0.36, 0.58, 0.48, 0.66) # Clear of every shape (the moved arrow's tip is near 0.3, 0.53). firefox.type_into(".text_field", "Box") choose("pen") box = self.wait_until(lambda s: s["texts"].get("Drawer") and s["texts"]["Drawer"][0]["text"] == "Box", "the box")["texts"]["Drawer"][0] self.assertFalse(firefox.run(INK_AT, box["x"] + box["width"] / 2, box["y"]), "no frame along the top edge") self.assertFalse(firefox.run(INK_AT, box["x"], box["y"]), "no corner mark on the canvas") self.assert_hover_cue(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2, [box["x"], box["y"], box["width"], box["height"]], "rect") saved = self.wait_until_true(lambda: (self.notebooks / "Drawer notebook" / "Current.svg").read_text(), lambda text: "Box" in text, "the box saved") self.assertNotIn("data-ground", saved) self.assertNotIn('stroke="#e53935"', saved.split("Box")[1].split("")[0]) choose("text") firefox.run(TEXT_GESTURE, 0.75, 0.6, 0.75, 0.6) firefox.type_into(".text_field", "Frei") choose("pen") free = self.wait_until(lambda s: len(s["texts"]["Drawer"]) == 2 and s["texts"]["Drawer"][1]["text"] == "Frei", "the free text")["texts"]["Drawer"][1] self.assertTrue(firefox.run(HOVER_CUE)["hidden"]) self.assertFalse(firefox.run(INK_AT, free["x"], free["y"]), "no red L on free text") self.assert_hover_cue(free["x"] + free["boxWidth"] / 2, free["y"] + free["boxHeight"] / 2, [free["x"], free["y"], free["boxWidth"], free["boxHeight"]], "free") # A circle (Andy, 2026-10-14): the centre first, then a drag out to # the edge in any direction; every point at that distance from the # centre. Resized by Arrange from a corner, it stays a circle. choose("shape") pick("circle") self.assertEqual(label(), "Circle") self.assertEqual(firefox.run("return [...document.querySelectorAll('#shape_menu [data-shape]')].filter((i) => i.getAttribute('aria-checked') === 'true').map((i) => i.dataset.shape);"), ["circle"]) self.assertEqual(firefox.run("return getComputedStyle(document.querySelector('#shape_menu [data-shape=circle]'), '::before').content;"), '"✓"') self.assertEqual(firefox.run("return [...document.querySelectorAll('#shape_menu [data-shape]')].map((i) => i.dataset.shape);"), ["rectangle", "circle", "ellipse", "line", "arrow"]) now = self.state() count = len(strokes(now)) now_at = lambda fx, fy: (now["origin"][0] + fx * now["page"]["width"], now["origin"][1] + fy * now["page"]["height"]) firefox.run(TEXT_GESTURE, 0.3, 0.3, 0.33, 0.34) with_circle = self.wait_until(lambda s: len(strokes(s)) == count + 1 and strokes(s)[-1]["done"], "the circle") circle = strokes(with_circle)[-1] self.assertEqual(circle["shape"], "circle") cx, cy = now_at(0.3, 0.3) radius = math.hypot(*(a - b for a, b in zip(now_at(0.33, 0.34), (cx, cy)))) xs, ys = circle["points"][0::3], circle["points"][1::3] for x, y in zip(xs, ys): self.assertAlmostEqual(math.hypot(x - cx, y - cy), radius, delta=1) self.assertEqual((xs[0], ys[0]), (xs[-1], ys[-1]), "closed") choose("arrange") right, bottom = max(xs), max(ys) firefox.drag("#page", px(right) - 3, py(bottom) - 3, px(right) + 90, py(bottom) + 30) resized = self.wait_until(lambda s: strokes(s)[-1]["points"] != circle["points"], "the circle resized") rx, ry = strokes(resized)[-1]["points"][0::3], strokes(resized)[-1]["points"][1::3] ncx, ncy = (min(rx) + max(rx)) / 2, (min(ry) + max(ry)) / 2 nr = (max(rx) - min(rx)) / 2 self.assertGreater(nr, radius + 40 / scale, "the radius grew by half the drag") self.assertAlmostEqual(max(ry) - min(ry), max(rx) - min(rx), delta=1, msg="still round") for x, y in zip(rx, ry): self.assertAlmostEqual(math.hypot(x - ncx, y - ncy), nr, delta=1) def test_eraser_preview(self): """Andy, 2026-10-14: with the Eraser chosen, a red box around each element a press under the hovering pointer would erase: a stroke by the ink's extent, a text block, an image a tap would remove; none on empty paper, none with another tool.""" firefox = self.firefox firefox.go(self.address("Host")) self.enter_name("Eraser", "start_session") self.wait_until(lambda s: s["page"]["number"] == 1, "the page") choose = lambda tool: firefox.run(f"document.querySelector('[data-tool={tool}]').click();") cues = lambda: firefox.run("const p = document.getElementById('page').getBoundingClientRect(); return [...document.querySelectorAll('.erase_cue')]" ".map((c) => { const b = c.getBoundingClientRect(); return [b.left - p.left, b.top - p.top, b.right - p.left, b.bottom - p.top, getComputedStyle(c).borderTopColor]; });") firefox.run(PEN_WAVE, 0.3, 0.5) choose("text") firefox.run(TEXT_GESTURE, 0.1, 0.7, 0.3, 0.8) firefox.type_into(".text_field", "Box") choose("pen") firefox.run_until_done(LOAD_IMAGE, 300, 200, "png", 0, False) state = self.wait_until(lambda s: len(s["layers"]["Eraser"]) == 1 and s["layers"]["Eraser"][0]["done"] and s["texts"]["Eraser"] and s["texts"]["Eraser"][0]["text"] == "Box" and len(s["images"]) == 1 and s["image"]["ready"], "the stroke, the box and the image") origin = state["origin"] scale = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;") px = lambda x: (x - origin[0]) * scale py = lambda y: (y - origin[1]) * scale hover_at = lambda x, y: firefox.run(HOVER, (x - origin[0]) / state["page"]["width"], (y - origin[1]) / state["page"]["height"]) stroke = state["layers"]["Eraser"][0] p = stroke["points"] halves = [stroke["width"] * (0.36 + 4 * q * q) / 2 for q in p[2::3]] left = min(x - h for x, h in zip(p[0::3], halves)) right = max(x + h for x, h in zip(p[0::3], halves)) top = min(y - h for y, h in zip(p[1::3], halves)) bottom = max(y + h for y, h in zip(p[1::3], halves)) # With the pen, nothing; with the eraser, the stroke's ink boxed in red. hover_at(p[0], p[1]) self.assertEqual(cues(), []) choose("eraser") hover_at(p[0], p[1]) boxes = cues() self.assertEqual(len(boxes), 1) self.assertEqual(boxes[0][4], "rgb(229, 57, 53)") for got, want in zip(boxes[0], overlay_box(px(left), py(top), px(right), py(bottom), 2)): self.assertAlmostEqual(got, want, delta=0.01) # The text block, by its box; the image, by a tap's rule; empty # paper, nothing; the cue goes when the pointer leaves the page. box = state["texts"]["Eraser"][0] hover_at(box["x"] + 20, box["y"] + 20) boxes = cues() self.assertEqual(len(boxes), 1) self.assertTrue(firefox.run(HOVER_CUE)["hidden"], "no blue cue beside the red one") for got, want in zip(boxes[0], overlay_box(px(box["x"]), py(box["y"]), px(box["x"] + box["width"]), py(box["y"] + box["height"]), 2)): self.assertAlmostEqual(got, want, delta=0.01) im = state["images"][0] hover_at(im["x"] + im["width"] / 2, im["y"] + im["height"] / 2) boxes = cues() self.assertEqual(len(boxes), 1) for got, want in zip(boxes[0], overlay_box(px(im["x"]), py(im["y"]), px(im["x"] + im["width"]), py(im["y"] + im["height"]), 2)): self.assertAlmostEqual(got, want, delta=0.01) hover_at(origin[0] + 0.9 * state["page"]["width"], origin[1] + 0.9 * state["page"]["height"]) self.assertEqual(cues(), []) hover_at(p[0], p[1]) self.assertEqual(len(cues()), 1) firefox.run("document.getElementById('page').dispatchEvent(new PointerEvent('pointerleave', {pointerId: 9, pointerType: 'mouse', bubbles: true}));") self.assertEqual(cues(), []) # Choosing another tool clears it. hover_at(p[0], p[1]) self.assertEqual(len(cues()), 1) choose("pen") self.assertEqual(cues(), []) def test_arrange_handwriting(self): """Andy, 2026-10-14: under Arrange a handwritten stroke gets its box while the pointer hovers over its ink, kept while the pointer stays within the box; a press on the ink moves it, on the box's corner scales it; the hold with the pen leaves it alone.""" firefox = self.firefox firefox.go(self.address("Host")) self.enter_name("Scribe", "start_session") # Not "Writer": another test counts the pages kept under that name. self.wait_until(lambda s: s["page"]["number"] == 1, "the page") choose = lambda tool: firefox.run(f"document.querySelector('[data-tool={tool}]').click();") cues = lambda: firefox.run("return [...document.querySelectorAll('.arrange_cue')].map((c) => c.className.split(' ')[1]);") firefox.run(PEN_WAVE, 0.3, 0.5) state = self.wait_until(lambda s: len(s["layers"]["Scribe"]) == 1 and s["layers"]["Scribe"][0]["done"], "the stroke") origin = state["origin"] scale = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;") px = lambda x: (x - origin[0]) * scale py = lambda y: (y - origin[1]) * scale hover_at = lambda x, y: firefox.run(HOVER, (x - origin[0]) / state["page"]["width"], (y - origin[1]) / state["page"]["height"]) stroke = state["layers"]["Scribe"][0] p = stroke["points"] halves = [stroke["width"] * (0.36 + 4 * q * q) / 2 for q in p[2::3]] left = min(x - h for x, h in zip(p[0::3], halves)) right = max(x + h for x, h in zip(p[0::3], halves)) top = min(y - h for y, h in zip(p[1::3], halves)) bottom = max(y + h for y, h in zip(p[1::3], halves)) choose("arrange") self.assertEqual(cues(), [], "no box until the pointer is over the ink") hover_at(p[0], p[1]) self.assertEqual(cues(), ["stroke"]) page_box = firefox.run("const b = document.getElementById('page').getBoundingClientRect(); return [b.left, b.top];") got = firefox.run("const b = document.querySelector('.arrange_cue.stroke').getBoundingClientRect(); return [b.left, b.top, b.right, b.bottom];") want = overlay_box(px(left), py(top), px(right), py(bottom), 2) for side, g, w in zip(("left", "top", "right", "bottom"), (got[0] - page_box[0], got[1] - page_box[1], got[2] - page_box[0], got[3] - page_box[1]), want): self.assertAlmostEqual(g, w, delta=0.01, msg=side) # Off the ink but within the box, the box stays; outside it, it goes. hover_at(left + 2, top + 2) self.assertEqual(cues(), ["stroke"], "kept within the box") hover_at(right + 200, bottom + 200) self.assertEqual(cues(), [], "gone outside the box") # A drag on the ink moves the whole stroke; Undo takes it back. firefox.drag("#page", px(p[0]), py(p[1]), px(p[0]) + 120, py(p[1]) + 60) moved = self.wait_until(lambda s: s["layers"]["Scribe"][0]["points"] != p, "the stroke moved") q = moved["layers"]["Scribe"][0]["points"] self.assertEqual(len(q), len(p)) self.assertAlmostEqual(q[0] - p[0], 120 / scale, delta=2) self.assertAlmostEqual(q[1] - p[1], 60 / scale, delta=2) self.assertEqual(q[2::3], p[2::3], "the pressures stay") self.assertAlmostEqual(max(q[0::3]) - min(q[0::3]), max(p[0::3]) - min(p[0::3]), delta=0.3, msg="the same size") firefox.run("document.getElementById('undo').click();") self.wait_until(lambda s: s["layers"]["Scribe"][0]["points"] == p, "the move undone") # A drag on the shown box's bottom-right corner scales the stroke. hover_at(p[0], p[1]) self.assertEqual(cues(), ["stroke"]) firefox.drag("#page", px(right) + 3, py(bottom) + 3, px(right) + 150, py(bottom) + 40) scaled = self.wait_until(lambda s: s["layers"]["Scribe"][0]["points"] != p, "the stroke scaled") q = scaled["layers"]["Scribe"][0]["points"] self.assertAlmostEqual(min(q[0::3]), min(p[0::3]), delta=1, msg="the left edge stays") self.assertGreater(max(q[0::3]) - min(q[0::3]), max(p[0::3]) - min(p[0::3]) + 100 / scale) self.assertEqual(scaled["layers"]["Scribe"][0]["width"], stroke["width"], "the line width unchanged") # The hold with the pen draws a dot and leaves the stroke alone. choose("pen") self.assertEqual(cues(), []) firefox.run_until_done(PEN_HOLD, px(q[0]), py(q[1]), 40, 20, 700) held = self.wait_until(lambda s: len(s["layers"]["Scribe"]) == 2 and s["layers"]["Scribe"][1]["done"], "the pen's own stroke") self.assertEqual(held["layers"]["Scribe"][0]["points"], q, "the handwriting not moved by a hold") def test_page_menu_and_help(self): """Andy, 2026-10-14: the page and view commands are in a Page menu, a popup of Kokkoja's own under the button, closed by a press on an item, elsewhere, or Escape; one popup at a time. Help (Andy, 2026-10-15): one click shows the manual, two quick clicks the note about the documentation site.""" firefox = self.firefox firefox.go(self.address("Late")) # The closed manual window is not displayed, on the start screen # or in the session (a layout rule on the dialog itself had # overridden the browser's display: none, 2026-10-15). displayed = lambda: firefox.run("return getComputedStyle(document.getElementById('manual_dialog')).display;") self.assertEqual(displayed(), "none") self.enter_name("Menuist", "start_session") self.wait_until(lambda s: s["page"]["number"] == 1, "the page") self.assertEqual(displayed(), "none") hidden = lambda id: firefox.run(f"return document.getElementById('{id}').hidden;") click = lambda id: firefox.run(f"document.getElementById('{id}').click();") box = lambda id: firefox.run(f"const b = document.getElementById('{id}').getBoundingClientRect(); return [b.left, b.top, b.right, b.bottom];") self.assertTrue(hidden("page_menu")) self.assertIsNone(firefox.run("return document.querySelector('.page_view');"), "the middle group is gone") # The Page button is the first of the right-hand group; Help the last of all. self.assertEqual(firefox.run("return [...document.querySelectorAll('.toolbar button')].at(-1).id;"), "help") self.assertEqual(firefox.run("return document.querySelector('.toolbar_right button').id;"), "page_menu_button") # Open: below the button, left edges aligned, within the window. click("page_menu_button") self.assertFalse(hidden("page_menu")) self.assertEqual(firefox.run("return document.getElementById('page_menu_button').getAttribute('aria-expanded');"), "true") button, menu = box("page_menu_button"), box("page_menu") self.assertAlmostEqual(menu[0], button[0], delta=1) self.assertGreater(menu[1], button[3]) self.assertLess(menu[2], firefox.run("return window.innerWidth;")) items = firefox.run("return [...document.querySelectorAll('#page_menu .menu_item')].filter((e) => !e.hidden).map((e) => e.textContent.trim());") self.assertEqual(items, ["Fit to the window", "Normal scale", "Expand to the window", "Shrink to the contents", "Extend the page while writing"]) # Two grey titles, over the first two sections, in the muted colour. self.assertEqual(firefox.run("return [...document.querySelectorAll('#page_menu .menu_title')].map((e) => e.textContent);"), ["Change the display of the page", "Change the size of the page"]) self.assertEqual(firefox.run("const t = document.querySelector('#page_menu .menu_title'); return [getComputedStyle(t).color, getComputedStyle(t).fontStyle];"), firefox.run("return [getComputedStyle(document.getElementById('undo')).color, 'normal'];")) # An item's press runs it and closes the menu. click("zoom_sheet") self.assertTrue(hidden("page_menu")) # The checkbox item toggles Extend and closes too. click("page_menu_button") self.assertTrue(firefox.run("return document.getElementById('extend').checked;")) click("extend") self.assertFalse(firefox.run("return document.getElementById('extend').checked;")) self.assertTrue(hidden("page_menu")) click("extend") # back on, for the tests that follow # Escape closes; so does a press elsewhere. click("page_menu_button") self.assertFalse(hidden("page_menu")) firefox.chord("\ue00c") self.assertTrue(hidden("page_menu")) click("page_menu_button") firefox.run("document.getElementById('page').dispatchEvent(new PointerEvent('pointerdown', {bubbles: true}));") self.assertTrue(hidden("page_menu")) # Help, two quick clicks: the note about the documentation site; # opening it closes the menu, and the reverse. click("page_menu_button") double_click = lambda id: firefox.run(f"const b = document.getElementById('{id}'); b.click(); b.click();") double_click("help") self.assertFalse(hidden("help_popup")) self.assertTrue(hidden("page_menu")) # At the far right, the note's right edge aligns with the button's, inside the window. help_button, note = box("help"), box("help_popup") self.assertAlmostEqual(note[2], help_button[2], delta=1) self.assertLess(note[2], firefox.run("return window.innerWidth;")) self.assertEqual(firefox.run("return document.getElementById('help_popup').textContent.trim();"), "Documentation website coming soon!") time.sleep(0.6) self.assertFalse(firefox.run("return document.getElementById('manual_dialog').open;"), "two clicks do not open the manual") click("page_menu_button") self.assertTrue(hidden("help_popup")) self.assertFalse(hidden("page_menu")) firefox.chord("\ue00c") # One click on Help: the manual, the tooltips of every command in a # window, once the interval for a second click has passed. The # title, then each control in alphabetical order, its name in the # interface's sans-serif and its explanation in a serif below; the # window's contents 2em from its edges. click("help") self.assertFalse(firefox.run("return document.getElementById('manual_dialog').open;"), "not before the interval") self.wait_until_true(lambda: firefox.run("return document.getElementById('manual_dialog').open;"), bool, "the manual", seconds=3) self.assertEqual(displayed(), "flex") self.assertTrue(hidden("help_popup"), "the note did not open as well") self.assertEqual(firefox.run("return document.getElementById('manual_text').querySelector('h3').textContent;"), "User interface actions") shown = firefox.run(""" const list = document.querySelector('#manual_text dl'); return [...list.children].map((e) => [e.tagName, e.textContent, getComputedStyle(e).fontFamily]); """) self.assertEqual([tag for tag, _, _ in shown[::2]], ["DT"] * (len(shown) // 2)) self.assertEqual([tag for tag, _, _ in shown[1::2]], ["DD"] * (len(shown) // 2)) self.assertTrue(all("sans-serif" in font for _, _, font in shown[::2]), "the names in a sans-serif") self.assertTrue(all(font == "serif" for _, _, font in shown[1::2]), "the explanations in a serif") self.assertEqual(firefox.run("const s = getComputedStyle(document.getElementById('manual_dialog')); return [s.paddingTop, s.paddingRight, s.paddingBottom, s.paddingLeft];"), ["30px"] * 4, "2em at 15px") self.assertEqual(firefox.run("return document.activeElement.id;"), "manual_text") # The text the Copy button copies: the title, then each control in # alphabetical order, its name on a line, its explanation wrapped # at 80 columns, and a blank line after; the same entries as shown. manual = firefox.run("return window.kokkoja.manualText();") self.assertTrue(manual.startswith("User interface actions\n\n")) blocks = manual.split("\n\n")[1:] names = [block.split("\n")[0] for block in blocks if block] self.assertEqual(names, [text for _, text, _ in shown[::2]], "the text and the window agree") self.assertEqual(names, sorted(names, key=str.casefold), "alphabetical") self.assertIn("Pen", names) self.assertIn("Color", names) # the swatches as one entry self.assertIn("Line width", names) # the widths as one entry self.assertIn("Notebook for the pages of this session", names) self.assertIn("QR code", names) # A name two controls share gets each one's context: the nearest heading above it. self.assertIn("Invitation: Copy", names) self.assertIn("Log: Copy", names) self.assertNotIn("Copy", names) self.assertEqual(len(names), len(set(names)), "no two entries of one name") self.assertIn("\nPen\nWrite on the page. Only your own writing can be erased.\n", manual) self.assertIn("\nFit to the window\nShow the whole page in the window.", manual) self.assertTrue(all(len(line) <= 80 for line in manual.split("\n")), "wrapped at 80") self.assertTrue(any(len(line) > 60 for line in manual.split("\n")), "filled, not one word a line") # Every tooltip is in it; those that differ only by a number (the # line widths) as one entry, so the numbers are set aside. helps = firefox.run("return [...document.querySelectorAll('[data-help]')].filter((e) => !e.closest('#manual_dialog')).map((e) => e.getAttribute('data-help'));") unwrapped = re.sub(r"\d+(?:\.\d+)?", "#", manual.replace("\n", " ")) for help in set(helps): self.assertIn(re.sub(r"\d+(?:\.\d+)?", "#", help), unwrapped, help[:40]) self.assertIn("Two quick clicks show where the documentation is.", unwrapped) firefox.run("document.getElementById('manual_close').click();") self.assertFalse(firefox.run("return document.getElementById('manual_dialog').open;")) self.assertEqual(displayed(), "none") # The same with a real mouse: two clicks within the interval show # the note, and the manual does not open; after a click the list # has the focus without a focus ring (the ring is for keyboard # focus). x, y = firefox.run("const b = document.getElementById('help').getBoundingClientRect(); return [Math.round(b.left + b.width / 2), Math.round(b.top + b.height / 2)];") press = [{"type": "pointerDown", "button": 0}, {"type": "pointerUp", "button": 0}] firefox.pointer_actions([{"type": "pointerMove", "duration": 0, "origin": "viewport", "x": x, "y": y}, *press, {"type": "pause", "duration": 120}, *press]) self.assertFalse(hidden("help_popup")) time.sleep(0.6) self.assertFalse(firefox.run("return document.getElementById('manual_dialog').open;")) firefox.run("document.getElementById('page').dispatchEvent(new PointerEvent('pointerdown', {bubbles: true}));") firefox.pointer_actions([{"type": "pointerMove", "duration": 0, "origin": "viewport", "x": x, "y": y}, *press]) self.wait_until_true(lambda: firefox.run("return document.getElementById('manual_dialog').open;"), bool, "the manual", seconds=3) self.assertEqual(firefox.run("const e = document.getElementById('manual_text'); return [document.activeElement.id, getComputedStyle(e).outlineStyle];"), ["manual_text", "none"]) firefox.run("document.getElementById('manual_close').click();") def test_shrink_ignores_the_invisible(self): """Andy, 2026-10-14: what cannot be seen does not count for Shrink: strokes and free text in the paper's white, and the blank lines at the end of a text block, which go when its typing ends (a box grown by them shrinks back) and, for one's own, at Shrink.""" firefox = self.firefox firefox.go(self.address("Late")) self.enter_name("Shrinker", "start_session") state = self.wait_until(lambda s: s["page"]["number"] == 1, "the page") scale = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;") origin = state["origin"] px = lambda x: (x - origin[0]) * scale py = lambda y: (y - origin[1]) * scale swatch = lambda i: firefox.run(f"document.querySelectorAll('#colors .swatch')[{i}].click();") # A black stroke, then a white stroke and white free text far to the # right and below, where they would widen the shrunk page if counted. firefox.drag("#page", px(500), py(500), px(900), py(520)) self.wait_until(lambda s: len(s["layers"]["Shrinker"]) == 1 and s["layers"]["Shrinker"][0]["done"], "the black stroke") swatch(4) firefox.drag("#page", px(2000), py(1500), px(2500), py(1600)) self.wait_until(lambda s: len(s["layers"]["Shrinker"]) == 2 and s["layers"]["Shrinker"][1]["done"], "the white stroke") self.assertEqual(self.state()["layers"]["Shrinker"][1]["id"] and firefox.run( "return [...window.kokkoja.session.ownLayer.strokes.values()][1].color;"), "#ffffff") firefox.run("document.querySelector('[data-tool=text]').click();") firefox.run(TEXT_GESTURE, px(2000) / state["page"]["width"] / scale, py(600) / state["page"]["height"] / scale, px(2000) / state["page"]["width"] / scale, py(600) / state["page"]["height"] / scale) firefox.type_into(".text_field", "unsichtbar") # A small text rectangle, in black, grown by blank lines typed after # the word: finishing removes them and the box shrinks back to its # one line. Dragging it open finishes the white text first; the # colour is chosen after, since a change restyles the block typed in. fx = lambda x: px(x) / (state["page"]["width"] * scale) fy = lambda y: py(y) / (state["page"]["height"] * scale) firefox.run(TEXT_GESTURE, fx(500), fy(800), fx(1100), fy(830)) swatch(0) firefox.type_into(".text_field", "Antwort\n\n\n\n") grown = self.wait_until(lambda s: len(s["texts"]["Shrinker"]) == 2 and s["texts"]["Shrinker"][1]["text"] == "Antwort\n\n\n\n", "the box typed in") box = grown["texts"]["Shrinker"][1] self.assertGreater(box["height"], 300, "grown by the blank lines: five lines of about 70 units") self.assertEqual(box["height"], math.ceil(box["boxHeight"])) firefox.run("document.querySelector('[data-tool=pen]').click();") trimmed = self.wait_until(lambda s: s["texts"]["Shrinker"][1]["text"] == "Antwort", "the blank lines gone") box = trimmed["texts"]["Shrinker"][1] self.assertEqual(box["height"], math.ceil(box["boxHeight"])) self.assertLess(box["height"], 80, "back to one line") self.assertEqual(trimmed["texts"]["Shrinker"][0]["text"], "unsichtbar") self.assertEqual(firefox.run("return [...window.kokkoja.session.ownLayer.texts.values()][0].color;"), "#ffffff") # Shrink: the sheet holds the black stroke and the box with the # margin, and nothing of the white marks. before = self.state()["page"] firefox.run("document.getElementById('zoom_shrink').click();") shrunk = self.wait_until(lambda s: s["page"]["width"] < before["width"], "the shrunk page") sheet = firefox.run("const p = window.kokkoja.session.page; return [p.baseLeft, p.baseTop, p.baseWidth, p.baseHeight];") self.assertAlmostEqual(sheet[0], 500 - 14 - 50, delta=4) # the stroke's reach, then the margin self.assertAlmostEqual(sheet[1], 500 - 14 - 50, delta=4) self.assertAlmostEqual(sheet[0] + sheet[2], 1100 + 50, delta=4) # the box's right edge self.assertAlmostEqual(sheet[1] + sheet[3], 800 + box["height"] + 50, delta=4) # A box of one's own that still has blank lines at its end, as one # from a page made before the trimming would, is trimmed by Shrink # itself, as one gesture to undo. Finishing typing trims now, so # such a block is made by script here, the way a loaded page is. firefox.run(""" const s = window.kokkoja.session; const b = [...s.ownLayer.texts.values()][1]; b.text += '\\n\\n\\n'; b.lines = [...b.lines, '', '', '']; s.setText(b); """) self.wait_until(lambda s: s["texts"]["Shrinker"][1]["text"] == "Antwort\n\n\n", "the blank lines on the block") firefox.run("document.getElementById('zoom_shrink').click();") self.wait_until(lambda s: s["texts"]["Shrinker"][1]["text"] == "Antwort", "trimmed by Shrink") firefox.run("document.getElementById('undo').click();") self.wait_until(lambda s: s["texts"]["Shrinker"][1]["text"] == "Antwort\n\n\n", "the trim undone") 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 Normal scale 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) # "Normal scale" ("A4" until 2026-10-14) 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 # region in which the text wraps and which grows downward by lines # as the text needs, marked by blue corners (until 2026-10-14 a red # box, white inside, which the field drew as an outline). 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;"), "none") 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 ', '')); """) row_height = firefox.run("return document.querySelector('.toolbar').getBoundingClientRect().height;") firefox.run("document.getElementById('more_colors').click();") self.assertFalse(firefox.run("return document.getElementById('color_palette').hidden;")) # The palette floats below "+", above the page; it does not reflow # the toolbar (it did from the Page menu's commit of 2026-10-14 # until the same day, when its positioning had moved to the shared # popup rule and the palette had not been given that class). self.assertEqual(firefox.run("return document.querySelector('.toolbar').getBoundingClientRect().height;"), row_height) placed = firefox.run(""" const p = document.getElementById('color_palette').getBoundingClientRect(); const b = document.getElementById('more_colors').getBoundingClientRect(); return {position: getComputedStyle(document.getElementById('color_palette')).position, below: p.top >= b.bottom, left: Math.abs(p.left - b.left) < 1}; """) self.assertEqual(placed, {"position": "absolute", "below": True, "left": True}) 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") # The added colour is among the shapes' fill colours as well. self.assertEqual(firefox.run("return [...document.querySelectorAll('#shape_fill .swatch')].map((s) => s.getAttribute('aria-label'));"), ["No fill", "Fill #000000", "Fill #e53935", "Fill #43a047", "Fill #1e88e5", "Fill #ffffff", "Fill #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()