Files
kokkoja-rel/tests/test_end_to_end.py
T

2597 lines
146 KiB
Python
Raw Normal View History

2026-10-06 15:58:02 +02:00
"""End-to-end test in Firefox: two participants, one page, through the relay.
Starts a relay and the local program on free ports, then drives a headless
Firefox through geckodriver (the WebDriver protocol, spoken here with the
standard library only). Skipped if geckodriver is not installed.
Run from the project directory:
.venv/bin/python -m unittest tests.test_end_to_end -v
Set KOKKOJA_SCREENSHOT to a file name to save a picture of the page as
the second participant sees it at the end.
"""
import base64
import json
import os
import pathlib
import re
2026-10-06 15:58:02 +02:00
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.
2026-10-06 15:58:02 +02:00
import urllib.error
import urllib.parse
import urllib.request
import zlib
PROJECT = pathlib.Path(__file__).resolve().parent.parent
# Describes the page as one participant holds it: for each layer, by the
# participant's name, the strokes with their points.
PAGE_STATE = """
const session = window.kokkoja && window.kokkoja.session;
if (!session) return null;
const layers = {};
const texts = {};
for (const layer of session.page.layers.values()) {
layers[layer.name] = [...layer.strokes.values()].map(
(stroke) => ({id: stroke.id, done: stroke.done, points: stroke.points}));
texts[layer.name] = [...layer.texts.values()];
}
// The first image, and all of them in sequence.
const images = session.page.images;
const image = images[0] ?? null;
const shown = document.querySelectorAll('.page_image');
return {
status: document.getElementById('status').textContent,
page: {number: session.page.number, width: session.page.width, height: session.page.height},
origin: [session.page.left, session.page.top],
2026-10-06 15:58:02 +02:00
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]),
2026-10-06 15:58:02 +02:00
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);
"""
2026-10-06 15:58:02 +02:00
# 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.
2026-10-06 15:58:02 +02:00
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.
2026-10-06 15:58:02 +02:00
"""
# 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;
"""
2026-10-11 13:17:22 +02:00
# 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();
"""
2026-10-06 15:58:02 +02:00
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"],
2026-10-06 15:58:02 +02:00
# Downloads go to the given directory without asking.
"prefs": {"browser.download.folderList": 2, "browser.download.dir": str(downloads),
"browser.download.useDownloadDir": True,
"browser.download.always_ask_before_handling_new_types": False},
},
}}
self.session = self.command("POST", "/session", {"capabilities": capabilities})["sessionId"]
def command(self, method, path, body=None):
data = None if body is None else json.dumps(body).encode()
request = urllib.request.Request(self.base + path, data=data, method=method,
headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(request, timeout=60) as response:
return json.load(response)["value"]
except urllib.error.HTTPError as error:
raise RuntimeError(error.read().decode()) from None
def in_session(self, method, path, body=None):
return self.command(method, f"/session/{self.session}{path}", body)
def go(self, address):
self.in_session("POST", "/url", {"url": address})
def run(self, script, *arguments):
return self.in_session("POST", "/execute/sync", {"script": script, "args": list(arguments)})
def run_until_done(self, script, *arguments):
"""Run a script that calls its last argument when it has finished."""
return self.in_session("POST", "/execute/async", {"script": script, "args": list(arguments)})
def new_tab(self):
handle = self.in_session("POST", "/window/new", {"type": "tab"})["handle"]
self.switch_to(handle)
return handle
def current_tab(self):
return self.in_session("GET", "/window")
def switch_to(self, handle):
self.in_session("POST", "/window", {"handle": handle})
def drag(self, selector, from_x, from_y, to_x, to_y):
"""Drag the mouse over an element; positions are from its top left."""
box = self.run(f"const b = document.querySelector({json.dumps(selector)})"
".getBoundingClientRect(); return [b.left, b.top];")
moves = [{"type": "pointerMove", "duration": 0, "origin": "viewport",
"x": round(box[0] + from_x), "y": round(box[1] + from_y)},
{"type": "pointerDown", "button": 0}]
for step in range(1, 11):
moves.append({"type": "pointerMove", "duration": 20, "origin": "viewport",
"x": round(box[0] + from_x + (to_x - from_x) * step / 10),
"y": round(box[1] + from_y + (to_y - from_y) * step / 10)})
moves.append({"type": "pointerUp", "button": 0})
self.in_session("POST", "/actions", {"actions": [{
"type": "pointer", "id": "mouse", "parameters": {"pointerType": "mouse"},
"actions": moves}]})
def type_into(self, selector, text):
"""Type text into an element, as the keyboard would."""
found = self.in_session("POST", "/element", {"using": "css selector", "value": selector})
element_id = next(iter(found.values()))
self.in_session("POST", f"/element/{element_id}/value", {"text": text})
def hover(self, selector):
"""Rest the mouse on the middle of an element."""
box = self.run(f"const b = document.querySelector({json.dumps(selector)})"
".getBoundingClientRect(); return [b.left + b.width / 2, b.top + b.height / 2];")
self.in_session("POST", "/actions", {"actions": [{
"type": "pointer", "id": "mouse", "parameters": {"pointerType": "mouse"},
"actions": [{"type": "pointerMove", "duration": 0, "origin": "viewport",
"x": round(box[0]), "y": round(box[1])}]}]})
2026-10-11 13:17:22 +02:00
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})
2026-10-06 15:58:02 +02:00
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"
2026-10-06 15:58:02 +02:00
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])
2026-10-06 15:58:02 +02:00
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):
"""The Kokkoja address of a participant's own local program, with
its token, and an invitation to join if given."""
base, token = self.programs[name]
joining = "" if join is None else f"&join={urllib.parse.quote(join, safe='')}"
return f"{base}#token={token}{joining}"
@classmethod
def start(cls, command):
cls.processes.append(subprocess.Popen(
command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL))
@classmethod
def stop(cls):
for process in reversed(cls.processes):
process.terminate()
for process in cls.processes:
process.wait(timeout=10)
@classmethod
def tearDownClass(cls):
try:
cls.firefox.quit()
finally:
cls.stop()
cls.directory.cleanup()
shutil.rmtree(cls.downloads, ignore_errors=True)
def state(self):
return self.firefox.run(PAGE_STATE)
def wait_until_true(self, read, condition, what, seconds=15):
"""Wait until condition(read()) is true and return the value read."""
deadline = time.monotonic() + seconds
value = None
while time.monotonic() < deadline:
value = read()
if condition(value):
return value
time.sleep(0.1)
self.fail(f"timed out waiting for {what}; last value: {value}")
def wait_until(self, condition, what, seconds=15):
"""Wait until condition(state) is true and return the state."""
deadline = time.monotonic() + seconds
state = None
while time.monotonic() < deadline:
state = self.state()
if state and condition(state):
return state
time.sleep(0.1)
self.fail(f"timed out waiting for {what}; last state: {json.dumps(state)[:600]}")
def enter_name(self, name, button):
# The start screen appears once the client has read config.json.
self.wait_until_true(lambda: self.firefox.run("return !document.getElementById('start').hidden;"),
bool, "the start screen")
# The notebook is made through the picker: "New notebook…", then its name.
self.firefox.run("""
document.getElementById('name').value = arguments[0];
const pick = document.getElementById('notebook');
pick.value = '/new';
pick.dispatchEvent(new Event('change'));
const entry = document.getElementById('notebook_name');
entry.value = arguments[0] + ' notebook';
entry.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', bubbles: true}));
if (pick.value !== arguments[0] + ' notebook') throw new Error('notebook not chosen: ' + pick.value);
document.getElementById(arguments[1]).click();
""", name, button)
2026-10-06 15:58:02 +02:00
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("<g data-stroke=")
for layer in text.split("<g data-layer=")[1:]}
return {name: count for name, count in counts.items() if count}
def current_page(self, notebook, stroke_counts, number=None, images=None, seconds=10):
"""Wait until the notebook's current page holds the given number of
strokes in each layer (and of images, if given), and return its text."""
deadline = time.monotonic() + seconds
found = None
while time.monotonic() < deadline:
try:
text = (self.notebooks / notebook / "Current.svg").read_text()
except FileNotFoundError:
text = None
if text is not None:
found = self.stroke_counts(text)
if (found == stroke_counts and (number is None or f'data-number="{number}"' in text)
and (images is None or text.count("<image ") == images)):
return text
time.sleep(0.1)
self.fail(f"the current page of {notebook} has {found}, expected {stroke_counts}")
def kept_pages(self, notebook):
return sorted(page.name for page in (self.notebooks / notebook).glob("2*.svg"))
def wait_for_kept(self, notebook, count, seconds=10):
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
if len(self.kept_pages(notebook)) == count:
return self.kept_pages(notebook)
time.sleep(0.1)
self.fail(f"{notebook} keeps {self.kept_pages(notebook)}, expected {count} pages")
def wait_for_download(self, name, seconds=15):
"""Wait for a finished download of the given name and return its bytes."""
path = self.downloads / name
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
if path.exists() and not (self.downloads / f"{name}.part").exists():
return path.read_bytes()
time.sleep(0.1)
self.fail(f"no download {name}; downloads: {sorted(p.name for p in self.downloads.iterdir())}")
def test_browser_guest(self):
"""A guest with nothing installed: the host's invitation is a link to
the Kokkoja web site, and the guest's notebook is in the browser."""
firefox = self.firefox
today = time.strftime("%Y_%m_%d")
# The host's program knows the web site, so the invitation it shows
# is a link there that carries the invitation itself.
host_tab = firefox.current_tab()
firefox.go(self.address("Host"))
self.enter_name("Host", "start_session")
link = firefox.run("return document.getElementById('invitation').value;")
self.assertRegex(link, rf"^{self.site}#join=ws://127\.0\.0\.1:\d+%23[\w-]{{22}}\.[\w-]{{22}}$")
firefox.drag("#page", 100, 100, 400, 180)
host = self.wait_until(lambda s: len(s["layers"]["Host"]) == 1 and s["layers"]["Host"][0]["done"],
"the host's stroke")
# The guest opens the link. The start screen has the invitation
# filled in, says where the notebooks are, and offers the installed
# program to anyone who has it.
guest_tab = firefox.new_tab()
firefox.go(link)
self.wait_until_true(lambda: firefox.run("return document.getElementById('invitation_entry').value;"),
lambda v: v and v.startswith("ws://"), "the invitation in the start screen")
self.assertEqual(firefox.run("return document.getElementById('invitation_entry').value;"),
urllib.parse.unquote(link.split("#join=")[1]))
self.assertIn("kept in this browser", firefox.run(
"return document.getElementById('notebook_note').textContent;"))
# Arriving with an invitation, the start screen offers joining alone.
self.assertTrue(firefox.run("return document.getElementById('start_choice').hidden;"))
self.assertFalse(firefox.run("return document.getElementById('join_choice').hidden;"))
2026-10-06 15:58:02 +02:00
installed = firefox.run("const a = document.getElementById('open_installed');"
"return a.hidden ? null : a.href;")
self.assertTrue(installed.startswith("http://localhost:8760/#join=ws"), installed)
# The guest joins, receives the host's stroke and writes one.
self.enter_name("Guest", "join_session")
guest = self.wait_until(lambda s: len(s["layers"].get("Host", [])) == 1,
"the guest to receive the host's layer")
self.assertEqual(guest["layers"]["Host"], host["layers"]["Host"])
firefox.run(PEN_WAVE, 0.3, 0.5)
guest = self.state()
self.assertEqual(len(guest["layers"]["Guest"]), 1)
firefox.switch_to(host_tab)
host = self.wait_until(
lambda s: len(s["layers"].get("Guest", [])) == 1 and s["layers"]["Guest"][0]["done"],
"the host to receive the guest's stroke")
self.assertEqual(host["layers"]["Guest"], guest["layers"]["Guest"])
self.current_page("Host notebook", {"Host": 1, "Guest": 1}, number=1)
# Too narrow for one row, the toolbar centres its rows instead of
# holding the two ends; wide again, it holds them.
firefox.switch_to(host_tab)
bar = lambda: firefox.run("""
const bar = document.querySelector('.toolbar');
const first = bar.firstElementChild.getBoundingClientRect();
const last = bar.lastElementChild.getBoundingClientRect();
return {wrapped: bar.classList.contains('wrapped'), rows: bar.offsetHeight < first.height * 1.9 ? 1 : 2,
firstLeft: first.left, lastRight: last.right, width: bar.getBoundingClientRect().width};
""")
wide = bar()
self.assertEqual((wide["wrapped"], wide["rows"]), (False, 1))
firefox.set_window(1100, 1000)
narrow = self.wait_until_true(bar, lambda b: b["wrapped"], "the toolbar to wrap and centre")
self.assertGreater(narrow["rows"], 1)
self.assertGreater(narrow["firstLeft"], 40) # Centred: not at the left edge.
firefox.set_window(2000, 1000)
self.wait_until_true(bar, lambda b: not b["wrapped"], "the toolbar back in one row")
# "Session" opens the dialog with the participants and the invitation.
2026-10-06 15:58:02 +02:00
firefox.switch_to(guest_tab)
firefox.run("document.getElementById('session_button').click();")
self.assertEqual(firefox.run("""
const dialog = document.getElementById('session_dialog');
return {open: dialog.open,
names: [...document.querySelectorAll('#participants li label')].map((l) => 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, 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.
2026-10-06 15:58:02 +02:00
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")
2026-10-06 15:58:02 +02:00
# 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;"))
2026-10-06 15:58:02 +02:00
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})
2026-10-06 15:58:02 +02:00
# 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;"))
2026-10-06 15:58:02 +02:00
# Allowed by the host, the guest puts an image on the page (the
# file would come from the browser's own chooser).
2026-10-11 17:34:24 +02:00
self.assertTrue(firefox.run("return document.getElementById('new_page').hidden;"))
2026-10-06 15:58:02 +02:00
firefox.switch_to(host_tab)
self.assertEqual(firefox.run(TOGGLE_PERMISSION, "Guest"), "true")
firefox.switch_to(guest_tab)
2026-10-11 17:34:24 +02:00
self.wait_until(lambda s: not firefox.run("return document.getElementById('new_page').hidden;"),
2026-10-06 15:58:02 +02:00
"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")
2026-10-11 18:05:49 +02:00
# 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(
'<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg" data-kokkoja="1" data-page="abcdefghijkl" data-number="1" data-base="2970 2100" '
'viewBox="0 0 2970 2100"><rect width="2970" height="2100" fill="#fff"/>'
'<g data-layer="a" data-name="Anna"><g data-stroke="s" data-width="7" data-points="10 10 0.5 20 20 0.5" '
'stroke="#000000" fill="none"><path d="M10 10 L20 20"/></g>'
'<g data-text="t2" data-x="100" data-y="900" data-width="" data-height="" data-size="35" '
'data-font="sans-serif" data-box="300 50"><desc>Lower text</desc>'
'<text fill="#000000"><tspan>Lower text</tspan></text></g>'
'<g data-text="t1" data-x="500" data-y="200" data-width="" data-height="" data-size="35" '
'data-font="sans-serif" data-box="500 50"><desc>Übung 12: der Dativ</desc>'
'<text fill="#000000"><tspan>Übung 12: der Dativ</tspan></text></g></g></svg>')
(notebook / "2026_10_03_01.svg").write_text(
'<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg" data-kokkoja="1" data-page="abcdefghijkl" data-number="1" data-base="2970 2100" '
'viewBox="0 0 2970 2100"><rect width="2970" height="2100" fill="#fff"/></svg>')
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('<g data-layer='), saved.index('<g data-hides='))
self.assertIn(f'x="{resized[0]}" y="{resized[1]}" width="{resized[2]}" height="{resized[3]}" fill="#ffffff"', saved)
firefox.switch_to(guest_tab)
self.wait_until(lambda s: s["hides"] == [resized], "the resized Hide to reach the watcher")
firefox.switch_to(host_tab)
# A tap of the eraser removes it; the writing beneath is back.
firefox.run(ERASER_AT, resized[0] + 50, resized[1] + 50, 5)
gone = self.wait_until(lambda s: not s["hides"], "the Hide removed")
self.assertEqual(len(gone["layers"]["Hider"]), len(written["layers"]["Hider"]))
self.assertFalse(white_at(hide[0] + hide[2] / 2, hide[1] + hide[3] / 2))
self.assertNotIn('data-hides', self.current_page("Hider notebook", {"Hider": 1}, number=1))
firefox.switch_to(guest_tab)
firefox.in_session("DELETE", "/window")
firefox.switch_to(host_tab)
firefox.go("about:blank")
2026-10-11 13:17:22 +02:00
def test_undo(self):
"""Andy, 2026-10-11: one's latest gesture is taken back, then the
one before, by the Undo button, Ctrl+Z, or two quick taps of the
eraser on empty paper; a stroke, an erasure, text, a Hide."""
firefox = self.firefox
host_tab = firefox.current_tab()
firefox.go(self.address("Late"))
self.enter_name("Writer", "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)
undo_disabled = lambda: firefox.run("return document.getElementById('undo').disabled;")
undo = lambda: firefox.run("document.getElementById('undo').click();")
self.assertTrue(undo_disabled(), "nothing to undo on a new page")
# Two strokes; Undo takes back the second, for everyone.
firefox.drag("#page", 200, 300, 500, 320)
firefox.drag("#page", 200, 400, 500, 420)
two = self.wait_until(lambda s: len(s["layers"]["Writer"]) == 2 and s["layers"]["Writer"][-1]["done"],
"two strokes")
self.assertFalse(undo_disabled())
undo()
one = self.wait_until(lambda s: len(s["layers"]["Writer"]) == 1, "one stroke after the undo")
self.assertEqual(one["layers"]["Writer"][0]["id"], two["layers"]["Writer"][0]["id"])
firefox.switch_to(guest_tab)
self.wait_until(lambda s: len(s["layers"].get("Writer", [])) == 1, "the undo to reach the watcher")
firefox.switch_to(host_tab)
# An erasure is one gesture: undone, the stroke is back whole,
# with its id, on every screen.
stroke = one["layers"]["Writer"][0]
page_box = firefox.run("const b = document.getElementById('page').getBoundingClientRect(); return [b.width, b.height];")
firefox.run(ERASE_THROUGH, 350 / page_box[0], 310 / page_box[1])
self.wait_until(lambda s: len(s["layers"]["Writer"]) == 0, "the stroke erased")
self.assertFalse(undo_disabled())
undo()
back = self.wait_until(lambda s: len(s["layers"]["Writer"]) == 1, "the stroke back")
self.assertEqual(back["layers"]["Writer"][0], stroke)
firefox.switch_to(guest_tab)
watcher = self.wait_until(lambda s: len(s["layers"].get("Writer", [])) == 1 and s["layers"]["Writer"][0]["done"],
"the restored stroke to reach the watcher")
self.assertEqual(watcher["layers"]["Writer"][0], stroke)
firefox.switch_to(host_tab)
# Ctrl+Z undoes the stroke itself, the gesture before the erasure;
# then there is nothing left to undo.
firefox.chord("\ue009", "z")
self.wait_until(lambda s: len(s["layers"]["Writer"]) == 0, "the stroke gone by Ctrl+Z")
self.assertTrue(undo_disabled())
# Two quick taps of the eraser on empty paper undo; one tap, or two
# far apart, do nothing; a tap that erases is not half of a double
# tap, so the stroke it erased stays erased.
origin = self.state()["origin"]
vscale = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;")
units = lambda cx, cy: (origin[0] + cx / vscale, origin[1] + cy / vscale)
empty = units(700, 150)
firefox.drag("#page", 200, 400, 500, 420)
self.wait_until(lambda s: len(s["layers"]["Writer"]) == 1 and s["layers"]["Writer"][0]["done"], "a stroke")
firefox.run_until_done(ERASER_TAPS, *empty, 1, 0)
time.sleep(0.6)
firefox.run_until_done(ERASER_TAPS, *empty, 1, 0)
time.sleep(0.2)
self.assertEqual(len(self.state()["layers"]["Writer"]), 1, "taps far apart undo nothing")
firefox.run_until_done(ERASER_TAPS, *empty, 2, 50)
self.wait_until(lambda s: len(s["layers"]["Writer"]) == 0, "the stroke undone by a double tap")
firefox.drag("#page", 200, 400, 500, 420)
self.wait_until(lambda s: len(s["layers"]["Writer"]) == 1 and s["layers"]["Writer"][0]["done"], "a stroke again")
firefox.run_until_done(ERASER_TAPS, *units(350, 410), 1, 0)
self.wait_until(lambda s: len(s["layers"]["Writer"]) == 0, "the stroke erased by a tap")
firefox.run_until_done(ERASER_TAPS, *empty, 1, 0)
time.sleep(0.2)
self.assertEqual(len(self.state()["layers"]["Writer"]), 0, "an erasing tap and an empty one are no double tap")
firefox.run_until_done(ERASER_TAPS, *empty, 2, 50)
self.wait_until(lambda s: len(s["layers"]["Writer"]) == 1, "the erasure undone by a double tap")
# Text: typing a block is one gesture, changing it another; undo
# restores the text as it was before the change, then removes the
# block.
firefox.run("document.querySelector('[data-tool=text]').click();")
firefox.run(TEXT_GESTURE, 0.6, 0.6, 0.6, 0.6)
firefox.type_into(".text_field", "Hallo")
firefox.run("document.querySelector('[data-tool=pen]').click();")
typed = self.wait_until(lambda s: len(s["texts"]["Writer"]) == 1 and s["texts"]["Writer"][0]["text"] == "Hallo",
"the text block")
block = typed["texts"]["Writer"][0]
state = self.state()
fx = (block["x"] + 10 - state["origin"][0]) / state["page"]["width"]
fy = (block["y"] + 10 - state["origin"][1]) / state["page"]["height"]
firefox.run("document.querySelector('[data-tool=text]').click();")
firefox.run(TEXT_GESTURE, fx, fy, fx, fy)
firefox.type_into(".text_field", " Welt")
firefox.run("document.querySelector('[data-tool=pen]').click();")
self.wait_until(lambda s: s["texts"]["Writer"][0]["text"] == "Hallo Welt", "the changed text")
undo()
self.wait_until(lambda s: s["texts"]["Writer"][0]["text"] == "Hallo", "the text as it was")
undo()
self.wait_until(lambda s: len(s["texts"]["Writer"]) == 0, "the block gone")
self.assertEqual(len(self.state()["layers"]["Writer"]), 1)
# A Hide rectangle made, and one removed by the eraser, are undone.
firefox.run("document.querySelector('[data-tool=hide]').click();")
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")
undo()
self.wait_until(lambda s: not s["hides"], "the Hide undone")
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 again")
hide = made["hides"][0]
firefox.run(ERASER_AT, hide[0] + 50, hide[1] + 50, 5)
self.wait_until(lambda s: not s["hides"], "the Hide removed by the eraser")
undo()
self.wait_until(lambda s: s["hides"] == [hide], "the Hide back where it was")
firefox.switch_to(guest_tab)
self.wait_until(lambda s: s["hides"] == [hide], "the restored Hide to reach the watcher")
firefox.switch_to(host_tab)
# A new page starts with nothing to undo.
firefox.run("document.getElementById('new_page').click();")
self.wait_until(lambda s: s["page"]["number"] == 2, "the new page")
self.assertTrue(undo_disabled())
firefox.switch_to(guest_tab)
firefox.in_session("DELETE", "/window")
firefox.switch_to(host_tab)
firefox.go("about:blank")
def test_images_are_moved_and_scaled_by_hand(self):
"""The teacher's requests of 2026-10-08: composition mode, and
images moved, scaled and brought to the front by a hold of the pen."""
firefox = self.firefox
host_tab = firefox.current_tab()
firefox.go(self.address("Late"))
self.enter_name("Mover", "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)
# Every image arrives below the others, centred, at its natural size:
# a wide one widens the sheet equally on both sides.
firefox.run_until_done(LOAD_IMAGE, 300, 200, "png", 0, False)
first = self.wait_until(lambda s: len(s["images"]) == 1 and s["image"]["ready"], "the first image")
self.assertEqual(first["images"][0]["x"], 1335)
firefox.run_until_done(LOAD_IMAGE, 3200, 300, "png", 0, False)
composed = self.wait_until(lambda s: len(s["images"]) == 2 and all(i["ready"] for i in s["images"]),
"the wide image")
rect = lambda state, i: [state["images"][i][k] for k in ("x", "y", "width", "height")]
self.assertEqual(rect(composed, 1), [-115, 300, 3200, 300])
self.assertEqual(composed["page"]["width"], 3300)
# A stroke, which will stay where it is whatever the images do.
firefox.drag("#page", 30, 500, 90, 504)
marked = self.wait_until(lambda s: s["layers"]["Mover"] and s["layers"]["Mover"][-1]["done"], "the stroke")
stroke_points = marked["layers"]["Mover"][-1]["points"]
strokes_before = len(marked["layers"]["Mover"])
# A hold on the wide image, dragged, moves it down out of the way;
# the dot the hold began as is gone.
scale = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;")
pen_hold = lambda fx, fy, dx, dy: firefox.run_until_done(PEN_HOLD, fx, fy, dx, dy, 800)
px = lambda x: (x - composed["origin"][0]) * scale
py = lambda y: (y - composed["origin"][1]) * scale
pen_hold(px(400), py(450), 0, 600 * scale)
lowered = self.wait_until(lambda s: s["images"][1]["y"] == 900, "the wide image moved down")
self.assertEqual(rect(lowered, 1), [-115, 900, 3200, 300])
self.assertEqual(len(lowered["layers"]["Mover"]), strokes_before)
self.assertEqual(lowered["layers"]["Mover"][-1]["points"], stroke_points)
# A hold on the first image, now uncovered but behind, brings it
# to the front and moves it.
target = rect(composed, 0)
pen_hold(px(target[0] + 150), py(target[1] + 100), 400 * scale, 300 * scale)
moved = self.wait_until(lambda s: s["images"][-1]["x"] == target[0] + 400, "the image moved to the front")
self.assertEqual(rect(moved, 1), [target[0] + 400, target[1] + 300, 300, 200])
self.assertEqual(moved["images"][1]["hash"], composed["images"][0]["hash"])
self.assertEqual(moved["images"][0]["hash"], composed["images"][1]["hash"])
self.assertEqual(len(moved["layers"]["Mover"]), strokes_before)
# A hold near a corner scales it along the diagonal, the opposite
# corner fixed, the aspect ratio kept.
corner = [moved["images"][1]["x"] + 300, moved["images"][1]["y"] + 200]
pen_hold(px(corner[0]) - 5, py(corner[1]) - 5, 300 * scale, 0)
scaled = self.wait_until(lambda s: s["images"][1]["width"] > 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)
2026-10-11 17:34:24 +02:00
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)
2026-10-11 17:34:24 +02:00
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_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)
# Widen on a page that writing has grown on one side: the page
# comes out as wide as the window, measured from the page's own
# edges, not the sheet's (Andy, 2026-10-08).
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_widen').click();")
widened = self.wait_until(lambda s: s["page"]["width"] > grown["page"]["width"], "the page to widen")
measured = firefox.run("""
const r = window.kokkoja.renderer;
const desk = document.getElementById('desk');
const style = getComputedStyle(desk);
const room = desk.offsetWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
return {page: document.getElementById('page').getBoundingClientRect().width, room};
""")
self.assertAlmostEqual(measured["page"], measured["room"], 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")
2026-10-06 15:58:02 +02:00
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;"))
2026-10-06 15:58:02 +02:00
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")
2026-10-06 15:58:02 +02:00
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")
2026-10-06 15:58:02 +02:00
# 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('<?xml version="1.0" encoding="UTF-8"?>\n<svg '))
self.assertEqual(self.kept_pages("Teacher notebook"), [])
# The page file reads back as exactly the page that was written.
firefox.switch_to(teacher_tab)
self.assertTrue(firefox.run("""
return (async () => {
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;"))
2026-10-06 15:58:02 +02:00
# Only the host can start a new page.
2026-10-11 17:34:24 +02:00
self.assertTrue(firefox.run("return document.getElementById('new_page').hidden;"))
2026-10-06 15:58:02 +02:00
firefox.switch_to(teacher_tab)
2026-10-11 17:34:24 +02:00
self.assertFalse(firefox.run("return document.getElementById('new_page').hidden;"))
2026-10-06 15:58:02 +02:00
# 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})
2026-10-06 15:58:02 +02:00
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.
2026-10-06 15:58:02 +02:00
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};
2026-10-06 15:58:02 +02:00
""")
before = zoom()
self.assertIsInstance(before["zoom"], float)
for modifier in ("ctrlKey", "metaKey"):
firefox.run("""
document.getElementById('desk').dispatchEvent(new WheelEvent('wheel',
{deltaY: -100, [arguments[0]]: true, clientX: 300, clientY: 300, bubbles: true, cancelable: true}));
""", modifier)
self.assertAlmostEqual(zoom()["width"], before["width"] * 1.21, delta=1)
firefox.run("document.getElementById('zoom_fit').click();")
self.assertEqual(zoom(), before)
# A pen drag of 100 pixels up with Ctrl doubles the scale, 4 pixels
# of it being the dead zone; a tap with Ctrl changes nothing.
firefox.run(CTRL_DRAG, 0.5, 0.6, 0.5, 0.6)
self.assertEqual(zoom(), before)
firefox.run(CTRL_DRAG, 0.5, 0.7, 0.5, 0.7, -104)
doubled = zoom()
self.assertAlmostEqual(doubled["width"], before["width"] * 2, delta=2)
self.assertEqual(doubled["strokes"], before["strokes"])
firefox.run(CTRL_DRAG, 0.5, 0.3, 0.5, 0.3, 104)
self.assertAlmostEqual(zoom()["width"], before["width"], delta=2)
# The host's Fit and A4 reach the guest, who then fits the page to
# the guest's own window; the guest's own Fit stays the guest's.
firefox.switch_to(student_tab)
2026-10-06 15:58:02 +02:00
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)
2026-10-06 15:58:02 +02:00
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)
# Widen makes the sheet itself wider, for everyone, until it fills
# the window at this zoom; 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_widen').click();")
widened = zoom()
# The sheet grew equally on both sides: nothing on it moved on the screen.
self.assertEqual(image_box(), box_before)
self.assertLess(firefox.run("return window.kokkoja.session.page.baseLeft;"), 0)
desk_width = firefox.run("""
const desk = document.getElementById('desk');
const style = getComputedStyle(desk);
return desk.offsetWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
""")
self.assertAlmostEqual(widened["width"], desk_width, delta=1)
self.assertEqual(widened["zoom"], smaller["zoom"])
wide_page = self.state()["page"]
self.assertGreater(wide_page["width"], teacher["page"]["width"])
self.assertEqual(wide_page["height"], teacher["page"]["height"])
firefox.switch_to(student_tab)
self.wait_until(lambda s: s["page"] == wide_page, "the wider page to reach the student")
self.assertTrue(firefox.run("return document.getElementById('zoom_widen').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 left edge and reads back with it.
widened_file = self.current_page("Student notebook", {}, number=3)
self.assertRegex(widened_file, r'data-base="\d+ 2100 -\d+ 0"')
firefox.switch_to(teacher_tab)
# Shrink makes the sheet just large enough for what is on it, with
# the margin around, on the margin's grid; everyone gets it, and
# the file carries the sheet's edges.
firefox.run("document.getElementById('zoom_shrink').click();")
shrunk_page = self.wait_until(lambda s: s["page"]["width"] < wide_page["width"], "the shrunk page")
sheet = firefox.run("""
const p = window.kokkoja.session.page;
return {left: p.baseLeft, top: p.baseTop, width: p.baseWidth, height: p.baseHeight,
pageLeft: p.left, pageTop: p.top, pageWidth: p.width, pageHeight: p.height,
images: p.images.map((i) => [i.x, i.y, i.width, i.height])};
""")
self.assertEqual((sheet["left"], sheet["top"], sheet["width"], sheet["height"]),
(sheet["pageLeft"], sheet["pageTop"], sheet["pageWidth"], sheet["pageHeight"]))
# Exactly the margin on every side of the contents: the image is at
# the top and the strokes lie within it horizontally.
x, y, w, h = sheet["images"][0]
self.assertEqual(sheet["top"], y - 50)
self.assertLessEqual(sheet["left"] + 50, x)
self.assertGreaterEqual(sheet["left"] + sheet["width"] - 50, x + w)
self.assertGreaterEqual(sheet["top"] + sheet["height"] - 50, y + h)
self.assertLess(sheet["height"], 2100)
firefox.switch_to(student_tab)
self.wait_until(lambda s: s["page"] == shrunk_page["page"], "the shrunk page to reach the student")
self.wait_until_true(lambda: self.current_page("Student notebook", {}, number=3),
lambda text: f'data-base="{sheet["width"]} {sheet["height"]} {sheet["left"]} {sheet["top"]}"' in text,
"the shrunk sheet in the student's file")
firefox.switch_to(teacher_tab)
# "A4" shows the shrunk page at the scale a new page has: a blank
# sheet fitted to the window, so the page is smaller than it.
firefox.run("document.getElementById('zoom_fit').click();")
fitted_scale = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;")
firefox.run("document.getElementById('zoom_sheet').click();")
sheet_scale = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;")
self.assertLess(sheet_scale, fitted_scale)
self.assertAlmostEqual(sheet_scale, min(desk_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)
# Widened again, from the shrunk sheet, for the room the rest needs.
firefox.run("document.getElementById('zoom_fit').click();")
firefox.run("document.getElementById('zoom_widen').click();")
self.wait_until(lambda s: s["page"]["width"] > shrunk_page["page"]["width"], "the page widened again")
firefox.run("document.getElementById('zoom_fit').click();")
teacher = self.state()
2026-10-06 15:58:02 +02:00
# 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};
""")
2026-10-11 18:05:49 +02:00
# One second in the button before the tooltip shows (two until
# 2026-10-11, one until 2026-10-06).
2026-10-06 15:58:02 +02:00
firefox.hover("#new_page")
2026-10-11 18:05:49 +02:00
time.sleep(0.5)
2026-10-06 15:58:02 +02:00
self.assertIsNone(tooltip())
2026-10-11 18:05:49 +02:00
time.sleep(0.8)
2026-10-06 15:58:02 +02:00
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)
2026-10-06 15:58:02 +02:00
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())
2026-10-06 15:58:02 +02:00
# 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")
2026-10-06 15:58:02 +02:00
self.assertEqual(block["font"], "sans-serif")
firefox.switch_to(student_tab)
self.wait_until(lambda s: s["texts"].get("Teacher") == [block],
"the text to reach the student")
firefox.switch_to(teacher_tab)
# With the text tool, a size and a font stand where the line widths
# were. Changing them changes the block being typed.
self.assertTrue(firefox.run("return document.getElementById('widths').hidden;"))
self.assertFalse(firefox.run("return document.getElementById('text_style').hidden;"))
self.assertEqual(block["size"], 49.4) # 14 points.
firefox.run("""
const size = document.getElementById('text_size');
size.value = '24';
size.dispatchEvent(new Event('change'));
const font = document.getElementById('text_font');
font.value = 'serif';
font.dispatchEvent(new Event('change'));
""")
restyled = self.wait_until(lambda s: s["texts"]["Teacher"][0]["size"] == 84.7 and
s["texts"]["Teacher"][0]["font"] == "serif", "the restyled text")
self.assertGreater(restyled["texts"]["Teacher"][0]["boxHeight"], block["boxHeight"])
firefox.run("""
const size = document.getElementById('text_size');
size.value = '14';
size.dispatchEvent(new Event('change'));
const font = document.getElementById('text_font');
font.value = 'sans-serif';
font.dispatchEvent(new Event('change'));
""")
block = self.wait_until(lambda s: s["texts"]["Teacher"][0]["size"] == 49.4,
"the text back at 14 points")["texts"]["Teacher"][0]
# A drag opens a text rectangle, shown dotted while dragging: a red
# box, white inside, in which the text wraps and which grows
# downward by lines as the text needs. A hold at its corner
# resizes it freely; a hold inside moves it.
2026-10-06 15:58:02 +02:00
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);
2026-10-06 15:58:02 +02:00
""")
preview = firefox.run("""
const p = document.querySelector('.rect_preview');
const b = p.getBoundingClientRect();
return {hidden: p.hidden, width: b.width, height: b.height};
2026-10-06 15:58:02 +02:00
""")
self.assertFalse(preview["hidden"])
self.assertGreater(preview["width"], 50)
self.assertGreater(preview["height"], 10)
firefox.run(TEXT_GESTURE, 0.1, 0.75, 0.6, 0.78)
self.assertTrue(firefox.run("return document.querySelector('.rect_preview').hidden;"))
self.assertEqual(firefox.run("return getComputedStyle(document.querySelector('.text_field')).outlineStyle;"), "solid")
2026-10-06 15:58:02 +02:00
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)
2026-10-06 15:58:02 +02:00
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"]))
2026-10-06 15:58:02 +02:00
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(
2026-10-06 15:58:02 +02:00
lambda: (self.notebooks / "Teacher notebook" / "Current.svg").read_text(),
lambda saved: "<text " in saved and "zwei Zeilen" in saved and 'data-ground=' in saved,
"the text to be saved")
# The rectangle's white ground lies beneath every layer, its red
# frame within its own.
self.assertLess(saved_text.index('<g data-grounds='), saved_text.index('<g data-layer='))
self.assertIn('fill="none" stroke="#e53935"', saved_text)
# Free text carries its corner mark, a text rectangle its frame.
self.assertEqual(saved_text.count('<path d="M'), 1)
self.assertEqual(saved_text.count('<rect x='), 2) # The sheet, and the frame.
2026-10-06 15:58:02 +02:00
round_trip = 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}));
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"]
2026-10-06 15:58:02 +02:00
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")
2026-10-06 15:58:02 +02:00
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"')
2026-10-06 15:58:02 +02:00
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.
2026-10-06 15:58:02 +02:00
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.
2026-10-06 15:58:02 +02:00
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');
2026-10-06 15:58:02 +02:00
""")
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…")
2026-10-06 15:58:02 +02:00
firefox.run("""
const pick = document.getElementById('session_notebook');
pick.value = 'Teacher notebook';
pick.dispatchEvent(new Event('change'));
2026-10-06 15:58:02 +02:00
""")
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.
firefox.go(self.programs["Teacher"][0])
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("<image ", last)
self.assertEqual(self.state()["layers"], {"Teacher": []})
# A large image is made smaller and stored as JPEG; its natural size
# is still that of the original (0.1 mm a pixel, 36 by 24 cm), and it
# arrives at that size, centred, the sheet grown to hold it.
2026-10-06 15:58:02 +02:00
firefox.run_until_done(LOAD_IMAGE, 3600, 2400, "png", 0, True)
large = self.wait_until(lambda s: s["page"]["number"] == 2, "the large image page")
self.assertEqual(large["page"], {"number": 2, "width": 3700, "height": 2500})
self.assertEqual(large["placed"], {"x": -315, "y": 50, "width": 3600, "height": 2400})
2026-10-06 15:58:02 +02:00
self.assertEqual(large["image"]["type"], "image/jpeg")
self.assertLess(large["image"]["size"], 1500000)
self.assertEqual(firefox.run("""
const shown = document.querySelector('.page_image');
return [shown.naturalWidth, shown.naturalHeight];
"""), [2400, 1600])
# The student joins the new session. The host lets the student
# start pages, the student loads an image, and the host withdraws
# the permission.
invitation = firefox.run("return document.getElementById('invitation').value;")
firefox.switch_to(student_tab)
firefox.go(self.address("Student", join=invitation))
self.enter_name("Student", "join_session")
self.wait_until(lambda s: s["page"]["number"] == 2 and s["image"] and s["image"]["ready"],
"the student to reach the large image page")
2026-10-11 17:34:24 +02:00
self.assertTrue(firefox.run("return document.getElementById('new_page').hidden;"))
2026-10-06 15:58:02 +02:00
firefox.switch_to(teacher_tab)
self.assertEqual(firefox.run(TOGGLE_PERMISSION, "Student"), "true")
firefox.switch_to(student_tab)
self.wait_until(
2026-10-11 17:34:24 +02:00
lambda s: not firefox.run("return document.getElementById('new_page').hidden;"),
2026-10-06 15:58:02 +02:00
"the student to be allowed to start pages")
firefox.run_until_done(LOAD_IMAGE, 300, 400, "png", 0, True)
student = self.wait_until(lambda s: s["page"]["number"] == 3, "the student's image page")
self.assertEqual(student["page"], {"number": 3, "width": 2970, "height": 2100})
2026-10-06 15:58:02 +02:00
firefox.switch_to(teacher_tab)
teacher = self.wait_until(
lambda s: s["page"]["number"] == 3 and s["image"] and s["image"]["ready"],
"the student's image to reach the teacher")
self.assertEqual(teacher["image"], student["image"])
self.assertEqual(firefox.run(TOGGLE_PERMISSION, "Student"), "false")
firefox.switch_to(student_tab)
self.wait_until(
2026-10-11 17:34:24 +02:00
lambda s: firefox.run("return document.getElementById('new_page').hidden;"),
2026-10-06 15:58:02 +02:00
"the student's permission to be withdrawn")
firefox.run("window.kokkoja.session.newPage();")
time.sleep(1)
self.assertEqual(self.state()["page"]["number"], 3)
# Writing past the edge of the page makes the page grow, for
# everyone, in steps of 5 mm.
2026-10-06 15:58:02 +02:00
firefox.run(PEN_WAVE, 0.98, 0.5)
grown = self.wait_until(lambda s: s["page"]["width"] > 2970, "the page to grow")
self.assertEqual((grown["page"]["width"] - 2970) % 50, 0)
2026-10-06 15:58:02 +02:00
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})
2026-10-06 15:58:02 +02:00
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})
2026-10-06 15:58:02 +02:00
# 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"])
2026-10-06 15:58:02 +02:00
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,
2026-10-06 15:58:02 +02:00
"scrollsDown": True, "scrollsAcross": False}
self.assertEqual(firefox.run(VIEW), long_view)
self.assertEqual(stacked["page"], {"number": 5, "width": 2970, "height": 3608})
2026-10-06 15:58:02 +02:00
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]])
2026-10-06 15:58:02 +02:00
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"]])
2026-10-06 15:58:02 +02:00
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}));
2026-10-06 15:58:02 +02:00
document.getElementById('desk').scrollTop = 240;
""")
seen = firefox.run(VIEW)
self.assertEqual(seen["top"], 240)
2026-10-06 15:58:02 +02:00
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"])
2026-10-06 15:58:02 +02:00
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)
2026-10-06 15:58:02 +02:00
# 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);
2026-10-06 15:58:02 +02:00
"""), 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')]
2026-10-06 15:58:02 +02:00
.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})
2026-10-06 15:58:02 +02:00
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();")
2026-10-11 17:34:24 +02:00
# 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)
2026-10-06 15:58:02 +02:00
# 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 ', ''));
""")
2026-10-06 15:58:02 +02:00
firefox.run("document.getElementById('more_colors').click();")
self.assertFalse(firefox.run("return document.getElementById('color_palette').hidden;"))
shown = palette()
self.assertEqual(len(shown), 32)
self.assertEqual([i for i, c in enumerate(shown) if c == ""], [0, 3, 5, 24, 31])
2026-10-06 15:58:02 +02:00
firefox.run("""
[...document.querySelectorAll('#color_palette .swatch')]
.find((s) => s.getAttribute('aria-label') === 'Color #8e24aa').click();
2026-10-06 15:58:02 +02:00
""")
self.assertTrue(firefox.run("return document.getElementById('color_palette').hidden;"))
self.assertEqual(swatches()[-1], "#8e24aa")
2026-10-06 15:58:02 +02:00
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();")
2026-10-06 15:58:02 +02:00
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")
2026-10-06 15:58:02 +02:00
# "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})
2026-10-06 15:58:02 +02:00
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;"),
2026-10-06 15:58:02 +02:00
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))
2026-10-06 15:58:02 +02:00
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]))
2026-10-06 15:58:02 +02:00
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.
2026-10-06 15:58:02 +02:00
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})
2026-10-06 15:58:02 +02:00
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]])
2026-10-06 15:58:02 +02:00
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.
2026-10-06 15:58:02 +02:00
firefox.run(ERASER_AT, 1050, 1500, 5)
firefox.run(PEN_WAVE, 0.3, 0.5)
2026-10-06 15:58:02 +02:00
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.
2026-10-06 15:58:02 +02:00
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.
2026-10-06 15:58:02 +02:00
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)
2026-10-06 15:58:02 +02:00
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.
2026-10-06 15:58:02 +02:00
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)
2026-10-06 15:58:02 +02:00
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.
2026-10-06 15:58:02 +02:00
(scans / "Wide").mkdir()
(scans / "Wide" / "wide.png").write_bytes(png_picture(3200, 300, (60, 120, 200)))
2026-10-06 15:58:02 +02:00
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")
2026-10-06 15:58:02 +02:00
firefox.run("""
[...document.querySelectorAll('.files_directories button')]
.find((b) => b.textContent === 'Wide').click();
""")
chooser(lambda c: c["files"] == ["wide.png"], "the wide image file")
2026-10-06 15:58:02 +02:00
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})
2026-10-06 15:58:02 +02:00
view = firefox.run(VIEW) # The scale is kept here too.
self.assertEqual(view["scale"], sheet_view["scale"])
2026-10-06 15:58:02 +02:00
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.
2026-10-06 15:58:02 +02:00
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})
2026-10-06 15:58:02 +02:00
screenshot = os.environ.get("KOKKOJA_SCREENSHOT")
if screenshot:
firefox.screenshot(screenshot)
if __name__ == "__main__":
unittest.main()