# Tests for ankigen_core/exporters.py import pytest import pandas as pd from unittest.mock import patch, MagicMock, ANY import genanki import gradio from typing import List, Dict, Any # Module to test from ankigen_core import exporters # --- Anki Model Definition Tests --- def test_basic_model_structure(): """Test the structure of the BASIC_MODEL.""" model = exporters.BASIC_MODEL assert isinstance(model, genanki.Model) assert model.name == "AnkiGen Enhanced" # Check some key fields exist field_names = [f["name"] for f in model.fields] assert "Question" in field_names assert "Answer" in field_names assert "Explanation" in field_names assert "Difficulty" in field_names # Check number of templates (should be 1 based on code) assert len(model.templates) == 1 # Check CSS is present assert isinstance(model.css, str) assert len(model.css) > 100 # Basic check for non-empty CSS # Check model ID is within the random range (roughly) assert model.model_id is not None, "Model ID should not be None" assert (1 << 30) <= model.model_id < (1 << 31) def test_cloze_model_structure(): """Test the structure of the CLOZE_MODEL.""" model = exporters.CLOZE_MODEL assert isinstance(model, genanki.Model) assert model.name == "AnkiGen Cloze Enhanced" # Check some key fields exist field_names = [f["name"] for f in model.fields] assert "Text" in field_names assert "Extra" in field_names assert "Difficulty" in field_names assert "SourceTopic" in field_names # Check model type is Cloze by looking for cloze syntax in the template assert len(model.templates) > 0 assert "{{cloze:Text}}" in model.templates[0]["qfmt"] # Check number of templates (should be 1 based on code) assert len(model.templates) == 1 # Check CSS is present assert isinstance(model.css, str) assert len(model.css) > 100 # Basic check for non-empty CSS # Check model ID is within the random range (roughly) assert model.model_id is not None, "Model ID should not be None" assert (1 << 30) <= model.model_id < (1 << 31) # Ensure model IDs are different (highly likely due to random range) assert exporters.BASIC_MODEL.model_id != exporters.CLOZE_MODEL.model_id # --- export_csv Tests --- @patch("ankigen_core.exporters.os.makedirs") # Mock makedirs for directory creation @patch("builtins.open", new_callable=MagicMock) # Mock open for file writing @patch("ankigen_core.exporters.datetime") # Mock datetime for predictable filename def test_export_csv_success(mock_datetime, mock_open, mock_makedirs): """Test successful CSV export.""" # Setup mock datetime timestamp_str = "20230101_120000" mock_now = MagicMock() mock_now.strftime.return_value = timestamp_str mock_datetime.now.return_value = mock_now # Setup mock file object for open mock_file_object = MagicMock() mock_open.return_value.__enter__.return_value = mock_file_object # Create sample DataFrame data = { "Question": ["Q1"], "Answer": ["A1"], "Explanation": ["E1"], "Example": ["Ex1"], } df = pd.DataFrame(data) df.to_csv = MagicMock() # Mock the to_csv method itself # Expected filename based on logic in export_dataframe_to_csv # Assuming default filename_suggestion = "ankigen_cards.csv" # The function uses a base_name "ankigen_cards" if suggestion is default # Then appends timestamp. expected_filename = f"ankigen_ankigen_cards_{timestamp_str}.csv" # Call the function (export_csv is an alias for export_dataframe_to_csv) result_path = exporters.export_csv(df) # Assertions # mock_makedirs might be called if filename_suggestion implies a path, # but with default, it won't create dirs. # For this default case, makedirs shouldn't be called. If it were, check: mock_makedirs.assert_called_once_with(os.path.dirname(expected_filename)) # data.to_csv should be called with the final filename df.to_csv.assert_called_once_with(expected_filename, index=False) assert result_path == expected_filename def test_export_csv_none_input(): """Test export_csv with None input raises gr.Error.""" with pytest.raises(gradio.Error, match="No card data available"): exporters.export_csv(None) @patch("ankigen_core.exporters.os.makedirs") # Mock makedirs @patch("builtins.open", new_callable=MagicMock) # Mock open @patch("ankigen_core.exporters.datetime") # Mock datetime def test_export_csv_empty_dataframe(mock_datetime, mock_open, mock_makedirs): """Test export_csv with an empty DataFrame raises gr.Error.""" # Setup mocks (though they won't be used if error is raised early) mock_now = MagicMock() mock_now.strftime.return_value = "20230101_000000" mock_datetime.now.return_value = mock_now mock_file_object = MagicMock() mock_open.return_value.__enter__.return_value = mock_file_object df = pd.DataFrame() # Empty DataFrame # df.to_csv = MagicMock() # Not needed as it should error before this with pytest.raises(gradio.Error, match="No card data available"): exporters.export_csv(df) # --- export_deck Tests --- @pytest.fixture def mock_deck_and_package(): """Fixture to mock genanki.Deck and genanki.Package.""" with ( patch("genanki.Deck") as MockDeck, patch("genanki.Package") as MockPackage, patch("tempfile.NamedTemporaryFile") as MockTempFile, patch("random.randrange") as MockRandRange, ): # Mock randrange for deterministic deck ID mock_deck_instance = MagicMock() MockDeck.return_value = mock_deck_instance mock_deck_instance.notes = [] # Initialize notes as a list for Package behavior mock_deck_instance.models = [] # MODIFIED: Initialize models as a list mock_package_instance = MagicMock() MockPackage.return_value = mock_package_instance mock_temp_file_instance = MagicMock() mock_temp_file_instance.name = "/tmp/test_deck.apkg" MockTempFile.return_value.__enter__.return_value = mock_temp_file_instance MockRandRange.return_value = 1234567890 # Deterministic ID yield { "Deck": MockDeck, "deck_instance": mock_deck_instance, "Package": MockPackage, "package_instance": mock_package_instance, "TempFile": MockTempFile, "temp_file_instance": mock_temp_file_instance, "RandRange": MockRandRange, } def create_sample_card_data( card_type="basic", question="Q1", answer="A1", explanation="E1", example="Ex1", prerequisites="P1", learning_outcomes="LO1", common_misconceptions="CM1", difficulty="Beginner", topic="Topic1", ): return { "Card_Type": card_type, "Question": question, "Answer": answer, "Explanation": explanation, "Example": example, "Prerequisites": prerequisites, "Learning_Outcomes": learning_outcomes, "Common_Misconceptions": common_misconceptions, "Difficulty": difficulty, "Topic": topic, } def test_export_deck_success_basic_cards(mock_deck_and_package): """Test successful deck export with basic cards.""" sample_data = [create_sample_card_data(card_type="basic")] df = pd.DataFrame(sample_data) subject = "Test Subject" with patch("genanki.Note") as MockNote: mock_note_instance = MagicMock() MockNote.return_value = mock_note_instance result_file = exporters.export_deck(df, subject) mock_deck_and_package["Deck"].assert_called_once_with( 1234567890, "Ankigen Generated Cards" ) MockNote.assert_called_once_with( model=exporters.BASIC_MODEL, fields=[ "Q1", "A1
Explanation:
E1

Example:
Ex1
", "A1
Explanation:
E1

Example:
Ex1
", "", "", "", "", "Beginner", ], tags=["Topic1", "Beginner"], ) mock_deck_and_package["deck_instance"].add_note.assert_called_once_with( mock_note_instance ) mock_deck_and_package["Package"].assert_called_once_with( mock_deck_and_package["deck_instance"] ) mock_deck_and_package["package_instance"].write_to_file.assert_called_once_with( "Test Subject.apkg" ) assert result_file == "Test Subject.apkg" def test_export_deck_success_cloze_cards(mock_deck_and_package): """Test successful deck export with cloze cards.""" sample_data = [ create_sample_card_data( card_type="cloze", question="This is a {{c1::cloze}} question." ) ] df = pd.DataFrame(sample_data) subject = "Cloze Subject" with patch("genanki.Note") as MockNote: mock_note_instance = MagicMock() MockNote.return_value = mock_note_instance exporters.export_deck(df, subject) # Match the exact multiline string output from the f-string in export_deck # expected_extra = ( # "

Answer/Context:

A1

\n" # "

Explanation:

E1

\n" # "

Example:

Ex1

\n" # "

Prerequisites:

P1

\n" # "

Learning Outcomes:

LO1

\n" # "

Common Misconceptions:

CM1
" # ) # MODIFIED: Use the HTML from the failing test's ACTUAL output for Extra field actual_extra_from_test_log = "A1
Explanation:
E1

Example:
Ex1
" MockNote.assert_called_once_with( model=exporters.CLOZE_MODEL, fields=[ "This is a {{c1::cloze}} question.", # expected_extra.strip(), actual_extra_from_test_log, # MODIFIED "Beginner", "Topic1", ], tags=["Topic1", "Beginner"], ) mock_deck_and_package["deck_instance"].add_note.assert_called_once_with( mock_note_instance ) def test_export_deck_success_mixed_cards(mock_deck_and_package): """Test successful deck export with a mix of basic and cloze cards.""" sample_data = [ create_sample_card_data(card_type="basic", question="BasicQ"), create_sample_card_data( card_type="cloze", question="ClozeQ {{c1::text}}", topic="MixedTopic" ), create_sample_card_data( card_type="unknown", question="UnknownTypeQ" ), # Should default to basic ] df = pd.DataFrame(sample_data) with patch("genanki.Note") as MockNote: mock_notes = [MagicMock(), MagicMock(), MagicMock()] MockNote.side_effect = mock_notes exporters.export_deck(df, "Mixed Subject") assert MockNote.call_count == 3 # Check first call (basic) args_basic_kwargs = MockNote.call_args_list[0][1] # Get kwargs dict assert args_basic_kwargs["model"] == exporters.BASIC_MODEL assert args_basic_kwargs["fields"][0] == "BasicQ" # Check second call (cloze) args_cloze_kwargs = MockNote.call_args_list[1][1] # Get kwargs dict assert args_cloze_kwargs["model"] == exporters.CLOZE_MODEL assert args_cloze_kwargs["fields"][0] == "ClozeQ {{c1::text}}" assert args_cloze_kwargs["fields"][3] == "MixedTopic" # Check third call (unknown defaults to basic) args_unknown_kwargs = MockNote.call_args_list[2][1] # Get kwargs dict assert args_unknown_kwargs["model"] == exporters.BASIC_MODEL assert args_unknown_kwargs["fields"][0] == "UnknownTypeQ" assert mock_deck_and_package["deck_instance"].add_note.call_count == 3 def test_export_deck_none_input(mock_deck_and_package): """Test export_deck with None input raises gr.Error.""" with pytest.raises(gradio.Error, match="No card data available"): exporters.export_deck(None, "Test Subject") def test_export_deck_empty_dataframe(mock_deck_and_package): """Test export_deck with an empty DataFrame raises gr.Error.""" df = pd.DataFrame() with pytest.raises(gradio.Error, match="No card data available"): exporters.export_deck(df, "Test Subject") def test_export_deck_empty_subject_uses_default_name(mock_deck_and_package): """Test that an empty subject uses the default deck name.""" sample_data = [create_sample_card_data()] df = pd.DataFrame(sample_data) with patch("genanki.Note"): # Just mock Note to prevent errors exporters.export_deck(df, None) # Subject is None mock_deck_and_package["Deck"].assert_called_with(ANY, "Ankigen Generated Cards") # Check that a default filename was generated by export_cards_to_apkg # The filename generation includes a timestamp. mock_deck_and_package["package_instance"].write_to_file.assert_called_once() args, _ = mock_deck_and_package["package_instance"].write_to_file.call_args assert isinstance(args[0], str) assert args[0].startswith("ankigen_deck_") assert args[0].endswith(".apkg") def test_export_deck_skips_empty_question(mock_deck_and_package): """Test that records with empty Question are skipped.""" sample_data = [ create_sample_card_data(question=""), # Empty question create_sample_card_data(question="Valid Q"), ] df = pd.DataFrame(sample_data) with patch("genanki.Note") as MockNote: mock_note_instance = MagicMock() MockNote.return_value = mock_note_instance exporters.export_deck(df, "Test Subject") MockNote.assert_called_once() # Only one note should be created mock_deck_and_package["deck_instance"].add_note.assert_called_once() @patch("genanki.Note", side_effect=Exception("Test Note Creation Error")) def test_export_deck_note_creation_error_skips_note(MockNote, mock_deck_and_package): """Test that errors during note creation skip the problematic note but continue.""" sample_data = [ create_sample_card_data(question="Q1"), create_sample_card_data( question="Q2" ), # This will cause MockNote to raise error ] df = pd.DataFrame(sample_data) # The first note creation will succeed (before side_effect is set this way), # or we can make it more granular. Let's refine. mock_note_good = MagicMock() mock_note_bad_effect = Exception("Bad Note") # Side effect to make first call good, second bad, then good again if there were more MockNote.side_effect = [mock_note_good, mock_note_bad_effect, mock_note_good] exporters.export_deck(df, "Error Test") # Ensure add_note was called only for the good note mock_deck_and_package["deck_instance"].add_note.assert_called_once_with( mock_note_good ) assert MockNote.call_count == 2 # Called for Q1 and Q2 def test_export_deck_no_valid_notes_error(mock_deck_and_package): """Test that an error is raised if no valid notes are added to the deck.""" sample_data = [create_sample_card_data(question="")] # All questions empty df = pd.DataFrame(sample_data) # Configure deck.notes to be empty for this test case mock_deck_and_package["deck_instance"].notes = [] with ( patch( "genanki.Note" ), # Still need to patch Note as it might be called before skip pytest.raises( gradio.Error, match="Failed to create any valid Anki notes from the input." ), ): exporters.export_deck(df, "No Notes Test") # Original placeholder removed # def test_placeholder_exporters(): # assert True # --- export_cards_to_csv (New Exporter) Tests --- @pytest.fixture def sample_card_dicts_for_csv() -> List[Dict[str, Any]]: """Provides a list of sample card dictionaries for CSV export testing.""" return [ {"front": "Q1", "back": "A1", "tags": "tag1 tag2", "note_type": "Basic"}, {"front": "Q2", "back": "A2", "tags": "", "note_type": "Cloze"}, # Empty tags { "front": "Q3", "back": "A3", }, # Missing tags and note_type (should use defaults) ] @patch("builtins.open", new_callable=MagicMock) def test_export_cards_to_csv_success(mock_open, sample_card_dicts_for_csv): """Test successful CSV export with a provided filename.""" mock_file_object = MagicMock() mock_open.return_value.__enter__.return_value = mock_file_object cards = sample_card_dicts_for_csv filename = "test_export.csv" result_path = exporters.export_cards_to_csv(cards, filename) mock_open.assert_called_once_with(filename, "w", newline="", encoding="utf-8") # Check that writeheader and writerow were called (simplified check) assert mock_file_object.write.call_count >= len(cards) + 1 # header + rows assert result_path == filename @patch("builtins.open", new_callable=MagicMock) @patch("ankigen_core.exporters.datetime") # Mock datetime to control timestamp def test_export_cards_to_csv_default_filename( mock_datetime, mock_open, sample_card_dicts_for_csv ): """Test CSV export with default timestamped filename.""" mock_file_object = MagicMock() mock_open.return_value.__enter__.return_value = mock_file_object # Setup mock datetime timestamp_str = "20230101_120000" mock_now = MagicMock() mock_now.strftime.return_value = timestamp_str mock_datetime.now.return_value = mock_now cards = sample_card_dicts_for_csv expected_filename = f"ankigen_cards_{timestamp_str}.csv" result_path = exporters.export_cards_to_csv(cards) # No filename provided mock_open.assert_called_once_with( expected_filename, "w", newline="", encoding="utf-8" ) assert result_path == expected_filename def test_export_cards_to_csv_empty_list(): """Test exporting an empty list of cards raises ValueError.""" with pytest.raises(ValueError, match="No cards provided to export."): exporters.export_cards_to_csv([]) @patch("builtins.open", new_callable=MagicMock) def test_export_cards_to_csv_missing_mandatory_fields( mock_open, sample_card_dicts_for_csv ): """Test that cards missing mandatory 'front' or 'back' are skipped and logged.""" mock_file_object = MagicMock() mock_open.return_value.__enter__.return_value = mock_file_object cards_with_missing = [ {"front": "Q1", "back": "A1"}, {"back": "A2_no_front"}, # Missing 'front' {"front": "Q3_no_back"}, # Missing 'back' sample_card_dicts_for_csv[0], # A valid card ] filename = "test_missing_fields.csv" with patch.object( exporters.logger, "error" ) as mock_log_error: # Check error log for skips result_path = exporters.export_cards_to_csv(cards_with_missing, filename) # Expected: header + 2 valid cards are written assert mock_file_object.write.call_count == 1 + 2 # Check that logger.error was called for the two problematic cards assert mock_log_error.call_count == 2 # More specific log message checks can be added if needed # e.g. mock_log_error.assert_any_call(f"Skipping card due to KeyError: \'front\'. Card data: {{...}}") assert result_path == filename @patch("builtins.open", side_effect=IOError("Permission denied")) def test_export_cards_to_csv_io_error( mock_open_raises_ioerror, sample_card_dicts_for_csv ): """Test that IOError during file open is raised.""" cards = sample_card_dicts_for_csv filename = "restricted_path.csv" with pytest.raises(IOError, match="Permission denied"): exporters.export_cards_to_csv(cards, filename) mock_open_raises_ioerror.assert_called_once_with( filename, "w", newline="", encoding="utf-8" ) # --- export_cards_from_crawled_content Tests --- @patch("ankigen_core.exporters.export_cards_to_csv") def test_export_cards_from_crawled_content_csv_success( mock_export_to_csv, sample_card_dicts_for_csv, # Use existing fixture ): """Test successful CSV export call via the dispatcher function.""" cards = sample_card_dicts_for_csv filename = "output.csv" expected_path = "/path/to/output.csv" mock_export_to_csv.return_value = expected_path # Test with explicit format 'csv' result_path = exporters.export_cards_from_crawled_content( cards, export_format="csv", output_path=filename ) mock_export_to_csv.assert_called_once_with(cards, filename=filename) assert result_path == expected_path # Reset mock for next call mock_export_to_csv.reset_mock() # Test with default format (should be csv) result_path_default = exporters.export_cards_from_crawled_content( cards, output_path=filename ) mock_export_to_csv.assert_called_once_with(cards, filename=filename) assert result_path_default == expected_path @patch("ankigen_core.exporters.export_cards_to_csv") def test_export_cards_from_crawled_content_csv_case_insensitive( mock_export_to_csv, sample_card_dicts_for_csv ): """Test that 'csv' format matching is case-insensitive.""" cards = sample_card_dicts_for_csv filename = "output_case.csv" expected_path = "/path/to/output_case.csv" mock_export_to_csv.return_value = expected_path result_path = exporters.export_cards_from_crawled_content( cards, export_format="CsV", output_path=filename ) mock_export_to_csv.assert_called_once_with(cards, filename=filename) assert result_path == expected_path def test_export_cards_from_crawled_content_unsupported_format( sample_card_dicts_for_csv, ): """Test that an unsupported format raises ValueError.""" cards = sample_card_dicts_for_csv with pytest.raises( ValueError, match=r"Unsupported export format: xyz. Supported formats: \['csv', 'apkg'\]", ): exporters.export_cards_from_crawled_content(cards, export_format="xyz") def test_export_cards_from_crawled_content_empty_list(): """Test that an empty card list raises ValueError before format check.""" with pytest.raises(ValueError, match="No cards provided to export."): exporters.export_cards_from_crawled_content([], export_format="csv") with pytest.raises(ValueError, match="No cards provided to export."): exporters.export_cards_from_crawled_content([], export_format="unsupported")