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