/ tests / test_strategy.py
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