#!/bin/bash # The Presentations library: what it imports, what it refuses, what it # keeps and what it is never allowed to delete. # # No LibreOffice and no poppler here. Those are exercised by the lab and # by the release validator against a real Debian; this is the part that # has to be right whatever is installed — when a download counts as # finished, whose files may be touched, what a teacher's choice of # thumbnail does and does not change, and which of the two retention # clocks is running. set -uo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" PYTHONPATH="$ROOT/lib" python3 - "$ROOT" <<'PY' import os import shutil import sys import tempfile import time from datetime import timedelta from pathlib import Path root = Path(sys.argv[1]) sys.path.insert(0, str(root / "lib")) import classroom_presentations as library passed = failed = 0 def check(label, condition, detail=""): global passed, failed if condition: print(f" ok {label}") passed += 1 else: print(f" FAIL {label}") if detail: print(f" {detail}") failed += 1 work = Path(tempfile.mkdtemp(prefix="classroom-presentations-")) downloads = work / "Downloads" wonderink = downloads / "Wonder Ink" shelf = work / "library" wonderink.mkdir(parents=True) shelf.mkdir(parents=True) def download(name, contents="x" * 400, age=60): """A file sitting in Downloads, finished `age` seconds ago.""" path = downloads / name path.parent.mkdir(parents=True, exist_ok=True) path.write_text(contents) when = time.time() - age os.utime(path, (when, when)) return path def entry(**data): """A presentation in the library, without running an import.""" directory = shelf / data.get("id", "one") directory.mkdir(parents=True, exist_ok=True) base = { "id": directory.name, "state": "ready", "title": "A session", "pages": 10, "thumbnail_page": 1, "imported_at": library.stamp(), "last_presented_at": None, "times_presented": 0, } base.update(data) presentation = library.Presentation(directory, base) presentation.save() (directory / "slides.pdf").write_text("%PDF-1.4") return presentation print("1. a download that has finished, and ones that have not") done = download("Session 7 - Romans.pptx") check("a settled PowerPoint is importable", library.importable(done)) arriving = download("Still arriving.pptx") partial = downloads / "Still arriving.pptx.crdownload" partial.write_text("half") check("one with a .crdownload beside it is not", not library.importable(arriving), "Chrome renames the .crdownload away when the transfer completes") check("and the .crdownload itself is never a candidate", not library.importable(partial)) fresh = download("Just landed.pdf", age=0) check("one still being written is not", not library.importable(fresh), "a file copied from a USB stick has no .crdownload to go by") check("something that is not a presentation is not", not library.importable(download("notes.txt"))) empty = download("Nothing.pdf", contents="", age=60) check("a nought-byte file is not", not library.importable(empty)) print("2. Wonder Ink's worksheets are not presentations") worksheet = wonderink / "Colouring page.pdf" worksheet.write_text("%PDF-1.4" + "x" * 400) os.utime(worksheet, (time.time() - 60, time.time() - 60)) check("a file in the Wonder Ink folder is recognised as Wonder Ink's", library.within_wonderink(worksheet, downloads)) check("and one beside it in Downloads is not", not library.within_wonderink(done, downloads)) names = [p.name for p in library.candidates(downloads)] check("so it is not offered for import", "Colouring page.pdf" not in names, str(names)) check("the finished session is", "Session 7 - Romans.pptx" in names, str(names)) check("and nothing unfinished is", "Still arriving.pptx" not in names and "Just landed.pdf" not in names, str(names)) print("3. titles a teacher would recognise") check("an ordinary name is kept", library.readable_title("Session 7 - Romans.pptx") == "Session 7 - Romans", library.readable_title("Session 7 - Romans.pptx")) check("underscores become spaces", library.readable_title("Session_7_Romans.pdf") == "Session 7 Romans", library.readable_title("Session_7_Romans.pdf")) check("a browser's duplicate counter goes", library.readable_title("Bible Study (1).pdf") == "Bible Study", library.readable_title("Bible Study (1).pdf")) check("a percent-escaped space is decoded", library.readable_title("Session%207.pdf") == "Session 7", library.readable_title("Session%207.pdf")) hashed = library.readable_title( "a4f39c2e91bb4d7e.pdf", "Session 7 of the autumn study\nmore") check("a hash falls back to the document's own first words", hashed == "Session 7 of the autumn study", hashed) check("and something with no name at all still gets one", library.readable_title("...pdf", "") == "Presentation", library.readable_title("...pdf", "")) print("4. two downloads of the same name are two presentations") first = entry(id="first", title="Session 7") second = entry(id="second", title="Session 7") # The bug this replaces: excluding the import being named BY TITLE also # removed the other presentation's identical title from the set, so a # PDF and a PPTX of one session both came out called "Session 7". taken = library.titles_in_use(shelf, exclude="second") check("the other presentation's title is still in the way", "Session 7" in taken, str(taken)) check("so the second gets a title of its own", library.unique_title("Session 7", taken) == "Session 7 (2)", library.unique_title("Session 7", taken)) check("and a name nothing else has is left alone", library.unique_title("Session 8", taken) == "Session 8") shutil.rmtree(shelf / "first") shutil.rmtree(shelf / "second") print("5. the thumbnail is the teacher's choice, and only that") # The first slide of a Bible study session is a generic opening shared by # every session; the one that names the session is usually the second. p = entry(id="thumb", pages=12) before = dict(p.data) p.set_thumbnail_page(2) after = library.load(shelf / "thumb") check("the chosen slide is remembered", after.thumbnail_page == 2, str(after.thumbnail_page)) check("it survives being read back from disk, so it survives a restart", library.load(shelf / "thumb").thumbnail_page == 2) check("choosing it did not mark the session as presented", after.last_presented_at is None and after.times_presented == 0) check("and did not move the retention clock", after.data["imported_at"] == before["imported_at"] and after.data.get("last_presented_at") is None) check("a slide past the end is clamped rather than stored", entry(id="thumb2", pages=5, thumbnail_page=99).thumbnail_page == 5) check("and a nonsense one does not crash the card", entry(id="thumb3", pages=5, thumbnail_page="banana").thumbnail_page == 1) print("6. the two retention clocks") old = library.now() - timedelta(days=181) recent = library.now() - timedelta(days=100) never = entry(id="never", imported_at=library.stamp(old)) check("never presented, imported over 180 days ago, is expired", never.expired(), str(never.expires_at())) young = entry(id="young", imported_at=library.stamp(recent)) check("never presented but only 100 days old is not", not young.expired(), str(young.expires_at())) shown = entry(id="shown", imported_at=library.stamp(old), last_presented_at=library.stamp(library.now() - timedelta(days=2)), times_presented=3) check("presented two days ago is kept, however old the import is", not shown.expired(), "presenting has to start the 30-day clock and stop the 180-day one") stale = entry(id="stale", imported_at=library.stamp(old), last_presented_at=library.stamp(library.now() - timedelta(days=31)), times_presented=1) check("presented 31 days ago is expired", stale.expired()) print("7. presenting is the only thing that resets the clock") p = entry(id="counted", pages=4) check("a fresh import has never been presented", p.last_presented_at is None and p.times_presented == 0) p.mark_presented() reread = library.load(shelf / "counted") check("presenting is recorded", reread.last_presented_at is not None) check("and counted", reread.times_presented == 1) first_due = reread.expires_at() time.sleep(1.1) reread.mark_presented() again = library.load(shelf / "counted") check("presenting again starts the thirty days over", again.expires_at() > first_due, f"{first_due} -> {again.expires_at()}") check("and counts again", again.times_presented == 2) print("8. an import that was interrupted is not shown to a teacher") half = entry(id="half", state="importing") check("it is in the library's own list", any(x.id == "half" for x in library.library(shelf))) check("but it is not ready, so no card offers Present", not library.load(shelf / "half").ready) broken = shelf / "broken" broken.mkdir() (broken / "presentation.json").write_text("{ this is not json") check("a truncated metadata file is skipped rather than crashing the library", all(x.id != "broken" for x in library.library(shelf)), "a card a teacher cannot open is worse than no card") print("9. the presenting lock cannot outlive a reboot") check("it lives in the runtime directory", "/run" in str(library.presenting_lock()) or str(library.presenting_lock()).startswith("/tmp"), str(library.presenting_lock())) print("10. the ledger remembers a download after its presentation is gone") mark = library.fingerprint(done) library.remember(mark, "some-id", shelf) check("a fingerprint is path, size and time", mark is not None and str(done.resolve()) in mark, str(mark)) check("it is written down", library.ledger(shelf).get(mark) == "some-id") check("so Remove means removed, not removed until the next scan", mark in library.ledger(shelf)) touched = download("Session 7 - Romans.pptx", contents="y" * 900) check("but re-downloading the same session is a new presentation", library.fingerprint(touched) != mark, "a different size or time is a different download") shutil.rmtree(work, ignore_errors=True) print() print(f"{passed} passed, {failed} failed") raise SystemExit(1 if failed else 0) PY