A host from the web site; the same page kept once; deleting from the Notebooks chooser

From the development repository at 299f73d, 2026-10-11:
hosting from the site documented; a page loaded from a notebook and left
unchanged is not kept again; notebooks and pages are deleted from the
chooser, to the system's Trash (--trash); the Notebooks button.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
ackandClaude Fable 5.1 committed 2026-10-11 16:59:03 +02:00
1 parent 4e3a281d65
commit 0d01e23d7c
15 files changed
+711 -63

No files matched your search

+95 -3
View File
@@ -521,6 +521,8 @@ class EndToEndTest(unittest.TestCase):
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)])
@@ -552,7 +554,8 @@ class EndToEndTest(unittest.TestCase):
site_option = ["--site", cls.site] if name == "Host" else []
cls.start([sys.executable, str(PROJECT / "kokkoja.py"), "--no-browser",
"--port", str(port), "--relay", f"ws://127.0.0.1:{relay_port}",
"--notebooks", str(cls.notebooks), "--config", str(config), *site_option])
"--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:
@@ -824,6 +827,21 @@ class EndToEndTest(unittest.TestCase):
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();")
@@ -2072,11 +2090,11 @@ class EndToEndTest(unittest.TestCase):
# is the student's again.
firefox.run("document.getElementById('load_notebook_page').click();")
notebooks = self.wait_until_true(lambda: firefox.run("""
return [...document.querySelectorAll('#chooser_notebooks button')].map((b) => b.textContent);
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')]
[...document.querySelectorAll('#chooser_notebooks button.notebook_pick')]
.find((b) => b.textContent === 'Teacher notebook').click();
""")
cards = self.wait_until_true(lambda: firefox.run("""
@@ -2140,6 +2158,80 @@ class EndToEndTest(unittest.TestCase):
"""), 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])
firefox.run("document.getElementById('chooser_delete').click();")
self.assertEqual(delete(), "Press again to move 2 pages to the Trash")
# Marking something else takes the first press back.
firefox.run("document.querySelectorAll('#chooser_pages .page_slot button.mark')[2].click();")
self.assertEqual(delete(), "Delete 3 pages?")
firefox.run("document.getElementById('chooser_delete').click();")
firefox.run("document.getElementById('chooser_delete').click();")
self.wait_until_true(lambda: firefox.run("return document.getElementById('chooser_message').textContent;"),
lambda text: text == "3 pages moved to the Trash.", "the deletion to be reported")
self.assertEqual(self.kept_pages("Teacher notebook"), before[3:])
self.assertEqual(sorted(p.name for p in self.trash.iterdir()), before[:3])
self.assertEqual(firefox.run("return document.querySelectorAll('#chooser_pages .page_slot').length;"),
count - 3)
self.assertIsNone(delete())
# The notebook being written in: its mark explains instead.
firefox.run("""
[...document.querySelectorAll('#chooser_notebooks .notebook_row')]
.find((r) => r.querySelector('.notebook_pick').textContent === 'Teacher notebook')
.querySelector('button.mark').click();
""")
self.assertIn("writing in this notebook", firefox.run("return document.getElementById('chooser_message').textContent;"))
self.assertIsNone(delete())
# Another notebook, with its pages, goes as one; the toolbar's list loses it.
self.assertTrue((self.notebooks / "Other notebook" / "Current.svg").exists())
firefox.run("""
[...document.querySelectorAll('#chooser_notebooks .notebook_row')]
.find((r) => r.querySelector('.notebook_pick').textContent === 'Other notebook')
.querySelector('button.mark').click();
""")
self.assertEqual(delete(), "Delete 1 notebook?")
firefox.run("document.getElementById('chooser_delete').click();")
self.assertEqual(delete(), "Press again to move 1 notebook to the Trash")
firefox.run("document.getElementById('chooser_delete').click();")
self.wait_until_true(lambda: firefox.run("return document.getElementById('chooser_message').textContent;"),
lambda text: text == "1 notebook moved to the Trash.", "the notebook's deletion to be reported")
self.assertFalse((self.notebooks / "Other notebook").exists())
self.assertTrue((self.trash / "Other notebook" / "Current.svg").exists())
self.assertNotIn("Other notebook", firefox.run("""
return [...document.querySelectorAll('#chooser_notebooks button.notebook_pick')].map((b) => b.textContent);
"""))
self.wait_until_true(lambda: firefox.run("""
return [...document.getElementById('session_notebook').options].map((o) => o.textContent);
"""), lambda names: "Other notebook" not in names, "the toolbar's list to lose the notebook")
firefox.run("document.getElementById('chooser_close').click();")
# A 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)
+93 -1
View File
@@ -28,14 +28,29 @@ TEXT_ONLY_LAYER = (b'<g data-layer="abc" data-name="Andy"><g data-text="t1" data
WRITTEN_PAGE = b'<svg data-kokkoja="1"><g data-layer="a"><g data-stroke="s"/></g></svg>\n'
def page_named(session, page, number, body=b'<g data-layer="a"><g data-stroke="s"/></g>'):
"""A page file as the client writes it: named on the root element and
in the title, then the paper and the content."""
return (b'<?xml version="1.0" encoding="UTF-8"?>\n'
b'<svg xmlns="http://www.w3.org/2000/svg" data-kokkoja="1" data-session="' + session +
b'" data-host="h" data-page="' + page + b'" data-number="' + number +
b'" data-base="2970 2100" viewBox="0 0 2970 2100">\n'
b'<title>Kokkoja page ' + number + b'</title>\n'
b'<rect x="0" y="0" width="2970" height="2100" fill="#ffffff"/>\n' + body + b'</svg>\n')
class LocalProgramTest(unittest.TestCase):
def setUp(self):
self.directory = tempfile.TemporaryDirectory()
self.addCleanup(self.directory.cleanup)
self.notebooks = pathlib.Path(self.directory.name) / "notebooks"
self.config = pathlib.Path(self.directory.name) / "config"
# Deleted things go to a Trash of the test's own, a plain
# directory as macOS's Trash is; the freedesktop layout has its
# own test.
self.trash = pathlib.Path(self.directory.name) / "trash"
self.server = kokkoja.make_server(0, "ws://relay.example:8770/", self.notebooks, TOKEN,
self.config)
self.config, trash=kokkoja.Trash(self.trash))
self.port = self.server.server_address[1]
thread = threading.Thread(target=self.server.serve_forever, daemon=True)
thread.start()
@@ -166,6 +181,83 @@ class LocalProgramTest(unittest.TestCase):
self.request("PUT", current, body=b'<svg data-kokkoja="1">' + TEXT_ONLY_LAYER + b"</svg>\n")
self.assertEqual(self.json("POST", keep), (200, {"kept": f"{today}_03.svg", "new": True}))
# The same page under other names is the same page: loaded from
# the notebook into another session, it is a new page with a new
# id and number, and is not kept again when left unchanged. A
# different sheet, or typed text that happens to name a page, is
# not the same.
self.request("PUT", current, body=page_named(b"s1", b"p1", b"4"))
self.assertEqual(self.json("POST", keep), (200, {"kept": f"{today}_04.svg", "new": True}))
self.request("PUT", current, body=page_named(b"s2", b"p2", b"11"))
self.assertEqual(self.json("POST", keep), (200, {"kept": f"{today}_04.svg", "new": False}))
self.request("PUT", current, body=page_named(b"s2", b"p2", b"11").replace(b'viewBox="0 0', b'viewBox="-50 0'))
self.assertEqual(self.json("POST", keep), (200, {"kept": f"{today}_05.svg", "new": True}))
typed = page_named(b"s1", b"p1", b"4", body=b'<g data-layer="a"><g data-text="t"><desc>data-page="p1"</desc></g></g>')
self.request("PUT", current, body=typed)
self.assertEqual(self.json("POST", keep), (200, {"kept": f"{today}_06.svg", "new": True}))
self.request("PUT", current, body=typed.replace(b'<desc>data-page="p1"', b'<desc>data-page="p2"'))
self.assertEqual(self.json("POST", keep), (200, {"kept": f"{today}_07.svg", "new": True}))
def test_deleting_pages_and_notebooks(self):
notebook = self.notebooks / "Andy"
notebook.mkdir(parents=True)
for name in ("Current.svg", "2026_10_01_01.svg", "2026_10_01_02.svg"):
(notebook / name).write_bytes(WRITTEN_PAGE)
(self.notebooks / "Other").mkdir()
(self.notebooks / "Other" / "2026_10_02_01.svg").write_bytes(WRITTEN_PAGE)
self.assertTrue(self.json("GET", "/api/notebooks")[1]["trash"])
# A kept page goes to the Trash; the page being written stays.
self.assertEqual(self.json("DELETE", "/api/notebooks/Andy/2026_10_01_01.svg"),
(200, {"discarded": "2026_10_01_01.svg", "trash": True}))
self.assertEqual(self.json("GET", "/api/notebooks/Andy")[1]["pages"], ["2026_10_01_02.svg", "Current.svg"])
self.assertEqual((self.trash / "2026_10_01_01.svg").read_bytes(), WRITTEN_PAGE)
self.assertEqual(self.request("DELETE", "/api/notebooks/Andy/Current.svg")[0], 400)
self.assertEqual(self.request("DELETE", "/api/notebooks/Andy/none.svg")[0], 404)
self.assertEqual(self.request("DELETE", "/api/notebooks")[0], 405)
self.assertEqual(self.request("DELETE", "/api/notebooks/Andy/2026_10_01_02.svg", token=None)[0], 401)
# A whole notebook goes as one, and a name already taken in the
# Trash is not overwritten.
(self.notebooks / "Andy" / "2026_10_01_01.svg").write_bytes(b"kept again")
self.assertEqual(self.json("DELETE", "/api/notebooks/Andy"),
(200, {"discarded": "Andy", "trash": True}))
self.assertEqual(self.json("GET", "/api/notebooks")[1]["notebooks"], ["Other"])
self.assertEqual(sorted(p.name for p in (self.trash / "Andy").iterdir()),
["2026_10_01_01.svg", "2026_10_01_02.svg", "Current.svg"])
(self.notebooks / "Andy").mkdir()
(self.notebooks / "Andy" / "Current.svg").write_bytes(b"a second Andy")
self.assertEqual(self.json("DELETE", "/api/notebooks/Andy")[1]["discarded"], "Andy")
self.assertEqual((self.trash / "Andy.2" / "Current.svg").read_bytes(), b"a second Andy")
self.assertEqual((self.trash / "2026_10_01_01.svg").read_bytes(), WRITTEN_PAGE)
def test_the_freedesktop_trash(self):
basket = pathlib.Path(self.directory.name) / "Trash"
trash = kokkoja.Trash(basket, freedesktop=True)
page = self.notebooks / "Andy" / "2026_10_01_01.svg"
page.parent.mkdir(parents=True)
page.write_bytes(WRITTEN_PAGE)
trash.take(page)
self.assertFalse(page.exists())
self.assertEqual((basket / "files" / page.name).read_bytes(), WRITTEN_PAGE)
info = (basket / "info" / f"{page.name}.trashinfo").read_text()
self.assertTrue(info.startswith("[Trash Info]\nPath="), info)
self.assertIn(f"Path={urllib.parse.quote(str(page.resolve()))}\n", info)
self.assertRegex(info, r"DeletionDate=\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\n")
self.assertEqual(basket.stat().st_mode & 0o777, 0o700)
# The same name again: the info file reserves the first, so the
# second gets a serial.
page.write_bytes(b"another")
trash.take(page)
self.assertEqual((basket / "files" / "2026_10_01_01.2.svg").read_bytes(), b"another")
self.assertTrue((basket / "info" / "2026_10_01_01.2.svg.trashinfo").exists())
# Without a Trash, a deletion is for good.
page.write_bytes(b"gone")
self.assertFalse(kokkoja.discard(page, None))
self.assertFalse(page.exists())
self.assertTrue(kokkoja.discard(page.parent, kokkoja.Trash(basket)))
self.assertTrue((basket / "Andy").is_dir())
def test_a_kept_page_is_dated_when_it_was_last_changed(self):
notebook = self.notebooks / "Andy"
notebook.mkdir(parents=True)