You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

test_visualize.py 13KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430
  1. from __future__ import print_function
  2. import functools
  3. import os
  4. import subprocess
  5. from unittest import TestCase, skipIf
  6. import attr
  7. from .._methodical import MethodicalMachine
  8. from .test_discover import isTwistedInstalled
  9. def isGraphvizModuleInstalled():
  10. """
  11. Is the graphviz Python module installed?
  12. """
  13. try:
  14. __import__("graphviz")
  15. except ImportError:
  16. return False
  17. else:
  18. return True
  19. def isGraphvizInstalled():
  20. """
  21. Are the graphviz tools installed?
  22. """
  23. r, w = os.pipe()
  24. os.close(w)
  25. try:
  26. return not subprocess.call("dot", stdin=r, shell=True)
  27. finally:
  28. os.close(r)
  29. def sampleMachine():
  30. """
  31. Create a sample L{MethodicalMachine} with some sample states.
  32. """
  33. mm = MethodicalMachine()
  34. class SampleObject(object):
  35. @mm.state(initial=True)
  36. def begin(self):
  37. "initial state"
  38. @mm.state()
  39. def end(self):
  40. "end state"
  41. @mm.input()
  42. def go(self):
  43. "sample input"
  44. @mm.output()
  45. def out(self):
  46. "sample output"
  47. begin.upon(go, end, [out])
  48. so = SampleObject()
  49. so.go()
  50. return mm
  51. @skipIf(not isGraphvizModuleInstalled(), "Graphviz module is not installed.")
  52. class ElementMakerTests(TestCase):
  53. """
  54. L{elementMaker} generates HTML representing the specified element.
  55. """
  56. def setUp(self):
  57. from .._visualize import elementMaker
  58. self.elementMaker = elementMaker
  59. def test_sortsAttrs(self):
  60. """
  61. L{elementMaker} orders HTML attributes lexicographically.
  62. """
  63. expected = r'<div a="1" b="2" c="3"></div>'
  64. self.assertEqual(expected,
  65. self.elementMaker("div",
  66. b='2',
  67. a='1',
  68. c='3'))
  69. def test_quotesAttrs(self):
  70. """
  71. L{elementMaker} quotes HTML attributes according to DOT's quoting rule.
  72. See U{http://www.graphviz.org/doc/info/lang.html}, footnote 1.
  73. """
  74. expected = r'<div a="1" b="a \" quote" c="a string"></div>'
  75. self.assertEqual(expected,
  76. self.elementMaker("div",
  77. b='a " quote',
  78. a=1,
  79. c="a string"))
  80. def test_noAttrs(self):
  81. """
  82. L{elementMaker} should render an element with no attributes.
  83. """
  84. expected = r'<div ></div>'
  85. self.assertEqual(expected, self.elementMaker("div"))
  86. @attr.s
  87. class HTMLElement(object):
  88. """Holds an HTML element, as created by elementMaker."""
  89. name = attr.ib()
  90. children = attr.ib()
  91. attributes = attr.ib()
  92. def findElements(element, predicate):
  93. """
  94. Recursively collect all elements in an L{HTMLElement} tree that
  95. match the optional predicate.
  96. """
  97. if predicate(element):
  98. return [element]
  99. elif isLeaf(element):
  100. return []
  101. return [result
  102. for child in element.children
  103. for result in findElements(child, predicate)]
  104. def isLeaf(element):
  105. """
  106. This HTML element is actually leaf node.
  107. """
  108. return not isinstance(element, HTMLElement)
  109. @skipIf(not isGraphvizModuleInstalled(), "Graphviz module is not installed.")
  110. class TableMakerTests(TestCase):
  111. """
  112. Tests that ensure L{tableMaker} generates HTML tables usable as
  113. labels in DOT graphs.
  114. For more information, read the "HTML-Like Labels" section of
  115. U{http://www.graphviz.org/doc/info/shapes.html}.
  116. """
  117. def fakeElementMaker(self, name, *children, **attributes):
  118. return HTMLElement(name=name, children=children, attributes=attributes)
  119. def setUp(self):
  120. from .._visualize import tableMaker
  121. self.inputLabel = "input label"
  122. self.port = "the port"
  123. self.tableMaker = functools.partial(tableMaker,
  124. _E=self.fakeElementMaker)
  125. def test_inputLabelRow(self):
  126. """
  127. The table returned by L{tableMaker} always contains the input
  128. symbol label in its first row, and that row contains one cell
  129. with a port attribute set to the provided port.
  130. """
  131. def hasPort(element):
  132. return (not isLeaf(element)
  133. and element.attributes.get("port") == self.port)
  134. for outputLabels in ([], ["an output label"]):
  135. table = self.tableMaker(self.inputLabel, outputLabels,
  136. port=self.port)
  137. self.assertGreater(len(table.children), 0)
  138. inputLabelRow = table.children[0]
  139. portCandidates = findElements(table, hasPort)
  140. self.assertEqual(len(portCandidates), 1)
  141. self.assertEqual(portCandidates[0].name, "td")
  142. self.assertEqual(findElements(inputLabelRow, isLeaf),
  143. [self.inputLabel])
  144. def test_noOutputLabels(self):
  145. """
  146. L{tableMaker} does not add a colspan attribute to the input
  147. label's cell or a second row if there no output labels.
  148. """
  149. table = self.tableMaker("input label", (), port=self.port)
  150. self.assertEqual(len(table.children), 1)
  151. (inputLabelRow,) = table.children
  152. self.assertNotIn("colspan", inputLabelRow.attributes)
  153. def test_withOutputLabels(self):
  154. """
  155. L{tableMaker} adds a colspan attribute to the input label's cell
  156. equal to the number of output labels and a second row that
  157. contains the output labels.
  158. """
  159. table = self.tableMaker(self.inputLabel, ("output label 1",
  160. "output label 2"),
  161. port=self.port)
  162. self.assertEqual(len(table.children), 2)
  163. inputRow, outputRow = table.children
  164. def hasCorrectColspan(element):
  165. return (not isLeaf(element)
  166. and element.name == "td"
  167. and element.attributes.get('colspan') == "2")
  168. self.assertEqual(len(findElements(inputRow, hasCorrectColspan)),
  169. 1)
  170. self.assertEqual(findElements(outputRow, isLeaf), ["output label 1",
  171. "output label 2"])
  172. @skipIf(not isGraphvizModuleInstalled(), "Graphviz module is not installed.")
  173. @skipIf(not isGraphvizInstalled(), "Graphviz tools are not installed.")
  174. class IntegrationTests(TestCase):
  175. """
  176. Tests which make sure Graphviz can understand the output produced by
  177. Automat.
  178. """
  179. def test_validGraphviz(self):
  180. """
  181. L{graphviz} emits valid graphviz data.
  182. """
  183. p = subprocess.Popen("dot", stdin=subprocess.PIPE,
  184. stdout=subprocess.PIPE)
  185. out, err = p.communicate("".join(sampleMachine().asDigraph())
  186. .encode("utf-8"))
  187. self.assertEqual(p.returncode, 0)
  188. @skipIf(not isGraphvizModuleInstalled(), "Graphviz module is not installed.")
  189. class SpotChecks(TestCase):
  190. """
  191. Tests to make sure that the output contains salient features of the machine
  192. being generated.
  193. """
  194. def test_containsMachineFeatures(self):
  195. """
  196. The output of L{graphviz} should contain the names of the states,
  197. inputs, outputs in the state machine.
  198. """
  199. gvout = "".join(sampleMachine().asDigraph())
  200. self.assertIn("begin", gvout)
  201. self.assertIn("end", gvout)
  202. self.assertIn("go", gvout)
  203. self.assertIn("out", gvout)
  204. class RecordsDigraphActions(object):
  205. """
  206. Records calls made to L{FakeDigraph}.
  207. """
  208. def __init__(self):
  209. self.reset()
  210. def reset(self):
  211. self.renderCalls = []
  212. self.saveCalls = []
  213. class FakeDigraph(object):
  214. """
  215. A fake L{graphviz.Digraph}. Instantiate it with a
  216. L{RecordsDigraphActions}.
  217. """
  218. def __init__(self, recorder):
  219. self._recorder = recorder
  220. def render(self, **kwargs):
  221. self._recorder.renderCalls.append(kwargs)
  222. def save(self, **kwargs):
  223. self._recorder.saveCalls.append(kwargs)
  224. class FakeMethodicalMachine(object):
  225. """
  226. A fake L{MethodicalMachine}. Instantiate it with a L{FakeDigraph}
  227. """
  228. def __init__(self, digraph):
  229. self._digraph = digraph
  230. def asDigraph(self):
  231. return self._digraph
  232. @skipIf(not isGraphvizModuleInstalled(), "Graphviz module is not installed.")
  233. @skipIf(not isGraphvizInstalled(), "Graphviz tools are not installed.")
  234. @skipIf(not isTwistedInstalled(), "Twisted is not installed.")
  235. class VisualizeToolTests(TestCase):
  236. def setUp(self):
  237. self.digraphRecorder = RecordsDigraphActions()
  238. self.fakeDigraph = FakeDigraph(self.digraphRecorder)
  239. self.fakeProgname = 'tool-test'
  240. self.fakeSysPath = ['ignored']
  241. self.collectedOutput = []
  242. self.fakeFQPN = 'fake.fqpn'
  243. def collectPrints(self, *args):
  244. self.collectedOutput.append(' '.join(args))
  245. def fakeFindMachines(self, fqpn):
  246. yield fqpn, FakeMethodicalMachine(self.fakeDigraph)
  247. def tool(self,
  248. progname=None,
  249. argv=None,
  250. syspath=None,
  251. findMachines=None,
  252. print=None):
  253. from .._visualize import tool
  254. return tool(
  255. _progname=progname or self.fakeProgname,
  256. _argv=argv or [self.fakeFQPN],
  257. _syspath=syspath or self.fakeSysPath,
  258. _findMachines=findMachines or self.fakeFindMachines,
  259. _print=print or self.collectPrints)
  260. def test_checksCurrentDirectory(self):
  261. """
  262. L{tool} adds '' to sys.path to ensure
  263. L{automat._discover.findMachines} searches the current
  264. directory.
  265. """
  266. self.tool(argv=[self.fakeFQPN])
  267. self.assertEqual(self.fakeSysPath[0], '')
  268. def test_quietHidesOutput(self):
  269. """
  270. Passing -q/--quiet hides all output.
  271. """
  272. self.tool(argv=[self.fakeFQPN, '--quiet'])
  273. self.assertFalse(self.collectedOutput)
  274. self.tool(argv=[self.fakeFQPN, '-q'])
  275. self.assertFalse(self.collectedOutput)
  276. def test_onlySaveDot(self):
  277. """
  278. Passing an empty string for --image-directory/-i disables
  279. rendering images.
  280. """
  281. for arg in ('--image-directory', '-i'):
  282. self.digraphRecorder.reset()
  283. self.collectedOutput = []
  284. self.tool(argv=[self.fakeFQPN, arg, ''])
  285. self.assertFalse(any("image" in line
  286. for line in self.collectedOutput))
  287. self.assertEqual(len(self.digraphRecorder.saveCalls), 1)
  288. (call,) = self.digraphRecorder.saveCalls
  289. self.assertEqual("{}.dot".format(self.fakeFQPN),
  290. call['filename'])
  291. self.assertFalse(self.digraphRecorder.renderCalls)
  292. def test_saveOnlyImage(self):
  293. """
  294. Passing an empty string for --dot-directory/-d disables saving dot
  295. files.
  296. """
  297. for arg in ('--dot-directory', '-d'):
  298. self.digraphRecorder.reset()
  299. self.collectedOutput = []
  300. self.tool(argv=[self.fakeFQPN, arg, ''])
  301. self.assertFalse(any("dot" in line
  302. for line in self.collectedOutput))
  303. self.assertEqual(len(self.digraphRecorder.renderCalls), 1)
  304. (call,) = self.digraphRecorder.renderCalls
  305. self.assertEqual("{}.dot".format(self.fakeFQPN),
  306. call['filename'])
  307. self.assertTrue(call['cleanup'])
  308. self.assertFalse(self.digraphRecorder.saveCalls)
  309. def test_saveDotAndImagesInDifferentDirectories(self):
  310. """
  311. Passing different directories to --image-directory and --dot-directory
  312. writes images and dot files to those directories.
  313. """
  314. imageDirectory = 'image'
  315. dotDirectory = 'dot'
  316. self.tool(argv=[self.fakeFQPN,
  317. '--image-directory', imageDirectory,
  318. '--dot-directory', dotDirectory])
  319. self.assertTrue(any("image" in line
  320. for line in self.collectedOutput))
  321. self.assertTrue(any("dot" in line
  322. for line in self.collectedOutput))
  323. self.assertEqual(len(self.digraphRecorder.renderCalls), 1)
  324. (renderCall,) = self.digraphRecorder.renderCalls
  325. self.assertEqual(renderCall["directory"], imageDirectory)
  326. self.assertTrue(renderCall['cleanup'])
  327. self.assertEqual(len(self.digraphRecorder.saveCalls), 1)
  328. (saveCall,) = self.digraphRecorder.saveCalls
  329. self.assertEqual(saveCall["directory"], dotDirectory)
  330. def test_saveDotAndImagesInSameDirectory(self):
  331. """
  332. Passing the same directory to --image-directory and --dot-directory
  333. writes images and dot files to that one directory.
  334. """
  335. directory = 'imagesAndDot'
  336. self.tool(argv=[self.fakeFQPN,
  337. '--image-directory', directory,
  338. '--dot-directory', directory])
  339. self.assertTrue(any("image and dot" in line
  340. for line in self.collectedOutput))
  341. self.assertEqual(len(self.digraphRecorder.renderCalls), 1)
  342. (renderCall,) = self.digraphRecorder.renderCalls
  343. self.assertEqual(renderCall["directory"], directory)
  344. self.assertFalse(renderCall['cleanup'])
  345. self.assertFalse(len(self.digraphRecorder.saveCalls))