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:
1 parent
4e3a281d65
commit
0d01e23d7c
15 files changed
+711
-63
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user