diff --git a/.github/workflows/pr-tests.yml b/.github/workflows/pr-tests.yml index 29fd1b1..b90992e 100644 --- a/.github/workflows/pr-tests.yml +++ b/.github/workflows/pr-tests.yml @@ -31,7 +31,7 @@ jobs: uv run black --check . - name: Run tests with coverage - run: uv run pytest --cov=rmagent --cov-report=term-missing --cov-fail-under=80 + run: uv run pytest --cov=rmagent --cov-report=term-missing --cov-report=xml --cov-fail-under=80 env: # Set test environment variables RM_DATABASE_PATH: data/Iiams.rmtree @@ -46,4 +46,5 @@ jobs: if: always() with: token: ${{ secrets.CODECOV_TOKEN }} + files: ./coverage.xml fail_ci_if_error: false diff --git a/tests/unit/test_citations.py b/tests/unit/test_citations.py new file mode 100644 index 0000000..99919b7 --- /dev/null +++ b/tests/unit/test_citations.py @@ -0,0 +1,408 @@ +""" +Unit tests for biography citation processing and formatting. + +Tests citation formatting, footnote generation, and bibliography creation. +""" + +from rmagent.generators.biography.citations import CitationProcessor +from rmagent.generators.biography.models import CitationInfo, CitationStyle, CitationTracker + + +class TestStripSourceTypePrefix: + """Test strip_source_type_prefix static method.""" + + def test_strip_book_prefix(self): + """Test removing 'Book: ' prefix.""" + result = CitationProcessor.strip_source_type_prefix("Book: Smith Family History") + assert result == "Smith Family History" + + def test_strip_newspaper_prefix(self): + """Test removing 'Newspaper: ' prefix.""" + result = CitationProcessor.strip_source_type_prefix("Newspaper: Baltimore Sun") + assert result == "Baltimore Sun" + + def test_strip_newspapers_plural_prefix(self): + """Test removing 'Newspapers: ' prefix.""" + result = CitationProcessor.strip_source_type_prefix("Newspapers: New York Times") + assert result == "New York Times" + + def test_no_prefix_to_strip(self): + """Test source name without prefix remains unchanged.""" + result = CitationProcessor.strip_source_type_prefix("US Census Records") + assert result == "US Census Records" + + def test_strip_cemetery_prefix(self): + """Test removing 'Cemetery: ' prefix.""" + result = CitationProcessor.strip_source_type_prefix("Cemetery: Oak Hill") + assert result == "Oak Hill" + + def test_strip_website_prefix(self): + """Test removing 'Website: ' prefix.""" + result = CitationProcessor.strip_source_type_prefix("Website: Ancestry.com") + assert result == "Ancestry.com" + + +class TestFormatCitationInfo: + """Test format_citation_info method.""" + + def test_format_freeform_citation_with_all_fields(self): + """Test formatting free-form citation with all fields populated.""" + processor = CitationProcessor() + citation = { + "CitationID": 123, + "SourceID": 456, + "TemplateID": 0, # Free-form + "Footnote": "Smith, *Family History*, p. 42", + "ShortFootnote": "Smith, p. 42", + "CitationBibliography": "Smith, John. *Family History*. Publisher, 2000.", + } + + result = processor.format_citation_info(citation) + + assert isinstance(result, CitationInfo) + assert result.citation_id == 123 + assert result.source_id == 456 + assert result.footnote == "Smith, *Family History*, p. 42" + assert result.short_footnote == "Smith, p. 42" + assert result.bibliography == "Smith, John. *Family History*. Publisher, 2000." + assert result.is_freeform is True + assert result.template_name is None + + def test_format_template_citation(self): + """Test formatting template-based citation shows placeholders.""" + processor = CitationProcessor() + citation = { + "CitationID": 789, + "SourceID": 101, + "TemplateID": 5, # Template-based + "TemplateName": "US Census", + "Footnote": None, + "ShortFootnote": None, + "CitationBibliography": None, + } + + result = processor.format_citation_info(citation) + + assert result.citation_id == 789 + assert result.source_id == 101 + assert result.is_freeform is False + assert result.template_name == "US Census" + assert "[Citation 789, Template: US Census]" in result.footnote + assert "[Source 101, Template: US Census]" in result.bibliography + + +class TestProcessCitationsInText: + """Test process_citations_in_text method.""" + + def test_process_single_citation(self): + """Test processing single citation marker in text.""" + processor = CitationProcessor() + text = "He was born in 1850.{{cite:123}}" + citations = [ + { + "CitationID": 123, + "SourceID": 456, + "TemplateID": 0, + "Footnote": "Birth Record, p. 10", + "ShortFootnote": "Birth Record", + "CitationBibliography": "Vital Records Office.", + } + ] + + modified_text, footnotes, tracker = processor.process_citations_in_text(text, citations) + + assert modified_text == "He was born in 1850.[^1]" + assert len(footnotes) == 1 + assert footnotes[0][0] == 1 # Footnote number + assert footnotes[0][1].citation_id == 123 + assert len(tracker.citation_order) == 1 + + def test_process_multiple_citations(self): + """Test processing multiple citation markers in text.""" + processor = CitationProcessor() + text = "He was born{{cite:123}} and died{{cite:456}}." + citations = [ + { + "CitationID": 123, + "SourceID": 1, + "TemplateID": 0, + "Footnote": "Birth Record", + "ShortFootnote": "Birth Record", + "CitationBibliography": "Vital Records.", + }, + { + "CitationID": 456, + "SourceID": 2, + "TemplateID": 0, + "Footnote": "Death Record", + "ShortFootnote": "Death Record", + "CitationBibliography": "Death Index.", + }, + ] + + modified_text, footnotes, tracker = processor.process_citations_in_text(text, citations) + + assert modified_text == "He was born[^1] and died[^2]." + assert len(footnotes) == 2 + assert footnotes[0][0] == 1 + assert footnotes[1][0] == 2 + + def test_process_duplicate_citation(self): + """Test that duplicate citations get same footnote number.""" + processor = CitationProcessor() + text = "First mention{{cite:123}} and second mention{{cite:123}}." + citations = [ + { + "CitationID": 123, + "SourceID": 456, + "TemplateID": 0, + "Footnote": "Source A", + "ShortFootnote": "Source A", + "CitationBibliography": "Bibliography A.", + } + ] + + modified_text, footnotes, tracker = processor.process_citations_in_text(text, citations) + + assert modified_text == "First mention[^1] and second mention[^1]." + assert len(footnotes) == 1 # Only one unique citation + + def test_process_missing_citation(self): + """Test processing citation marker with missing citation.""" + processor = CitationProcessor() + text = "Reference to missing citation{{cite:999}}." + citations = [] # No citations available + + modified_text, footnotes, tracker = processor.process_citations_in_text(text, citations) + + assert "[^999?]" in modified_text # Should show placeholder with ? + assert len(footnotes) == 0 + + def test_process_no_citations(self): + """Test text with no citation markers.""" + processor = CitationProcessor() + text = "Plain text with no citations." + citations = [] + + modified_text, footnotes, tracker = processor.process_citations_in_text(text, citations) + + assert modified_text == text + assert len(footnotes) == 0 + assert len(tracker.citation_order) == 0 + + +class TestGenerateFootnotesSection: + """Test generate_footnotes_section method.""" + + def test_generate_single_footnote(self): + """Test generating footnotes section with single entry.""" + processor = CitationProcessor() + tracker = CitationTracker() + tracker.add_citation(123, 456) + + citation_info = CitationInfo( + citation_id=123, + source_id=456, + footnote="Full footnote text", + short_footnote="Short footnote", + bibliography="Bibliography entry", + is_freeform=True, + template_name=None, + ) + footnotes = [(1, citation_info)] + + result = processor.generate_footnotes_section(footnotes, tracker) + + assert result == " [^1]: Full footnote text" + + def test_generate_multiple_footnotes_first_and_subsequent(self): + """Test first citation uses full footnote, subsequent use short.""" + processor = CitationProcessor() + tracker = CitationTracker() + + # Same source cited twice + tracker.add_citation(123, 456) # First citation for source 456 + tracker.add_citation(124, 456) # Second citation for same source + + citation1 = CitationInfo( + citation_id=123, + source_id=456, + footnote="Full footnote for source 456", + short_footnote="Short for 456", + bibliography="Bibliography", + is_freeform=True, + template_name=None, + ) + citation2 = CitationInfo( + citation_id=124, + source_id=456, + footnote="Full footnote for source 456", + short_footnote="Short for 456", + bibliography="Bibliography", + is_freeform=True, + template_name=None, + ) + + footnotes = [(1, citation1), (2, citation2)] + result = processor.generate_footnotes_section(footnotes, tracker) + + lines = result.split("\n") + assert "Full footnote for source 456" in lines[0] # First uses full + assert "Short for 456" in lines[1] # Second uses short + + +class TestGenerateSourcesSection: + """Test generate_sources_section method.""" + + def test_generate_single_source(self): + """Test generating bibliography with single source.""" + processor = CitationProcessor() + citations = [ + { + "CitationID": 123, + "SourceID": 456, + "TemplateID": 0, + "Footnote": "Footnote", + "ShortFootnote": "Short", + "CitationBibliography": "Smith, John. *Family History*. 2000.", + } + ] + + result = processor.generate_sources_section(citations) + + assert " Smith, John. *Family History*. 2000." in result + + def test_generate_multiple_sources_sorted(self): + """Test bibliography is alphabetically sorted.""" + processor = CitationProcessor() + citations = [ + { + "CitationID": 1, + "SourceID": 1, + "TemplateID": 0, + "Footnote": "F", + "ShortFootnote": "S", + "CitationBibliography": "Zimmerman, Alice. Book Z.", + }, + { + "CitationID": 2, + "SourceID": 2, + "TemplateID": 0, + "Footnote": "F", + "ShortFootnote": "S", + "CitationBibliography": "Adams, Bob. Book A.", + }, + ] + + result = processor.generate_sources_section(citations) + + lines = result.split("\n") + assert "Adams" in lines[0] # Adams should be first alphabetically + assert "Zimmerman" in lines[1] # Zimmerman should be second + + def test_deduplicate_sources_by_id(self): + """Test that sources are deduplicated by SourceID.""" + processor = CitationProcessor() + citations = [ + { + "CitationID": 1, + "SourceID": 100, + "TemplateID": 0, + "Footnote": "F", + "ShortFootnote": "S", + "CitationBibliography": "Same Source.", + }, + { + "CitationID": 2, + "SourceID": 100, # Same SourceID + "TemplateID": 0, + "Footnote": "F", + "ShortFootnote": "S", + "CitationBibliography": "Same Source.", + }, + ] + + result = processor.generate_sources_section(citations) + + # Should only appear once + assert result.count("Same Source.") == 1 + + +class TestFormatSourcesSection: + """Test format_sources_section method for legacy formatting.""" + + @staticmethod + def _create_minimal_context(**kwargs): + """Helper to create PersonContext with minimal required fields.""" + from rmagent.generators.biography.models import PersonContext + + defaults = { + "person_id": 1, + "full_name": "Test Person", + "given_name": "Test", + "surname": "Person", + "prefix": None, + "suffix": None, + "nickname": None, + "birth_year": None, + "birth_date": None, + "birth_place": None, + "death_year": None, + "death_date": None, + "death_place": None, + "sex": 2, # Unknown + "is_private": False, + "is_living": False, + } + defaults.update(kwargs) + return PersonContext(**defaults) + + def test_format_footnote_style(self): + """Test formatting sources in footnote style.""" + processor = CitationProcessor() + context = self._create_minimal_context( + all_citations=[ + {"SourceName": "Book: Family History", "CitationName": "Page 42"}, + ] + ) + + result = processor.format_sources_section(context, CitationStyle.FOOTNOTE) + + assert "1. *Family History*" in result # Prefix stripped + assert " Page 42" in result + + def test_format_parenthetical_style(self): + """Test formatting sources in parenthetical style.""" + processor = CitationProcessor() + context = self._create_minimal_context( + all_citations=[ + {"SourceName": "Newspaper: Daily News", "CitationName": "1950-01-01"}, + ] + ) + + result = processor.format_sources_section(context, CitationStyle.PARENTHETICAL) + + assert "- *Daily News*" in result # Prefix stripped + assert " (1950-01-01)" in result + + def test_format_narrative_style(self): + """Test formatting sources in narrative style.""" + processor = CitationProcessor() + context = self._create_minimal_context( + all_citations=[ + {"SourceName": "Census Records", "CitationName": ""}, + ] + ) + + result = processor.format_sources_section(context, CitationStyle.NARRATIVE) + + assert "- *Census Records*" in result + + def test_format_no_citations(self): + """Test formatting with no citations returns empty string.""" + processor = CitationProcessor() + context = self._create_minimal_context(all_citations=[]) + + result = processor.format_sources_section(context, CitationStyle.FOOTNOTE) + + assert result == "" diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 2f2059e..922cdf7 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -342,6 +342,28 @@ def test_ask_with_question(self, runner, test_db_path): # Either way, command should recognize the question format assert "Ask questions" not in result.output # Not showing help text + def test_ask_interactive_mode(self, runner, test_db_path): + """Test ask interactive mode with simulated user input.""" + # Simulate user typing a question then "quit" + result = runner.invoke( + cli, + ["--database", test_db_path, "ask", "--interactive"], + input="Who is person 1?\nquit\n", + ) + # Should enter interactive mode + assert "Interactive Q&A Mode" in result.output or result.exit_code in [0, 1] + + def test_ask_interactive_exit_commands(self, runner, test_db_path): + """Test that various exit commands work in interactive mode.""" + for exit_cmd in ["exit", "quit", "q"]: + result = runner.invoke( + cli, + ["--database", test_db_path, "ask", "--interactive"], + input=f"{exit_cmd}\n", + ) + # Should accept exit command and show goodbye message + assert result.exit_code in [0, 1] # May succeed or fail depending on LLM + class TestTimelineCommand: """Test timeline command.""" @@ -661,6 +683,54 @@ def test_search_with_all_keyword(self, runner, test_db_path): # Should search all 8 configured variants assert "Searching 8 name variations" in result.output or "Found" in result.output + def test_search_with_married_name(self, runner, test_db_path): + """Test search with --married-name flag.""" + result = runner.invoke(cli, ["--database", test_db_path, "search", "--name", "Janet", "--married-name"]) + assert result.exit_code == 0 + # Should search for females by maiden and married names + assert "Found" in result.output or "No persons" in result.output + + def test_search_radius_both_units_error(self, runner, test_db_path): + """Test that specifying both --kilometers and --miles fails.""" + result = runner.invoke( + cli, + [ + "--database", + test_db_path, + "search", + "--place", + "Phoenix, Arizona", + "--kilometers", + "100", + "--miles", + "50", + ], + ) + assert result.exit_code != 0 + assert "Cannot specify both" in result.output + + def test_search_radius_negative_value(self, runner, test_db_path): + """Test that negative radius value fails.""" + result = runner.invoke( + cli, + ["--database", test_db_path, "search", "--place", "Phoenix, Arizona", "--kilometers", "-10"], + ) + assert result.exit_code != 0 + assert "must be positive" in result.output or "Error" in result.output + + def test_search_radius_without_place(self, runner, test_db_path): + """Test that radius search requires --place.""" + result = runner.invoke(cli, ["--database", test_db_path, "search", "--name", "Smith", "--kilometers", "100"]) + assert result.exit_code != 0 + assert "requires --place" in result.output or "Error" in result.output + + def test_search_place_exact_match(self, runner, test_db_path): + """Test place search with --exact flag.""" + result = runner.invoke(cli, ["--database", test_db_path, "search", "--place", "Maryland", "--exact"]) + assert result.exit_code == 0 + # Should return results or no matches + assert "Found" in result.output or "No places" in result.output + class TestGlobalOptions: """Test global CLI options.""" diff --git a/tests/unit/test_rendering.py b/tests/unit/test_rendering.py new file mode 100644 index 0000000..188ce9d --- /dev/null +++ b/tests/unit/test_rendering.py @@ -0,0 +1,424 @@ +""" +Unit tests for biography rendering and markdown generation. + +Tests biography rendering, metadata formatting, and image handling. +""" + +from rmagent.generators.biography.models import Biography, BiographyLength, CitationStyle, LLMMetadata +from rmagent.generators.biography.rendering import BiographyRenderer + + +class TestFormatTokens: + """Test format_tokens static method.""" + + def test_format_less_than_thousand(self): + """Test formatting tokens less than 1000.""" + assert BiographyRenderer.format_tokens(500) == "500" + assert BiographyRenderer.format_tokens(999) == "999" + + def test_format_thousands(self): + """Test formatting tokens in thousands.""" + assert BiographyRenderer.format_tokens(1000) == "1.0k" + assert BiographyRenderer.format_tokens(1500) == "1.5k" + assert BiographyRenderer.format_tokens(2300) == "2.3k" + + def test_format_large_numbers(self): + """Test formatting large token counts.""" + assert BiographyRenderer.format_tokens(10000) == "10.0k" + assert BiographyRenderer.format_tokens(42500) == "42.5k" + + +class TestFormatDuration: + """Test format_duration static method.""" + + def test_format_seconds_only(self): + """Test formatting durations less than 60 seconds.""" + assert BiographyRenderer.format_duration(5.2) == "5s" + assert BiographyRenderer.format_duration(45.9) == "45s" + assert BiographyRenderer.format_duration(59) == "59s" + + def test_format_minutes_and_seconds(self): + """Test formatting durations with minutes and seconds.""" + assert BiographyRenderer.format_duration(65) == "1m5s" + assert BiographyRenderer.format_duration(125) == "2m5s" + assert BiographyRenderer.format_duration(183.5) == "3m3s" + + def test_format_minutes_only(self): + """Test formatting durations with even minutes.""" + assert BiographyRenderer.format_duration(60) == "1m" + assert BiographyRenderer.format_duration(120) == "2m" + assert BiographyRenderer.format_duration(180) == "3m" + + +class TestFormatImageCaption: + """Test _format_image_caption static method.""" + + def test_caption_with_both_years(self): + """Test caption with birth and death years.""" + caption = BiographyRenderer._format_image_caption("John Doe", 1850, 1920) + assert caption == "John Doe (1850-1920)" + + def test_caption_birth_only(self): + """Test caption with only birth year.""" + caption = BiographyRenderer._format_image_caption("Jane Smith", 1900, None) + assert caption == "Jane Smith (1900-????)" + + def test_caption_death_only(self): + """Test caption with only death year.""" + caption = BiographyRenderer._format_image_caption("Bob Jones", None, 1950) + assert caption == "Bob Jones (????-1950)" + + def test_caption_no_years(self): + """Test caption without any years.""" + caption = BiographyRenderer._format_image_caption("Alice Brown", None, None) + assert caption == "Alice Brown" + + +class TestFormatImagePath: + """Test _format_image_path method.""" + + def test_format_path_with_question_mark_backslash(self): + """Test formatting path with question-backslash prefix (Windows-style).""" + renderer = BiographyRenderer() + media = {"MediaPath": r"?\Photos\Family", "MediaFile": "portrait.jpg"} + + result = renderer._format_image_path(media) + + # Path object preserves backslashes on Unix, but as_posix() converts separators + # The actual behavior depends on the implementation - accept either format + assert "../images" in result + assert "portrait.jpg" in result + + def test_format_path_with_question_mark_slash(self): + """Test formatting path with ?/ prefix (Unix-style).""" + renderer = BiographyRenderer() + media = {"MediaPath": "?/Photos/Family", "MediaFile": "photo.png"} + + result = renderer._format_image_path(media) + + assert result == "../images/Photos/Family/photo.png" + + def test_format_path_without_question_mark(self): + """Test formatting path without ? prefix.""" + renderer = BiographyRenderer() + media = {"MediaPath": "Photos/Family", "MediaFile": "image.jpg"} + + result = renderer._format_image_path(media) + + assert result == "Photos/Family/image.jpg" + + def test_format_path_no_media_path(self): + """Test formatting with no MediaPath (only MediaFile).""" + renderer = BiographyRenderer() + media = {"MediaPath": "", "MediaFile": "standalone.jpg"} + + result = renderer._format_image_path(media) + + assert result == "standalone.jpg" + + +class TestRenderMetadata: + """Test render_metadata method.""" + + @staticmethod + def _create_minimal_biography(**kwargs): + """Helper to create Biography with minimal required fields.""" + defaults = { + "person_id": 1, + "full_name": "Test Person", + "length": BiographyLength.STANDARD, + "citation_style": CitationStyle.FOOTNOTE, + "introduction": "Test intro", + "early_life": "", + "education": "", + "career": "", + "marriage_family": "", + "later_life": "", + "death_legacy": "", + "footnotes": "", + "sources": "", + } + defaults.update(kwargs) + return Biography(**defaults) + + def test_render_metadata_basic(self): + """Test rendering basic metadata without LLM metadata.""" + bio = self._create_minimal_biography( + full_name="John Doe", + birth_year=1850, + death_year=1920, + citation_count=5, + source_count=3, + ) + renderer = BiographyRenderer() + + result = renderer.render_metadata(bio) + + assert "---" in result + assert 'Title: "Biography of John Doe (1850-1920)"' in result + assert "PersonID: 1" in result + assert "Words:" in result + assert "Citations: 5" in result + assert "Sources: 3" in result + + def test_render_metadata_with_llm_metadata(self): + """Test rendering metadata with LLM metadata.""" + llm_meta = LLMMetadata( + provider="anthropic", + model="claude-3-5-sonnet-20241022", + prompt_tokens=1500, + completion_tokens=800, + total_tokens=2300, + prompt_time=2.5, + llm_time=5.3, + ) + bio = self._create_minimal_biography( + llm_metadata=llm_meta, + citation_count=10, + source_count=5, + ) + renderer = BiographyRenderer() + + result = renderer.render_metadata(bio) + + assert "TokensIn: 1.5k" in result + assert "TokensOut: 800" in result + assert "TotalTokens: 2.3k" in result + assert "LLM: Anthropic" in result + assert "Model: claude-3-5-sonnet-20241022" in result + assert "PromptTime: 2s" in result + assert "LLMTime: 5s" in result + + def test_render_metadata_missing_years(self): + """Test rendering metadata with missing birth/death years.""" + bio = self._create_minimal_biography( + birth_year=None, + death_year=None, + ) + renderer = BiographyRenderer() + + result = renderer.render_metadata(bio) + + # Should not include years in title when both are None + assert 'Title: "Biography of Test Person"' in result + assert "????" not in result # No placeholder years + + +class TestRenderMarkdown: + """Test render_markdown method.""" + + @staticmethod + def _create_minimal_biography(**kwargs): + """Helper to create Biography with minimal required fields.""" + defaults = { + "person_id": 1, + "full_name": "Test Person", + "length": BiographyLength.STANDARD, + "citation_style": CitationStyle.FOOTNOTE, + "introduction": "Test intro", + "early_life": "", + "education": "", + "career": "", + "marriage_family": "", + "later_life": "", + "death_legacy": "", + "footnotes": "", + "sources": "", + } + defaults.update(kwargs) + return Biography(**defaults) + + def test_render_markdown_with_all_sections(self): + """Test rendering biography with all sections populated.""" + bio = self._create_minimal_biography( + full_name="Jane Smith", + birth_year=1900, + death_year=1980, + introduction="Jane was born in 1900.", + early_life="She grew up in Maryland.", + education="She attended local schools.", + career="She worked as a teacher.", + marriage_family="She married John.", + later_life="She retired in 1965.", + death_legacy="She passed away in 1980.", + sources="Source 1\nSource 2", + ) + renderer = BiographyRenderer() + + result = renderer.render_markdown(bio, include_metadata=False) + + # Check all sections are present + assert "# Biography of Jane Smith (1900-1980)" in result + assert "## Introduction" in result + assert "Jane was born in 1900." in result + assert "## Early Life & Family Background" in result + assert "She grew up in Maryland." in result + assert "## Education" in result + assert "She attended local schools." in result + assert "## Career & Accomplishments" in result + assert "She worked as a teacher." in result + assert "## Marriage & Family" in result + assert "She married John." in result + assert "## Later Life & Activities" in result + assert "She retired in 1965." in result + assert "## Death & Legacy" in result + assert "She passed away in 1980." in result + assert "## Sources" in result + assert "Source 1" in result + + def test_render_markdown_with_metadata(self): + """Test rendering biography with front matter metadata.""" + bio = self._create_minimal_biography( + introduction="Test introduction.", + ) + renderer = BiographyRenderer() + + result = renderer.render_markdown(bio, include_metadata=True) + + # Should have front matter + assert "---" in result + assert "PersonID:" in result + + def test_render_markdown_without_metadata(self): + """Test rendering biography without front matter.""" + bio = self._create_minimal_biography( + introduction="Test introduction.", + ) + renderer = BiographyRenderer() + + result = renderer.render_markdown(bio, include_metadata=False) + + # Should not have front matter + lines = result.split("\n") + # First line should be the title, not --- + assert not lines[0].startswith("---") + assert lines[0].startswith("# Biography") + + def test_render_markdown_with_footnotes(self): + """Test rendering biography with footnotes section.""" + bio = self._create_minimal_biography( + introduction="Test intro.", + footnotes="[^1]: Footnote 1\n[^2]: Footnote 2", + citation_style=CitationStyle.FOOTNOTE, + ) + renderer = BiographyRenderer() + + result = renderer.render_markdown(bio, include_metadata=False) + + assert "## Footnotes" in result + assert "[^1]: Footnote 1" in result + + def test_render_markdown_no_footnotes_for_other_styles(self): + """Test that footnotes section is omitted for non-footnote citation styles.""" + bio = self._create_minimal_biography( + introduction="Test intro.", + footnotes="[^1]: Footnote 1", + citation_style=CitationStyle.NARRATIVE, # Not FOOTNOTE + ) + renderer = BiographyRenderer() + + result = renderer.render_markdown(bio, include_metadata=False) + + assert "## Footnotes" not in result + + def test_render_markdown_short_biography_no_images(self): + """Test that SHORT biographies don't include images.""" + bio = self._create_minimal_biography( + length=BiographyLength.SHORT, + introduction="Short bio.", + media_files=[{"IsPrimary": 1, "MediaPath": "?/test", "MediaFile": "photo.jpg"}], + ) + renderer = BiographyRenderer() + + result = renderer.render_markdown(bio, include_metadata=False) + + # Should not have image HTML + assert "' in result + assert '