Files
Classroom_Desktop/tests/test-classroom-presentations.sh
T

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