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