Repository: kivy/kivy
Pull request: Animation: Fix kivy.animation.Sequence and kivy.animation.Parallel consistency
fix #5204 #5443 #5929 

Problem statement:
kivy.animation.Sequence sends "on_complete" event while animation is still in progress
### Versions

* Python: 2.7
* OS: Linux Ubuntu 16.04
* Kivy: 1.10.1.dev0
* Kivy installation method: git clone and python setup.py install

### Description

The program uses Animation to pulse a Widget color from black to red and back again.  When the animation completes, the Widget is supposed to be black, but it is red.  Could be a canvas update problem, could be an animation bug.

### Code and Logs

```python
from kivy.app import App
from kivy.uix.widget import Widget
from kivy.uix.button import Button
from kivy.uix.boxlayout import BoxLayout
from kivy.graphics import Color, Line, Rectangle
from kivy.animation import Animation

class Blinker(Widget):

    def __init__(self, *args, **kwargs):
        super(Blinker, self).__init__(*args, **kwargs)
        self.color = None
        self.animation = None
        self.draw()
        self.bind(size=self.draw, pos=self.draw, parent=self.draw)

    def draw(self, *args):
        self.canvas.clear()
        with self.canvas:
            self.color = Color(0, 0, 0, 1)
            Rectangle(pos=self.pos, size=self.size)        

    def repaint(self, *args):
        if self.color is not None:
            self.color.rgba = (0, 0, 0, 1)
            self.canvas.ask_update()
            print 'color.rgba is', self.color.rgba

    def start(self, *args):
        if self.color is not None:
            self.animation = Animation(r=1, duration=0.5) + Animation(r=0, duration=0.5)
            self.animation.repeat = True
            self.animation.bind(on_complete=self.repaint)
            self.animation.start(self.color)

    def stop(self, *args):
        if self.animation is not None:
            self.animation.stop(self.color)
            self.animation = None


class A(App):

    def stop_start_animation(self, *args):
        if self.blinker.animation is not None:
            self.blinker.stop()
            self.button.text = "Start"
        else:
            self.blinker.start()
            self.button.text = "Stop"

    def build(self):
        top = BoxLayout(orientation="horizontal")
        self.button = (Button(on_press=self.stop_start_animation, text="Start"))
        top.add_widget(self.button)
        self.blinker = Blinker()
        top.add_widget(self.blinker)
        return top

if __name__ == "__main__":
    A().run()
```
Logs:
```
(testkivy) wjanssen@archie:/local/kivy-1.10$ python ~/projects/cbmx/platform/t.py
[INFO   ] [Logger      ] Record log in /home/wjanssen/.kivy/logs/kivy_17-05-31_34.txt
[WARNING] [Modules     ] Module <clipboard> not found
[WARNING] [Modules     ] Module <cutbuffer> not found
[INFO   ] [Factory     ] 194 symbols loaded
[INFO   ] [Image       ] Providers: img_tex, img_dds, img_sdl2, img_pil, img_gif (img_ffpyplayer ignored)
[INFO   ] [Text        ] Provider: sdl2
[INFO   ] [Kivy        ] v1.10.1.dev0, git-b7906e7, 20170531
[INFO   ] [Python      ] v2.7.13 |Continuum Analytics, Inc.| (default, Dec 20 2016, 23:09:15) 
[GCC 4.4.7 20120313 (Red Hat 4.4.7-1)]
[INFO   ] [OSC         ] using <multiprocessing> for socket
[INFO   ] [Window      ] Provider: sdl2(['window_egl_rpi'] ignored)
[INFO   ] [GL          ] Using the "OpenGL" graphics system
[INFO   ] [GL          ] Backend used <gl>
[INFO   ] [GL          ] OpenGL version <3.0 Mesa 12.0.6>
[INFO   ] [GL          ] OpenGL vendor <Intel Open Source Technology Center>
[INFO   ] [GL          ] OpenGL renderer <Mesa DRI Intel(R) HD Graphics 5500 (Broadwell GT2) >
[INFO   ] [GL          ] OpenGL parsed version: 3, 0
[INFO   ] [GL          ] Shading version <1.30>
[INFO   ] [GL          ] Texture max size <8192>
[INFO   ] [GL          ] Texture max units <32>
[INFO   ] [Window      ] auto add sdl2 input provider
[WARNING] [Modules     ] Module <clipboard> not found
[WARNING] [Modules     ] Module <cutbuffer> not found
[INFO   ] [Window      ] virtual keyboard not allowed, single mode, not docked
[INFO   ] [Base        ] Start application main loop
[INFO   ] [GL          ] NPOT texture support is available
color.rgba is [0.0, 0.0, 0.0, 1.0]
color.rgba is [0.0, 0.0, 0.0, 1.0]
color.rgba is [0.0, 0.0, 0.0, 1.0]
[INFO   ] [Base        ] Leaving application in progress...
```

Hints from issue discussion:
Seems to work properly with 1.9.2
Not in my version of 1.9.2.  I think it's a race condition; the "on_complete" event is delivered before the animation is canceled:

```python
    def stop(self, widget):
        '''Stop the animation previously applied to a widget, triggering the
        `on_complete` event.'''
        props = self._widgets.pop(widget.uid, None)
        if props:
            self.dispatch('on_complete', widget)
        self.cancel(widget)

    def cancel(self, widget):
        '''Cancel the animation previously applied to a widget. Same
        effect as :attr:`stop`, except the `on_complete` event will
        *not* be triggered!

        .. versionadded:: 1.4.0
        '''
        self._widgets.pop(widget.uid, None)
        self._clock_uninstall()
        if not self._widgets:
            self._unregister()
```
In my traces, the animation is clearly updating the color even after the "on_complete" callback has been called.
Ah, it's a bug in kivy.animation.Sequence.  The "stop" method only calls "super(Sequence, self).cancel" instead of "self.cancel".  I'll make a patch.  Now I'm curious as to why it works for @Bakterija?
No, that's wrong, too.  Calling the "stop" method on the sub-animations should work to cancel them, too.  However, a fix that works is changing Sequence.stop to something more conventional.  Changing it from
```python
    def stop(self, widget):
        self.anim1.stop(widget)
        self.anim2.stop(widget)
        props = self._widgets.pop(widget.uid, None)
        if props:
            self.dispatch('on_complete', widget)
        super(Sequence, self).cancel(widget)
```
to
```python
    def stop(self, widget):
        self.anim1.stop(widget)
        self.anim2.stop(widget)
        super(Sequence, self).stop(widget)
```
fixes the problem.  Better yet, since "cancel" will cancel the sub-animations, how about just removing the "stop" method from Sequence completely, and let it inherit the method from Animation?  The side-effect would be that any "on_complete" events on sub-animations would not be delivered.
Sequence is using the "on_complete" callbacks internally to transition between the two sub-animations.  Calling "stop" individually on those sub-animations causes the transition to occur.  In particular, calling stop on anim2 will re-start anim1, which is why the panel ends up red.  Here's a different version which illustrates this:
```python
from kivy.app import App
from kivy.uix.widget import Widget
from kivy.uix.button import Button
from kivy.uix.boxlayout import BoxLayout
from kivy.graphics import Color, Line, Rectangle
from kivy.animation import Animation
from kivy.properties import ListProperty

class Blinker(Widget):

    color = ListProperty([0, 0, 0, 1])

    def __init__(self, *args, **kwargs):
        super(Blinker, self).__init__(*args, **kwargs)
        self._color = None
        self.animation = None
        self.draw()
        self.bind(size=self.draw, pos=self.draw, parent=self.draw)
        self.bind(color=self.repaint)

    def draw(self, *args):
        self.canvas.clear()
        with self.canvas:
            self._color = Color(*self.color)
            Rectangle(pos=self.pos, size=self.size)        

    def repaint(self, *args):
        if self._color is not None:
            self._color.rgba = self.color
            print 'color changed to', self._color.rgba

    def clear_color(self, *args):
        print 'clear_color'
        self.color = [0, 0, 0, 1]

    def start(self, *args):
        if self.color is not None:
            self.animation = Animation(color=[1, 0, 0, 1], duration=0.5) + Animation(color=[0, 0, 0, 1], duration=0.5)
            self.animation.repeat = True
            self.animation.bind(on_complete=self.clear_color)
            self.animation.start(self)

    def stop(self, *args):
        if self.animation is not None:
            self.animation.stop(self)
            self.animation = None


class A(App):

    def stop_start_animation(self, *args):
        if self.blinker.animation is not None:
            self.blinker.stop()
            self.button.text = "Start"
        else:
            self.blinker.start()
            self.button.text = "Stop"

    def build(self):
        top = BoxLayout(orientation="horizontal")
        self.button = (Button(on_press=self.stop_start_animation, text="Start"))
        top.add_widget(self.button)
        self.blinker = Blinker()
        top.add_widget(self.blinker)
        return top

if __name__ == "__main__":
    A().run()
```
And a run log:
```
(testkivy) wjanssen@archie:/tmp$ python  t.py
[INFO   ] [Logger      ] Record log in /home/wjanssen/.kivy/logs/kivy_17-05-31_56.txt
[WARNING] [Modules     ] Module <clipboard> not found
[WARNING] [Modules     ] Module <cutbuffer> not found
[INFO   ] [Factory     ] 194 symbols loaded
[INFO   ] [Image       ] Providers: img_tex, img_dds, img_sdl2, img_pil, img_gif (img_ffpyplayer ignored)
[INFO   ] [Text        ] Provider: sdl2
[INFO   ] [Kivy        ] v1.10.1.dev0, git-b7906e7, 20170531
[INFO   ] [Python      ] v2.7.13 |Continuum Analytics, Inc.| (default, Dec 20 2016, 23:09:15) 
[GCC 4.4.7 20120313 (Red Hat 4.4.7-1)]
[INFO   ] [OSC         ] using <multiprocessing> for socket
[INFO   ] [Window      ] Provider: sdl2(['window_egl_rpi'] ignored)
[INFO   ] [GL          ] Using the "OpenGL" graphics system
[INFO   ] [GL          ] Backend used <gl>
[INFO   ] [GL          ] OpenGL version <3.0 Mesa 12.0.6>
[INFO   ] [GL          ] OpenGL vendor <Intel Open Source Technology Center>
[INFO   ] [GL          ] OpenGL renderer <Mesa DRI Intel(R) HD Graphics 5500 (Broadwell GT2) >
[INFO   ] [GL          ] OpenGL parsed version: 3, 0
[INFO   ] [GL          ] Shading version <1.30>
[INFO   ] [GL          ] Texture max size <8192>
[INFO   ] [GL          ] Texture max units <32>
[INFO   ] [Window      ] auto add sdl2 input provider
[WARNING] [Modules     ] Module <clipboard> not found
[WARNING] [Modules     ] Module <cutbuffer> not found
[INFO   ] [Window      ] virtual keyboard not allowed, single mode, not docked
[INFO   ] [Base        ] Start application main loop
[INFO   ] [GL          ] NPOT texture support is available
color changed to [0.024756965693086386, 0.0, 0.0, 1.0]
color changed to [0.04962125374004245, 0.0, 0.0, 1.0]
[... much elided ...]
color changed to [0.899866493884474, 0.0, 0.0, 1.0]
color changed to [0.9339322880841792, 0.0, 0.0, 1.0]
color changed to [0.9667866700328887, 0.0, 0.0, 1.0]
color changed to [1.0, 0.0, 0.0, 1.0]
color changed to [0.9752612402662635, 0.0, 0.0, 1.0]
color changed to [0.9505139281973243, 0.0, 0.0, 1.0]
color changed to [0.9333896283060312, 0.0, 0.0, 1.0]
color changed to [0.8997913300991058, 0.0, 0.0, 1.0]
clear_color
color changed to [0.0, 0.0, 0.0, 1.0]
color changed to [0.8997913300991058, 0.0, 0.0, 1.0]
color changed to [0.9031326513221788, 0.0, 0.0, 1.0]
color changed to [0.9063893373147385, 0.0, 0.0, 1.0]
color changed to [0.9097728098684048, 0.0, 0.0, 1.0]
color changed to [0.913084502603576, 0.0, 0.0, 1.0]
color changed to [0.9165224191404773, 0.0, 0.0, 1.0]
color changed to [0.9198008155591794, 0.0, 0.0, 1.0]
color changed to [0.9231110896834211, 0.0, 0.0, 1.0]
color changed to [0.9264407727066103, 0.0, 0.0, 1.0]
color changed to [0.9298956812169273, 0.0, 0.0, 1.0]
color changed to [0.9331380437701068, 0.0, 0.0, 1.0]
color changed to [0.9364636276092001, 0.0, 0.0, 1.0]
color changed to [0.9398213152841761, 0.0, 0.0, 1.0]
color changed to [0.9432598609823274, 0.0, 0.0, 1.0]
color changed to [0.9465285225037536, 0.0, 0.0, 1.0]
color changed to [0.9498485018474137, 0.0, 0.0, 1.0]
color changed to [0.9531709700247476, 0.0, 0.0, 1.0]
color changed to [0.9565706765998767, 0.0, 0.0, 1.0]
color changed to [0.9598479991560969, 0.0, 0.0, 1.0]
color changed to [0.9632828475344765, 0.0, 0.0, 1.0]
color changed to [0.966577154038025, 0.0, 0.0, 1.0]
color changed to [0.9699492422868132, 0.0, 0.0, 1.0]
color changed to [0.9732090599933068, 0.0, 0.0, 1.0]
color changed to [0.9765808033536578, 0.0, 0.0, 1.0]
color changed to [0.9798984039890273, 0.0, 0.0, 1.0]
color changed to [0.9833513885248952, 0.0, 0.0, 1.0]
color changed to [0.9866624030090266, 0.0, 0.0, 1.0]
color changed to [0.9899588275596817, 0.0, 0.0, 1.0]
color changed to [0.9935370673071986, 0.0, 0.0, 1.0]
color changed to [0.9966442455388062, 0.0, 0.0, 1.0]
color changed to [0.9999147116233014, 0.0, 0.0, 1.0]
color changed to [1.0, 0.0, 0.0, 1.0]
[INFO   ] [Base        ] Leaving application in progress...
(testkivy) wjanssen@archie:/tmp$ 
```
Yes, what's going on is that calling stop() on anim2 triggers the on_complete event, and there's a handler which re-starts anim1.  I now think the correct fix is to remove the override of the "stop" method in Sequence, and just let the supertype's method handle the stop.  This will use cancel(), so the on_complete events on anim1 and anim2 will not be triggered.  And Parallel probably has a similar problem.
This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

Hi, I made a smaller example that reproduce the same problem.

```python
from kivy.app import runTouchApp
from kivy.animation import Animation
from kivy.factory import Factory


root = Factory.Widget()
button = Factory.Button(text='start')
root.add_widget(button)
anim = None


def on_press(button):
    global anim
    if anim is None:
        anim = Animation(width=400) + Animation(width=100)
        anim.repeat = True
        button.text = 'stop'
        anim.start(button)
    else:
        anim.stop(button)
        button.text = 'start'
        anim = None


button.bind(on_press=on_press)

runTouchApp(root)
```
I am also getting the same problem this ruins my project.

Commit message: re-write unit tests in pytest-style

Patch to review:
diff --git a/kivy/animation.py b/kivy/animation.py
index 83123a5942..12366845f4 100644
--- a/kivy/animation.py
+++ b/kivy/animation.py
@@ -391,41 +391,7 @@ def __and__(self, animation):
         return Parallel(self, animation)
 
 
-class Sequence(Animation):
-
-    def __init__(self, anim1, anim2):
-        super(Sequence, self).__init__()
-
-        #: Repeat the sequence. See 'Repeating animation' in the header
-        #: documentation.
-        self.repeat = False
-
-        self.anim1 = anim1
-        self.anim2 = anim2
-
-        self.anim1.bind(on_start=self.on_anim1_start,
-                        on_progress=self.on_anim1_progress)
-        self.anim2.bind(on_complete=self.on_anim2_complete,
-                        on_progress=self.on_anim2_progress)
-
-    @property
-    def duration(self):
-        return self.anim1.duration + self.anim2.duration
-
-    def start(self, widget):
-        self.stop(widget)
-        self._widgets[widget.uid] = True
-        self._register()
-        self.anim1.start(widget)
-        self.anim1.bind(on_complete=self.on_anim1_complete)
-
-    def stop(self, widget):
-        self.anim1.stop(widget)
-        self.anim2.stop(widget)
-        props = self._widgets.pop(widget.uid, None)
-        if props:
-            self.dispatch('on_complete', widget)
-        super(Sequence, self).cancel(widget)
+class CompoundAnimation(Animation):
 
     def stop_property(self, widget, prop):
         self.anim1.stop_property(widget, prop)
@@ -437,7 +403,7 @@ def stop_property(self, widget, prop):
     def cancel(self, widget):
         self.anim1.cancel(widget)
         self.anim2.cancel(widget)
-        super(Sequence, self).cancel(widget)
+        super(CompoundAnimation, self).cancel(widget)
 
     def cancel_property(self, widget, prop):
         '''Even if an animation is running, remove a property. It will not be
@@ -455,11 +421,64 @@ def cancel_property(self, widget, prop):
                 not self.anim2.have_properties_to_animate(widget)):
             self.cancel(widget)
 
-    def on_anim1_start(self, instance, widget):
+    def have_properties_to_animate(self, widget):
+        return (self.anim1.have_properties_to_animate(widget) or
+                self.anim2.have_properties_to_animate(widget))
+
+    @property
+    def animated_properties(self):
+        # TODO: after kivy drops python2 support, this code can be optimized.
+        #       for example: using ChainMap
+        d = dict(self.anim1.animated_properties)
+        d.update(self.anim2.animated_properties)
+        return d
+
+    @property
+    def transition(self):
+        # This property is impossible to implement
+        raise AttributeError(
+            "Can't lookup transition attribute of a CompoundAnimation")
+
+
+class Sequence(CompoundAnimation):
+
+    def __init__(self, anim1, anim2):
+        super(Sequence, self).__init__()
+
+        #: Repeat the sequence. See 'Repeating animation' in the header
+        #: documentation.
+        self.repeat = False
+
+        self.anim1 = anim1
+        self.anim2 = anim2
+
+        self.anim1.bind(on_complete=self.on_anim1_complete,
+                        on_progress=self.on_anim1_progress)
+        self.anim2.bind(on_complete=self.on_anim2_complete,
+                        on_progress=self.on_anim2_progress)
+
+    @property
+    def duration(self):
+        return self.anim1.duration + self.anim2.duration
+
+    def stop(self, widget):
+        props = self._widgets.pop(widget.uid, None)
+        self.anim1.stop(widget)
+        self.anim2.stop(widget)
+        if props:
+            self.dispatch('on_complete', widget)
+        super(CompoundAnimation, self).cancel(widget)
+
+    def start(self, widget):
+        self.stop(widget)
+        self._widgets[widget.uid] = True
+        self._register()
         self.dispatch('on_start', widget)
+        self.anim1.start(widget)
 
     def on_anim1_complete(self, instance, widget):
-        self.anim1.unbind(on_complete=self.on_anim1_complete)
+        if widget.uid not in self._widgets:
+            return
         self.anim2.start(widget)
 
     def on_anim1_progress(self, instance, widget, progress):
@@ -470,6 +489,8 @@ def on_anim2_complete(self, instance, widget):
 
         .. versionadded:: 1.7.1
         '''
+        if widget.uid not in self._widgets:
+            return
         if self.repeat:
             self.anim1.start(widget)
             self.anim1.bind(on_complete=self.on_anim1_complete)
@@ -481,7 +502,7 @@ def on_anim2_progress(self, instance, widget, progress):
         self.dispatch('on_progress', widget, .5 + progress / 2.)
 
 
-class Parallel(Animation):
+class Parallel(CompoundAnimation):
 
     def __init__(self, anim1, anim2):
         super(Parallel, self).__init__()
@@ -495,33 +516,21 @@ def __init__(self, anim1, anim2):
     def duration(self):
         return max(self.anim1.duration, self.anim2.duration)
 
-    def start(self, widget):
-        self.stop(widget)
-        self.anim1.start(widget)
-        self.anim2.start(widget)
-        self._widgets[widget.uid] = {'complete': 0}
-        self._register()
-        self.dispatch('on_start', widget)
-
     def stop(self, widget):
         self.anim1.stop(widget)
         self.anim2.stop(widget)
         props = self._widgets.pop(widget.uid, None)
         if props:
             self.dispatch('on_complete', widget)
-        super(Parallel, self).cancel(widget)
-
-    def stop_property(self, widget, prop):
-        self.anim1.stop_property(widget, prop)
-        self.anim2.stop_property(widget, prop)
-        if (not self.anim1.have_properties_to_animate(widget) and
-                not self.anim2.have_properties_to_animate(widget)):
-            self.stop(widget)
+        super(CompoundAnimation, self).cancel(widget)
 
-    def cancel(self, widget):
-        self.anim1.cancel(widget)
-        self.anim2.cancel(widget)
-        super(Parallel, self).cancel(widget)
+    def start(self, widget):
+        self.stop(widget)
+        self.anim1.start(widget)
+        self.anim2.start(widget)
+        self._widgets[widget.uid] = {'complete': 0}
+        self._register()
+        self.dispatch('on_start', widget)
 
     def on_anim_complete(self, instance, widget):
         self._widgets[widget.uid]['complete'] += 1
diff --git a/kivy/tests/test_animations.py b/kivy/tests/test_animations.py
index 222297cc1b..027fe5fc19 100644
--- a/kivy/tests/test_animations.py
+++ b/kivy/tests/test_animations.py
@@ -2,139 +2,384 @@
 Animations tests
 ================
 '''
+import pytest
 
-import unittest
-import gc
-from time import time, sleep
-from kivy.animation import Animation, AnimationTransition
-from kivy.uix.widget import Widget
-from kivy.clock import Clock
-from kivy.graphics import Scale
-from kivy.weakproxy import WeakProxy
 
+@pytest.fixture(scope='module')
+def ec_cls():
+    class EventCounter:
+        def __init__(self, anim):
+            self.n_start = 0
+            self.n_progress = 0
+            self.n_complete = 0
+            anim.bind(on_start=self.on_start,
+                    on_progress=self.on_progress,
+                    on_complete=self.on_complete)
 
-class AnimationTestCase(unittest.TestCase):
-    def sleep(self, t):
-        start = time()
-        while time() < start + t:
-            sleep(.01)
-            Clock.tick()
+        def on_start(self, anim, widget):
+            self.n_start += 1
 
-    def setUp(self):
-        self.assertEqual(len(Animation._instances), 0)
-        self.a = Animation(x=100, d=1, t='out_bounce')
-        self.w = Widget()
+        def on_progress(self, anim, widget, progress):
+            self.n_progress += 1
 
-    def tearDown(self):
-        self.assertEqual(len(Animation._instances), 0)
+        def on_complete(self, anim, widget):
+            self.n_complete += 1
+
+        def assert_(self, n_start, n_progress_greater_than_zero, n_complete):
+            assert self.n_start == n_start
+            if n_progress_greater_than_zero:
+                assert self.n_progress > 0
+            else:
+                assert self.n_progress == 0
+            assert self.n_complete == n_complete
+    return EventCounter
+
+
+@pytest.fixture(autouse=True)
+def cleanup():
+    from kivy.animation import Animation
+    for anim in Animation._instances.copy():
+        anim.cancel()
+    Animation._instances.clear()
+
+
+def no_animations_being_played():
+    from kivy.animation import Animation
+    return len(Animation._instances) == 0
+
+
+def sleep(t):
+    from time import time, sleep
+    from kivy.clock import Clock
+    tick = Clock.tick
+    start = time()
+    while time() < start + t:
+        sleep(.01)
+        tick()
+
+
+class TestAnimation:
 
     def test_start_animation(self):
-        self.a.start(self.w)
-        self.sleep(1.5)
-        self.assertAlmostEqual(self.w.x, 100)
+        from kivy.animation import Animation
+        from kivy.uix.widget import Widget
+        a = Animation(x=100, d=1)
+        w = Widget()
+        a.start(w)
+        sleep(1.5)
+        assert w.x == pytest.approx(100)
+        assert no_animations_being_played()
 
     def test_animation_duration_0(self):
+        from kivy.animation import Animation
+        from kivy.uix.widget import Widget
         a = Animation(x=100, d=0)
-        a.start(self.w)
-        self.sleep(.5)
+        w = Widget()
+        a.start(w)
+        sleep(.5)
+        assert no_animations_being_played()
 
     def test_stop_animation(self):
-        self.a.start(self.w)
-        self.sleep(.5)
-        self.a.stop(self.w)
-        self.assertNotAlmostEqual(self.w.x, 100)
-        self.assertNotAlmostEqual(self.w.x, 0)
+        from kivy.animation import Animation
+        from kivy.uix.widget import Widget
+        a = Animation(x=100, d=1)
+        w = Widget()
+        a.start(w)
+        sleep(.5)
+        a.stop(w)
+        assert w.x != pytest.approx(100)
+        assert w.x != pytest.approx(0)
+        assert no_animations_being_played()
 
     def test_stop_all(self):
-        self.a.start(self.w)
-        self.sleep(.5)
-        Animation.stop_all(self.w)
+        from kivy.animation import Animation
+        from kivy.uix.widget import Widget
+        a = Animation(x=100, d=1)
+        w = Widget()
+        a.start(w)
+        sleep(.5)
+        Animation.stop_all(w)
+        assert no_animations_being_played()
 
     def test_stop_all_2(self):
-        self.a.start(self.w)
-        self.sleep(.5)
-        Animation.stop_all(self.w, 'x')
+        from kivy.animation import Animation
+        from kivy.uix.widget import Widget
+        a = Animation(x=100, d=1)
+        w = Widget()
+        a.start(w)
+        sleep(.5)
+        Animation.stop_all(w, 'x')
+        assert no_animations_being_played()
 
     def test_duration(self):
-        self.assertEqual(self.a.duration, 1)
+        from kivy.animation import Animation
+        a = Animation(x=100, d=1)
+        assert a.duration == 1
 
     def test_transition(self):
-        self.assertEqual(self.a.transition, AnimationTransition.out_bounce)
+        from kivy.animation import Animation, AnimationTransition
+        a = Animation(x=100, t='out_bounce')
+        assert a.transition is AnimationTransition.out_bounce
 
     def test_animated_properties(self):
-        self.assertEqual(self.a.animated_properties['x'], 100)
+        from kivy.animation import Animation
+        a = Animation(x=100)
+        assert a.animated_properties == {'x': 100, }
 
     def test_animated_instruction(self):
+        from kivy.graphics import Scale
+        from kivy.animation import Animation
+        a = Animation(x=100, d=1)
         instruction = Scale(3)
-        self.a.start(instruction)
-        self.assertEqual(self.a.animated_properties['x'], 100)
-        self.assertAlmostEqual(instruction.x, 3)
-        self.sleep(1.5)
-        self.assertAlmostEqual(instruction.x, 100)
+        a.start(instruction)
+        assert a.animated_properties == {'x': 100, }
+        assert instruction.x == pytest.approx(3)
+        sleep(1.5)
+        assert instruction.x == pytest.approx(100)
+        assert no_animations_being_played()
 
     def test_weakref(self):
-        widget = Widget()
-        anim = Animation(x=100)
-        anim.start(widget.proxy_ref)
-        del widget
+        import gc
+        from kivy.animation import Animation
+        from kivy.uix.widget import Widget
+        w = Widget()
+        a = Animation(x=100)
+        a.start(w.proxy_ref)
+        del w
         gc.collect()
         try:
-            self.sleep(1.)
+            sleep(1.)
         except ReferenceError:
             pass
+        assert no_animations_being_played()
 
 
-class SequentialAnimationTestCase(unittest.TestCase):
-
-    def sleep(self, t):
-        start = time()
-        while time() < start + t:
-            sleep(.01)
-            Clock.tick()
-
-    def setUp(self):
-        self.assertEqual(len(Animation._instances), 0)
-        self.a = Animation(x=100, d=1, t='out_bounce')
-        self.a += Animation(x=0, d=1, t='out_bounce')
-        self.w = Widget()
-
-    def tearDown(self):
-        self.assertEqual(len(Animation._instances), 0)
+class TestSequence:
 
     def test_cancel_all(self):
-        self.a.start(self.w)
-        self.sleep(.5)
-        Animation.cancel_all(self.w)
+        from kivy.animation import Animation
+        from kivy.uix.widget import Widget
+        a = Animation(x=100) + Animation(x=0)
+        w = Widget()
+        a.start(w)
+        sleep(.5)
+        Animation.cancel_all(w)
+        assert no_animations_being_played()
 
     def test_cancel_all_2(self):
-        self.a.start(self.w)
-        self.sleep(.5)
-        Animation.cancel_all(self.w, 'x')
+        from kivy.animation import Animation
+        from kivy.uix.widget import Widget
+        a = Animation(x=100) + Animation(x=0)
+        w = Widget()
+        a.start(w)
+        sleep(.5)
+        Animation.cancel_all(w, 'x')
+        assert no_animations_being_played()
 
     def test_stop_all(self):
-        self.a.start(self.w)
-        self.sleep(.5)
-        Animation.stop_all(self.w)
+        from kivy.animation import Animation
+        from kivy.uix.widget import Widget
+        a = Animation(x=100) + Animation(x=0)
+        w = Widget()
+        a.start(w)
+        sleep(.5)
+        Animation.stop_all(w)
+        assert no_animations_being_played()
 
     def test_stop_all_2(self):
-        self.a.start(self.w)
-        self.sleep(.5)
-        Animation.stop_all(self.w, 'x')
-
-    def _test_on_progress(self, anim, widget, progress):
-        self._on_progress_called = True
-
-    def _test_on_complete(self, anim, widget):
-        self._on_complete_called = True
-
-    def test_events(self):
-        self._on_progress_called = False
-        self._on_complete_called = False
-        self.a.bind(on_progress=self._test_on_progress,
-                    on_complete=self._test_on_complete)
-        self.a.start(self.w)
-        self.sleep(.5)
-        self.assertTrue(self._on_progress_called)
-        self.sleep(2)
-        self.assertTrue(self._on_progress_called)
-        self.assertTrue(self._on_complete_called)
+        from kivy.animation import Animation
+        from kivy.uix.widget import Widget
+        a = Animation(x=100) + Animation(x=0)
+        w = Widget()
+        a.start(w)
+        sleep(.5)
+        Animation.stop_all(w, 'x')
+        assert no_animations_being_played()
+
+    def test_count_events(self, ec_cls):
+        from kivy.animation import Animation
+        from kivy.uix.widget import Widget
+        a = Animation(x=100, d=.5) + Animation(x=0, d=.5)
+        w = Widget()
+        ec = ec_cls(a)
+        ec1 = ec_cls(a.anim1)
+        ec2 = ec_cls(a.anim2)
+        a.start(w)
+
+        # right after the animation starts
+        ec.assert_(1, False, 0)
+        ec1.assert_(1, False, 0)
+        ec2.assert_(0, False, 0)
+        sleep(.2)
+
+        # during the first half of the animation
+        ec.assert_(1, True, 0)
+        ec1.assert_(1, True, 0)
+        ec2.assert_(0, False, 0)
+        sleep(.5)
+
+        # during the second half of the animation
+        ec.assert_(1, True, 0)
+        ec1.assert_(1, True, 1)
+        ec2.assert_(1, True, 0)
+        sleep(.5)
+
+        # after the animation completed
+        ec.assert_(1, True, 1)
+        ec1.assert_(1, True, 1)
+        ec2.assert_(1, True, 1)
+        assert no_animations_being_played()
+
+    def test_have_properties_to_animate(self):
+        from kivy.animation import Animation
+        from kivy.uix.widget import Widget
+        a = Animation(x=100) + Animation(x=0)
+        w = Widget()
+        assert not a.have_properties_to_animate(w)
+        a.start(w)
+        assert a.have_properties_to_animate(w)
+        a.stop(w)
+        assert not a.have_properties_to_animate(w)
+        assert no_animations_being_played()
+
+    def test_animated_properties(self):
+        from kivy.animation import Animation
+        a = Animation(x=100, y=200) + Animation(x=0)
+        assert a.animated_properties == {'x': 0, 'y': 200, }
+
+    def test_transition(self):
+        from kivy.animation import Animation
+        a = Animation(x=100) + Animation(x=0)
+        with pytest.raises(AttributeError):
+            a.transition
+
+
+class TestRepetitiveSequence:
+
+    def test_stop(self):
+        from kivy.animation import Animation
+        from kivy.uix.widget import Widget
+        a = Animation(x=100) + Animation(x=0)
+        a.repeat = True
+        w = Widget()
+        a.start(w)
+        a.stop(w)
+        assert no_animations_being_played()
+
+    def test_count_events(self, ec_cls):
+        from kivy.animation import Animation
+        from kivy.uix.widget import Widget
+        a = Animation(x=100, d=.5) + Animation(x=0, d=.5)
+        a.repeat = True
+        w = Widget()
+        ec = ec_cls(a)
+        ec1 = ec_cls(a.anim1)
+        ec2 = ec_cls(a.anim2)
+        a.start(w)
+
+        # right after the animation starts
+        ec.assert_(1, False, 0)
+        ec1.assert_(1, False, 0)
+        ec2.assert_(0, False, 0)
+        sleep(.2)
+
+        # during the first half of the first round of the animation
+        ec.assert_(1, True, 0)
+        ec1.assert_(1, True, 0)
+        ec2.assert_(0, False, 0)
+        sleep(.5)
+
+        # during the second half of the first round of the animation
+        ec.assert_(1, True, 0)
+        ec1.assert_(1, True, 1)
+        ec2.assert_(1, True, 0)
+        sleep(.5)
+
+        # during the first half of the second round of the animation
+        ec.assert_(1, True, 0)
+        ec1.assert_(2, True, 1)
+        ec2.assert_(1, True, 1)
+        sleep(.5)
+
+        # during the second half of the second round of the animation
+        ec.assert_(1, True, 0)
+        ec1.assert_(2, True, 2)
+        ec2.assert_(2, True, 1)
+        a.stop(w)
+
+        # after the animation stopped
+        ec.assert_(1, True, 1)
+        ec1.assert_(2, True, 2)
+        ec2.assert_(2, True, 2)
+        assert no_animations_being_played()
+
+
+class TestParallel:
+
+    def test_have_properties_to_animate(self):
+        from kivy.animation import Animation
+        from kivy.uix.widget import Widget
+        a = Animation(x=100) & Animation(y=100)
+        w = Widget()
+        assert not a.have_properties_to_animate(w)
+        a.start(w)
+        assert a.have_properties_to_animate(w)
+        a.stop(w)
+        assert not a.have_properties_to_animate(w)
+        assert no_animations_being_played()
+
+    def test_cancel_property(self):
+        from kivy.animation import Animation
+        from kivy.uix.widget import Widget
+        a = Animation(x=100) & Animation(y=100)
+        w = Widget()
+        a.start(w)
+        a.cancel_property(w, 'x')
+        assert not no_animations_being_played()
+        a.stop(w)
+        assert no_animations_being_played()
+
+    def test_animated_properties(self):
+        from kivy.animation import Animation
+        a = Animation(x=100) & Animation(y=100)
+        assert a.animated_properties == {'x': 100, 'y': 100, }
+
+    def test_transition(self):
+        from kivy.animation import Animation
+        a = Animation(x=100) & Animation(y=100)
+        with pytest.raises(AttributeError):
+            a.transition
+
+    def test_count_events(self, ec_cls):
+        from kivy.animation import Animation
+        from kivy.uix.widget import Widget
+        a = Animation(x=100) & Animation(y=100, d=.5)
+        w = Widget()
+        ec = ec_cls(a)
+        ec1 = ec_cls(a.anim1)
+        ec2 = ec_cls(a.anim2)
+        a.start(w)
+
+        # right after the animation started
+        ec.assert_(1, False, 0)
+        ec1.assert_(1, False, 0)
+        ec2.assert_(1, False, 0)
+        sleep(.2)
+
+        # during the first half of the animation
+        ec.assert_(1, False, 0)  # n_progress is still 0 !!
+        ec1.assert_(1, True, 0)
+        ec2.assert_(1, True, 0)
+        sleep(.5)
+
+        # during the second half of the animation
+        ec.assert_(1, False, 0)  # n_progress is still 0 !!
+        ec1.assert_(1, True, 0)
+        ec2.assert_(1, True, 1)
+        sleep(.5)
+
+        # after the animation compeleted
+        ec.assert_(1, False, 1)  # n_progress is still 0 !!
+        ec1.assert_(1, True, 1)
+        ec2.assert_(1, True, 1)
+        assert no_animations_being_played()


Code review comments:
diff_hunk: @@ -455,11 +421,64 @@ def cancel_property(self, widget, prop):
                 not self.anim2.have_properties_to_animate(widget)):
             self.cancel(widget)
 
-    def on_anim1_start(self, instance, widget):
+    def have_properties_to_animate(self, widget):
+        return (self.anim1.have_properties_to_animate(widget) or
+                self.anim2.have_properties_to_animate(widget))
+
+    @property
+    def animated_properties(self):
+        # TODO: after kivy drops python2 support, this code can be optimized.
+        #       for example: using ChainMap
+        d = dict(self.anim1.animated_properties)
+        d.update(self.anim2.animated_properties)
+        return d
path: kivy/animation.py
text: @author:
Might be better not to use `ChainMap`. Because if we re-implement it like this:

```python
    @user1
    def animated_properties(self):
        return ChainMap(self.anim2.animated_properties, self.anim1.animated_properties)
```

the user will be able to write `anim2.animated_properties`. The following implementation might be better.

```python
    @user1
    def animated_properties(self):
        return {**self.anim1.animated_properties, **self.anim2.animated_properties, }
```

@user2:
in both case it's an object that is returned to the user, that they can write to, but that doesn't mean they can write to animation.animated_properties, unless the property defines a setter.

@author:
Oh sorry, I meant this part

> Lookups search the underlying mappings successively until a key is found. In contrast, writes, updates, and deletions only operate on the first mapping.

in the [doc](https://docs.python.org/3/library/collections.html#collections.ChainMap).

To prevent the user from writing to the underlying dictionaries(`anim1.animated_properties` and `anim2.animated_properties`), we need to implement it like either of

```python
@user1
def animated_properties(self):
    return {**self.anim1.animated_properties, **self.anim2.animated_properties, }
```

or 

```python
@user1
def animated_properties(self):
    return ChainMap({}, self.anim2.animated_properties, self.anim1.animated_properties)
```

@user2:
ok, that makes sense indeed. I guess the second one is a bit more efficient, as it doesn't unnecessarily copy data around, while still allowing to update the values for this animation (as they'll be saved in the first dict, they'll override values for the same key in the other dicts), and also they'll reflect updates to the animated properties of the underlying animations, in case they happen (and unless an overriding value has been put in the first dict, of course).