from unittest.mock import MagicMock, patch import pytest import torch # --- Fast tests (no model loading required) --- def test_app_imports_without_model_load(): """Importing app should not trigger model download.""" import app assert hasattr(app, "translate") assert hasattr(app, "_load_tokenizer") assert hasattr(app, "_load_model") def test_app_has_main_function(): """main() should be callable (UI construction).""" import app assert callable(app.main) def test_translate_accepts_generation_params(): """translate() signature must accept generation parameters.""" import inspect import app sig = inspect.signature(app.translate) params = list(sig.parameters.keys()) assert "max_new_tokens" in params assert "num_beams" in params assert "temperature" in params def test_translate_signature_defaults(): """translate() should have correct default values for generation parameters.""" import inspect import app sig = inspect.signature(app.translate) assert sig.parameters["max_new_tokens"].default == 512 assert sig.parameters["num_beams"].default == 1 assert sig.parameters["temperature"].default == 1.0 def test_get_device_returns_cpu_when_no_cuda(): """_get_device() should return CPU when CUDA is not available.""" import app with patch("app.torch.cuda.is_available", return_value=False): with pytest.warns(UserWarning): device = app._get_device() assert device.type == "cpu" def test_get_device_warns_on_cpu(): """_get_device() should emit a UserWarning when falling back to CPU.""" import app with patch("app.torch.cuda.is_available", return_value=False): with pytest.warns(UserWarning, match="No GPU available"): app._get_device() def test_load_model_uses_bfloat16_on_cuda(): """T5/MADLAD is numerically unstable in float16, so the model must load in bfloat16 on CUDA.""" import app fake_model = MagicMock() fake_model.to.return_value = fake_model app._load_model.cache_clear() try: with ( patch("app._get_device", return_value=torch.device("cuda")), patch("app.AutoModelForSeq2SeqLM.from_pretrained", return_value=fake_model) as mock_load, ): app._load_model() assert mock_load.call_args.kwargs["dtype"] == torch.bfloat16 finally: app._load_model.cache_clear() def test_load_model_uses_float32_on_cpu(): """On CPU the model must load in float32.""" import app fake_model = MagicMock() fake_model.to.return_value = fake_model app._load_model.cache_clear() try: with ( patch("app._get_device", return_value=torch.device("cpu")), patch("app.AutoModelForSeq2SeqLM.from_pretrained", return_value=fake_model) as mock_load, ): app._load_model() assert mock_load.call_args.kwargs["dtype"] == torch.float32 finally: app._load_model.cache_clear() def test_maybe_eager_load_skipped_off_zerogpu(monkeypatch): """Off ZeroGPU, _maybe_eager_load() must not load the model (no download on import).""" import app monkeypatch.delenv("SPACES_ZERO_GPU", raising=False) with patch.object(app, "_load_model") as load_model, patch.object(app, "_load_tokenizer") as load_tokenizer: app._maybe_eager_load() load_model.assert_not_called() load_tokenizer.assert_not_called() def test_maybe_eager_load_runs_on_zerogpu(monkeypatch): """On ZeroGPU (SPACES_ZERO_GPU=1), _maybe_eager_load() eagerly loads model + tokenizer.""" import app monkeypatch.setenv("SPACES_ZERO_GPU", "1") with patch.object(app, "_load_model") as load_model, patch.object(app, "_load_tokenizer") as load_tokenizer: app._maybe_eager_load() load_model.assert_called_once() load_tokenizer.assert_called_once() def test_estimate_duration_is_input_aware_and_capped(): """Duration should scale with tokens*beams, give small inputs a smaller reservation, and cap at 120s.""" import app small = app._estimate_duration("hi", "French (fr)", max_new_tokens=10, num_beams=1) default = app._estimate_duration("hi", "French (fr)", max_new_tokens=512, num_beams=1) heavy = app._estimate_duration("hi", "French (fr)", max_new_tokens=512, num_beams=8) assert small < default <= 120 assert heavy == 120 # capped assert all(isinstance(d, int) for d in (small, default, heavy)) def test_estimate_duration_mirrors_translate_signature(): """ZeroGPU calls the duration callable with translate()'s exact args, so _estimate_duration must mirror translate()'s parameter names and order (the load-bearing ZeroGPU contract).""" import inspect import app assert list(inspect.signature(app._estimate_duration).parameters) == list( inspect.signature(app.translate).parameters ) def test_translate_greedy_by_default_samples_on_custom_temperature(): """num_beams=1 should greedy-decode at the default temperature (deterministic) and enable sampling only when the user sets a non-default temperature.""" import app def run(temperature): model = MagicMock() model.device = torch.device("cpu") model.generate.return_value = [[0]] tokenizer = MagicMock() tokenizer.decode.return_value = "out" with ( patch("app._load_model", return_value=model), patch("app._load_tokenizer", return_value=tokenizer), patch("app._build_language_mappings", return_value=({"French (fr)": "<2fr>"}, ["French (fr)"])), ): app.translate("Hello", "French (fr)", temperature=temperature) return model.generate.call_args.kwargs greedy = run(1.0) sampled = run(0.5) assert not greedy.get("do_sample", False), "default temperature should greedy-decode" assert sampled.get("do_sample") is True and sampled["temperature"] == 0.5 def test_requirements_excludes_platform_packages(): """gradio and spaces are provided by the HF Spaces runtime on every tier; pinning them in requirements.txt drifts the ZeroGPU runtime, so they must stay out (see requirements-dev.txt).""" import re from pathlib import Path reqs = Path(__file__).resolve().parent.parent / "requirements.txt" names = { re.split(r"[<>=~!;\[\s,]", line.strip())[0].lower() for line in reqs.read_text().splitlines() if line.strip() and not line.strip().startswith("#") } assert "gradio" not in names, "gradio must not be in requirements.txt (locked by sdk_version)" assert "spaces" not in names, "spaces must not be in requirements.txt (platform-pinned)" # --- UI component tests --- @pytest.fixture def demo(): import app return app._build_demo() def test_demo_has_no_tabs(demo): """Redesigned UI should have no Tab components.""" tabs = [b for b in demo.blocks.values() if type(b).__name__ == "Tab"] assert len(tabs) == 0, f"Expected no tabs, found {len(tabs)}" def test_demo_has_no_sliders(demo): """Redesigned UI should have no Slider components.""" sliders = [b for b in demo.blocks.values() if type(b).__name__ == "Slider"] assert len(sliders) == 0, f"Expected no sliders, found {len(sliders)}" def test_demo_has_two_interactive_dropdowns(demo): """UI should have two interactive language dropdowns.""" dropdowns = [b for b in demo.blocks.values() if type(b).__name__ == "Dropdown"] assert len(dropdowns) == 2, f"Expected 2 dropdowns, found {len(dropdowns)}" interactive = [d for d in dropdowns if d.interactive is not False] assert len(interactive) == 2, f"Expected 2 interactive dropdowns, found {len(interactive)}" def test_source_dropdown_default_is_english(demo): """Source dropdown should default to English (en).""" dropdowns = [b for b in demo.blocks.values() if type(b).__name__ == "Dropdown"] english = [d for d in dropdowns if d.value == "English (en)"] assert len(english) == 1, "Expected one dropdown defaulting to 'English (en)'" def test_target_dropdown_default_is_french(demo): """Target dropdown should default to French (fr).""" dropdowns = [b for b in demo.blocks.values() if type(b).__name__ == "Dropdown"] french = [d for d in dropdowns if d.value == "French (fr)"] assert len(french) == 1, "Expected one dropdown defaulting to 'French (fr)'" def test_both_dropdowns_filterable(demo): """Both language dropdowns should be filterable/searchable.""" dropdowns = [b for b in demo.blocks.values() if type(b).__name__ == "Dropdown"] interactive = [d for d in dropdowns if d.interactive is not False] assert all(d.filterable is True for d in interactive), "Both dropdowns should be filterable" def test_dropdown_choices_include_locale_codes(demo): """Dropdown choices should include locale codes like 'French (fr)'.""" dropdowns = [b for b in demo.blocks.values() if type(b).__name__ == "Dropdown"] interactive = [d for d in dropdowns if d.interactive is not False] # Gradio stores choices as (label, value) tuples labels = [c[0] if isinstance(c, tuple) else c for c in interactive[0].choices] assert all("(" in label and ")" in label for label in labels), f"Expected locale codes in choices: {labels}" def test_demo_has_two_textboxes(demo): """UI should have input and output textboxes.""" textboxes = [b for b in demo.blocks.values() if type(b).__name__ == "Textbox"] assert len(textboxes) == 2, f"Expected 2 textboxes, found {len(textboxes)}" def test_input_textbox_height(demo): """Input textbox should use lines=6.""" textboxes = [b for b in demo.blocks.values() if type(b).__name__ == "Textbox"] interactive = [t for t in textboxes if t.interactive is not False] assert interactive[0].lines == 6, f"Expected lines=6, got {interactive[0].lines}" def test_input_textbox_max_length(demo): """Input textbox should enforce 2000 character limit.""" textboxes = [b for b in demo.blocks.values() if type(b).__name__ == "Textbox"] interactive = [t for t in textboxes if t.interactive is not False] assert interactive[0].max_length == 2000, f"Expected max_length=2000, got {interactive[0].max_length}" def test_input_textbox_has_no_placeholder(demo): """Input textbox should have no placeholder text.""" textboxes = [b for b in demo.blocks.values() if type(b).__name__ == "Textbox"] interactive = [t for t in textboxes if t.interactive is not False] assert interactive[0].placeholder is None, f"Expected no placeholder, got {interactive[0].placeholder!r}" def test_input_textbox_autofocus(demo): """Input textbox should autofocus so the cursor lands there on page load.""" textboxes = [b for b in demo.blocks.values() if type(b).__name__ == "Textbox"] interactive = [t for t in textboxes if t.interactive is not False] assert interactive[0].autofocus is True, f"Expected autofocus=True, got {interactive[0].autofocus!r}" def test_input_textbox_has_no_buttons(demo): """Input textbox should expose no toolbar buttons (gradio accepts invalid button values silently).""" textboxes = [b for b in demo.blocks.values() if type(b).__name__ == "Textbox"] interactive = [t for t in textboxes if t.interactive is not False] assert interactive[0].buttons == [], f"Expected no buttons, got {interactive[0].buttons!r}" def test_output_textbox_is_non_interactive(demo): """Output textbox should be non-interactive.""" textboxes = [b for b in demo.blocks.values() if type(b).__name__ == "Textbox"] output = [t for t in textboxes if t.interactive is False] assert len(output) == 1, "Expected exactly one non-interactive textbox" def test_output_textbox_height(demo): """Output textbox should use lines=6.""" textboxes = [b for b in demo.blocks.values() if type(b).__name__ == "Textbox"] non_interactive = [t for t in textboxes if t.interactive is False] assert non_interactive[0].lines == 6, f"Expected lines=6, got {non_interactive[0].lines}" def test_output_placeholder(demo): """Output textbox should have 'Translation' as placeholder.""" textboxes = [b for b in demo.blocks.values() if type(b).__name__ == "Textbox"] output = [t for t in textboxes if t.interactive is False] assert output[0].placeholder == "Translation" def test_output_textbox_has_copy_button(demo): """Output textbox should expose a copy button.""" textboxes = [b for b in demo.blocks.values() if type(b).__name__ == "Textbox"] output = [t for t in textboxes if t.interactive is False] assert output[0].buttons == ["copy"], f"Expected ['copy'], got {output[0].buttons!r}" def test_demo_has_translate_button(demo): """UI should have a Translate button.""" buttons = [b for b in demo.blocks.values() if type(b).__name__ == "Button"] assert any(b.value == "Translate" for b in buttons), "Expected a 'Translate' button" def test_translate_button_outside_columns(demo): """Translate button should not be inside either Column.""" buttons = [b for b in demo.blocks.values() if type(b).__name__ == "Button" and b.value == "Translate"] assert len(buttons) == 1 node = getattr(buttons[0], "parent", None) while node is not None: assert type(node).__name__ != "Column", "Translate button should not be inside a Column" node = getattr(node, "parent", None) def test_demo_has_no_html_elements(demo): """UI should have no HTML elements (hint/char count removed).""" html_blocks = [b for b in demo.blocks.values() if type(b).__name__ == "HTML"] assert len(html_blocks) == 0, f"Expected no HTML blocks, found {len(html_blocks)}" def test_demo_has_swap_button(demo): """UI should have a swap button.""" buttons = [b for b in demo.blocks.values() if type(b).__name__ == "Button"] swap = [b for b in buttons if "⇄" in str(b.value)] assert len(swap) == 1, "Expected one swap button" def test_swap_handler_wired(demo): """Swap button should have a click handler with 4 inputs and 4 outputs.""" swap_fns = [fn for fn in demo.fns.values() if len(fn.inputs) == 4 and len(fn.outputs) == 4] assert len(swap_fns) >= 1, "Expected a handler with 4 inputs and 4 outputs (swap handler)" def test_translate_handlers_wire_text_and_language(demo): """Translate click and submit handlers should wire input text and target language.""" translate_fns = [fn for fn in demo.fns.values() if [type(i).__name__ for i in fn.inputs] == ["Textbox", "Dropdown"]] assert len(translate_fns) == 2, f"Expected 2 translate handlers (click + submit), found {len(translate_fns)}" def test_all_handlers_wired(demo): """UI should have exactly 3 click/submit handlers: translate click, translate submit, swap.""" assert len(demo.fns) == 3, f"Expected 3 handlers, found {len(demo.fns)}" def test_translate_endpoint_has_stable_api_name(demo): """The lean submit handler (text + language -> string) should expose a stable 'translate' API endpoint.""" api_fns = [fn for fn in demo.fns.values() if getattr(fn, "api_name", None) == "translate"] assert len(api_fns) == 1, "Expected exactly one handler with api_name='translate'" fn = api_fns[0] assert [type(i).__name__ for i in fn.inputs] == ["Textbox", "Dropdown"] assert [type(o).__name__ for o in fn.outputs] == ["Textbox"] def test_only_translate_endpoint_is_public(demo): """UI-only handlers (swap, button loading) should be private; only /translate is a public API endpoint.""" named = list(demo.get_api_info()["named_endpoints"].keys()) assert named == ["/translate"], f"Expected only ['/translate'] exposed, found {named}" def test_no_title(demo): """UI should not have an H1 title.""" markdowns = [b for b in demo.blocks.values() if type(b).__name__ == "Markdown"] for md in markdowns: assert not md.value.startswith("# "), f"Found unexpected title: {md.value}" # --- Slow tests (require CUDA + model download) --- gpu_available = torch.cuda.is_available() @pytest.fixture(scope="module") def loaded_app(): import app # Force model/tokenizer loading app._load_tokenizer() app._load_model() return app @pytest.mark.slow @pytest.mark.skipif(not gpu_available, reason="Requires CUDA") def test_name_to_code_matches_language_names(loaded_app): name_to_code, language_names = loaded_app._build_language_mappings() assert set(name_to_code.keys()) == set(language_names) @pytest.mark.slow @pytest.mark.skipif(not gpu_available, reason="Requires CUDA") def test_language_names_sorted_by_region(loaded_app): """Language names should be sorted by region, then alphabetically within each region.""" from langmap.langid_mapping import langid_to_language name_to_code, language_names = loaded_app._build_language_mappings() expected = sorted( language_names, key=lambda n: (langid_to_language[name_to_code[n]]["region"], n), ) assert language_names == expected @pytest.mark.slow @pytest.mark.skipif(not gpu_available, reason="Requires CUDA") def test_all_codes_are_bcp47_tokens(loaded_app): name_to_code, _ = loaded_app._build_language_mappings() for name, code in name_to_code.items(): assert code.startswith("<2") and code.endswith(">"), f"Invalid code {code} for {name}" @pytest.mark.slow @pytest.mark.skipif(not gpu_available, reason="Requires CUDA") def test_translate_unsupported_language(loaded_app): with pytest.raises(ValueError, match="Unsupported language"): loaded_app.translate("hello", "FakeLanguage") @pytest.mark.slow @pytest.mark.skipif(not gpu_available, reason="Requires CUDA") def test_translate_returns_string(loaded_app): result = loaded_app.translate("Hello", "French (fr)") assert isinstance(result, str) assert len(result) > 0 @pytest.mark.slow @pytest.mark.skipif(not gpu_available, reason="Requires CUDA") def test_translate_with_beam_search(loaded_app): """Translation with beam search (num_beams=4) should return a string.""" result = loaded_app.translate("Hello", "French (fr)", num_beams=4) assert isinstance(result, str) assert len(result) > 0 @pytest.mark.slow @pytest.mark.skipif(not gpu_available, reason="Requires CUDA") def test_translate_with_custom_temperature(loaded_app): """Translation with custom temperature should return a string.""" result = loaded_app.translate("Hello", "French (fr)", temperature=0.5) assert isinstance(result, str) assert len(result) > 0 @pytest.mark.slow @pytest.mark.skipif(not gpu_available, reason="Requires CUDA") def test_translate_with_custom_max_tokens(loaded_app): """Translation with low max_new_tokens should return a short string.""" result = loaded_app.translate("Hello", "French (fr)", max_new_tokens=10) assert isinstance(result, str) @pytest.mark.slow @pytest.mark.skipif(not gpu_available, reason="Requires CUDA") def test_translate_empty_string(loaded_app): """Translating an empty string should not crash.""" result = loaded_app.translate("", "French (fr)") assert isinstance(result, str) @pytest.mark.slow @pytest.mark.skipif(not gpu_available, reason="Requires CUDA") def test_translate_beam_search_ignores_temperature(loaded_app): """When beam search is active with non-default temperature, gr.Info should be called.""" with patch("app.gr.Info") as mock_info: result = loaded_app.translate("Hello", "French (fr)", num_beams=4, temperature=0.5) mock_info.assert_called_once() assert isinstance(result, str)