431 lines
17 KiB
Bash
Executable File
431 lines
17 KiB
Bash
Executable File
#!/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 errno
|
|
import importlib.util
|
|
import shutil
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
from datetime import timedelta
|
|
from importlib.machinery import SourceFileLoader
|
|
from pathlib import Path
|
|
|
|
root = Path(sys.argv[1])
|
|
sys.path.insert(0, str(root / "lib"))
|
|
|
|
import classroom_presentations as library
|
|
|
|
loader = SourceFileLoader(
|
|
"classroom_presentation_import",
|
|
str(root / "scripts" / "classroom-presentation-import"),
|
|
)
|
|
spec = importlib.util.spec_from_loader(loader.name, loader)
|
|
importer = importlib.util.module_from_spec(spec)
|
|
loader.exec_module(importer)
|
|
|
|
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 identifies a download transaction")
|
|
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 an interrupted move can be matched after a restart",
|
|
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")
|
|
|
|
print("11. a completed download is moved into the managed library")
|
|
original_render_pages = library.render_pages
|
|
original_render_thumbnail = library.render_thumbnail
|
|
original_first_text = library.first_text
|
|
original_convert = library.convert_to_pdf
|
|
|
|
|
|
def fake_render_pages(_pdf, directory, width=library.SLIDE_WIDTH,
|
|
progress=None):
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
(directory / "page-001.png").write_bytes(b"png")
|
|
return 1, ""
|
|
|
|
|
|
def fake_render_thumbnail(_source, destination,
|
|
width=library.THUMBNAIL_WIDTH):
|
|
destination.write_bytes(b"thumbnail")
|
|
return True
|
|
|
|
|
|
library.render_pages = fake_render_pages
|
|
library.render_thumbnail = fake_render_thumbnail
|
|
library.first_text = lambda _pdf, pages=2: ""
|
|
|
|
moved = download("Move me.pdf", contents="%PDF-1.4 moved")
|
|
moved_contents = moved.read_bytes()
|
|
managed = importer.import_one(moved, shelf)
|
|
check("the source leaves Downloads after import", not moved.exists())
|
|
check("the one original is kept in the managed entry",
|
|
managed is not None and managed.managed_copy.read_bytes() == moved_contents,
|
|
str(managed.managed_copy if managed else None))
|
|
check("its PDF and page images are derived assets",
|
|
managed is not None and managed.ready and managed.slides.exists()
|
|
and managed.page_image(1).exists())
|
|
|
|
print("12. conversion failure does not lose the downloaded original")
|
|
library.convert_to_pdf = lambda _source, _destination: (False, "broken deck")
|
|
broken_source = download("Broken deck.pptx", contents="not really a deck")
|
|
failed_import = importer.import_one(broken_source, shelf)
|
|
check("the failed source was still moved out of Downloads",
|
|
not broken_source.exists())
|
|
check("a failed card retains the managed original",
|
|
failed_import is not None and failed_import.failed
|
|
and failed_import.managed_copy.read_text() == "not really a deck")
|
|
check("the failure did not create a second original",
|
|
failed_import is not None
|
|
and list((failed_import.directory / "original").iterdir())
|
|
== [failed_import.managed_copy])
|
|
|
|
print("13. a cross-filesystem move publishes before deleting")
|
|
cross_source = download("Across filesystems.pdf", contents="cross-device")
|
|
cross_destination = work / "other-device" / cross_source.name
|
|
real_replace = importer.os.replace
|
|
source_text = str(cross_source)
|
|
destination_text = str(cross_destination)
|
|
|
|
|
|
def cross_replace(source, destination):
|
|
if os.fspath(source) == source_text and os.fspath(destination) == destination_text:
|
|
raise OSError(errno.EXDEV, "different filesystems")
|
|
return real_replace(source, destination)
|
|
|
|
|
|
importer.os.replace = cross_replace
|
|
try:
|
|
importer.move_download(cross_source, cross_destination)
|
|
finally:
|
|
importer.os.replace = real_replace
|
|
|
|
check("the cross-filesystem destination is complete",
|
|
cross_destination.read_text() == "cross-device")
|
|
check("only the destination remains after it is durable",
|
|
not cross_source.exists()
|
|
and not cross_destination.with_name(cross_destination.name + ".moving").exists())
|
|
|
|
print("14. restart recovery never deletes the only moved original")
|
|
resume_dir = shelf / "resume"
|
|
resume_original = resume_dir / "original" / "Resume.pdf"
|
|
resume_original.parent.mkdir(parents=True)
|
|
resume_original.write_text("%PDF-1.4 resume")
|
|
resume_data = {
|
|
"id": "resume",
|
|
"state": "importing",
|
|
"title": "Resume",
|
|
"original_name": "Resume.pdf",
|
|
"source_path": str(downloads / "Resume.pdf"),
|
|
"managed_copy": "original/Resume.pdf",
|
|
"format": "pdf",
|
|
"imported_at": library.stamp(),
|
|
"last_presented_at": None,
|
|
"times_presented": 0,
|
|
"thumbnail_page": 1,
|
|
"pages": 0,
|
|
"error": None,
|
|
}
|
|
library.Presentation(resume_dir, resume_data).save()
|
|
importer.sweep(shelf)
|
|
resumed = library.load(resume_dir)
|
|
check("an import interrupted after the move resumes in place",
|
|
resumed is not None and resumed.ready)
|
|
check("and its original survives recovery", resume_original.exists())
|
|
|
|
print("15. removal is isolated and refuses an active presentation")
|
|
remove_one = entry(id="remove-one", title="Similar title")
|
|
keep_one = entry(id="keep-one", title="Similar title (2)")
|
|
(remove_one.directory / "original").mkdir()
|
|
(remove_one.directory / "original" / "one.pdf").write_text("one")
|
|
(keep_one.directory / "original").mkdir()
|
|
(keep_one.directory / "original" / "two.pdf").write_text("two")
|
|
check("removing one managed entry succeeds", library.remove(remove_one))
|
|
check("a similarly named presentation and its files are untouched",
|
|
keep_one.directory.exists()
|
|
and (keep_one.directory / "original" / "two.pdf").exists())
|
|
|
|
runtime = work / "runtime"
|
|
runtime.mkdir()
|
|
old_runtime = os.environ.get("XDG_RUNTIME_DIR")
|
|
os.environ["XDG_RUNTIME_DIR"] = str(runtime)
|
|
library.presenting_lock().write_text(keep_one.id)
|
|
check("an actively presented entry cannot be removed",
|
|
not library.remove(keep_one) and keep_one.directory.exists())
|
|
library.presenting_lock().unlink()
|
|
if old_runtime is None:
|
|
os.environ.pop("XDG_RUNTIME_DIR", None)
|
|
else:
|
|
os.environ["XDG_RUNTIME_DIR"] = old_runtime
|
|
|
|
library.render_pages = original_render_pages
|
|
library.render_thumbnail = original_render_thumbnail
|
|
library.first_text = original_first_text
|
|
library.convert_to_pdf = original_convert
|
|
|
|
print("16. teacher-facing wording and compositor policy")
|
|
ui = (root / "scripts" / "classroom-presentations").read_text()
|
|
layout = (root / "config" / "plasma" / "lookandfeel" / "contents"
|
|
/ "layouts" / "org.kde.plasma.desktop-layout.js").read_text()
|
|
kwin = (root / "config" / "plasma" / "xdg" / "kwinrc").read_text()
|
|
rules = (root / "config" / "plasma" / "xdg" / "kwinrulesrc").read_text()
|
|
check("the empty state has one concise browser-neutral instruction",
|
|
'label="No presentations yet\\n\\n"' in ui
|
|
and "Download a PowerPoint or PDF to get started." in ui
|
|
and "Download a presentation in Chrome" not in ui)
|
|
check("cover selection uses the requested plain wording",
|
|
'label="Use as cover"' in ui and 'label="Current cover"' in ui
|
|
and "this is the library picture" not in ui)
|
|
check("Remove no longer promises a copy in Chrome",
|
|
"This will delete it from this computer." in ui
|
|
and "Your download in Chrome" not in ui)
|
|
check("the presenter has a compositor-enforced fullscreen rule",
|
|
"title=Presenting" in rules and "fullscreen=true" in rules
|
|
and "fullscreenrule=2" in rules)
|
|
check("normal title bars contain only Close",
|
|
"ButtonsOnRight=X" in kwin and "ButtonsOnRight=FX" not in kwin)
|
|
check("the unfinished Help launcher is absent from the panel",
|
|
"ekklesia-manual.desktop" not in layout)
|
|
|
|
shutil.rmtree(work, ignore_errors=True)
|
|
|
|
print()
|
|
print(f"{passed} passed, {failed} failed")
|
|
raise SystemExit(1 if failed else 0)
|
|
PY
|