Files
kokkoja-rel/tests/test_end_to_end.py
T

3599 lines
213 KiB
Python
Raw Normal View History

2026-10-06 15:58:02 +02:00
"""End-to-end test in Firefox: two participants, one page, through the relay.
Starts a relay and the local program on free ports, then drives a headless
Firefox through geckodriver (the WebDriver protocol, spoken here with the
standard library only). Skipped if geckodriver is not installed.
Run from the project directory:
.venv/bin/python -m unittest tests.test_end_to_end -v
Set KOKKOJA_SCREENSHOT to a file name to save a picture of the page as
the second participant sees it at the end.
"""
import base64
import json
import math
2026-10-06 15:58:02 +02:00
import os
import pathlib
import re
2026-10-06 15:58:02 +02:00
import shutil
import socket
import struct
import subprocess
import sys
import tempfile
import time
import unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import test_qr_code # noqa: E402 The reference encoder's modules.
2026-10-06 15:58:02 +02:00
import urllib.error
import urllib.parse
import urllib.request
import zlib
PROJECT = pathlib.Path(__file__).resolve().parent.parent
# The hover cue (the blue corners over an implied rectangle under the
# pointer): whether it shows, and where, in page units.
HOVER_CUE = """
const c = document.querySelector('.hover_cue');
const r = window.kokkoja.renderer, s = window.kokkoja.session;
const page = document.getElementById('page').getBoundingClientRect();
const b = c.getBoundingClientRect();
const k = s.page.width / page.width;
const st = getComputedStyle(c);
return {hidden: c.hidden, x: s.page.left + (b.left - page.left) * k, y: s.page.top + (b.top - page.top) * k,
width: b.width * k, height: b.height * k, kind: c.className.split(' ')[1],
box: [b.left - page.left, b.top - page.top, b.right - page.left, b.bottom - page.top],
scale: r.cssPixelsPerUnit, origin: [s.page.left, s.page.top],
edges: [st.borderTopWidth, st.borderRightWidth, st.borderBottomWidth, st.borderLeftWidth]};
"""
# Where an overlay's box must lie around a rectangle's edges in pixels
# (CUE_OFFSET_PX in renderer.js): the edges taken out to whole pixels and
# the line this far beyond them.
CUE_OFFSET_PX = 2
def overlay_box(left, top, right, bottom, line):
"""The pixel box an overlay with this line width takes around a
rectangle whose edges are at these pixel positions."""
beyond = CUE_OFFSET_PX - 1 + line
return [math.floor(left) - beyond, math.floor(top) - beyond, math.ceil(right) + beyond, math.ceil(bottom) + beyond]
# Whether anything at all is drawn on the writer's own layer canvas within
# two device pixels of the page point (x, y).
INK_AT = """
const [x, y] = arguments;
const r = window.kokkoja.renderer, s = window.kokkoja.session;
const canvas = r.canvases.get(s.identity.id);
const px = Math.round((x - s.page.left) * r.scale), py = Math.round((y - s.page.top) * r.scale);
const data = canvas.getContext('2d').getImageData(px - 2, py - 2, 5, 5).data;
for (let i = 3; i < data.length; i += 4) if (data[i] > 0) return true;
return false;
"""
# A mouse moving over the page with no button down, to a fraction of the
# page: a hover.
HOVER = """
const [fx, fy] = arguments;
const page = document.getElementById('page');
const box = page.getBoundingClientRect();
page.dispatchEvent(new PointerEvent('pointermove', {pointerId: 9, pointerType: 'mouse', isPrimary: true,
clientX: box.left + fx * box.width, clientY: box.top + fy * box.height, button: -1, buttons: 0,
bubbles: true, cancelable: true}));
"""
# A press on the Shape button held for `wait` ms, released, and the click
# that a browser sends after a press; calls back with whether the menu
# is open.
HOLD_SHAPE_BUTTON = """
const [wait, done] = arguments;
const b = document.getElementById('shape_tool');
const box = b.getBoundingClientRect();
const fire = (type) => b.dispatchEvent(new PointerEvent(type, {pointerId: 5, pointerType: 'mouse', isPrimary: true,
clientX: box.left + 5, clientY: box.top + 5, button: 0, buttons: type === 'pointerup' ? 0 : 1,
bubbles: true, cancelable: true}));
fire('pointerdown');
setTimeout(() => {
fire('pointerup');
b.click();
done(!document.getElementById('shape_menu').hidden);
}, wait);
"""
2026-10-06 15:58:02 +02:00
# Describes the page as one participant holds it: for each layer, by the
# participant's name, the strokes with their points.
PAGE_STATE = """
const session = window.kokkoja && window.kokkoja.session;
if (!session) return null;
const layers = {};
const texts = {};
for (const layer of session.page.layers.values()) {
layers[layer.name] = [...layer.strokes.values()].map(
(stroke) => ({id: stroke.id, done: stroke.done, points: stroke.points, color: stroke.color,
width: stroke.width, filled: stroke.filled, fill: stroke.fill, shape: stroke.shape}));
2026-10-06 15:58:02 +02:00
texts[layer.name] = [...layer.texts.values()];
}
// The first image, and all of them in sequence.
const images = session.page.images;
const image = images[0] ?? null;
const shown = document.querySelectorAll('.page_image');
return {
status: document.getElementById('status').textContent,
page: {number: session.page.number, width: session.page.width, height: session.page.height},
origin: [session.page.left, session.page.top],
2026-10-06 15:58:02 +02:00
image: image && {type: image.type, size: image.size, hash: image.hash,
ready: image.data !== null,
shown: shown.length > 0 && !shown[0].hidden},
placed: image && {x: image.x, y: image.y, width: image.width, height: image.height},
hides: session.page.hides.map((h) => [h.x, h.y, h.width, h.height]),
2026-10-06 15:58:02 +02:00
images: images.map((one, i) => ({
hash: one.hash, x: one.x, y: one.y, width: one.width, height: one.height,
ready: one.data !== null, shown: i < shown.length && !shown[i].hidden})),
participants: session.participantList().map(
(p) => ({name: p.name, host: p.host, present: p.self || p.peer !== null})),
layers,
texts,
};
"""
# A press and release with the text tool, from (fx0, fy0) to (fx1, fy1) as
# fractions of the page.
TEXT_GESTURE = """
const [fx0, fy0, fx1, fy1] = arguments;
const page = document.getElementById('page');
const box = page.getBoundingClientRect();
const capture = page.setPointerCapture;
page.setPointerCapture = () => {};
const fire = (type, x, y, buttons) => page.dispatchEvent(
new PointerEvent(type, {pointerId: 9, pointerType: 'mouse', isPrimary: true,
clientX: box.left + x, clientY: box.top + y, pressure: 0.5,
button: type === 'pointermove' ? -1 : 0, buttons, bubbles: true, cancelable: true}));
fire('pointerdown', fx0 * box.width, fy0 * box.height, 1);
fire('pointermove', fx1 * box.width, fy1 * box.height, 1);
fire('pointerup', fx1 * box.width, fy1 * box.height, 0);
page.setPointerCapture = capture;
"""
# A pen press with Ctrl held at a fraction of the page, moved by dy CSS
# pixels (optional), and lifted: a zoom drag, or a Ctrl-tap.
CTRL_DRAG = """
const [fx0, fy0, fx1, fy1, dy] = arguments;
const page = document.getElementById('page');
const box = page.getBoundingClientRect();
const capture = page.setPointerCapture;
page.setPointerCapture = () => {};
const fire = (type, x, y, buttons) => page.dispatchEvent(
new PointerEvent(type, {pointerId: 7, pointerType: 'pen', isPrimary: true, ctrlKey: true,
clientX: box.left + x, clientY: box.top + y, pressure: 0.5,
button: type === 'pointermove' ? -1 : 0, buttons, bubbles: true, cancelable: true}));
const x = fx0 * box.width, y = fy0 * box.height;
fire('pointerdown', x, y, 1);
if (dy) {
fire('pointermove', x, y + dy / 2, 1);
fire('pointermove', x, y + dy, 1);
}
fire('pointerup', x, y + (dy || 0), 0);
page.setPointerCapture = capture;
"""
# A pen press at (x, y) CSS pixels from the page's top left, held still
# for `wait` ms, then moved by (dx, dy) in two steps and lifted.
PEN_HOLD = """
const [x, y, dx, dy, wait, done] = arguments;
const page = document.getElementById('page');
const box = page.getBoundingClientRect();
const capture = page.setPointerCapture;
page.setPointerCapture = () => {};
const fire = (type, px, py, buttons) => page.dispatchEvent(
new PointerEvent(type, {pointerId: 11, pointerType: 'pen', isPrimary: true,
clientX: box.left + px, clientY: box.top + py, pressure: 0.5,
button: type === 'pointermove' ? -1 : 0, buttons, bubbles: true, cancelable: true}));
fire('pointerdown', x, y, 1);
setTimeout(() => {
fire('pointermove', x + dx / 2, y + dy / 2, 1);
fire('pointermove', x + dx, y + dy, 1);
fire('pointerup', x + dx, y + dy, 0);
page.setPointerCapture = capture;
done(true);
}, wait);
"""
# A pen press on the text field at (x, y) CSS pixels from the page's top
# left, held still for `wait` ms, moved by (dx, dy) and lifted.
FIELD_HOLD = """
const [x, y, dx, dy, wait, done] = arguments;
const page = document.getElementById('page');
const field = document.querySelector('.text_field');
const box = page.getBoundingClientRect();
field.setPointerCapture = () => {};
const fire = (type, px, py, buttons) => field.dispatchEvent(
new PointerEvent(type, {pointerId: 12, pointerType: 'pen', isPrimary: true,
clientX: box.left + px, clientY: box.top + py, pressure: 0.5,
button: type === 'pointermove' ? -1 : 0, buttons, bubbles: true, cancelable: true}));
fire('pointerdown', x, y, 1);
setTimeout(() => {
fire('pointermove', x + dx / 2, y + dy / 2, 1);
fire('pointermove', x + dx, y + dy, 1);
fire('pointerup', x + dx, y + dy, 0);
done(true);
}, wait);
"""
2026-10-06 15:58:02 +02:00
# Drags one of the text editor's handles by (dx, dy) CSS pixels.
DRAG_HANDLE = """
const [selector, dx, dy] = arguments;
const handle = document.querySelector(selector);
const box = handle.getBoundingClientRect();
handle.setPointerCapture = () => {};
const fire = (type, x, y, buttons) => handle.dispatchEvent(
new PointerEvent(type, {pointerId: 11, pointerType: 'mouse', isPrimary: true,
clientX: x, clientY: y, button: type === 'pointermove' ? -1 : 0, buttons,
bubbles: true, cancelable: true}));
const x = box.left + box.width / 2, y = box.top + box.height / 2;
fire('pointerdown', x, y, 1);
fire('pointermove', x + dx, y + dy, 1);
fire('pointerup', x + dx, y + dy, 0);
"""
# Draws with a simulated pen: a wave with changing pressure. Arguments:
# x and y of the start, as fractions of the page width and height.
PEN_WAVE = """
const [fx, fy] = arguments;
const page = document.getElementById('page');
const box = page.getBoundingClientRect();
const capture = page.setPointerCapture;
page.setPointerCapture = () => {};
const fire = (type, x, y, pressure, buttons) => page.dispatchEvent(
new PointerEvent(type, {pointerId: 7, pointerType: 'pen', isPrimary: true,
clientX: box.left + x, clientY: box.top + y, pressure,
button: type === 'pointermove' ? -1 : 0, buttons, bubbles: true, cancelable: true}));
const x0 = fx * box.width;
const y0 = fy * box.height;
fire('pointerdown', x0, y0, 0.1, 1);
for (let i = 1; i <= 60; i++) {
const t = i / 60;
fire('pointermove', x0 + t * 150, y0 + Math.sin(t * Math.PI * 6) * 25,
0.15 + 0.8 * Math.abs(Math.sin(t * Math.PI * 3)), 1);
}
fire('pointerup', x0 + 150, y0, 0, 0);
page.setPointerCapture = capture;
"""
# Clicks the host's "Co-host" box beside the named participant.
2026-10-06 15:58:02 +02:00
TOGGLE_PERMISSION = """
const find = () => [...document.querySelectorAll('#participants li')]
.find((li) => li.textContent.includes(arguments[0])).querySelector('.allow');
find().click();
return String(find().checked); // The list is rebuilt by the click.
2026-10-06 15:58:02 +02:00
"""
# Makes an image of the given size in pixels and puts it on the page, as
# the "Image" button does with a file. Arguments: width, height, kind
# ('png' or 'jpeg'), a resolution in dots per inch to record in the file,
# or 0 for none, and whether to press "New" first, for a page of its own.
LOAD_IMAGE = """
const [width, height, kind, dpi, fresh, done] = arguments;
if (fresh) document.getElementById('new_page').click();
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d');
context.fillStyle = '#f4f0e0';
context.fillRect(0, 0, width, height);
context.fillStyle = '#203040';
for (let y = 40; y < height; y += 60) context.fillRect(30, y, width - 60, 3);
const crc32 = (bytes) => {
let crc = -1;
for (const byte of bytes) {
crc ^= byte;
for (let k = 0; k < 8; k++) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1));
}
return (crc ^ -1) >>> 0;
};
const withResolution = async (blob) => {
if (!dpi) return blob;
const bytes = new Uint8Array(await blob.arrayBuffer());
if (kind === 'png') {
// A pHYs chunk after IHDR: pixels per metre, both ways, unit 1.
const perMetre = Math.round(dpi / 0.0254);
const chunk = new Uint8Array(4 + 4 + 9 + 4);
const view = new DataView(chunk.buffer);
view.setUint32(0, 9);
chunk.set([0x70, 0x48, 0x59, 0x73], 4);
view.setUint32(8, perMetre);
view.setUint32(12, perMetre);
chunk[16] = 1;
view.setUint32(17, crc32(chunk.subarray(4, 17)));
const ihdrEnd = 8 + 4 + 4 + 13 + 4;
const out = new Uint8Array(bytes.length + chunk.length);
out.set(bytes.subarray(0, ihdrEnd));
out.set(chunk, ihdrEnd);
out.set(bytes.subarray(ihdrEnd), ihdrEnd + chunk.length);
return new Blob([out], {type: 'image/png'});
}
// The JFIF header: units 1 (inches) and the density, at fixed offsets.
const view = new DataView(bytes.buffer);
if (view.getUint16(2) !== 0xffe0) throw new Error('no JFIF header to change');
bytes[13] = 1;
view.setUint16(14, dpi);
view.setUint16(16, dpi);
return new Blob([bytes], {type: 'image/jpeg'});
};
canvas.toBlob((blob) => withResolution(blob)
.then((file) => window.kokkoja.loadImage(file).then(() => done(file.size))),
kind === 'png' ? 'image/png' : 'image/jpeg', 0.92);
"""
# Puts the eraser down at (x, y), in page units, moves it dx to the right
# and lifts it.
ERASER_AT = """
const [x, y, dx] = arguments;
const page = document.getElementById('page');
const box = page.getBoundingClientRect();
const extent = window.kokkoja.session.page;
const cx = (px) => box.left + (px - extent.left) / extent.width * box.width;
const cy = (py) => box.top + (py - extent.top) / extent.height * box.height;
const capture = page.setPointerCapture;
page.setPointerCapture = () => {};
const fire = (type, px, buttons) => page.dispatchEvent(
new PointerEvent(type, {pointerId: 7, pointerType: 'pen', isPrimary: true,
clientX: cx(px), clientY: cy(y), pressure: 0.5,
button: type === 'pointermove' ? -1 : 0, buttons, bubbles: true, cancelable: true}));
document.querySelector('[data-tool=eraser]').click();
fire('pointerdown', x, 1);
fire('pointermove', x + dx, 1);
fire('pointerup', x + dx, 0);
document.querySelector('[data-tool=pen]').click();
page.setPointerCapture = capture;
"""
# Describes how the page is shown: whether fitted to the window, at what
# scale, and whether it extends beyond the window.
VIEW = """
const desk = document.getElementById('desk');
return {
scale: Math.round(window.kokkoja.renderer.cssPixelsPerUnit * 1e6) / 1e6,
top: Math.round(desk.scrollTop),
scrollsDown: desk.scrollHeight > desk.clientHeight + 1,
scrollsAcross: desk.scrollWidth > desk.clientWidth + 1,
};
"""
# Describes the file chooser as it stands.
FILE_CHOOSER = """
const texts = (selector) => [...document.querySelectorAll(selector)].map((e) => e.textContent);
return {
open: document.getElementById('file_chooser').open,
title: document.getElementById('files_title').textContent,
directory: document.getElementById('files_directory').value,
directories: texts('.files_directories button'),
files: texts('.file_card span'),
drawn: [...document.querySelectorAll('.file_card canvas')].map((c) => [c.width, c.height]),
existing: texts('.files_existing button'),
saving: !document.getElementById('files_save').hidden,
name: document.getElementById('files_name').value,
button: document.getElementById('files_save_here').textContent,
message: document.getElementById('files_message').textContent,
};
"""
# Reads an exported picture back and returns the colours at three places:
# a corner of the page, the middle of its first image, and the middle of
# the teacher's latest stroke. Argument: the picture's full path.
PICTURE_COLOURS = """
const [path, done] = arguments;
(async () => {
const {readFile} = await import('./js/files.js');
const bitmap = await createImageBitmap(await readFile(path));
const canvas = document.createElement('canvas');
canvas.width = bitmap.width;
canvas.height = bitmap.height;
const context = canvas.getContext('2d');
context.drawImage(bitmap, 0, 0);
const at = (x, y) => [...context.getImageData(
Math.round(x * 200 / 254), Math.round(y * 200 / 254), 1, 1).data].slice(0, 3);
const session = window.kokkoja.session;
const image = session.page.images[0];
const points = [...session.ownLayer.strokes.values()].at(-1).points;
const middle = Math.floor(points.length / 6) * 3;
return {size: [bitmap.width, bitmap.height], corner: at(20, 20),
image: at(image.x + image.width / 2, image.y + image.height / 2),
stroke: at(points[middle], points[middle + 1])};
})().then(done);
"""
def png_picture(width, height, colour):
"""A PNG file of one colour."""
def chunk(kind, data):
return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data))
row = b"\x00" + bytes(colour) * width
return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0))
+ chunk(b"IDAT", zlib.compress(row * height)) + chunk(b"IEND", b""))
# Moves the eraser down through the point (fx, fy) of the page.
ERASE_THROUGH = """
const [fx, fy] = arguments;
const page = document.getElementById('page');
const box = page.getBoundingClientRect();
const capture = page.setPointerCapture;
page.setPointerCapture = () => {};
const fire = (type, x, y, buttons) => page.dispatchEvent(
new PointerEvent(type, {pointerId: 7, pointerType: 'pen', isPrimary: true,
clientX: box.left + x, clientY: box.top + y, pressure: 0.5,
button: type === 'pointermove' ? -1 : 0, buttons, bubbles: true, cancelable: true}));
document.querySelector('[data-tool=eraser]').click();
const x = fx * box.width;
const y = fy * box.height;
fire('pointerdown', x, y - 40, 1);
for (let i = 0; i <= 8; i++) fire('pointermove', x, y - 40 + i * 10, 1);
fire('pointerup', x, y + 40, 0);
document.querySelector('[data-tool=pen]').click();
page.setPointerCapture = capture;
"""
2026-10-11 13:17:22 +02:00
# Taps the eraser `count` times at (x, y) in page units, `gap` ms apart,
# and calls the last argument when done.
ERASER_TAPS = """
const [x, y, count, gap, done] = arguments;
const page = document.getElementById('page');
const box = page.getBoundingClientRect();
const extent = window.kokkoja.session.page;
const cx = box.left + (x - extent.left) / extent.width * box.width;
const cy = box.top + (y - extent.top) / extent.height * box.height;
const capture = page.setPointerCapture;
page.setPointerCapture = () => {};
const fire = (type, buttons) => page.dispatchEvent(
new PointerEvent(type, {pointerId: 7, pointerType: 'pen', isPrimary: true,
clientX: cx, clientY: cy, pressure: 0.5,
button: type === 'pointermove' ? -1 : 0, buttons, bubbles: true, cancelable: true}));
document.querySelector('[data-tool=eraser]').click();
let left = count;
const tap = () => {
fire('pointerdown', 1);
fire('pointerup', 0);
if (--left > 0) {
setTimeout(tap, gap);
} else {
document.querySelector('[data-tool=pen]').click();
page.setPointerCapture = capture;
done();
}
};
tap();
"""
2026-10-06 15:58:02 +02:00
def free_port():
with socket.socket() as listener:
listener.bind(("127.0.0.1", 0))
return listener.getsockname()[1]
def wait_for_port(port, seconds=15):
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
with socket.socket() as probe:
if probe.connect_ex(("127.0.0.1", port)) == 0:
return
time.sleep(0.1)
raise TimeoutError(f"nothing is listening on port {port}")
class Firefox:
"""A small WebDriver client."""
def __init__(self, port, downloads):
self.base = f"http://127.0.0.1:{port}"
capabilities = {"alwaysMatch": {
"browserName": "firefox",
"moz:firefoxOptions": {
"args": ["-headless", "--width=2000", "--height=1000"],
2026-10-06 15:58:02 +02:00
# Downloads go to the given directory without asking.
"prefs": {"browser.download.folderList": 2, "browser.download.dir": str(downloads),
"browser.download.useDownloadDir": True,
"browser.download.always_ask_before_handling_new_types": False},
},
}}
self.session = self.command("POST", "/session", {"capabilities": capabilities})["sessionId"]
def command(self, method, path, body=None):
data = None if body is None else json.dumps(body).encode()
request = urllib.request.Request(self.base + path, data=data, method=method,
headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(request, timeout=60) as response:
return json.load(response)["value"]
except urllib.error.HTTPError as error:
raise RuntimeError(error.read().decode()) from None
def in_session(self, method, path, body=None):
return self.command(method, f"/session/{self.session}{path}", body)
def go(self, address):
self.in_session("POST", "/url", {"url": address})
def run(self, script, *arguments):
return self.in_session("POST", "/execute/sync", {"script": script, "args": list(arguments)})
def run_until_done(self, script, *arguments):
"""Run a script that calls its last argument when it has finished."""
return self.in_session("POST", "/execute/async", {"script": script, "args": list(arguments)})
def new_tab(self):
handle = self.in_session("POST", "/window/new", {"type": "tab"})["handle"]
self.switch_to(handle)
return handle
def current_tab(self):
return self.in_session("GET", "/window")
def switch_to(self, handle):
self.in_session("POST", "/window", {"handle": handle})
def drag(self, selector, from_x, from_y, to_x, to_y):
"""Drag the mouse over an element; positions are from its top left."""
box = self.run(f"const b = document.querySelector({json.dumps(selector)})"
".getBoundingClientRect(); return [b.left, b.top];")
moves = [{"type": "pointerMove", "duration": 0, "origin": "viewport",
"x": round(box[0] + from_x), "y": round(box[1] + from_y)},
{"type": "pointerDown", "button": 0}]
for step in range(1, 11):
moves.append({"type": "pointerMove", "duration": 20, "origin": "viewport",
"x": round(box[0] + from_x + (to_x - from_x) * step / 10),
"y": round(box[1] + from_y + (to_y - from_y) * step / 10)})
moves.append({"type": "pointerUp", "button": 0})
self.in_session("POST", "/actions", {"actions": [{
"type": "pointer", "id": "mouse", "parameters": {"pointerType": "mouse"},
"actions": moves}]})
def pointer_actions(self, actions):
"""Perform mouse actions (WebDriver's pointer actions) in sequence."""
self.in_session("POST", "/actions", {"actions": [{
"type": "pointer", "id": "mouse", "parameters": {"pointerType": "mouse"},
"actions": actions}]})
2026-10-06 15:58:02 +02:00
def type_into(self, selector, text):
"""Type text into an element, as the keyboard would."""
found = self.in_session("POST", "/element", {"using": "css selector", "value": selector})
element_id = next(iter(found.values()))
self.in_session("POST", f"/element/{element_id}/value", {"text": text})
def hover(self, selector):
"""Rest the mouse on the middle of an element."""
box = self.run(f"const b = document.querySelector({json.dumps(selector)})"
".getBoundingClientRect(); return [b.left + b.width / 2, b.top + b.height / 2];")
self.in_session("POST", "/actions", {"actions": [{
"type": "pointer", "id": "mouse", "parameters": {"pointerType": "mouse"},
"actions": [{"type": "pointerMove", "duration": 0, "origin": "viewport",
"x": round(box[0]), "y": round(box[1])}]}]})
2026-10-11 13:17:22 +02:00
def chord(self, *keys):
"""Press keys together and release them, as a keyboard shortcut."""
actions = ([{"type": "keyDown", "value": key} for key in keys] +
[{"type": "keyUp", "value": key} for key in reversed(keys)])
self.in_session("POST", "/actions", {"actions": [{"type": "key", "id": "keyboard", "actions": actions}]})
def set_window(self, width, height):
self.in_session("POST", "/window/rect", {"width": width, "height": height})
2026-10-06 15:58:02 +02:00
def screenshot(self, file_name):
pathlib.Path(file_name).write_bytes(base64.b64decode(self.in_session("GET", "/screenshot")))
def quit(self):
self.in_session("DELETE", "")
@unittest.skipUnless(shutil.which("geckodriver"), "geckodriver is not installed")
class EndToEndTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.processes = []
cls.directory = tempfile.TemporaryDirectory()
cls.notebooks = pathlib.Path(cls.directory.name) / "notebooks"
# Deleted notebooks and pages go here, not to the real Trash.
cls.trash = pathlib.Path(cls.directory.name) / "trash"
2026-10-06 15:58:02 +02:00
relay_port, driver_port = free_port(), free_port()
cls.start([sys.executable, str(PROJECT / "relay" / "kokkoja_relay.py"),
"--port", str(relay_port)])
# Each participant has their own local program, as on their own
# computer: it keeps their identity. All share one notebooks
# directory here, so that the test can read every notebook.
# A Kokkoja web site, as a server would host one: the client's files
# and a config.json that says the notebooks are kept in the browser.
# Its participants have nothing installed.
site_port = free_port()
site = pathlib.Path(cls.directory.name) / "site"
shutil.copytree(PROJECT / "client", site)
cls.site = f"http://127.0.0.1:{site_port}/"
(site / "config.json").write_text(json.dumps(
{"relay": f"ws://127.0.0.1:{relay_port}", "storage": "browser", "site": cls.site}))
cls.start([sys.executable, "-m", "http.server", str(site_port), "--bind", "127.0.0.1",
"--directory", str(site)])
# Where the browser's downloads go. Not in the temporary directory:
# a Firefox installed as a snap has a /tmp of its own, and a download
# saved there is invisible to this test. A directory under the home
# directory is reachable by both.
cls.downloads = pathlib.Path(tempfile.mkdtemp(prefix="downloads_", dir=PROJECT / "tests"))
cls.programs = {}
for name, host in (("Teacher", "localhost"), ("Student", "127.0.0.1"), ("Late", "localhost"),
("Host", "localhost")):
port = free_port()
config = pathlib.Path(cls.directory.name) / f"config_{name}"
# The "Host" program knows the web site, so its invitations are links.
site_option = ["--site", cls.site] if name == "Host" else []
cls.start([sys.executable, str(PROJECT / "kokkoja.py"), "--no-browser",
"--port", str(port), "--relay", f"ws://127.0.0.1:{relay_port}",
"--notebooks", str(cls.notebooks), "--config", str(config),
"--trash", str(cls.trash), *site_option])
2026-10-06 15:58:02 +02:00
cls.programs[name] = (f"http://{host}:{port}/", config)
cls.start(["geckodriver", "--port", str(driver_port)])
try:
for port in (relay_port, driver_port, site_port):
wait_for_port(port)
for address, config in cls.programs.values():
wait_for_port(int(address.rsplit(":", 1)[1].rstrip("/")))
cls.programs[[k for k, v in cls.programs.items() if v[0] == address][0]] = (
address, (config / "token").read_text().strip())
cls.firefox = Firefox(driver_port, cls.downloads)
except Exception:
cls.stop()
raise
def address(self, name, join=None, focus_settle=False):
2026-10-06 15:58:02 +02:00
"""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)."""
2026-10-06 15:58:02 +02:00
base, token = self.programs[name]
query = "" if focus_settle else "?no_focus_settle"
2026-10-06 15:58:02 +02:00
joining = "" if join is None else f"&join={urllib.parse.quote(join, safe='')}"
return f"{base}{query}#token={token}{joining}"
2026-10-06 15:58:02 +02:00
@classmethod
def start(cls, command):
cls.processes.append(subprocess.Popen(
command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL))
@classmethod
def stop(cls):
for process in reversed(cls.processes):
process.terminate()
for process in cls.processes:
process.wait(timeout=10)
@classmethod
def tearDownClass(cls):
try:
cls.firefox.quit()
finally:
cls.stop()
cls.directory.cleanup()
shutil.rmtree(cls.downloads, ignore_errors=True)
def state(self):
return self.firefox.run(PAGE_STATE)
def assert_hover_cue(self, x, y, rect, kind="hide"):
"""A pointer hovering at the page point (x, y) shows the blue cue
over the rectangle (page units), and none once it leaves: a text
rectangle or a Hide outlined, free text's top and left edges
(Andy, 2026-10-14)."""
state = self.state()
fx = (x - state["origin"][0]) / state["page"]["width"]
fy = (y - state["origin"][1]) / state["page"]["height"]
self.firefox.run(HOVER, fx, fy)
cue = self.firefox.run(HOVER_CUE)
self.assertFalse(cue["hidden"], f"no cue at {x}, {y}")
self.assertEqual(cue["kind"], kind)
line = cue["edges"][0]
self.assertNotEqual(line, "0px")
# The cue's box lies CUE_OFFSET_PX outside the rectangle.
px = lambda v: (v - cue["origin"][0]) * cue["scale"]
py = lambda v: (v - cue["origin"][1]) * cue["scale"]
expected = overlay_box(px(rect[0]), py(rect[1]), px(rect[0] + rect[2]), py(rect[1] + rect[3]), float(line[:-2]))
for side, got, want in zip(("left", "top", "right", "bottom"), cue["box"], expected):
self.assertAlmostEqual(got, want, delta=0.01, msg=side)
if kind == "free":
self.assertEqual(cue["edges"], [line, "0px", "0px", line], "top and left edges only")
else:
self.assertEqual(cue["edges"], [line] * 4, "outlined")
self.firefox.run(HOVER, 0.01, 0.01)
self.assertTrue(self.firefox.run(HOVER_CUE)["hidden"])
2026-10-06 15:58:02 +02:00
def wait_until_true(self, read, condition, what, seconds=15):
"""Wait until condition(read()) is true and return the value read."""
deadline = time.monotonic() + seconds
value = None
while time.monotonic() < deadline:
value = read()
if condition(value):
return value
time.sleep(0.1)
self.fail(f"timed out waiting for {what}; last value: {value}")
def wait_until(self, condition, what, seconds=15):
"""Wait until condition(state) is true and return the state."""
deadline = time.monotonic() + seconds
state = None
while time.monotonic() < deadline:
state = self.state()
if state and condition(state):
return state
time.sleep(0.1)
self.fail(f"timed out waiting for {what}; last state: {json.dumps(state)[:600]}")
def enter_name(self, name, button):
# The start screen appears once the client has read config.json.
self.wait_until_true(lambda: self.firefox.run("return !document.getElementById('start').hidden;"),
bool, "the start screen")
# The notebook is made through the picker: "New notebook…", then its name.
self.firefox.run("""
document.getElementById('name').value = arguments[0];
const pick = document.getElementById('notebook');
pick.value = '/new';
pick.dispatchEvent(new Event('change'));
const entry = document.getElementById('notebook_name');
entry.value = arguments[0] + ' notebook';
entry.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', bubbles: true}));
if (pick.value !== arguments[0] + ' notebook') throw new Error('notebook not chosen: ' + pick.value);
document.getElementById(arguments[1]).click();
""", name, button)
2026-10-06 15:58:02 +02:00
return self.wait_until(lambda s: s["status"] == "Connected", f"{name} to connect")
@staticmethod
def stroke_counts(text):
"""For each layer of a page file that has strokes, by name, their number."""
counts = {layer.split('data-name="')[1].split('"')[0]: layer.count("<g data-stroke=")
for layer in text.split("<g data-layer=")[1:]}
return {name: count for name, count in counts.items() if count}
def current_page(self, notebook, stroke_counts, number=None, images=None, seconds=10):
"""Wait until the notebook's current page holds the given number of
strokes in each layer (and of images, if given), and return its text."""
deadline = time.monotonic() + seconds
found = None
while time.monotonic() < deadline:
try:
text = (self.notebooks / notebook / "Current.svg").read_text()
except FileNotFoundError:
text = None
if text is not None:
found = self.stroke_counts(text)
if (found == stroke_counts and (number is None or f'data-number="{number}"' in text)
and (images is None or text.count("<image ") == images)):
return text
time.sleep(0.1)
self.fail(f"the current page of {notebook} has {found}, expected {stroke_counts}")
def kept_pages(self, notebook):
return sorted(page.name for page in (self.notebooks / notebook).glob("2*.svg"))
def wait_for_kept(self, notebook, count, seconds=10):
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
if len(self.kept_pages(notebook)) == count:
return self.kept_pages(notebook)
time.sleep(0.1)
self.fail(f"{notebook} keeps {self.kept_pages(notebook)}, expected {count} pages")
def wait_for_download(self, name, seconds=15):
"""Wait for a finished download of the given name and return its bytes."""
path = self.downloads / name
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
if path.exists() and not (self.downloads / f"{name}.part").exists():
return path.read_bytes()
time.sleep(0.1)
self.fail(f"no download {name}; downloads: {sorted(p.name for p in self.downloads.iterdir())}")
def test_browser_guest(self):
"""A guest with nothing installed: the host's invitation is a link to
the Kokkoja web site, and the guest's notebook is in the browser."""
firefox = self.firefox
today = time.strftime("%Y_%m_%d")
# The host's program knows the web site, so the invitation it shows
# is a link there that carries the invitation itself.
host_tab = firefox.current_tab()
firefox.go(self.address("Host"))
self.enter_name("Host", "start_session")
link = firefox.run("return document.getElementById('invitation').value;")
self.assertRegex(link, rf"^{self.site}#join=ws://127\.0\.0\.1:\d+%23[\w-]{{22}}\.[\w-]{{22}}$")
firefox.drag("#page", 100, 100, 400, 180)
host = self.wait_until(lambda s: len(s["layers"]["Host"]) == 1 and s["layers"]["Host"][0]["done"],
"the host's stroke")
# The guest opens the link. The start screen has the invitation
# filled in, says where the notebooks are, and offers the installed
# program to anyone who has it.
guest_tab = firefox.new_tab()
firefox.go(link.replace("#", "?no_focus_settle#", 1))
2026-10-06 15:58:02 +02:00
self.wait_until_true(lambda: firefox.run("return document.getElementById('invitation_entry').value;"),
lambda v: v and v.startswith("ws://"), "the invitation in the start screen")
self.assertEqual(firefox.run("return document.getElementById('invitation_entry').value;"),
urllib.parse.unquote(link.split("#join=")[1]))
self.assertIn("kept in this browser", firefox.run(
"return document.getElementById('notebook_note').textContent;"))
# Arriving with an invitation, the start screen offers joining alone.
self.assertTrue(firefox.run("return document.getElementById('start_choice').hidden;"))
self.assertFalse(firefox.run("return document.getElementById('join_choice').hidden;"))
2026-10-06 15:58:02 +02:00
installed = firefox.run("const a = document.getElementById('open_installed');"
"return a.hidden ? null : a.href;")
self.assertTrue(installed.startswith("http://localhost:8760/#join=ws"), installed)
# The guest joins, receives the host's stroke and writes one.
self.enter_name("Guest", "join_session")
guest = self.wait_until(lambda s: len(s["layers"].get("Host", [])) == 1,
"the guest to receive the host's layer")
self.assertEqual(guest["layers"]["Host"], host["layers"]["Host"])
firefox.run(PEN_WAVE, 0.3, 0.5)
guest = self.state()
self.assertEqual(len(guest["layers"]["Guest"]), 1)
firefox.switch_to(host_tab)
host = self.wait_until(
lambda s: len(s["layers"].get("Guest", [])) == 1 and s["layers"]["Guest"][0]["done"],
"the host to receive the guest's stroke")
self.assertEqual(host["layers"]["Guest"], guest["layers"]["Guest"])
self.current_page("Host notebook", {"Host": 1, "Guest": 1}, number=1)
# Too narrow for one row, the toolbar centres its rows instead of
# holding the two ends; wide again, it holds them.
firefox.switch_to(host_tab)
bar = lambda: firefox.run("""
const bar = document.querySelector('.toolbar');
const first = bar.firstElementChild.getBoundingClientRect();
const last = bar.lastElementChild.getBoundingClientRect();
return {wrapped: bar.classList.contains('wrapped'), rows: bar.offsetHeight < first.height * 1.9 ? 1 : 2,
firstLeft: first.left, lastRight: last.right, width: bar.getBoundingClientRect().width};
""")
wide = bar()
self.assertEqual((wide["wrapped"], wide["rows"]), (False, 1))
firefox.set_window(1100, 1000)
narrow = self.wait_until_true(bar, lambda b: b["wrapped"], "the toolbar to wrap and centre")
self.assertGreater(narrow["rows"], 1)
self.assertGreater(narrow["firstLeft"], 40) # Centred: not at the left edge.
firefox.set_window(2000, 1000)
self.wait_until_true(bar, lambda b: not b["wrapped"], "the toolbar back in one row")
# "Session" opens the dialog with the participants and the invitation.
2026-10-06 15:58:02 +02:00
firefox.switch_to(guest_tab)
firefox.run("document.getElementById('session_button').click();")
self.assertEqual(firefox.run("""
const dialog = document.getElementById('session_dialog');
return {open: dialog.open,
names: [...document.querySelectorAll('#participants li label')].map((l) => l.textContent.trim()),
invitation: document.getElementById('invitation').value};
"""), {"open": True, "names": ["Guest", "Host"], "invitation": link})
# Below the link stands the same invitation as a QR code: the
# modules of the reference encoder, inside the quiet zone.
drawn = firefox.run("""
const svg = document.querySelector('#invitation_code svg');
return {hidden: document.getElementById('invitation_code').hidden,
viewBox: svg.getAttribute('viewBox'), d: svg.firstElementChild.getAttribute('d')};
""")
_, _, matrix = test_qr_code.reference(link.encode(), "M")
size = len(matrix)
self.assertEqual((drawn["hidden"], drawn["viewBox"]), (False, f"-4 -4 {size + 8} {size + 8}"))
self.assertEqual({(int(x), int(y)) for x, y in re.findall(r"M(\d+) (\d+)h1v1h-1z", drawn["d"])},
{(x, y) for y, row in enumerate(matrix) for x, dark in enumerate(row) if dark})
# The log in the window records the stroke drawn above, as a press
# and a release of the pen, after the browser's name.
shown = firefox.run("return document.getElementById('event_log').textContent;")
self.assertRegex(shown, r"^\d\d:\d\d:\d\d\.\d\d\d Browser: Mozilla")
self.assertRegex(shown, r"\n\S+ press pen \d+ buttons 1 pressure \d\.\d\d \((?: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.
2026-10-06 15:58:02 +02:00
self.wait_until_true(lambda: firefox.run("return document.getElementById('save_status').textContent;"),
lambda v: v.startswith("Saved at"), "the guest's page to be saved")
self.assertFalse((self.notebooks / "Guest notebook").exists())
in_browser = firefox.run_until_done("""
const done = arguments[0];
const m = window.kokkoja.notebook;
(async () => {
const {notebooks, directory} = await m.listNotebooks();
done({notebooks, directory, pages: await m.listKeptPages('Guest notebook'),
current: await m.readCurrentPage('Guest notebook')});
})().catch((e) => done({error: String(e)}));
""")
self.assertEqual(in_browser["notebooks"], ["Guest notebook"])
self.assertEqual(in_browser["directory"], "")
self.assertEqual(in_browser["pages"], [])
# The page is written a moment after the latest change.
self.wait_until_true(lambda: self.stroke_counts(firefox.run_until_done("""
const done = arguments[0];
window.kokkoja.notebook.readCurrentPage('Guest notebook').then(done);
""") or ""), lambda counts: counts == {"Host": 1, "Guest": 1}, "both strokes in the browser's notebook")
2026-10-06 15:58:02 +02:00
# Keeping the page keeps it in the browser and downloads it.
firefox.run("document.getElementById('keep_page').click();")
kept = self.wait_for_download(f"{today}_01.svg").decode()
self.assertEqual(self.stroke_counts(kept), {"Host": 1, "Guest": 1})
self.assertIn("Kept as", firefox.run("return document.getElementById('notice').textContent;"))
2026-10-06 15:58:02 +02:00
self.assertEqual(firefox.run_until_done("""
const done = arguments[0];
window.kokkoja.notebook.listKeptPages('Guest notebook').then(done);
"""), [f"{today}_01.svg"])
# A second press keeps nothing new and downloads nothing more.
firefox.run("document.getElementById('keep_page').click();")
self.wait_until_true(lambda: firefox.run("return document.getElementById('notice').textContent;"),
lambda v: v.startswith("Already kept"), "the second keep to be reported")
time.sleep(1)
self.assertEqual(sorted(p.name for p in self.downloads.iterdir()), [f"{today}_01.svg"])
# The browser has no trash: a deleted page is gone, and the
# notebook with it when it is deleted whole.
self.assertEqual(firefox.run_until_done("""
const [name, done] = arguments;
(async () => {
const m = window.kokkoja.notebook;
const listed = await m.listNotebooks();
await m.discardPage('Guest notebook', name);
const after = await m.listKeptPages('Guest notebook');
await m.discardNotebook('Guest notebook');
done({trash: listed.trash, after,
notebooks: (await m.listNotebooks()).notebooks,
current: await m.readCurrentPage('Guest notebook')});
})().catch((e) => done({error: String(e)}));
""", f"{today}_01.svg"), {"trash": False, "after": [], "notebooks": [], "current": None})
2026-10-06 15:58:02 +02:00
# Exporting downloads a picture.
firefox.run("document.getElementById('export_page').click();")
picture = self.wait_for_download(f"{today}_page_1.png")
self.assertTrue(picture.startswith(b"\x89PNG"))
self.assertIn("downloads", firefox.run("return document.getElementById('notice').textContent;"))
2026-10-06 15:58:02 +02:00
# Allowed by the host, the guest puts an image on the page (the
# file would come from the browser's own chooser).
2026-10-11 17:34:24 +02:00
self.assertTrue(firefox.run("return document.getElementById('new_page').hidden;"))
2026-10-06 15:58:02 +02:00
firefox.switch_to(host_tab)
self.assertEqual(firefox.run(TOGGLE_PERMISSION, "Guest"), "true")
firefox.switch_to(guest_tab)
2026-10-11 17:34:24 +02:00
self.wait_until(lambda s: not firefox.run("return document.getElementById('new_page').hidden;"),
2026-10-06 15:58:02 +02:00
"the guest to be allowed to start pages")
firefox.run_until_done(LOAD_IMAGE, 300, 400, "png", 0, False)
guest = self.wait_until(lambda s: s["image"] and s["image"]["ready"], "the guest's image")
firefox.switch_to(host_tab)
self.wait_until(lambda s: s["image"] and s["image"]["ready"] and s["image"]["hash"] == guest["image"]["hash"],
"the host to receive the image")
# Reloaded, the guest's tab comes back as the same participant with
# the page from the browser's notebook.
firefox.switch_to(guest_tab)
firefox.run("location.reload();")
time.sleep(0.5)
reloaded = self.wait_until(
lambda s: len(s["layers"].get("Guest", [])) == 1 and len(s["layers"].get("Host", [])) == 1
and s["image"] and s["image"]["ready"],
"the guest's page after a reload")
self.assertEqual(reloaded["layers"]["Guest"], guest["layers"]["Guest"])
self.assertEqual(len(reloaded["participants"]), 2)
# The host leaves the session; the tabs are closed for the next test.
firefox.in_session("DELETE", "/window")
firefox.switch_to(host_tab)
firefox.go("about:blank")
def test_tooltips(self):
"""A button's explanation appears after the pointer has been in it
for a second; once one is showing, a neighbour's appears at once;
a press ends that."""
firefox = self.firefox
firefox.go(self.address("Late"))
self.enter_name("Reader", "start_session")
self.wait_until(lambda s: s["page"]["number"] == 1, "the page")
hover = lambda tool: firefox.run("""
document.querySelector('[data-tool=' + arguments[0] + ']')
.dispatchEvent(new PointerEvent('pointerover', {bubbles: true, pointerType: 'pen'}));
""", tool)
showing = lambda: firefox.run("""
const t = document.querySelector('.tooltip');
return t && !t.hidden ? t.textContent.slice(0, 20) : null;
""")
hover("pen")
time.sleep(0.5)
self.assertIsNone(showing())
time.sleep(0.8)
self.assertEqual(showing(), "Write on the page. O")
# The neighbour rule: the next tooltip comes without the wait.
firefox.run("document.body.dispatchEvent(new PointerEvent('pointerover', {bubbles: true}));")
self.assertIsNone(showing())
hover("text")
time.sleep(0.1)
self.assertEqual(showing(), "Type on the page. Ta")
# A press ends the tooltip and the rule.
firefox.run("document.body.dispatchEvent(new PointerEvent('pointerdown', {bubbles: true}));")
self.assertIsNone(showing())
hover("pen")
time.sleep(0.5)
self.assertIsNone(showing())
time.sleep(0.8)
self.assertEqual(showing(), "Write on the page. O")
2026-10-11 18:05:49 +02:00
# In the Notebooks chooser, a page card's tooltip gives the facts
# about the page, headed by its first text (nearest the top, then
# the left), and a notebook's gives its facts; both shown inside
# the dialog, which is above everything else.
notebook = self.notebooks / "Reader notebook"
notebook.mkdir(parents=True, exist_ok=True)
(notebook / "2026_10_01_01.svg").write_text(
'<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg" data-kokkoja="1" data-page="abcdefghijkl" data-number="1" data-base="2970 2100" '
'viewBox="0 0 2970 2100"><rect width="2970" height="2100" fill="#fff"/>'
'<g data-layer="a" data-name="Anna"><g data-stroke="s" data-width="7" data-points="10 10 0.5 20 20 0.5" '
'stroke="#000000" fill="none"><path d="M10 10 L20 20"/></g>'
'<g data-text="t2" data-x="100" data-y="900" data-width="" data-height="" data-size="35" '
'data-font="sans-serif" data-box="300 50"><desc>Lower text</desc>'
'<text fill="#000000"><tspan>Lower text</tspan></text></g>'
'<g data-text="t1" data-x="500" data-y="200" data-width="" data-height="" data-size="35" '
'data-font="sans-serif" data-box="500 50"><desc>Übung 12: der Dativ</desc>'
'<text fill="#000000"><tspan>Übung 12: der Dativ</tspan></text></g></g></svg>')
(notebook / "2026_10_03_01.svg").write_text(
'<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg" data-kokkoja="1" data-page="abcdefghijkl" data-number="1" data-base="2970 2100" '
'viewBox="0 0 2970 2100"><rect width="2970" height="2100" fill="#fff"/></svg>')
firefox.run("document.getElementById('load_notebook_page').click();")
self.wait_until_true(lambda: firefox.run("""
return [...document.querySelectorAll('#chooser_notebooks button.notebook_pick')].map((b) => b.textContent);
"""), lambda names: "Reader notebook" in names, "the chooser to list the notebooks")
firefox.run("""
[...document.querySelectorAll('#chooser_notebooks button.notebook_pick')]
.find((b) => b.textContent === 'Reader notebook').click();
""")
self.wait_until_true(lambda: firefox.run("""
return [...document.querySelectorAll('#chooser_pages .page_card')].map((c) => c.dataset.help);
"""), lambda helps: len(helps) == 2 and all("Sheet" in h for h in helps), "the cards' tooltips to be made")
helps = firefox.run("return [...document.querySelectorAll('#chooser_pages .page_card')].map((c) => c.dataset.help);")
self.assertEqual(helps[0].split("\n")[0], "Übung 12: der Dativ")
self.assertIn("Written by Anna", helps[0])
self.assertIn("1 stroke, 2 text blocks", helps[0])
self.assertIn("Sheet 29.7 × 21.0 cm", helps[0])
self.assertRegex(helps[0], r"Last changed \S+ at \d")
self.assertEqual(helps[1].split("\n")[0], "Last changed " + helps[1].split("\n")[0][13:])
self.assertIn("Nothing on it", helps[1])
self.wait_until_true(lambda: firefox.run("""
return [...document.querySelectorAll('#chooser_notebooks button.notebook_pick')]
.find((b) => b.textContent === 'Reader notebook').dataset.help;
"""), lambda help: "kept pages" in help, "the notebook's tooltip to be made")
self.assertIn("2 kept pages, from 2026-10-01 to 2026-10-03", firefox.run("""
return [...document.querySelectorAll('#chooser_notebooks button.notebook_pick')]
.find((b) => b.textContent === 'Reader notebook').dataset.help;
"""))
firefox.run("""
document.querySelector('#chooser_pages .page_card')
.dispatchEvent(new PointerEvent('pointerover', {bubbles: true, pointerType: 'mouse'}));
""")
time.sleep(1.3)
self.assertEqual(firefox.run("""
const t = document.querySelector('dialog[open] .tooltip');
return t && !t.hidden ? t.textContent.split('\\n')[0] : null;
"""), "Übung 12: der Dativ")
firefox.run("document.getElementById('chooser_close').click();")
def test_hide_rectangles(self):
"""The teacher's requests of 2026-10-08: a Hide rectangle covers part
of the page in white for everyone, marked by blue corners."""
firefox = self.firefox
host_tab = firefox.current_tab()
firefox.go(self.address("Late"))
self.enter_name("Hider", "start_session")
invitation = firefox.run("return document.getElementById('invitation').value;")
guest_tab = firefox.new_tab()
firefox.go(self.address("Student", join=invitation))
self.enter_name("Watcher", "join_session")
self.wait_until(lambda s: s["page"]["number"] == 1 and len(s["participants"]) == 2, "the watcher")
self.assertTrue(firefox.run("return document.querySelector('[data-tool=hide]').hidden;"))
firefox.switch_to(host_tab)
self.assertFalse(firefox.run("return document.querySelector('[data-tool=hide]').hidden;"))
# The host writes, then hides a rectangle over the writing.
firefox.drag("#page", 200, 300, 500, 320)
written = self.wait_until(lambda s: s["layers"]["Hider"] and s["layers"]["Hider"][-1]["done"], "a stroke")
firefox.run("document.querySelector('[data-tool=hide]').click();")
self.assertTrue(firefox.run("return document.getElementById('colors').hidden;"))
firefox.run(TEXT_GESTURE, 0.05, 0.2, 0.35, 0.4)
made = self.wait_until(lambda s: len(s["hides"]) == 1, "the Hide rectangle")
hide = made["hides"][0]
self.assertGreater(hide[2], 100)
self.assertGreater(hide[3], 100)
white_at = lambda x, y: firefox.run("""
const r = window.kokkoja.renderer;
const c = r.hideCanvas.getContext('2d');
const [x, y] = arguments;
const px = c.getImageData((x - r.page.left) * r.scale, (y - r.page.top) * r.scale, 1, 1).data;
return px[3] === 255 && px[0] === 255 && px[1] === 255 && px[2] === 255;
""", x, y)
self.assertTrue(white_at(hide[0] + hide[2] / 2, hide[1] + hide[3] / 2))
self.assertFalse(white_at(hide[0] + hide[2] + 100, hide[1] + hide[3] + 100))
# The watcher sees it; hiding the host's layer on the watcher's
# screen does not uncover it; the watcher can neither move nor
# remove it.
firefox.switch_to(guest_tab)
self.wait_until(lambda s: s["hides"] == made["hides"], "the Hide to reach the watcher")
firefox.run("""
[...document.querySelectorAll('#participants li')].find((li) => li.textContent.includes('Hider'))
.querySelector('input[type=checkbox]').click();
""")
self.assertTrue(white_at(hide[0] + hide[2] / 2, hide[1] + hide[3] / 2))
vscale = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;")
origin = self.state()["origin"]
firefox.run_until_done(PEN_HOLD, (hide[0] + 50 - origin[0]) * vscale, (hide[1] + 50 - origin[1]) * vscale,
100 * vscale, 0, 800)
firefox.run(ERASER_AT, hide[0] + 50, hide[1] + 50, 5)
time.sleep(0.5)
self.assertEqual(self.state()["hides"], made["hides"])
# The host moves it by a hold, resizes it freely from a corner,
# and the file carries it above every layer.
firefox.switch_to(host_tab)
scale = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;")
origin = self.state()["origin"]
px = lambda x: (x - origin[0]) * scale
py = lambda y: (y - origin[1]) * scale
firefox.run_until_done(PEN_HOLD, px(hide[0] + 50), py(hide[1] + 50), 200 * scale, 100 * scale, 800)
moved = self.wait_until(lambda s: s["hides"][0][0] == hide[0] + 200, "the Hide moved")["hides"][0]
self.assertEqual(moved, [hide[0] + 200, hide[1] + 100, hide[2], hide[3]])
firefox.run_until_done(PEN_HOLD, px(moved[0] + moved[2]) - 4, py(moved[1] + moved[3]) - 4,
300 * scale, 0, 800)
resized = self.wait_until(lambda s: s["hides"][0][2] > moved[2], "the Hide resized")["hides"][0]
# The width grew; the height followed the pen, 4 pixels inside the corner: no shape kept.
self.assertAlmostEqual(resized[3], moved[3], delta=4 / scale + 1)
self.assertEqual(resized[:2], moved[:2])
saved = self.current_page("Hider notebook", {"Hider": 1}, number=1)
self.assertLess(saved.index('<g data-layer='), saved.index('<g data-hides='))
self.assertIn(f'x="{resized[0]}" y="{resized[1]}" width="{resized[2]}" height="{resized[3]}" fill="#ffffff"', saved)
firefox.switch_to(guest_tab)
self.wait_until(lambda s: s["hides"] == [resized], "the resized Hide to reach the watcher")
firefox.switch_to(host_tab)
# A tap of the eraser removes it; the writing beneath is back.
firefox.run(ERASER_AT, resized[0] + 50, resized[1] + 50, 5)
gone = self.wait_until(lambda s: not s["hides"], "the Hide removed")
self.assertEqual(len(gone["layers"]["Hider"]), len(written["layers"]["Hider"]))
self.assertFalse(white_at(hide[0] + hide[2] / 2, hide[1] + hide[3] / 2))
self.assertNotIn('data-hides', self.current_page("Hider notebook", {"Hider": 1}, number=1))
firefox.switch_to(guest_tab)
firefox.in_session("DELETE", "/window")
firefox.switch_to(host_tab)
firefox.go("about:blank")
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")
2026-10-11 13:17:22 +02:00
def test_undo(self):
"""Andy, 2026-10-11: one's latest gesture is taken back, then the
one before, by the Undo button, Ctrl+Z, or two quick taps of the
eraser on empty paper; a stroke, an erasure, text, a Hide."""
firefox = self.firefox
host_tab = firefox.current_tab()
firefox.go(self.address("Late"))
self.enter_name("Writer", "start_session")
invitation = firefox.run("return document.getElementById('invitation').value;")
guest_tab = firefox.new_tab()
firefox.go(self.address("Student", join=invitation))
self.enter_name("Watcher", "join_session")
self.wait_until(lambda s: s["page"]["number"] == 1 and len(s["participants"]) == 2, "the watcher")
firefox.switch_to(host_tab)
undo_disabled = lambda: firefox.run("return document.getElementById('undo').disabled;")
undo = lambda: firefox.run("document.getElementById('undo').click();")
self.assertTrue(undo_disabled(), "nothing to undo on a new page")
# Two strokes; Undo takes back the second, for everyone.
firefox.drag("#page", 200, 300, 500, 320)
firefox.drag("#page", 200, 400, 500, 420)
two = self.wait_until(lambda s: len(s["layers"]["Writer"]) == 2 and s["layers"]["Writer"][-1]["done"],
"two strokes")
self.assertFalse(undo_disabled())
undo()
one = self.wait_until(lambda s: len(s["layers"]["Writer"]) == 1, "one stroke after the undo")
self.assertEqual(one["layers"]["Writer"][0]["id"], two["layers"]["Writer"][0]["id"])
firefox.switch_to(guest_tab)
self.wait_until(lambda s: len(s["layers"].get("Writer", [])) == 1, "the undo to reach the watcher")
firefox.switch_to(host_tab)
# An erasure is one gesture: undone, the stroke is back whole,
# with its id, on every screen.
stroke = one["layers"]["Writer"][0]
page_box = firefox.run("const b = document.getElementById('page').getBoundingClientRect(); return [b.width, b.height];")
firefox.run(ERASE_THROUGH, 350 / page_box[0], 310 / page_box[1])
self.wait_until(lambda s: len(s["layers"]["Writer"]) == 0, "the stroke erased")
self.assertFalse(undo_disabled())
undo()
back = self.wait_until(lambda s: len(s["layers"]["Writer"]) == 1, "the stroke back")
self.assertEqual(back["layers"]["Writer"][0], stroke)
firefox.switch_to(guest_tab)
watcher = self.wait_until(lambda s: len(s["layers"].get("Writer", [])) == 1 and s["layers"]["Writer"][0]["done"],
"the restored stroke to reach the watcher")
self.assertEqual(watcher["layers"]["Writer"][0], stroke)
firefox.switch_to(host_tab)
# Ctrl+Z undoes the stroke itself, the gesture before the erasure;
# then there is nothing left to undo.
firefox.chord("\ue009", "z")
self.wait_until(lambda s: len(s["layers"]["Writer"]) == 0, "the stroke gone by Ctrl+Z")
self.assertTrue(undo_disabled())
# Two quick taps of the eraser on empty paper undo; one tap, or two
# far apart, do nothing; a tap that erases is not half of a double
# tap, so the stroke it erased stays erased.
origin = self.state()["origin"]
vscale = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;")
units = lambda cx, cy: (origin[0] + cx / vscale, origin[1] + cy / vscale)
empty = units(700, 150)
firefox.drag("#page", 200, 400, 500, 420)
self.wait_until(lambda s: len(s["layers"]["Writer"]) == 1 and s["layers"]["Writer"][0]["done"], "a stroke")
firefox.run_until_done(ERASER_TAPS, *empty, 1, 0)
time.sleep(0.6)
firefox.run_until_done(ERASER_TAPS, *empty, 1, 0)
time.sleep(0.2)
self.assertEqual(len(self.state()["layers"]["Writer"]), 1, "taps far apart undo nothing")
firefox.run_until_done(ERASER_TAPS, *empty, 2, 50)
self.wait_until(lambda s: len(s["layers"]["Writer"]) == 0, "the stroke undone by a double tap")
firefox.drag("#page", 200, 400, 500, 420)
self.wait_until(lambda s: len(s["layers"]["Writer"]) == 1 and s["layers"]["Writer"][0]["done"], "a stroke again")
firefox.run_until_done(ERASER_TAPS, *units(350, 410), 1, 0)
self.wait_until(lambda s: len(s["layers"]["Writer"]) == 0, "the stroke erased by a tap")
firefox.run_until_done(ERASER_TAPS, *empty, 1, 0)
time.sleep(0.2)
self.assertEqual(len(self.state()["layers"]["Writer"]), 0, "an erasing tap and an empty one are no double tap")
firefox.run_until_done(ERASER_TAPS, *empty, 2, 50)
self.wait_until(lambda s: len(s["layers"]["Writer"]) == 1, "the erasure undone by a double tap")
# Text: typing a block is one gesture, changing it another; undo
# restores the text as it was before the change, then removes the
# block.
firefox.run("document.querySelector('[data-tool=text]').click();")
firefox.run(TEXT_GESTURE, 0.6, 0.6, 0.6, 0.6)
firefox.type_into(".text_field", "Hallo")
firefox.run("document.querySelector('[data-tool=pen]').click();")
typed = self.wait_until(lambda s: len(s["texts"]["Writer"]) == 1 and s["texts"]["Writer"][0]["text"] == "Hallo",
"the text block")
block = typed["texts"]["Writer"][0]
state = self.state()
fx = (block["x"] + 10 - state["origin"][0]) / state["page"]["width"]
fy = (block["y"] + 10 - state["origin"][1]) / state["page"]["height"]
firefox.run("document.querySelector('[data-tool=text]').click();")
firefox.run(TEXT_GESTURE, fx, fy, fx, fy)
firefox.type_into(".text_field", " Welt")
firefox.run("document.querySelector('[data-tool=pen]').click();")
self.wait_until(lambda s: s["texts"]["Writer"][0]["text"] == "Hallo Welt", "the changed text")
undo()
self.wait_until(lambda s: s["texts"]["Writer"][0]["text"] == "Hallo", "the text as it was")
undo()
self.wait_until(lambda s: len(s["texts"]["Writer"]) == 0, "the block gone")
self.assertEqual(len(self.state()["layers"]["Writer"]), 1)
# A Hide rectangle made, and one removed by the eraser, are undone.
firefox.run("document.querySelector('[data-tool=hide]').click();")
firefox.run(TEXT_GESTURE, 0.05, 0.2, 0.35, 0.4)
made = self.wait_until(lambda s: len(s["hides"]) == 1, "the Hide rectangle")
undo()
self.wait_until(lambda s: not s["hides"], "the Hide undone")
firefox.run(TEXT_GESTURE, 0.05, 0.2, 0.35, 0.4)
made = self.wait_until(lambda s: len(s["hides"]) == 1, "the Hide rectangle again")
hide = made["hides"][0]
firefox.run(ERASER_AT, hide[0] + 50, hide[1] + 50, 5)
self.wait_until(lambda s: not s["hides"], "the Hide removed by the eraser")
undo()
self.wait_until(lambda s: s["hides"] == [hide], "the Hide back where it was")
firefox.switch_to(guest_tab)
self.wait_until(lambda s: s["hides"] == [hide], "the restored Hide to reach the watcher")
firefox.switch_to(host_tab)
# A new page starts with nothing to undo.
firefox.run("document.getElementById('new_page').click();")
self.wait_until(lambda s: s["page"]["number"] == 2, "the new page")
self.assertTrue(undo_disabled())
firefox.switch_to(guest_tab)
firefox.in_session("DELETE", "/window")
firefox.switch_to(host_tab)
firefox.go("about:blank")
def test_images_are_moved_and_scaled_by_hand(self):
"""The teacher's requests of 2026-10-08: composition mode, and
images moved, scaled and brought to the front by a hold of the pen."""
firefox = self.firefox
host_tab = firefox.current_tab()
firefox.go(self.address("Late"))
self.enter_name("Mover", "start_session")
invitation = firefox.run("return document.getElementById('invitation').value;")
guest_tab = firefox.new_tab()
firefox.go(self.address("Student", join=invitation))
self.enter_name("Viewer", "join_session")
self.wait_until(lambda s: s["page"]["number"] == 1 and len(s["participants"]) == 2, "the viewer")
firefox.switch_to(host_tab)
# Every image arrives below the others, centred, at its natural size:
# a wide one widens the sheet equally on both sides.
firefox.run_until_done(LOAD_IMAGE, 300, 200, "png", 0, False)
first = self.wait_until(lambda s: len(s["images"]) == 1 and s["image"]["ready"], "the first image")
self.assertEqual(first["images"][0]["x"], 1335)
firefox.run_until_done(LOAD_IMAGE, 3200, 300, "png", 0, False)
composed = self.wait_until(lambda s: len(s["images"]) == 2 and all(i["ready"] for i in s["images"]),
"the wide image")
rect = lambda state, i: [state["images"][i][k] for k in ("x", "y", "width", "height")]
self.assertEqual(rect(composed, 1), [-115, 300, 3200, 300])
self.assertEqual(composed["page"]["width"], 3300)
# A stroke, which will stay where it is whatever the images do.
firefox.drag("#page", 30, 500, 90, 504)
marked = self.wait_until(lambda s: s["layers"]["Mover"] and s["layers"]["Mover"][-1]["done"], "the stroke")
stroke_points = marked["layers"]["Mover"][-1]["points"]
strokes_before = len(marked["layers"]["Mover"])
# A hold on the wide image, dragged, moves it down out of the way;
# the dot the hold began as is gone.
scale = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;")
pen_hold = lambda fx, fy, dx, dy: firefox.run_until_done(PEN_HOLD, fx, fy, dx, dy, 800)
px = lambda x: (x - composed["origin"][0]) * scale
py = lambda y: (y - composed["origin"][1]) * scale
pen_hold(px(400), py(450), 0, 600 * scale)
lowered = self.wait_until(lambda s: s["images"][1]["y"] == 900, "the wide image moved down")
self.assertEqual(rect(lowered, 1), [-115, 900, 3200, 300])
self.assertEqual(len(lowered["layers"]["Mover"]), strokes_before)
self.assertEqual(lowered["layers"]["Mover"][-1]["points"], stroke_points)
# A hold on the first image, now uncovered but behind, brings it
# to the front and moves it.
target = rect(composed, 0)
pen_hold(px(target[0] + 150), py(target[1] + 100), 400 * scale, 300 * scale)
moved = self.wait_until(lambda s: s["images"][-1]["x"] == target[0] + 400, "the image moved to the front")
self.assertEqual(rect(moved, 1), [target[0] + 400, target[1] + 300, 300, 200])
self.assertEqual(moved["images"][1]["hash"], composed["images"][0]["hash"])
self.assertEqual(moved["images"][0]["hash"], composed["images"][1]["hash"])
self.assertEqual(len(moved["layers"]["Mover"]), strokes_before)
# A hold near a corner scales it along the diagonal, the opposite
# corner fixed, the aspect ratio kept.
corner = [moved["images"][1]["x"] + 300, moved["images"][1]["y"] + 200]
pen_hold(px(corner[0]) - 5, py(corner[1]) - 5, 300 * scale, 0)
scaled = self.wait_until(lambda s: s["images"][1]["width"] > 300, "the image scaled")
self.assertEqual((scaled["images"][1]["x"], scaled["images"][1]["y"]), (moved["images"][1]["x"], moved["images"][1]["y"]))
self.assertAlmostEqual(scaled["images"][1]["width"] / scaled["images"][1]["height"], 1.5, places=2)
self.assertAlmostEqual(scaled["images"][1]["width"], 600, delta=12)
# The eraser holds an image too, and erases nothing meanwhile: a
# stroke under the press stays, and the image moves.
firefox.run("document.querySelector('[data-tool=eraser]').click();")
firefox.run("document.querySelector('[data-tool=pen]').click();")
firefox.drag("#page", px(scaled["images"][1]["x"] + 100), py(scaled["images"][1]["y"] + 100),
px(scaled["images"][1]["x"] + 160), py(scaled["images"][1]["y"] + 104))
on_image = self.wait_until(lambda s: s["layers"]["Mover"][-1]["done"] and
len(s["layers"]["Mover"]) == strokes_before + 1, "a stroke on the image")
firefox.run("document.querySelector('[data-tool=eraser]').click();")
pen_hold(px(scaled["images"][1]["x"] + 120), py(scaled["images"][1]["y"] + 102), 200 * scale, 0)
erased_not = self.wait_until(lambda s: s["images"][1]["x"] == scaled["images"][1]["x"] + 200,
"the image moved by the eraser's hold")
self.assertEqual(len(erased_not["layers"]["Mover"]), strokes_before + 1)
firefox.run("document.querySelector('[data-tool=pen]').click();")
scaled = erased_not
# A guest given the right adds an image, keeps the power to remove it
# after the right is withdrawn, and cannot remove the host's.
self.assertEqual(firefox.run(TOGGLE_PERMISSION, "Viewer"), "true")
firefox.switch_to(guest_tab)
2026-10-11 17:34:24 +02:00
self.wait_until(lambda s: not firefox.run("return document.getElementById('new_page').hidden;"),
"the viewer to be allowed")
firefox.run_until_done(LOAD_IMAGE, 200, 100, "png", 0, False)
added = self.wait_until(lambda s: len(s["images"]) == 3, "the viewer's image")
firefox.switch_to(host_tab)
self.wait_until(lambda s: len(s["images"]) == 3, "the viewer's image to reach the host")
self.assertEqual(firefox.run(TOGGLE_PERMISSION, "Viewer"), "false")
firefox.switch_to(guest_tab)
2026-10-11 17:34:24 +02:00
self.wait_until(lambda s: firefox.run("return document.getElementById('new_page').hidden;"),
"the right to be withdrawn")
own = added["images"][2]
firefox.run(ERASER_AT, own["x"] + 100, own["y"] + 50, 5)
self.wait_until(lambda s: len(s["images"]) == 2, "the viewer's own image removed")
hosts = self.state()["images"][1]
firefox.run(ERASER_AT, hosts["x"] + 50, hosts["y"] + 50, 5)
time.sleep(0.5)
self.assertEqual(len(self.state()["images"]), 2)
firefox.switch_to(host_tab)
self.wait_until(lambda s: len(s["images"]) == 2, "the removal to reach the host")
firefox.switch_to(guest_tab)
# The viewer sees the same, and cannot move an image: a hold there
# draws a dot instead.
firefox.switch_to(guest_tab)
viewer = self.wait_until(lambda s: s["images"] and rect(s, 1) == rect(scaled, 1), "the viewer's copy")
self.assertEqual(viewer["images"][1]["hash"], scaled["images"][1]["hash"])
vscale = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;")
firefox.run_until_done(PEN_HOLD, (viewer["images"][1]["x"] + 50 - viewer["origin"][0]) * vscale,
(viewer["images"][1]["y"] + 50 - viewer["origin"][1]) * vscale, 60 * vscale, 0, 800)
time.sleep(0.5)
after = self.state()
self.assertEqual(rect(after, 1), rect(scaled, 1))
self.assertEqual(len(after["layers"].get("Viewer", [])), 1)
firefox.in_session("DELETE", "/window")
firefox.switch_to(host_tab)
firefox.go("about:blank")
def test_character_codes_in_text(self):
"""Andy, 2026-10-13: Klammertext's character codes in a text
block, replaced as each is completed; the field's own undo
survives a replacement."""
firefox = self.firefox
host_tab = firefox.current_tab()
firefox.go(self.address("Late"))
self.enter_name("Typist", "start_session")
invitation = firefox.run("return document.getElementById('invitation').value;")
guest_tab = firefox.new_tab()
firefox.go(self.address("Student", join=invitation))
self.enter_name("Onlooker", "join_session")
self.wait_until(lambda s: s["page"]["number"] == 1 and len(s["participants"]) == 2, "the onlooker")
firefox.switch_to(host_tab)
field_text = lambda: firefox.run("return document.querySelector('.text_field').value;")
firefox.run("document.querySelector('[data-tool=text]').click();")
firefox.run(TEXT_GESTURE, 0.2, 0.3, 0.2, 0.3)
firefox.type_into(".text_field", 'Gr^u"^s^e, ^a` 2^^3, ^OE^uvre, ^00E9^, m^a1, ^130ED^ ^9053 53EF^')
self.assertEqual(field_text(), "Grüße, à 2^3, Œuvre, é, mā, 𓃭 道可")
self.wait_until(lambda s: s["texts"]["Typist"] and s["texts"]["Typist"][0]["text"] == "Grüße, à 2^3, Œuvre, é, mā, 𓃭 道可",
"the text with its codes replaced")
# The caret counts UTF-16 units, so the hieroglyph counts twice.
self.assertEqual(firefox.run("return document.querySelector('.text_field').selectionStart;"),
len("Grüße, à 2^3, Œuvre, é, mā, 𓃭 道可".encode("utf-16-le")) // 2)
# In the middle of the text too, and only at the caret: the ^
# left earlier stays.
firefox.run("const f = document.querySelector('.text_field'); f.setSelectionRange(2, 2);")
firefox.type_into(".text_field", "^o-")
self.assertEqual(field_text(), "Grōüße, à 2^3, Œuvre, é, mā, 𓃭 道可")
self.assertEqual(firefox.run("return document.querySelector('.text_field').selectionStart;"), 3)
# The browser's own undo in the field is not lost by a replacement:
# one step gives back the code as typed.
firefox.chord("\ue009", "z")
self.assertEqual(field_text(), "Gr^o-üße, à 2^3, Œuvre, é, mā, 𓃭 道可")
firefox.run("document.querySelector('[data-tool=pen]').click();")
shown = self.wait_until(lambda s: s["texts"]["Typist"] and "üße" in s["texts"]["Typist"][0]["text"],
"the finished block")["texts"]["Typist"][0]["text"]
firefox.switch_to(guest_tab)
self.wait_until(lambda s: s["texts"].get("Typist") and s["texts"]["Typist"][0]["text"] == shown,
"the onlooker to see the same text")
def test_arrange_tool(self):
"""Andy, 2026-10-13: with the Arrange tool, blue corners mark every
image (and, until 2026-10-14, every Hide and own text rectangle,
which carry blue corners at all times since), a drag inside one
moves it and a drag on a corner resizes it, at once, no hold; a
press elsewhere does nothing. A guest without the right sees no
cues, their own text rectangle's corners being drawn anyway."""
firefox = self.firefox
host_tab = firefox.current_tab()
firefox.go(self.address("Late"))
self.enter_name("Arranger", "start_session")
invitation = firefox.run("return document.getElementById('invitation').value;")
guest_tab = firefox.new_tab()
firefox.go(self.address("Student", join=invitation))
self.enter_name("Watcher", "join_session")
self.wait_until(lambda s: s["page"]["number"] == 1 and len(s["participants"]) == 2, "the watcher")
firefox.switch_to(host_tab)
cues = lambda: firefox.run("return [...document.querySelectorAll('.arrange_cue')].map((c) => c.className.split(' ')[1]);")
choose = lambda tool: firefox.run(f"document.querySelector('[data-tool={tool}]').click();")
rect = lambda state, i: [state["images"][i][k] for k in ("x", "y", "width", "height")]
firefox.run_until_done(LOAD_IMAGE, 300, 200, "png", 0, False)
placed = self.wait_until(lambda s: len(s["images"]) == 1 and s["image"]["ready"], "the image")
self.assertEqual(cues(), [], "no cues with the pen")
choose("arrange")
self.assertEqual(cues(), ["image"])
self.assertTrue(firefox.run("return document.getElementById('colors').hidden;"))
scale = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;")
origin = placed["origin"]
px = lambda x: (x - origin[0]) * scale
py = lambda y: (y - origin[1]) * scale
# A drag inside the image moves it at once, and draws nothing.
before = rect(placed, 0)
firefox.drag("#page", px(before[0] + 150), py(before[1] + 100), px(before[0] + 150) + 200, py(before[1] + 100) + 100)
moved = self.wait_until(lambda s: s["images"][0]["x"] != before[0], "the image moved")
self.assertEqual(rect(moved, 0)[2:], before[2:], "the same size")
self.assertAlmostEqual(moved["images"][0]["x"], before[0] + 200 / scale, delta=2)
self.assertAlmostEqual(moved["images"][0]["y"], before[1] + 100 / scale, delta=2)
self.assertEqual(moved["layers"]["Arranger"], [], "no stroke")
# A drag on its bottom-right corner scales it, the shape kept, the
# top-left corner fixed.
now = rect(moved, 0)
firefox.drag("#page", px(now[0] + now[2]) - 3, py(now[1] + now[3]) - 3, px(now[0] + now[2]) + 150, py(now[1] + now[3]) - 3)
scaled = self.wait_until(lambda s: s["images"][0]["width"] != now[2], "the image scaled")
self.assertEqual(rect(scaled, 0)[:2], now[:2], "the top-left corner stays")
self.assertAlmostEqual(scaled["images"][0]["width"] / scaled["images"][0]["height"], 300 / 200, places=2)
self.assertGreater(scaled["images"][0]["width"], now[2] + 100 / scale)
# A drag on empty paper does nothing at all.
firefox.drag("#page", px(100), py(1800), px(600), py(1900))
time.sleep(0.5)
after = self.state()
self.assertEqual(after["layers"]["Arranger"], [])
self.assertEqual(rect(after, 0), rect(scaled, 0))
# A text rectangle of one's own, free text and a Hide get cues too
# (Andy, 2026-10-14), the free text's without corners; the hover
# cue still shows on the box and the Hide; a corner drag widens
# the text box, a drag inside moves the Hide.
choose("text")
firefox.run(TEXT_GESTURE, 0.1, 0.6, 0.3, 0.7)
firefox.type_into(".text_field", "Box")
firefox.run(TEXT_GESTURE, 0.4, 0.8, 0.4, 0.8) # Finishes the box.
self.wait_until(lambda s: firefox.run("return document.querySelector('.text_field').hidden;"), "the box finished")
firefox.run(TEXT_GESTURE, 0.4, 0.8, 0.4, 0.8) # Starts free text.
firefox.type_into(".text_field", "Free")
choose("hide")
firefox.run(TEXT_GESTURE, 0.5, 0.6, 0.7, 0.7)
with_all = self.wait_until(lambda s: len(s["hides"]) == 1 and len(s["texts"]["Arranger"]) == 2
and s["texts"]["Arranger"][0]["text"] == "Box" and s["texts"]["Arranger"][1]["text"] == "Free",
"the box, the free text and the Hide")
choose("arrange")
self.assertEqual(cues(), ["image", "hide", "text", "text"])
self.assertEqual(firefox.run("return [...document.querySelectorAll('.arrange_cue.text')].map((c) => c.classList.contains('no_corners'));"),
[False, True], "free text's cue has no corners")
self.assertEqual(firefox.run("return [...document.querySelectorAll('.arrange_cue')].map((c) => getComputedStyle(c).borderTopWidth);"),
["2px"] * 4, "every cue is a box, its line the grip's width")
# The image's cue lies CUE_OFFSET_PX outside the image.
im = scaled["images"][0]
page_box = firefox.run("const b = document.getElementById('page').getBoundingClientRect(); return [b.left, b.top];")
got = firefox.run("const b = document.querySelector('.arrange_cue.image').getBoundingClientRect(); return [b.left, b.top, b.right, b.bottom];")
want = overlay_box(px(im["x"]), py(im["y"]), px(im["x"] + im["width"]), py(im["y"] + im["height"]), 2)
for side, g, w in zip(("left", "top", "right", "bottom"), (got[0] - page_box[0], got[1] - page_box[1], got[2] - page_box[0], got[3] - page_box[1]), want):
self.assertAlmostEqual(g, w, delta=0.01, msg=side)
self.assertEqual(firefox.run("return getComputedStyle(document.documentElement).getPropertyValue('--arrange_blue');").strip(), "#1e88e5")
self.assertEqual(firefox.run("return getComputedStyle(document.querySelector('.arrange_cue')).borderTopColor;"), "rgb(30, 136, 229)")
box = with_all["texts"]["Arranger"][0]
self.assert_hover_cue(box["x"] + 20, box["y"] + 20, [box["x"], box["y"], box["width"], box["height"]], "rect")
h = with_all["hides"][0]
self.assert_hover_cue(h[0] + h[2] / 2, h[1] + h[3] / 2, h)
firefox.drag("#page", px(box["x"] + box["width"]) - 3, py(box["y"] + box["height"]) - 3,
px(box["x"] + box["width"]) + 100, py(box["y"] + box["height"]) + 40)
widened = self.wait_until(lambda s: s["texts"]["Arranger"][0]["width"] > box["width"] + 80 / scale, "the box widened")
self.assertEqual((widened["texts"]["Arranger"][0]["x"], widened["texts"]["Arranger"][0]["y"]), (box["x"], box["y"]))
hide = with_all["hides"][0]
firefox.drag("#page", px(hide[0] + hide[2] / 2), py(hide[1] + hide[3] / 2),
px(hide[0] + hide[2] / 2) - 100, py(hide[1] + hide[3] / 2) + 50)
hide_moved = self.wait_until(lambda s: s["hides"][0][0] != hide[0], "the Hide moved")
self.assertEqual(hide_moved["hides"][0][2:], hide[2:])
self.assertEqual(len(cues()), 4)
# Undo takes the Hide's move back, as it does a hold's.
firefox.run("document.getElementById('undo').click();")
self.wait_until(lambda s: s["hides"][0] == hide, "the Hide back by Undo")
# While something is held its blue cue gives way to the green grip:
# a press on the Hide, held down, then released.
page_box = firefox.run("const b = document.getElementById('page').getBoundingClientRect(); return [b.left, b.top];")
firefox.pointer_actions([{"type": "pointerMove", "duration": 0, "origin": "viewport",
"x": round(page_box[0] + px(hide[0] + hide[2] / 2)), "y": round(page_box[1] + py(hide[1] + hide[3] / 2))},
{"type": "pointerDown", "button": 0}, {"type": "pause", "duration": 100}])
self.assertEqual(cues(), ["image", "text", "text"], "the held Hide's cue is gone")
self.assertFalse(firefox.run("return document.querySelector('.grip').hidden;"))
self.assertEqual(firefox.run("return getComputedStyle(document.querySelector('.grip')).borderTopColor;"), "rgb(46, 125, 50)")
# Held for moving, all four corners are the moving size (Andy, 2026-10-14).
self.assertEqual(firefox.run("return [...document.querySelectorAll('.grip .handle')].map((h) => getComputedStyle(h).width);"), ["14px"] * 4)
firefox.pointer_actions([{"type": "pointerUp", "button": 0}])
self.assertEqual(cues(), ["image", "hide", "text", "text"], "the cue is back")
self.assertTrue(firefox.run("return document.querySelector('.grip').hidden;"))
# Held at a corner, only that corner is the moving size.
firefox.pointer_actions([{"type": "pointerMove", "duration": 0, "origin": "viewport",
"x": round(page_box[0] + px(hide[0] + hide[2])) - 3, "y": round(page_box[1] + py(hide[1] + hide[3])) - 3},
{"type": "pointerDown", "button": 0}, {"type": "pause", "duration": 100}])
self.assertEqual(firefox.run("return [...document.querySelectorAll('.grip .handle')].map((h) => getComputedStyle(h).width);"), ["10px"] * 4)
# The square's position is a percentage of the grip's padding box.
self.assertEqual(firefox.run("const g = document.querySelector('.grip'); const s = getComputedStyle(g, '::after');"
" return [s.width, parseFloat(s.left) === g.clientWidth, parseFloat(s.top) === g.clientHeight];"),
["14px", True, True], "the dragged corner, bottom right, larger")
firefox.pointer_actions([{"type": "pointerUp", "button": 0}])
self.wait_until(lambda s: s["hides"][0] == hide, "the Hide unchanged by a press")
# Held free text shows the grip without corners, as its cue has none.
free = self.state()["texts"]["Arranger"][1]
firefox.pointer_actions([{"type": "pointerMove", "duration": 0, "origin": "viewport",
"x": round(page_box[0] + px(free["x"] + 20)), "y": round(page_box[1] + py(free["y"] + 20))},
{"type": "pointerDown", "button": 0}, {"type": "pause", "duration": 100}])
self.assertEqual(cues(), ["image", "hide", "text"], "the held free text's cue is gone")
self.assertEqual(firefox.run("return [...document.querySelectorAll('.grip .handle')].map((h) => getComputedStyle(h).display);"),
["none"] * 4, "no corners on held free text")
firefox.pointer_actions([{"type": "pointerUp", "button": 0}])
self.assertEqual(cues(), ["image", "hide", "text", "text"])
# The watcher, without the right, is shown no cue on the image or
# the Hide, only on their own text.
firefox.switch_to(guest_tab)
self.wait_until(lambda s: len(s["images"]) == 1 and len(s["hides"]) == 1, "the page at the watcher")
choose("arrange")
self.assertEqual(cues(), [])
choose("text")
firefox.run(TEXT_GESTURE, 0.1, 0.8, 0.3, 0.9)
firefox.type_into(".text_field", "Mine")
choose("arrange")
self.assertEqual(cues(), ["text"])
mine = self.wait_until(lambda s: s["texts"].get("Watcher") and s["texts"]["Watcher"][0]["text"] == "Mine", "the watcher's box")
mine = mine["texts"]["Watcher"][0]
self.assert_hover_cue(mine["x"] + 20, mine["y"] + 20, [mine["x"], mine["y"], mine["width"], mine["height"]], "rect")
def test_shapes(self):
"""Andy, 2026-10-14: a Shape button right of Text draws the shape it
names in the pen's colour and width; a hold on the button, or a
secondary press, opens a menu of shapes. A rectangle, an ellipse
and an arrow are dragged open and become one stroke each, the
arrow a filled one; a line is made of taps, one point each, grows
for everyone as it is made, and is finished by a tap on its last
point or closed by one on its first. Text rectangles have no
frame or ground any more, only blue corners on screen; free text
shows its corners while the pointer hovers over it."""
firefox = self.firefox
host_tab = firefox.current_tab()
firefox.go(self.address("Late"))
self.enter_name("Drawer", "start_session")
invitation = firefox.run("return document.getElementById('invitation').value;")
guest_tab = firefox.new_tab()
firefox.go(self.address("Student", join=invitation))
self.enter_name("Viewer", "join_session")
self.wait_until(lambda s: s["page"]["number"] == 1 and len(s["participants"]) == 2, "the viewer")
firefox.switch_to(host_tab)
choose = lambda tool: firefox.run(f"document.querySelector('[data-tool={tool}]').click();")
label = lambda: firefox.run("return document.getElementById('shape_tool').textContent;")
pressed = lambda: firefox.run("return document.querySelector('[data-tool][aria-pressed=true]').dataset.tool;")
menu_open = lambda: firefox.run("return !document.getElementById('shape_menu').hidden;")
pick = lambda shape: firefox.run(f"document.querySelector('#shape_menu [data-shape={shape}]').click();")
strokes = lambda s: s["layers"].get("Drawer", [])
state = self.state()
origin, page = state["origin"], state["page"]
at = lambda fx, fy: (origin[0] + fx * page["width"], origin[1] + fy * page["height"])
# The button names the default shape and keeps one width whatever it names.
self.assertEqual(label(), "Rectangle")
button_width = firefox.run("return document.getElementById('shape_tool').getBoundingClientRect().width;")
choose("shape")
self.assertEqual(pressed(), "shape")
self.assertEqual(firefox.run("return ['colors', 'widths', 'text_style'].map((id) => document.getElementById(id).hidden);"),
[False, False, True], "colours and widths show, as for the pen")
# A rectangle: one stroke of four corners, each twice for straight
# sides (the first and last once), at the pen's width and the
# nominal pressure, no fill.
firefox.run(TEXT_GESTURE, 0.1, 0.1, 0.3, 0.25)
drawn = self.wait_until(lambda s: len(strokes(s)) == 1 and strokes(s)[0]["done"], "the rectangle")
rect = strokes(drawn)[0]
self.assertEqual((rect["filled"], rect["color"], rect["width"]), (False, "#000000", 7))
self.assertEqual(len(rect["points"]), 8 * 3)
self.assertEqual(set(rect["points"][2::3]), {0.4})
self.assertAlmostEqual(rect["points"][0], at(0.1, 0.1)[0], delta=1)
self.assertAlmostEqual(rect["points"][13], at(0.3, 0.25)[1], delta=1)
self.assertEqual(rect["points"][0:2], rect["points"][21:23], "closed at the first corner")
# A tap draws nothing.
firefox.run(TEXT_GESTURE, 0.5, 0.5, 0.5, 0.5)
time.sleep(0.3)
self.assertEqual(len(strokes(self.state())), 1)
# The menu: a quick press on the button chooses the tool and opens
# nothing; a press held for the hold time opens it, and the click
# that follows chooses nothing.
choose("pen")
self.assertFalse(menu_open())
self.assertTrue(firefox.run_until_done(HOLD_SHAPE_BUTTON, 600), "the menu opens on a hold")
self.assertEqual(pressed(), "pen", "the click after the hold chooses nothing")
pick("ellipse")
self.assertFalse(menu_open())
self.assertEqual((label(), pressed()), ("Ellipse", "shape"))
self.assertEqual(firefox.run("return document.getElementById('shape_tool').getBoundingClientRect().width;"), button_width)
# Two quick clicks open it too, and it marks the current shape.
firefox.run("const b = document.getElementById('shape_tool'); b.click(); b.click();")
self.assertTrue(menu_open())
self.assertEqual(firefox.run("return [...document.querySelectorAll('#shape_menu [data-shape]')].filter((i) => i.getAttribute('aria-checked') === 'true').map((i) => i.dataset.shape);"), ["ellipse"])
self.assertEqual(firefox.run("return getComputedStyle(document.querySelector('#shape_menu [data-shape=ellipse]'), '::before').content;"), '"✓"')
self.assertEqual(firefox.run("return getComputedStyle(document.querySelector('#shape_menu [data-shape=line]'), '::before').content;"), '""')
firefox.chord("\ue00c")
self.assertFalse(menu_open())
firefox.run(TEXT_GESTURE, 0.4, 0.1, 0.6, 0.3)
drawn = self.wait_until(lambda s: len(strokes(s)) == 2 and strokes(s)[1]["done"], "the ellipse")
ellipse = strokes(drawn)[1]["points"]
self.assertGreaterEqual(len(ellipse) // 3, 24)
self.assertEqual(ellipse[0:2], ellipse[-3:-1], "closed")
self.assertAlmostEqual(ellipse[0], at(0.6, 0.1)[0], delta=1, msg="begins at the right end of the middle line")
self.assertAlmostEqual(ellipse[1], at(0.5, 0.2)[1], delta=1)
# A secondary press (the right button, a pen's barrel button) opens it too.
firefox.run("document.getElementById('shape_tool').dispatchEvent(new MouseEvent('contextmenu', {bubbles: true, cancelable: true}));")
self.assertTrue(menu_open())
pick("arrow")
self.assertEqual(label(), "Arrow")
# An arrow: a filled polygon of seven points, its tip at the end of
# the drag; the file holds it as a filled path.
firefox.run(TEXT_GESTURE, 0.1, 0.5, 0.3, 0.5)
drawn = self.wait_until(lambda s: len(strokes(s)) == 3 and strokes(s)[2]["done"], "the arrow")
arrow = strokes(drawn)[2]
self.assertTrue(arrow["filled"])
self.assertEqual(len(arrow["points"]), 7 * 3)
self.assertAlmostEqual(arrow["points"][9], at(0.3, 0.5)[0], delta=1)
self.assertAlmostEqual(arrow["points"][10], at(0.3, 0.5)[1], delta=1)
self.assertEqual(arrow["points"][6], arrow["points"][12], "the barbs are abreast")
self.assertAlmostEqual(abs(arrow["points"][13] - arrow["points"][7]), 2 * 0.6 * 25 / 7 * 7, delta=1, msg="the head of a 0.7 mm arrow is 2.5 mm long, 3 mm wide")
self.assertAlmostEqual(arrow["points"][9] - arrow["points"][6], 25, delta=0.5, msg="the head's length")
self.assertEqual(arrow["shape"], "arrow")
saved = self.wait_until_true(lambda: (self.notebooks / "Drawer notebook" / "Current.svg").read_text(),
lambda text: "data-filled" in text, "the arrow saved")
self.assertRegex(saved, r'<g data-stroke="[^"]+" data-width="7" data-points="[^"]+" data-filled="" data-shape="arrow" stroke="#000000"[^>]*><path d="M[^"]+Z" fill="#000000" stroke="none"/></g>')
self.assertEqual(saved.count("<g data-stroke="), 3)
# A line: a tap for each point, the stroke growing with each (its
# inner points twice, for straight sides); a tap on the last point
# finishes it.
firefox.run("document.getElementById('shape_tool').dispatchEvent(new MouseEvent('contextmenu', {bubbles: true, cancelable: true}));")
pick("line")
firefox.run(TEXT_GESTURE, 0.1, 0.7, 0.1, 0.7)
begun = self.wait_until(lambda s: len(strokes(s)) == 4, "the line's first point")
self.assertEqual((strokes(begun)[3]["done"], len(strokes(begun)[3]["points"])), (False, 3))
firefox.run(TEXT_GESTURE, 0.3, 0.7, 0.3, 0.7)
firefox.run(TEXT_GESTURE, 0.3, 0.9, 0.3, 0.9)
grown = self.wait_until(lambda s: len(strokes(s)[3]["points"]) == 5 * 3, "two more points")
self.assertFalse(strokes(grown)[3]["done"])
p = strokes(grown)[3]["points"]
self.assertEqual(p[3:5], p[6:8])
self.assertEqual(p[9:11], p[12:14])
# The rubber band follows a hovering pointer.
firefox.run(HOVER, 0.5, 0.9)
self.assertFalse(firefox.run("return document.querySelector('.shape_preview').hasAttribute('hidden');"))
firefox.run(TEXT_GESTURE, 0.3, 0.9, 0.3, 0.9)
done = self.wait_until(lambda s: strokes(s)[3]["done"], "the line finished by a tap on its last point")
self.assertEqual(len(strokes(done)[3]["points"]), 5 * 3)
self.assertTrue(firefox.run("return document.querySelector('.shape_preview').hasAttribute('hidden');"))
# A tap on the first point closes the shape: a triangle.
for fx, fy in ((0.5, 0.7), (0.7, 0.7), (0.6, 0.9), (0.5, 0.7)):
firefox.run(TEXT_GESTURE, fx, fy, fx, fy)
closed = self.wait_until(lambda s: len(strokes(s)) == 5 and strokes(s)[4]["done"], "the triangle")
p = strokes(closed)[4]["points"]
self.assertEqual(len(p), 6 * 3)
self.assertEqual(p[0:2], p[15:17], "back at the first point")
# Backspace takes the last point back, Escape finishes.
for fx, fy in ((0.8, 0.7), (0.9, 0.7), (0.9, 0.9)):
firefox.run(TEXT_GESTURE, fx, fy, fx, fy)
self.wait_until(lambda s: len(strokes(s)) == 6 and len(strokes(s)[5]["points"]) == 5 * 3, "three points")
firefox.chord("\ue003")
shortened = self.wait_until(lambda s: len(strokes(s)) == 6 and len(strokes(s)[5]["points"]) == 3 * 3, "two points left")
self.assertFalse(strokes(shortened)[5]["done"])
firefox.chord("\ue00c")
self.wait_until(lambda s: strokes(s)[5]["done"], "the line finished by Escape")
# The viewer holds the same six strokes, the arrow filled.
firefox.switch_to(guest_tab)
theirs = self.wait_until(lambda s: len(strokes(s)) == 6 and all(one["done"] for one in strokes(s)), "the shapes at the viewer")
self.assertEqual(strokes(theirs), strokes(self.state()))
firefox.switch_to(host_tab)
self.assertEqual(strokes(theirs), strokes(self.state()))
# Undo takes the last line back; the eraser removes the rectangle
# like any stroke.
firefox.run("document.getElementById('undo').click();")
self.wait_until(lambda s: len(strokes(s)) == 5, "the line undone")
choose("eraser")
firefox.run(TEXT_GESTURE, 0.2, 0.1, 0.2, 0.1)
self.wait_until(lambda s: len(strokes(s)) == 4 and strokes(s)[0]["id"] != rect["id"], "the rectangle erased")
# With Extend off a shape that would reach past the page is not drawn.
firefox.run("const box = document.getElementById('extend'); box.checked = false; box.dispatchEvent(new Event('change'));")
choose("shape")
firefox.run("document.getElementById('shape_tool').dispatchEvent(new MouseEvent('contextmenu', {bubbles: true, cancelable: true}));")
pick("rectangle")
firefox.run(TEXT_GESTURE, 0.9, 0.5, 0.995, 0.6)
time.sleep(0.4)
self.assertEqual(len(strokes(self.state())), 4)
firefox.run("const box = document.getElementById('extend'); box.checked = true; box.dispatchEvent(new Event('change'));")
# A fill colour from the menu's row, a state of its own: the
# rectangle's polygon is filled beneath its line, in the file too;
# a closed line is filled, an open one not; "No fill" ends it.
firefox.run("document.getElementById('shape_tool').dispatchEvent(new MouseEvent('contextmenu', {bubbles: true, cancelable: true}));")
firefox.run("document.querySelector('#shape_fill [aria-label=\"Fill #43a047\"]').click();")
self.assertTrue(menu_open(), "a fill keeps the menu open")
pick("rectangle")
firefox.run(TEXT_GESTURE, 0.1, 0.3, 0.3, 0.45)
filled = self.wait_until(lambda s: len(strokes(s)) == 5 and strokes(s)[4]["done"], "the filled rectangle")
self.assertEqual((strokes(filled)[4]["fill"], strokes(filled)[4]["color"], strokes(filled)[4]["shape"]), ("#43a047", "#000000", "rectangle"))
saved = self.wait_until_true(lambda: (self.notebooks / "Drawer notebook" / "Current.svg").read_text(),
lambda text: 'data-fill="' in text, "the fill saved")
self.assertRegex(saved, r'data-fill="#43a047" data-shape="rectangle"[^>]*><path d="M[^"]+Z" fill="#43a047" stroke="none"/><path d="M')
firefox.run("document.getElementById('shape_tool').dispatchEvent(new MouseEvent('contextmenu', {bubbles: true, cancelable: true}));")
pick("line")
for fx, fy in ((0.5, 0.3), (0.7, 0.3), (0.6, 0.45), (0.5, 0.3)):
firefox.run(TEXT_GESTURE, fx, fy, fx, fy)
triangle = self.wait_until(lambda s: len(strokes(s)) == 6 and strokes(s)[5]["done"], "a filled triangle")
self.assertEqual(strokes(triangle)[5]["fill"], "#43a047")
saved = self.wait_until_true(lambda: (self.notebooks / "Drawer notebook" / "Current.svg").read_text(),
lambda text: text.count('data-fill="') == 2, "the triangle saved")
self.assertEqual(saved.count('fill="#43a047" stroke="none"'), 2)
firefox.run("document.getElementById('shape_tool').dispatchEvent(new MouseEvent('contextmenu', {bubbles: true, cancelable: true}));")
firefox.run("document.querySelector('#shape_fill [aria-label=\"No fill\"]').click();")
pick("line")
for fx, fy in ((0.8, 0.3), (0.9, 0.45), (0.9, 0.45)):
firefox.run(TEXT_GESTURE, fx, fy, fx, fy)
unfilled = self.wait_until(lambda s: len(strokes(s)) == 7 and strokes(s)[6]["done"], "an unfilled line")
self.assertIsNone(strokes(unfilled)[6]["fill"])
# Arrange marks one's own shapes and moves or resizes them: a drag
# inside the filled rectangle moves it whole, a corner drag of the
# triangle resizes it, the arrow keeps its head when moved; the
# viewer follows; Undo takes a move back.
cues = lambda: firefox.run("return [...document.querySelectorAll('.arrange_cue')].map((c) => c.className.split(' ')[1]);")
choose("arrange")
self.assertEqual(cues(), ["shape"] * 7)
scale = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;")
px = lambda x: (x - origin[0]) * scale
py = lambda y: (y - origin[1]) * scale
# Each cue's line lies just outside the shape's ink (the points plus
# half the line's width, taken out to whole pixels), one pixel
# beyond it on every side (Andy, 2026-10-14).
page_box = firefox.run("const b = document.getElementById('page').getBoundingClientRect(); return [b.left, b.top];")
boxes = firefox.run("return [...document.querySelectorAll('.arrange_cue')].map((c) => { const b = c.getBoundingClientRect();"
" return [b.left, b.top, b.right, b.bottom, parseFloat(getComputedStyle(c).borderTopWidth)]; });")
for stroke, (left, top, right, bottom, line) in zip(strokes(unfilled), boxes):
xs, ys = stroke["points"][0::3], stroke["points"][1::3]
half = stroke["width"] / 2
self.assertEqual(line, 2)
expected = overlay_box(px(min(xs) - half), py(min(ys) - half), px(max(xs) + half), py(max(ys) + half), line)
for side, got, want in zip(("left", "top", "right", "bottom"), (left - page_box[0], top - page_box[1], right - page_box[0], bottom - page_box[1]), expected):
self.assertAlmostEqual(got, want, delta=0.01, msg=side)
before = strokes(filled)[4]["points"]
firefox.drag("#page", px(at(0.2, 0.37)[0]), py(at(0.2, 0.37)[1]), px(at(0.2, 0.37)[0]) + 100, py(at(0.2, 0.37)[1]) + 50)
moved = self.wait_until(lambda s: strokes(s)[4]["points"] != before, "the rectangle moved")
after = strokes(moved)[4]["points"]
self.assertEqual(len(after), len(before))
for i in range(0, len(before), 3):
self.assertAlmostEqual(after[i] - before[i], 100 / scale, delta=2)
self.assertAlmostEqual(after[i + 1] - before[i + 1], 50 / scale, delta=2)
self.assertEqual((strokes(moved)[4]["id"], strokes(moved)[4]["fill"]), (strokes(filled)[4]["id"], "#43a047"))
firefox.switch_to(guest_tab)
self.wait_until(lambda s: strokes(s)[4]["points"] == after, "the moved rectangle at the viewer")
firefox.switch_to(host_tab)
firefox.run("document.getElementById('undo').click();")
self.wait_until(lambda s: strokes(s)[4]["points"] == before, "the move undone")
tri = strokes(triangle)[5]["points"]
right, bottom = max(tri[0::3]), max(tri[1::3])
firefox.drag("#page", px(right) - 3, py(bottom) - 3, px(right) + 80, py(bottom) + 60)
resized = self.wait_until(lambda s: strokes(s)[5]["points"] != tri, "the triangle resized")
p = strokes(resized)[5]["points"]
self.assertAlmostEqual(min(p[0::3]), min(tri[0::3]), delta=1, msg="the left edge stays")
self.assertAlmostEqual(max(p[0::3]), right + 80 / scale, delta=2)
self.assertAlmostEqual(max(p[1::3]), bottom + 60 / scale, delta=2)
self.assertEqual(p[0:2], p[-3:-1], "still closed")
head = arrow["points"][13] - arrow["points"][7]
firefox.drag("#page", px(at(0.2, 0.5)[0]), py(at(0.2, 0.5)[1]), px(at(0.2, 0.5)[0]), py(at(0.2, 0.5)[1]) + 40)
arrow_moved = self.wait_until(lambda s: strokes(s)[1]["points"] != arrow["points"], "the arrow moved") # Index 1 since the rectangle's erasure.
a = strokes(arrow_moved)[1]["points"]
self.assertAlmostEqual(a[13] - a[7], head, delta=0.5, msg="the head keeps its size")
self.assertAlmostEqual(a[10] - arrow["points"][10], 40 / scale, delta=2)
choose("pen")
self.assertEqual(cues(), [])
# A text rectangle: nothing along its edges, nothing of it in the
# file; it and free text show their corners while the pointer
# hovers over them, and not otherwise.
choose("text")
firefox.run(TEXT_GESTURE, 0.36, 0.58, 0.48, 0.66) # Clear of every shape (the moved arrow's tip is near 0.3, 0.53).
firefox.type_into(".text_field", "Box")
choose("pen")
box = self.wait_until(lambda s: s["texts"].get("Drawer") and s["texts"]["Drawer"][0]["text"] == "Box", "the box")["texts"]["Drawer"][0]
self.assertFalse(firefox.run(INK_AT, box["x"] + box["width"] / 2, box["y"]), "no frame along the top edge")
self.assertFalse(firefox.run(INK_AT, box["x"], box["y"]), "no corner mark on the canvas")
self.assert_hover_cue(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2, [box["x"], box["y"], box["width"], box["height"]], "rect")
saved = self.wait_until_true(lambda: (self.notebooks / "Drawer notebook" / "Current.svg").read_text(),
lambda text: "<desc>Box</desc>" in text, "the box saved")
self.assertNotIn("data-ground", saved)
self.assertNotIn('stroke="#e53935"', saved.split("<desc>Box</desc>")[1].split("</g>")[0])
choose("text")
firefox.run(TEXT_GESTURE, 0.75, 0.6, 0.75, 0.6)
firefox.type_into(".text_field", "Frei")
choose("pen")
free = self.wait_until(lambda s: len(s["texts"]["Drawer"]) == 2 and s["texts"]["Drawer"][1]["text"] == "Frei", "the free text")["texts"]["Drawer"][1]
self.assertTrue(firefox.run(HOVER_CUE)["hidden"])
self.assertFalse(firefox.run(INK_AT, free["x"], free["y"]), "no red L on free text")
self.assert_hover_cue(free["x"] + free["boxWidth"] / 2, free["y"] + free["boxHeight"] / 2,
[free["x"], free["y"], free["boxWidth"], free["boxHeight"]], "free")
# A circle (Andy, 2026-10-14): the centre first, then a drag out to
# the edge in any direction; every point at that distance from the
# centre. Resized by Arrange from a corner, it stays a circle.
choose("shape")
pick("circle")
self.assertEqual(label(), "Circle")
self.assertEqual(firefox.run("return [...document.querySelectorAll('#shape_menu [data-shape]')].filter((i) => i.getAttribute('aria-checked') === 'true').map((i) => i.dataset.shape);"), ["circle"])
self.assertEqual(firefox.run("return getComputedStyle(document.querySelector('#shape_menu [data-shape=circle]'), '::before').content;"), '"✓"')
self.assertEqual(firefox.run("return [...document.querySelectorAll('#shape_menu [data-shape]')].map((i) => i.dataset.shape);"),
["rectangle", "circle", "ellipse", "line", "arrow"])
now = self.state()
count = len(strokes(now))
now_at = lambda fx, fy: (now["origin"][0] + fx * now["page"]["width"], now["origin"][1] + fy * now["page"]["height"])
firefox.run(TEXT_GESTURE, 0.3, 0.3, 0.33, 0.34)
with_circle = self.wait_until(lambda s: len(strokes(s)) == count + 1 and strokes(s)[-1]["done"], "the circle")
circle = strokes(with_circle)[-1]
self.assertEqual(circle["shape"], "circle")
cx, cy = now_at(0.3, 0.3)
radius = math.hypot(*(a - b for a, b in zip(now_at(0.33, 0.34), (cx, cy))))
xs, ys = circle["points"][0::3], circle["points"][1::3]
for x, y in zip(xs, ys):
self.assertAlmostEqual(math.hypot(x - cx, y - cy), radius, delta=1)
self.assertEqual((xs[0], ys[0]), (xs[-1], ys[-1]), "closed")
choose("arrange")
right, bottom = max(xs), max(ys)
firefox.drag("#page", px(right) - 3, py(bottom) - 3, px(right) + 90, py(bottom) + 30)
resized = self.wait_until(lambda s: strokes(s)[-1]["points"] != circle["points"], "the circle resized")
rx, ry = strokes(resized)[-1]["points"][0::3], strokes(resized)[-1]["points"][1::3]
ncx, ncy = (min(rx) + max(rx)) / 2, (min(ry) + max(ry)) / 2
nr = (max(rx) - min(rx)) / 2
self.assertGreater(nr, radius + 40 / scale, "the radius grew by half the drag")
self.assertAlmostEqual(max(ry) - min(ry), max(rx) - min(rx), delta=1, msg="still round")
for x, y in zip(rx, ry):
self.assertAlmostEqual(math.hypot(x - ncx, y - ncy), nr, delta=1)
def test_eraser_preview(self):
"""Andy, 2026-10-14: with the Eraser chosen, a red box around each
element a press under the hovering pointer would erase: a stroke by
the ink's extent, a text block, an image a tap would remove; none
on empty paper, none with another tool."""
firefox = self.firefox
firefox.go(self.address("Host"))
self.enter_name("Eraser", "start_session")
self.wait_until(lambda s: s["page"]["number"] == 1, "the page")
choose = lambda tool: firefox.run(f"document.querySelector('[data-tool={tool}]').click();")
cues = lambda: firefox.run("const p = document.getElementById('page').getBoundingClientRect(); return [...document.querySelectorAll('.erase_cue')]"
".map((c) => { const b = c.getBoundingClientRect(); return [b.left - p.left, b.top - p.top, b.right - p.left, b.bottom - p.top, getComputedStyle(c).borderTopColor]; });")
firefox.run(PEN_WAVE, 0.3, 0.5)
choose("text")
firefox.run(TEXT_GESTURE, 0.1, 0.7, 0.3, 0.8)
firefox.type_into(".text_field", "Box")
choose("pen")
firefox.run_until_done(LOAD_IMAGE, 300, 200, "png", 0, False)
state = self.wait_until(lambda s: len(s["layers"]["Eraser"]) == 1 and s["layers"]["Eraser"][0]["done"] and s["texts"]["Eraser"]
and s["texts"]["Eraser"][0]["text"] == "Box" and len(s["images"]) == 1 and s["image"]["ready"], "the stroke, the box and the image")
origin = state["origin"]
scale = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;")
px = lambda x: (x - origin[0]) * scale
py = lambda y: (y - origin[1]) * scale
hover_at = lambda x, y: firefox.run(HOVER, (x - origin[0]) / state["page"]["width"], (y - origin[1]) / state["page"]["height"])
stroke = state["layers"]["Eraser"][0]
p = stroke["points"]
halves = [stroke["width"] * (0.36 + 4 * q * q) / 2 for q in p[2::3]]
left = min(x - h for x, h in zip(p[0::3], halves))
right = max(x + h for x, h in zip(p[0::3], halves))
top = min(y - h for y, h in zip(p[1::3], halves))
bottom = max(y + h for y, h in zip(p[1::3], halves))
# With the pen, nothing; with the eraser, the stroke's ink boxed in red.
hover_at(p[0], p[1])
self.assertEqual(cues(), [])
choose("eraser")
hover_at(p[0], p[1])
boxes = cues()
self.assertEqual(len(boxes), 1)
self.assertEqual(boxes[0][4], "rgb(229, 57, 53)")
for got, want in zip(boxes[0], overlay_box(px(left), py(top), px(right), py(bottom), 2)):
self.assertAlmostEqual(got, want, delta=0.01)
# The text block, by its box; the image, by a tap's rule; empty
# paper, nothing; the cue goes when the pointer leaves the page.
box = state["texts"]["Eraser"][0]
hover_at(box["x"] + 20, box["y"] + 20)
boxes = cues()
self.assertEqual(len(boxes), 1)
self.assertTrue(firefox.run(HOVER_CUE)["hidden"], "no blue cue beside the red one")
for got, want in zip(boxes[0], overlay_box(px(box["x"]), py(box["y"]), px(box["x"] + box["width"]), py(box["y"] + box["height"]), 2)):
self.assertAlmostEqual(got, want, delta=0.01)
im = state["images"][0]
hover_at(im["x"] + im["width"] / 2, im["y"] + im["height"] / 2)
boxes = cues()
self.assertEqual(len(boxes), 1)
for got, want in zip(boxes[0], overlay_box(px(im["x"]), py(im["y"]), px(im["x"] + im["width"]), py(im["y"] + im["height"]), 2)):
self.assertAlmostEqual(got, want, delta=0.01)
hover_at(origin[0] + 0.9 * state["page"]["width"], origin[1] + 0.9 * state["page"]["height"])
self.assertEqual(cues(), [])
hover_at(p[0], p[1])
self.assertEqual(len(cues()), 1)
firefox.run("document.getElementById('page').dispatchEvent(new PointerEvent('pointerleave', {pointerId: 9, pointerType: 'mouse', bubbles: true}));")
self.assertEqual(cues(), [])
# Choosing another tool clears it.
hover_at(p[0], p[1])
self.assertEqual(len(cues()), 1)
choose("pen")
self.assertEqual(cues(), [])
def test_arrange_handwriting(self):
"""Andy, 2026-10-14: under Arrange a handwritten stroke gets its
box while the pointer hovers over its ink, kept while the pointer
stays within the box; a press on the ink moves it, on the box's
corner scales it; the hold with the pen leaves it alone."""
firefox = self.firefox
firefox.go(self.address("Host"))
self.enter_name("Scribe", "start_session") # Not "Writer": another test counts the pages kept under that name.
self.wait_until(lambda s: s["page"]["number"] == 1, "the page")
choose = lambda tool: firefox.run(f"document.querySelector('[data-tool={tool}]').click();")
cues = lambda: firefox.run("return [...document.querySelectorAll('.arrange_cue')].map((c) => c.className.split(' ')[1]);")
firefox.run(PEN_WAVE, 0.3, 0.5)
state = self.wait_until(lambda s: len(s["layers"]["Scribe"]) == 1 and s["layers"]["Scribe"][0]["done"], "the stroke")
origin = state["origin"]
scale = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;")
px = lambda x: (x - origin[0]) * scale
py = lambda y: (y - origin[1]) * scale
hover_at = lambda x, y: firefox.run(HOVER, (x - origin[0]) / state["page"]["width"], (y - origin[1]) / state["page"]["height"])
stroke = state["layers"]["Scribe"][0]
p = stroke["points"]
halves = [stroke["width"] * (0.36 + 4 * q * q) / 2 for q in p[2::3]]
left = min(x - h for x, h in zip(p[0::3], halves))
right = max(x + h for x, h in zip(p[0::3], halves))
top = min(y - h for y, h in zip(p[1::3], halves))
bottom = max(y + h for y, h in zip(p[1::3], halves))
choose("arrange")
self.assertEqual(cues(), [], "no box until the pointer is over the ink")
hover_at(p[0], p[1])
self.assertEqual(cues(), ["stroke"])
page_box = firefox.run("const b = document.getElementById('page').getBoundingClientRect(); return [b.left, b.top];")
got = firefox.run("const b = document.querySelector('.arrange_cue.stroke').getBoundingClientRect(); return [b.left, b.top, b.right, b.bottom];")
want = overlay_box(px(left), py(top), px(right), py(bottom), 2)
for side, g, w in zip(("left", "top", "right", "bottom"), (got[0] - page_box[0], got[1] - page_box[1], got[2] - page_box[0], got[3] - page_box[1]), want):
self.assertAlmostEqual(g, w, delta=0.01, msg=side)
# Off the ink but within the box, the box stays; outside it, it goes.
hover_at(left + 2, top + 2)
self.assertEqual(cues(), ["stroke"], "kept within the box")
hover_at(right + 200, bottom + 200)
self.assertEqual(cues(), [], "gone outside the box")
# A drag on the ink moves the whole stroke; Undo takes it back.
firefox.drag("#page", px(p[0]), py(p[1]), px(p[0]) + 120, py(p[1]) + 60)
moved = self.wait_until(lambda s: s["layers"]["Scribe"][0]["points"] != p, "the stroke moved")
q = moved["layers"]["Scribe"][0]["points"]
self.assertEqual(len(q), len(p))
self.assertAlmostEqual(q[0] - p[0], 120 / scale, delta=2)
self.assertAlmostEqual(q[1] - p[1], 60 / scale, delta=2)
self.assertEqual(q[2::3], p[2::3], "the pressures stay")
self.assertAlmostEqual(max(q[0::3]) - min(q[0::3]), max(p[0::3]) - min(p[0::3]), delta=0.3, msg="the same size")
firefox.run("document.getElementById('undo').click();")
self.wait_until(lambda s: s["layers"]["Scribe"][0]["points"] == p, "the move undone")
# A drag on the shown box's bottom-right corner scales the stroke.
hover_at(p[0], p[1])
self.assertEqual(cues(), ["stroke"])
firefox.drag("#page", px(right) + 3, py(bottom) + 3, px(right) + 150, py(bottom) + 40)
scaled = self.wait_until(lambda s: s["layers"]["Scribe"][0]["points"] != p, "the stroke scaled")
q = scaled["layers"]["Scribe"][0]["points"]
self.assertAlmostEqual(min(q[0::3]), min(p[0::3]), delta=1, msg="the left edge stays")
self.assertGreater(max(q[0::3]) - min(q[0::3]), max(p[0::3]) - min(p[0::3]) + 100 / scale)
self.assertEqual(scaled["layers"]["Scribe"][0]["width"], stroke["width"], "the line width unchanged")
# The hold with the pen draws a dot and leaves the stroke alone.
choose("pen")
self.assertEqual(cues(), [])
firefox.run_until_done(PEN_HOLD, px(q[0]), py(q[1]), 40, 20, 700)
held = self.wait_until(lambda s: len(s["layers"]["Scribe"]) == 2 and s["layers"]["Scribe"][1]["done"], "the pen's own stroke")
self.assertEqual(held["layers"]["Scribe"][0]["points"], q, "the handwriting not moved by a hold")
def test_page_menu_and_help(self):
"""Andy, 2026-10-14: the page and view commands are in a Page menu,
a popup of Kokkoja's own under the button, closed by a press on an
item, elsewhere, or Escape; Help shows a note for now; one popup
at a time."""
firefox = self.firefox
firefox.go(self.address("Late"))
self.enter_name("Menuist", "start_session")
self.wait_until(lambda s: s["page"]["number"] == 1, "the page")
hidden = lambda id: firefox.run(f"return document.getElementById('{id}').hidden;")
click = lambda id: firefox.run(f"document.getElementById('{id}').click();")
box = lambda id: firefox.run(f"const b = document.getElementById('{id}').getBoundingClientRect(); return [b.left, b.top, b.right, b.bottom];")
self.assertTrue(hidden("page_menu"))
self.assertIsNone(firefox.run("return document.querySelector('.page_view');"), "the middle group is gone")
# The Page button is the first of the right-hand group; Help the last of all.
self.assertEqual(firefox.run("return [...document.querySelectorAll('.toolbar button')].at(-1).id;"), "help")
self.assertEqual(firefox.run("return document.querySelector('.toolbar_right button').id;"), "page_menu_button")
# Open: below the button, left edges aligned, within the window.
click("page_menu_button")
self.assertFalse(hidden("page_menu"))
self.assertEqual(firefox.run("return document.getElementById('page_menu_button').getAttribute('aria-expanded');"), "true")
button, menu = box("page_menu_button"), box("page_menu")
self.assertAlmostEqual(menu[0], button[0], delta=1)
self.assertGreater(menu[1], button[3])
self.assertLess(menu[2], firefox.run("return window.innerWidth;"))
items = firefox.run("return [...document.querySelectorAll('#page_menu .menu_item')].filter((e) => !e.hidden).map((e) => e.textContent.trim());")
self.assertEqual(items, ["Fit to the window", "Normal scale", "Expand to the window",
"Shrink to the contents", "Extend the page while writing"])
# Two grey titles, over the first two sections, in the muted colour.
self.assertEqual(firefox.run("return [...document.querySelectorAll('#page_menu .menu_title')].map((e) => e.textContent);"),
["Change the display of the page", "Change the size of the page"])
self.assertEqual(firefox.run("const t = document.querySelector('#page_menu .menu_title'); return [getComputedStyle(t).color, getComputedStyle(t).fontStyle];"),
firefox.run("return [getComputedStyle(document.getElementById('undo')).color, 'normal'];"))
# An item's press runs it and closes the menu.
click("zoom_sheet")
self.assertTrue(hidden("page_menu"))
# The checkbox item toggles Extend and closes too.
click("page_menu_button")
self.assertTrue(firefox.run("return document.getElementById('extend').checked;"))
click("extend")
self.assertFalse(firefox.run("return document.getElementById('extend').checked;"))
self.assertTrue(hidden("page_menu"))
click("extend") # back on, for the tests that follow
# Escape closes; so does a press elsewhere.
click("page_menu_button")
self.assertFalse(hidden("page_menu"))
firefox.chord("\ue00c")
self.assertTrue(hidden("page_menu"))
click("page_menu_button")
firefox.run("document.getElementById('page').dispatchEvent(new PointerEvent('pointerdown', {bubbles: true}));")
self.assertTrue(hidden("page_menu"))
# Help: a note for now; opening it closes the menu, and the reverse.
click("page_menu_button")
click("help")
self.assertFalse(hidden("help_popup"))
self.assertTrue(hidden("page_menu"))
# At the far right, the note's right edge aligns with the button's, inside the window.
help_button, note = box("help"), box("help_popup")
self.assertAlmostEqual(note[2], help_button[2], delta=1)
self.assertLess(note[2], firefox.run("return window.innerWidth;"))
self.assertEqual(firefox.run("return document.getElementById('help_popup').textContent.trim();"), "Help is coming!")
click("page_menu_button")
self.assertTrue(hidden("help_popup"))
self.assertFalse(hidden("page_menu"))
firefox.chord("\ue00c")
# Ctrl-click on Help: the tooltips of every command as one text, in
# a window: the title, then each control in alphabetical order,
# its name on a line, its explanation wrapped at 80 columns, and
# a blank line after.
firefox.run("document.getElementById('help').dispatchEvent(new MouseEvent('click', {ctrlKey: true, bubbles: true}));")
self.assertTrue(firefox.run("return document.getElementById('manual_dialog').open;"))
self.assertTrue(hidden("help_popup"), "the note did not open as well")
manual = firefox.run("return document.getElementById('manual_text').textContent;")
self.assertTrue(manual.startswith("User interface actions\n\n"))
blocks = manual.split("\n\n")[1:]
names = [block.split("\n")[0] for block in blocks if block]
self.assertEqual(names, sorted(names, key=str.casefold), "alphabetical")
self.assertIn("Pen", names)
self.assertIn("Color", names) # the swatches as one entry
self.assertIn("Line width", names) # the widths as one entry
self.assertIn("Notebook for the pages of this session", names)
self.assertIn("QR code", names)
# A name two controls share gets each one's context: the nearest heading above it.
self.assertIn("Invitation: Copy", names)
self.assertIn("Log: Copy", names)
self.assertNotIn("Copy", names)
self.assertEqual(len(names), len(set(names)), "no two entries of one name")
self.assertIn("\nPen\nWrite on the page. Only your own writing can be erased.\n", manual)
self.assertIn("\nFit to the window\nShow the whole page in the window.", manual)
self.assertTrue(all(len(line) <= 80 for line in manual.split("\n")), "wrapped at 80")
self.assertTrue(any(len(line) > 60 for line in manual.split("\n")), "filled, not one word a line")
# Every tooltip is in it; those that differ only by a number (the
# line widths) as one entry, so the numbers are set aside.
helps = firefox.run("return [...document.querySelectorAll('[data-help]')].filter((e) => !e.closest('#manual_dialog')).map((e) => e.getAttribute('data-help'));")
unwrapped = re.sub(r"\d+(?:\.\d+)?", "#", manual.replace("\n", " "))
for help in set(helps):
self.assertIn(re.sub(r"\d+(?:\.\d+)?", "#", help), unwrapped, help[:40])
firefox.run("document.getElementById('manual_close').click();")
self.assertFalse(firefox.run("return document.getElementById('manual_dialog').open;"))
def test_shrink_ignores_the_invisible(self):
"""Andy, 2026-10-14: what cannot be seen does not count for Shrink:
strokes and free text in the paper's white, and the blank lines at
the end of a text block, which go when its typing ends (a box
grown by them shrinks back) and, for one's own, at Shrink."""
firefox = self.firefox
firefox.go(self.address("Late"))
self.enter_name("Shrinker", "start_session")
state = self.wait_until(lambda s: s["page"]["number"] == 1, "the page")
scale = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;")
origin = state["origin"]
px = lambda x: (x - origin[0]) * scale
py = lambda y: (y - origin[1]) * scale
swatch = lambda i: firefox.run(f"document.querySelectorAll('#colors .swatch')[{i}].click();")
# A black stroke, then a white stroke and white free text far to the
# right and below, where they would widen the shrunk page if counted.
firefox.drag("#page", px(500), py(500), px(900), py(520))
self.wait_until(lambda s: len(s["layers"]["Shrinker"]) == 1 and s["layers"]["Shrinker"][0]["done"], "the black stroke")
swatch(4)
firefox.drag("#page", px(2000), py(1500), px(2500), py(1600))
self.wait_until(lambda s: len(s["layers"]["Shrinker"]) == 2 and s["layers"]["Shrinker"][1]["done"], "the white stroke")
self.assertEqual(self.state()["layers"]["Shrinker"][1]["id"] and firefox.run(
"return [...window.kokkoja.session.ownLayer.strokes.values()][1].color;"), "#ffffff")
firefox.run("document.querySelector('[data-tool=text]').click();")
firefox.run(TEXT_GESTURE, px(2000) / state["page"]["width"] / scale, py(600) / state["page"]["height"] / scale,
px(2000) / state["page"]["width"] / scale, py(600) / state["page"]["height"] / scale)
firefox.type_into(".text_field", "unsichtbar")
# A small text rectangle, in black, grown by blank lines typed after
# the word: finishing removes them and the box shrinks back to its
# one line. Dragging it open finishes the white text first; the
# colour is chosen after, since a change restyles the block typed in.
fx = lambda x: px(x) / (state["page"]["width"] * scale)
fy = lambda y: py(y) / (state["page"]["height"] * scale)
firefox.run(TEXT_GESTURE, fx(500), fy(800), fx(1100), fy(830))
swatch(0)
firefox.type_into(".text_field", "Antwort\n\n\n\n")
grown = self.wait_until(lambda s: len(s["texts"]["Shrinker"]) == 2 and s["texts"]["Shrinker"][1]["text"] == "Antwort\n\n\n\n",
"the box typed in")
box = grown["texts"]["Shrinker"][1]
self.assertGreater(box["height"], 300, "grown by the blank lines: five lines of about 70 units")
self.assertEqual(box["height"], math.ceil(box["boxHeight"]))
firefox.run("document.querySelector('[data-tool=pen]').click();")
trimmed = self.wait_until(lambda s: s["texts"]["Shrinker"][1]["text"] == "Antwort", "the blank lines gone")
box = trimmed["texts"]["Shrinker"][1]
self.assertEqual(box["height"], math.ceil(box["boxHeight"]))
self.assertLess(box["height"], 80, "back to one line")
self.assertEqual(trimmed["texts"]["Shrinker"][0]["text"], "unsichtbar")
self.assertEqual(firefox.run("return [...window.kokkoja.session.ownLayer.texts.values()][0].color;"), "#ffffff")
# Shrink: the sheet holds the black stroke and the box with the
# margin, and nothing of the white marks.
before = self.state()["page"]
firefox.run("document.getElementById('zoom_shrink').click();")
shrunk = self.wait_until(lambda s: s["page"]["width"] < before["width"], "the shrunk page")
sheet = firefox.run("const p = window.kokkoja.session.page; return [p.baseLeft, p.baseTop, p.baseWidth, p.baseHeight];")
self.assertAlmostEqual(sheet[0], 500 - 14 - 50, delta=4) # the stroke's reach, then the margin
self.assertAlmostEqual(sheet[1], 500 - 14 - 50, delta=4)
self.assertAlmostEqual(sheet[0] + sheet[2], 1100 + 50, delta=4) # the box's right edge
self.assertAlmostEqual(sheet[1] + sheet[3], 800 + box["height"] + 50, delta=4)
# A box of one's own that still has blank lines at its end, as one
# from a page made before the trimming would, is trimmed by Shrink
# itself, as one gesture to undo. Finishing typing trims now, so
# such a block is made by script here, the way a loaded page is.
firefox.run("""
const s = window.kokkoja.session;
const b = [...s.ownLayer.texts.values()][1];
b.text += '\\n\\n\\n';
b.lines = [...b.lines, '', '', ''];
s.setText(b);
""")
self.wait_until(lambda s: s["texts"]["Shrinker"][1]["text"] == "Antwort\n\n\n", "the blank lines on the block")
firefox.run("document.getElementById('zoom_shrink').click();")
self.wait_until(lambda s: s["texts"]["Shrinker"][1]["text"] == "Antwort", "trimmed by Shrink")
firefox.run("document.getElementById('undo').click();")
self.wait_until(lambda s: s["texts"]["Shrinker"][1]["text"] == "Antwort\n\n\n", "the trim undone")
def test_text_being_typed_stays_on_the_page_left(self):
"""Andy, 2026-10-06: a guest was typing when the host pressed New,
and the text reappeared on the new page."""
firefox = self.firefox
host_tab = firefox.current_tab()
firefox.go(self.address("Late"))
self.enter_name("Lecturer", "start_session")
invitation = firefox.run("return document.getElementById('invitation').value;")
guest_tab = firefox.new_tab()
firefox.go(self.address("Student", join=invitation))
self.enter_name("Writer", "join_session")
self.wait_until(lambda s: s["page"]["number"] == 1 and len(s["participants"]) == 2,
"the writer to be in the session")
# The writer is in the middle of typing when the host starts a new page.
firefox.run("document.querySelector('[data-tool=text]').click();")
firefox.run(TEXT_GESTURE, 0.2, 0.3, 0.2, 0.3)
firefox.type_into(".text_field", "unfinished")
self.wait_until(lambda s: s["texts"].get("Writer") and s["texts"]["Writer"][0]["text"] == "unfinished",
"the writer's text")
firefox.switch_to(host_tab)
self.wait_until(lambda s: s["texts"].get("Writer") and s["texts"]["Writer"][0]["text"] == "unfinished",
"the host to receive the text")
firefox.run("document.getElementById('new_page').click();")
self.wait_until(lambda s: s["page"]["number"] == 2, "the host's new page")
# The new page is empty for both, and stays so; the text is on
# the kept page in both notebooks.
firefox.switch_to(guest_tab)
self.wait_until(lambda s: s["page"]["number"] == 2, "the writer's new page")
self.assertTrue(firefox.run("return document.querySelector('.text_field').hidden;"))
time.sleep(1)
self.assertEqual(self.state()["texts"], {"Lecturer": [], "Writer": []})
firefox.switch_to(host_tab)
self.assertEqual(self.state()["texts"], {"Lecturer": [], "Writer": []})
today = time.strftime("%Y_%m_%d")
for notebook in ("Lecturer notebook", "Writer notebook"):
self.assertEqual(self.wait_for_kept(notebook, 1), [f"{today}_01.svg"])
self.assertIn("unfinished", (self.notebooks / notebook / f"{today}_01.svg").read_text())
self.current_page("Lecturer notebook", {}, number=2)
self.assertNotIn("unfinished", (self.notebooks / "Lecturer notebook" / "Current.svg").read_text())
# "Extend" off: a stroke stops short of the page's edge and the page
# does not grow; on again, writing near the edge grows it.
extend = lambda: firefox.run("return document.getElementById('extend').checked;")
self.assertTrue(extend())
firefox.run("document.getElementById('extend').click();")
self.assertFalse(extend())
width = self.state()["page"]["width"]
box = firefox.run("const b = document.getElementById('page').getBoundingClientRect();"
"return [b.width, b.height];")
firefox.drag("#page", box[0] - 60, 300, box[0] - 2, 300)
stopped = self.wait_until(lambda s: s["layers"]["Lecturer"] and s["layers"]["Lecturer"][-1]["done"],
"the stroke at the edge")
self.assertEqual(stopped["page"]["width"], width)
self.assertLessEqual(max(stopped["layers"]["Lecturer"][-1]["points"][0::3]), width - 50)
firefox.run("document.getElementById('extend').click();")
self.assertTrue(extend())
firefox.drag("#page", box[0] - 60, 350, box[0] - 2, 350)
grown = self.wait_until(lambda s: s["page"]["width"] > width, "the page to grow with Extend on")
# An image goes to the top of the page as it stands: grown upward
# by writing, the page's top is above the sheet's, and the image
# lies the margin below that top, not below the old one (Andy,
# 2026-10-08).
firefox.drag("#page", 300, 4, 400, 4)
upward = self.wait_until(lambda s: s["origin"][1] < 0, "the page to grow upward")
firefox.run_until_done(LOAD_IMAGE, 300, 200, "png", 0, False)
placed = self.wait_until(lambda s: s["image"] and s["image"]["ready"], "the image on the grown page")
self.assertEqual(placed["placed"]["y"], upward["origin"][1] + 50)
self.assertEqual(placed["placed"]["x"], upward["origin"][0] + (upward["page"]["width"] - 300) // 2)
# Expand on a page that writing has grown on one side: the page
# comes out as wide and as tall as the window, measured from the
# page's own edges, not the sheet's (Andy, 2026-10-08, 2026-10-11).
firefox.run("""
document.getElementById('desk').dispatchEvent(new WheelEvent('wheel',
{deltaY: 100, ctrlKey: true, clientX: 300, clientY: 300, bubbles: true, cancelable: true}));
""")
firefox.run("document.getElementById('zoom_expand').click();")
self.wait_until(lambda s: s["page"]["width"] > grown["page"]["width"]
and s["page"]["height"] > grown["page"]["height"], "the page to expand")
measured = firefox.run("""
const desk = document.getElementById('desk');
const style = getComputedStyle(desk);
const page = document.getElementById('page').getBoundingClientRect();
return {width: page.width, height: page.height,
room: desk.offsetWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight),
roomHeight: desk.offsetHeight - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom)};
""")
self.assertAlmostEqual(measured["width"], measured["room"], delta=1)
self.assertAlmostEqual(measured["height"], measured["roomHeight"], delta=1)
# The writer's tab is closed for the next test; the host's is cleared.
firefox.switch_to(guest_tab)
firefox.in_session("DELETE", "/window")
firefox.switch_to(host_tab)
firefox.go("about:blank")
2026-10-06 15:58:02 +02:00
def test_two_participants(self):
firefox = self.firefox
# The teacher starts a session and draws two lines with the mouse.
teacher_tab = firefox.current_tab()
firefox.go(self.address("Teacher"))
self.assertNotIn("token", firefox.run("return location.href;"))
self.enter_name("Teacher", "start_session")
# Without an invitation the start screen offered both choices.
self.assertFalse(firefox.run("return document.getElementById('start_choice').hidden;"))
2026-10-06 15:58:02 +02:00
invitation = firefox.run("return document.getElementById('invitation').value;")
self.assertRegex(invitation, r"^ws://127\.0\.0\.1:\d+#[\w-]{22}\.[\w-]{22}$")
firefox.drag("#page", 100, 100, 400, 180)
firefox.drag("#page", 100, 250, 400, 230)
teacher = self.wait_until(
lambda s: len(s["layers"]["Teacher"]) == 2 and all(k["done"] for k in s["layers"]["Teacher"]),
"the teacher's two strokes")
self.assertGreaterEqual(len(teacher["layers"]["Teacher"][0]["points"]), 6)
# The student joins later, from the invitation, and receives them.
student_tab = firefox.new_tab()
firefox.go(self.address("Student", join=invitation))
self.wait_until_true(lambda: firefox.run("return document.getElementById('invitation_entry').value;"),
lambda v: v == invitation, "the invitation in the start screen")
2026-10-06 15:58:02 +02:00
self.enter_name("Student", "join_session")
student = self.wait_until(lambda s: len(s["layers"].get("Teacher", [])) == 2,
"the student to receive the teacher's layer")
self.assertEqual(student["layers"]["Teacher"], teacher["layers"]["Teacher"])
teacher_first_page = {"Teacher": teacher["layers"]["Teacher"]}
self.assertEqual([p["host"] for p in student["participants"]], [False, True])
# The student writes with a pen; the teacher sees the same strokes.
for fx in (0.15, 0.4, 0.65):
firefox.run(PEN_WAVE, fx, 0.45)
student = self.state()
self.assertEqual(len(student["layers"]["Student"]), 3)
pressures = student["layers"]["Student"][0]["points"][2::3]
self.assertGreater(max(pressures) - min(pressures), 0.5)
firefox.switch_to(teacher_tab)
teacher = self.wait_until(
lambda s: len(s["layers"].get("Student", [])) == 3 and all(k["done"] for k in s["layers"]["Student"]),
"the teacher to receive the student's strokes")
self.assertEqual(teacher["layers"]["Student"], student["layers"]["Student"])
# The student erases the middle stroke.
firefox.switch_to(student_tab)
box = firefox.run("const b = document.getElementById('page').getBoundingClientRect();"
"return [b.width, b.height];")
firefox.run(ERASE_THROUGH, 0.4 + 75 / box[0], 0.45)
student = self.state()
self.assertEqual(len(student["layers"]["Student"]), 2)
firefox.switch_to(teacher_tab)
teacher = self.wait_until(lambda s: len(s["layers"]["Student"]) == 2,
"the erasure to reach the teacher")
self.assertEqual(teacher["layers"]["Student"], student["layers"]["Student"])
# The teacher cannot erase the student's writing: the eraser acts
# only on one's own layer.
firefox.run(ERASE_THROUGH, 0.15 + 75 / box[0], 0.45)
self.assertEqual(len(self.state()["layers"]["Student"]), 2)
# The log in the Session window says so: the tool changed, and
# the release erased nothing.
firefox.run("document.getElementById('session_button').click();")
shown = firefox.run("return document.getElementById('event_log').textContent;")
firefox.run("document.getElementById('session_close').click();")
self.assertRegex(shown, r"\n\S+ Tool: eraser\n")
self.assertRegex(shown, r"\n\S+ pointerup pen \d+, erased 0 strokes and 0 texts\n")
2026-10-06 15:58:02 +02:00
# The student reloads the page and gets everything back, as the
# same participant.
firefox.switch_to(student_tab)
firefox.run("location.reload();")
time.sleep(0.5)
reloaded = self.wait_until(
lambda s: len(s["layers"].get("Student", [])) == 2 and len(s["layers"].get("Teacher", [])) == 2,
"the student's page after a reload")
self.assertEqual(reloaded["layers"]["Student"], student["layers"]["Student"])
self.assertEqual(len(reloaded["participants"]), 2)
# Each participant's notebook holds the page, with both layers.
counts = {"Teacher": 2, "Student": 2}
self.current_page("Student notebook", counts, number=1)
saved = self.current_page("Teacher notebook", counts, number=1)
self.assertTrue(saved.startswith('<?xml version="1.0" encoding="UTF-8"?>\n<svg '))
self.assertEqual(self.kept_pages("Teacher notebook"), [])
# The page file reads back as exactly the page that was written.
firefox.switch_to(teacher_tab)
self.assertTrue(firefox.run("""
return (async () => {
const {pageToSvg, pageFromSvg} = await import('./js/page_file.js');
const session = window.kokkoja.session;
const text = pageToSvg(session.page, {session: session.roomId, hostId: session.hostId});
const read = pageFromSvg(text);
const strokes = (page) => JSON.stringify([...page.layers.values()]
.map((layer) => [layer.id, layer.name, [...layer.strokes.values()]])
.sort());
return read.hostId === session.hostId && read.session === session.roomId &&
JSON.stringify(read.page.descriptor()) === JSON.stringify(session.page.descriptor()) &&
strokes(read.page) === strokes(session.page);
})();
"""))
# The student keeps the page. Asking again makes no second copy.
today = time.strftime("%Y_%m_%d")
firefox.switch_to(student_tab)
firefox.run("document.getElementById('keep_page').click();")
self.assertEqual(self.wait_for_kept("Student notebook", 1), [f"{today}_01.svg"])
firefox.run("document.getElementById('keep_page').click();")
time.sleep(1)
self.assertEqual(self.kept_pages("Student notebook"), [f"{today}_01.svg"])
self.assertIn("Already kept", firefox.run(
"return document.getElementById('notice').textContent;"))
2026-10-06 15:58:02 +02:00
# Only the host can start a new page.
2026-10-11 17:34:24 +02:00
self.assertTrue(firefox.run("return document.getElementById('new_page').hidden;"))
2026-10-06 15:58:02 +02:00
firefox.switch_to(teacher_tab)
2026-10-11 17:34:24 +02:00
self.assertFalse(firefox.run("return document.getElementById('new_page').hidden;"))
2026-10-06 15:58:02 +02:00
# The host starts a new page. Everyone moves to it, and everyone's
# old page is kept without anyone asking: the teacher's now, the
# student's already.
firefox.run("document.getElementById('new_page').click();")
self.assertEqual(self.state()["layers"], {"Teacher": [], "Student": []})
self.assertEqual(self.wait_for_kept("Teacher notebook", 1), [f"{today}_01.svg"])
kept = (self.notebooks / "Teacher notebook" / f"{today}_01.svg").read_text()
self.assertEqual(self.stroke_counts(kept), counts)
self.assertIn('data-number="1"', kept)
self.current_page("Teacher notebook", {}, number=2)
self.current_page("Student notebook", {}, number=2)
self.assertEqual(self.kept_pages("Student notebook"), [f"{today}_01.svg"])
firefox.switch_to(student_tab)
self.assertEqual(firefox.run("return document.getElementById('page_number').textContent;"),
"Page 2")
# Writing continues on the new page.
firefox.run(PEN_WAVE, 0.2, 0.3)
student = self.state()
self.assertEqual(len(student["layers"]["Student"]), 1)
firefox.switch_to(teacher_tab)
teacher = self.wait_until(
lambda s: len(s["layers"].get("Student", [])) == 1 and s["layers"]["Student"][0]["done"],
"the student's stroke on the new page")
self.assertEqual(teacher["layers"]["Student"], student["layers"]["Student"])
self.current_page("Teacher notebook", {"Student": 1}, number=2)
# The host starts a new page and puts an image on it. The page that
# was being written is kept in each notebook.
size = firefox.run_until_done(LOAD_IMAGE, 300, 400, "png", 0, True)
teacher = self.wait_until(lambda s: s["page"]["number"] == 3, "the teacher's image page")
# The image lies at the top of an A4 sheet, centred across it,
# inside the 5 mm margin, a pixel being 0.1 mm.
self.assertEqual(teacher["page"], {"number": 3, "width": 2970, "height": 2100})
self.assertEqual(teacher["placed"], {"x": 1335, "y": 50, "width": 300, "height": 400})
2026-10-06 15:58:02 +02:00
self.assertEqual(teacher["image"]["type"], "image/png")
self.assertEqual(teacher["image"]["size"], size)
self.assertTrue(teacher["image"]["ready"] and teacher["image"]["shown"])
self.assertEqual(self.wait_for_kept("Teacher notebook", 2),
[f"{today}_01.svg", f"{today}_02.svg"])
self.current_page("Teacher notebook", {}, number=3, images=1)
# Zoom is each person's own. Ctrl (or Command) and the wheel zoom
# by 10% about the pointer; Ctrl and a pen drag zoom continuously,
# a factor of 2 per 100 pixels, nothing drawn; "Fit" shows the
# whole page; "Widen" makes it as wide as the window with the top
# of the view staying put. None of it changes the page.
2026-10-06 15:58:02 +02:00
zoom = lambda: firefox.run("""
const r = window.kokkoja.renderer;
const page = document.getElementById('page').getBoundingClientRect();
const desk = document.getElementById('desk');
return {zoom: r.zoom, width: page.width, top: desk.scrollTop,
strokes: window.kokkoja.session.ownLayer.strokes.size};
2026-10-06 15:58:02 +02:00
""")
before = zoom()
self.assertIsInstance(before["zoom"], float)
for modifier in ("ctrlKey", "metaKey"):
firefox.run("""
document.getElementById('desk').dispatchEvent(new WheelEvent('wheel',
{deltaY: -100, [arguments[0]]: true, clientX: 300, clientY: 300, bubbles: true, cancelable: true}));
""", modifier)
self.assertAlmostEqual(zoom()["width"], before["width"] * 1.21, delta=1)
firefox.run("document.getElementById('zoom_fit').click();")
self.assertEqual(zoom(), before)
# A pen drag of 100 pixels up with Ctrl doubles the scale, 4 pixels
# of it being the dead zone; a tap with Ctrl changes nothing.
firefox.run(CTRL_DRAG, 0.5, 0.6, 0.5, 0.6)
self.assertEqual(zoom(), before)
firefox.run(CTRL_DRAG, 0.5, 0.7, 0.5, 0.7, -104)
doubled = zoom()
self.assertAlmostEqual(doubled["width"], before["width"] * 2, delta=2)
self.assertEqual(doubled["strokes"], before["strokes"])
firefox.run(CTRL_DRAG, 0.5, 0.3, 0.5, 0.3, 104)
self.assertAlmostEqual(zoom()["width"], before["width"], delta=2)
# The host's Fit and Normal scale reach the guest, who then fits the page to
# the guest's own window; the guest's own Fit stays the guest's.
firefox.switch_to(student_tab)
2026-10-06 15:58:02 +02:00
firefox.run("""
document.getElementById('desk').dispatchEvent(new WheelEvent('wheel',
{deltaY: -100, ctrlKey: true, clientX: 300, clientY: 300, bubbles: true, cancelable: true}));
""")
guest_zoomed = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;")
firefox.switch_to(teacher_tab)
2026-10-06 15:58:02 +02:00
firefox.run("document.getElementById('zoom_fit').click();")
firefox.switch_to(student_tab)
self.wait_until_true(lambda: firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;"),
lambda v: v < guest_zoomed, "the guest's view to follow the host's Fit")
firefox.run("document.getElementById('zoom_sheet').click();")
firefox.switch_to(teacher_tab)
host_scale = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;")
time.sleep(0.5)
self.assertEqual(firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;"), host_scale)
# Expand makes the sheet fill the window, for everyone, at this
# zoom, in both directions; the scale and what is in view stay.
firefox.run("""
document.getElementById('desk').dispatchEvent(new WheelEvent('wheel',
{deltaY: 100, ctrlKey: true, clientX: 300, clientY: 300, bubbles: true, cancelable: true}));
""")
smaller = zoom()
image_box = lambda: firefox.run("""
const b = document.querySelector('.page_image').getBoundingClientRect();
return [Math.round(b.left), Math.round(b.top), Math.round(b.width)];
""")
box_before = image_box()
firefox.run("document.getElementById('zoom_expand').click();")
expanded = zoom()
# The sheet grew equally on every side: nothing on it moved on screen.
self.assertEqual(image_box(), box_before)
self.assertLess(firefox.run("return window.kokkoja.session.page.baseLeft;"), 0)
self.assertLess(firefox.run("return window.kokkoja.session.page.baseTop;"), 0)
room = firefox.run("""
const desk = document.getElementById('desk');
const style = getComputedStyle(desk);
return {width: desk.offsetWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight),
height: desk.offsetHeight - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom)};
""")
page_box = firefox.run("""
const b = document.getElementById('page').getBoundingClientRect();
return {width: b.width, height: b.height};
""")
self.assertAlmostEqual(page_box["width"], room["width"], delta=1)
self.assertAlmostEqual(page_box["height"], room["height"], delta=1)
self.assertEqual(expanded["zoom"], smaller["zoom"])
wide_page = self.state()["page"]
self.assertGreater(wide_page["width"], teacher["page"]["width"])
self.assertGreater(wide_page["height"], teacher["page"]["height"])
firefox.switch_to(student_tab)
self.wait_until(lambda s: s["page"] == wide_page, "the expanded page to reach the student")
self.assertTrue(firefox.run("return document.getElementById('zoom_expand').hidden;"))
self.assertEqual(firefox.run("return window.kokkoja.session.page.baseLeft;"),
-((wide_page["width"] - teacher["page"]["width"]) // 2))
# The page file carries the sheet's edges and reads back with them.
expanded_file = self.current_page("Student notebook", {}, number=3)
self.assertRegex(expanded_file, r'data-base="\d+ \d+ -\d+ -\d+"')
firefox.switch_to(teacher_tab)
# Shrink makes the sheet just large enough for what is on it, with
# the margin around, on the margin's grid; everyone gets it, and
# the file carries the sheet's edges.
firefox.run("document.getElementById('zoom_shrink').click();")
shrunk_page = self.wait_until(lambda s: s["page"]["width"] < wide_page["width"], "the shrunk page")
sheet = firefox.run("""
const p = window.kokkoja.session.page;
return {left: p.baseLeft, top: p.baseTop, width: p.baseWidth, height: p.baseHeight,
pageLeft: p.left, pageTop: p.top, pageWidth: p.width, pageHeight: p.height,
images: p.images.map((i) => [i.x, i.y, i.width, i.height])};
""")
self.assertEqual((sheet["left"], sheet["top"], sheet["width"], sheet["height"]),
(sheet["pageLeft"], sheet["pageTop"], sheet["pageWidth"], sheet["pageHeight"]))
# Exactly the margin on every side of the contents: the image is at
# the top and the strokes lie within it horizontally.
x, y, w, h = sheet["images"][0]
self.assertEqual(sheet["top"], y - 50)
self.assertLessEqual(sheet["left"] + 50, x)
self.assertGreaterEqual(sheet["left"] + sheet["width"] - 50, x + w)
self.assertGreaterEqual(sheet["top"] + sheet["height"] - 50, y + h)
self.assertLess(sheet["height"], 2100)
firefox.switch_to(student_tab)
self.wait_until(lambda s: s["page"] == shrunk_page["page"], "the shrunk page to reach the student")
self.wait_until_true(lambda: self.current_page("Student notebook", {}, number=3),
lambda text: f'data-base="{sheet["width"]} {sheet["height"]} {sheet["left"]} {sheet["top"]}"' in text,
"the shrunk sheet in the student's file")
firefox.switch_to(teacher_tab)
# "Normal scale" ("A4" until 2026-10-14) shows the shrunk page at the scale a new page has: a blank
# sheet fitted to the window, so the page is smaller than it.
firefox.run("document.getElementById('zoom_fit').click();")
fitted_scale = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;")
firefox.run("document.getElementById('zoom_sheet').click();")
sheet_scale = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;")
self.assertLess(sheet_scale, fitted_scale)
self.assertAlmostEqual(sheet_scale, min(room["width"] / 2970, firefox.run("""
const desk = document.getElementById('desk');
const style = getComputedStyle(desk);
return desk.offsetHeight - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom);
""") / 2100), places=4)
# Expanded again, from the shrunk sheet, for the room the rest needs.
firefox.run("document.getElementById('zoom_fit').click();")
firefox.run("document.getElementById('zoom_expand').click();")
self.wait_until(lambda s: s["page"]["width"] > shrunk_page["page"]["width"], "the page expanded again")
firefox.run("document.getElementById('zoom_fit').click();")
teacher = self.state()
2026-10-06 15:58:02 +02:00
# Resting the pointer on a button brings up its explanation below it.
tooltip = lambda: firefox.run("""
const tip = document.querySelector('.tooltip');
const button = document.getElementById('new_page').getBoundingClientRect();
if (!tip || tip.hidden) return null;
const box = tip.getBoundingClientRect();
return {text: tip.textContent, below: box.top > button.bottom, left: box.left, buttonLeft: button.left};
""")
2026-10-11 18:05:49 +02:00
# One second in the button before the tooltip shows (two until
# 2026-10-11, one until 2026-10-06).
2026-10-06 15:58:02 +02:00
firefox.hover("#new_page")
2026-10-11 18:05:49 +02:00
time.sleep(0.5)
2026-10-06 15:58:02 +02:00
self.assertIsNone(tooltip())
2026-10-11 18:05:49 +02:00
time.sleep(0.8)
2026-10-06 15:58:02 +02:00
shown = tooltip()
self.assertEqual(shown["text"], firefox.run(
"return document.getElementById('new_page').getAttribute('data-help');"))
self.assertTrue(shown["below"])
self.assertAlmostEqual(shown["left"], shown["buttonLeft"], delta=1)
firefox.hover("#page")
self.assertIsNone(tooltip())
# A tooltip goes away as soon as a key is pressed.
firefox.hover("#new_page")
time.sleep(2.5)
2026-10-06 15:58:02 +02:00
self.assertIsNotNone(tooltip())
firefox.run("document.dispatchEvent(new KeyboardEvent('keydown', {key: 'a', bubbles: true}));")
self.assertIsNone(tooltip())
# Focus by a click brings no tooltip (it would cover the notebook
# list); focus from the keyboard does.
firefox.run("""
const pick = document.getElementById('session_notebook');
pick.dispatchEvent(new PointerEvent('pointerdown', {bubbles: true}));
pick.focus();
""")
self.assertIsNone(tooltip())
firefox.run("document.getElementById('session_notebook').blur();")
time.sleep(0.6)
firefox.run("document.getElementById('session_notebook').focus();")
self.assertIsNotNone(tooltip())
firefox.run("document.getElementById('session_notebook').blur();")
self.assertIsNone(tooltip())
2026-10-06 15:58:02 +02:00
# Text. A tap with the text tool places the corner of a block;
# typing goes through a real field, umlauts and line breaks
# included, and reaches the others as it is typed.
firefox.run("document.querySelector('[data-tool=text]').click();")
firefox.run(TEXT_GESTURE, 0.1, 0.6, 0.1, 0.6)
self.assertFalse(firefox.run("return document.querySelector('.text_field').hidden;"))
firefox.type_into(".text_field", "Grüße\nzwei Zeilen")
typed = self.wait_until(lambda s: len(s["texts"]["Teacher"]) == 1 and
s["texts"]["Teacher"][0]["text"] == "Grüße\nzwei Zeilen",
"the typed text")
block = typed["texts"]["Teacher"][0]
self.assertEqual(block["lines"], ["Grüße", "zwei Zeilen"])
self.assertIsNone(block["width"])
# The field shows every line and no spare one, with room above the first.
self.assertTrue(firefox.run("""
const field = document.querySelector('.text_field');
const style = getComputedStyle(field);
const lineHeight = parseFloat(style.fontSize) * 1.25;
const content = parseFloat(style.height);
return Math.abs(content - 2 * lineHeight) < 1 && parseFloat(style.paddingTop) > 2;
"""))
self.assertEqual(block["color"], "#000000")
2026-10-06 15:58:02 +02:00
self.assertEqual(block["font"], "sans-serif")
firefox.switch_to(student_tab)
self.wait_until(lambda s: s["texts"].get("Teacher") == [block],
"the text to reach the student")
firefox.switch_to(teacher_tab)
# With the text tool, a size and a font stand where the line widths
# were. Changing them changes the block being typed.
self.assertTrue(firefox.run("return document.getElementById('widths').hidden;"))
self.assertFalse(firefox.run("return document.getElementById('text_style').hidden;"))
self.assertEqual(block["size"], 49.4) # 14 points.
firefox.run("""
const size = document.getElementById('text_size');
size.value = '24';
size.dispatchEvent(new Event('change'));
const font = document.getElementById('text_font');
font.value = 'serif';
font.dispatchEvent(new Event('change'));
""")
restyled = self.wait_until(lambda s: s["texts"]["Teacher"][0]["size"] == 84.7 and
s["texts"]["Teacher"][0]["font"] == "serif", "the restyled text")
self.assertGreater(restyled["texts"]["Teacher"][0]["boxHeight"], block["boxHeight"])
firefox.run("""
const size = document.getElementById('text_size');
size.value = '14';
size.dispatchEvent(new Event('change'));
const font = document.getElementById('text_font');
font.value = 'sans-serif';
font.dispatchEvent(new Event('change'));
""")
block = self.wait_until(lambda s: s["texts"]["Teacher"][0]["size"] == 49.4,
"the text back at 14 points")["texts"]["Teacher"][0]
# A drag opens a text rectangle, shown dotted while dragging: a
# region in which the text wraps and which grows downward by lines
# as the text needs, marked by blue corners (until 2026-10-14 a red
# box, white inside, which the field drew as an outline). A hold
# at its corner resizes it freely; a hold inside moves it.
2026-10-06 15:58:02 +02:00
firefox.run("""
const page = document.getElementById('page');
const box = page.getBoundingClientRect();
page.setPointerCapture = () => {};
const fire = (type, x, y, buttons) => page.dispatchEvent(
new PointerEvent(type, {pointerId: 9, pointerType: 'mouse', isPrimary: true,
clientX: box.left + x, clientY: box.top + y, button: type === 'pointermove' ? -1 : 0,
buttons, bubbles: true, cancelable: true}));
fire('pointerdown', 0.1 * box.width, 0.75 * box.height, 1);
fire('pointermove', 0.25 * box.width, 0.8 * box.height, 1);
2026-10-06 15:58:02 +02:00
""")
preview = firefox.run("""
const p = document.querySelector('.rect_preview');
const b = p.getBoundingClientRect();
return {hidden: p.hidden, width: b.width, height: b.height};
2026-10-06 15:58:02 +02:00
""")
self.assertFalse(preview["hidden"])
self.assertGreater(preview["width"], 50)
self.assertGreater(preview["height"], 10)
firefox.run(TEXT_GESTURE, 0.1, 0.75, 0.6, 0.78)
self.assertTrue(firefox.run("return document.querySelector('.rect_preview').hidden;"))
self.assertEqual(firefox.run("return getComputedStyle(document.querySelector('.text_field')).outlineStyle;"), "none")
2026-10-06 15:58:02 +02:00
firefox.type_into(".text_field", "Ein langer Satz, der an der rechten Kante umbrochen wird.")
wrapped = self.wait_until(lambda s: len(s["texts"]["Teacher"]) == 2 and
len(s["texts"]["Teacher"][1]["lines"]) > 1, "the wrapped text")
wide = wrapped["texts"]["Teacher"][1]
self.assertGreater(wide["width"], 100)
self.assertIsNotNone(wide["height"])
self.assertGreaterEqual(wide["height"], wide["boxHeight"] - 1)
2026-10-06 15:58:02 +02:00
self.assertEqual(" ".join(wide["lines"]), wide["text"])
# More text than the box holds: it grows downward.
firefox.type_into(".text_field", " Und noch ein Satz, der die Box nach unten wachsen lässt, Zeile um Zeile.")
grown = self.wait_until(lambda s: s["texts"]["Teacher"][1]["height"] > wide["height"],
"the box to grow")["texts"]["Teacher"][1]
self.assertGreater(len(grown["lines"]), len(wide["lines"]))
# A hold on the field being typed in moves the box, typing going on
# afterwards; a tap elsewhere then finishes it and starts nothing.
scale = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;")
origin = self.state()["origin"]
px = lambda x: (x - origin[0]) * scale
py = lambda y: (y - origin[1]) * scale
firefox.run_until_done(FIELD_HOLD, px(grown["x"] + 80), py(grown["y"] + 40), 30 * scale, 50 * scale, 800)
nudged = self.wait_until(lambda s: s["texts"]["Teacher"][1]["y"] > grown["y"],
"the box to move while typed in")["texts"]["Teacher"][1]
self.assertFalse(firefox.run("return document.querySelector('.text_field').hidden;"))
firefox.type_into(".text_field", " Weiter.")
self.wait_until(lambda s: s["texts"]["Teacher"][1]["text"].endswith(" Weiter."), "typing to go on")
texts_before = len(self.state()["texts"]["Teacher"])
firefox.run(TEXT_GESTURE, 0.9, 0.95, 0.9, 0.95)
time.sleep(0.4)
self.assertTrue(firefox.run("return document.querySelector('.text_field').hidden;"))
self.assertEqual(len(self.state()["texts"]["Teacher"]), texts_before)
grown = self.state()["texts"]["Teacher"][1]
# A tap in the box's empty part edits the box, the cursor at the end
# of its text, rather than starting free text there.
fx = (grown["x"] + grown["width"] - 40 - origin[0]) / self.state()["page"]["width"]
fy = (grown["y"] + grown["height"] - 20 - origin[1]) / self.state()["page"]["height"]
firefox.run(TEXT_GESTURE, fx, fy, fx, fy)
self.assertEqual(firefox.run("""
const f = document.querySelector('.text_field');
return {hidden: f.hidden, rect: f.classList.contains('rect'), atEnd: f.selectionStart === f.value.length,
length: f.value.length, blocks: window.kokkoja.session.ownLayer.texts.size};
"""), {"hidden": False, "rect": True, "atEnd": True, "length": len(grown["text"]), "blocks": texts_before})
firefox.run("document.querySelector('[data-tool=pen]').click();")
firefox.run("document.querySelector('[data-tool=text]').click();")
firefox.run_until_done(PEN_HOLD, px(grown["x"] + grown["width"]) - 4,
py(grown["y"] + grown["height"]) - 4, 600 * scale, 0, 800)
widened = self.wait_until(lambda s: s["texts"]["Teacher"][1]["width"] > grown["width"],
"the box to widen")["texts"]["Teacher"][1]
self.assertLess(len(widened["lines"]), len(grown["lines"]))
self.assertEqual((widened["x"], widened["y"]), (grown["x"], grown["y"]))
firefox.run_until_done(PEN_HOLD, px(widened["x"] + 60), py(widened["y"] + 40), 40 * scale, 60 * scale, 800)
moved = self.wait_until(lambda s: s["texts"]["Teacher"][1]["y"] > widened["y"],
"the box to move")["texts"]["Teacher"][1]
self.assertGreater(moved["x"], widened["x"])
self.assertEqual((moved["width"], moved["height"]), (widened["width"], widened["height"]))
2026-10-06 15:58:02 +02:00
firefox.switch_to(student_tab)
self.wait_until(lambda s: len(s["texts"].get("Teacher", [])) == 2 and
s["texts"]["Teacher"][1] == moved, "the moved text to reach the student")
firefox.switch_to(teacher_tab)
# The page file holds the text, and reads it back.
saved_text = self.wait_until_true(
2026-10-06 15:58:02 +02:00
lambda: (self.notebooks / "Teacher notebook" / "Current.svg").read_text(),
lambda saved: "<text " in saved and "zwei Zeilen" in saved and "Weiter." in saved,
"the text to be saved")
# A text block draws nothing of its own on the page (Andy,
# 2026-10-14 and 2026-10-14: an implied rectangle, blue corners on
# screen while the pointer hovers; a red frame on a white ground
# and a red L on free text until then).
self.assertNotIn('#e53935', saved_text)
self.assertEqual(saved_text.count('<path d="M'), 0)
self.assertNotIn('data-ground', saved_text)
self.assertEqual(saved_text.count('<rect x='), 1) # The sheet alone.
2026-10-06 15:58:02 +02:00
round_trip = firefox.run("""
return (async () => {
const {pageToSvg, pageFromSvg} = await import('./js/page_file.js');
const session = window.kokkoja.session;
const read = pageFromSvg(pageToSvg(session.page, {session: session.roomId, hostId: session.hostId}));
const texts = (page) => JSON.stringify([...page.layers.values()]
.filter((l) => l.texts.size).map((l) => [l.id, [...l.texts.values()]]));
return texts(read.page) === texts(session.page) || 'READ ' + texts(read.page) + ' LIVE ' + texts(session.page);
})();
""")
if round_trip is not True:
self.fail(round_trip)
# The eraser removes a whole block of one's own.
firefox.run("document.querySelector('[data-tool=eraser]').click();")
page_box = firefox.run("const b = document.getElementById('page').getBoundingClientRect(); return [b.width, b.height];")
state = self.state()
first = state["texts"]["Teacher"][0]
fx = (first["x"] + 10 - state["origin"][0]) / state["page"]["width"]
fy = (first["y"] + 10 - state["origin"][1]) / state["page"]["height"]
2026-10-06 15:58:02 +02:00
firefox.run(ERASE_THROUGH, fx, fy + 20 / page_box[1])
self.wait_until(lambda s: len(s["texts"]["Teacher"]) == 1 and s["texts"]["Teacher"][0]["id"] == moved["id"],
"the first text to be erased")
firefox.run("document.querySelector('[data-tool=eraser]').click();")
self.assertTrue(firefox.run("""
return ['colors', 'widths', 'text_style'].every((id) => document.getElementById(id).hidden);
"""))
firefox.run("document.querySelector('[data-tool=pen]').click();")
self.assertFalse(firefox.run("return document.getElementById('widths').hidden;"))
self.assertFalse(firefox.run("return document.getElementById('colors').hidden;"))
self.assertTrue(firefox.run("return document.getElementById('text_style').hidden;"))
teacher = self.state() # The text may have grown the page.
firefox.switch_to(student_tab)
student = self.wait_until(lambda s: s["image"] and s["image"]["ready"] and s["page"] == teacher["page"],
"the image to reach the student")
self.assertEqual(student["image"], teacher["image"])
self.assertEqual(student["placed"], teacher["placed"])
self.assertEqual(self.wait_for_kept("Student notebook", 2),
[f"{today}_01.svg", f"{today}_02.svg"])
self.current_page("Student notebook", {}, number=3, images=1)
firefox.run(PEN_WAVE, 0.2, 0.3)
student = self.state()
# Someone who joins now gets the page, the image and the writing.
# Arriving with an invitation shows the start screen, where the name
# and the notebook are chosen.
firefox.new_tab()
firefox.go(self.address("Late", join=invitation))
self.assertIsNone(self.state())
self.wait_until_true(lambda: firefox.run("return document.getElementById('invitation_entry').value;"),
lambda v: v == invitation, "the invitation in the start screen")
2026-10-06 15:58:02 +02:00
late = self.enter_name("Late", "join_session")
late = self.wait_until(
lambda s: s["image"] and s["image"]["ready"] and len(s["layers"].get("Student", [])) == 1,
"the image and writing to reach the late joiner")
self.assertEqual(late["image"], teacher["image"])
self.assertEqual(late["layers"]["Student"], student["layers"]["Student"])
saved = self.current_page("Late notebook", {"Student": 1}, number=3)
self.assertRegex(saved, f'data-hash="{teacher["image"]["hash"]}" data-by="[A-Za-z0-9_-]{{22}}" x="1335" y="50" width="300" height="400"')
2026-10-06 15:58:02 +02:00
firefox.go("about:blank")
# Everyone leaves. The teacher comes back alone: the page, with its
# image, is restored from the notebook, and writing continues in the
# same file.
firefox.switch_to(student_tab)
firefox.go("about:blank")
firefox.switch_to(teacher_tab)
self.wait_until(lambda s: [p["present"] for p in s["participants"]] == [True, False, False],
"the others to leave")
firefox.run("location.reload();")
time.sleep(0.5)
alone = self.wait_until(lambda s: s["status"] == "Connected", "the teacher to reconnect")
self.assertEqual(alone["layers"], {"Student": student["layers"]["Student"], "Teacher": []})
self.assertEqual(alone["page"], teacher["page"])
self.assertEqual(alone["image"], teacher["image"])
self.assertEqual(alone["participants"], [
{"name": "Teacher", "host": True, "present": True},
{"name": "Student", "host": False, "present": False}])
firefox.run("document.getElementById('zoom_fit').click();") # The view came back scrolled.
2026-10-06 15:58:02 +02:00
firefox.drag("#page", 100, 500, 300, 520)
self.current_page("Teacher notebook", {"Teacher": 1, "Student": 1}, number=3)
self.assertEqual(len(self.kept_pages("Teacher notebook")), 2)
# Changing notebook writes the page there from then on. A new
# notebook is made from the picker's last entry and named in the
# field that takes its place; an existing one is simply chosen.
2026-10-06 15:58:02 +02:00
firefox.run("""
const pick = document.getElementById('session_notebook');
pick.value = '/new';
pick.dispatchEvent(new Event('change'));
const entry = document.getElementById('session_notebook_name');
if (entry.hidden || !pick.hidden) throw new Error('the name field did not appear');
entry.value = 'Other notebook';
entry.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', bubbles: true}));
if (!entry.hidden || pick.hidden) throw new Error('the name field did not go away');
2026-10-06 15:58:02 +02:00
""")
self.current_page("Other notebook", {"Teacher": 1, "Student": 1}, number=3)
# A refused name is explained and nothing changes.
self.assertIn("cannot contain", firefox.run("""
const pick = document.getElementById('session_notebook');
pick.value = '/new';
pick.dispatchEvent(new Event('change'));
const entry = document.getElementById('session_notebook_name');
entry.value = 'a/b';
entry.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', bubbles: true}));
const said = document.getElementById('notice').textContent;
entry.dispatchEvent(new KeyboardEvent('keydown', {key: 'Escape', bubbles: true}));
if (pick.value !== 'Other notebook') throw new Error('not back to the notebook: ' + pick.value);
return said;
"""))
self.assertEqual(firefox.run("""
const pick = document.getElementById('session_notebook');
return [...pick.options].map((o) => o.textContent);
""")[-1], "New notebook…")
2026-10-06 15:58:02 +02:00
firefox.run("""
const pick = document.getElementById('session_notebook');
pick.value = 'Teacher notebook';
pick.dispatchEvent(new Event('change'));
2026-10-06 15:58:02 +02:00
""")
time.sleep(1)
self.assertEqual(len(self.kept_pages("Teacher notebook")), 2)
# A new session in the same notebook: the page the last session
# left there is kept, without anyone having asked. (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")
2026-10-06 15:58:02 +02:00
firefox.run("location.reload();")
time.sleep(0.5)
self.enter_name("Teacher", "start_session")
self.assertEqual(self.wait_for_kept("Teacher notebook", 3)[-1], f"{today}_03.svg")
last = (self.notebooks / "Teacher notebook" / f"{today}_03.svg").read_text()
self.assertEqual(self.stroke_counts(last), {"Teacher": 1, "Student": 1})
self.assertIn("<image ", last)
self.assertEqual(self.state()["layers"], {"Teacher": []})
# A large image is made smaller and stored as JPEG; its natural size
# is still that of the original (0.1 mm a pixel, 36 by 24 cm), and it
# arrives at that size, centred, the sheet grown to hold it.
2026-10-06 15:58:02 +02:00
firefox.run_until_done(LOAD_IMAGE, 3600, 2400, "png", 0, True)
large = self.wait_until(lambda s: s["page"]["number"] == 2, "the large image page")
self.assertEqual(large["page"], {"number": 2, "width": 3700, "height": 2500})
self.assertEqual(large["placed"], {"x": -315, "y": 50, "width": 3600, "height": 2400})
2026-10-06 15:58:02 +02:00
self.assertEqual(large["image"]["type"], "image/jpeg")
self.assertLess(large["image"]["size"], 1500000)
self.assertEqual(firefox.run("""
const shown = document.querySelector('.page_image');
return [shown.naturalWidth, shown.naturalHeight];
"""), [2400, 1600])
# The student joins the new session. The host lets the student
# start pages, the student loads an image, and the host withdraws
# the permission.
invitation = firefox.run("return document.getElementById('invitation').value;")
firefox.switch_to(student_tab)
firefox.go(self.address("Student", join=invitation))
self.enter_name("Student", "join_session")
self.wait_until(lambda s: s["page"]["number"] == 2 and s["image"] and s["image"]["ready"],
"the student to reach the large image page")
2026-10-11 17:34:24 +02:00
self.assertTrue(firefox.run("return document.getElementById('new_page').hidden;"))
2026-10-06 15:58:02 +02:00
firefox.switch_to(teacher_tab)
self.assertEqual(firefox.run(TOGGLE_PERMISSION, "Student"), "true")
firefox.switch_to(student_tab)
self.wait_until(
2026-10-11 17:34:24 +02:00
lambda s: not firefox.run("return document.getElementById('new_page').hidden;"),
2026-10-06 15:58:02 +02:00
"the student to be allowed to start pages")
firefox.run_until_done(LOAD_IMAGE, 300, 400, "png", 0, True)
student = self.wait_until(lambda s: s["page"]["number"] == 3, "the student's image page")
self.assertEqual(student["page"], {"number": 3, "width": 2970, "height": 2100})
2026-10-06 15:58:02 +02:00
firefox.switch_to(teacher_tab)
teacher = self.wait_until(
lambda s: s["page"]["number"] == 3 and s["image"] and s["image"]["ready"],
"the student's image to reach the teacher")
self.assertEqual(teacher["image"], student["image"])
self.assertEqual(firefox.run(TOGGLE_PERMISSION, "Student"), "false")
firefox.switch_to(student_tab)
self.wait_until(
2026-10-11 17:34:24 +02:00
lambda s: firefox.run("return document.getElementById('new_page').hidden;"),
2026-10-06 15:58:02 +02:00
"the student's permission to be withdrawn")
firefox.run("window.kokkoja.session.newPage();")
time.sleep(1)
self.assertEqual(self.state()["page"]["number"], 3)
# Writing past the edge of the page makes the page grow, for
# everyone, in steps of 5 mm.
2026-10-06 15:58:02 +02:00
firefox.run(PEN_WAVE, 0.98, 0.5)
grown = self.wait_until(lambda s: s["page"]["width"] > 2970, "the page to grow")
self.assertEqual((grown["page"]["width"] - 2970) % 50, 0)
2026-10-06 15:58:02 +02:00
firefox.switch_to(teacher_tab)
self.wait_until(lambda s: s["page"] == grown["page"], "the teacher's page to grow the same way")
box = f'viewBox="0 0 {grown["page"]["width"]} {grown["page"]["height"]}"'
deadline = time.monotonic() + 10
while box not in (self.notebooks / "Teacher notebook" / "Current.svg").read_text():
self.assertLess(time.monotonic(), deadline, f"the saved page never got {box}")
time.sleep(0.1)
# An image that records its resolution is placed at that size: at
# 300 dots per inch, 1200 pixels are 101.6 mm. PNG and JPEG alike.
firefox.run_until_done(LOAD_IMAGE, 1200, 600, "png", 300, True)
scanned = self.wait_until(lambda s: s["page"]["number"] == 4, "the scanned PNG page")
self.assertEqual(scanned["placed"], {"x": 977, "y": 50, "width": 1016, "height": 508})
2026-10-06 15:58:02 +02:00
firefox.run_until_done(LOAD_IMAGE, 1200, 600, "jpeg", 300, True)
scanned = self.wait_until(lambda s: s["page"]["number"] == 5, "the scanned JPEG page")
self.assertEqual(scanned["image"]["type"], "image/jpeg")
self.assertEqual(scanned["placed"], {"x": 977, "y": 50, "width": 1016, "height": 508})
2026-10-06 15:58:02 +02:00
# A further image goes on the same page, below the lowest image
# there and the margin apart, centred like the first. The sheet
# grows downward when an image needs the room. Everyone gets them.
sheet_view = firefox.run(VIEW)
self.assertFalse(sheet_view["scrollsDown"])
2026-10-06 15:58:02 +02:00
firefox.run_until_done(LOAD_IMAGE, 300, 400, "png", 0, False)
self.assertEqual(firefox.run(VIEW), sheet_view) # The sheet did not grow.
firefox.run_until_done(LOAD_IMAGE, 1000, 2500, "png", 0, False)
stacked = self.wait_until(lambda s: len(s["images"]) == 3, "the three images")
# The view keeps its scale: the page now extends below the window,
# to be scrolled to, and is no longer fitted to it.
long_view = {"scale": sheet_view["scale"], "top": 0,
2026-10-06 15:58:02 +02:00
"scrollsDown": True, "scrollsAcross": False}
self.assertEqual(firefox.run(VIEW), long_view)
self.assertEqual(stacked["page"], {"number": 5, "width": 2970, "height": 3608})
2026-10-06 15:58:02 +02:00
self.assertEqual(
[[i["x"], i["y"], i["width"], i["height"]] for i in stacked["images"]],
[[977, 50, 1016, 508], [1335, 608, 300, 400], [985, 1058, 1000, 2500]])
2026-10-06 15:58:02 +02:00
self.assertTrue(all(i["ready"] and i["shown"] for i in stacked["images"]))
# Each is shown where it lies on the page.
self.assertEqual(firefox.run("""
const page = document.getElementById('page').getBoundingClientRect();
return [...document.querySelectorAll('.page_image')].map((img) => {
const box = img.getBoundingClientRect();
return [(box.left - page.left) / page.width, (box.top - page.top) / page.height,
box.width / page.width, box.height / page.height]
.map((v) => Math.round(v * 1000));
});
"""), [[round(v * 1000) for v in (i["x"] / 2970, i["y"] / 3608, i["width"] / 2970, i["height"] / 3608)]
for i in stacked["images"]])
2026-10-06 15:58:02 +02:00
firefox.switch_to(student_tab)
student = self.wait_until(
lambda s: len(s["images"]) == 3 and all(i["ready"] and i["shown"] for i in s["images"]),
"the three images to reach the student")
self.assertEqual(student["images"], stacked["images"])
self.assertEqual(student["page"], stacked["page"])
self.assertEqual(firefox.run(VIEW), long_view)
saved = self.current_page("Student notebook", {}, number=5, images=3)
# A reloaded tab shows the page as it was last seen there: zoomed
# and scrolled as it was, not fitted anew.
firefox.run("""
document.getElementById('desk').dispatchEvent(new WheelEvent('wheel',
{deltaY: -100, ctrlKey: true, clientX: 300, clientY: 300, bubbles: true, cancelable: true}));
2026-10-06 15:58:02 +02:00
document.getElementById('desk').scrollTop = 240;
""")
seen = firefox.run(VIEW)
self.assertEqual(seen["top"], 240)
2026-10-06 15:58:02 +02:00
self.assertGreater(seen["scale"], long_view["scale"])
firefox.run("location.reload();")
time.sleep(0.5)
self.wait_until(lambda s: s["status"] == "Connected" and len(s["images"]) == 3
and all(i["shown"] for i in s["images"]), "the student to return to the long page")
self.assertEqual(firefox.run(VIEW), seen)
firefox.run("document.getElementById('zoom_fit').click();")
fitted = firefox.run(VIEW)
self.assertFalse(fitted["scrollsDown"])
2026-10-06 15:58:02 +02:00
firefox.run("location.reload();")
time.sleep(0.5)
self.wait_until(lambda s: s["status"] == "Connected" and len(s["images"]) == 3,
"the student to return again")
self.assertEqual(firefox.run(VIEW), fitted)
self.assertIn('data-base="2970 3608"', saved)
2026-10-06 15:58:02 +02:00
# The page file reads back with its images, in their sequence.
self.assertTrue(firefox.run("""
return (async () => {
const {pageToSvg, pageFromSvg} = await import('./js/page_file.js');
const session = window.kokkoja.session;
const read = pageFromSvg(pageToSvg(session.page, {session: session.roomId, hostId: session.hostId}));
return JSON.stringify(read.page.descriptor()) === JSON.stringify(session.page.descriptor()) &&
read.page.images.every((image, i) => image.data.length === session.page.images[i].size);
})();
"""))
firefox.switch_to(teacher_tab)
# The host loads a page kept in a notebook: the first page of the
# first session, with the teacher's and the student's writing. It
# comes back as a new page for everyone, and the student's writing
# is the student's again.
firefox.run("document.getElementById('load_notebook_page').click();")
notebooks = self.wait_until_true(lambda: firefox.run("""
return [...document.querySelectorAll('#chooser_notebooks button.notebook_pick')].map((b) => b.textContent);
2026-10-06 15:58:02 +02:00
"""), lambda names: "Teacher notebook" in names, "the chooser to list the notebooks")
self.assertEqual(notebooks, sorted(notebooks))
firefox.run("""
[...document.querySelectorAll('#chooser_notebooks button.notebook_pick')]
2026-10-06 15:58:02 +02:00
.find((b) => b.textContent === 'Teacher notebook').click();
""")
cards = self.wait_until_true(lambda: firefox.run("""
return [...document.querySelectorAll('#chooser_pages .page_card span')].map((s) => s.textContent);
"""), lambda titles: len(titles) >= 3, "the chooser to list the kept pages")
self.assertEqual(cards[0], f"{today[:4]}-{today[5:7]}-{today[8:10]} (1)")
firefox.run("document.querySelector('#chooser_pages .page_card').click();")
loaded = self.wait_until(lambda s: s["page"]["number"] == 6, "the page loaded from the notebook")
self.assertFalse(firefox.run("return document.getElementById('notebook_chooser').open;"))
self.assertEqual({name: len(strokes) for name, strokes in loaded["layers"].items()},
{"Teacher": 2, "Student": 2})
self.assertEqual(loaded["layers"]["Teacher"], teacher_first_page["Teacher"])
firefox.switch_to(student_tab)
student = self.wait_until(
lambda s: s["page"]["number"] == 6 and len(s["layers"].get("Student", [])) == 2
and len(s["layers"].get("Teacher", [])) == 2,
"the loaded page to reach the student")
self.assertEqual(student["layers"], loaded["layers"])
page_width = firefox.run("return document.getElementById('page').getBoundingClientRect().width;")
firefox.run(ERASE_THROUGH, 0.15 + 75 / page_width, 0.45)
self.assertEqual(len(self.state()["layers"]["Student"]), 1)
firefox.switch_to(teacher_tab)
self.wait_until(lambda s: len(s["layers"]["Student"]) == 1,
"the student's erasure on the loaded page to reach the teacher")
# A kept page with an image comes back with the image, shown to
# everyone without further ado.
firefox.run("document.getElementById('load_notebook_page').click();")
self.wait_until_true(lambda: firefox.run("""
return document.querySelectorAll('#chooser_pages .page_card').length;
"""), lambda n: n >= 3, "the chooser to list the kept pages again")
firefox.run("document.querySelectorAll('#chooser_pages .page_card')[2].click();")
loaded = self.wait_until(lambda s: s["page"]["number"] == 7, "the loaded image page")
self.assertTrue(loaded["image"]["ready"] and loaded["image"]["shown"])
self.assertEqual(loaded["placed"], {"x": 1335, "y": 50, "width": 300, "height": 400})
2026-10-06 15:58:02 +02:00
firefox.switch_to(student_tab)
student = self.wait_until(lambda s: s["page"]["number"] == 7 and s["image"] and s["image"]["ready"],
"the loaded image to reach the student")
self.assertTrue(student["image"]["shown"])
self.assertEqual(firefox.run("""
const img = document.querySelector('.page_image');
const s = getComputedStyle(img);
return [img.naturalWidth, s.display, parseFloat(s.width) > 10];
"""), [300, "block", True])
firefox.switch_to(teacher_tab)
# The page of three images was kept when it was left, and comes
# back from the notebook with all three, for everyone.
firefox.run("document.getElementById('load_notebook_page').click();")
count = self.wait_until_true(lambda: firefox.run("""
return document.querySelectorAll('#chooser_pages .page_card').length;
"""), lambda n: n >= 4, "the chooser to list the kept pages a third time")
for card in range(count):
firefox.run("document.querySelectorAll('#chooser_pages .page_card')[arguments[0]].click();", card)
loaded = self.wait_until(lambda s: s["page"]["number"] == 8 + card, "a kept page to load")
if len(loaded["images"]) == 3:
break
firefox.run("document.getElementById('load_notebook_page').click();")
self.wait_until_true(lambda: firefox.run("""
return document.querySelectorAll('#chooser_pages .page_card').length;
"""), lambda n: n >= count, "the chooser to list the kept pages again")
self.assertEqual(loaded["images"], stacked["images"])
self.assertEqual(loaded["page"]["height"], 3608)
# Each page loaded on the way was left unchanged, so none was kept
# again (the file names a page anew at every load; the contents
# are compared without the names).
self.wait_until_true(
lambda: (self.notebooks / "Teacher notebook" / "Current.svg").read_text(),
lambda text: f'data-number="{loaded["page"]["number"]}"' in text, "the loaded page to be written")
self.assertEqual(len(self.kept_pages("Teacher notebook")), count)
# Deleting from the chooser: a mark on each page and notebook, one
# Delete button that asks for a second press, the entries moved
# to the Trash. The notebook being written in cannot be
# marked; another notebook goes with everything in it.
firefox.run("document.getElementById('load_notebook_page').click();")
self.wait_until_true(lambda: firefox.run("""
return document.querySelectorAll('#chooser_pages .page_slot button.mark').length;
"""), lambda n: n == count, "the chooser to list the kept pages with marks")
self.assertTrue(firefox.run("return document.getElementById('chooser_delete').hidden;"))
before = self.kept_pages("Teacher notebook")
firefox.run("""
const marks = document.querySelectorAll('#chooser_pages .page_slot button.mark');
marks[0].click(); marks[1].click(); marks[2].click(); marks[2].click();
""")
delete = lambda: firefox.run("""
const b = document.getElementById('chooser_delete');
return b.hidden ? null : b.textContent;
""")
self.assertEqual(delete(), "Delete 2 pages?")
self.assertEqual(firefox.run("""
return [...document.querySelectorAll('#chooser_pages .page_slot')].map((s) => s.classList.contains('marked'));
""")[:3], [True, True, False])
# A marked card is dimmed, and its mark must still be what a tap
# at the mark's place hits, so that the second tap unmarks and
# does not load the page (a scripted click would not tell).
self.assertEqual(firefox.run("""
return [...document.querySelectorAll('#chooser_pages .page_slot')].slice(0, 3).map((slot) => {
const r = slot.querySelector('button.mark').getBoundingClientRect();
return document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2).classList.contains('mark');
});
"""), [True, True, True])
firefox.run("document.getElementById('chooser_delete').click();")
self.assertEqual(delete(), "Press again to move 2 pages to the Trash")
# Marking something else takes the first press back.
firefox.run("document.querySelectorAll('#chooser_pages .page_slot button.mark')[2].click();")
self.assertEqual(delete(), "Delete 3 pages?")
firefox.run("document.getElementById('chooser_delete').click();")
firefox.run("document.getElementById('chooser_delete').click();")
self.wait_until_true(lambda: firefox.run("return document.getElementById('chooser_message').textContent;"),
lambda text: text == "3 pages moved to the Trash.", "the deletion to be reported")
self.assertEqual(self.kept_pages("Teacher notebook"), before[3:])
self.assertEqual(sorted(p.name for p in self.trash.iterdir()), before[:3])
self.assertEqual(firefox.run("return document.querySelectorAll('#chooser_pages .page_slot').length;"),
count - 3)
self.assertIsNone(delete())
# The notebook being written in: its mark explains instead.
firefox.run("""
[...document.querySelectorAll('#chooser_notebooks .notebook_row')]
.find((r) => r.querySelector('.notebook_pick').textContent === 'Teacher notebook')
.querySelector('button.mark').click();
""")
self.assertIn("writing in this notebook", firefox.run("return document.getElementById('chooser_message').textContent;"))
self.assertIsNone(delete())
# Another notebook, with its pages, goes as one; the toolbar's list loses it.
self.assertTrue((self.notebooks / "Other notebook" / "Current.svg").exists())
firefox.run("""
[...document.querySelectorAll('#chooser_notebooks .notebook_row')]
.find((r) => r.querySelector('.notebook_pick').textContent === 'Other notebook')
.querySelector('button.mark').click();
""")
self.assertEqual(delete(), "Delete 1 notebook?")
firefox.run("document.getElementById('chooser_delete').click();")
self.assertEqual(delete(), "Press again to move 1 notebook to the Trash")
firefox.run("document.getElementById('chooser_delete').click();")
self.wait_until_true(lambda: firefox.run("return document.getElementById('chooser_message').textContent;"),
lambda text: text == "1 notebook moved to the Trash.", "the notebook's deletion to be reported")
self.assertFalse((self.notebooks / "Other notebook").exists())
self.assertTrue((self.trash / "Other notebook" / "Current.svg").exists())
self.assertNotIn("Other notebook", firefox.run("""
return [...document.querySelectorAll('#chooser_notebooks button.notebook_pick')].map((b) => b.textContent);
"""))
self.wait_until_true(lambda: firefox.run("""
return [...document.getElementById('session_notebook').options].map((o) => o.textContent);
"""), lambda names: "Other notebook" not in names, "the toolbar's list to lose the notebook")
firefox.run("document.getElementById('chooser_close').click();")
2026-10-11 17:34:24 +02:00
# A guest has the chooser too, to look at and clear their own
# notebook; a card tells them that loading is the host's.
firefox.switch_to(student_tab)
number = self.state()["page"]["number"]
firefox.run("document.getElementById('load_notebook_page').click();")
self.wait_until_true(lambda: firefox.run("""
return document.querySelectorAll('#chooser_pages .page_card').length;
"""), lambda n: n >= 1, "the student's chooser to list kept pages")
firefox.run("document.querySelector('#chooser_pages .page_card').click();")
self.assertIn("Only the host and co-hosts", firefox.run("return document.getElementById('chooser_message').textContent;"))
self.assertTrue(firefox.run("return document.getElementById('notebook_chooser').open;"))
self.assertEqual(self.state()["page"]["number"], number)
firefox.run("document.getElementById('chooser_close').click();")
firefox.switch_to(teacher_tab)
2026-10-06 15:58:02 +02:00
# A page that its images have made longer than a sheet is first
# shown at the scale at which one sheet fits the window.
self.assertEqual(firefox.run(VIEW), long_view)
firefox.switch_to(student_tab)
student = self.wait_until(
lambda s: s["page"]["number"] == loaded["page"]["number"] and len(s["images"]) == 3
and all(i["ready"] and i["shown"] for i in s["images"]),
"the loaded page of three images to reach the student")
self.assertEqual(student["images"], stacked["images"])
self.assertEqual(firefox.run(VIEW), long_view)
firefox.switch_to(teacher_tab)
# Five colours and four widths in the toolbar. "+" among the
# colours opens a palette of our own, in which the colours already
# in the row leave their places empty; a chosen colour becomes a
# swatch and the pen's colour, and its place in the palette empties.
swatches = lambda: firefox.run("""
return [...document.querySelectorAll('#colors .swatch:not(.more)')]
.map((s) => s.getAttribute('aria-label').replace('Color ', ''));
""")
self.assertEqual(swatches(), ["#000000", "#e53935", "#43a047", "#1e88e5", "#ffffff"])
self.assertEqual(firefox.run("return document.querySelectorAll('#widths button').length;"), 4)
palette = lambda: firefox.run("""
return [...document.querySelectorAll('#color_palette .swatch')]
.map((s) => s.classList.contains('missing') ? '' : s.getAttribute('aria-label').replace('Color ', ''));
""")
row_height = firefox.run("return document.querySelector('.toolbar').getBoundingClientRect().height;")
2026-10-06 15:58:02 +02:00
firefox.run("document.getElementById('more_colors').click();")
self.assertFalse(firefox.run("return document.getElementById('color_palette').hidden;"))
# The palette floats below "+", above the page; it does not reflow
# the toolbar (it did from the Page menu's commit of 2026-10-14
# until the same day, when its positioning had moved to the shared
# popup rule and the palette had not been given that class).
self.assertEqual(firefox.run("return document.querySelector('.toolbar').getBoundingClientRect().height;"), row_height)
placed = firefox.run("""
const p = document.getElementById('color_palette').getBoundingClientRect();
const b = document.getElementById('more_colors').getBoundingClientRect();
return {position: getComputedStyle(document.getElementById('color_palette')).position,
below: p.top >= b.bottom, left: Math.abs(p.left - b.left) < 1};
""")
self.assertEqual(placed, {"position": "absolute", "below": True, "left": True})
shown = palette()
self.assertEqual(len(shown), 32)
self.assertEqual([i for i, c in enumerate(shown) if c == ""], [0, 3, 5, 24, 31])
2026-10-06 15:58:02 +02:00
firefox.run("""
[...document.querySelectorAll('#color_palette .swatch')]
.find((s) => s.getAttribute('aria-label') === 'Color #8e24aa').click();
2026-10-06 15:58:02 +02:00
""")
self.assertTrue(firefox.run("return document.getElementById('color_palette').hidden;"))
self.assertEqual(swatches()[-1], "#8e24aa")
# The added colour is among the shapes' fill colours as well.
self.assertEqual(firefox.run("return [...document.querySelectorAll('#shape_fill .swatch')].map((s) => s.getAttribute('aria-label'));"),
["No fill", "Fill #000000", "Fill #e53935", "Fill #43a047", "Fill #1e88e5", "Fill #ffffff", "Fill #8e24aa"])
2026-10-06 15:58:02 +02:00
self.assertEqual(firefox.run("""
const pressed = [...document.querySelectorAll('#colors .swatch[aria-pressed=true]')];
return pressed.map((s) => s.getAttribute('aria-label'));
"""), ["Color #8e24aa"])
firefox.run("document.getElementById('more_colors').click();")
self.assertEqual(palette()[6], "")
firefox.run("document.getElementById('more_colors').click();")
2026-10-06 15:58:02 +02:00
firefox.run("document.querySelector('[data-tool=pen]').click();")
firefox.drag("#page", 100, 700, 300, 720)
picked = self.wait_until(lambda s: s["layers"]["Teacher"] and s["layers"]["Teacher"][-1]["done"],
"the stroke in the picked colour")
self.assertEqual(firefox.run("""
const layer = window.kokkoja.session.ownLayer;
return [...layer.strokes.values()].at(-1).color;
"""), "#8e24aa")
2026-10-06 15:58:02 +02:00
# "Image" opens a chooser of our own. It begins where the notebooks
# are, reaches other directories through the local program, and
# shows image files only, each with a small picture.
scans = pathlib.Path(self.directory.name) / "Scans"
scans.mkdir()
(scans / "exercise.png").write_bytes(png_picture(300, 200, (200, 60, 60)))
(scans / "notes.txt").write_text("not an image")
chooser = lambda condition, what: self.wait_until_true(
lambda: firefox.run(FILE_CHOOSER), condition, what)
firefox.run("document.getElementById('new_page').click();")
number = self.wait_until(lambda s: not s["images"], "a new page")["page"]["number"]
firefox.run("document.getElementById('load_image').click();")
shown = chooser(lambda c: c["open"] and c["directory"] == str(self.notebooks),
"the chooser to show the notebooks directory")
self.assertEqual(shown["title"], "Image")
self.assertIn("Teacher notebook", shown["directories"])
self.assertEqual((shown["files"], shown["saving"]), ([], False))
self.assertEqual(shown["message"], "No image files here.")
firefox.run("document.getElementById('files_up').click();")
shown = chooser(lambda c: c["directory"] == self.directory.name, "the directory above")
self.assertIn("Scans", shown["directories"])
firefox.run("""
[...document.querySelectorAll('.files_directories button')]
.find((b) => b.textContent === 'Scans').click();
""")
shown = chooser(lambda c: c["directory"] == str(scans) and c["drawn"] == [[240, 160]],
"the image file and its small picture")
self.assertEqual((shown["files"], shown["message"]), (["exercise.png"], ""))
firefox.run("document.querySelector('.file_card').click();")
chosen = self.wait_until(lambda s: len(s["images"]) == 1 and s["image"]["shown"],
"the chosen image on the page")
self.assertEqual(chosen["page"]["number"], number)
self.assertEqual(chosen["placed"], {"x": 1335, "y": 50, "width": 300, "height": 200})
2026-10-06 15:58:02 +02:00
self.assertFalse(firefox.run(FILE_CHOOSER)["open"])
# The chooser begins where it was last used.
firefox.run("document.getElementById('load_image').click();")
chooser(lambda c: c["open"] and c["directory"] == str(scans), "the chooser to return to Scans")
# Closed and opened again at once, it stays open: the first close
# is reported late and must not end the second use.
firefox.run("""
document.getElementById('files_close').click();
document.getElementById('load_image').click();
""")
time.sleep(0.3)
chooser(lambda c: c["open"] and c["files"] == ["exercise.png"], "the chooser to stay open")
firefox.run("document.getElementById('files_close').click();")
chooser(lambda c: not c["open"], "the chooser to close")
self.assertEqual(len(self.state()["images"]), 1)
# "Export" saves a picture of the page where the user chooses: a
# PNG at 200 dots per inch, which records that resolution.
firefox.drag("#page", 200, 400, 320, 420)
self.wait_until(lambda s: s["layers"]["Teacher"] and s["layers"]["Teacher"][-1]["done"],
"a stroke to find in the picture")
firefox.run("document.getElementById('export_page').click();")
shown = chooser(lambda c: c["open"] and c["directory"] == str(self.notebooks),
"the export chooser to show the notebooks directory")
self.assertEqual((shown["title"], shown["saving"], shown["button"]), ("Export", True, "Export"))
self.assertEqual(shown["name"], f"{today}_page_{number}.png")
self.assertEqual(shown["files"], [])
firefox.run("""
const field = document.getElementById('files_directory');
field.value = arguments[0];
field.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', bubbles: true}));
""", str(scans))
shown = chooser(lambda c: c["directory"] == str(scans) and c["existing"], "the typed directory")
self.assertEqual((shown["existing"], shown["directories"]), (["exercise.png"], []))
export = """
document.getElementById('files_name').value = 'page';
document.getElementById('files_name').dispatchEvent(new Event('input', {bubbles: true}));
document.getElementById('files_save_here').click();
"""
firefox.run(export)
chooser(lambda c: not c["open"], "the export to finish")
picture = scans / "page.png"
self.assertEqual(firefox.run("return document.getElementById('notice').textContent;"),
2026-10-06 15:58:02 +02:00
f"Exported to {picture}")
data = picture.read_bytes()
self.assertEqual(data[:8], b"\x89PNG\r\n\x1a\n")
self.assertEqual(struct.unpack(">II", data[16:24]), (2339, 1654))
2026-10-06 15:58:02 +02:00
self.assertEqual((data[33:41], struct.unpack(">IIB", data[41:50])),
(b"\x00\x00\x00\x09pHYs", (7874, 7874, 1)))
colours = firefox.run_until_done(PICTURE_COLOURS, str(picture))
self.assertEqual((colours["size"], colours["corner"]), ([2339, 1654], [255, 255, 255]))
2026-10-06 15:58:02 +02:00
self.assertTrue(all(abs(a - b) <= 3 for a, b in zip(colours["image"], (200, 60, 60))), colours)
self.assertLess(sum(colours["stroke"]), 500, colours)
# Layers hidden on one's own screen are left out of the picture. A
# file is replaced only at a second press of the button.
firefox.run("document.querySelector('#participants input[type=checkbox]').click();")
firefox.run("document.getElementById('export_page').click();")
chooser(lambda c: c["open"] and c["directory"] == str(scans) and "page.png" in c["existing"],
"the export chooser to return to Scans")
firefox.run(export)
shown = chooser(lambda c: c["button"] == "Replace", "the offer to replace")
self.assertEqual((shown["open"], shown["message"]), (True, "page.png exists here."))
self.assertEqual(picture.read_bytes(), data)
firefox.run("document.getElementById('files_save_here').click();")
chooser(lambda c: not c["open"], "the replacing export to finish")
colours = firefox.run_until_done(PICTURE_COLOURS, str(picture))
self.assertEqual(colours["stroke"], [255, 255, 255])
self.assertTrue(all(abs(a - b) <= 3 for a, b in zip(colours["image"], (200, 60, 60))), colours)
firefox.run("document.querySelector('#participants input[type=checkbox]').click();")
# A tap of the eraser on an image removes it, for whoever may make
# pages or added the image; a sweep across it does not. Writing on
# it stays; the sheet keeps its size.
2026-10-06 15:58:02 +02:00
firefox.run("document.getElementById('new_page').click();")
number = self.wait_until(lambda s: not s["images"], "another new page")["page"]["number"]
firefox.run_until_done(LOAD_IMAGE, 300, 400, "png", 0, False)
firefox.run_until_done(LOAD_IMAGE, 1000, 2700, "png", 0, False)
two = self.wait_until(lambda s: len(s["images"]) == 2, "two images")
self.assertEqual(two["page"], {"number": number, "width": 2970, "height": 3250})
2026-10-06 15:58:02 +02:00
rectangles = lambda state: [[i["x"], i["y"], i["width"], i["height"]] for i in state["images"]]
self.assertEqual(rectangles(two), [[1335, 50, 300, 400], [985, 500, 1000, 2700]])
2026-10-06 15:58:02 +02:00
firefox.switch_to(student_tab)
self.wait_until(lambda s: len(s["images"]) == 2 and all(i["ready"] for i in s["images"]),
"the two images to reach the student")
# Not the student, who has no right and did not add it.
2026-10-06 15:58:02 +02:00
firefox.run(ERASER_AT, 1050, 1500, 5)
firefox.run(PEN_WAVE, 0.3, 0.5)
2026-10-06 15:58:02 +02:00
firefox.switch_to(teacher_tab)
self.wait_until(lambda s: len(s["layers"].get("Student", [])) == 1 and s["layers"]["Student"][0]["done"],
"the student's writing on the image")
# Not by moving the eraser across it.
2026-10-06 15:58:02 +02:00
firefox.run(ERASER_AT, 1050, 1500, 100)
time.sleep(0.3)
self.assertEqual(rectangles(self.state()), rectangles(two))
# A tap by the teacher removes it, written on or not.
2026-10-06 15:58:02 +02:00
firefox.run(ERASER_AT, 1050, 1500, 5)
one = self.wait_until(lambda s: len(s["images"]) == 1, "the image to be removed")
self.assertEqual(one["page"], {"number": number, "width": 2970, "height": 3250})
self.assertEqual(rectangles(one), [[1335, 50, 300, 400]])
self.assertEqual(len(one["layers"]["Student"]), 1)
2026-10-06 15:58:02 +02:00
self.assertEqual(firefox.run("return document.querySelectorAll('.page_image').length;"), 1)
firefox.switch_to(student_tab)
student = self.wait_until(lambda s: len(s["images"]) == 1, "the student to lose the image too")
self.assertEqual(student["page"], one["page"])
self.current_page("Student notebook", {"Student": 1}, number=number, images=1)
# The next image lands where the removed one was. The file records
# who added each image.
2026-10-06 15:58:02 +02:00
firefox.switch_to(teacher_tab)
firefox.run_until_done(LOAD_IMAGE, 1000, 2700, "png", 0, False)
again = self.wait_until(lambda s: len(s["images"]) == 2, "the image added again")
self.assertEqual(rectangles(again), rectangles(two))
self.assertEqual(self.current_page("Teacher notebook", {"Student": 1}, number=number, images=2)
.count('data-by="'), 2)
2026-10-06 15:58:02 +02:00
firefox.switch_to(student_tab)
self.wait_until(lambda s: len(s["images"]) == 2 and all(i["ready"] and i["shown"] for i in s["images"]),
"the image added again to reach the student")
firefox.switch_to(teacher_tab)
# An image wider than the page arrives at its own size, centred,
# the sheet widened equally on both sides to hold it.
2026-10-06 15:58:02 +02:00
(scans / "Wide").mkdir()
(scans / "Wide" / "wide.png").write_bytes(png_picture(3200, 300, (60, 120, 200)))
2026-10-06 15:58:02 +02:00
firefox.run("document.getElementById('new_page').click();")
number = self.wait_until(lambda s: not s["images"], "a page for the wide image")["page"]["number"]
sheet_view = firefox.run(VIEW)
firefox.run("document.getElementById('load_image').click();")
chooser(lambda c: c["open"] and "Wide" in c["directories"], "the chooser to list Wide")
2026-10-06 15:58:02 +02:00
firefox.run("""
[...document.querySelectorAll('.files_directories button')]
.find((b) => b.textContent === 'Wide').click();
""")
chooser(lambda c: c["files"] == ["wide.png"], "the wide image file")
2026-10-06 15:58:02 +02:00
firefox.run("document.querySelector('.file_card').click();")
wide = self.wait_until(lambda s: len(s["images"]) == 1 and s["image"]["shown"],
"the wide image at its own size")
self.assertEqual(rectangles(wide), [[-115, 50, 3200, 300]])
self.assertEqual(wide["page"], {"number": number, "width": 3300, "height": 2100})
2026-10-06 15:58:02 +02:00
view = firefox.run(VIEW) # The scale is kept here too.
self.assertEqual(view["scale"], sheet_view["scale"])
2026-10-06 15:58:02 +02:00
firefox.switch_to(student_tab)
student = self.wait_until(lambda s: len(s["images"]) == 1 and s["image"]["ready"],
"the wide image to reach the student")
self.assertEqual(student["page"], wide["page"])
self.assertIn('data-base="3300 2100 -165 0"', self.current_page("Student notebook", {}, number=number, images=1))
# Removed, it leaves the sheet as wide as it made it; Shrink would trim it.
2026-10-06 15:58:02 +02:00
firefox.switch_to(teacher_tab)
firefox.run(ERASER_AT, 1000, 200, 5)
back = self.wait_until(lambda s: not s["images"], "the wide image to be removed")
self.assertEqual(back["page"], {"number": number, "width": 3300, "height": 2100})
2026-10-06 15:58:02 +02:00
screenshot = os.environ.get("KOKKOJA_SCREENSHOT")
if screenshot:
firefox.screenshot(screenshot)
if __name__ == "__main__":
unittest.main()