test_methodical.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505
  1. """
  2. Tests for the public interface of Automat.
  3. """
  4. from functools import reduce
  5. from unittest import TestCase
  6. from .. import MethodicalMachine, NoTransition
  7. from .. import _methodical
  8. class MethodicalTests(TestCase):
  9. """
  10. Tests for L{MethodicalMachine}.
  11. """
  12. def test_oneTransition(self):
  13. """
  14. L{MethodicalMachine} provides a way for you to declare a state machine
  15. with inputs, outputs, and states as methods. When you have declared an
  16. input, an output, and a state, calling the input method in that state
  17. will produce the specified output.
  18. """
  19. class Machination(object):
  20. machine = MethodicalMachine()
  21. @machine.input()
  22. def anInput(self):
  23. "an input"
  24. @machine.output()
  25. def anOutput(self):
  26. "an output"
  27. return "an-output-value"
  28. @machine.output()
  29. def anotherOutput(self):
  30. "another output"
  31. return "another-output-value"
  32. @machine.state(initial=True)
  33. def anState(self):
  34. "a state"
  35. @machine.state()
  36. def anotherState(self):
  37. "another state"
  38. anState.upon(anInput, enter=anotherState, outputs=[anOutput])
  39. anotherState.upon(anInput, enter=anotherState,
  40. outputs=[anotherOutput])
  41. m = Machination()
  42. self.assertEqual(m.anInput(), ["an-output-value"])
  43. self.assertEqual(m.anInput(), ["another-output-value"])
  44. def test_machineItselfIsPrivate(self):
  45. """
  46. L{MethodicalMachine} is an implementation detail. If you attempt to
  47. access it on an instance of your class, you will get an exception.
  48. However, since tools may need to access it for the purposes of, for
  49. example, visualization, you may access it on the class itself.
  50. """
  51. expectedMachine = MethodicalMachine()
  52. class Machination(object):
  53. machine = expectedMachine
  54. machination = Machination()
  55. with self.assertRaises(AttributeError) as cm:
  56. machination.machine
  57. self.assertIn("MethodicalMachine is an implementation detail",
  58. str(cm.exception))
  59. self.assertIs(Machination.machine, expectedMachine)
  60. def test_outputsArePrivate(self):
  61. """
  62. One of the benefits of using a state machine is that your output method
  63. implementations don't need to take invalid state transitions into
  64. account - the methods simply won't be called. This property would be
  65. broken if client code called output methods directly, so output methods
  66. are not directly visible under their names.
  67. """
  68. class Machination(object):
  69. machine = MethodicalMachine()
  70. counter = 0
  71. @machine.input()
  72. def anInput(self):
  73. "an input"
  74. @machine.output()
  75. def anOutput(self):
  76. self.counter += 1
  77. @machine.state(initial=True)
  78. def state(self):
  79. "a machine state"
  80. state.upon(anInput, enter=state, outputs=[anOutput])
  81. mach1 = Machination()
  82. mach1.anInput()
  83. self.assertEqual(mach1.counter, 1)
  84. mach2 = Machination()
  85. with self.assertRaises(AttributeError) as cm:
  86. mach2.anOutput
  87. self.assertEqual(mach2.counter, 0)
  88. self.assertIn(
  89. "Machination.anOutput is a state-machine output method; to "
  90. "produce this output, call an input method instead.",
  91. str(cm.exception)
  92. )
  93. def test_multipleMachines(self):
  94. """
  95. Two machines may co-exist happily on the same instance; they don't
  96. interfere with each other.
  97. """
  98. class MultiMach(object):
  99. a = MethodicalMachine()
  100. b = MethodicalMachine()
  101. @a.input()
  102. def inputA(self):
  103. "input A"
  104. @b.input()
  105. def inputB(self):
  106. "input B"
  107. @a.state(initial=True)
  108. def initialA(self):
  109. "initial A"
  110. @b.state(initial=True)
  111. def initialB(self):
  112. "initial B"
  113. @a.output()
  114. def outputA(self):
  115. return "A"
  116. @b.output()
  117. def outputB(self):
  118. return "B"
  119. initialA.upon(inputA, initialA, [outputA])
  120. initialB.upon(inputB, initialB, [outputB])
  121. mm = MultiMach()
  122. self.assertEqual(mm.inputA(), ["A"])
  123. self.assertEqual(mm.inputB(), ["B"])
  124. def test_collectOutputs(self):
  125. """
  126. Outputs can be combined with the "collector" argument to "upon".
  127. """
  128. import operator
  129. class Machine(object):
  130. m = MethodicalMachine()
  131. @m.input()
  132. def input(self):
  133. "an input"
  134. @m.output()
  135. def outputA(self):
  136. return "A"
  137. @m.output()
  138. def outputB(self):
  139. return "B"
  140. @m.state(initial=True)
  141. def state(self):
  142. "a state"
  143. state.upon(input, state, [outputA, outputB],
  144. collector=lambda x: reduce(operator.add, x))
  145. m = Machine()
  146. self.assertEqual(m.input(), "AB")
  147. def test_methodName(self):
  148. """
  149. Input methods preserve their declared names.
  150. """
  151. class Mech(object):
  152. m = MethodicalMachine()
  153. @m.input()
  154. def declaredInputName(self):
  155. "an input"
  156. @m.state(initial=True)
  157. def aState(self):
  158. "state"
  159. m = Mech()
  160. with self.assertRaises(TypeError) as cm:
  161. m.declaredInputName("too", "many", "arguments")
  162. self.assertIn("declaredInputName", str(cm.exception))
  163. def test_inputWithArguments(self):
  164. """
  165. If an input takes an argument, it will pass that along to its output.
  166. """
  167. class Mechanism(object):
  168. m = MethodicalMachine()
  169. @m.input()
  170. def input(self, x, y=1):
  171. "an input"
  172. @m.state(initial=True)
  173. def state(self):
  174. "a state"
  175. @m.output()
  176. def output(self, x, y=1):
  177. self._x = x
  178. return x + y
  179. state.upon(input, state, [output])
  180. m = Mechanism()
  181. self.assertEqual(m.input(3), [4])
  182. self.assertEqual(m._x, 3)
  183. def test_inputFunctionsMustBeEmpty(self):
  184. """
  185. The wrapped input function must have an empty body.
  186. """
  187. # input functions are executed to assert that the signature matches,
  188. # but their body must be empty
  189. _methodical._empty() # chase coverage
  190. _methodical._docstring()
  191. class Mechanism(object):
  192. m = MethodicalMachine()
  193. with self.assertRaises(ValueError) as cm:
  194. @m.input()
  195. def input(self):
  196. "an input"
  197. list() # pragma: no cover
  198. self.assertEqual(str(cm.exception), "function body must be empty")
  199. # all three of these cases should be valid. Functions/methods with
  200. # docstrings produce slightly different bytecode than ones without.
  201. class MechanismWithDocstring(object):
  202. m = MethodicalMachine()
  203. @m.input()
  204. def input(self):
  205. "an input"
  206. @m.state(initial=True)
  207. def start(self):
  208. "starting state"
  209. start.upon(input, enter=start, outputs=[])
  210. MechanismWithDocstring().input()
  211. class MechanismWithPass(object):
  212. m = MethodicalMachine()
  213. @m.input()
  214. def input(self):
  215. pass
  216. @m.state(initial=True)
  217. def start(self):
  218. "starting state"
  219. start.upon(input, enter=start, outputs=[])
  220. MechanismWithPass().input()
  221. class MechanismWithDocstringAndPass(object):
  222. m = MethodicalMachine()
  223. @m.input()
  224. def input(self):
  225. "an input"
  226. pass
  227. @m.state(initial=True)
  228. def start(self):
  229. "starting state"
  230. start.upon(input, enter=start, outputs=[])
  231. MechanismWithDocstringAndPass().input()
  232. class MechanismReturnsNone(object):
  233. m = MethodicalMachine()
  234. @m.input()
  235. def input(self):
  236. return None
  237. @m.state(initial=True)
  238. def start(self):
  239. "starting state"
  240. start.upon(input, enter=start, outputs=[])
  241. MechanismReturnsNone().input()
  242. class MechanismWithDocstringAndReturnsNone(object):
  243. m = MethodicalMachine()
  244. @m.input()
  245. def input(self):
  246. "an input"
  247. return None
  248. @m.state(initial=True)
  249. def start(self):
  250. "starting state"
  251. start.upon(input, enter=start, outputs=[])
  252. MechanismWithDocstringAndReturnsNone().input()
  253. def test_inputOutputMismatch(self):
  254. """
  255. All the argument lists of the outputs for a given input must match; if
  256. one does not the call to C{upon} will raise a C{TypeError}.
  257. """
  258. class Mechanism(object):
  259. m = MethodicalMachine()
  260. @m.input()
  261. def nameOfInput(self, a):
  262. "an input"
  263. @m.output()
  264. def outputThatMatches(self, a):
  265. "an output that matches"
  266. @m.output()
  267. def outputThatDoesntMatch(self, b):
  268. "an output that doesn't match"
  269. @m.state()
  270. def state(self):
  271. "a state"
  272. with self.assertRaises(TypeError) as cm:
  273. state.upon(nameOfInput, state, [outputThatMatches,
  274. outputThatDoesntMatch])
  275. self.assertIn("nameOfInput", str(cm.exception))
  276. self.assertIn("outputThatDoesntMatch", str(cm.exception))
  277. def test_multipleInitialStatesFailure(self):
  278. """
  279. A L{MethodicalMachine} can only have one initial state.
  280. """
  281. class WillFail(object):
  282. m = MethodicalMachine()
  283. @m.state(initial=True)
  284. def firstInitialState(self):
  285. "The first initial state -- this is OK."
  286. with self.assertRaises(ValueError):
  287. @m.state(initial=True)
  288. def secondInitialState(self):
  289. "The second initial state -- results in a ValueError."
  290. def test_multipleTransitionsFailure(self):
  291. """
  292. A L{MethodicalMachine} can only have one transition per start/event
  293. pair.
  294. """
  295. class WillFail(object):
  296. m = MethodicalMachine()
  297. @m.state(initial=True)
  298. def start(self):
  299. "We start here."
  300. @m.state()
  301. def end(self):
  302. "Rainbows end."
  303. @m.input()
  304. def event(self):
  305. "An event."
  306. start.upon(event, enter=end, outputs=[])
  307. with self.assertRaises(ValueError):
  308. start.upon(event, enter=end, outputs=[])
  309. def test_badTransitionForCurrentState(self):
  310. """
  311. Calling any input method that lacks a transition for the machine's
  312. current state raises an informative L{NoTransition}.
  313. """
  314. class OnlyOnePath(object):
  315. m = MethodicalMachine()
  316. @m.state(initial=True)
  317. def start(self):
  318. "Start state."
  319. @m.state()
  320. def end(self):
  321. "End state."
  322. @m.input()
  323. def advance(self):
  324. "Move from start to end."
  325. @m.input()
  326. def deadEnd(self):
  327. "A transition from nowhere to nowhere."
  328. start.upon(advance, end, [])
  329. machine = OnlyOnePath()
  330. with self.assertRaises(NoTransition) as cm:
  331. machine.deadEnd()
  332. self.assertIn("deadEnd", str(cm.exception))
  333. self.assertIn("start", str(cm.exception))
  334. machine.advance()
  335. with self.assertRaises(NoTransition) as cm:
  336. machine.deadEnd()
  337. self.assertIn("deadEnd", str(cm.exception))
  338. self.assertIn("end", str(cm.exception))
  339. def test_saveState(self):
  340. """
  341. L{MethodicalMachine.serializer} is a decorator that modifies its
  342. decoratee's signature to take a "state" object as its first argument,
  343. which is the "serialized" argument to the L{MethodicalMachine.state}
  344. decorator.
  345. """
  346. class Mechanism(object):
  347. m = MethodicalMachine()
  348. def __init__(self):
  349. self.value = 1
  350. @m.state(serialized="first-state", initial=True)
  351. def first(self):
  352. "First state."
  353. @m.state(serialized="second-state")
  354. def second(self):
  355. "Second state."
  356. @m.serializer()
  357. def save(self, state):
  358. return {
  359. 'machine-state': state,
  360. 'some-value': self.value,
  361. }
  362. self.assertEqual(
  363. Mechanism().save(),
  364. {
  365. "machine-state": "first-state",
  366. "some-value": 1,
  367. }
  368. )
  369. def test_restoreState(self):
  370. """
  371. L{MethodicalMachine.unserializer} decorates a function that becomes a
  372. machine-state unserializer; its return value is mapped to the
  373. C{serialized} parameter to C{state}, and the L{MethodicalMachine}
  374. associated with that instance's state is updated to that state.
  375. """
  376. class Mechanism(object):
  377. m = MethodicalMachine()
  378. def __init__(self):
  379. self.value = 1
  380. self.ranOutput = False
  381. @m.state(serialized="first-state", initial=True)
  382. def first(self):
  383. "First state."
  384. @m.state(serialized="second-state")
  385. def second(self):
  386. "Second state."
  387. @m.input()
  388. def input(self):
  389. "an input"
  390. @m.output()
  391. def output(self):
  392. self.value = 2
  393. self.ranOutput = True
  394. return 1
  395. @m.output()
  396. def output2(self):
  397. return 2
  398. first.upon(input, second, [output],
  399. collector=lambda x: list(x)[0])
  400. second.upon(input, second, [output2],
  401. collector=lambda x: list(x)[0])
  402. @m.serializer()
  403. def save(self, state):
  404. return {
  405. 'machine-state': state,
  406. 'some-value': self.value,
  407. }
  408. @m.unserializer()
  409. def _restore(self, blob):
  410. self.value = blob['some-value']
  411. return blob['machine-state']
  412. @classmethod
  413. def fromBlob(cls, blob):
  414. self = cls()
  415. self._restore(blob)
  416. return self
  417. m1 = Mechanism()
  418. m1.input()
  419. blob = m1.save()
  420. m2 = Mechanism.fromBlob(blob)
  421. self.assertEqual(m2.ranOutput, False)
  422. self.assertEqual(m2.input(), 2)
  423. self.assertEqual(
  424. m2.save(),
  425. {
  426. 'machine-state': 'second-state',
  427. 'some-value': 2,
  428. }
  429. )
  430. # FIXME: error for wrong types on any call to _oneTransition
  431. # FIXME: better public API for .upon; maybe a context manager?
  432. # FIXME: when transitions are defined, validate that we can always get to
  433. # terminal? do we care about this?
  434. # FIXME: implementation (and use-case/example) for passing args from in to out
  435. # FIXME: possibly these need some kind of support from core
  436. # FIXME: wildcard state (in all states, when input X, emit Y and go to Z)
  437. # FIXME: wildcard input (in state X, when any input, emit Y and go to Z)
  438. # FIXME: combined wildcards (in any state for any input, emit Y go to Z)