|
|
|
import pytest |
|
from unittest.mock import patch, MagicMock, ANY |
|
import pandas as pd |
|
|
|
|
|
from ankigen_core.models import Card, CardFront, CardBack, AnkiCardData |
|
from ankigen_core.utils import ResponseCache |
|
from ankigen_core.llm_interface import OpenAIClientManager |
|
|
|
|
|
from ankigen_core import card_generator |
|
from ankigen_core.card_generator import ( |
|
get_dataframe_columns, |
|
) |
|
|
|
|
|
|
|
|
|
def test_constants_exist_and_have_expected_type(): |
|
"""Test that constants exist and are lists.""" |
|
assert isinstance(card_generator.AVAILABLE_MODELS, list) |
|
assert isinstance(card_generator.GENERATION_MODES, list) |
|
assert len(card_generator.AVAILABLE_MODELS) > 0 |
|
assert len(card_generator.GENERATION_MODES) > 0 |
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture |
|
def mock_openai_client_fixture(): |
|
"""Provides a MagicMock OpenAI client.""" |
|
return MagicMock() |
|
|
|
|
|
@pytest.fixture |
|
def mock_response_cache_fixture(): |
|
"""Provides a MagicMock ResponseCache.""" |
|
cache = MagicMock(spec=ResponseCache) |
|
cache.get.return_value = None |
|
return cache |
|
|
|
|
|
@patch("ankigen_core.card_generator.structured_output_completion") |
|
async def test_generate_cards_batch_success( |
|
mock_soc, mock_openai_client_fixture, mock_response_cache_fixture |
|
): |
|
"""Test successful card generation using generate_cards_batch.""" |
|
mock_openai_client = mock_openai_client_fixture |
|
mock_response_cache = mock_response_cache_fixture |
|
model = "gpt-test" |
|
topic = "Test Topic" |
|
num_cards = 2 |
|
system_prompt = "System prompt" |
|
generate_cloze = False |
|
|
|
|
|
mock_soc.return_value = { |
|
"cards": [ |
|
{ |
|
"card_type": "basic", |
|
"front": {"question": "Q1"}, |
|
"back": {"answer": "A1", "explanation": "E1", "example": "Ex1"}, |
|
"metadata": {"difficulty": "beginner"}, |
|
}, |
|
{ |
|
"card_type": "cloze", |
|
"front": {"question": "{{c1::Q2}}"}, |
|
"back": {"answer": "A2_full", "explanation": "E2", "example": "Ex2"}, |
|
"metadata": {"difficulty": "intermediate"}, |
|
}, |
|
] |
|
} |
|
|
|
result_cards = await card_generator.generate_cards_batch( |
|
openai_client=mock_openai_client, |
|
cache=mock_response_cache, |
|
model=model, |
|
topic=topic, |
|
num_cards=num_cards, |
|
system_prompt=system_prompt, |
|
generate_cloze=generate_cloze, |
|
) |
|
|
|
assert len(result_cards) == 2 |
|
assert isinstance(result_cards[0], Card) |
|
assert result_cards[0].card_type == "basic" |
|
assert result_cards[0].front.question == "Q1" |
|
assert result_cards[1].card_type == "cloze" |
|
assert result_cards[1].front.question == "{{c1::Q2}}" |
|
assert result_cards[1].metadata["difficulty"] == "intermediate" |
|
|
|
mock_soc.assert_called_once() |
|
call_args = mock_soc.call_args[1] |
|
assert call_args["openai_client"] == mock_openai_client |
|
assert call_args["cache"] == mock_response_cache |
|
assert call_args["model"] == model |
|
assert call_args["system_prompt"] == system_prompt |
|
assert topic in call_args["user_prompt"] |
|
assert str(num_cards) in call_args["user_prompt"] |
|
|
|
assert "generate Cloze deletion cards" not in call_args["user_prompt"] |
|
|
|
|
|
@patch("ankigen_core.card_generator.structured_output_completion") |
|
async def test_generate_cards_batch_cloze_prompt( |
|
mock_soc, mock_openai_client_fixture, mock_response_cache_fixture |
|
): |
|
"""Test generate_cards_batch includes cloze instructions when requested.""" |
|
mock_openai_client = mock_openai_client_fixture |
|
mock_response_cache = mock_response_cache_fixture |
|
mock_soc.return_value = {"cards": []} |
|
|
|
await card_generator.generate_cards_batch( |
|
openai_client=mock_openai_client, |
|
cache=mock_response_cache, |
|
model="gpt-test", |
|
topic="Cloze Topic", |
|
num_cards=1, |
|
system_prompt="System", |
|
generate_cloze=True, |
|
) |
|
|
|
mock_soc.assert_called_once() |
|
call_args = mock_soc.call_args[1] |
|
|
|
assert "generate Cloze deletion cards" in call_args["user_prompt"] |
|
|
|
assert ( |
|
"Format the question field using Anki's cloze syntax" |
|
in call_args["user_prompt"] |
|
) |
|
|
|
|
|
@patch("ankigen_core.card_generator.structured_output_completion") |
|
async def test_generate_cards_batch_api_error( |
|
mock_soc, mock_openai_client_fixture, mock_response_cache_fixture |
|
): |
|
"""Test generate_cards_batch handles API errors by re-raising.""" |
|
mock_openai_client = mock_openai_client_fixture |
|
mock_response_cache = mock_response_cache_fixture |
|
error_message = "API Error" |
|
mock_soc.side_effect = ValueError(error_message) |
|
|
|
with pytest.raises(ValueError, match=error_message): |
|
await card_generator.generate_cards_batch( |
|
openai_client=mock_openai_client, |
|
cache=mock_response_cache, |
|
model="gpt-test", |
|
topic="Error Topic", |
|
num_cards=1, |
|
system_prompt="System", |
|
generate_cloze=False, |
|
) |
|
|
|
|
|
@patch("ankigen_core.card_generator.structured_output_completion") |
|
async def test_generate_cards_batch_invalid_response( |
|
mock_soc, mock_openai_client_fixture, mock_response_cache_fixture |
|
): |
|
"""Test generate_cards_batch handles invalid JSON or missing keys.""" |
|
mock_openai_client = mock_openai_client_fixture |
|
mock_response_cache = mock_response_cache_fixture |
|
mock_soc.return_value = {"wrong_key": []} |
|
|
|
with pytest.raises(ValueError, match="Failed to generate cards"): |
|
await card_generator.generate_cards_batch( |
|
openai_client=mock_openai_client, |
|
cache=mock_response_cache, |
|
model="gpt-test", |
|
topic="Invalid Response Topic", |
|
num_cards=1, |
|
system_prompt="System", |
|
generate_cloze=False, |
|
) |
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture |
|
def mock_client_manager_fixture(): |
|
"""Provides a MagicMock OpenAIClientManager.""" |
|
manager = MagicMock(spec=OpenAIClientManager) |
|
mock_client = MagicMock() |
|
manager.get_client.return_value = mock_client |
|
|
|
manager.initialize_client.return_value = None |
|
return manager, mock_client |
|
|
|
|
|
def base_orchestrator_args(api_key="valid_key", **kwargs): |
|
"""Base arguments for orchestrate_card_generation.""" |
|
base_args = { |
|
"api_key_input": api_key, |
|
"subject": "Subject", |
|
"generation_mode": "subject", |
|
"source_text": "Source text", |
|
"url_input": "http://example.com", |
|
"model_name": "gpt-test", |
|
"topic_number": 1, |
|
"cards_per_topic": 5, |
|
"preference_prompt": "Pref prompt", |
|
"generate_cloze": False, |
|
"use_llm_judge": False, |
|
} |
|
base_args.update(kwargs) |
|
return base_args |
|
|
|
|
|
@patch("ankigen_core.card_generator.structured_output_completion") |
|
@patch("ankigen_core.card_generator.generate_cards_batch") |
|
async def test_orchestrate_subject_mode( |
|
mock_gcb, mock_soc, mock_client_manager_fixture, mock_response_cache_fixture |
|
): |
|
"""Test orchestrate_card_generation in 'subject' mode.""" |
|
manager, client = mock_client_manager_fixture |
|
cache = mock_response_cache_fixture |
|
args = base_orchestrator_args(generation_mode="subject") |
|
|
|
|
|
mock_soc.return_value = { |
|
"topics": [ |
|
{"name": "Topic 1", "difficulty": "beginner", "description": "Desc 1"} |
|
] |
|
} |
|
|
|
|
|
mock_gcb.return_value = [ |
|
Card( |
|
front=CardFront(question="Q1"), |
|
back=CardBack(answer="A1", explanation="E1", example="Ex1"), |
|
) |
|
] |
|
|
|
|
|
with patch("gradio.Info"), patch("gradio.Warning"): |
|
df_result, status, count = await card_generator.orchestrate_card_generation( |
|
client_manager=manager, cache=cache, **args |
|
) |
|
|
|
manager.initialize_client.assert_called_once_with(args["api_key_input"]) |
|
manager.get_client.assert_called_once() |
|
|
|
|
|
mock_soc.assert_called_once() |
|
soc_call_args = mock_soc.call_args[1] |
|
assert soc_call_args["openai_client"] == client |
|
assert "Generate the top" in soc_call_args["user_prompt"] |
|
assert args["subject"] in soc_call_args["user_prompt"] |
|
|
|
|
|
mock_gcb.assert_called_once_with( |
|
openai_client=client, |
|
cache=cache, |
|
model=args["model_name"], |
|
topic="Topic 1", |
|
num_cards=args["cards_per_topic"], |
|
system_prompt=ANY, |
|
generate_cloze=args["generate_cloze"], |
|
) |
|
assert count == 1 |
|
assert isinstance(df_result, pd.DataFrame) |
|
assert len(df_result) == 1 |
|
assert df_result.iloc[0]["Question"] == "Q1" |
|
|
|
assert "Generation complete!" in status |
|
assert "Total cards generated: 1" in status |
|
assert "<div" in status |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@patch("ankigen_core.card_generator.judge_cards") |
|
@patch("ankigen_core.card_generator.structured_output_completion") |
|
@patch("ankigen_core.card_generator.generate_cards_batch") |
|
async def test_orchestrate_subject_mode_with_judge( |
|
mock_gcb, |
|
mock_soc, |
|
mock_judge, |
|
mock_client_manager_fixture, |
|
mock_response_cache_fixture, |
|
): |
|
"""Test orchestrate_card_generation calls judge_cards when enabled.""" |
|
manager, client = mock_client_manager_fixture |
|
cache = mock_response_cache_fixture |
|
args = base_orchestrator_args(generation_mode="subject", use_llm_judge=True) |
|
|
|
mock_soc.return_value = { |
|
"topics": [{"name": "T1", "difficulty": "d", "description": "d"}] |
|
} |
|
sample_card = Card( |
|
front=CardFront(question="Q1"), |
|
back=CardBack(answer="A1", explanation="E1", example="Ex1"), |
|
) |
|
mock_gcb.return_value = [sample_card] |
|
mock_judge.return_value = [sample_card] |
|
|
|
with patch("gradio.Info"), patch("gradio.Warning"): |
|
await card_generator.orchestrate_card_generation( |
|
client_manager=manager, |
|
cache=cache, |
|
**args, |
|
) |
|
|
|
mock_judge.assert_called_once_with(client, cache, args["model_name"], [sample_card]) |
|
|
|
|
|
@patch("ankigen_core.card_generator.structured_output_completion") |
|
@patch("ankigen_core.card_generator.generate_cards_batch") |
|
async def test_orchestrate_text_mode( |
|
mock_gcb, mock_soc, mock_client_manager_fixture, mock_response_cache_fixture |
|
): |
|
"""Test orchestrate_card_generation in 'text' mode.""" |
|
manager, client = mock_client_manager_fixture |
|
cache = mock_response_cache_fixture |
|
args = base_orchestrator_args(generation_mode="text") |
|
mock_soc.return_value = {"cards": []} |
|
|
|
await card_generator.orchestrate_card_generation( |
|
client_manager=manager, cache=cache, **args |
|
) |
|
|
|
mock_soc.assert_called_once() |
|
call_args = mock_soc.call_args[1] |
|
assert args["source_text"] in call_args["user_prompt"] |
|
|
|
|
|
@patch("ankigen_core.card_generator.fetch_webpage_text") |
|
@patch("ankigen_core.card_generator.structured_output_completion") |
|
async def test_orchestrate_web_mode( |
|
mock_soc, mock_fetch, mock_client_manager_fixture, mock_response_cache_fixture |
|
): |
|
"""Test orchestrate_card_generation in 'web' mode.""" |
|
manager, client = mock_client_manager_fixture |
|
cache = mock_response_cache_fixture |
|
args = base_orchestrator_args(generation_mode="web") |
|
|
|
fetched_text = "This is the fetched web page text." |
|
mock_fetch.return_value = fetched_text |
|
mock_soc.return_value = { |
|
"cards": [] |
|
} |
|
|
|
|
|
|
|
with patch("gradio.Info"), patch("gradio.Warning"): |
|
await card_generator.orchestrate_card_generation( |
|
client_manager=manager, cache=cache, **args |
|
) |
|
|
|
mock_fetch.assert_called_once_with(args["url_input"]) |
|
mock_soc.assert_called_once() |
|
call_args = mock_soc.call_args[1] |
|
assert fetched_text in call_args["user_prompt"] |
|
|
|
|
|
@patch("ankigen_core.card_generator.fetch_webpage_text") |
|
@patch( |
|
"ankigen_core.card_generator.gr.Error" |
|
) |
|
async def test_orchestrate_web_mode_fetch_error( |
|
mock_gr_error, mock_fetch, mock_client_manager_fixture, mock_response_cache_fixture |
|
): |
|
"""Test 'web' mode handles errors during webpage fetching by calling gr.Error.""" |
|
manager, _ = mock_client_manager_fixture |
|
cache = mock_response_cache_fixture |
|
args = base_orchestrator_args(generation_mode="web") |
|
error_msg = "Connection timed out" |
|
mock_fetch.side_effect = ConnectionError(error_msg) |
|
|
|
with patch("gradio.Info"), patch("gradio.Warning"): |
|
df, status_msg, count = await card_generator.orchestrate_card_generation( |
|
client_manager=manager, cache=cache, **args |
|
) |
|
|
|
mock_gr_error.assert_called_once_with( |
|
f"Failed to get content from URL: {error_msg}" |
|
) |
|
assert isinstance(df, pd.DataFrame) |
|
assert df.empty |
|
assert df.columns.tolist() == get_dataframe_columns() |
|
assert status_msg == "Failed to get content from URL." |
|
assert count == 0 |
|
|
|
|
|
@patch("ankigen_core.card_generator.structured_output_completion") |
|
@patch("ankigen_core.card_generator.generate_cards_batch") |
|
async def test_orchestrate_generation_batch_error( |
|
mock_gcb, mock_soc, mock_client_manager_fixture, mock_response_cache_fixture |
|
): |
|
"""Test orchestrator handles errors from generate_cards_batch.""" |
|
manager, client = mock_client_manager_fixture |
|
cache = mock_response_cache_fixture |
|
args = base_orchestrator_args(generation_mode="subject") |
|
error_msg = "LLM generation failed" |
|
|
|
|
|
mock_soc.return_value = { |
|
"topics": [ |
|
{"name": "Topic 1", "difficulty": "beginner", "description": "Desc 1"} |
|
] |
|
} |
|
|
|
|
|
mock_gcb.side_effect = ValueError(error_msg) |
|
|
|
|
|
|
|
with patch("gradio.Info"), patch("gradio.Warning") as mock_gr_warning: |
|
|
|
await card_generator.orchestrate_card_generation( |
|
client_manager=manager, cache=cache, **args |
|
) |
|
|
|
|
|
mock_gr_warning.assert_called_with( |
|
"Failed to generate cards for 'Topic 1'. Skipping." |
|
) |
|
|
|
mock_soc.assert_called_once() |
|
mock_gcb.assert_called_once() |
|
|
|
|
|
@patch("ankigen_core.card_generator.gr.Error") |
|
async def test_orchestrate_path_mode_raises_not_implemented( |
|
mock_gr_error, mock_client_manager_fixture, mock_response_cache_fixture |
|
): |
|
"""Test 'path' mode calls gr.Error for being unsupported.""" |
|
manager, _ = mock_client_manager_fixture |
|
cache = mock_response_cache_fixture |
|
args = base_orchestrator_args(generation_mode="path") |
|
|
|
df, status_msg, count = await card_generator.orchestrate_card_generation( |
|
client_manager=manager, cache=cache, **args |
|
) |
|
|
|
mock_gr_error.assert_called_once_with("Unsupported generation mode selected: path") |
|
assert isinstance(df, pd.DataFrame) |
|
assert df.empty |
|
assert df.columns.tolist() == get_dataframe_columns() |
|
assert status_msg == "Unsupported mode." |
|
assert count == 0 |
|
|
|
|
|
@patch("ankigen_core.card_generator.gr.Error") |
|
async def test_orchestrate_invalid_mode_raises_value_error( |
|
mock_gr_error, mock_client_manager_fixture, mock_response_cache_fixture |
|
): |
|
"""Test invalid mode calls gr.Error.""" |
|
manager, _ = mock_client_manager_fixture |
|
cache = mock_response_cache_fixture |
|
args = base_orchestrator_args(generation_mode="invalid_mode") |
|
|
|
df, status_msg, count = await card_generator.orchestrate_card_generation( |
|
client_manager=manager, cache=cache, **args |
|
) |
|
|
|
mock_gr_error.assert_called_once_with( |
|
"Unsupported generation mode selected: invalid_mode" |
|
) |
|
assert isinstance(df, pd.DataFrame) |
|
assert df.empty |
|
assert df.columns.tolist() == get_dataframe_columns() |
|
assert status_msg == "Unsupported mode." |
|
assert count == 0 |
|
|
|
|
|
@patch("ankigen_core.card_generator.gr.Error") |
|
async def test_orchestrate_no_api_key_raises_error( |
|
mock_gr_error, mock_client_manager_fixture, mock_response_cache_fixture |
|
): |
|
"""Test orchestrator calls gr.Error if API key is missing.""" |
|
manager, _ = mock_client_manager_fixture |
|
cache = mock_response_cache_fixture |
|
args = base_orchestrator_args(api_key="") |
|
|
|
df, status_msg, count = await card_generator.orchestrate_card_generation( |
|
client_manager=manager, cache=cache, **args |
|
) |
|
|
|
mock_gr_error.assert_called_once_with("OpenAI API key is required") |
|
assert isinstance(df, pd.DataFrame) |
|
assert df.empty |
|
assert df.columns.tolist() == get_dataframe_columns() |
|
assert status_msg == "API key is required." |
|
assert count == 0 |
|
|
|
|
|
@patch("ankigen_core.card_generator.gr.Error") |
|
async def test_orchestrate_client_init_error_raises_error( |
|
mock_gr_error, mock_client_manager_fixture, mock_response_cache_fixture |
|
): |
|
"""Test orchestrator calls gr.Error if client initialization fails.""" |
|
manager, _ = mock_client_manager_fixture |
|
cache = mock_response_cache_fixture |
|
args = base_orchestrator_args() |
|
error_msg = "Invalid API Key" |
|
manager.initialize_client.side_effect = ValueError(error_msg) |
|
|
|
df, status_msg, count = await card_generator.orchestrate_card_generation( |
|
client_manager=manager, cache=cache, **args |
|
) |
|
|
|
mock_gr_error.assert_called_once_with(f"OpenAI Client Error: {error_msg}") |
|
assert isinstance(df, pd.DataFrame) |
|
assert df.empty |
|
assert df.columns.tolist() == get_dataframe_columns() |
|
assert status_msg == f"OpenAI Client Error: {error_msg}" |
|
assert count == 0 |
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture |
|
def sample_anki_card_data_list() -> list[AnkiCardData]: |
|
"""Provides a list of sample AnkiCardData objects for testing.""" |
|
return [ |
|
AnkiCardData( |
|
front="Question 1", |
|
back="Answer 1", |
|
tags=["tagA", "tagB"], |
|
source_url="http://example.com/source1", |
|
note_type="Basic", |
|
), |
|
AnkiCardData( |
|
front="Question 2", |
|
back="Answer 2", |
|
tags=[], |
|
source_url=None, |
|
note_type="Cloze", |
|
), |
|
AnkiCardData( |
|
front="Question 3", |
|
back="Answer 3", |
|
tags=[], |
|
source_url="http://example.com/source3", |
|
note_type="Basic", |
|
), |
|
] |
|
|
|
|
|
def test_process_anki_card_data_basic_conversion(sample_anki_card_data_list): |
|
"""Test basic conversion of AnkiCardData to dicts.""" |
|
input_cards = sample_anki_card_data_list |
|
processed = card_generator.process_anki_card_data(input_cards) |
|
|
|
assert len(processed) == 3 |
|
assert isinstance(processed[0], dict) |
|
assert processed[0]["front"] == "Question 1" |
|
assert ( |
|
processed[0]["back"] |
|
== "Answer 1\\n\\n<hr><small>Source: <a href='http://example.com/source1'>http://example.com/source1</a></small>" |
|
) |
|
assert processed[0]["tags"] == "tagA tagB" |
|
assert processed[0]["note_type"] == "Basic" |
|
|
|
assert processed[1]["front"] == "Question 2" |
|
assert processed[1]["back"] == "Answer 2" |
|
assert processed[1]["tags"] == "" |
|
assert processed[1]["note_type"] == "Cloze" |
|
|
|
assert processed[2]["front"] == "Question 3" |
|
assert "<hr><small>Source" in processed[2]["back"] |
|
assert "http://example.com/source3" in processed[2]["back"] |
|
assert processed[2]["tags"] == "" |
|
assert processed[2]["note_type"] == "Basic" |
|
|
|
|
|
def test_process_anki_card_data_empty_list(): |
|
"""Test processing an empty list of cards.""" |
|
processed = card_generator.process_anki_card_data([]) |
|
assert processed == [] |
|
|
|
|
|
def test_process_anki_card_data_source_url_formatting(sample_anki_card_data_list): |
|
"""Test that the source_url is correctly formatted and appended to the back.""" |
|
|
|
card_with_source = [sample_anki_card_data_list[0]] |
|
processed = card_generator.process_anki_card_data(card_with_source) |
|
expected_back_html = "\\n\\n<hr><small>Source: <a href='http://example.com/source1'>http://example.com/source1</a></small>" |
|
assert processed[0]["back"].endswith(expected_back_html) |
|
|
|
|
|
card_without_source = [sample_anki_card_data_list[1]] |
|
processed_no_source = card_generator.process_anki_card_data(card_without_source) |
|
assert "<hr><small>Source:" not in processed_no_source[0]["back"] |
|
|
|
|
|
def test_process_anki_card_data_tags_formatting(sample_anki_card_data_list): |
|
"""Test tags are correctly joined into a space-separated string.""" |
|
processed = card_generator.process_anki_card_data(sample_anki_card_data_list) |
|
assert processed[0]["tags"] == "tagA tagB" |
|
assert processed[1]["tags"] == "" |
|
assert processed[2]["tags"] == "" |
|
|
|
|
|
def test_process_anki_card_data_note_type_handling(sample_anki_card_data_list): |
|
"""Test note_type handling, including default.""" |
|
processed = card_generator.process_anki_card_data(sample_anki_card_data_list) |
|
assert processed[0]["note_type"] == "Basic" |
|
assert processed[1]["note_type"] == "Cloze" |
|
assert processed[2]["note_type"] == "Basic" |
|
|
|
|
|
|
|
card_without_note_type_field = AnkiCardData( |
|
front="Q", back="A" |
|
) |
|
processed_single = card_generator.process_anki_card_data( |
|
[card_without_note_type_field] |
|
) |
|
|
|
|
|
|
|
|
|
|
|
|
|
assert processed_single[0]["note_type"] == "Basic" |
|
|
|
|
|
|
|
|
|
|
|
def test_deduplicate_cards_removes_duplicates(): |
|
"""Test that duplicate cards (based on 'front' content) are removed.""" |
|
cards_with_duplicates = [ |
|
{"front": "Q1", "back": "A1"}, |
|
{"front": "Q2", "back": "A2"}, |
|
{"front": "Q1", "back": "A1_variant"}, |
|
{"front": "Q3", "back": "A3"}, |
|
{"front": "Q2", "back": "A2_variant"}, |
|
] |
|
expected_cards = [ |
|
{"front": "Q1", "back": "A1"}, |
|
{"front": "Q2", "back": "A2"}, |
|
{"front": "Q3", "back": "A3"}, |
|
] |
|
assert card_generator.deduplicate_cards(cards_with_duplicates) == expected_cards |
|
|
|
|
|
def test_deduplicate_cards_preserves_order(): |
|
"""Test that the order of first-seen unique cards is preserved.""" |
|
ordered_cards = [ |
|
{"front": "Q_alpha", "back": "A_alpha"}, |
|
{"front": "Q_beta", "back": "A_beta"}, |
|
{"front": "Q_gamma", "back": "A_gamma"}, |
|
{"front": "Q_alpha", "back": "A_alpha_redux"}, |
|
] |
|
expected_ordered_cards = [ |
|
{"front": "Q_alpha", "back": "A_alpha"}, |
|
{"front": "Q_beta", "back": "A_beta"}, |
|
{"front": "Q_gamma", "back": "A_gamma"}, |
|
] |
|
assert card_generator.deduplicate_cards(ordered_cards) == expected_ordered_cards |
|
|
|
|
|
def test_deduplicate_cards_empty_list(): |
|
"""Test deduplicating an empty list of cards.""" |
|
assert card_generator.deduplicate_cards([]) == [] |
|
|
|
|
|
def test_deduplicate_cards_all_unique(): |
|
"""Test deduplicating a list where all cards are unique.""" |
|
all_unique_cards = [ |
|
{"front": "Unique1", "back": "Ans1"}, |
|
{"front": "Unique2", "back": "Ans2"}, |
|
{"front": "Unique3", "back": "Ans3"}, |
|
] |
|
assert card_generator.deduplicate_cards(all_unique_cards) == all_unique_cards |
|
|
|
|
|
def test_deduplicate_cards_missing_front_key(): |
|
"""Test that cards missing the 'front' key are skipped and logged.""" |
|
cards_with_missing_front = [ |
|
{"front": "Q1", "back": "A1"}, |
|
{"foo": "bar", "back": "A2"}, |
|
{"front": "Q3", "back": "A3"}, |
|
] |
|
expected_cards = [ |
|
{"front": "Q1", "back": "A1"}, |
|
{"front": "Q3", "back": "A3"}, |
|
] |
|
|
|
with patch.object(card_generator.logger, "warning") as mock_log_warning: |
|
result = card_generator.deduplicate_cards(cards_with_missing_front) |
|
assert result == expected_cards |
|
mock_log_warning.assert_called_once_with( |
|
"Card skipped during deduplication due to missing 'front' key: {'foo': 'bar', 'back': 'A2'}" |
|
) |
|
|
|
|
|
def test_deduplicate_cards_front_is_none(): |
|
"""Test that cards where 'front' value is None are skipped and logged.""" |
|
cards_with_none_front = [ |
|
{"front": "Q1", "back": "A1"}, |
|
{"front": None, "back": "A2"}, |
|
{"front": "Q3", "back": "A3"}, |
|
] |
|
expected_cards = [ |
|
{"front": "Q1", "back": "A1"}, |
|
{"front": "Q3", "back": "A3"}, |
|
] |
|
with patch.object(card_generator.logger, "warning") as mock_log_warning: |
|
result = card_generator.deduplicate_cards(cards_with_none_front) |
|
assert result == expected_cards |
|
mock_log_warning.assert_called_once_with( |
|
"Card skipped during deduplication due to missing 'front' key: {'front': None, 'back': 'A2'}" |
|
) |
|
|
|
|
|
|
|
|
|
|
|
@patch("ankigen_core.card_generator.deduplicate_cards") |
|
@patch("ankigen_core.card_generator.process_anki_card_data") |
|
def test_generate_cards_from_crawled_content_orchestration( |
|
mock_process_anki_card_data, |
|
mock_deduplicate_cards, |
|
sample_anki_card_data_list, |
|
): |
|
"""Test that generate_cards_from_crawled_content correctly orchestrates calls.""" |
|
|
|
|
|
mock_processed_list = [{"front": "Processed Q1", "back": "Processed A1"}] |
|
mock_process_anki_card_data.return_value = mock_processed_list |
|
|
|
mock_unique_list = [{"front": "Unique Q1", "back": "Unique A1"}] |
|
mock_deduplicate_cards.return_value = mock_unique_list |
|
|
|
input_anki_cards = sample_anki_card_data_list |
|
|
|
|
|
result = card_generator.generate_cards_from_crawled_content(input_anki_cards) |
|
|
|
|
|
mock_process_anki_card_data.assert_called_once_with(input_anki_cards) |
|
mock_deduplicate_cards.assert_called_once_with(mock_processed_list) |
|
assert result == mock_unique_list |
|
|
|
|
|
def test_generate_cards_from_crawled_content_empty_input(): |
|
"""Test with an empty list of AnkiCardData objects.""" |
|
with ( |
|
patch( |
|
"ankigen_core.card_generator.process_anki_card_data", return_value=[] |
|
) as mock_process, |
|
patch( |
|
"ankigen_core.card_generator.deduplicate_cards", return_value=[] |
|
) as mock_dedup, |
|
): |
|
result = card_generator.generate_cards_from_crawled_content([]) |
|
mock_process.assert_called_once_with([]) |
|
mock_dedup.assert_called_once_with([]) |
|
assert result == [] |
|
|
|
|
|
|
|
|
|
def test_generate_cards_from_crawled_content_integration(sample_anki_card_data_list): |
|
""" |
|
A more integration-style test to ensure the flow works with real sub-functions. |
|
This relies on the correctness of process_anki_card_data and deduplicate_cards. |
|
""" |
|
|
|
card1 = AnkiCardData(front="Q1", back="A1", tags=["test"], note_type="Basic") |
|
card2_dup = AnkiCardData( |
|
front="Q1", back="A1_variant", tags=["test"], note_type="Basic" |
|
) |
|
card3 = AnkiCardData(front="Q2", back="A2", tags=["test"], note_type="Basic") |
|
|
|
input_list = [card1, card2_dup, card3] |
|
|
|
result = card_generator.generate_cards_from_crawled_content(input_list) |
|
|
|
|
|
|
|
|
|
assert len(result) == 2 |
|
|
|
|
|
result_fronts = [item["front"] for item in result] |
|
assert "Q1" in result_fronts |
|
assert "Q2" in result_fronts |
|
|
|
|
|
|
|
q1_card_in_result = next(item for item in result if item["front"] == "Q1") |
|
assert ( |
|
"A1" in q1_card_in_result["back"] |
|
) |
|
assert "A1_variant" not in q1_card_in_result["back"] |
|
|
|
|