Files
kokkoja-rel/tests/test_end_to_end.py
T

3600 lines
213 KiB
Python
Raw Blame History

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