Everyone has the same buttons; the view is personal

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>
This commit is contained in:
ackandClaude Fable 5.1 committed 2026-10-18 20:22:13 +02:00
1 parent 5d63a0f758
commit 60ad747e32
10 files changed
+215 -354

No files matched your search

+114 -106
View File
@@ -136,7 +136,7 @@ PAGE_STATE = """
hash: one.hash, x: one.x, y: one.y, width: one.width, height: one.height,
ready: one.data !== null, shown: i < shown.length && !shown[i].hidden})),
participants: session.participantList().map(
(p) => ({name: p.name, host: p.host, present: p.self || p.peer !== null})),
(p) => ({name: p.name, present: p.self || p.peer !== null})),
layers,
texts,
};
@@ -265,14 +265,6 @@ PEN_WAVE = """
page.setPointerCapture = capture;
"""
# Clicks the host's "Co-host" box beside the named participant.
TOGGLE_PERMISSION = """
const find = () => [...document.querySelectorAll('#participants li')]
.find((li) => li.textContent.includes(arguments[0])).querySelector('.allow');
find().click();
return String(find().checked); // The list is rebuilt by the click.
"""
# Makes an image of the given size in pixels and puts it on the page, as
# the "Image" button does with a file. Arguments: width, height, kind
# ('png' or 'jpeg'), a resolution in dots per inch to record in the file,
@@ -799,7 +791,10 @@ class EndToEndTest(unittest.TestCase):
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."""
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")
@@ -959,14 +954,11 @@ class EndToEndTest(unittest.TestCase):
self.assertTrue(picture.startswith(b"\x89PNG"))
self.assertIn("downloads", firefox.run("return document.getElementById('notice').textContent;"))
# Allowed by the host, the guest puts an image on the page (the
# file would come from the browser's own chooser).
self.assertTrue(firefox.run("return document.getElementById('new_page').hidden;"))
firefox.switch_to(host_tab)
self.assertEqual(firefox.run(TOGGLE_PERMISSION, "Guest"), "true")
firefox.switch_to(guest_tab)
self.wait_until(lambda s: not firefox.run("return document.getElementById('new_page').hidden;"),
"the guest to be allowed to start pages")
# 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)
@@ -985,9 +977,34 @@ class EndToEndTest(unittest.TestCase):
self.assertEqual(reloaded["layers"]["Guest"], guest["layers"]["Guest"])
self.assertEqual(len(reloaded["participants"]), 2)
# The host leaves the session; the tabs are closed for the next test.
# 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):
@@ -1086,7 +1103,8 @@ class EndToEndTest(unittest.TestCase):
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."""
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"))
@@ -1096,7 +1114,7 @@ class EndToEndTest(unittest.TestCase):
firefox.go(self.address("Student", join=invitation))
self.enter_name("Watcher", "join_session")
self.wait_until(lambda s: s["page"]["number"] == 1 and len(s["participants"]) == 2, "the watcher")
self.assertTrue(firefox.run("return document.querySelector('[data-tool=hide]').hidden;"))
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;"))
@@ -1121,8 +1139,9 @@ class EndToEndTest(unittest.TestCase):
self.assertFalse(white_at(hide[0] + hide[2] + 100, hide[1] + hide[3] + 100))
# The watcher sees it; hiding the host's layer on the watcher's
# screen does not uncover it; the watcher can neither move nor
# remove it.
# 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("""
@@ -1134,9 +1153,16 @@ class EndToEndTest(unittest.TestCase):
origin = self.state()["origin"]
firefox.run_until_done(PEN_HOLD, (hide[0] + 50 - origin[0]) * vscale, (hide[1] + 50 - origin[1]) * vscale,
100 * vscale, 0, 800)
firefox.run(ERASER_AT, hide[0] + 50, hide[1] + 50, 5)
time.sleep(0.5)
self.assertEqual(self.state()["hides"], made["hides"])
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.
@@ -1409,43 +1435,36 @@ class EndToEndTest(unittest.TestCase):
firefox.run("document.querySelector('[data-tool=pen]').click();")
scaled = erased_not
# A guest given the right adds an image, keeps the power to remove it
# after the right is withdrawn, and cannot remove the host's.
self.assertEqual(firefox.run(TOGGLE_PERMISSION, "Viewer"), "true")
# 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.wait_until(lambda s: not firefox.run("return document.getElementById('new_page').hidden;"),
"the viewer to be allowed")
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")
self.assertEqual(firefox.run(TOGGLE_PERMISSION, "Viewer"), "false")
firefox.switch_to(guest_tab)
self.wait_until(lambda s: firefox.run("return document.getElementById('new_page').hidden;"),
"the right to be withdrawn")
own = added["images"][2]
firefox.run(ERASER_AT, own["x"] + 100, own["y"] + 50, 5)
self.wait_until(lambda s: len(s["images"]) == 2, "the viewer's own image removed")
hosts = self.state()["images"][1]
firefox.run(ERASER_AT, hosts["x"] + 50, hosts["y"] + 50, 5)
time.sleep(0.5)
self.assertEqual(len(self.state()["images"]), 2)
firefox.switch_to(host_tab)
self.wait_until(lambda s: len(s["images"]) == 2, "the removal to reach the host")
firefox.switch_to(guest_tab)
# The viewer sees the same, and cannot move an image: a hold there
# draws a dot instead.
firefox.switch_to(guest_tab)
# 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)
time.sleep(0.5)
after = self.state()
self.assertEqual(rect(after, 1), rect(scaled, 1))
self.assertEqual(len(after["layers"].get("Viewer", [])), 1)
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)
@@ -1501,8 +1520,9 @@ class EndToEndTest(unittest.TestCase):
image (and, until 2026-10-14, every Hide and own text rectangle,
which carry blue corners at all times since), a drag inside one
moves it and a drag on a corner resizes it, at once, no hold; a
press elsewhere does nothing. A guest without the right sees no
cues, their own text rectangle's corners being drawn anyway."""
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"))
@@ -1640,17 +1660,17 @@ class EndToEndTest(unittest.TestCase):
firefox.pointer_actions([{"type": "pointerUp", "button": 0}])
self.assertEqual(cues(), ["image", "hide", "text", "text"])
# The watcher, without the right, is shown no cue on the image or
# the Hide, only on their own text.
# 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(), [])
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(), ["text"])
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")
@@ -2224,7 +2244,7 @@ class EndToEndTest(unittest.TestCase):
self.assertNotIn("Copy", names)
self.assertEqual(len(names), len(set(names)), "no two entries of one name")
self.assertIn("\nPen\nWrite on the page. Only your own writing can be erased.\n", manual)
self.assertIn("\nFit to the window\nShow the whole page in the window.", manual)
self.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
@@ -2460,7 +2480,6 @@ class EndToEndTest(unittest.TestCase):
"the student to receive the teacher's layer")
self.assertEqual(student["layers"]["Teacher"], teacher["layers"]["Teacher"])
teacher_first_page = {"Teacher": teacher["layers"]["Teacher"]}
self.assertEqual([p["host"] for p in student["participants"]], [False, True])
# The student writes with a pen; the teacher sees the same strokes.
for fx in (0.15, 0.4, 0.65):
@@ -2545,12 +2564,11 @@ class EndToEndTest(unittest.TestCase):
self.assertIn("Already kept", firefox.run(
"return document.getElementById('notice').textContent;"))
# Only the host can start a new page.
self.assertTrue(firefox.run("return document.getElementById('new_page').hidden;"))
firefox.switch_to(teacher_tab)
# 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 host starts a new page. Everyone moves to it, and everyone's
# 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();")
@@ -2624,8 +2642,10 @@ class EndToEndTest(unittest.TestCase):
self.assertEqual(doubled["strokes"], before["strokes"])
firefox.run(CTRL_DRAG, 0.5, 0.3, 0.5, 0.3, 104)
self.assertAlmostEqual(zoom()["width"], before["width"], delta=2)
# The host's Fit and Normal scale reach the guest, who then fits the page to
# the guest's own window; the guest's own Fit stays the guest's.
# 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',
@@ -2634,12 +2654,13 @@ class EndToEndTest(unittest.TestCase):
guest_zoomed = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;")
firefox.switch_to(teacher_tab)
firefox.run("document.getElementById('zoom_fit').click();")
firefox.switch_to(student_tab)
self.wait_until_true(lambda: firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;"),
lambda v: v < guest_zoomed, "the guest's view to follow the host's Fit")
firefox.run("document.getElementById('zoom_sheet').click();")
firefox.switch_to(teacher_tab)
host_scale = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;")
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)
@@ -2679,7 +2700,7 @@ class EndToEndTest(unittest.TestCase):
self.assertGreater(wide_page["height"], teacher["page"]["height"])
firefox.switch_to(student_tab)
self.wait_until(lambda s: s["page"] == wide_page, "the expanded page to reach the student")
self.assertTrue(firefox.run("return document.getElementById('zoom_expand').hidden;"))
self.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.
@@ -3007,8 +3028,8 @@ class EndToEndTest(unittest.TestCase):
self.assertEqual(alone["page"], teacher["page"])
self.assertEqual(alone["image"], teacher["image"])
self.assertEqual(alone["participants"], [
{"name": "Teacher", "host": True, "present": True},
{"name": "Student", "host": False, "present": False}])
{"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)
@@ -3082,22 +3103,14 @@ class EndToEndTest(unittest.TestCase):
return [shown.naturalWidth, shown.naturalHeight];
"""), [2400, 1600])
# The student joins the new session. The host lets the student
# start pages, the student loads an image, and the host withdraws
# the permission.
# 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")
self.assertTrue(firefox.run("return document.getElementById('new_page').hidden;"))
firefox.switch_to(teacher_tab)
self.assertEqual(firefox.run(TOGGLE_PERMISSION, "Student"), "true")
firefox.switch_to(student_tab)
self.wait_until(
lambda s: not firefox.run("return document.getElementById('new_page').hidden;"),
"the student to be allowed to start pages")
firefox.run_until_done(LOAD_IMAGE, 300, 400, "png", 0, True)
student = self.wait_until(lambda s: s["page"]["number"] == 3, "the student's image page")
self.assertEqual(student["page"], {"number": 3, "width": 2970, "height": 2100})
@@ -3106,14 +3119,7 @@ class EndToEndTest(unittest.TestCase):
lambda s: s["page"]["number"] == 3 and s["image"] and s["image"]["ready"],
"the student's image to reach the teacher")
self.assertEqual(teacher["image"], student["image"])
self.assertEqual(firefox.run(TOGGLE_PERMISSION, "Student"), "false")
firefox.switch_to(student_tab)
self.wait_until(
lambda s: firefox.run("return document.getElementById('new_page').hidden;"),
"the student's permission to be withdrawn")
firefox.run("window.kokkoja.session.newPage();")
time.sleep(1)
self.assertEqual(self.state()["page"]["number"], 3)
# Writing past the edge of the page makes the page grow, for
# everyone, in steps of 5 mm.
@@ -3370,19 +3376,6 @@ class EndToEndTest(unittest.TestCase):
"""), lambda names: "Other notebook" not in names, "the toolbar's list to lose the notebook")
firefox.run("document.getElementById('chooser_close').click();")
# A guest has the chooser too, to look at and clear their own
# notebook; a card tells them that loading is the host's.
firefox.switch_to(student_tab)
number = self.state()["page"]["number"]
firefox.run("document.getElementById('load_notebook_page').click();")
self.wait_until_true(lambda: firefox.run("""
return document.querySelectorAll('#chooser_pages .page_card').length;
"""), lambda n: n >= 1, "the student's chooser to list kept pages")
firefox.run("document.querySelector('#chooser_pages .page_card').click();")
self.assertIn("Only the host and co-hosts", firefox.run("return document.getElementById('chooser_message').textContent;"))
self.assertTrue(firefox.run("return document.getElementById('notebook_chooser').open;"))
self.assertEqual(self.state()["page"]["number"], number)
firefox.run("document.getElementById('chooser_close').click();")
firefox.switch_to(teacher_tab)
# A page that its images have made longer than a sheet is first
# shown at the scale at which one sheet fits the window.
@@ -3394,7 +3387,20 @@ class EndToEndTest(unittest.TestCase):
"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
@@ -3557,9 +3563,10 @@ class EndToEndTest(unittest.TestCase):
self.assertTrue(all(abs(a - b) <= 3 for a, b in zip(colours["image"], (200, 60, 60))), colours)
firefox.run("document.querySelector('#participants input[type=checkbox]').click();")
# A tap of the eraser on an image removes it, for whoever may make
# pages or added the image; a sweep across it does not. Writing on
# it stays; the sheet keeps its size.
# 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)
@@ -3571,18 +3578,19 @@ class EndToEndTest(unittest.TestCase):
firefox.switch_to(student_tab)
self.wait_until(lambda s: len(s["images"]) == 2 and all(i["ready"] for i in s["images"]),
"the two images to reach the student")
# Not the student, who has no right and did not add it.
firefox.run(ERASER_AT, 1050, 1500, 5)
# 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")
# Not by moving the eraser across it.
firefox.run(ERASER_AT, 1050, 1500, 100)
time.sleep(0.3)
self.assertEqual(rectangles(self.state()), rectangles(two))
# A tap by the teacher removes it, written on or not.
# 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]])