import csv from pathlib import Path from unittest.mock import 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 # --- Task 1: Signature default + device tests --- 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_translate_batch_signature_defaults(): """translate_batch() should have correct default values for generation parameters.""" import inspect import app sig = inspect.signature(app.translate_batch) 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() # --- Batch file parsing helper tests --- def _make_txt_file(tmp_dir: Path, lines: list[str]) -> str: """Create a .txt file in tmp_dir and return its path.""" path = tmp_dir / "input.txt" path.write_text("\n".join(lines)) return str(path) def _make_csv_file(tmp_dir: Path, rows: list[dict]) -> str: """Create a .csv file in tmp_dir and return its path.""" path = tmp_dir / "input.csv" with open(path, "w", newline="") as f: writer = csv.DictWriter(f, fieldnames=rows[0].keys()) writer.writeheader() writer.writerows(rows) return str(path) def test_parse_txt_file(tmp_path): import app path = _make_txt_file(tmp_path, ["Hello", "World", "Test"]) texts, original_data = app._parse_input_file(path) assert texts == ["Hello", "World", "Test"] assert original_data is None def test_parse_csv_file(tmp_path): import app path = _make_csv_file(tmp_path, [{"id": "1", "text": "Hello"}, {"id": "2", "text": "World"}]) texts, original_data = app._parse_input_file(path) assert texts == ["Hello", "World"] assert original_data is not None assert len(original_data) == 2 assert original_data[0]["id"] == "1" def test_parse_csv_missing_text_column(tmp_path): import app path = _make_csv_file(tmp_path, [{"id": "1", "content": "Hello"}]) with pytest.raises(ValueError, match="text"): app._parse_input_file(path) # --- Task 2: Batch parsing edge cases --- def test_parse_empty_txt_file(tmp_path): """Empty .txt file should return ([], None).""" import app path = tmp_path / "empty.txt" path.write_text("") texts, original_data = app._parse_input_file(str(path)) assert texts == [] assert original_data is None def test_parse_unsupported_extension(tmp_path): """Unsupported file extension should raise ValueError.""" import app path = tmp_path / "data.json" path.write_text("{}") with pytest.raises(ValueError, match="Unsupported file type"): app._parse_input_file(str(path)) def test_parse_single_line_txt(tmp_path): """Single-line .txt file should return one-element list.""" import app path = _make_txt_file(tmp_path, ["Only one line"]) texts, original_data = app._parse_input_file(path) assert texts == ["Only one line"] assert original_data is None def test_parse_single_row_csv(tmp_path): """Single-row .csv file should return one-element list with metadata.""" import app path = _make_csv_file(tmp_path, [{"id": "1", "text": "Single row"}]) texts, original_data = app._parse_input_file(path) assert texts == ["Single row"] assert original_data is not None assert len(original_data) == 1 assert original_data[0]["id"] == "1" # --- Write output tests --- def test_write_txt_output(): import app output_path = app._write_output_file(["Bonjour", "Monde"], None, ".txt") try: content = Path(output_path).read_text() assert content == "Bonjour\nMonde" finally: Path(output_path).unlink(missing_ok=True) def test_write_csv_output(): import app original_data = [{"id": "1", "text": "Hello"}, {"id": "2", "text": "World"}] output_path = app._write_output_file(["Bonjour", "Monde"], original_data, ".csv") try: with open(output_path, newline="") as f: reader = list(csv.DictReader(f)) assert len(reader) == 2 assert reader[0]["translation"] == "Bonjour" assert reader[0]["id"] == "1" assert reader[1]["translation"] == "Monde" finally: Path(output_path).unlink(missing_ok=True) # --- Task 3: Write output edge case --- def test_write_single_txt_output(): """Single translation output should have no extraneous newlines.""" import app output_path = app._write_output_file(["Bonjour"], None, ".txt") try: content = Path(output_path).read_text() assert content == "Bonjour" assert "\n" not in content finally: Path(output_path).unlink(missing_ok=True) # --- Batch function tests --- def test_translate_batch_exists(): import app assert callable(app.translate_batch) # --- Task 4: Batch function guard clauses --- def test_translate_batch_none_file_raises_error(): """translate_batch() should raise gr.Error when file is None.""" import gradio as gr import app with pytest.raises(gr.Error, match="upload a file"): app.translate_batch(None, "French") def test_translate_batch_empty_file_raises_error(tmp_path): """translate_batch() should raise gr.Error for an empty file.""" import gradio as gr import app class FakeFile: def __init__(self, name): self.name = name path = tmp_path / "empty.txt" path.write_text("") fake_file = FakeFile(str(path)) with pytest.raises(gr.Error, match="empty"): app.translate_batch(fake_file, "French") def test_translate_batch_accepts_generation_params(): """translate_batch() signature must accept generation parameters and progress.""" import inspect import app sig = inspect.signature(app.translate_batch) params = list(sig.parameters.keys()) assert "max_new_tokens" in params assert "num_beams" in params assert "temperature" in params assert "progress" in params # --- Batch preview table tests --- def test_translate_batch_returns_tuple(tmp_path): """translate_batch() should return a (table_data, file_path) tuple.""" import inspect import app sig = inspect.signature(app.translate_batch) # Check return annotation is a tuple assert sig.return_annotation == tuple[dict, str] def test_build_table_data_txt(): """_build_table_data should create headers+data for txt inputs.""" import app texts = ["Hello", "World"] translations = ["Bonjour", "Monde"] result = app._build_table_data(texts, translations, None) assert result["headers"] == ["source", "translation"] assert result["data"] == [["Hello", "Bonjour"], ["World", "Monde"]] def test_build_table_data_csv(): """_build_table_data should include original CSV columns.""" import app texts = ["Hello"] translations = ["Bonjour"] original_data = [{"id": "1", "text": "Hello"}] result = app._build_table_data(texts, translations, original_data) assert result["headers"] == ["id", "text", "translation"] assert result["data"] == [["1", "Hello", "Bonjour"]] # --- Batch tab Dataframe tests --- def test_demo_has_dataframe_component(demo): """Batch tab should contain a Dataframe component for preview.""" dataframes = [b for b in demo.blocks.values() if type(b).__name__ == "Dataframe"] assert len(dataframes) >= 1, "Expected at least one Dataframe component" def test_dataframe_is_non_interactive(demo): """Batch preview Dataframe should be non-interactive.""" dataframes = [b for b in demo.blocks.values() if type(b).__name__ == "Dataframe"] assert len(dataframes) >= 1 assert dataframes[0].interactive is False def test_batch_tab_has_row_layout(demo): """Batch tab should use a Row for side-by-side upload/download layout.""" # Rows: outer layout + Single tab + Batch tab = 3 rows = [b for b in demo.blocks.values() if type(b).__name__ == "Row"] assert len(rows) == 3, f"Expected 3 Rows (outer + Single + Batch), found {len(rows)}" # --- UI component tests --- @pytest.fixture def demo(): import app return app._build_demo() def _find_slider_by_label(demo, label: str): """Find a slider in the demo by its label.""" sliders = [b for b in demo.blocks.values() if type(b).__name__ == "Slider" and b.label == label] assert len(sliders) == 1, f"Expected 1 slider with label '{label}', found {len(sliders)}" return sliders[0] def test_demo_has_sidebar_and_tabs(demo): """Verify the demo contains the expected layout components.""" component_types = {type(b).__name__ for b in demo.blocks.values()} assert "Tab" in component_types, f"Expected Tab component, found: {component_types}" assert "Slider" in component_types, f"Expected Slider component, found: {component_types}" assert "Dropdown" in component_types, f"Expected Dropdown component, found: {component_types}" def test_demo_has_three_sliders(demo): """Three sliders: max_new_tokens, num_beams, temperature.""" sliders = [b for b in demo.blocks.values() if type(b).__name__ == "Slider"] assert len(sliders) == 3, f"Expected 3 sliders, found {len(sliders)}" def test_demo_has_two_tabs(demo): """Two tabs: Single and Batch.""" tabs = [b for b in demo.blocks.values() if type(b).__name__ == "Tab"] assert len(tabs) == 2, f"Expected 2 tabs, found {len(tabs)}" # --- Task 5: UI component detail tests --- def test_slider_max_new_tokens_config(demo): """Max new tokens slider should have correct config.""" slider = _find_slider_by_label(demo, "Max new tokens") assert slider.minimum == 1 assert slider.maximum == 1024 assert slider.value == 512 assert slider.step == 1 def test_slider_num_beams_config(demo): """Num beams slider should have correct config.""" slider = _find_slider_by_label(demo, "Num beams") assert slider.minimum == 1 assert slider.maximum == 10 assert slider.value == 1 assert slider.step == 1 def test_slider_temperature_config(demo): """Temperature slider should have correct config.""" slider = _find_slider_by_label(demo, "Temperature") assert slider.minimum == 0.1 assert slider.maximum == 2.0 assert slider.value == 1.0 assert slider.step == 0.1 def test_dropdown_default_is_french(demo): """Default dropdown value should be French.""" dropdowns = [b for b in demo.blocks.values() if type(b).__name__ == "Dropdown"] assert len(dropdowns) >= 1 assert dropdowns[0].value == "French" def test_file_upload_accepts_txt_and_csv(demo): """File upload component should accept .txt and .csv files with instructions in label.""" file_components = [b for b in demo.blocks.values() if type(b).__name__ == "File"] upload_files = [f for f in file_components if f.file_types is not None] assert len(upload_files) >= 1 assert set(upload_files[0].file_types) == {".txt", ".csv"} assert ".txt" in upload_files[0].label assert ".csv" in upload_files[0].label def test_batch_output_is_non_interactive(demo): """Batch output file component should be non-interactive.""" file_components = [b for b in demo.blocks.values() if type(b).__name__ == "File"] output_files = [f for f in file_components if f.interactive is False] assert len(output_files) >= 1, "Expected at least one non-interactive File component" def test_examples_use_tier1_languages(demo): """All example languages should be in the tier 1 language mapping.""" from langmap.langid_mapping import langid_to_language tier1_languages = set(langid_to_language.values()) # gr.Examples creates a Dataset component internally datasets = [b for b in demo.blocks.values() if type(b).__name__ == "Dataset"] assert len(datasets) >= 1, "Expected at least one Dataset (Examples) component" for dataset in datasets: for sample in dataset.samples: language = sample[1] # [text, language] assert language in tier1_languages, f"Example language '{language}' not in tier 1 mapping" # --- 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(loaded_app): _, language_names = loaded_app._build_language_mappings() assert language_names == sorted(language_names) @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") assert isinstance(result, str) assert len(result) > 0 # --- Task 6: Slow tests for non-default params --- @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", 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", 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", 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") 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", num_beams=4, temperature=0.5) mock_info.assert_called_once() assert isinstance(result, str)