search_tests.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458
  1. #!/usr/bin/env python
  2. import unittest
  3. from unittest import mock
  4. import json
  5. from mkdocs.structure.files import File
  6. from mkdocs.structure.pages import Page
  7. from mkdocs.structure.toc import get_toc
  8. from mkdocs.contrib import search
  9. from mkdocs.contrib.search import search_index
  10. from mkdocs.config.config_options import ValidationError
  11. from mkdocs.tests.base import dedent, get_markdown_toc, load_config
  12. def strip_whitespace(string):
  13. return string.replace("\n", "").replace(" ", "")
  14. class SearchConfigTests(unittest.TestCase):
  15. def test_lang_default(self):
  16. option = search.LangOption(default=['en'])
  17. value = option.validate(None)
  18. self.assertEqual(['en'], value)
  19. def test_lang_str(self):
  20. option = search.LangOption()
  21. value = option.validate('en')
  22. self.assertEqual(['en'], value)
  23. def test_lang_list(self):
  24. option = search.LangOption()
  25. value = option.validate(['en'])
  26. self.assertEqual(['en'], value)
  27. def test_lang_multi_list(self):
  28. option = search.LangOption()
  29. value = option.validate(['en', 'es', 'fr'])
  30. self.assertEqual(['en', 'es', 'fr'], value)
  31. def test_lang_bad_type(self):
  32. option = search.LangOption()
  33. self.assertRaises(ValidationError, option.validate, {})
  34. def test_lang_bad_code(self):
  35. option = search.LangOption()
  36. self.assertRaises(ValidationError, option.validate, ['foo'])
  37. def test_lang_good_and_bad_code(self):
  38. option = search.LangOption()
  39. self.assertRaises(ValidationError, option.validate, ['en', 'foo'])
  40. class SearchPluginTests(unittest.TestCase):
  41. def test_plugin_config_defaults(self):
  42. expected = {
  43. 'lang': ['en'],
  44. 'separator': r'[\s\-]+',
  45. 'min_search_length': 3,
  46. 'prebuild_index': False
  47. }
  48. plugin = search.SearchPlugin()
  49. errors, warnings = plugin.load_config({})
  50. self.assertEqual(plugin.config, expected)
  51. self.assertEqual(errors, [])
  52. self.assertEqual(warnings, [])
  53. def test_plugin_config_lang(self):
  54. expected = {
  55. 'lang': ['es'],
  56. 'separator': r'[\s\-]+',
  57. 'min_search_length': 3,
  58. 'prebuild_index': False
  59. }
  60. plugin = search.SearchPlugin()
  61. errors, warnings = plugin.load_config({'lang': 'es'})
  62. self.assertEqual(plugin.config, expected)
  63. self.assertEqual(errors, [])
  64. self.assertEqual(warnings, [])
  65. def test_plugin_config_separator(self):
  66. expected = {
  67. 'lang': ['en'],
  68. 'separator': r'[\s\-\.]+',
  69. 'min_search_length': 3,
  70. 'prebuild_index': False
  71. }
  72. plugin = search.SearchPlugin()
  73. errors, warnings = plugin.load_config({'separator': r'[\s\-\.]+'})
  74. self.assertEqual(plugin.config, expected)
  75. self.assertEqual(errors, [])
  76. self.assertEqual(warnings, [])
  77. def test_plugin_config_min_search_length(self):
  78. expected = {
  79. 'lang': ['en'],
  80. 'separator': r'[\s\-]+',
  81. 'min_search_length': 2,
  82. 'prebuild_index': False
  83. }
  84. plugin = search.SearchPlugin()
  85. errors, warnings = plugin.load_config({'min_search_length': 2})
  86. self.assertEqual(plugin.config, expected)
  87. self.assertEqual(errors, [])
  88. self.assertEqual(warnings, [])
  89. def test_plugin_config_prebuild_index(self):
  90. expected = {
  91. 'lang': ['en'],
  92. 'separator': r'[\s\-]+',
  93. 'min_search_length': 3,
  94. 'prebuild_index': True
  95. }
  96. plugin = search.SearchPlugin()
  97. errors, warnings = plugin.load_config({'prebuild_index': True})
  98. self.assertEqual(plugin.config, expected)
  99. self.assertEqual(errors, [])
  100. self.assertEqual(warnings, [])
  101. def test_event_on_config_defaults(self):
  102. plugin = search.SearchPlugin()
  103. plugin.load_config({})
  104. result = plugin.on_config(load_config(theme='mkdocs', extra_javascript=[]))
  105. self.assertFalse(result['theme']['search_index_only'])
  106. self.assertFalse(result['theme']['include_search_page'])
  107. self.assertEqual(result['theme'].static_templates, {'404.html', 'sitemap.xml'})
  108. self.assertEqual(len(result['theme'].dirs), 3)
  109. self.assertEqual(result['extra_javascript'], ['search/main.js'])
  110. def test_event_on_config_include_search_page(self):
  111. plugin = search.SearchPlugin()
  112. plugin.load_config({})
  113. config = load_config(theme={'name': 'mkdocs', 'include_search_page': True}, extra_javascript=[])
  114. result = plugin.on_config(config)
  115. self.assertFalse(result['theme']['search_index_only'])
  116. self.assertTrue(result['theme']['include_search_page'])
  117. self.assertEqual(result['theme'].static_templates, {'404.html', 'sitemap.xml', 'search.html'})
  118. self.assertEqual(len(result['theme'].dirs), 3)
  119. self.assertEqual(result['extra_javascript'], ['search/main.js'])
  120. def test_event_on_config_search_index_only(self):
  121. plugin = search.SearchPlugin()
  122. plugin.load_config({})
  123. config = load_config(theme={'name': 'mkdocs', 'search_index_only': True}, extra_javascript=[])
  124. result = plugin.on_config(config)
  125. self.assertTrue(result['theme']['search_index_only'])
  126. self.assertFalse(result['theme']['include_search_page'])
  127. self.assertEqual(result['theme'].static_templates, {'404.html', 'sitemap.xml'})
  128. self.assertEqual(len(result['theme'].dirs), 2)
  129. self.assertEqual(len(result['extra_javascript']), 0)
  130. @mock.patch('mkdocs.utils.write_file', autospec=True)
  131. @mock.patch('mkdocs.utils.copy_file', autospec=True)
  132. def test_event_on_post_build_defaults(self, mock_copy_file, mock_write_file):
  133. plugin = search.SearchPlugin()
  134. plugin.load_config({})
  135. config = load_config(theme='mkdocs')
  136. plugin.on_pre_build(config)
  137. plugin.on_post_build(config)
  138. self.assertEqual(mock_copy_file.call_count, 0)
  139. self.assertEqual(mock_write_file.call_count, 1)
  140. @mock.patch('mkdocs.utils.write_file', autospec=True)
  141. @mock.patch('mkdocs.utils.copy_file', autospec=True)
  142. def test_event_on_post_build_single_lang(self, mock_copy_file, mock_write_file):
  143. plugin = search.SearchPlugin()
  144. plugin.load_config({'lang': ['es']})
  145. config = load_config(theme='mkdocs')
  146. plugin.on_pre_build(config)
  147. plugin.on_post_build(config)
  148. self.assertEqual(mock_copy_file.call_count, 2)
  149. self.assertEqual(mock_write_file.call_count, 1)
  150. @mock.patch('mkdocs.utils.write_file', autospec=True)
  151. @mock.patch('mkdocs.utils.copy_file', autospec=True)
  152. def test_event_on_post_build_multi_lang(self, mock_copy_file, mock_write_file):
  153. plugin = search.SearchPlugin()
  154. plugin.load_config({'lang': ['es', 'fr']})
  155. config = load_config(theme='mkdocs')
  156. plugin.on_pre_build(config)
  157. plugin.on_post_build(config)
  158. self.assertEqual(mock_copy_file.call_count, 4)
  159. self.assertEqual(mock_write_file.call_count, 1)
  160. @mock.patch('mkdocs.utils.write_file', autospec=True)
  161. @mock.patch('mkdocs.utils.copy_file', autospec=True)
  162. def test_event_on_post_build_search_index_only(self, mock_copy_file, mock_write_file):
  163. plugin = search.SearchPlugin()
  164. plugin.load_config({'lang': ['es']})
  165. config = load_config(theme={'name': 'mkdocs', 'search_index_only': True})
  166. plugin.on_pre_build(config)
  167. plugin.on_post_build(config)
  168. self.assertEqual(mock_copy_file.call_count, 0)
  169. self.assertEqual(mock_write_file.call_count, 1)
  170. class SearchIndexTests(unittest.TestCase):
  171. def test_html_stripper(self):
  172. stripper = search_index.HTMLStripper()
  173. stripper.feed("<h1>Testing</h1><p>Content</p>")
  174. self.assertEqual(stripper.data, ["Testing", "Content"])
  175. def test_content_parser(self):
  176. parser = search_index.ContentParser()
  177. parser.feed('<h1 id="title">Title</h1>TEST')
  178. parser.close()
  179. self.assertEqual(parser.data, [search_index.ContentSection(
  180. text=["TEST"],
  181. id_="title",
  182. title="Title"
  183. )])
  184. def test_content_parser_no_id(self):
  185. parser = search_index.ContentParser()
  186. parser.feed("<h1>Title</h1>TEST")
  187. parser.close()
  188. self.assertEqual(parser.data, [search_index.ContentSection(
  189. text=["TEST"],
  190. id_=None,
  191. title="Title"
  192. )])
  193. def test_content_parser_content_before_header(self):
  194. parser = search_index.ContentParser()
  195. parser.feed("Content Before H1 <h1>Title</h1>TEST")
  196. parser.close()
  197. self.assertEqual(parser.data, [search_index.ContentSection(
  198. text=["TEST"],
  199. id_=None,
  200. title="Title"
  201. )])
  202. def test_content_parser_no_sections(self):
  203. parser = search_index.ContentParser()
  204. parser.feed("No H1 or H2<span>Title</span>TEST")
  205. self.assertEqual(parser.data, [])
  206. def test_find_toc_by_id(self):
  207. """
  208. Test finding the relevant TOC item by the tag ID.
  209. """
  210. index = search_index.SearchIndex()
  211. md = dedent("""
  212. # Heading 1
  213. ## Heading 2
  214. ### Heading 3
  215. """)
  216. toc = get_toc(get_markdown_toc(md))
  217. toc_item = index._find_toc_by_id(toc, "heading-1")
  218. self.assertEqual(toc_item.url, "#heading-1")
  219. self.assertEqual(toc_item.title, "Heading 1")
  220. toc_item2 = index._find_toc_by_id(toc, "heading-2")
  221. self.assertEqual(toc_item2.url, "#heading-2")
  222. self.assertEqual(toc_item2.title, "Heading 2")
  223. toc_item3 = index._find_toc_by_id(toc, "heading-3")
  224. self.assertEqual(toc_item3.url, "#heading-3")
  225. self.assertEqual(toc_item3.title, "Heading 3")
  226. def test_create_search_index(self):
  227. html_content = """
  228. <h1 id="heading-1">Heading 1</h1>
  229. <p>Content 1</p>
  230. <h2 id="heading-2">Heading 2</h1>
  231. <p>Content 2</p>
  232. <h3 id="heading-3">Heading 3</h1>
  233. <p>Content 3</p>
  234. """
  235. cfg = load_config()
  236. pages = [
  237. Page('Home', File('index.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), cfg),
  238. Page('About', File('about.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), cfg)
  239. ]
  240. md = dedent("""
  241. # Heading 1
  242. ## Heading 2
  243. ### Heading 3
  244. """)
  245. toc = get_toc(get_markdown_toc(md))
  246. full_content = ''.join("""Heading{0}Content{0}""".format(i) for i in range(1, 4))
  247. for page in pages:
  248. # Fake page.read_source() and page.render()
  249. page.markdown = md
  250. page.toc = toc
  251. page.content = html_content
  252. index = search_index.SearchIndex()
  253. index.add_entry_from_context(page)
  254. self.assertEqual(len(index._entries), 4)
  255. loc = page.url
  256. self.assertEqual(index._entries[0]['title'], page.title)
  257. self.assertEqual(strip_whitespace(index._entries[0]['text']), full_content)
  258. self.assertEqual(index._entries[0]['location'], loc)
  259. self.assertEqual(index._entries[1]['title'], "Heading 1")
  260. self.assertEqual(index._entries[1]['text'], "Content 1")
  261. self.assertEqual(index._entries[1]['location'], "{}#heading-1".format(loc))
  262. self.assertEqual(index._entries[2]['title'], "Heading 2")
  263. self.assertEqual(strip_whitespace(index._entries[2]['text']), "Content2")
  264. self.assertEqual(index._entries[2]['location'], "{}#heading-2".format(loc))
  265. self.assertEqual(index._entries[3]['title'], "Heading 3")
  266. self.assertEqual(strip_whitespace(index._entries[3]['text']), "Content3")
  267. self.assertEqual(index._entries[3]['location'], "{}#heading-3".format(loc))
  268. @mock.patch('subprocess.Popen', autospec=True)
  269. def test_prebuild_index(self, mock_popen):
  270. # See https://stackoverflow.com/a/36501078/866026
  271. mock_popen.return_value = mock.Mock()
  272. mock_popen_obj = mock_popen.return_value
  273. mock_popen_obj.communicate.return_value = ('{"mock": "index"}', None)
  274. mock_popen_obj.returncode = 0
  275. index = search_index.SearchIndex(prebuild_index=True)
  276. expected = {
  277. 'docs': [],
  278. 'config': {'prebuild_index': True},
  279. 'index': {'mock': 'index'}
  280. }
  281. result = json.loads(index.generate_search_index())
  282. self.assertEqual(mock_popen.call_count, 1)
  283. self.assertEqual(mock_popen_obj.communicate.call_count, 1)
  284. self.assertEqual(result, expected)
  285. @mock.patch('subprocess.Popen', autospec=True)
  286. def test_prebuild_index_returns_error(self, mock_popen):
  287. # See https://stackoverflow.com/a/36501078/866026
  288. mock_popen.return_value = mock.Mock()
  289. mock_popen_obj = mock_popen.return_value
  290. mock_popen_obj.communicate.return_value = ('', 'Some Error')
  291. mock_popen_obj.returncode = 0
  292. index = search_index.SearchIndex(prebuild_index=True)
  293. expected = {
  294. 'docs': [],
  295. 'config': {'prebuild_index': True}
  296. }
  297. result = json.loads(index.generate_search_index())
  298. self.assertEqual(mock_popen.call_count, 1)
  299. self.assertEqual(mock_popen_obj.communicate.call_count, 1)
  300. self.assertEqual(result, expected)
  301. @mock.patch('subprocess.Popen', autospec=True)
  302. def test_prebuild_index_raises_ioerror(self, mock_popen):
  303. # See https://stackoverflow.com/a/36501078/866026
  304. mock_popen.return_value = mock.Mock()
  305. mock_popen_obj = mock_popen.return_value
  306. mock_popen_obj.communicate.side_effect = OSError
  307. mock_popen_obj.returncode = 1
  308. index = search_index.SearchIndex(prebuild_index=True)
  309. expected = {
  310. 'docs': [],
  311. 'config': {'prebuild_index': True}
  312. }
  313. result = json.loads(index.generate_search_index())
  314. self.assertEqual(mock_popen.call_count, 1)
  315. self.assertEqual(mock_popen_obj.communicate.call_count, 1)
  316. self.assertEqual(result, expected)
  317. @mock.patch('subprocess.Popen', autospec=True, side_effect=OSError)
  318. def test_prebuild_index_raises_oserror(self, mock_popen):
  319. # See https://stackoverflow.com/a/36501078/866026
  320. mock_popen.return_value = mock.Mock()
  321. mock_popen_obj = mock_popen.return_value
  322. mock_popen_obj.communicate.return_value = ('', '')
  323. mock_popen_obj.returncode = 0
  324. index = search_index.SearchIndex(prebuild_index=True)
  325. expected = {
  326. 'docs': [],
  327. 'config': {'prebuild_index': True}
  328. }
  329. result = json.loads(index.generate_search_index())
  330. self.assertEqual(mock_popen.call_count, 1)
  331. self.assertEqual(mock_popen_obj.communicate.call_count, 0)
  332. self.assertEqual(result, expected)
  333. @mock.patch('subprocess.Popen', autospec=True)
  334. def test_prebuild_index_false(self, mock_popen):
  335. # See https://stackoverflow.com/a/36501078/866026
  336. mock_popen.return_value = mock.Mock()
  337. mock_popen_obj = mock_popen.return_value
  338. mock_popen_obj.communicate.return_value = ('', '')
  339. mock_popen_obj.returncode = 0
  340. index = search_index.SearchIndex(prebuild_index=False)
  341. expected = {
  342. 'docs': [],
  343. 'config': {'prebuild_index': False}
  344. }
  345. result = json.loads(index.generate_search_index())
  346. self.assertEqual(mock_popen.call_count, 0)
  347. self.assertEqual(mock_popen_obj.communicate.call_count, 0)
  348. self.assertEqual(result, expected)
  349. @mock.patch('mkdocs.contrib.search.search_index.lunr', autospec=True)
  350. def test_prebuild_index_python(self, mock_lunr):
  351. mock_lunr.return_value.serialize.return_value = {'mock': 'index'}
  352. index = search_index.SearchIndex(prebuild_index='python', lang='en')
  353. expected = {
  354. 'docs': [],
  355. 'config': {'prebuild_index': 'python', 'lang': 'en'},
  356. 'index': {'mock': 'index'}
  357. }
  358. result = json.loads(index.generate_search_index())
  359. self.assertEqual(mock_lunr.call_count, 1)
  360. self.assertEqual(result, expected)
  361. @mock.patch('subprocess.Popen', autospec=True)
  362. def test_prebuild_index_node(self, mock_popen):
  363. # See https://stackoverflow.com/a/36501078/866026
  364. mock_popen.return_value = mock.Mock()
  365. mock_popen_obj = mock_popen.return_value
  366. mock_popen_obj.communicate.return_value = ('{"mock": "index"}', None)
  367. mock_popen_obj.returncode = 0
  368. index = search_index.SearchIndex(prebuild_index='node')
  369. expected = {
  370. 'docs': [],
  371. 'config': {'prebuild_index': 'node'},
  372. 'index': {'mock': 'index'}
  373. }
  374. result = json.loads(index.generate_search_index())
  375. self.assertEqual(mock_popen.call_count, 1)
  376. self.assertEqual(mock_popen_obj.communicate.call_count, 1)
  377. self.assertEqual(result, expected)