test_brain.py 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. # /// script
  2. # requires-python = ">=3.10"
  3. # dependencies = ["pytest>=8.0"]
  4. # ///
  5. """Tests for brain.py. Run: uv run -m pytest scripts/tests/test_brain.py"""
  6. import sys
  7. from pathlib import Path
  8. import pytest
  9. sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
  10. import brain # noqa: E402
  11. CSV = """category,technique_name,description,detail
  12. collaborative,Yes And Building,Build on every idea with "yes and" to keep momentum,
  13. wild,Quantum Superposition,Hold contradictory ideas as simultaneously true,techniques/quantum.md
  14. structured,SCAMPER Method,Run the idea through seven transformation lenses,
  15. wild,Anti-Solution,Brainstorm how to make the problem worse then invert,
  16. """
  17. DETAIL = "# Quantum Superposition\nFull multi-step instructions for the complex technique."
  18. @pytest.fixture
  19. def lib(tmp_path):
  20. csv_path = tmp_path / "brain-methods.csv"
  21. csv_path.write_text(CSV, encoding="utf-8")
  22. (tmp_path / "techniques").mkdir()
  23. (tmp_path / "techniques" / "quantum.md").write_text(DETAIL, encoding="utf-8")
  24. return csv_path
  25. def test_load_normalizes_detail(lib):
  26. rows = brain.load(lib)
  27. assert len(rows) == 4
  28. assert rows[0]["detail"] == ""
  29. assert rows[1]["detail"] == "techniques/quantum.md"
  30. def test_categories_counts_sorted(lib):
  31. assert brain.categories(brain.load(lib)) == [("collaborative", 1), ("structured", 1), ("wild", 2)]
  32. def test_filter_is_case_insensitive(lib):
  33. rows = brain.filter_cats(brain.load(lib), ["WILD"])
  34. assert {r["technique_name"] for r in rows} == {"Quantum Superposition", "Anti-Solution"}
  35. def test_filter_none_returns_all(lib):
  36. assert len(brain.filter_cats(brain.load(lib), None)) == 4
  37. def test_find_hits_and_misses(lib):
  38. found, missing = brain.find(brain.load(lib), ["scamper method", "Nope"])
  39. assert [r["technique_name"] for r in found] == ["SCAMPER Method"]
  40. assert missing == ["Nope"]
  41. def test_resolve_detail_present(lib):
  42. row = next(r for r in brain.load(lib) if r["detail"])
  43. assert "multi-step instructions" in brain.resolve_detail(row, lib.parent)
  44. def test_resolve_detail_absent_is_none(lib):
  45. row = next(r for r in brain.load(lib) if not r["detail"])
  46. assert brain.resolve_detail(row, lib.parent) is None
  47. def test_resolve_detail_missing_file_warns_not_fatal(lib, capsys):
  48. rows = brain.load(lib)
  49. rows[1]["detail"] = "techniques/gone.md"
  50. assert brain.resolve_detail(rows[1], lib.parent) is None
  51. assert "not found" in capsys.readouterr().err
  52. def test_show_inlines_detail(lib, capsys):
  53. assert brain.main(["--file", str(lib), "show", "Quantum Superposition"]) == 0
  54. out = capsys.readouterr().out
  55. assert "multi-step instructions" in out and "[wild]" in out
  56. def test_show_simple_has_no_detail(lib, capsys):
  57. brain.main(["--file", str(lib), "show", "SCAMPER Method"])
  58. out = capsys.readouterr().out
  59. assert "transformation lenses" in out
  60. def test_show_all_missing_returns_1(lib):
  61. assert brain.main(["--file", str(lib), "show", "Ghost"]) == 1
  62. def test_list_filtered_text(lib, capsys):
  63. brain.main(["--file", str(lib), "list", "--category", "structured"])
  64. out = capsys.readouterr().out.strip().splitlines()
  65. assert len(out) == 1 and out[0].startswith("structured\tSCAMPER Method\t")
  66. def test_list_bare_is_refused(lib, capsys):
  67. # the footgun: bare `list` must NOT dump the catalog into context
  68. assert brain.main(["--file", str(lib), "list"]) == 2
  69. captured = capsys.readouterr()
  70. assert captured.out == "" # nothing leaked to stdout
  71. assert "--category" in captured.err and "--all" in captured.err
  72. def test_list_all_dumps_everything(lib, capsys):
  73. assert brain.main(["--file", str(lib), "list", "--all"]) == 0
  74. out = capsys.readouterr().out.strip().splitlines()
  75. assert len(out) == 4 # the deliberate full-catalog escape hatch
  76. def test_json_output(lib, capsys):
  77. import json
  78. brain.main(["--file", str(lib), "--json", "categories"])
  79. data = json.loads(capsys.readouterr().out)
  80. assert {"category": "wild", "count": 2} in data
  81. def test_random_respects_n_and_category(lib, capsys):
  82. brain.main(["--file", str(lib), "random", "--category", "wild", "-n", "5"])
  83. lines = capsys.readouterr().out.strip().splitlines()
  84. assert len(lines) == 2 # only 2 wild exist, n capped
  85. assert all(line.startswith("wild\t") for line in lines)
  86. def test_random_negative_n_does_not_crash(lib, capsys):
  87. # a negative -n is clamped to 0, not passed to random.sample (which would raise)
  88. assert brain.main(["--file", str(lib), "random", "-n", "-1"]) == 0
  89. assert capsys.readouterr().out.strip() == ""
  90. def test_missing_file_returns_2(tmp_path):
  91. assert brain.main(["--file", str(tmp_path / "nope.csv"), "categories"]) == 2
  92. # --- html selection page ------------------------------------------------
  93. def test_html_requires_out(lib, capsys):
  94. # never dump the catalog to stdout — writing to a file is the whole point
  95. assert brain.main(["--file", str(lib), "html"]) == 2
  96. assert "--out" in capsys.readouterr().err
  97. def test_html_writes_selection_page(lib, tmp_path):
  98. out = tmp_path / "sel.html"
  99. assert brain.main(["--file", str(lib), "html", "--out", str(out)]) == 0
  100. doc = out.read_text(encoding="utf-8")
  101. assert doc.startswith("<!DOCTYPE html>")
  102. assert "BMad Method Brainstorming Selection" in doc
  103. for r in brain.load(lib):
  104. assert r["technique_name"] in doc # every technique is selectable
  105. assert "&quot;yes and&quot;" in doc # quotes in a description are escaped, not raw
  106. def test_html_creates_missing_parent(lib, tmp_path):
  107. out = tmp_path / "nested" / "deep" / "sel.html"
  108. assert brain.main(["--file", str(lib), "html", "--out", str(out)]) == 0
  109. assert out.is_file()
  110. # --- --extra overlay (customize.toml additional_techniques) -------------
  111. EXTRA = (
  112. '[{"category": "domain-specific", "technique_name": "Regulatory Inversion", '
  113. '"description": "Start from the compliance constraint and brainstorm what it unlocks."}, '
  114. '{"category": "wild", "technique_name": "Extra Wild One", "description": "An added wild method."}]'
  115. )
  116. @pytest.fixture
  117. def extra(tmp_path):
  118. p = tmp_path / "extra.json"
  119. p.write_text(EXTRA, encoding="utf-8")
  120. return p
  121. def test_extra_merges_into_categories(lib, extra, capsys):
  122. brain.main(["--file", str(lib), "--extra", str(extra), "categories"])
  123. out = capsys.readouterr().out
  124. assert "domain-specific\t1" in out # a brand-new category appears
  125. assert "wild\t3" in out # the extra wild one is counted alongside the shipped two
  126. def test_extra_appears_in_list_and_random(lib, extra, capsys):
  127. brain.main(["--file", str(lib), "--extra", str(extra), "list", "--category", "domain-specific"])
  128. assert "Regulatory Inversion" in capsys.readouterr().out
  129. def test_extra_is_first_class_in_html(lib, extra, tmp_path):
  130. out = tmp_path / "sel.html"
  131. assert brain.main(["--file", str(lib), "--extra", str(extra), "html", "--out", str(out)]) == 0
  132. doc = out.read_text(encoding="utf-8")
  133. # custom technique is selectable and its new category renders without crashing (fallback glyph/hue)
  134. assert "Regulatory Inversion" in doc
  135. assert "Domain Specific" in doc
  136. def test_extra_missing_file_returns_2(lib, tmp_path):
  137. assert brain.main(["--file", str(lib), "--extra", str(tmp_path / "nope.json"), "categories"]) == 2
  138. def test_unknown_category_style_uses_fallback_glyph():
  139. hue, glyph = brain.category_style("totally-made-up-category")
  140. assert hue.startswith("#") and len(hue) == 7 # valid derived hex
  141. assert glyph == brain._FALLBACK_GLYPH
  142. def test_shipped_selector_is_in_sync_with_catalog():
  143. # foolproofing: if someone edits brain-methods.csv they must regenerate the page.
  144. # Regenerate with: uv run brain.py html --out assets/brain-selector.html
  145. asset = brain.DEFAULT_FILE.parent / "brain-selector.html"
  146. assert asset.is_file(), "missing assets/brain-selector.html — generate it"
  147. expected = brain.html_doc(brain.load(brain.DEFAULT_FILE))
  148. assert asset.read_text(encoding="utf-8") == expected, (
  149. "assets/brain-selector.html is stale; regenerate: "
  150. "uv run brain.py html --out assets/brain-selector.html"
  151. )