test_strategy.py
1 """STRATEGY.md related tests (parsing, rendering, file I/O, Goal section).""" 2 3 from __future__ import annotations 4 5 import logging 6 from pathlib import Path 7 from unittest.mock import patch 8 9 import pytest 10 11 from mureo.context.errors import ContextFileError 12 from mureo.context.models import StrategyEntry 13 from mureo.context.strategy import ( 14 add_strategy_entry, 15 parse_strategy, 16 read_strategy_file, 17 remove_strategy_entry, 18 render_strategy, 19 write_strategy_file, 20 ) 21 22 23 # ============================================================ 24 # STRATEGY.md parse tests 25 # ============================================================ 26 27 28 class TestParseStrategy: 29 """STRATEGY.md parse tests.""" 30 31 @pytest.mark.unit 32 def test_parse_strategy_empty(self) -> None: 33 """Empty string parses to empty list.""" 34 result = parse_strategy("") 35 assert result == [] 36 37 @pytest.mark.unit 38 def test_parse_strategy_single_section(self) -> None: 39 """Single section parse.""" 40 md = "# Strategy\n\n## Persona\nターゲットは30代男性\n" 41 result = parse_strategy(md) 42 assert len(result) == 1 43 assert result[0].context_type == "persona" 44 assert result[0].title == "Persona" 45 assert result[0].content == "ターゲットは30代男性" 46 47 @pytest.mark.unit 48 def test_parse_strategy_multiple_sections(self) -> None: 49 """Multiple sections parse.""" 50 md = ( 51 "# Strategy\n\n" 52 "## Persona\nターゲットは30代男性\n\n" 53 "## USP\n業界最安値の広告運用自動化ツール\n\n" 54 "## Target Audience\n- 年齢: 25-45歳\n- 職種: マーケター\n" 55 ) 56 result = parse_strategy(md) 57 assert len(result) == 3 58 assert result[0].context_type == "persona" 59 assert result[1].context_type == "usp" 60 assert result[2].context_type == "target_audience" 61 assert result[2].content == "- 年齢: 25-45歳\n- 職種: マーケター" 62 63 @pytest.mark.unit 64 def test_parse_strategy_custom_type(self) -> None: 65 """`## Custom: title` parse.""" 66 md = "# Strategy\n\n## Custom: 季節要因\n年末商戦に向けてCPA許容値を上げる\n" 67 result = parse_strategy(md) 68 assert len(result) == 1 69 assert result[0].context_type == "custom" 70 assert result[0].title == "季節要因" 71 assert result[0].content == "年末商戦に向けてCPA許容値を上げる" 72 73 @pytest.mark.unit 74 def test_parse_strategy_deep_research(self) -> None: 75 """`## Deep Research: title` parse.""" 76 md = "# Strategy\n\n## Deep Research: 競合調査\n競合A社の広告戦略は...\n" 77 result = parse_strategy(md) 78 assert len(result) == 1 79 assert result[0].context_type == "deep_research" 80 assert result[0].title == "競合調査" 81 assert result[0].content == "競合A社の広告戦略は..." 82 83 @pytest.mark.unit 84 def test_parse_strategy_sales_material(self) -> None: 85 """`## Sales Material: title` parse.""" 86 md = "# Strategy\n\n## Sales Material: 営業資料Q4\n四半期の実績...\n" 87 result = parse_strategy(md) 88 assert len(result) == 1 89 assert result[0].context_type == "sales_material" 90 assert result[0].title == "営業資料Q4" 91 92 @pytest.mark.unit 93 def test_parse_strategy_operation_mode(self) -> None: 94 """`## Operation Mode` parse.""" 95 md = "# Strategy\n\n## Operation Mode\nTURNAROUND_RESCUE\n" 96 result = parse_strategy(md) 97 assert len(result) == 1 98 assert result[0].context_type == "operation_mode" 99 assert result[0].title == "Operation Mode" 100 assert result[0].content == "TURNAROUND_RESCUE" 101 102 @pytest.mark.unit 103 def test_parse_strategy_unknown_section_ignored(self) -> None: 104 """Unknown sections are skipped.""" 105 md = ( 106 "# Strategy\n\n" 107 "## Persona\nターゲット\n\n" 108 "## Unknown Section\nこれは無視される\n\n" 109 "## USP\n強み\n" 110 ) 111 result = parse_strategy(md) 112 assert len(result) == 2 113 assert result[0].context_type == "persona" 114 assert result[1].context_type == "usp" 115 116 117 # ============================================================ 118 # STRATEGY.md render tests 119 # ============================================================ 120 121 122 class TestRenderStrategy: 123 """STRATEGY.md rendering tests.""" 124 125 @pytest.mark.unit 126 def test_render_strategy(self) -> None: 127 """Generate Markdown from StrategyEntry list.""" 128 entries = [ 129 StrategyEntry(context_type="persona", title="Persona", content="30代男性"), 130 StrategyEntry(context_type="usp", title="USP", content="業界最安値"), 131 ] 132 md = render_strategy(entries) 133 assert "# Strategy" in md 134 assert "## Persona" in md 135 assert "30代男性" in md 136 assert "## USP" in md 137 assert "業界最安値" in md 138 139 @pytest.mark.unit 140 def test_render_strategy_custom(self) -> None: 141 """Custom type rendering.""" 142 entries = [ 143 StrategyEntry(context_type="custom", title="季節要因", content="年末商戦"), 144 ] 145 md = render_strategy(entries) 146 assert "## Custom: 季節要因" in md 147 148 @pytest.mark.unit 149 def test_render_parse_roundtrip(self) -> None: 150 """render -> parse -> render preserves content.""" 151 original = [ 152 StrategyEntry(context_type="persona", title="Persona", content="30代男性"), 153 StrategyEntry(context_type="usp", title="USP", content="業界最安値"), 154 StrategyEntry(context_type="custom", title="季節要因", content="年末商戦"), 155 StrategyEntry( 156 context_type="deep_research", title="競合調査", content="A社は..." 157 ), 158 StrategyEntry( 159 context_type="operation_mode", 160 title="Operation Mode", 161 content="SCALE_EXPANSION", 162 ), 163 ] 164 md = render_strategy(original) 165 parsed = parse_strategy(md) 166 assert len(parsed) == len(original) 167 for orig, p in zip(original, parsed): 168 assert orig.context_type == p.context_type 169 assert orig.content == p.content 170 171 172 # ============================================================ 173 # STRATEGY.md file I/O tests 174 # ============================================================ 175 176 177 class TestStrategyFile: 178 """STRATEGY.md file I/O tests.""" 179 180 @pytest.mark.unit 181 def test_read_strategy_file(self, tmp_path: Path) -> None: 182 """Read from file.""" 183 fp = tmp_path / "STRATEGY.md" 184 fp.write_text("# Strategy\n\n## Persona\nターゲット\n", encoding="utf-8") 185 result = read_strategy_file(fp) 186 assert len(result) == 1 187 assert result[0].context_type == "persona" 188 189 @pytest.mark.unit 190 def test_write_strategy_file(self, tmp_path: Path) -> None: 191 """Write to file.""" 192 fp = tmp_path / "STRATEGY.md" 193 entries = [ 194 StrategyEntry(context_type="persona", title="Persona", content="30代男性"), 195 ] 196 write_strategy_file(fp, entries) 197 assert fp.exists() 198 content = fp.read_text(encoding="utf-8") 199 assert "## Persona" in content 200 assert "30代男性" in content 201 202 @pytest.mark.unit 203 def test_read_strategy_file_not_exists(self, tmp_path: Path) -> None: 204 """Missing file returns empty list.""" 205 fp = tmp_path / "STRATEGY.md" 206 result = read_strategy_file(fp) 207 assert result == [] 208 209 @pytest.mark.unit 210 def test_add_strategy_entry(self, tmp_path: Path) -> None: 211 """Add entry to existing file.""" 212 fp = tmp_path / "STRATEGY.md" 213 fp.write_text("# Strategy\n\n## Persona\nターゲット\n", encoding="utf-8") 214 new_entry = StrategyEntry(context_type="usp", title="USP", content="業界最安値") 215 add_strategy_entry(fp, new_entry) 216 result = read_strategy_file(fp) 217 assert len(result) == 2 218 assert result[0].context_type == "persona" 219 assert result[1].context_type == "usp" 220 221 @pytest.mark.unit 222 def test_remove_strategy_entry(self, tmp_path: Path) -> None: 223 """Remove entry by context_type.""" 224 fp = tmp_path / "STRATEGY.md" 225 entries = [ 226 StrategyEntry(context_type="persona", title="Persona", content="30代男性"), 227 StrategyEntry(context_type="usp", title="USP", content="業界最安値"), 228 ] 229 write_strategy_file(fp, entries) 230 remove_strategy_entry(fp, "persona") 231 result = read_strategy_file(fp) 232 assert len(result) == 1 233 assert result[0].context_type == "usp" 234 235 @pytest.mark.unit 236 def test_remove_strategy_entry_custom_with_title(self, tmp_path: Path) -> None: 237 """Remove custom entry by title.""" 238 fp = tmp_path / "STRATEGY.md" 239 entries = [ 240 StrategyEntry(context_type="custom", title="季節要因", content="年末商戦"), 241 StrategyEntry(context_type="custom", title="その他", content="メモ"), 242 ] 243 write_strategy_file(fp, entries) 244 remove_strategy_entry(fp, "custom", title="季節要因") 245 result = read_strategy_file(fp) 246 assert len(result) == 1 247 assert result[0].title == "その他" 248 249 250 # ============================================================ 251 # Strategy file I/O error handling tests 252 # ============================================================ 253 254 255 class TestStrategyFileErrorHandling: 256 """Strategy file I/O error tests.""" 257 258 @pytest.mark.unit 259 def test_read_strategy_file_permission_error(self, tmp_path: Path) -> None: 260 """Permission error raises ContextFileError.""" 261 fp = tmp_path / "STRATEGY.md" 262 fp.write_text("# Strategy\n\n## Persona\nTest\n", encoding="utf-8") 263 with patch.object(Path, "read_text", side_effect=PermissionError("denied")): 264 with pytest.raises(ContextFileError): 265 read_strategy_file(fp) 266 267 @pytest.mark.unit 268 def test_write_strategy_file_creates_parent_dir(self, tmp_path: Path) -> None: 269 """Parent directory is auto-created on write.""" 270 fp = tmp_path / "subdir" / "deep" / "STRATEGY.md" 271 entries = [ 272 StrategyEntry(context_type="persona", title="Persona", content="Test"), 273 ] 274 write_strategy_file(fp, entries) 275 assert fp.exists() 276 assert "## Persona" in fp.read_text(encoding="utf-8") 277 278 279 # ============================================================ 280 # Unknown section warning test 281 # ============================================================ 282 283 284 class TestUnknownSectionWarning: 285 """Warning log test for unknown sections.""" 286 287 @pytest.mark.unit 288 def test_unknown_section_logs_warning( 289 self, caplog: pytest.LogCaptureFixture 290 ) -> None: 291 """Unknown section triggers warning log.""" 292 md = ( 293 "# Strategy\n\n" 294 "## Persona\nターゲット\n\n" 295 "## Unknown Section\nこれは無視される\n\n" 296 "## USP\n強み\n" 297 ) 298 with caplog.at_level(logging.WARNING, logger="mureo.context.strategy"): 299 result = parse_strategy(md) 300 301 assert len(result) == 2 302 assert any("Unknown Section" in record.message for record in caplog.records) 303 304 305 # ============================================================ 306 # _TYPE_TO_PREFIX constant test 307 # ============================================================ 308 309 310 class TestTypeToPrefixConstant: 311 """Module-level _TYPE_TO_PREFIX constant test.""" 312 313 @pytest.mark.unit 314 def test_type_to_prefix_exists(self) -> None: 315 """_TYPE_TO_PREFIX constant is defined in strategy.py.""" 316 from mureo.context import strategy 317 318 assert hasattr(strategy, "_TYPE_TO_PREFIX") 319 assert strategy._TYPE_TO_PREFIX["custom"] == "Custom" 320 assert strategy._TYPE_TO_PREFIX["deep_research"] == "Deep Research" 321 assert strategy._TYPE_TO_PREFIX["sales_material"] == "Sales Material" 322 323 324 # ============================================================ 325 # Goal section tests 326 # ============================================================ 327 328 329 class TestGoalSection: 330 """Goal section parsing and rendering tests.""" 331 332 @pytest.mark.unit 333 def test_parse_goal_section(self) -> None: 334 """Parse a STRATEGY.md with ## Goal: title -> context_type='goal'.""" 335 md = ( 336 "# Strategy\n\n" 337 "## Goal: Reduce CPA below 5000 JPY\n" 338 "- Target: CPA < 5,000 JPY\n" 339 "- Deadline: 2026-06-30\n" 340 "- Current: CPA 6,200 JPY\n" 341 "- Platform: Google Ads, Meta Ads\n" 342 "- Priority: HIGH\n" 343 ) 344 result = parse_strategy(md) 345 assert len(result) == 1 346 assert result[0].context_type == "goal" 347 assert result[0].title == "Reduce CPA below 5000 JPY" 348 assert "Target: CPA < 5,000 JPY" in result[0].content 349 assert "Priority: HIGH" in result[0].content 350 351 @pytest.mark.unit 352 def test_parse_multiple_goals(self) -> None: 353 """Parse STRATEGY.md with 2 Goal sections -> 2 entries.""" 354 md = ( 355 "# Strategy\n\n" 356 "## Goal: Reduce CPA below 5000 JPY\n" 357 "- Target: CPA < 5,000 JPY\n" 358 "- Deadline: 2026-06-30\n" 359 "- Priority: HIGH\n\n" 360 "## Goal: Increase monthly leads to 100\n" 361 "- Target: Leads >= 100/month\n" 362 "- Deadline: 2026-05-31\n" 363 "- Priority: MEDIUM\n" 364 ) 365 result = parse_strategy(md) 366 assert len(result) == 2 367 assert result[0].context_type == "goal" 368 assert result[0].title == "Reduce CPA below 5000 JPY" 369 assert result[1].context_type == "goal" 370 assert result[1].title == "Increase monthly leads to 100" 371 372 @pytest.mark.unit 373 def test_write_goal_section(self) -> None: 374 """Write a StrategyEntry with type='goal' -> outputs '## Goal: title'.""" 375 entries = [ 376 StrategyEntry( 377 context_type="goal", 378 title="Reduce CPA below 5000 JPY", 379 content="- Target: CPA < 5,000 JPY\n- Priority: HIGH", 380 ), 381 ] 382 md = render_strategy(entries) 383 assert "## Goal: Reduce CPA below 5000 JPY" in md 384 assert "- Target: CPA < 5,000 JPY" in md 385 386 @pytest.mark.unit 387 def test_goal_coexists_with_other_sections(self) -> None: 388 """Parse a full STRATEGY.md with Persona + USP + Goal -> all 3 entries.""" 389 md = ( 390 "# Strategy\n\n" 391 "## Persona\n30-40 year old marketing managers\n\n" 392 "## USP\nAI-powered ad optimization\n\n" 393 "## Goal: Reduce CPA below 5000 JPY\n" 394 "- Target: CPA < 5,000 JPY\n" 395 "- Priority: HIGH\n" 396 ) 397 result = parse_strategy(md) 398 assert len(result) == 3 399 assert result[0].context_type == "persona" 400 assert result[1].context_type == "usp" 401 assert result[2].context_type == "goal" 402 assert result[2].title == "Reduce CPA below 5000 JPY" 403 404 @pytest.mark.unit 405 def test_goal_roundtrip(self) -> None: 406 """render -> parse roundtrip preserves goal entries.""" 407 original = [ 408 StrategyEntry( 409 context_type="persona", title="Persona", content="Target user" 410 ), 411 StrategyEntry( 412 context_type="goal", 413 title="Reduce CPA", 414 content="- Target: CPA < 5,000 JPY\n- Priority: HIGH", 415 ), 416 ] 417 md = render_strategy(original) 418 parsed = parse_strategy(md) 419 assert len(parsed) == 2 420 assert parsed[1].context_type == "goal" 421 assert parsed[1].title == "Reduce CPA" 422 assert parsed[1].content == original[1].content