"""포터블 빌드(= torch 없는 환경) 검증. 포터블 exe 는 torch 를 넣지 않는다. torch+CUDA 만 2.5GB 라 onefile 로 묶으면 실행할 때마다 임시폴더에 푸느라 못 쓸 만큼 느려지기 때문이다. 그 대신 **1~3티어(CTranslate2)는 torch 없이 전부 돌아야 한다.** 이 파일은 그 전제가 깨지지 않았는지 지킨다. 특히 `CTranslate2Translator.load()` 가 torch 없이 통과하는지 — 한 번 이걸 놓쳐서 포터블이 실행 즉시 죽을 뻔했다. """ from __future__ import annotations import ast import importlib import importlib.util from pathlib import Path import pytest from livesub.models.manager import first_available_tier, has_torch, tier_availability from livesub.models.tiers import TIERS, MTBackend, ordered_tiers SPEC = Path(__file__).resolve().parents[1] / "packaging" / "livesub.spec" PORTABLE_REQS = Path(__file__).resolve().parents[1] / "packaging" / "requirements-portable.txt" #: CTranslate2 경로가 실제로 import 하는 것들. 포터블에 반드시 들어가야 한다. CT2_RUNTIME_IMPORTS = ["ctranslate2", "transformers", "huggingface_hub", "faster_whisper"] def _spec_list(name: str) -> list[str]: """spec 파일에서 excludes / hiddenimports 리스트를 정적으로 읽는다.""" tree = ast.parse(SPEC.read_text(encoding="utf-8")) for node in ast.walk(tree): if isinstance(node, ast.Assign): for target in node.targets: if isinstance(target, ast.Name) and target.id == name: return [ e.value for e in node.value.elts if isinstance(e, ast.Constant) ] raise AssertionError(f"spec 에 {name} 가 없습니다") def test_spec_excludes_torch(): """torch 가 들어가면 exe 가 3GB 로 불어나 포터블의 의미가 사라진다.""" assert "torch" in _spec_list("excludes") def test_spec_does_not_exclude_transformers(): """NLLB 토크나이저가 transformers 를 쓰므로 제외하면 안 된다. 한 번 제외했다가 포터블에서 번역 모델 로드가 통째로 실패할 뻔했다. """ assert "transformers" not in _spec_list("excludes") @pytest.mark.parametrize("module", CT2_RUNTIME_IMPORTS) def test_ct2_runtime_deps_are_in_portable_requirements(module): text = PORTABLE_REQS.read_text(encoding="utf-8") name = module.replace("_", "-") assert name in text or module in text, f"{module} 가 포터블 의존성에 없습니다" def test_portable_requirements_have_no_torch(): text = PORTABLE_REQS.read_text(encoding="utf-8").lower() for line in text.splitlines(): line = line.split("#")[0].strip() assert not line.startswith("torch"), f"포터블에 torch 가 있습니다: {line}" # --- torch 없는 환경에서의 실제 동작 ----------------------------------------- @pytest.mark.skipif(has_torch(), reason="torch 가 설치된 환경 — 포터블 조건이 아님") def test_ct2_translator_loads_without_torch(): """리뷰에서 지적된 지점 — 포터블에서 load() 가 죽으면 앱을 못 쓴다. 모델 가중치는 받지 않고, load() 가 실제로 부르는 import 들이 torch 없이 통과하는지만 본다. """ for module in CT2_RUNTIME_IMPORTS: assert importlib.util.find_spec(module) is not None, f"{module} 없음" from transformers import AutoTokenizer # noqa: F401 - import 자체가 검증 importlib.import_module("ctranslate2") @pytest.mark.skipif(has_torch(), reason="torch 가 설치된 환경 — 포터블 조건이 아님") def test_ct2_tiers_are_usable_without_torch(): for tier in ordered_tiers(): if tier.mt.backend is MTBackend.CTRANSLATE2: assert tier_availability(tier)[0], f"{tier.key} 가 포터블에서 막혔습니다" @pytest.mark.skipif(has_torch(), reason="torch 가 설치된 환경 — 포터블 조건이 아님") def test_llm_tiers_are_blocked_without_torch(): """쓸 수 없는 티어는 '사용 불가'로 막고 이유를 알려줘야 한다.""" for tier in TIERS.values(): if tier.mt.backend is MTBackend.TRANSFORMERS: ok, reason = tier_availability(tier) assert not ok assert "PyTorch" in reason @pytest.mark.skipif(has_torch(), reason="torch 가 설치된 환경 — 포터블 조건이 아님") def test_default_tier_is_usable_in_portable(): """기본 티어가 포터블에서 못 쓰는 것이면 첫 실행부터 막힌다.""" from livesub.config import AppConfig assert tier_availability(TIERS[AppConfig().models.tier])[0] @pytest.mark.skipif(has_torch(), reason="torch 가 설치된 환경 — 포터블 조건이 아님") def test_unavailable_tier_falls_back_to_best_usable(): assert first_available_tier("precision") == "balance" assert first_available_tier("ultimate") == "balance" @pytest.mark.skipif(has_torch(), reason="torch 가 설치된 환경 — 포터블 조건이 아님") def test_app_window_builds_without_torch(tmp_path, monkeypatch): """포터블에서 앱 전체가 뜨는지 (오디오 장치도 GPU도 없이).""" pytest.importorskip("PySide6") import os os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") from PySide6.QtWidgets import QApplication from livesub.config import AppConfig from livesub.ui.main_window import MainWindow app = QApplication.instance() or QApplication([]) cfg = AppConfig() cfg.models.preload_on_start = False cfg.glossary.path = str(tmp_path / "g.json") win = MainWindow(cfg) try: assert win.stack.count() == 5 finally: win.engine.shutdown() win.overlay.close() win.close() _ = app # --- 티어 게이트가 실제 제약을 보는지 ------------------------------------- # # 예전에는 torch 유무만 봤다. 그래서 8GB 카드에서도 "극한(16GB 필요) 사용 # 가능"이라고 답했고, 사용자가 고르면 로드에서 죽었다. 실제 GPU 로 검증하다 # 발견한 버그다. def _gpu(total_gb: float): from livesub.models.manager import GpuInfo return GpuInfo(available=True, name="test", total_vram_mb=int(total_gb * 1024)) @pytest.mark.skipif(not has_torch(), reason="torch 없으면 어차피 앞단에서 막힌다") @pytest.mark.parametrize("key", ["precision", "ultimate"]) def test_vram_이_모자라면_티어를_막는다(key, monkeypatch): monkeypatch.setattr("livesub.models.manager.has_awq", lambda: True) ok, reason = tier_availability(TIERS[key], _gpu(7.5)) assert not ok assert "VRAM" in reason and "7.5GB" in reason @pytest.mark.skipif(not has_torch(), reason="torch 없으면 어차피 앞단에서 막힌다") def test_vram_이_충분하면_통과한다(monkeypatch): monkeypatch.setattr("livesub.models.manager.has_awq", lambda: True) assert tier_availability(TIERS["ultimate"], _gpu(24))[0] @pytest.mark.skipif(not has_torch(), reason="torch 없으면 어차피 앞단에서 막힌다") def test_autoawq_가_없으면_int4_티어를_막는다(monkeypatch): monkeypatch.setattr("livesub.models.manager.has_awq", lambda: False) ok, reason = tier_availability(TIERS["precision"], _gpu(24)) assert not ok assert "autoawq" in reason def test_ct2_티어는_gpu_조회_없이도_통과한다(): """1~3티어는 torch 도 GPU 도 안 따진다 — 포터블에서 돌아야 하므로.""" def _boom(): raise AssertionError("CT2 티어는 detect_gpu 를 부르면 안 된다") import livesub.models.manager as mgr original, mgr.detect_gpu = mgr.detect_gpu, _boom try: for key in ("lightning", "swift", "balance"): assert tier_availability(TIERS[key])[0] finally: mgr.detect_gpu = original