Spaces:
Build error
Build error
| """Tests for export/csv_export.py — CSV zip export functionality.""" | |
| import csv | |
| import zipfile | |
| from pathlib import Path | |
| import pytest | |
| # Import functions under test | |
| from export.csv_export import ( | |
| _LANGUAGE_ABBREVS, | |
| _get_language_abbrev, | |
| _sanitize_folder_name, | |
| export_csv_zip, | |
| ) | |
| PROJECT_ROOT = Path(__file__).resolve().parent.parent | |
| def sample_cards(): | |
| """Sample card data matching the structure from Phase 2.""" | |
| return [ | |
| { | |
| "text": "I love eating fresh fruits.", | |
| "translation": "Me encanta comer frutas frescas.", | |
| "audio_path": str(PROJECT_ROOT / "tests" / "test_outputs" / "audio" / "audio_0.wav"), | |
| "image_path": str(PROJECT_ROOT / "tests" / "test_outputs" / "images" / "image_0.png"), | |
| }, | |
| { | |
| "text": "She enjoys cooking pasta.", | |
| "translation": "Le encanta cocinar pasta.", | |
| "audio_path": None, | |
| "image_path": str(PROJECT_ROOT / "tests" / "test_outputs" / "images" / "image_1.png"), | |
| }, | |
| { | |
| "text": "The chef prepared a delicious meal.", | |
| "translation": "El chef prepar\u00f3 una comida deliciosa.", | |
| "audio_path": str(PROJECT_ROOT / "tests" / "test_outputs" / "audio" / "audio_2.wav"), | |
| "image_path": None, | |
| }, | |
| ] | |
| def tmp_export_base(monkeypatch, tmp_path): | |
| """Fixture that patches _PROJECT_ROOT to tmp_path and returns the export base dir. | |
| The code creates .local/models/output/export/ under _PROJECT_ROOT, | |
| so files end up at tmp_path/.local/models/output/export/{folder}/cards.csv. | |
| This fixture returns that export base for easy test assertions. | |
| """ | |
| import export.csv_export as mod | |
| monkeypatch.setattr(mod, '_PROJECT_ROOT', tmp_path) | |
| return tmp_path / ".local" / "models" / "output" / "export" | |
| class TestSanitizeFolderName: | |
| """Tests for _sanitize_folder_name helper.""" | |
| def test_simple_scenario(self): | |
| assert _sanitize_folder_name("ordering coffee") == "ordering_coffee" | |
| def test_special_chars_removed(self): | |
| assert _sanitize_folder_name("hello! world?") == "hello_world" | |
| def test_multiple_spaces_collapsed(self): | |
| assert _sanitize_folder_name("many spaces here") == "many_spaces_here" | |
| def test_leading_trailing_stripped(self): | |
| assert _sanitize_folder_name(" hello world ") == "hello_world" | |
| class TestLanguageAbbrevMapping: | |
| """Tests for _get_language_abbreviation helper.""" | |
| def test_all_languages_mapped(self): | |
| expected = { | |
| "Bulgarian": "BG", "Croatian": "HR", "Czech": "CS", | |
| "Danish": "DA", "Dutch": "NL", "Estonian": "ET", | |
| "Finnish": "FI", "French": "FR", "German": "DE", | |
| "Greek": "EL", "Hungarian": "HU", "Irish": "GA", | |
| "Italian": "IT", "Latvian": "LV", "Lithuanian": "LT", | |
| "Maltese": "MT", "Polish": "PL", "Portuguese": "PT", | |
| "Romanian": "RO", "Slovak": "SK", "Slovenian": "SL", | |
| "Spanish": "ES", "Swedish": "SV", | |
| } | |
| assert _LANGUAGE_ABBREVS == expected | |
| def test_invalid_language_raises(self): | |
| with pytest.raises(ValueError, match="Unknown language"): | |
| _get_language_abbrev("Japanese") | |
| class TestExportCsvZip: | |
| """Tests for the main export_csv_zip function.""" | |
| def test_folder_name_generation(self, sample_cards, tmp_export_base): | |
| """Folder name matches expected pattern: scenario_cefr_lang.""" | |
| zip_path = export_csv_zip( | |
| cards=sample_cards, | |
| scenario="ordering coffee", | |
| cefr_level="A2", | |
| target_language="Latvian", | |
| ) | |
| assert "ordering_coffee_A2_LV" in zip_path | |
| def test_csv_content_columns(self, sample_cards, tmp_export_base): | |
| """CSV has the 7 expected columns in order.""" | |
| export_csv_zip( | |
| cards=sample_cards, | |
| scenario="test topic", | |
| cefr_level="B1", | |
| target_language="Spanish", | |
| ) | |
| csv_file = tmp_export_base / "test_topic_B1_ES" / "cards.csv" | |
| with open(csv_file, 'r', encoding='utf-8') as f: | |
| reader = csv.reader(f) | |
| header = next(reader) | |
| assert header == [ | |
| 'scenario', 'cefr_level', 'target_language', | |
| 'english_text', 'translated_text', | |
| 'audio_filename', 'image_filename' | |
| ] | |
| def test_csv_content_row_count(self, sample_cards, tmp_export_base): | |
| """CSV row count matches number of cards (excluding header).""" | |
| export_csv_zip( | |
| cards=sample_cards, | |
| scenario="test topic", | |
| cefr_level="B1", | |
| target_language="Spanish", | |
| ) | |
| csv_file = tmp_export_base / "test_topic_B1_ES" / "cards.csv" | |
| with open(csv_file, 'r', encoding='utf-8') as f: | |
| reader = csv.reader(f) | |
| next(reader) # skip header | |
| rows = list(reader) | |
| assert len(rows) == 3 | |
| def test_csv_quoting(self, tmp_export_base): | |
| """Fields with commas/accents are properly double-quote escaped.""" | |
| cards_with_special = [ | |
| { | |
| "text": "Hello, world!", | |
| "translation": "\u00a1Hola, mundo!", | |
| "audio_path": None, | |
| "image_path": None, | |
| }, | |
| ] | |
| export_csv_zip( | |
| cards=cards_with_special, | |
| scenario="greetings", | |
| cefr_level="A1", | |
| target_language="Spanish", | |
| ) | |
| csv_file = tmp_export_base / "greetings_A1_ES" / "cards.csv" | |
| with open(csv_file, 'r', encoding='utf-8') as f: | |
| reader = csv.reader(f) | |
| next(reader) # skip header | |
| row = next(reader) | |
| assert row[3] == "Hello, world!" # english_text | |
| assert row[4] == "\u00a1Hola, mundo!" # translated_text | |
| def test_media_file_copying(self, sample_cards, tmp_export_base): | |
| """Audio and image files are copied into the export folder as flat files with meaningful names.""" | |
| export_csv_zip( | |
| cards=sample_cards, | |
| scenario="ordering coffee", | |
| cefr_level="A2", | |
| target_language="Latvian", | |
| ) | |
| export_dir = tmp_export_base / "ordering_coffee_A2_LV" | |
| # Card 0 has both audio and image — flat naming with base prefix | |
| assert (export_dir / "ordering_coffee_A2_LV_0.wav").exists() | |
| assert (export_dir / "ordering_coffee_A2_LV_0.png").exists() | |
| # Card 1 has only image | |
| assert not (export_dir / "ordering_coffee_A2_LV_1.wav").exists() | |
| assert (export_dir / "ordering_coffee_A2_LV_1.png").exists() | |
| # Card 2 has only audio | |
| assert (export_dir / "ordering_coffee_A2_LV_2.wav").exists() | |
| assert not (export_dir / "ordering_coffee_A2_LV_2.png").exists() | |
| def test_zip_creation(self, sample_cards, tmp_export_base): | |
| """Zip is created and extractable with expected flat structure.""" | |
| zip_path = export_csv_zip( | |
| cards=sample_cards, | |
| scenario="ordering coffee", | |
| cefr_level="A2", | |
| target_language="Latvian", | |
| ) | |
| assert Path(zip_path).exists() | |
| assert zip_path.endswith('.zip') | |
| with zipfile.ZipFile(zip_path, 'r') as zf: | |
| names = zf.namelist() | |
| assert any('cards.csv' in n for n in names) | |
| # Verify flat structure — no audio/ or images/ subfolders | |
| assert not any('audio/' in n for n in names) | |
| assert not any('images/' in n for n in names) | |
| # Verify meaningful filenames present | |
| assert any('ordering_coffee_A2_LV_0.wav' in n for n in names) | |
| assert any('ordering_coffee_A2_LV_0.png' in n for n in names) | |
| def test_csv_media_columns_have_bare_filenames(self, sample_cards, tmp_export_base): | |
| """CSV audio_filename and image_filename columns contain bare filenames, not paths.""" | |
| export_csv_zip( | |
| cards=sample_cards, | |
| scenario="ordering coffee", | |
| cefr_level="A2", | |
| target_language="Latvian", | |
| ) | |
| csv_file = tmp_export_base / "ordering_coffee_A2_LV" / "cards.csv" | |
| with open(csv_file, 'r', encoding='utf-8') as f: | |
| reader = csv.reader(f) | |
| next(reader) # skip header | |
| rows = list(reader) | |
| # Card 0: has both audio and image | |
| assert rows[0][5] == "ordering_coffee_A2_LV_0.wav" # audio_filename | |
| assert rows[0][6] == "ordering_coffee_A2_LV_0.png" # image_filename | |
| # Card 1: only image | |
| assert rows[1][5] == "" # audio_filename | |
| assert rows[1][6] == "ordering_coffee_A2_LV_1.png" # image_filename | |
| # Card 2: only audio | |
| assert rows[2][5] == "ordering_coffee_A2_LV_2.wav" # audio_filename | |
| assert rows[2][6] == "" # image_filename | |
| def test_missing_media_files_handled(self, tmp_export_base): | |
| """Export succeeds even when media files don't exist.""" | |
| cards_no_media = [ | |
| { | |
| "text": "No media card", | |
| "translation": "Sin multimedia", | |
| "audio_path": "/nonexistent/path.wav", | |
| "image_path": "/nonexistent/path.png", | |
| }, | |
| ] | |
| zip_path = export_csv_zip( | |
| cards=cards_no_media, | |
| scenario="no media test", | |
| cefr_level="A1", | |
| target_language="Spanish", | |
| ) | |
| assert Path(zip_path).exists() | |
| csv_file = tmp_export_base / "no_media_test_A1_ES" / "cards.csv" | |
| with open(csv_file, 'r', encoding='utf-8') as f: | |
| reader = csv.reader(f) | |
| next(reader) # skip header | |
| row = next(reader) | |
| assert row[5] == '' # audio_filename | |
| assert row[6] == '' # image_filename | |
| def test_return_path_is_absolute(self, sample_cards, tmp_export_base): | |
| """Return value is an absolute path string.""" | |
| result = export_csv_zip( | |
| cards=sample_cards, | |
| scenario="ordering coffee", | |
| cefr_level="A2", | |
| target_language="Latvian", | |
| ) | |
| assert Path(result).is_absolute() | |
| def test_all_languages_work(self, tmp_export_base): | |
| """Export works for all 8 supported languages.""" | |
| cards = [{"text": "test", "translation": "test", "audio_path": None, "image_path": None}] | |
| for lang in _LANGUAGE_ABBREVS: | |
| zip_path = export_csv_zip( | |
| cards=cards, | |
| scenario="lang test", | |
| cefr_level="A1", | |
| target_language=lang, | |
| ) | |
| assert Path(zip_path).exists() | |