- ContentMacro: add __post_init__ to auto-derive raw_text when built
programmatically, preventing str.replace("", X) corruption
- MacroParser: add @{target} shorthand syntax support mapped to REQUIRED kind,
updating parse, has_macros, count_macros, and find_macro_positions
- Artifact: store content in model and SQLite DB, replace resolver placeholder
with actual artifact content, add migration for existing databases
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
243 lines
8.5 KiB
Python
243 lines
8.5 KiB
Python
"""Unit tests for macro parser."""
|
|
|
|
import pytest
|
|
from markitect.prompts.templates.parser import MacroParser, MacroParsingError
|
|
from markitect.prompts.templates.models import MacroKind
|
|
|
|
|
|
class TestMacroParser:
|
|
"""Tests for MacroParser."""
|
|
|
|
def setup_method(self):
|
|
"""Setup parser for each test."""
|
|
self.parser = MacroParser()
|
|
|
|
def test_parse_required_macro(self):
|
|
"""Test parsing required macro."""
|
|
content = "Some text {{require:glossary}} more text"
|
|
macros = self.parser.parse(content)
|
|
|
|
assert len(macros) == 1
|
|
assert macros[0].kind == MacroKind.REQUIRED
|
|
assert macros[0].target == "glossary"
|
|
assert macros[0].parameters == {}
|
|
|
|
def test_parse_optional_macro(self):
|
|
"""Test parsing optional macro."""
|
|
content = "Text {{optional:constraints}}"
|
|
macros = self.parser.parse(content)
|
|
|
|
assert len(macros) == 1
|
|
assert macros[0].kind == MacroKind.OPTIONAL
|
|
assert macros[0].target == "constraints"
|
|
|
|
def test_parse_generate_macro(self):
|
|
"""Test parsing generate macro."""
|
|
content = "{{generate:examples}}"
|
|
macros = self.parser.parse(content)
|
|
|
|
assert len(macros) == 1
|
|
assert macros[0].kind == MacroKind.GENERATE
|
|
assert macros[0].target == "examples"
|
|
|
|
def test_parse_macro_with_parameters(self):
|
|
"""Test parsing macro with parameters."""
|
|
content = "{{generate:code|language=python|framework=fastapi}}"
|
|
macros = self.parser.parse(content)
|
|
|
|
assert len(macros) == 1
|
|
assert macros[0].target == "code"
|
|
assert macros[0].parameters == {
|
|
"language": "python",
|
|
"framework": "fastapi",
|
|
}
|
|
|
|
def test_parse_multiple_macros(self):
|
|
"""Test parsing multiple macros."""
|
|
content = """
|
|
{{require:glossary}}
|
|
Some text here
|
|
{{optional:notes}}
|
|
{{generate:examples|lang=python}}
|
|
"""
|
|
macros = self.parser.parse(content)
|
|
|
|
assert len(macros) == 3
|
|
assert macros[0].kind == MacroKind.REQUIRED
|
|
assert macros[1].kind == MacroKind.OPTIONAL
|
|
assert macros[2].kind == MacroKind.GENERATE
|
|
|
|
def test_parse_with_line_numbers(self):
|
|
"""Test that line numbers are recorded."""
|
|
content = """Line 1
|
|
{{require:dep1}}
|
|
Line 3
|
|
{{optional:dep2}}
|
|
"""
|
|
macros = self.parser.parse(content)
|
|
|
|
assert macros[0].line_number == 2
|
|
assert macros[1].line_number == 4
|
|
|
|
def test_parse_case_insensitive(self):
|
|
"""Test macro kind is case insensitive."""
|
|
content = "{{REQUIRE:test}} {{Optional:test2}} {{Generate:test3}}"
|
|
macros = self.parser.parse(content)
|
|
|
|
assert len(macros) == 3
|
|
assert all(m.kind in MacroKind for m in macros)
|
|
|
|
def test_parse_alias_gen_for_generate(self):
|
|
"""Test 'gen' alias for 'generate'."""
|
|
content = "{{gen:test}}"
|
|
macros = self.parser.parse(content)
|
|
|
|
assert len(macros) == 1
|
|
assert macros[0].kind == MacroKind.GENERATE
|
|
|
|
def test_parse_alias_required_for_require(self):
|
|
"""Test 'required' alias for 'require'."""
|
|
content = "{{required:test}}"
|
|
macros = self.parser.parse(content)
|
|
|
|
assert len(macros) == 1
|
|
assert macros[0].kind == MacroKind.REQUIRED
|
|
|
|
def test_parse_preserves_raw_text(self):
|
|
"""Test raw text is preserved."""
|
|
content = "{{require:test|a=1}}"
|
|
macros = self.parser.parse(content)
|
|
|
|
assert macros[0].raw_text == "{{require:test|a=1}}"
|
|
|
|
def test_parse_empty_content(self):
|
|
"""Test parsing empty content."""
|
|
macros = self.parser.parse("")
|
|
assert macros == []
|
|
|
|
def test_parse_no_macros(self):
|
|
"""Test parsing content without macros."""
|
|
content = "Just regular text without any macros"
|
|
macros = self.parser.parse(content)
|
|
assert macros == []
|
|
|
|
def test_parse_invalid_macro_kind_raises_error(self):
|
|
"""Test invalid macro kind raises error."""
|
|
content = "{{invalid:target}}"
|
|
with pytest.raises(MacroParsingError, match="Invalid macro kind"):
|
|
self.parser.parse(content)
|
|
|
|
def test_parse_empty_target_raises_error(self):
|
|
"""Test empty target raises error."""
|
|
content = "{{require:}}"
|
|
with pytest.raises(MacroParsingError, match="target cannot be empty"):
|
|
self.parser.parse(content)
|
|
|
|
def test_find_macro_positions(self):
|
|
"""Test finding macro positions."""
|
|
content = "Start {{require:dep1}} middle {{optional:dep2}} end"
|
|
positions = self.parser.find_macro_positions(content)
|
|
|
|
assert len(positions) == 2
|
|
assert content[positions[0][0]:positions[0][1]] == "{{require:dep1}}"
|
|
assert content[positions[1][0]:positions[1][1]] == "{{optional:dep2}}"
|
|
|
|
def test_count_macros(self):
|
|
"""Test macro counting."""
|
|
content = """
|
|
{{require:dep1}}
|
|
{{require:dep2}}
|
|
{{optional:dep3}}
|
|
{{generate:gen1}}
|
|
"""
|
|
counts = self.parser.count_macros(content)
|
|
|
|
assert counts['required'] == 2
|
|
assert counts['optional'] == 1
|
|
assert counts['generate'] == 1
|
|
|
|
def test_has_macros_true(self):
|
|
"""Test has_macros returns True when macros present."""
|
|
content = "Text {{require:test}} text"
|
|
assert self.parser.has_macros(content) is True
|
|
|
|
def test_has_macros_false(self):
|
|
"""Test has_macros returns False when no macros."""
|
|
content = "Just plain text"
|
|
assert self.parser.has_macros(content) is False
|
|
|
|
def test_parse_macro_with_spaces_in_target(self):
|
|
"""Test target with spaces is trimmed."""
|
|
content = "{{require: my-artifact }}"
|
|
macros = self.parser.parse(content)
|
|
assert macros[0].target == "my-artifact"
|
|
|
|
def test_parse_parameter_with_spaces(self):
|
|
"""Test parameters with spaces are trimmed."""
|
|
content = "{{generate:test| key = value }}"
|
|
macros = self.parser.parse(content)
|
|
assert macros[0].parameters == {"key": "value"}
|
|
|
|
# --- Shorthand @{target} syntax tests ---
|
|
|
|
def test_parse_shorthand_macro(self):
|
|
"""Test parsing @{target} shorthand syntax."""
|
|
content = "Some text @{glossary} more text"
|
|
macros = self.parser.parse(content)
|
|
|
|
assert len(macros) == 1
|
|
assert macros[0].kind == MacroKind.REQUIRED
|
|
assert macros[0].target == "glossary"
|
|
assert macros[0].raw_text == "@{glossary}"
|
|
assert macros[0].parameters == {}
|
|
|
|
def test_parse_shorthand_with_line_numbers(self):
|
|
"""Test shorthand macros record line numbers."""
|
|
content = "Line 1\n@{dep1}\nLine 3\n@{dep2}"
|
|
macros = self.parser.parse(content)
|
|
|
|
assert len(macros) == 2
|
|
assert macros[0].line_number == 2
|
|
assert macros[1].line_number == 4
|
|
|
|
def test_parse_mixed_syntax(self):
|
|
"""Test parsing both {{kind:target}} and @{target} in same content."""
|
|
content = "{{require:dep1}} and @{dep2} and {{optional:dep3}}"
|
|
macros = self.parser.parse(content)
|
|
|
|
assert len(macros) == 3
|
|
assert macros[0].kind == MacroKind.REQUIRED
|
|
assert macros[0].target == "dep1"
|
|
assert macros[1].kind == MacroKind.OPTIONAL
|
|
assert macros[1].target == "dep3"
|
|
assert macros[2].kind == MacroKind.REQUIRED
|
|
assert macros[2].target == "dep2"
|
|
|
|
def test_shorthand_has_macros(self):
|
|
"""Test has_macros returns True for shorthand syntax."""
|
|
assert self.parser.has_macros("@{target}") is True
|
|
assert self.parser.has_macros("no macros here") is False
|
|
|
|
def test_shorthand_count_macros(self):
|
|
"""Test count_macros includes shorthand macros."""
|
|
content = "@{dep1} @{dep2} {{optional:dep3}}"
|
|
counts = self.parser.count_macros(content)
|
|
assert counts['required'] == 2
|
|
assert counts['optional'] == 1
|
|
|
|
def test_shorthand_find_macro_positions(self):
|
|
"""Test find_macro_positions includes shorthand macros."""
|
|
content = "Start @{dep1} middle {{require:dep2}} end"
|
|
positions = self.parser.find_macro_positions(content)
|
|
|
|
assert len(positions) == 2
|
|
# Should be sorted by position
|
|
assert positions[0][2] == "@{dep1}"
|
|
assert positions[1][2] == "{{require:dep2}}"
|
|
|
|
def test_shorthand_target_trimmed(self):
|
|
"""Test shorthand target with spaces is trimmed."""
|
|
content = "@{ my-artifact }"
|
|
macros = self.parser.parse(content)
|
|
assert macros[0].target == "my-artifact"
|