Files
kokkoja-rel/tests/test_end_to_end.py
T
ackandClaude Fable 5.1 95c80d04c9 Kokkoja: first public assembly
Assembled from the development repository by tools/make_release.sh.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 15:58:02 +02:00

1556 lines
83 KiB
Python

"""End-to-end test in Firefox: two participants, one page, through the relay.
Starts a relay and the local program on free ports, then drives a headless
Firefox through geckodriver (the WebDriver protocol, spoken here with the
standard library only). Skipped if geckodriver is not installed.
Run from the project directory:
.venv/bin/python -m unittest tests.test_end_to_end -v
Set KOKKOJA_SCREENSHOT to a file name to save a picture of the page as
the second participant sees it at the end.
"""
import base64
import json
import os
import pathlib
import shutil
import socket
import struct
import subprocess
import sys
import tempfile
import time
import unittest
import urllib.error
import urllib.parse
import urllib.request
import zlib
PROJECT = pathlib.Path(__file__).resolve().parent.parent
# Describes the page as one participant holds it: for each layer, by the
# participant's name, the strokes with their points.
PAGE_STATE = """
const session = window.kokkoja && window.kokkoja.session;
if (!session) return null;
const layers = {};
const texts = {};
for (const layer of session.page.layers.values()) {
layers[layer.name] = [...layer.strokes.values()].map(
(stroke) => ({id: stroke.id, done: stroke.done, points: stroke.points}));
texts[layer.name] = [...layer.texts.values()];
}
// The first image, and all of them in sequence.
const images = session.page.images;
const image = images[0] ?? null;
const shown = document.querySelectorAll('.page_image');
return {
status: document.getElementById('status').textContent,
page: {number: session.page.number, width: session.page.width, height: session.page.height},
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},
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;
"""
# 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 "pages" toggle beside the named participant.
TOGGLE_PERMISSION = """
const find = () => [...document.querySelectorAll('#participants li')]
.find((li) => li.textContent.includes(arguments[0])).querySelector('.allow');
find().click();
return find().getAttribute('aria-pressed'); // 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 {
fit: document.getElementById('zoom_fit').getAttribute('aria-pressed') === 'true',
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,
shrink: document.getElementById('files_shrink').checked,
shrinkShown: !document.getElementById('files_shrink_line').hidden,
};
"""
# 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;
"""
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=1200", "--height=900"],
# Downloads go to the given directory without asking.
"prefs": {"browser.download.folderList": 2, "browser.download.dir": str(downloads),
"browser.download.useDownloadDir": True,
"browser.download.always_ask_before_handling_new_types": False},
},
}}
self.session = self.command("POST", "/session", {"capabilities": capabilities})["sessionId"]
def command(self, method, path, body=None):
data = None if body is None else json.dumps(body).encode()
request = urllib.request.Request(self.base + path, data=data, method=method,
headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(request, timeout=60) as response:
return json.load(response)["value"]
except urllib.error.HTTPError as error:
raise RuntimeError(error.read().decode()) from None
def in_session(self, method, path, body=None):
return self.command(method, f"/session/{self.session}{path}", body)
def go(self, address):
self.in_session("POST", "/url", {"url": address})
def run(self, script, *arguments):
return self.in_session("POST", "/execute/sync", {"script": script, "args": list(arguments)})
def run_until_done(self, script, *arguments):
"""Run a script that calls its last argument when it has finished."""
return self.in_session("POST", "/execute/async", {"script": script, "args": list(arguments)})
def new_tab(self):
handle = self.in_session("POST", "/window/new", {"type": "tab"})["handle"]
self.switch_to(handle)
return handle
def current_tab(self):
return self.in_session("GET", "/window")
def switch_to(self, handle):
self.in_session("POST", "/window", {"handle": handle})
def drag(self, selector, from_x, from_y, to_x, to_y):
"""Drag the mouse over an element; positions are from its top left."""
box = self.run(f"const b = document.querySelector({json.dumps(selector)})"
".getBoundingClientRect(); return [b.left, b.top];")
moves = [{"type": "pointerMove", "duration": 0, "origin": "viewport",
"x": round(box[0] + from_x), "y": round(box[1] + from_y)},
{"type": "pointerDown", "button": 0}]
for step in range(1, 11):
moves.append({"type": "pointerMove", "duration": 20, "origin": "viewport",
"x": round(box[0] + from_x + (to_x - from_x) * step / 10),
"y": round(box[1] + from_y + (to_y - from_y) * step / 10)})
moves.append({"type": "pointerUp", "button": 0})
self.in_session("POST", "/actions", {"actions": [{
"type": "pointer", "id": "mouse", "parameters": {"pointerType": "mouse"},
"actions": moves}]})
def type_into(self, selector, text):
"""Type text into an element, as the keyboard would."""
found = self.in_session("POST", "/element", {"using": "css selector", "value": selector})
element_id = next(iter(found.values()))
self.in_session("POST", f"/element/{element_id}/value", {"text": text})
def hover(self, selector):
"""Rest the mouse on the middle of an element."""
box = self.run(f"const b = document.querySelector({json.dumps(selector)})"
".getBoundingClientRect(); return [b.left + b.width / 2, b.top + b.height / 2];")
self.in_session("POST", "/actions", {"actions": [{
"type": "pointer", "id": "mouse", "parameters": {"pointerType": "mouse"},
"actions": [{"type": "pointerMove", "duration": 0, "origin": "viewport",
"x": round(box[0]), "y": round(box[1])}]}]})
def 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"
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), *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):
"""The Kokkoja address of a participant's own local program, with
its token, and an invitation to join if given."""
base, token = self.programs[name]
joining = "" if join is None else f"&join={urllib.parse.quote(join, safe='')}"
return f"{base}#token={token}{joining}"
@classmethod
def start(cls, command):
cls.processes.append(subprocess.Popen(
command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL))
@classmethod
def stop(cls):
for process in reversed(cls.processes):
process.terminate()
for process in cls.processes:
process.wait(timeout=10)
@classmethod
def tearDownClass(cls):
try:
cls.firefox.quit()
finally:
cls.stop()
cls.directory.cleanup()
shutil.rmtree(cls.downloads, ignore_errors=True)
def state(self):
return self.firefox.run(PAGE_STATE)
def wait_until_true(self, read, condition, what, seconds=15):
"""Wait until condition(read()) is true and return the value read."""
deadline = time.monotonic() + seconds
value = None
while time.monotonic() < deadline:
value = read()
if condition(value):
return value
time.sleep(0.1)
self.fail(f"timed out waiting for {what}; last value: {value}")
def wait_until(self, condition, what, seconds=15):
"""Wait until condition(state) is true and return the state."""
deadline = time.monotonic() + seconds
state = None
while time.monotonic() < deadline:
state = self.state()
if state and condition(state):
return state
time.sleep(0.1)
self.fail(f"timed out waiting for {what}; last state: {json.dumps(state)[:600]}")
def enter_name(self, name, button):
# The start screen appears once the client has read config.json.
self.wait_until_true(lambda: self.firefox.run("return !document.getElementById('start').hidden;"),
bool, "the start screen")
self.firefox.run("document.getElementById('name').value = arguments[0];"
"document.getElementById('notebook').value = arguments[0] + ' notebook';"
"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)
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;"))
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)
# The guest's page is in the browser's notebook, nowhere on disk.
firefox.switch_to(guest_tab)
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"], [])
self.assertEqual(self.stroke_counts(in_browser["current"]), {"Host": 1, "Guest": 1})
# 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('save_status').textContent;"))
self.assertEqual(firefox.run_until_done("""
const done = arguments[0];
window.kokkoja.notebook.listKeptPages('Guest notebook').then(done);
"""), [f"{today}_01.svg"])
# 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('save_status').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('host_tools').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('host_tools').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_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")
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.assertEqual(firefox.run("return document.getElementById('invitation_entry').value;"),
invitation)
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 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('save_status').textContent;"))
# Only the host can start a new page.
self.assertTrue(firefox.run("return document.getElementById('host_tools').hidden;"))
firefox.switch_to(teacher_tab)
self.assertFalse(firefox.run("return document.getElementById('host_tools').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 8 mm margin, a pixel being 0.1 mm.
self.assertEqual(teacher["page"], {"number": 3, "width": 2100, "height": 2970})
self.assertEqual(teacher["placed"], {"x": 900, "y": 80, "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. "+" and "-" zoom by 10%; "Fit" fits
# the whole page to the window; Ctrl and the wheel zoom about the
# pointer. None of it changes the page.
zoom = lambda: firefox.run("""
const r = window.kokkoja.renderer;
return {zoom: r.zoom, width: document.getElementById('page').getBoundingClientRect().width,
fit: document.getElementById('zoom_fit').getAttribute('aria-pressed')};
""")
before = zoom()
self.assertEqual(before["fit"], "true")
firefox.run("document.getElementById('zoom_in').click();")
larger = zoom()
self.assertAlmostEqual(larger["width"], before["width"] * 1.1, delta=1)
self.assertEqual(larger["fit"], "false")
firefox.run("document.getElementById('zoom_out').click();")
self.assertAlmostEqual(zoom()["width"], before["width"], delta=1)
firefox.run("""
document.getElementById('desk').dispatchEvent(new WheelEvent('wheel',
{deltaY: -100, ctrlKey: true, clientX: 300, clientY: 300, bubbles: true, cancelable: true}));
""")
self.assertAlmostEqual(zoom()["width"], before["width"] * 1.1, delta=1)
firefox.run("document.getElementById('zoom_fit').click();")
self.assertEqual(zoom(), before)
self.assertEqual(self.state()["page"], teacher["page"])
# 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};
""")
firefox.hover("#new_page")
time.sleep(0.5)
self.assertIsNone(tooltip())
time.sleep(1)
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(1.5)
self.assertIsNotNone(tooltip())
firefox.run("document.dispatchEvent(new KeyboardEvent('keydown', {key: 'a', bubbles: true}));")
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"], "#1c1c1e")
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 along the top edge shows the edge while dragging, and makes
# a block that wraps at its width; the right handle widens it and
# the corner handle 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.75 * box.height, 1);
""")
preview = firefox.run("""
const p = document.querySelector('.text_preview');
return {hidden: p.hidden, width: p.getBoundingClientRect().width};
""")
self.assertFalse(preview["hidden"])
self.assertGreater(preview["width"], 50)
firefox.run(TEXT_GESTURE, 0.1, 0.75, 0.3, 0.75)
self.assertTrue(firefox.run("return document.querySelector('.text_preview').hidden;"))
self.assertEqual(firefox.run("return getComputedStyle(document.querySelector('.text_field')).borderStyle;"), "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.assertEqual(" ".join(wide["lines"]), wide["text"])
firefox.run(DRAG_HANDLE, ".text_handle.end", 300, 0)
widened = self.wait_until(lambda s: s["texts"]["Teacher"][1]["width"] > wide["width"],
"the block to widen")["texts"]["Teacher"][1]
self.assertLess(len(widened["lines"]), len(wide["lines"]))
firefox.run(DRAG_HANDLE, ".text_handle.corner", 40, 60)
moved = self.wait_until(lambda s: s["texts"]["Teacher"][1]["y"] > wide["y"],
"the block to move")["texts"]["Teacher"][1]
self.assertGreater(moved["x"], wide["x"])
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.
self.wait_until_true(
lambda: (self.notebooks / "Teacher notebook" / "Current.svg").read_text(),
lambda saved: "<text " in saved and "zwei Zeilen" in saved, "the text to be saved")
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["page"].get("left", 0)) / state["page"]["width"]
fy = (first["y"] + 10) / 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.assertEqual(firefox.run("return document.getElementById('invitation_entry').value;"),
invitation)
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.assertIn(f'data-hash="{teacher["image"]["hash"]}" x="900" y="80" width="300" height="400"', saved)
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.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.
firefox.run("""
const field = document.getElementById('session_notebook');
field.value = 'Other notebook';
field.dispatchEvent(new Event('change'));
""")
self.current_page("Other notebook", {"Teacher": 1, "Student": 1}, number=3)
firefox.run("""
const field = document.getElementById('session_notebook');
field.value = 'Teacher notebook';
field.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.
firefox.go(self.programs["Teacher"][0])
firefox.run("location.reload();")
time.sleep(0.5)
self.enter_name("Teacher", "start_session")
self.assertEqual(self.wait_for_kept("Teacher notebook", 3)[-1], f"{today}_03.svg")
last = (self.notebooks / "Teacher notebook" / f"{today}_03.svg").read_text()
self.assertEqual(self.stroke_counts(last), {"Teacher": 1, "Student": 1})
self.assertIn("<image ", last)
self.assertEqual(self.state()["layers"], {"Teacher": []})
# A large image is made smaller and stored as JPEG, and is scaled to
# the width of the sheet inside the margin.
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": 2100, "height": 2970})
self.assertEqual(large["placed"], {"x": 80, "y": 80, "width": 1940, "height": 1293})
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('host_tools').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('host_tools').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": 2100, "height": 2970})
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('host_tools').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 8 mm.
firefox.run(PEN_WAVE, 0.98, 0.5)
grown = self.wait_until(lambda s: s["page"]["width"] > 2100, "the page to grow")
self.assertEqual(grown["page"]["width"] % 80, 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": 542, "y": 80, "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": 542, "y": 80, "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.assertEqual((sheet_view["fit"], sheet_view["scrollsDown"]), (True, False))
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 = {"fit": False, "scale": sheet_view["scale"], "top": 0,
"scrollsDown": True, "scrollsAcross": False}
self.assertEqual(firefox.run(VIEW), long_view)
self.assertEqual(stacked["page"], {"number": 5, "width": 2100, "height": 3728})
self.assertEqual(
[[i["x"], i["y"], i["width"], i["height"]] for i in stacked["images"]],
[[542, 80, 1016, 508], [900, 668, 300, 400], [550, 1148, 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));
});
"""), [[258, 21, 484, 136], [429, 179, 143, 107], [262, 308, 476, 671]])
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('zoom_in').click();
document.getElementById('desk').scrollTop = 240;
""")
seen = firefox.run(VIEW)
self.assertEqual((seen["fit"], seen["top"]), (False, 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.assertEqual((fitted["fit"], fitted["scrollsDown"]), (True, False))
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="2100 3728"', 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')].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')]
.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": 900, "y": 80, "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"], 3728)
# 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)
# "+" among the colours opens a palette of our own; a chosen colour
# becomes a swatch and the pen's colour.
swatches = lambda: firefox.run("return document.querySelectorAll('#colors .swatch:not(.more)').length;")
count = swatches()
firefox.run("document.getElementById('more_colors').click();")
self.assertFalse(firefox.run("return document.getElementById('color_palette').hidden;"))
firefox.run("""
[...document.querySelectorAll('#color_palette .swatch')]
.find((s) => s.getAttribute('aria-label') === 'Color #1e88e5').click();
""")
self.assertTrue(firefox.run("return document.getElementById('color_palette').hidden;"))
self.assertEqual(swatches(), count + 1)
self.assertEqual(firefox.run("""
const pressed = [...document.querySelectorAll('#colors .swatch[aria-pressed=true]')];
return pressed.map((s) => s.getAttribute('aria-label'));
"""), ["Color #1e88e5"])
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;
"""), "#1e88e5")
# "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": 900, "y": 80, "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.assertFalse(shown["shrinkShown"])
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('save_status').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]), (1654, 2339))
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"]), ([1654, 2339], [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();")
# An image just added can be taken back with a tap of the eraser by
# the one who added it, until anything else changes on the page.
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": 2100, "height": 3340})
rectangles = lambda state: [[i["x"], i["y"], i["width"], i["height"]] for i in state["images"]]
self.assertEqual(rectangles(two), [[900, 80, 300, 400], [550, 560, 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 did not add it.
firefox.run(ERASER_AT, 1050, 1500, 5)
firefox.switch_to(teacher_tab)
# Not by moving the eraser across it, and not the earlier image.
firefox.run(ERASER_AT, 1050, 1500, 100)
firefox.run(ERASER_AT, 1050, 280, 5)
time.sleep(0.3)
self.assertEqual(rectangles(self.state()), rectangles(two))
firefox.run(ERASER_AT, 1050, 1500, 5)
one = self.wait_until(lambda s: len(s["images"]) == 1, "the image to be taken back")
self.assertEqual(one["page"], {"number": number, "width": 2100, "height": 2970})
self.assertEqual(rectangles(one), [[900, 80, 300, 400]])
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", {}, number=number, images=1)
# The right image, added next, lands where the wrong one was.
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))
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")
# Once anyone has written, the image stays.
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")
firefox.run(ERASER_AT, 1050, 1500, 5)
time.sleep(0.3)
self.assertEqual(rectangles(self.state()), rectangles(two))
self.current_page("Teacher notebook", {"Student": 1}, number=number, images=2)
# An image wider than the page is shrunk to fit it unless the
# chooser is told otherwise. Then it keeps its size and begins at
# the left margin, and the sheet widens to hold it.
(scans / "Wide").mkdir()
(scans / "Wide" / "wide.png").write_bytes(png_picture(2600, 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();")
shown = chooser(lambda c: c["open"] and "Wide" in c["directories"], "the chooser to list Wide")
self.assertEqual((shown["shrinkShown"], shown["shrink"]), (True, True))
firefox.run("""
document.getElementById('files_shrink').click();
[...document.querySelectorAll('.files_directories button')]
.find((b) => b.textContent === 'Wide').click();
""")
chooser(lambda c: c["files"] == ["wide.png"] and not c["shrink"], "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), [[80, 80, 2600, 300]])
self.assertEqual(wide["page"], {"number": number, "width": 2760, "height": 2970})
view = firefox.run(VIEW) # The scale is kept here too.
self.assertEqual((view["fit"], view["scale"]), (False, 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="2760 2970"', self.current_page("Student notebook", {}, number=number, images=1))
# Taken back, it leaves an A4 sheet.
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 taken back")
self.assertEqual(back["page"], {"number": number, "width": 2100, "height": 2970})
# The choice was remembered. Shrunk, the image is as wide as the
# sheet inside its margins.
firefox.run("document.getElementById('load_image').click();")
shown = chooser(lambda c: c["open"] and c["files"] == ["wide.png"], "the chooser to return to Wide")
self.assertFalse(shown["shrink"])
firefox.run("""
document.getElementById('files_shrink').click();
document.querySelector('.file_card').click();
""")
shrunk = self.wait_until(lambda s: len(s["images"]) == 1, "the wide image, shrunk")
self.assertEqual(rectangles(shrunk), [[80, 80, 1940, 224]])
self.assertEqual(shrunk["page"], {"number": number, "width": 2100, "height": 2970})
screenshot = os.environ.get("KOKKOJA_SCREENSHOT")
if screenshot:
firefox.screenshot(screenshot)
if __name__ == "__main__":
unittest.main()