Spaces:
Running
Running
| """CLI integration tests. | |
| These tests exercise the CLI surface without spawning a subprocess by | |
| invoking typer's CliRunner directly. They rely on the existing | |
| smaller.m4v + M14_L3_S3.srt media in the repo root for the slow path, | |
| but they're skipped if those files aren't present so the suite stays | |
| green in environments that only check out source. | |
| """ | |
| from __future__ import annotations | |
| from pathlib import Path | |
| import pytest | |
| from typer.testing import CliRunner | |
| from app.cli import app | |
| from app.pipeline.metadata import load_metadata | |
| REPO_ROOT = Path(__file__).resolve().parents[1] | |
| VIDEO = REPO_ROOT / "smaller.m4v" | |
| TRANSCRIPT = REPO_ROOT / "M14_L3_S3.srt" | |
| requires_media = pytest.mark.skipif( | |
| not (VIDEO.exists() and TRANSCRIPT.exists()), | |
| reason="sample media (smaller.m4v / M14_L3_S3.srt) not present", | |
| ) | |
| def test_cli_help_lists_subcommands(): | |
| runner = CliRunner() | |
| result = runner.invoke(app, ["--help"]) | |
| assert result.exit_code == 0 | |
| out = result.stdout | |
| assert "build" in out | |
| assert "export-metadata" in out | |
| assert "render-from-metadata" in out | |
| def test_cli_export_then_render_is_idempotent(tmp_path): | |
| runner = CliRunner() | |
| meta_path = tmp_path / "study_guide_metadata.json" | |
| frames_dir = tmp_path / "frames" | |
| result = runner.invoke( | |
| app, | |
| [ | |
| "export-metadata", | |
| str(VIDEO), | |
| str(TRANSCRIPT), | |
| "--title", "M14 L3 S3", | |
| "--subtitle", "Prompting", | |
| "--module", "Module 14", | |
| "--output", str(meta_path), | |
| "--frames-dir", str(frames_dir), | |
| ], | |
| ) | |
| assert result.exit_code == 0, result.stdout + (result.stderr or "") | |
| assert meta_path.exists() | |
| page = load_metadata(meta_path) | |
| assert page.title == "M14 L3 S3" | |
| assert page.subtitle == "Prompting" | |
| assert page.module == "Module 14" | |
| assert len(page.segments) > 0 | |
| assert page.segments[0].image_filename.startswith("scene_") | |
| # Now re-render twice and confirm bytes are identical. | |
| out1 = tmp_path / "out1.html" | |
| out2 = tmp_path / "out2.html" | |
| for out in (out1, out2): | |
| result = runner.invoke( | |
| app, | |
| [ | |
| "render-from-metadata", | |
| str(meta_path), | |
| "--output", str(out), | |
| "--frames-dir", str(frames_dir), | |
| "--format", "single", | |
| ], | |
| ) | |
| assert result.exit_code == 0, result.stdout + (result.stderr or "") | |
| assert out.exists() | |
| assert out1.read_bytes() == out2.read_bytes() | |
| def test_cli_skip_ocr_and_max_frames(tmp_path): | |
| runner = CliRunner() | |
| meta = tmp_path / "meta.json" | |
| frames_dir = tmp_path / "frames" | |
| out = tmp_path / "out.html" | |
| result = runner.invoke( | |
| app, | |
| [ | |
| "build", | |
| str(VIDEO), | |
| str(TRANSCRIPT), | |
| "--title", "Skip OCR", | |
| "--output", str(out), | |
| "--frames-dir", str(frames_dir), | |
| "--export-metadata", str(meta), | |
| "--skip-ocr", | |
| "--max-frames", "2", | |
| "--format", "single", | |
| ], | |
| ) | |
| assert result.exit_code == 0, result.stdout + (result.stderr or "") | |
| page = load_metadata(meta) | |
| assert len(page.segments) <= 2 | |
| # All segments should have empty OCR because of --skip-ocr. | |
| assert all(seg.ocr_text == "" for seg in page.segments) | |
| def test_cli_build_single_inlines_audio_data_uris(tmp_path): | |
| """`build --format single` must inline audio as data URIs so the | |
| self-contained HTML doesn't reference a missing static/ folder.""" | |
| runner = CliRunner() | |
| out = tmp_path / "single.html" | |
| frames_dir = tmp_path / "static" | |
| result = runner.invoke( | |
| app, | |
| [ | |
| "build", | |
| str(VIDEO), | |
| str(TRANSCRIPT), | |
| "--title", "Audio Inline", | |
| "--output", str(out), | |
| "--frames-dir", str(frames_dir), | |
| "--format", "single", | |
| "--max-frames", "2", | |
| ], | |
| ) | |
| assert result.exit_code == 0, result.stdout + (result.stderr or "") | |
| html = out.read_text(encoding="utf-8") | |
| if "<audio" in html: | |
| # If ffmpeg sliced anything, every <audio> src must be inlined — | |
| # never a relative 'static/foo.mp3' or 'audio/foo.mp3'. | |
| assert "data:audio/mpeg;base64," in html | |
| assert 'src="audio/' not in html | |
| assert 'src="static/segment_' not in html | |
| def test_cli_build_zip_consolidates_assets_under_static(tmp_path): | |
| """`build --format zip` puts both frames and audio in a single | |
| static/ folder so the bundle has a clean two-entry layout.""" | |
| import zipfile | |
| runner = CliRunner() | |
| out = tmp_path / "guide.zip" | |
| frames_dir = tmp_path / "static" | |
| result = runner.invoke( | |
| app, | |
| [ | |
| "build", | |
| str(VIDEO), | |
| str(TRANSCRIPT), | |
| "--title", "Audio Zip", | |
| "--output", str(out), | |
| "--frames-dir", str(frames_dir), | |
| "--format", "zip", | |
| "--max-frames", "2", | |
| ], | |
| ) | |
| assert result.exit_code == 0, result.stdout + (result.stderr or "") | |
| with zipfile.ZipFile(out, "r") as zf: | |
| names = zf.namelist() | |
| # No legacy audio/ folder anywhere in the zip. | |
| assert not any(n.startswith("audio/") for n in names), ( | |
| f"unexpected audio/ entries: {[n for n in names if n.startswith('audio/')]}" | |
| ) | |
| # Top-level entries are only the html and the static folder. | |
| top_level = {n.split("/", 1)[0] for n in names} | |
| assert top_level <= {"static"} | {n for n in names if n.endswith(".html")}, ( | |
| f"unexpected top-level entries: {top_level}" | |
| ) | |
| # Frames are present; audio is present iff the html references it. | |
| assert any(n.startswith("static/scene_") and n.endswith(".jpg") for n in names) | |
| html_name = next((n for n in names if n.endswith(".html")), None) | |
| assert html_name is not None | |
| with zipfile.ZipFile(out, "r") as zf: | |
| html = zf.read(html_name).decode("utf-8") | |
| if "<audio" in html: | |
| assert any(n.startswith("static/segment_") and n.endswith(".mp3") for n in names), ( | |
| "HTML references audio but no static/segment_*.mp3 in the zip" | |
| ) | |
| def test_cli_build_writes_html_and_metadata(tmp_path): | |
| runner = CliRunner() | |
| out = tmp_path / "guide.html" | |
| meta = tmp_path / "meta.json" | |
| frames_dir = tmp_path / "frames" | |
| result = runner.invoke( | |
| app, | |
| [ | |
| "build", | |
| str(VIDEO), | |
| str(TRANSCRIPT), | |
| "--title", "Build Test", | |
| "--output", str(out), | |
| "--frames-dir", str(frames_dir), | |
| "--format", "single", | |
| "--export-metadata", str(meta), | |
| ], | |
| ) | |
| assert result.exit_code == 0, result.stdout + (result.stderr or "") | |
| assert out.exists() | |
| assert meta.exists() | |
| assert "<title>Build Test</title>" in out.read_text(encoding="utf-8") | |