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 '