| |
| import pytest |
| import pandas as pd |
| from unittest.mock import patch, MagicMock, ANY |
| import genanki |
| import gradio |
| from typing import List, Dict, Any |
|
|
| |
| from ankigen_core import exporters |
|
|
| |
|
|
|
|
| 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" |
| |
| 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 |
| |
| assert len(model.templates) == 1 |
| |
| assert isinstance(model.css, str) |
| assert len(model.css) > 100 |
| |
| 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" |
| |
| 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 |
| |
| assert len(model.templates) > 0 |
| assert "{{cloze:Text}}" in model.templates[0]["qfmt"] |
| |
| assert len(model.templates) == 1 |
| |
| assert isinstance(model.css, str) |
| assert len(model.css) > 100 |
| |
| assert model.model_id is not None, "Model ID should not be None" |
| assert (1 << 30) <= model.model_id < (1 << 31) |
| |
| assert exporters.BASIC_MODEL.model_id != exporters.CLOZE_MODEL.model_id |
|
|
|
|
| |
|
|
|
|
| @patch("ankigen_core.exporters.os.makedirs") |
| @patch("builtins.open", new_callable=MagicMock) |
| @patch("ankigen_core.exporters.datetime") |
| def test_export_csv_success(mock_datetime, mock_open, mock_makedirs): |
| """Test successful CSV export.""" |
| |
| timestamp_str = "20230101_120000" |
| mock_now = MagicMock() |
| mock_now.strftime.return_value = timestamp_str |
| mock_datetime.now.return_value = mock_now |
|
|
| |
| mock_file_object = MagicMock() |
| mock_open.return_value.__enter__.return_value = mock_file_object |
|
|
| |
| data = { |
| "Question": ["Q1"], |
| "Answer": ["A1"], |
| "Explanation": ["E1"], |
| "Example": ["Ex1"], |
| } |
| df = pd.DataFrame(data) |
| df.to_csv = MagicMock() |
|
|
| |
| |
| |
| |
| expected_filename = f"ankigen_ankigen_cards_{timestamp_str}.csv" |
|
|
| |
| result_path = exporters.export_csv(df) |
|
|
| |
| |
| |
| |
|
|
| |
| 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") |
| @patch("builtins.open", new_callable=MagicMock) |
| @patch("ankigen_core.exporters.datetime") |
| def test_export_csv_empty_dataframe(mock_datetime, mock_open, mock_makedirs): |
| """Test export_csv with an empty DataFrame raises gr.Error.""" |
| |
| 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() |
| |
|
|
| with pytest.raises(gradio.Error, match="No card data available"): |
| exporters.export_csv(df) |
|
|
|
|
| |
|
|
|
|
| @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_deck_instance = MagicMock() |
| MockDeck.return_value = mock_deck_instance |
| mock_deck_instance.notes = [] |
| mock_deck_instance.models = [] |
|
|
| 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 |
|
|
| 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<hr><b>Explanation:</b><br>E1<br><br><b>Example:</b><br><pre><code>Ex1</code></pre>", |
| "A1<hr><b>Explanation:</b><br>E1<br><br><b>Example:</b><br><pre><code>Ex1</code></pre>", |
| "", |
| "", |
| "", |
| "", |
| "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) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| actual_extra_from_test_log = "A1<hr><b>Explanation:</b><br>E1<br><br><b>Example:</b><br><pre><code>Ex1</code></pre>" |
|
|
| MockNote.assert_called_once_with( |
| model=exporters.CLOZE_MODEL, |
| fields=[ |
| "This is a {{c1::cloze}} question.", |
| |
| actual_extra_from_test_log, |
| "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" |
| ), |
| ] |
| 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 |
| |
| args_basic_kwargs = MockNote.call_args_list[0][1] |
| assert args_basic_kwargs["model"] == exporters.BASIC_MODEL |
| assert args_basic_kwargs["fields"][0] == "BasicQ" |
|
|
| |
| args_cloze_kwargs = MockNote.call_args_list[1][1] |
| assert args_cloze_kwargs["model"] == exporters.CLOZE_MODEL |
| assert args_cloze_kwargs["fields"][0] == "ClozeQ {{c1::text}}" |
| assert args_cloze_kwargs["fields"][3] == "MixedTopic" |
|
|
| |
| args_unknown_kwargs = MockNote.call_args_list[2][1] |
| 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"): |
| exporters.export_deck(df, None) |
| mock_deck_and_package["Deck"].assert_called_with(ANY, "Ankigen Generated Cards") |
| |
| |
| 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=""), |
| 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() |
| 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" |
| ), |
| ] |
| df = pd.DataFrame(sample_data) |
|
|
| |
| |
|
|
| mock_note_good = MagicMock() |
| mock_note_bad_effect = Exception("Bad Note") |
|
|
| |
| MockNote.side_effect = [mock_note_good, mock_note_bad_effect, mock_note_good] |
|
|
| exporters.export_deck(df, "Error Test") |
|
|
| |
| mock_deck_and_package["deck_instance"].add_note.assert_called_once_with( |
| mock_note_good |
| ) |
| assert MockNote.call_count == 2 |
|
|
|
|
| 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="")] |
| df = pd.DataFrame(sample_data) |
|
|
| |
| mock_deck_and_package["deck_instance"].notes = [] |
|
|
| with ( |
| patch( |
| "genanki.Note" |
| ), |
| pytest.raises( |
| gradio.Error, match="Failed to create any valid Anki notes from the input." |
| ), |
| ): |
| exporters.export_deck(df, "No Notes Test") |
|
|
|
|
| |
| |
| |
|
|
|
|
| |
|
|
|
|
| @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"}, |
| { |
| "front": "Q3", |
| "back": "A3", |
| }, |
| ] |
|
|
|
|
| @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") |
| |
| assert mock_file_object.write.call_count >= len(cards) + 1 |
| assert result_path == filename |
|
|
|
|
| @patch("builtins.open", new_callable=MagicMock) |
| @patch("ankigen_core.exporters.datetime") |
| 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 |
|
|
| |
| 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) |
|
|
| 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"}, |
| {"front": "Q3_no_back"}, |
| sample_card_dicts_for_csv[0], |
| ] |
| filename = "test_missing_fields.csv" |
|
|
| with patch.object( |
| exporters.logger, "error" |
| ) as mock_log_error: |
| result_path = exporters.export_cards_to_csv(cards_with_missing, filename) |
|
|
| |
| assert mock_file_object.write.call_count == 1 + 2 |
| |
| assert mock_log_error.call_count == 2 |
| |
| |
|
|
| 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" |
| ) |
|
|
|
|
| |
|
|
|
|
| @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, |
| ): |
| """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 |
|
|
| |
| 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 |
|
|
| |
| mock_export_to_csv.reset_mock() |
|
|
| |
| 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") |
|
|