Files
kokkoja-rel/tests/test_end_to_end.py
T
ackandClaude Opus 4.8 51e66db9a1 A press that only brings the window to the front draws nothing
From the development repository at 62e0967.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 19:32:13 +02:00

2633 lines
148 KiB
Python
Raw Blame History

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