|
| 1 | +""" |
| 2 | +Release-readiness tests — verify the VENV (the Python used in the released |
| 3 | +AppImage) has every package that every pipeline script imports. |
| 4 | +
|
| 5 | +Run with the project's conda env so pytest itself is available, but the |
| 6 | +imports are tested against the *venv* Python (~/.auto_note/venv/bin/python) |
| 7 | +which is what the GUI actually invokes. |
| 8 | +
|
| 9 | + conda run -n auto-note python -m pytest test/test_release.py -v |
| 10 | +""" |
| 11 | +from __future__ import annotations |
| 12 | + |
| 13 | +import subprocess |
| 14 | +import sys |
| 15 | +from pathlib import Path |
| 16 | + |
| 17 | +import pytest |
| 18 | + |
| 19 | +# ── Resolve paths ───────────────────────────────────────────────────────────── |
| 20 | + |
| 21 | +PROJECT_DIR = Path(__file__).parent.parent |
| 22 | +_AUTO_NOTE = Path.home() / ".auto_note" |
| 23 | +SCRIPTS_DIR = _AUTO_NOTE / "scripts" |
| 24 | +VENV_PYTHON = str(_AUTO_NOTE / "venv" / "bin" / "python") |
| 25 | + |
| 26 | +pytestmark = pytest.mark.skipif( |
| 27 | + not Path(VENV_PYTHON).exists(), |
| 28 | + reason=f"Venv not found at {VENV_PYTHON} — run the app installer first", |
| 29 | +) |
| 30 | + |
| 31 | + |
| 32 | +def _venv_import(module: str) -> tuple[bool, str]: |
| 33 | + """Return (success, error_msg) for importing module in the venv.""" |
| 34 | + r = subprocess.run( |
| 35 | + [VENV_PYTHON, "-c", f"import {module}"], |
| 36 | + capture_output=True, text=True, |
| 37 | + ) |
| 38 | + return r.returncode == 0, r.stderr.strip() |
| 39 | + |
| 40 | + |
| 41 | +def _assert_import(module: str, pip_name: str | None = None) -> None: |
| 42 | + ok, err = _venv_import(module) |
| 43 | + install_hint = pip_name or module |
| 44 | + assert ok, ( |
| 45 | + f"Venv is missing '{module}' (install: pip install {install_hint}).\n" |
| 46 | + f"Error: {err}" |
| 47 | + ) |
| 48 | + |
| 49 | + |
| 50 | +# ── Core packages (used at module-import time) ──────────────────────────────── |
| 51 | + |
| 52 | +class TestCorePackages: |
| 53 | + """Every package imported at the top of a pipeline script must be present.""" |
| 54 | + |
| 55 | + def test_canvasapi(self): |
| 56 | + _assert_import("canvasapi") |
| 57 | + |
| 58 | + def test_tqdm(self): |
| 59 | + _assert_import("tqdm") |
| 60 | + |
| 61 | + def test_requests(self): |
| 62 | + _assert_import("requests") |
| 63 | + |
| 64 | + def test_faiss(self): |
| 65 | + _assert_import("faiss") |
| 66 | + |
| 67 | + def test_numpy(self): |
| 68 | + _assert_import("numpy") |
| 69 | + |
| 70 | + def test_torch(self): |
| 71 | + _assert_import("torch") |
| 72 | + |
| 73 | + |
| 74 | +# ── Lazy / conditional packages ─────────────────────────────────────────────── |
| 75 | + |
| 76 | +class TestLazyPackages: |
| 77 | + """Packages imported only inside functions / on first use.""" |
| 78 | + |
| 79 | + def test_faster_whisper(self): |
| 80 | + _assert_import("faster_whisper", "faster-whisper") |
| 81 | + |
| 82 | + def test_sentence_transformers(self): |
| 83 | + _assert_import("sentence_transformers", "sentence-transformers") |
| 84 | + |
| 85 | + def test_openai(self): |
| 86 | + _assert_import("openai") |
| 87 | + |
| 88 | + def test_anthropic(self): |
| 89 | + _assert_import("anthropic") |
| 90 | + |
| 91 | + def test_pymupdf(self): |
| 92 | + _assert_import("fitz", "pymupdf") |
| 93 | + |
| 94 | + def test_pptx(self): |
| 95 | + _assert_import("pptx", "python-pptx") |
| 96 | + |
| 97 | + def test_docx(self): |
| 98 | + _assert_import("docx", "python-docx") |
| 99 | + |
| 100 | + def test_pillow(self): |
| 101 | + _assert_import("PIL", "pillow") |
| 102 | + |
| 103 | + def test_playwright(self): |
| 104 | + _assert_import("playwright", "playwright") |
| 105 | + |
| 106 | + def test_httpx(self): |
| 107 | + _assert_import("httpx") |
| 108 | + |
| 109 | + def test_PanoptoDownloader(self): |
| 110 | + _assert_import( |
| 111 | + "PanoptoDownloader", |
| 112 | + "git+https://github.com/Panopto-Video-DL/Panopto-Video-DL-lib.git", |
| 113 | + ) |
| 114 | + |
| 115 | + def test_ffmpeg_progress_yield(self): |
| 116 | + _assert_import("ffmpeg_progress_yield", "ffmpeg-progress-yield") |
| 117 | + |
| 118 | + def test_pycryptodomex(self): |
| 119 | + _assert_import("Cryptodome", "pycryptodomex") |
| 120 | + |
| 121 | + |
| 122 | +# ── Script-level importability ──────────────────────────────────────────────── |
| 123 | + |
| 124 | +class TestScriptImports: |
| 125 | + """Each installed script must be importable from SCRIPTS_DIR.""" |
| 126 | + |
| 127 | + @pytest.fixture(autouse=True) |
| 128 | + def _skip_if_not_installed(self): |
| 129 | + if not SCRIPTS_DIR.exists(): |
| 130 | + pytest.skip("Scripts not installed yet") |
| 131 | + |
| 132 | + def _run_import(self, script_stem: str) -> tuple[bool, str]: |
| 133 | + r = subprocess.run( |
| 134 | + [VENV_PYTHON, "-c", f"import {script_stem}; print('OK')"], |
| 135 | + capture_output=True, text=True, |
| 136 | + cwd=str(SCRIPTS_DIR), |
| 137 | + ) |
| 138 | + return r.returncode == 0, (r.stdout + r.stderr).strip() |
| 139 | + |
| 140 | + def test_downloader_importable(self): |
| 141 | + ok, out = self._run_import("downloader") |
| 142 | + assert ok, f"downloader import failed:\n{out}" |
| 143 | + |
| 144 | + def test_extract_caption_importable(self): |
| 145 | + ok, out = self._run_import("extract_caption") |
| 146 | + assert ok, f"extract_caption import failed:\n{out}" |
| 147 | + |
| 148 | + def test_semantic_alignment_importable(self): |
| 149 | + ok, out = self._run_import("semantic_alignment") |
| 150 | + assert ok, f"semantic_alignment import failed:\n{out}" |
| 151 | + |
| 152 | + def test_alignment_parser_importable(self): |
| 153 | + ok, out = self._run_import("alignment_parser") |
| 154 | + assert ok, f"alignment_parser import failed:\n{out}" |
| 155 | + |
| 156 | + def test_note_generation_importable(self): |
| 157 | + ok, out = self._run_import("note_generation") |
| 158 | + assert ok, ( |
| 159 | + f"note_generation import failed (alignment_parser missing?):\n{out}" |
| 160 | + ) |
| 161 | + |
| 162 | + def test_all_scripts_present(self): |
| 163 | + required = [ |
| 164 | + "downloader.py", |
| 165 | + "extract_caption.py", |
| 166 | + "semantic_alignment.py", |
| 167 | + "alignment_parser.py", |
| 168 | + "note_generation.py", |
| 169 | + ] |
| 170 | + for name in required: |
| 171 | + assert (SCRIPTS_DIR / name).exists(), ( |
| 172 | + f"{name} not in {SCRIPTS_DIR} — " |
| 173 | + "_install_scripts() may not have copied it" |
| 174 | + ) |
| 175 | + |
| 176 | + |
| 177 | +# ── CLI smoke tests (venv Python) ───────────────────────────────────────────── |
| 178 | + |
| 179 | +class TestCLISmoke: |
| 180 | + """--help on every script should exit 0 with the venv Python.""" |
| 181 | + |
| 182 | + @pytest.fixture(autouse=True) |
| 183 | + def _skip_if_not_installed(self): |
| 184 | + if not SCRIPTS_DIR.exists(): |
| 185 | + pytest.skip("Scripts not installed yet") |
| 186 | + |
| 187 | + def _help(self, script: str, timeout: int = 15) -> subprocess.CompletedProcess: |
| 188 | + return subprocess.run( |
| 189 | + [VENV_PYTHON, str(SCRIPTS_DIR / script), "--help"], |
| 190 | + capture_output=True, text=True, timeout=timeout, |
| 191 | + ) |
| 192 | + |
| 193 | + def test_downloader_help(self): |
| 194 | + r = self._help("downloader.py") |
| 195 | + assert r.returncode == 0, r.stderr[:500] |
| 196 | + assert "--material-list" in r.stdout |
| 197 | + |
| 198 | + def test_extract_caption_help(self): |
| 199 | + r = self._help("extract_caption.py") |
| 200 | + assert r.returncode == 0, r.stderr[:500] |
| 201 | + assert "--video" in r.stdout |
| 202 | + |
| 203 | + def test_note_generation_help(self): |
| 204 | + r = self._help("note_generation.py") |
| 205 | + assert r.returncode == 0, r.stderr[:500] |
| 206 | + assert "--course" in r.stdout |
| 207 | + |
| 208 | + def test_downloader_no_args_prints_help(self): |
| 209 | + r = subprocess.run( |
| 210 | + [VENV_PYTHON, str(SCRIPTS_DIR / "downloader.py")], |
| 211 | + capture_output=True, text=True, timeout=10, |
| 212 | + ) |
| 213 | + assert r.returncode == 0 |
| 214 | + |
| 215 | + def test_note_generation_missing_materials_clean_exit(self): |
| 216 | + """note_generation with unknown course must print [error] not traceback.""" |
| 217 | + r = subprocess.run( |
| 218 | + [VENV_PYTHON, str(SCRIPTS_DIR / "note_generation.py"), |
| 219 | + "--course", "99999"], |
| 220 | + capture_output=True, text=True, timeout=15, |
| 221 | + cwd=str(_AUTO_NOTE), |
| 222 | + ) |
| 223 | + combined = r.stdout + r.stderr |
| 224 | + assert "Traceback" not in combined, ( |
| 225 | + f"Unhandled traceback — should print [error] message:\n{combined[:1000]}" |
| 226 | + ) |
| 227 | + assert r.returncode != 0 # should fail with sys.exit(1) |
| 228 | + |
| 229 | + |
| 230 | +# ── ffmpeg availability (needed by PanoptoDownloader) ───────────────────────── |
| 231 | + |
| 232 | +class TestSystemDeps: |
| 233 | + def test_ffmpeg_on_path(self): |
| 234 | + """PanoptoDownloader calls ffmpeg at runtime — it must be on PATH.""" |
| 235 | + r = subprocess.run( |
| 236 | + ["ffmpeg", "-version"], capture_output=True, text=True |
| 237 | + ) |
| 238 | + assert r.returncode == 0, ( |
| 239 | + "ffmpeg not found on PATH. Install with: sudo pacman -S ffmpeg" |
| 240 | + ) |
| 241 | + |
| 242 | + def test_ffprobe_on_path(self): |
| 243 | + r = subprocess.run( |
| 244 | + ["ffprobe", "-version"], capture_output=True, text=True |
| 245 | + ) |
| 246 | + assert r.returncode == 0, ( |
| 247 | + "ffprobe not found on PATH. Install with: sudo pacman -S ffmpeg" |
| 248 | + ) |
0 commit comments